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.

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