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.

502 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}
45
46const SELECT_ROW: &str = "SELECT id, resource, user_id, created_at, updated_at FROM scim_users";
47
48impl 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}