Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

594 linesCodeBlameRaw
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
6use std::collections::HashMap;
7
8use serde_json::json;
9
10use super::*;
11
12#[derive(Deserialize)]
13struct ScimRow {
14 id: String,
15 resource: String,
16 user_id: Option<String>,
17 created_at: String,
18 updated_at: String,
19}
20
21impl 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.
28fn scim_actor() -> User {
29 User { id: "scim".into(), username: "scim".into(), kind: PrincipalKind::System, ..User::default() }
30}
31
32fn answer(status: u16, body: Option<Value>) -> ScimAnswer {
33 ScimAnswer { status, body }
34}
35
36fn scim_error(error: scim::ScimError) -> ScimAnswer {
37 answer(error.status, Some(error.body()))
38}
39
40/// Where a SCIM request is acting.
41struct 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
52impl Scim<'_> {
53 fn plan_refusal(&self) -> Option<ScimAnswer> {
54 self.plan.map(|why| scim_error(scim::ScimError::new(403, None, why)))
55 }
56}
57
58const SELECT_ROW: &str = "SELECT id, resource, user_id, created_at, updated_at FROM scim_users";
59
60/// Which provisioned user someone signing in is.
61pub(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
69impl 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}