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