From 940b42f3b4bc0624bf7604e9e05f289d1bea5456 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 09:38:00 -0700 Subject: [PATCH] SCIM: just-in-time directory sync is read-only on domains open to SCIM (SCIM-58 to SCIM-60) On such a domain, sign-in sync creates no account (the person gets an ordinary authentication failure), changes nothing on an existing one, and creates no group from a groups claim. Without the flag sync works as before, and turning it off hands accounts back to sync with no restart. Checked through synchronize_account itself; acceptance test 5 does the same over OIDC once per-domain directories exist. --- crates/common/src/cache/directory.rs | 46 ++++++++++ tests/src/scim/acceptance.rs | 124 +++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/crates/common/src/cache/directory.rs b/crates/common/src/cache/directory.rs index 896214b..2a7790c 100644 --- a/crates/common/src/cache/directory.rs +++ b/crates/common/src/cache/directory.rs @@ -51,6 +51,14 @@ impl Server { .ctx(trc::Key::AccountId, account_id) })?; + // inbuxa: SCIM-58: SCIM is authoritative; sign-in changes nothing + if domain.allows_scim() { + return Ok(AccountWithId { + id: account_id, + account: Account::from(current_account), + }); + } + let mut updated_account = Account::from(current_account.clone()) .into_user() .ok_or_else(|| { @@ -96,6 +104,10 @@ impl Server { if let Some(groups) = account.groups { let mut member_group_ids = Vec::with_capacity(groups.len()); for email in groups { + // inbuxa: SCIM-58: no group comes from a claim on a SCIM domain + if self.is_scim_address(&email).await? { + continue; + } member_group_ids.push( self.synchronize_group(directory::Group { email, @@ -155,6 +167,13 @@ impl Server { } } None => { + // inbuxa: SCIM-58: accounts on this domain come from SCIM only + if domain.allows_scim() { + return Err(trc::AuthEvent::Failed + .into_err() + .details("The account isn't provisioned: its domain is managed by SCIM") + .ctx(trc::Key::AccountName, account.email)); + } let mut aliases = Vec::with_capacity(account.email_aliases.len()); for alias in account.email_aliases { @@ -175,6 +194,10 @@ impl Server { } let mut member_group_ids = Vec::new(); for email in account.groups.unwrap_or_default() { + // inbuxa: SCIM-58: no group comes from a claim on a SCIM domain + if self.is_scim_address(&email).await? { + continue; + } member_group_ids.push( self.synchronize_group(directory::Group { email, @@ -255,6 +278,11 @@ impl Server { .ctx(trc::Key::AccountId, account_id) })?; + // inbuxa: SCIM-58: SCIM is authoritative; sign-in changes nothing + if domain.allows_scim() { + return Ok(account_id); + } + let mut updated_account = Account::from(current_account.clone()) .into_group() .ok_or_else(|| { @@ -322,6 +350,13 @@ impl Server { } } None => { + // inbuxa: SCIM-58: groups on this domain come from SCIM only + if domain.allows_scim() { + return Err(trc::AuthEvent::Error + .into_err() + .details("The group isn't provisioned: its domain is managed by SCIM") + .ctx(trc::Key::AccountName, group.email)); + } let mut aliases = Vec::with_capacity(group.email_aliases.len()); for alias in group.email_aliases { @@ -378,6 +413,17 @@ impl Server { } } + /// inbuxa: SCIM-58: whether an address is on a domain SCIM manages. + async fn is_scim_address(&self, address: &str) -> trc::Result { + Ok(match address.rsplit_once('@') { + Some((_, domain)) => self + .domain(domain) + .await? + .is_some_and(|domain| domain.allows_scim()), + None => false, + }) + } + async fn validate_address<'x>( &self, email: &'x str, diff --git a/tests/src/scim/acceptance.rs b/tests/src/scim/acceptance.rs index 486a17d..c33fedf 100644 --- a/tests/src/scim/acceptance.rs +++ b/tests/src/scim/acceptance.rs @@ -1390,3 +1390,127 @@ async fn tenants(test: &TestServer, scim: &ScimTest) { } admin.registry_create_object(Action::InvalidateCaches).await; } + +/// SCIM-58 to SCIM-60, through just-in-time sync itself (acceptance test +/// 5 does the same over OIDC once per-domain directories exist). +async fn authority(test: &TestServer, scim: &ScimTest, closed_id: Id) { + let admin = test.account("admin"); + let sync = |email: &str, name: &str, groups: Vec| directory::Account { + email: email.to_string(), + email_aliases: vec![], + secret: None, + groups: Some(groups), + description: Some(name.to_string()), + }; + + // An unprovisioned person on a SCIM domain: refused, nothing created + assert!( + test.server + .synchronize_account(sync(&format!("jit@{SCIM_DOMAIN}"), "JIT", vec![])) + .await + .is_err(), + "SCIM-58" + ); + scim.client + .get(&query( + "/Users", + &format!("userName eq \"jit@{SCIM_DOMAIN}\""), + )) + .await + .assert_status(200); + assert_eq!( + scim.client + .get(&query( + "/Users", + &format!("userName eq \"jit@{SCIM_DOMAIN}\"") + )) + .await + .total_results(), + 0, + "SCIM-58: no account" + ); + + // A provisioned one: sign-in changes nothing, creates no group + let id = scim + .client + .post( + "/Users", + json!({"schemas": [SCHEMA_USER], "userName": format!("synced@{SCIM_DOMAIN}"), "displayName": "From SCIM"}), + ) + .await + .assert_status(201) + .id(); + let before = scim.client.get(&format!("/Users/{id}")).await; + for _ in 0..2 { + test.server + .synchronize_account(sync( + &format!("synced@{SCIM_DOMAIN}"), + "From the directory", + vec![format!("claimed@{SCIM_DOMAIN}")], + )) + .await + .unwrap(); + } + let after = scim.client.get(&format!("/Users/{id}")).await; + assert_eq!(after.json["displayName"], json!("From SCIM"), "SCIM-58"); + assert_eq!(after.etag(), before.etag(), "SCIM-58: version unchanged"); + assert_eq!( + scim.client + .get(&query("/Groups", "displayName eq \"claimed\"")) + .await + .total_results(), + 0, + "SCIM-58: no group from the claim" + ); + + // SCIM-59: without the flag, sync works as it always has + let made = test + .server + .synchronize_account(sync(&format!("jit@{CLOSED}"), "JIT", vec![])) + .await + .unwrap(); + admin + .registry_destroy(ObjectType::Account, [Id::from(made.id)]) + .await; + + // SCIM-60: turning the flag off hands the account back to sync + admin + .registry_update_object( + ObjectType::Domain, + scim.domain_id, + json!({ Property::AllowScimProvisioning: false }), + ) + .await; + test.server + .synchronize_account(sync( + &format!("synced@{SCIM_DOMAIN}"), + "From the directory", + vec![], + )) + .await + .unwrap(); + let structs::Account::User(user) = admin + .registry_get::(Id::from_str(&id).unwrap()) + .await + else { + panic!() + }; + assert_eq!( + user.description.as_deref(), + Some("From the directory"), + "SCIM-60" + ); + scim.client + .get(&format!("/Users/{id}")) + .await + .assert_error(404, None); + admin + .registry_update_object( + ObjectType::Domain, + scim.domain_id, + json!({ Property::AllowScimProvisioning: true }), + ) + .await; + scim.destroy(&format!("/Users/{id}")).await; + let _ = closed_id; +}