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.

467 linesCodeBlameRaw
1//! SCIM 2.0 (RFC 7643, 7644) for a workspace with single sign-on: its
2//! identity provider tells g1t who is assigned to it, and who is not any
3//! more. The pure parts: users as SCIM resources, filters, PATCH, and
4//! errors. The rest is in ../sso.rs.
5//!
6//! Users only. A provisioned user is not a g1t account: people still sign
7//! in to g1t as themselves. It says the provider has them assigned, which
8//! single sign-on then requires of anyone signing in through it, and
9//! taking it away (`active: false`, or deleting it) takes them out of the
10//! workspace at once. Groups are not kept; a provider that pushes them is
11//! told so.
12
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value, json};
15
16pub const USER_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:User";
17pub const LIST_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
18#[cfg(test)]
19pub const PATCH_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
20pub const ERROR_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:Error";
21/// The most users one page lists.
22pub const MAX_PAGE: usize = 200;
23
24/// A user as the provider describes them, as kept.
25#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub struct ScimUser {
27 pub user_name: String,
28 pub external_id: Option<String>,
29 pub display_name: Option<String>,
30 pub given_name: Option<String>,
31 pub family_name: Option<String>,
32 /// Lowercased addresses, the primary first.
33 pub emails: Vec<String>,
34 pub active: bool,
35}
36
37/// A SCIM error: its status, and `scimType` when RFC 7644 names one.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct ScimError {
40 pub status: u16,
41 pub scim_type: Option<&'static str>,
42 pub detail: String,
43}
44
45impl ScimError {
46 pub fn new(status: u16, scim_type: Option<&'static str>, detail: impl Into<String>) -> Self {
47 ScimError { status, scim_type, detail: detail.into() }
48 }
49
50 pub fn invalid(detail: impl Into<String>) -> Self {
51 ScimError::new(400, Some("invalidValue"), detail)
52 }
53
54 pub fn body(&self) -> Value {
55 let mut body = json!({ "schemas": [ERROR_SCHEMA], "status": self.status.to_string(), "detail": self.detail });
56 if let Some(kind) = self.scim_type {
57 body["scimType"] = json!(kind);
58 }
59 body
60 }
61}
62
63/// The key a user name is found by.
64pub fn user_name_key(user_name: &str) -> String {
65 user_name.trim().to_lowercase()
66}
67
68/// A boolean as providers send it: `true`, or the string `"True"`.
69fn boolean(value: &Value) -> Option<bool> {
70 match value {
71 Value::Bool(on) => Some(*on),
72 Value::String(text) => match text.trim().to_lowercase().as_str() {
73 "true" => Some(true),
74 "false" => Some(false),
75 _ => None,
76 },
77 _ => None,
78 }
79}
80
81fn text(value: &Value) -> Option<String> {
82 value.as_str().map(str::trim).filter(|t| !t.is_empty()).map(|t| t.chars().take(512).collect())
83}
84
85/// The addresses of an `emails` value, the primary first, lowercased.
86fn emails_of(value: &Value) -> Vec<String> {
87 let mut found: Vec<(bool, String)> = value
88 .as_array()
89 .into_iter()
90 .flatten()
91 .filter_map(|email| Some((email["primary"].as_bool() == Some(true) || boolean(&email["primary"]) == Some(true), text(&email["value"])?.to_lowercase())))
92 .filter(|(_, address)| address.contains('@'))
93 .collect();
94 found.sort_by_key(|(primary, _)| !primary);
95 let mut out: Vec<String> = Vec::new();
96 for (_, address) in found {
97 if !out.contains(&address) {
98 out.push(address);
99 }
100 }
101 out.truncate(10);
102 out
103}
104
105/// A user from a resource sent with POST or PUT.
106pub fn from_resource(body: &Value) -> Result<ScimUser, ScimError> {
107 let user_name = text(&body["userName"]).ok_or_else(|| ScimError::invalid("userName is required."))?;
108 Ok(ScimUser {
109 user_name,
110 external_id: text(&body["externalId"]),
111 display_name: text(&body["displayName"]),
112 given_name: text(&body["name"]["givenName"]),
113 family_name: text(&body["name"]["familyName"]),
114 emails: emails_of(&body["emails"]),
115 // Active unless it says otherwise.
116 active: match body.get("active") {
117 None | Some(Value::Null) => true,
118 Some(value) => boolean(value).ok_or_else(|| ScimError::invalid("active is true or false."))?,
119 },
120 })
121}
122
123/// The resource for a user kept as `id`.
124pub fn to_resource(id: &str, user: &ScimUser, created: &str, modified: &str, base: &str) -> Value {
125 let mut name = Map::new();
126 if let Some(given) = &user.given_name {
127 name.insert("givenName".into(), json!(given));
128 }
129 if let Some(family) = &user.family_name {
130 name.insert("familyName".into(), json!(family));
131 }
132 let emails: Vec<Value> = user
133 .emails
134 .iter()
135 .enumerate()
136 .map(|(i, address)| json!({ "value": address, "type": "work", "primary": i == 0 }))
137 .collect();
138 let mut resource = json!({
139 "schemas": [USER_SCHEMA],
140 "id": id,
141 "userName": user.user_name,
142 "active": user.active,
143 "emails": emails,
144 "meta": { "resourceType": "User", "created": created, "lastModified": modified, "location": format!("{base}/Users/{id}") },
145 });
146 if let Some(external) = &user.external_id {
147 resource["externalId"] = json!(external);
148 }
149 if let Some(display) = &user.display_name {
150 resource["displayName"] = json!(display);
151 }
152 if !name.is_empty() {
153 resource["name"] = Value::Object(name);
154 }
155 resource
156}
157
158/// What a list may be filtered by: `attribute eq "value"`.
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub enum Filter {
161 UserName(String),
162 ExternalId(String),
163 Id(String),
164 Email(String),
165}
166
167/// Reads a filter. Providers look a user up by one attribute, `eq`;
168/// anything else is refused as RFC 7644 says, so nothing is listed wrongly.
169pub fn parse_filter(filter: &str) -> Result<Filter, ScimError> {
170 let refused = || ScimError::new(400, Some("invalidFilter"), format!("Only `attribute eq \"value\"` on userName, externalId, id or emails is supported: {filter}"));
171 let filter = filter.trim();
172 let (attribute, rest) = filter.split_once(char::is_whitespace).ok_or_else(refused)?;
173 let (operator, value) = rest.trim().split_once(char::is_whitespace).ok_or_else(refused)?;
174 if !operator.eq_ignore_ascii_case("eq") {
175 return Err(refused());
176 }
177 let value = value.trim();
178 let value = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')).ok_or_else(refused)?.replace("\\\"", "\"");
179 match attribute.to_lowercase().as_str() {
180 "username" => Ok(Filter::UserName(value)),
181 "externalid" => Ok(Filter::ExternalId(value)),
182 "id" => Ok(Filter::Id(value)),
183 "emails.value" | "emails" | "emails[type eq \"work\"].value" => Ok(Filter::Email(value.to_lowercase())),
184 _ => Err(refused()),
185 }
186}
187
188/// Applies a PATCH request (RFC 7644 3.5.2) to a user, as Okta and
189/// Microsoft Entra send them: `op` in any case, a `path`, or a `value`
190/// object of attributes with none, and booleans as strings.
191pub fn apply_patch(user: &mut ScimUser, body: &Value) -> Result<(), ScimError> {
192 let operations = body["Operations"].as_array().ok_or_else(|| ScimError::invalid("Operations is required."))?;
193 for operation in operations {
194 let op = operation["op"].as_str().unwrap_or_default().to_lowercase();
195 if !matches!(op.as_str(), "add" | "replace" | "remove") {
196 return Err(ScimError::invalid(format!("op {op:?} is add, replace or remove.")));
197 }
198 let value = &operation["value"];
199 match operation["path"].as_str().map(str::trim).filter(|p| !p.is_empty()) {
200 Some(path) => set(user, path, if op == "remove" { &Value::Null } else { value })?,
201 None => {
202 let fields = value.as_object().ok_or_else(|| ScimError::invalid("A change without a path needs an object of attributes."))?;
203 for (path, value) in fields {
204 set(user, path, if op == "remove" { &Value::Null } else { value })?;
205 }
206 }
207 }
208 }
209 Ok(())
210}
211
212/// Sets one attribute by its path; null clears it.
213fn set(user: &mut ScimUser, path: &str, value: &Value) -> Result<(), ScimError> {
214 let lower = path.to_lowercase();
215 // A schema-qualified path is read as the attribute it names.
216 let attribute = lower.strip_prefix(&format!("{}:", USER_SCHEMA.to_lowercase())).unwrap_or(&lower);
217 match attribute {
218 "active" => {
219 user.active = boolean(value).ok_or_else(|| ScimError::invalid("active is true or false."))?;
220 }
221 "username" => user.user_name = text(value).ok_or_else(|| ScimError::invalid("userName cannot be empty."))?,
222 "externalid" => user.external_id = text(value),
223 "displayname" => user.display_name = text(value),
224 "name.givenname" => user.given_name = text(value),
225 "name.familyname" => user.family_name = text(value),
226 "name" => {
227 user.given_name = text(&value["givenName"]);
228 user.family_name = text(&value["familyName"]);
229 }
230 "emails" => user.emails = emails_of(value),
231 path if path.starts_with("emails[") => {
232 // emails[type eq "work"].value: the one address.
233 match text(value).or_else(|| value.as_array().and_then(|items| items.first()).and_then(|first| text(&first["value"]))) {
234 Some(address) if address.contains('@') => {
235 let address = address.to_lowercase();
236 user.emails.retain(|kept| kept != &address);
237 user.emails.insert(0, address);
238 }
239 Some(_) => return Err(ScimError::invalid("That is not an email address.")),
240 None => user.emails.clear(),
241 }
242 }
243 // Attributes g1t does not keep (phone numbers, titles, enterprise
244 // extensions) are accepted and let go, as providers send them.
245 _ => {}
246 }
247 Ok(())
248}
249
250/// Whether someone who signed in through the provider is this provisioned
251/// user: one of the stable ids it gave (`ids`) is their userName or
252/// externalId, or an address it vouches for (`trusted_email`) is their
253/// userName or primary address. A secondary address, or one the provider
254/// does not vouch for, never matches.
255pub fn is_them(user: &ScimUser, ids: &[String], trusted_email: Option<&str>) -> bool {
256 let same = |value: &str| ids.iter().any(|id| id.trim().eq_ignore_ascii_case(value.trim()));
257 same(&user.user_name)
258 || user.external_id.as_deref().is_some_and(same)
259 || trusted_email.is_some_and(|email| {
260 let email = email.trim().to_lowercase();
261 user_name_key(&user.user_name) == email || user.emails.first() == Some(&email)
262 })
263}
264
265const GROUP_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:Group";
266
267/// A group a provider pushed, accepted and let go: g1t keeps users, not
268/// groups, but a provider that pushes them (Entra does by default) must
269/// not fail, or it stops sending deactivations too. Its id is its name,
270/// hex-encoded (hashed when long): pushing it again gives the same one, and
271/// reading it back gives its name.
272pub fn group_accepted(body: &Value, base: &str) -> Value {
273 let name = text(&body["displayName"]).unwrap_or_default();
274 let id = if name.len() <= 100 {
275 format!("grp_{}", hex::encode(name.as_bytes()))
276 } else {
277 use sha2::{Digest, Sha256};
278 format!("grh_{}", hex::encode(&Sha256::digest(name.as_bytes())[..12]))
279 };
280 group_echo(&id, body, base)
281}
282
283/// The name in a group's id, when it holds one.
284pub fn group_named(id: &str) -> Option<String> {
285 if let Some(hexed) = id.strip_prefix("grp_") {
286 return String::from_utf8(hex::decode(hexed).ok()?).ok();
287 }
288 id.starts_with("grh_").then(String::new)
289}
290
291/// A group as sent, under `id`, with no members kept.
292pub fn group_echo(id: &str, body: &Value, base: &str) -> Value {
293 let mut group = json!({
294 "schemas": [GROUP_SCHEMA],
295 "id": id,
296 "displayName": text(&body["displayName"]).unwrap_or_default(),
297 "members": [],
298 "meta": { "resourceType": "Group", "location": format!("{base}/Groups/{id}") },
299 });
300 if let Some(external) = text(&body["externalId"]) {
301 group["externalId"] = json!(external);
302 }
303 group
304}
305
306/// What g1t can do, for `GET /ServiceProviderConfig`.
307pub fn service_provider_config(docs: &str) -> Value {
308 json!({
309 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
310 "documentationUri": docs,
311 "patch": { "supported": true },
312 "bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
313 "filter": { "supported": true, "maxResults": MAX_PAGE },
314 "changePassword": { "supported": false },
315 "sort": { "supported": false },
316 "etag": { "supported": false },
317 "authenticationSchemes": [{
318 "type": "oauthbearertoken",
319 "name": "Bearer token",
320 "description": "The SCIM token from the workspace's single sign-on settings.",
321 "primary": true,
322 }],
323 "meta": { "resourceType": "ServiceProviderConfig" },
324 })
325}
326
327/// `GET /ResourceTypes`: users only.
328pub fn resource_types(base: &str) -> Value {
329 json!({
330 "schemas": [LIST_SCHEMA],
331 "totalResults": 1,
332 "Resources": [{
333 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
334 "id": "User",
335 "name": "User",
336 "endpoint": "/Users",
337 "schema": USER_SCHEMA,
338 "meta": { "resourceType": "ResourceType", "location": format!("{base}/ResourceTypes/User") },
339 }],
340 })
341}
342
343/// A page of a list.
344pub fn list(resources: Vec<Value>, total: usize, start_index: usize) -> Value {
345 json!({
346 "schemas": [LIST_SCHEMA],
347 "totalResults": total,
348 "startIndex": start_index,
349 "itemsPerPage": resources.len(),
350 "Resources": resources,
351 })
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 fn okta_user() -> Value {
359 json!({
360 "schemas": [USER_SCHEMA],
361 "userName": "Ada@Acme.test",
362 "name": { "givenName": "Ada", "familyName": "Lovelace" },
363 "emails": [{ "primary": true, "value": "Ada@Acme.test", "type": "work" }],
364 "displayName": "Ada Lovelace",
365 "externalId": "00u1abcd",
366 "active": true,
367 })
368 }
369
370 #[test]
371 fn a_resource_is_read_and_written_back() {
372 let user = from_resource(&okta_user()).unwrap();
373 assert_eq!(user.user_name, "Ada@Acme.test");
374 assert_eq!(user.emails, vec!["ada@acme.test".to_owned()]);
375 assert!(user.active);
376 let resource = to_resource("scu_1", &user, "2026-10-17T00:00:00Z", "2026-10-17T00:00:00Z", "https://api.g1t.sh/scim/v2/acme");
377 assert_eq!(resource["userName"], "Ada@Acme.test");
378 assert_eq!(resource["name"]["familyName"], "Lovelace");
379 assert_eq!(resource["meta"]["location"], "https://api.g1t.sh/scim/v2/acme/Users/scu_1");
380 assert_eq!(resource["emails"][0]["primary"], true);
381 // Entra sends active as a string; it may be left out.
382 assert!(!from_resource(&json!({ "userName": "x", "active": "False" })).unwrap().active);
383 assert!(from_resource(&json!({ "userName": "x" })).unwrap().active);
384 assert_eq!(from_resource(&json!({ "active": true })).unwrap_err().status, 400);
385 }
386
387 #[test]
388 fn filters_find_one_attribute() {
389 assert_eq!(parse_filter("userName eq \"ada@acme.test\"").unwrap(), Filter::UserName("ada@acme.test".into()));
390 assert_eq!(parse_filter("externalId eq \"00u1\"").unwrap(), Filter::ExternalId("00u1".into()));
391 assert_eq!(parse_filter("emails[type eq \"work\"].value eq \"Ada@Acme.test\"").unwrap_err().scim_type, Some("invalidFilter"));
392 assert_eq!(parse_filter("emails.value eq \"Ada@Acme.test\"").unwrap(), Filter::Email("ada@acme.test".into()));
393 assert!(parse_filter("userName co \"ada\"").is_err());
394 assert!(parse_filter("userName eq ada").is_err());
395 }
396
397 #[test]
398 fn okta_deactivates_with_a_path() {
399 let mut user = from_resource(&okta_user()).unwrap();
400 apply_patch(&mut user, &json!({ "schemas": [PATCH_SCHEMA], "Operations": [{ "op": "replace", "value": { "active": false } }] })).unwrap();
401 assert!(!user.active);
402 apply_patch(&mut user, &json!({ "Operations": [{ "op": "replace", "path": "active", "value": true }] })).unwrap();
403 assert!(user.active);
404 }
405
406 #[test]
407 fn entra_patches_as_entra_does() {
408 let mut user = from_resource(&okta_user()).unwrap();
409 apply_patch(
410 &mut user,
411 &json!({ "Operations": [
412 { "op": "Replace", "path": "active", "value": "False" },
413 { "op": "Replace", "path": "emails[type eq \"work\"].value", "value": "Ada.L@Acme.test" },
414 { "op": "Add", "path": "name.givenName", "value": "Augusta" },
415 { "op": "Replace", "path": "urn:ietf:params:scim:schemas:core:2.0:User:displayName", "value": "Augusta Ada" },
416 { "op": "Add", "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department", "value": "Engines" },
417 { "op": "Remove", "path": "externalId" },
418 ] }),
419 )
420 .unwrap();
421 assert!(!user.active);
422 assert_eq!(user.emails, vec!["ada.l@acme.test".to_owned(), "ada@acme.test".to_owned()]);
423 assert_eq!(user.given_name.as_deref(), Some("Augusta"));
424 assert_eq!(user.display_name.as_deref(), Some("Augusta Ada"));
425 assert_eq!(user.external_id, None);
426 let refused = apply_patch(&mut user, &json!({ "Operations": [{ "op": "move", "path": "active" }] })).unwrap_err();
427 assert_eq!(refused.status, 400);
428 assert!(apply_patch(&mut user, &json!({ "Operations": [{ "op": "replace", "path": "active", "value": "maybe" }] })).is_err());
429 }
430
431 #[test]
432 fn a_sign_in_is_matched_by_id_name_or_vouched_address() {
433 let user = from_resource(&okta_user()).unwrap();
434 let ids = |list: &[&str]| list.iter().map(|id| id.to_string()).collect::<Vec<_>>();
435 assert!(is_them(&user, &ids(&["00u1abcd"]), None));
436 assert!(is_them(&user, &ids(&["ADA@acme.test"]), None));
437 assert!(is_them(&user, &ids(&["pairwise", "00u1abcd"]), None));
438 assert!(is_them(&user, &ids(&["some-oidc-sub"]), Some("ada@acme.test")));
439 assert!(!is_them(&user, &ids(&["some-oidc-sub"]), Some("eve@acme.test")));
440 assert!(!is_them(&user, &ids(&["00u9"]), None));
441 // A secondary address is not enough.
442 let mut two = user.clone();
443 two.user_name = "ada".into();
444 two.emails = vec!["ada@acme.test".into(), "shared@acme.test".into()];
445 assert!(!is_them(&two, &ids(&["x"]), Some("shared@acme.test")));
446 assert!(is_them(&two, &ids(&["x"]), Some("ada@acme.test")));
447 }
448
449 #[test]
450 fn a_pushed_group_reads_back() {
451 let group = group_accepted(&json!({ "displayName": "Engineering", "externalId": null }), "https://api.test/scim/v2/wsp_1");
452 let id = group["id"].as_str().unwrap();
453 assert_eq!(group_named(id).as_deref(), Some("Engineering"));
454 assert!(group.get("externalId").is_none());
455 assert_eq!(group_accepted(&json!({ "displayName": "Engineering" }), "x")["id"], group["id"]);
456 assert_eq!(group_named("grp_zz"), None);
457 assert_eq!(group_named("scu_1"), None);
458 }
459
460 #[test]
461 fn errors_are_scim_errors() {
462 let body = ScimError::new(409, Some("uniqueness"), "taken").body();
463 assert_eq!(body["status"], "409");
464 assert_eq!(body["scimType"], "uniqueness");
465 assert_eq!(body["schemas"][0], ERROR_SCHEMA);
466 }
467}