| 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 | |
| 13 | use serde::{Deserialize, Serialize}; |
| 14 | use serde_json::{Map, Value, json}; |
| 15 | |
| 16 | pub const USER_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:User"; |
| 17 | pub const LIST_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; |
| 18 | #[cfg(test)] |
| 19 | pub const PATCH_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp"; |
| 20 | pub const ERROR_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:Error"; |
| 21 | /// The most users one page lists. |
| 22 | pub 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)] |
| 26 | pub 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)] |
| 39 | pub struct ScimError { |
| 40 | pub status: u16, |
| 41 | pub scim_type: Option<&'static str>, |
| 42 | pub detail: String, |
| 43 | } |
| 44 | |
| 45 | impl 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. |
| 64 | pub 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"`. |
| 69 | fn 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 | |
| 81 | fn 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. |
| 86 | fn 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. |
| 106 | pub 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`. |
| 124 | pub 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)] |
| 160 | pub 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. |
| 169 | pub 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. |
| 191 | pub 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. |
| 213 | fn 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. |
| 255 | pub 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 | |
| 265 | /// What g1t can do, for `GET /ServiceProviderConfig`. |
| 266 | pub fn service_provider_config(docs: &str) -> Value { |
| 267 | json!({ |
| 268 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], |
| 269 | "documentationUri": docs, |
| 270 | "patch": { "supported": true }, |
| 271 | "bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 }, |
| 272 | "filter": { "supported": true, "maxResults": MAX_PAGE }, |
| 273 | "changePassword": { "supported": false }, |
| 274 | "sort": { "supported": false }, |
| 275 | "etag": { "supported": false }, |
| 276 | "authenticationSchemes": [{ |
| 277 | "type": "oauthbearertoken", |
| 278 | "name": "Bearer token", |
| 279 | "description": "The SCIM token from the workspace's single sign-on settings.", |
| 280 | "primary": true, |
| 281 | }], |
| 282 | "meta": { "resourceType": "ServiceProviderConfig" }, |
| 283 | }) |
| 284 | } |
| 285 | |
| 286 | /// `GET /ResourceTypes`: users only. |
| 287 | pub fn resource_types(base: &str) -> Value { |
| 288 | json!({ |
| 289 | "schemas": [LIST_SCHEMA], |
| 290 | "totalResults": 1, |
| 291 | "Resources": [{ |
| 292 | "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], |
| 293 | "id": "User", |
| 294 | "name": "User", |
| 295 | "endpoint": "/Users", |
| 296 | "schema": USER_SCHEMA, |
| 297 | "meta": { "resourceType": "ResourceType", "location": format!("{base}/ResourceTypes/User") }, |
| 298 | }], |
| 299 | }) |
| 300 | } |
| 301 | |
| 302 | /// A page of a list. |
| 303 | pub fn list(resources: Vec<Value>, total: usize, start_index: usize) -> Value { |
| 304 | json!({ |
| 305 | "schemas": [LIST_SCHEMA], |
| 306 | "totalResults": total, |
| 307 | "startIndex": start_index, |
| 308 | "itemsPerPage": resources.len(), |
| 309 | "Resources": resources, |
| 310 | }) |
| 311 | } |
| 312 | |
| 313 | #[cfg(test)] |
| 314 | mod tests { |
| 315 | use super::*; |
| 316 | |
| 317 | fn okta_user() -> Value { |
| 318 | json!({ |
| 319 | "schemas": [USER_SCHEMA], |
| 320 | "userName": "Ada@Acme.test", |
| 321 | "name": { "givenName": "Ada", "familyName": "Lovelace" }, |
| 322 | "emails": [{ "primary": true, "value": "Ada@Acme.test", "type": "work" }], |
| 323 | "displayName": "Ada Lovelace", |
| 324 | "externalId": "00u1abcd", |
| 325 | "active": true, |
| 326 | }) |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn a_resource_is_read_and_written_back() { |
| 331 | let user = from_resource(&okta_user()).unwrap(); |
| 332 | assert_eq!(user.user_name, "Ada@Acme.test"); |
| 333 | assert_eq!(user.emails, vec!["ada@acme.test".to_owned()]); |
| 334 | assert!(user.active); |
| 335 | let resource = to_resource("scu_1", &user, "2026-10-17T00:00:00Z", "2026-10-17T00:00:00Z", "https://api.g1t.sh/scim/v2/acme"); |
| 336 | assert_eq!(resource["userName"], "Ada@Acme.test"); |
| 337 | assert_eq!(resource["name"]["familyName"], "Lovelace"); |
| 338 | assert_eq!(resource["meta"]["location"], "https://api.g1t.sh/scim/v2/acme/Users/scu_1"); |
| 339 | assert_eq!(resource["emails"][0]["primary"], true); |
| 340 | // Entra sends active as a string; it may be left out. |
| 341 | assert!(!from_resource(&json!({ "userName": "x", "active": "False" })).unwrap().active); |
| 342 | assert!(from_resource(&json!({ "userName": "x" })).unwrap().active); |
| 343 | assert_eq!(from_resource(&json!({ "active": true })).unwrap_err().status, 400); |
| 344 | } |
| 345 | |
| 346 | #[test] |
| 347 | fn filters_find_one_attribute() { |
| 348 | assert_eq!(parse_filter("userName eq \"ada@acme.test\"").unwrap(), Filter::UserName("ada@acme.test".into())); |
| 349 | assert_eq!(parse_filter("externalId eq \"00u1\"").unwrap(), Filter::ExternalId("00u1".into())); |
| 350 | assert_eq!(parse_filter("emails[type eq \"work\"].value eq \"Ada@Acme.test\"").unwrap_err().scim_type, Some("invalidFilter")); |
| 351 | assert_eq!(parse_filter("emails.value eq \"Ada@Acme.test\"").unwrap(), Filter::Email("ada@acme.test".into())); |
| 352 | assert!(parse_filter("userName co \"ada\"").is_err()); |
| 353 | assert!(parse_filter("userName eq ada").is_err()); |
| 354 | } |
| 355 | |
| 356 | #[test] |
| 357 | fn okta_deactivates_with_a_path() { |
| 358 | let mut user = from_resource(&okta_user()).unwrap(); |
| 359 | apply_patch(&mut user, &json!({ "schemas": [PATCH_SCHEMA], "Operations": [{ "op": "replace", "value": { "active": false } }] })).unwrap(); |
| 360 | assert!(!user.active); |
| 361 | apply_patch(&mut user, &json!({ "Operations": [{ "op": "replace", "path": "active", "value": true }] })).unwrap(); |
| 362 | assert!(user.active); |
| 363 | } |
| 364 | |
| 365 | #[test] |
| 366 | fn entra_patches_as_entra_does() { |
| 367 | let mut user = from_resource(&okta_user()).unwrap(); |
| 368 | apply_patch( |
| 369 | &mut user, |
| 370 | &json!({ "Operations": [ |
| 371 | { "op": "Replace", "path": "active", "value": "False" }, |
| 372 | { "op": "Replace", "path": "emails[type eq \"work\"].value", "value": "Ada.L@Acme.test" }, |
| 373 | { "op": "Add", "path": "name.givenName", "value": "Augusta" }, |
| 374 | { "op": "Replace", "path": "urn:ietf:params:scim:schemas:core:2.0:User:displayName", "value": "Augusta Ada" }, |
| 375 | { "op": "Add", "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department", "value": "Engines" }, |
| 376 | { "op": "Remove", "path": "externalId" }, |
| 377 | ] }), |
| 378 | ) |
| 379 | .unwrap(); |
| 380 | assert!(!user.active); |
| 381 | assert_eq!(user.emails, vec!["ada.l@acme.test".to_owned(), "ada@acme.test".to_owned()]); |
| 382 | assert_eq!(user.given_name.as_deref(), Some("Augusta")); |
| 383 | assert_eq!(user.display_name.as_deref(), Some("Augusta Ada")); |
| 384 | assert_eq!(user.external_id, None); |
| 385 | let refused = apply_patch(&mut user, &json!({ "Operations": [{ "op": "move", "path": "active" }] })).unwrap_err(); |
| 386 | assert_eq!(refused.status, 400); |
| 387 | assert!(apply_patch(&mut user, &json!({ "Operations": [{ "op": "replace", "path": "active", "value": "maybe" }] })).is_err()); |
| 388 | } |
| 389 | |
| 390 | #[test] |
| 391 | fn a_sign_in_is_matched_by_id_name_or_vouched_address() { |
| 392 | let user = from_resource(&okta_user()).unwrap(); |
| 393 | let ids = |list: &[&str]| list.iter().map(|id| id.to_string()).collect::<Vec<_>>(); |
| 394 | assert!(is_them(&user, &ids(&["00u1abcd"]), None)); |
| 395 | assert!(is_them(&user, &ids(&["ADA@acme.test"]), None)); |
| 396 | assert!(is_them(&user, &ids(&["pairwise", "00u1abcd"]), None)); |
| 397 | assert!(is_them(&user, &ids(&["some-oidc-sub"]), Some("ada@acme.test"))); |
| 398 | assert!(!is_them(&user, &ids(&["some-oidc-sub"]), Some("eve@acme.test"))); |
| 399 | assert!(!is_them(&user, &ids(&["00u9"]), None)); |
| 400 | // A secondary address is not enough. |
| 401 | let mut two = user.clone(); |
| 402 | two.user_name = "ada".into(); |
| 403 | two.emails = vec!["ada@acme.test".into(), "shared@acme.test".into()]; |
| 404 | assert!(!is_them(&two, &ids(&["x"]), Some("shared@acme.test"))); |
| 405 | assert!(is_them(&two, &ids(&["x"]), Some("ada@acme.test"))); |
| 406 | } |
| 407 | |
| 408 | #[test] |
| 409 | fn errors_are_scim_errors() { |
| 410 | let body = ScimError::new(409, Some("uniqueness"), "taken").body(); |
| 411 | assert_eq!(body["status"], "409"); |
| 412 | assert_eq!(body["scimType"], "uniqueness"); |
| 413 | assert_eq!(body["schemas"][0], ERROR_SCHEMA); |
| 414 | } |
| 415 | } |