| 1 | //! SCIM for a workspace with single sign-on, kept: its token, the users its |
| 2 | //! identity provider has assigned to it, and what happens when one is |
| 3 | //! unassigned. The protocol's own parts are in scim.rs; who may sign in is |
| 4 | //! decided in `complete` (../sso.rs), which asks `provisioned_as`. |
| 5 | |
| 6 | use std::collections::HashMap; |
| 7 | |
| 8 | use super::*; |
| 9 | |
| 10 | #[derive(Deserialize)] |
| 11 | struct ScimRow { |
| 12 | id: String, |
| 13 | resource: String, |
| 14 | user_id: Option<String>, |
| 15 | created_at: String, |
| 16 | updated_at: String, |
| 17 | } |
| 18 | |
| 19 | impl ScimRow { |
| 20 | fn user(&self) -> scim::ScimUser { |
| 21 | serde_json::from_str(&self.resource).unwrap_or_default() |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | /// Who SCIM acts as in the audit log: the workspace's identity provider. |
| 26 | fn scim_actor() -> User { |
| 27 | User { id: "scim".into(), username: "scim".into(), kind: PrincipalKind::System, ..User::default() } |
| 28 | } |
| 29 | |
| 30 | fn answer(status: u16, body: Option<Value>) -> ScimAnswer { |
| 31 | ScimAnswer { status, body } |
| 32 | } |
| 33 | |
| 34 | fn scim_error(error: scim::ScimError) -> ScimAnswer { |
| 35 | answer(error.status, Some(error.body())) |
| 36 | } |
| 37 | |
| 38 | /// Where a SCIM request is acting. |
| 39 | struct Scim<'a> { |
| 40 | workspace_id: &'a str, |
| 41 | slug: &'a str, |
| 42 | connection_id: &'a str, |
| 43 | base: &'a str, |
| 44 | /// Why the workspace may not let anyone in now, when its plan has |
| 45 | /// ended: assigning or reactivating anyone is refused; reading and |
| 46 | /// taking people out never are, so leavers still leave. |
| 47 | plan: Option<&'a str>, |
| 48 | } |
| 49 | |
| 50 | impl Scim<'_> { |
| 51 | fn plan_refusal(&self) -> Option<ScimAnswer> { |
| 52 | self.plan.map(|why| scim_error(scim::ScimError::new(403, None, why))) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | const SELECT_ROW: &str = "SELECT id, resource, user_id, created_at, updated_at FROM scim_users"; |
| 57 | |
| 58 | /// Which provisioned user someone signing in is. |
| 59 | pub(crate) enum Match { |
| 60 | None, |
| 61 | /// The one: its id, and the g1t account already linked to it. |
| 62 | One(String, Option<String>), |
| 63 | /// More than one: nobody is chosen. |
| 64 | Many, |
| 65 | } |
| 66 | |
| 67 | impl Identity { |
| 68 | pub(super) async fn scim_on(&self, workspace_id: &str) -> Result<bool> { |
| 69 | Ok(self |
| 70 | .db |
| 71 | .prepare("SELECT 1 AS yes FROM scim_tokens WHERE workspace_id = ?") |
| 72 | .bind(&[workspace_id.into()])? |
| 73 | .first::<Value>(None) |
| 74 | .await? |
| 75 | .is_some()) |
| 76 | } |
| 77 | |
| 78 | pub(super) async fn scim_status(&self, workspace_id: &str) -> Result<Option<ScimStatus>> { |
| 79 | #[derive(Deserialize)] |
| 80 | struct TokenRow { |
| 81 | hint: String, |
| 82 | created_at: String, |
| 83 | created_by: Option<String>, |
| 84 | last_used_at: Option<String>, |
| 85 | } |
| 86 | let token = self |
| 87 | .db |
| 88 | .prepare( |
| 89 | "SELECT t.hint, t.created_at, u.username AS created_by, t.last_used_at FROM scim_tokens t |
| 90 | LEFT JOIN users u ON u.id = t.created_by WHERE t.workspace_id = ?", |
| 91 | ) |
| 92 | .bind(&[workspace_id.into()])? |
| 93 | .first::<TokenRow>(None) |
| 94 | .await?; |
| 95 | let Some(token) = token else { |
| 96 | return Ok(None); |
| 97 | }; |
| 98 | #[derive(Deserialize)] |
| 99 | struct UserRow { |
| 100 | id: String, |
| 101 | user_name: String, |
| 102 | resource: String, |
| 103 | active: u8, |
| 104 | username: Option<String>, |
| 105 | updated_at: String, |
| 106 | } |
| 107 | let users = self |
| 108 | .db |
| 109 | .prepare( |
| 110 | "SELECT s.id, s.user_name, s.resource, s.active, u.username, s.updated_at FROM scim_users s |
| 111 | LEFT JOIN users u ON u.id = s.user_id WHERE s.workspace_id = ? ORDER BY s.user_name_key LIMIT 500", |
| 112 | ) |
| 113 | .bind(&[workspace_id.into()])? |
| 114 | .all() |
| 115 | .await? |
| 116 | .results::<UserRow>()? |
| 117 | .into_iter() |
| 118 | .map(|row| ProvisionedUser { |
| 119 | display_name: serde_json::from_str::<scim::ScimUser>(&row.resource).ok().and_then(|user| user.display_name), |
| 120 | id: row.id, |
| 121 | user_name: row.user_name, |
| 122 | active: row.active != 0, |
| 123 | username: row.username, |
| 124 | updated_at: row.updated_at, |
| 125 | }) |
| 126 | .collect(); |
| 127 | Ok(Some(ScimStatus { |
| 128 | hint: token.hint, |
| 129 | created_at: token.created_at, |
| 130 | created_by: token.created_by, |
| 131 | last_used_at: token.last_used_at, |
| 132 | users, |
| 133 | })) |
| 134 | } |
| 135 | |
| 136 | /// The active provisioned user someone signing in is (scim::is_them): |
| 137 | /// exactly one, or nobody. |
| 138 | pub(super) async fn provisioned_as(&self, workspace_id: &str, ids: &[String], trusted_email: Option<&str>) -> Result<Match> { |
| 139 | let rows = self |
| 140 | .db |
| 141 | .prepare(format!("{SELECT_ROW} WHERE workspace_id = ? AND active = 1")) |
| 142 | .bind(&[workspace_id.into()])? |
| 143 | .all() |
| 144 | .await? |
| 145 | .results::<ScimRow>()?; |
| 146 | let mut found = rows.into_iter().filter(|row| scim::is_them(&row.user(), ids, trusted_email)); |
| 147 | Ok(match (found.next(), found.next()) { |
| 148 | (None, _) => Match::None, |
| 149 | (Some(row), None) => Match::One(row.id, row.user_id), |
| 150 | (Some(_), Some(_)) => Match::Many, |
| 151 | }) |
| 152 | } |
| 153 | |
| 154 | /// `enable_scim`: a new token, replacing any other. Owners, on a paid |
| 155 | /// plan, with single sign-on set up. |
| 156 | pub async fn enable_scim(&self, a: SsoSettingsArgs) -> Result<Outcome<ScimToken>> { |
| 157 | let slug = a.slug.trim().to_lowercase(); |
| 158 | let workspace_id = match self.sso_owner(&a.actor, &slug).await? { |
| 159 | Outcome::Ok(id) => id, |
| 160 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 161 | }; |
| 162 | if self.connection_where("workspace_id", &workspace_id).await?.is_none() { |
| 163 | return Ok(Outcome::fail(FailureCode::Conflict, "Set up single sign-on before SCIM.")); |
| 164 | } |
| 165 | if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? { |
| 166 | return Ok(refused); |
| 167 | } |
| 168 | let token = format!("g1t_scim_{}", crypto::random_hex(32)); |
| 169 | let hint = token[token.len() - 4..].to_owned(); |
| 170 | let rotated = self.scim_on(&workspace_id).await?; |
| 171 | self.db |
| 172 | .prepare( |
| 173 | "INSERT INTO scim_tokens (workspace_id, token_hash, hint, created_by, created_at) VALUES (?1, ?2, ?3, ?4, ?5) |
| 174 | ON CONFLICT (workspace_id) DO UPDATE SET token_hash = excluded.token_hash, hint = excluded.hint, |
| 175 | created_by = excluded.created_by, created_at = excluded.created_at, last_used_at = NULL", |
| 176 | ) |
| 177 | .bind(&[ |
| 178 | workspace_id.as_str().into(), |
| 179 | crypto::sha256_hex(&token).into(), |
| 180 | hint.as_str().into(), |
| 181 | a.actor.id.as_str().into(), |
| 182 | rfc3339(now_ms()).into(), |
| 183 | ])? |
| 184 | .run() |
| 185 | .await?; |
| 186 | self.audit_workspace( |
| 187 | &a.actor, |
| 188 | if rotated { "sso.scim_token_rotated" } else { "sso.scim_enabled" }, |
| 189 | &slug, |
| 190 | a.surface.unwrap_or(Surface::Web), |
| 191 | if rotated { "Made a new SCIM token; the old one stopped working".to_owned() } else { "Turned on SCIM".to_owned() }, |
| 192 | ) |
| 193 | .await; |
| 194 | Ok(Outcome::Ok(ScimToken { token, hint })) |
| 195 | } |
| 196 | |
| 197 | /// `disable_scim`: the token stops working and the provisioned users are |
| 198 | /// forgotten. Nobody is removed from the workspace. |
| 199 | pub async fn disable_scim(&self, a: SsoSettingsArgs) -> Result<Outcome<bool>> { |
| 200 | let slug = a.slug.trim().to_lowercase(); |
| 201 | let workspace_id = match self.sso_owner(&a.actor, &slug).await? { |
| 202 | Outcome::Ok(id) => id, |
| 203 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 204 | }; |
| 205 | if !self.scim_on(&workspace_id).await? { |
| 206 | return Ok(Outcome::Ok(false)); |
| 207 | } |
| 208 | self.db |
| 209 | .batch(vec![ |
| 210 | self.db.prepare("DELETE FROM scim_tokens WHERE workspace_id = ?").bind(&[workspace_id.as_str().into()])?, |
| 211 | self.db.prepare("DELETE FROM scim_users WHERE workspace_id = ?").bind(&[workspace_id.as_str().into()])?, |
| 212 | ]) |
| 213 | .await?; |
| 214 | self.audit_workspace(&a.actor, "sso.scim_disabled", &slug, a.surface.unwrap_or(Surface::Web), "Turned off SCIM".to_owned()).await; |
| 215 | Ok(Outcome::Ok(true)) |
| 216 | } |
| 217 | |
| 218 | /// `scim_request`: one request from the workspace's provider. |
| 219 | pub async fn scim_request(&self, a: ScimRequestArgs) -> Result<ScimAnswer> { |
| 220 | let slug = a.workspace.trim().to_lowercase(); |
| 221 | let unauthorized = || scim_error(scim::ScimError::new(401, None, "Send the workspace's SCIM token as a bearer token.")); |
| 222 | let token = a.token.trim(); |
| 223 | if token.is_empty() { |
| 224 | return Ok(unauthorized()); |
| 225 | } |
| 226 | let Some(workspace_id) = self.workspace_id_of(&slug).await? else { |
| 227 | return Ok(unauthorized()); |
| 228 | }; |
| 229 | #[derive(Deserialize)] |
| 230 | struct Owner { |
| 231 | workspace_id: String, |
| 232 | } |
| 233 | let owner = self |
| 234 | .db |
| 235 | .prepare("SELECT workspace_id FROM scim_tokens WHERE token_hash = ?") |
| 236 | .bind(&[crypto::sha256_hex(token).into()])? |
| 237 | .first::<Owner>(None) |
| 238 | .await?; |
| 239 | if owner.is_none_or(|owner| owner.workspace_id != workspace_id) { |
| 240 | return Ok(unauthorized()); |
| 241 | } |
| 242 | let Some(connection) = self.connection_where("workspace_id", &workspace_id).await? else { |
| 243 | return Ok(scim_error(scim::ScimError::new(403, None, "Set up single sign-on for the workspace first."))); |
| 244 | }; |
| 245 | let plan = self.plan_refusal::<()>(&slug, plan_needed(&slug)).await?.and_then(|refused| match refused { |
| 246 | Outcome::Fail(failure) => Some(failure.message), |
| 247 | Outcome::Ok(()) => None, |
| 248 | }); |
| 249 | self.db |
| 250 | .prepare("UPDATE scim_tokens SET last_used_at = ? WHERE workspace_id = ?") |
| 251 | .bind(&[rfc3339(now_ms()).into(), workspace_id.as_str().into()])? |
| 252 | .run() |
| 253 | .await?; |
| 254 | let body: Value = match a.body.as_deref().map(str::trim).filter(|b| !b.is_empty()) { |
| 255 | None => Value::Null, |
| 256 | Some(text) => match serde_json::from_str(text) { |
| 257 | Ok(body) => body, |
| 258 | Err(_) => return Ok(scim_error(scim::ScimError::new(400, Some("invalidSyntax"), "The body is not JSON."))), |
| 259 | }, |
| 260 | }; |
| 261 | let segments: Vec<&str> = a.path.split('/').filter(|s| !s.is_empty()).collect(); |
| 262 | let method = a.method.to_uppercase(); |
| 263 | let query: HashMap<String, String> = worker::Url::parse(&format!("https://q.invalid/?{}", a.query.as_deref().unwrap_or(""))) |
| 264 | .map(|url| url.query_pairs().into_owned().collect()) |
| 265 | .unwrap_or_default(); |
| 266 | let at = Scim { workspace_id: &workspace_id, slug: &slug, connection_id: &connection.id, base: &a.base, plan: plan.as_deref() }; |
| 267 | Ok(match (method.as_str(), segments.as_slice()) { |
| 268 | ("GET", ["ServiceProviderConfig"]) => answer(200, Some(scim::service_provider_config("https://docs.g1t.sh/guides/single-sign-on/#scim"))), |
| 269 | ("GET", ["ResourceTypes"]) => answer(200, Some(scim::resource_types(&a.base))), |
| 270 | ("GET", ["Schemas"]) => answer(200, Some(scim::list(Vec::new(), 0, 1))), |
| 271 | ("GET", ["Users"]) => self.scim_list(&at, &query).await?, |
| 272 | ("POST", ["Users"]) => self.scim_create(&at, &body).await?, |
| 273 | ("GET", ["Users", id]) => match self.scim_row(&workspace_id, id).await? { |
| 274 | Some(row) => answer(200, Some(scim::to_resource(&row.id, &row.user(), &row.created_at, &row.updated_at, &a.base))), |
| 275 | None => scim_error(scim::ScimError::new(404, None, "No such user.")), |
| 276 | }, |
| 277 | ("PUT" | "PATCH", ["Users", id]) => self.scim_change(&at, id, &body, method == "PATCH").await?, |
| 278 | ("DELETE", ["Users", id]) => self.scim_delete(&at, id).await?, |
| 279 | ("GET", ["Groups"]) => answer(200, Some(scim::list(Vec::new(), 0, 1))), |
| 280 | (_, ["Groups", ..]) => scim_error(scim::ScimError::new( |
| 281 | 501, |
| 282 | None, |
| 283 | "g1t keeps users, not groups: assign people (or a group's members) to g1t, and turn off pushing groups.", |
| 284 | )), |
| 285 | _ => scim_error(scim::ScimError::new(404, None, "No such SCIM endpoint.")), |
| 286 | }) |
| 287 | } |
| 288 | |
| 289 | async fn scim_row(&self, workspace_id: &str, id: &str) -> Result<Option<ScimRow>> { |
| 290 | self.db |
| 291 | .prepare(format!("{SELECT_ROW} WHERE workspace_id = ? AND id = ?")) |
| 292 | .bind(&[workspace_id.into(), id.into()])? |
| 293 | .first::<ScimRow>(None) |
| 294 | .await |
| 295 | } |
| 296 | |
| 297 | async fn scim_list(&self, at: &Scim<'_>, query: &HashMap<String, String>) -> Result<ScimAnswer> { |
| 298 | let filter = match query.get("filter").map(String::as_str).filter(|f| !f.trim().is_empty()) { |
| 299 | Some(filter) => match scim::parse_filter(filter) { |
| 300 | Ok(filter) => Some(filter), |
| 301 | Err(error) => return Ok(scim_error(error)), |
| 302 | }, |
| 303 | None => None, |
| 304 | }; |
| 305 | let start = query.get("startIndex").and_then(|s| s.parse::<usize>().ok()).unwrap_or(1).max(1); |
| 306 | let count = query.get("count").and_then(|s| s.parse::<usize>().ok()).unwrap_or(100).min(scim::MAX_PAGE); |
| 307 | let rows = self |
| 308 | .db |
| 309 | .prepare(format!("{SELECT_ROW} WHERE workspace_id = ? ORDER BY created_at, id")) |
| 310 | .bind(&[at.workspace_id.into()])? |
| 311 | .all() |
| 312 | .await? |
| 313 | .results::<ScimRow>()?; |
| 314 | let matching: Vec<ScimRow> = rows |
| 315 | .into_iter() |
| 316 | .filter(|row| { |
| 317 | let user = row.user(); |
| 318 | match &filter { |
| 319 | None => true, |
| 320 | Some(scim::Filter::UserName(name)) => scim::user_name_key(&user.user_name) == scim::user_name_key(name), |
| 321 | Some(scim::Filter::ExternalId(id)) => user.external_id.as_deref() == Some(id.as_str()), |
| 322 | Some(scim::Filter::Id(id)) => &row.id == id, |
| 323 | Some(scim::Filter::Email(email)) => user.emails.contains(email), |
| 324 | } |
| 325 | }) |
| 326 | .collect(); |
| 327 | let page: Vec<Value> = matching |
| 328 | .iter() |
| 329 | .skip(start - 1) |
| 330 | .take(count) |
| 331 | .map(|row| scim::to_resource(&row.id, &row.user(), &row.created_at, &row.updated_at, at.base)) |
| 332 | .collect(); |
| 333 | Ok(answer(200, Some(scim::list(page, matching.len(), start)))) |
| 334 | } |
| 335 | |
| 336 | async fn user_name_taken(&self, workspace_id: &str, key: &str, other_than: &str) -> Result<bool> { |
| 337 | Ok(self |
| 338 | .db |
| 339 | .prepare("SELECT 1 AS yes FROM scim_users WHERE workspace_id = ? AND user_name_key = ? AND id != ?") |
| 340 | .bind(&[workspace_id.into(), key.into(), other_than.into()])? |
| 341 | .first::<Value>(None) |
| 342 | .await? |
| 343 | .is_some()) |
| 344 | } |
| 345 | |
| 346 | async fn scim_create(&self, at: &Scim<'_>, body: &Value) -> Result<ScimAnswer> { |
| 347 | let user = match scim::from_resource(body) { |
| 348 | Ok(user) => user, |
| 349 | Err(error) => return Ok(scim_error(error)), |
| 350 | }; |
| 351 | if user.active |
| 352 | && let Some(refused) = at.plan_refusal() |
| 353 | { |
| 354 | return Ok(refused); |
| 355 | } |
| 356 | let key = scim::user_name_key(&user.user_name); |
| 357 | if self.user_name_taken(at.workspace_id, &key, "").await? { |
| 358 | return Ok(scim_error(scim::ScimError::new(409, Some("uniqueness"), format!("{} is provisioned already.", user.user_name)))); |
| 359 | } |
| 360 | let id = new_id("scu", now_ms()); |
| 361 | let now = rfc3339(now_ms()); |
| 362 | let linked = self.sso_account_of(at.connection_id, &user).await?; |
| 363 | self.db |
| 364 | .prepare( |
| 365 | "INSERT INTO scim_users (id, workspace_id, user_name, user_name_key, external_id, resource, active, user_id, created_at, updated_at) |
| 366 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)", |
| 367 | ) |
| 368 | .bind(&[ |
| 369 | id.as_str().into(), |
| 370 | at.workspace_id.into(), |
| 371 | user.user_name.as_str().into(), |
| 372 | key.as_str().into(), |
| 373 | user.external_id.as_deref().map_or(JsValue::NULL, Into::into), |
| 374 | serde_json::to_string(&user).unwrap_or_default().into(), |
| 375 | (user.active as u8).into(), |
| 376 | linked.as_deref().map_or(JsValue::NULL, Into::into), |
| 377 | now.as_str().into(), |
| 378 | ])? |
| 379 | .run() |
| 380 | .await?; |
| 381 | self.audit_workspace(&scim_actor(), "sso.scim_user_provisioned", at.slug, Surface::Rest, format!("Provisioned {}", user.user_name)) |
| 382 | .await; |
| 383 | if !user.active |
| 384 | && let Some(user_id) = &linked |
| 385 | { |
| 386 | self.deprovision(at, user_id, &user.user_name).await?; |
| 387 | } |
| 388 | Ok(answer(201, Some(scim::to_resource(&id, &user, &now, &now, at.base)))) |
| 389 | } |
| 390 | |
| 391 | async fn scim_change(&self, at: &Scim<'_>, id: &str, body: &Value, patch: bool) -> Result<ScimAnswer> { |
| 392 | let Some(row) = self.scim_row(at.workspace_id, id).await? else { |
| 393 | return Ok(scim_error(scim::ScimError::new(404, None, "No such user."))); |
| 394 | }; |
| 395 | let before = row.user(); |
| 396 | let changed = if patch { |
| 397 | let mut user = before.clone(); |
| 398 | scim::apply_patch(&mut user, body).map(|_| user) |
| 399 | } else { |
| 400 | scim::from_resource(body) |
| 401 | }; |
| 402 | let user = match changed { |
| 403 | Ok(user) => user, |
| 404 | Err(error) => return Ok(scim_error(error)), |
| 405 | }; |
| 406 | if user.active |
| 407 | && let Some(refused) = at.plan_refusal() |
| 408 | { |
| 409 | return Ok(refused); |
| 410 | } |
| 411 | let key = scim::user_name_key(&user.user_name); |
| 412 | if self.user_name_taken(at.workspace_id, &key, id).await? { |
| 413 | return Ok(scim_error(scim::ScimError::new(409, Some("uniqueness"), format!("{} is provisioned already.", user.user_name)))); |
| 414 | } |
| 415 | let linked = match row.user_id.clone() { |
| 416 | Some(linked) => Some(linked), |
| 417 | None => self.sso_account_of(at.connection_id, &user).await?, |
| 418 | }; |
| 419 | let now = rfc3339(now_ms()); |
| 420 | self.db |
| 421 | .prepare( |
| 422 | "UPDATE scim_users SET user_name = ?, user_name_key = ?, external_id = ?, resource = ?, active = ?, user_id = ?, updated_at = ? |
| 423 | WHERE id = ? AND workspace_id = ?", |
| 424 | ) |
| 425 | .bind(&[ |
| 426 | user.user_name.as_str().into(), |
| 427 | key.as_str().into(), |
| 428 | user.external_id.as_deref().map_or(JsValue::NULL, Into::into), |
| 429 | serde_json::to_string(&user).unwrap_or_default().into(), |
| 430 | (user.active as u8).into(), |
| 431 | linked.as_deref().map_or(JsValue::NULL, Into::into), |
| 432 | now.as_str().into(), |
| 433 | id.into(), |
| 434 | at.workspace_id.into(), |
| 435 | ])? |
| 436 | .run() |
| 437 | .await?; |
| 438 | if before.active != user.active { |
| 439 | let (action, word) = if user.active { ("sso.scim_user_activated", "Activated") } else { ("sso.scim_user_deactivated", "Deactivated") }; |
| 440 | self.audit_workspace(&scim_actor(), action, at.slug, Surface::Rest, format!("{word} {}", user.user_name)).await; |
| 441 | } |
| 442 | if !user.active |
| 443 | && let Some(user_id) = &linked |
| 444 | { |
| 445 | self.deprovision(at, user_id, &user.user_name).await?; |
| 446 | } |
| 447 | Ok(answer(200, Some(scim::to_resource(id, &user, &row.created_at, &now, at.base)))) |
| 448 | } |
| 449 | |
| 450 | async fn scim_delete(&self, at: &Scim<'_>, id: &str) -> Result<ScimAnswer> { |
| 451 | let Some(row) = self.scim_row(at.workspace_id, id).await? else { |
| 452 | return Ok(scim_error(scim::ScimError::new(404, None, "No such user."))); |
| 453 | }; |
| 454 | let user = row.user(); |
| 455 | let linked = match row.user_id.clone() { |
| 456 | Some(linked) => Some(linked), |
| 457 | None => self.sso_account_of(at.connection_id, &user).await?, |
| 458 | }; |
| 459 | if let Some(user_id) = &linked { |
| 460 | self.deprovision(at, user_id, &user.user_name).await?; |
| 461 | } |
| 462 | self.db |
| 463 | .prepare("DELETE FROM scim_users WHERE id = ? AND workspace_id = ?") |
| 464 | .bind(&[id.into(), at.workspace_id.into()])? |
| 465 | .run() |
| 466 | .await?; |
| 467 | self.audit_workspace(&scim_actor(), "sso.scim_user_deleted", at.slug, Surface::Rest, format!("Deprovisioned {}", user.user_name)) |
| 468 | .await; |
| 469 | Ok(answer(204, None)) |
| 470 | } |
| 471 | |
| 472 | /// The g1t account already linked through single sign-on as `user`, by |
| 473 | /// the subject it signed in as (the address it gave is not trusted for |
| 474 | /// this). Only when exactly one is. |
| 475 | async fn sso_account_of(&self, connection_id: &str, user: &scim::ScimUser) -> Result<Option<String>> { |
| 476 | #[derive(Deserialize)] |
| 477 | struct Linked { |
| 478 | user_id: String, |
| 479 | subject: String, |
| 480 | } |
| 481 | let linked = self |
| 482 | .db |
| 483 | .prepare("SELECT user_id, subject FROM sso_identities WHERE connection_id = ?") |
| 484 | .bind(&[connection_id.into()])? |
| 485 | .all() |
| 486 | .await? |
| 487 | .results::<Linked>()?; |
| 488 | let mut found = linked.into_iter().filter(|l| scim::is_them(user, std::slice::from_ref(&l.subject), None)); |
| 489 | Ok(match (found.next(), found.next()) { |
| 490 | (Some(one), None) => Some(one.user_id), |
| 491 | _ => None, |
| 492 | }) |
| 493 | } |
| 494 | |
| 495 | /// Someone the provider unassigned leaves the workspace: their |
| 496 | /// membership and roles (`drop_member`), their link, and their sessions |
| 497 | /// through it. Never an owner: a provider cannot lock a workspace out of |
| 498 | /// itself, so an owner is kept and the audit log says so. |
| 499 | async fn deprovision(&self, at: &Scim<'_>, user_id: &str, user_name: &str) -> Result<()> { |
| 500 | #[derive(Deserialize)] |
| 501 | struct Member { |
| 502 | role: String, |
| 503 | } |
| 504 | let member = self |
| 505 | .db |
| 506 | .prepare("SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?") |
| 507 | .bind(&[at.workspace_id.into(), user_id.into()])? |
| 508 | .first::<Member>(None) |
| 509 | .await?; |
| 510 | if member.as_ref().is_some_and(|m| m.role == "owner") { |
| 511 | self.audit_workspace( |
| 512 | &scim_actor(), |
| 513 | "sso.scim_owner_kept", |
| 514 | at.slug, |
| 515 | Surface::Rest, |
| 516 | format!("{user_name} was deprovisioned but is an owner, so stays: remove them by hand if they should go"), |
| 517 | ) |
| 518 | .await; |
| 519 | return Ok(()); |
| 520 | } |
| 521 | self.db |
| 522 | .batch(vec![ |
| 523 | self.db |
| 524 | .prepare("DELETE FROM sso_identities WHERE connection_id = ? AND user_id = ?") |
| 525 | .bind(&[at.connection_id.into(), user_id.into()])?, |
| 526 | self.db |
| 527 | .prepare("DELETE FROM sso_sessions WHERE workspace_id = ? AND session_id IN (SELECT id FROM sessions WHERE user_id = ?)") |
| 528 | .bind(&[at.workspace_id.into(), user_id.into()])?, |
| 529 | // An invitation waiting for them would only let them back in. |
| 530 | self.db |
| 531 | .prepare(format!( |
| 532 | "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL |
| 533 | WHERE workspace_id = ? AND invitee_id = ? AND revoked_at IS NULL AND accepted_at IS NULL |
| 534 | AND declined_at IS NULL AND redeemed_at IS NULL" |
| 535 | )) |
| 536 | .bind(&[at.workspace_id.into(), user_id.into()])?, |
| 537 | ]) |
| 538 | .await?; |
| 539 | if member.is_some() { |
| 540 | self.drop_member(at.workspace_id, user_id).await?; |
| 541 | let username = self.sso_person(user_id).await?.map(|p| p.username).unwrap_or_else(|| user_name.to_owned()); |
| 542 | self.audit_workspace( |
| 543 | &scim_actor(), |
| 544 | "member.removed_by_scim", |
| 545 | at.slug, |
| 546 | Surface::Rest, |
| 547 | format!("Removed {username}: deprovisioned as {user_name}"), |
| 548 | ) |
| 549 | .await; |
| 550 | self.announce_workspace(at.workspace_id, at.slug, None).await; |
| 551 | } |
| 552 | Ok(()) |
| 553 | } |
| 554 | } |