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.

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