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.
This commit is contained in:
2026-09-19 09:38:00 -07:00
parent 0ca26070d7
commit 940b42f3b4
2 changed files with 170 additions and 0 deletions
+46
View File
@@ -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<bool> {
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,
+124
View File
@@ -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<String>| 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::<structs::Account>(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;
}