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