crates/nornir-identity-directory/src/scim.rs
//! **SCIM 2.0** (RFC 7643/7644): `/Users` and `/Groups` for one account's
//! directory, as Entra ID, Okta and the rest send them (moved from Gunnar's
//! `sso/scim.rs`, A34 M-6), over the product's [`DirectoryStore`].
//!
//! A SCIM `User` is an EMPLOYEE of the account the bearer belongs to:
//! created NEW (never an existing principal), its `id` the principal id.
//! `active: false` deactivates it (the store gates it and ends its sessions;
//! the [`Effect`] names it for the console, A34-9); `DELETE` deactivates it
//! and removes it from the directory. A `Group` is kept as the directory sent
//! it, its members the account's own users. The bearer's own rules (expiry,
//! read-only scope, the refusal flood guard: A34-27) are in [`bearer`].
pub mod bearer;
use serde_json::{json, Map, Value};
use crate::model::{DirectoryStore, Group, NewUser, Source, StoreError, User, UserPatch};
use crate::names::normalize_user_name;
pub const USER_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:User";
pub const GROUP_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:Group";
pub const LIST_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
pub const ERROR_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:Error";
pub const PATCH_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
/// The media type every answer carries.
pub const CONTENT_TYPE: &str = "application/scim+json";
/// The most a page holds.
pub const MAX_PAGE: usize = 200;
/// The most bytes one request body may be.
pub const MAX_BODY: usize = 1 << 20;
/// One SCIM request, authenticated: the store is the bearer's account's.
#[derive(Debug, Clone)]
pub struct Call<'a> {
pub method: &'a str,
pub path: &'a str,
pub query: &'a str,
pub body: &'a [u8],
/// `https://console/scim/v2`.
pub base_url: &'a str,
/// The bearer may only read ([`bearer::Scope::Read`]): a write is 403.
pub read_only: bool,
}
/// The answer, as the relay writes it back.
#[derive(Debug, Clone, PartialEq)]
pub struct Answer {
pub status: u16,
pub body: Value,
pub location: String,
}
/// What a write did, for the audit line.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Effect {
pub what: &'static str,
pub principal: String,
}
impl Effect {
/// **The principal whose sessions this write ended** (a deactivation or a
/// delete), for the console to sign out too (A34-9).
pub fn ended_session(&self) -> Option<&str> {
matches!(self.what, "deactivated" | "deleted").then_some(self.principal.as_str())
}
}
fn error(status: u16, scim_type: Option<&str>, detail: impl Into<String>) -> Answer {
let mut body =
json!({ "schemas": [ERROR_SCHEMA], "status": status.to_string(), "detail": detail.into() });
if let Some(t) = scim_type {
body["scimType"] = json!(t);
}
Answer {
status,
body,
location: String::new(),
}
}
fn ok(status: u16, body: Value) -> Answer {
Answer {
status,
body,
location: String::new(),
}
}
/// A SCIM error answer (`urn:…:Error`).
pub fn error_answer(status: u16, scim_type: Option<&str>, detail: impl Into<String>) -> Answer {
error(status, scim_type, detail)
}
fn from_store(err: StoreError) -> Answer {
match err {
StoreError::NotFound(m) => error(404, None, m),
StoreError::AlreadyExists(m) => error(409, Some("uniqueness"), m),
StoreError::Invalid(m) => error(400, Some("invalidValue"), m),
StoreError::Precondition(m) => error(409, None, m),
StoreError::Denied(m) => error(403, None, m),
StoreError::Exhausted(m) => error(429, None, m),
StoreError::Internal(m) => error(500, None, m),
}
}
/// `2026-10-05T12:00:00Z` for a unix second (UTC, proleptic Gregorian).
pub fn rfc3339(unix: i64) -> String {
let days = unix.div_euclid(86_400);
let secs = unix.rem_euclid(86_400);
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
secs / 3600,
secs % 3600 / 60,
secs % 60
)
}
fn rfc3339_ms(ms: i64) -> String {
rfc3339(ms.div_euclid(1000))
}
/// A directory user as a SCIM `User`.
pub fn user_resource(u: &User, base_url: &str) -> Value {
let mut v = json!({
"schemas": [USER_SCHEMA],
"id": u.principal_id,
"userName": u.user_name,
"active": u.active,
"meta": {
"resourceType": "User",
"created": rfc3339_ms(u.created_at_unix_ms),
"location": format!("{}/Users/{}", base_url.trim_end_matches('/'), u.principal_id),
},
});
if !u.external_id.is_empty() {
v["externalId"] = json!(u.external_id);
}
if !u.display_name.is_empty() {
v["displayName"] = json!(u.display_name);
v["name"] = json!({ "formatted": u.display_name });
}
if !u.email.is_empty() {
v["emails"] = json!([{ "value": u.email, "type": "work", "primary": true }]);
}
v
}
/// A group as a SCIM `Group`.
pub fn group_resource(group: &Group, users: &[User], base_url: &str) -> Value {
let members: Vec<Value> = group
.members
.iter()
.map(|m| {
let display = users.iter().find(|u| &u.principal_id == m).map(|u| u.user_name.clone()).unwrap_or_default();
json!({ "value": m, "display": display, "$ref": format!("{}/Users/{m}", base_url.trim_end_matches('/')) })
})
.collect();
let mut v = json!({
"schemas": [GROUP_SCHEMA],
"id": group.id,
"displayName": group.display_name,
"members": members,
"meta": {
"resourceType": "Group",
"created": rfc3339_ms(group.created_at_unix_ms),
"location": format!("{}/Groups/{}", base_url.trim_end_matches('/'), group.id),
},
});
if !group.external_id.is_empty() {
v["externalId"] = json!(group.external_id);
}
v
}
/// `true`, `"True"`, `"false"` … — Entra sends booleans as strings.
fn boolean(v: &Value) -> Option<bool> {
match v {
Value::Bool(b) => Some(*b),
Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
},
_ => None,
}
}
fn text(v: &Value) -> Option<String> {
v.as_str().map(|s| s.trim().to_owned())
}
/// The address a User carries: the primary email, else the first.
fn email_of(v: &Value) -> Option<String> {
let list = v.as_array()?;
list.iter()
.find(|e| e.get("primary").and_then(boolean) == Some(true))
.or_else(|| list.first())
.and_then(|e| e.get("value"))
.and_then(text)
}
fn name_of(v: &Map<String, Value>) -> Option<String> {
if let Some(d) = v
.get("displayName")
.and_then(text)
.filter(|s| !s.is_empty())
{
return Some(d);
}
let name = v.get("name")?;
if let Some(f) = name
.get("formatted")
.and_then(text)
.filter(|s| !s.is_empty())
{
return Some(f);
}
let given = name.get("givenName").and_then(text).unwrap_or_default();
let family = name.get("familyName").and_then(text).unwrap_or_default();
let joined = format!("{given} {family}").trim().to_owned();
(!joined.is_empty()).then_some(joined)
}
/// The query's `filter`, `startIndex` and `count`.
fn query_params(query: &str) -> (Option<String>, usize, usize) {
let mut filter = None;
let mut start = 1usize;
let mut count = 100usize;
for pair in query.split('&') {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
let v = percent_decode(v);
match k {
"filter" => filter = Some(v),
"startIndex" => start = v.parse().unwrap_or(1).max(1),
"count" => count = v.parse().unwrap_or(100).min(MAX_PAGE),
_ => {}
}
}
(filter, start, count)
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < bytes.len() => {
match std::str::from_utf8(&bytes[i + 1..i + 3])
.ok()
.and_then(|h| u8::from_str_radix(h, 16).ok())
{
Some(b) => {
out.push(b);
i += 2;
}
None => out.push(b'%'),
}
}
b => out.push(b),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// `attr eq "value"` — the one filter shape IdPs send. Anything else is
/// `invalidFilter`, never a silently empty list.
fn parse_filter(filter: &str) -> Result<(String, String), String> {
let f = filter.trim();
let mut parts = f.splitn(3, char::is_whitespace);
let attr = parts.next().unwrap_or_default().to_owned();
let op = parts.next().unwrap_or_default();
let rest = parts.next().unwrap_or_default().trim();
if !op.eq_ignore_ascii_case("eq") || attr.is_empty() {
return Err(format!(
"only `<attribute> eq \"<value>\"` filters are answered, not {f:?}"
));
}
let value = rest
.strip_prefix('"')
.and_then(|r| r.strip_suffix('"'))
.ok_or_else(|| format!("the filter's value must be quoted: {f:?}"))?
.replace("\\\"", "\"");
Ok((attr, value))
}
fn list(resources: Vec<Value>, start: usize, count: usize) -> Value {
let total = resources.len();
let page: Vec<Value> = resources.into_iter().skip(start - 1).take(count).collect();
json!({
"schemas": [LIST_SCHEMA],
"totalResults": total,
"startIndex": start,
"itemsPerPage": page.len(),
"Resources": page,
})
}
fn live(store: &dyn DirectoryStore) -> Result<Vec<User>, Answer> {
Ok(store
.users()
.map_err(from_store)?
.into_iter()
.filter(|u| !u.deleted)
.collect())
}
/// **Answer one SCIM request.** Returns the answer and, for a write, what it
/// did (the audit line's words).
pub fn handle(store: &dyn DirectoryStore, call: &Call<'_>) -> (Answer, Option<Effect>) {
if call.body.len() > MAX_BODY {
return (
error(413, None, "the request body is larger than 1 MiB"),
None,
);
}
let path = call.path.trim_end_matches('/');
let mut segments = path.trim_start_matches('/').splitn(2, '/');
let collection = segments.next().unwrap_or_default();
let id = segments.next().map(percent_decode);
let method = call.method.to_ascii_uppercase();
if call.read_only && !matches!(method.as_str(), "GET" | "HEAD") {
return (
error(
403,
None,
"this bearer may only read the directory (read-only scope)",
),
None,
);
}
let body: Option<Value> = if call.body.is_empty() {
None
} else {
match serde_json::from_slice(call.body) {
Ok(v) => Some(v),
Err(e) => {
return (
error(
400,
Some("invalidSyntax"),
format!("the body is not JSON: {e}"),
),
None,
)
}
}
};
match (collection, id.as_deref(), method.as_str()) {
("ServiceProviderConfig", None, "GET") => (ok(200, service_provider_config()), None),
("ResourceTypes", None, "GET") => (ok(200, resource_types(call.base_url)), None),
("Schemas", None, "GET") => (
ok(
200,
json!({ "schemas": [LIST_SCHEMA], "totalResults": 0, "Resources": [] }),
),
None,
),
("Users", None, "GET") => (users_list(store, call), None),
("Users", None, "POST") => users_create(store, call, body),
("Users", Some(id), "GET") => (users_get(store, call, id), None),
("Users", Some(id), "PUT") => users_put(store, call, id, body),
("Users", Some(id), "PATCH") => users_patch(store, call, id, body),
("Users", Some(id), "DELETE") => users_delete(store, id),
("Groups", None, "GET") => (groups_list(store, call), None),
("Groups", None, "POST") => groups_create(store, call, body),
("Groups", Some(id), "GET") => (groups_get(store, call, id), None),
("Groups", Some(id), "PUT") => groups_put(store, call, id, body),
("Groups", Some(id), "PATCH") => groups_patch(store, call, id, body),
("Groups", Some(id), "DELETE") => groups_delete(store, id),
("Users" | "Groups", _, _) => (
error(
405,
None,
format!("{method} is not answered on /{collection}"),
),
None,
),
_ => (
error(404, None, format!("there is no SCIM resource at {path:?}")),
None,
),
}
}
fn service_provider_config() -> Value {
json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"patch": { "supported": true },
"bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
"filter": { "supported": true, "maxResults": MAX_PAGE },
"changePassword": { "supported": false },
"sort": { "supported": false },
"etag": { "supported": false },
"authenticationSchemes": [{
"type": "oauthbearertoken",
"name": "OAuth Bearer Token",
"description": "A bearer the account admin creates in the console (Administration → Identity provider).",
"primary": true,
}],
})
}
fn resource_types(base_url: &str) -> Value {
let base = base_url.trim_end_matches('/');
json!({
"schemas": [LIST_SCHEMA],
"totalResults": 2,
"Resources": [
{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], "id": "User", "name": "User", "endpoint": "/Users", "schema": USER_SCHEMA, "meta": { "location": format!("{base}/ResourceTypes/User"), "resourceType": "ResourceType" } },
{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], "id": "Group", "name": "Group", "endpoint": "/Groups", "schema": GROUP_SCHEMA, "meta": { "location": format!("{base}/ResourceTypes/Group"), "resourceType": "ResourceType" } },
],
})
}
fn users_list(store: &dyn DirectoryStore, call: &Call<'_>) -> Answer {
let users = match live(store) {
Ok(u) => u,
Err(a) => return a,
};
let (filter, start, count) = query_params(call.query);
let users: Vec<&User> = match filter.as_deref().filter(|f| !f.trim().is_empty()) {
None => users.iter().collect(),
Some(f) => {
let (attr, value) = match parse_filter(f) {
Ok(p) => p,
Err(why) => return error(400, Some("invalidFilter"), why),
};
let value_l = value.to_lowercase();
users
.iter()
.filter(|u| match attr.to_ascii_lowercase().as_str() {
"username" => u.user_name == value_l,
"externalid" => u.external_id == value,
"id" => u.principal_id == value,
"emails.value" | "emails" => u.email.eq_ignore_ascii_case(&value),
_ => false,
})
.collect()
}
};
if let Some(f) = filter.as_deref() {
if let Ok((attr, _)) = parse_filter(f) {
if !matches!(
attr.to_ascii_lowercase().as_str(),
"username" | "externalid" | "id" | "emails.value" | "emails"
) {
return error(
400,
Some("invalidFilter"),
format!("users are not filtered by {attr:?}"),
);
}
}
}
ok(
200,
list(
users
.into_iter()
.map(|u| user_resource(u, call.base_url))
.collect(),
start,
count,
),
)
}
fn find_user(store: &dyn DirectoryStore, id: &str) -> Result<User, Answer> {
live(store)?
.into_iter()
.find(|u| u.principal_id == id)
.ok_or_else(|| error(404, None, format!("no user {id} in this directory")))
}
fn users_get(store: &dyn DirectoryStore, call: &Call<'_>, id: &str) -> Answer {
match find_user(store, id) {
Ok(u) => ok(200, user_resource(&u, call.base_url)),
Err(a) => a,
}
}
fn users_create(
store: &dyn DirectoryStore,
call: &Call<'_>,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let Some(Value::Object(v)) = body else {
return (
error(400, Some("invalidSyntax"), "a User is a JSON object"),
None,
);
};
let Some(user_name) = v
.get("userName")
.and_then(text)
.and_then(|s| normalize_user_name(&s))
else {
return (
error(400, Some("invalidValue"), "`userName` is required"),
None,
);
};
let ask = NewUser {
user_name,
display_name: name_of(&v).unwrap_or_default(),
email: v.get("emails").and_then(email_of).unwrap_or_default(),
external_id: v.get("externalId").and_then(text).unwrap_or_default(),
active: v.get("active").and_then(boolean).unwrap_or(true),
source: Source::Scim,
};
match store.provision(&ask) {
Ok(user) => {
let mut answer = ok(201, user_resource(&user, call.base_url));
answer.location = format!(
"{}/Users/{}",
call.base_url.trim_end_matches('/'),
user.principal_id
);
let effect = Effect {
what: "created",
principal: user.principal_id.clone(),
};
(answer, Some(effect))
}
Err(err) => (from_store(err), None),
}
}
fn apply(
store: &dyn DirectoryStore,
call: &Call<'_>,
id: &str,
patch: UserPatch,
) -> (Answer, Option<Effect>) {
if let Err(a) = find_user(store, id) {
return (a, None);
}
let deactivating = patch.active;
match store.update_user(id, &patch) {
Ok((user, changed)) => {
let what = match (changed, deactivating) {
(true, Some(false)) => "deactivated",
(true, Some(true)) => "reactivated",
_ => "updated",
};
(
ok(200, user_resource(&user, call.base_url)),
Some(Effect {
what,
principal: id.to_owned(),
}),
)
}
Err(err) => (from_store(err), None),
}
}
fn users_put(
store: &dyn DirectoryStore,
call: &Call<'_>,
id: &str,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let Some(Value::Object(v)) = body else {
return (
error(400, Some("invalidSyntax"), "a User is a JSON object"),
None,
);
};
let user_name = match v.get("userName").and_then(text) {
Some(s) => match normalize_user_name(&s) {
Some(n) => Some(n),
None => {
return (
error(400, Some("invalidValue"), "`userName` is empty"),
None,
)
}
},
None => None,
};
let patch = UserPatch {
user_name,
display_name: Some(name_of(&v).unwrap_or_default()),
email: Some(v.get("emails").and_then(email_of).unwrap_or_default()),
external_id: v.get("externalId").and_then(text),
active: Some(v.get("active").and_then(boolean).unwrap_or(true)),
};
apply(store, call, id, patch)
}
/// One PatchOp operation onto a [`UserPatch`].
fn user_op(
patch: &mut UserPatch,
op: &str,
path: Option<&str>,
value: Option<&Value>,
) -> Result<(), String> {
let remove = op == "remove";
let path = path.map(|p| p.trim().to_ascii_lowercase());
match path.as_deref() {
None => {
let Some(Value::Object(map)) = value else {
return Err("an operation without a `path` carries an object `value`".to_owned());
};
for (k, v) in map {
user_op(patch, op, Some(k), Some(v))?;
}
}
Some("active") => {
patch.active = Some(if remove {
false
} else {
value.and_then(boolean).ok_or("`active` is true or false")?
});
}
Some("username") => {
let name = value
.and_then(text)
.and_then(|s| normalize_user_name(&s))
.ok_or("`userName` is required")?;
if remove {
return Err("`userName` cannot be removed".to_owned());
}
patch.user_name = Some(name);
}
Some("displayname") | Some("name.formatted") => {
patch.display_name = Some(if remove {
String::new()
} else {
value.and_then(text).unwrap_or_default()
});
}
Some("name") => {
if let Some(Value::Object(m)) = value {
let mut wrapper = Map::new();
wrapper.insert("name".to_owned(), Value::Object(m.clone()));
patch.display_name = Some(name_of(&wrapper).unwrap_or_default());
} else if remove {
patch.display_name = Some(String::new());
}
}
Some("name.givenname") | Some("name.familyname") => {
// Kept whole as the display name: the console shows one name.
if let Some(v) = value.and_then(text) {
let old = patch.display_name.clone().unwrap_or_default();
patch.display_name = Some(if old.is_empty() {
v
} else {
format!("{old} {v}")
});
}
}
Some("externalid") => {
patch.external_id = Some(if remove {
String::new()
} else {
value.and_then(text).unwrap_or_default()
});
}
Some(p) if p == "emails" || p.starts_with("emails[") || p.starts_with("emails.") => {
patch.email = Some(if remove {
String::new()
} else {
match value {
Some(v @ Value::Array(_)) => email_of(v).unwrap_or_default(),
Some(v) => text(v).unwrap_or_default(),
None => String::new(),
}
});
}
// Attributes the directory keeps no copy of (title, phoneNumbers, the
// enterprise extension, …) are accepted and dropped: refusing them
// would stall the IdP's whole provisioning on a field nobody reads.
Some(_) => {}
}
Ok(())
}
/// One PatchOp operation: `op` (lowercase), `path`, `value`.
type Operation = (String, Option<String>, Option<Value>);
fn operations(body: Option<Value>) -> Result<Vec<Operation>, Answer> {
let Some(Value::Object(v)) = body else {
return Err(error(
400,
Some("invalidSyntax"),
"a PatchOp is a JSON object",
));
};
let ops = v
.get("Operations")
.or_else(|| v.get("operations"))
.and_then(Value::as_array)
.ok_or_else(|| error(400, Some("invalidSyntax"), "a PatchOp carries `Operations`"))?;
let mut out = Vec::new();
for op in ops {
let name = op
.get("op")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
if !matches!(name.as_str(), "add" | "replace" | "remove") {
return Err(error(
400,
Some("invalidSyntax"),
format!("unknown PatchOp op {name:?}"),
));
}
out.push((
name,
op.get("path").and_then(Value::as_str).map(str::to_owned),
op.get("value").cloned(),
));
}
Ok(out)
}
fn users_patch(
store: &dyn DirectoryStore,
call: &Call<'_>,
id: &str,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let ops = match operations(body) {
Ok(ops) => ops,
Err(a) => return (a, None),
};
let mut patch = UserPatch::default();
for (op, path, value) in &ops {
if let Err(why) = user_op(&mut patch, op, path.as_deref(), value.as_ref()) {
return (error(400, Some("invalidValue"), why), None);
}
}
apply(store, call, id, patch)
}
fn users_delete(store: &dyn DirectoryStore, id: &str) -> (Answer, Option<Effect>) {
if let Err(a) = find_user(store, id) {
return (a, None);
}
match store.delete_user(id) {
Ok(_) => (
ok(204, Value::Null),
Some(Effect {
what: "deleted",
principal: id.to_owned(),
}),
),
Err(err) => (from_store(err), None),
}
}
fn groups_list(store: &dyn DirectoryStore, call: &Call<'_>) -> Answer {
let groups = match store.groups() {
Ok(g) => g,
Err(e) => return from_store(e),
};
let users = match live(store) {
Ok(u) => u,
Err(a) => return a,
};
let (filter, start, count) = query_params(call.query);
let groups: Vec<&Group> = match filter.as_deref().filter(|f| !f.trim().is_empty()) {
None => groups.iter().collect(),
Some(f) => {
let (attr, value) = match parse_filter(f) {
Ok(p) => p,
Err(why) => return error(400, Some("invalidFilter"), why),
};
match attr.to_ascii_lowercase().as_str() {
"displayname" => groups
.iter()
.filter(|g| g.display_name.eq_ignore_ascii_case(&value))
.collect(),
"externalid" => groups.iter().filter(|g| g.external_id == value).collect(),
"id" => groups.iter().filter(|g| g.id == value).collect(),
other => {
return error(
400,
Some("invalidFilter"),
format!("groups are not filtered by {other:?}"),
)
}
}
}
};
ok(
200,
list(
groups
.into_iter()
.map(|g| group_resource(g, &users, call.base_url))
.collect(),
start,
count,
),
)
}
fn find_group(store: &dyn DirectoryStore, id: &str) -> Result<Group, Answer> {
store
.groups()
.map_err(from_store)?
.into_iter()
.find(|g| g.id == id)
.ok_or_else(|| error(404, None, format!("no group {id} in this directory")))
}
fn groups_get(store: &dyn DirectoryStore, call: &Call<'_>, id: &str) -> Answer {
let users = match live(store) {
Ok(u) => u,
Err(a) => return a,
};
match find_group(store, id) {
Ok(g) => ok(200, group_resource(&g, &users, call.base_url)),
Err(a) => a,
}
}
fn member_ids(v: Option<&Value>) -> Vec<String> {
match v {
Some(Value::Array(list)) => list
.iter()
.filter_map(|m| m.get("value").and_then(text))
.filter(|s| !s.is_empty())
.collect(),
Some(Value::Object(_)) => v
.and_then(|m| m.get("value"))
.and_then(text)
.into_iter()
.collect(),
_ => Vec::new(),
}
}
fn store_group(
store: &dyn DirectoryStore,
call: &Call<'_>,
group: Group,
status: u16,
what: &'static str,
) -> (Answer, Option<Effect>) {
let users = match live(store) {
Ok(u) => u,
Err(a) => return (a, None),
};
match store.put_group(group) {
Ok(g) => {
let mut answer = ok(status, group_resource(&g, &users, call.base_url));
if status == 201 {
answer.location =
format!("{}/Groups/{}", call.base_url.trim_end_matches('/'), g.id);
}
(
answer,
Some(Effect {
what,
principal: format!("group:{}", g.id),
}),
)
}
Err(e) => (from_store(e), None),
}
}
fn groups_create(
store: &dyn DirectoryStore,
call: &Call<'_>,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let Some(Value::Object(v)) = body else {
return (
error(400, Some("invalidSyntax"), "a Group is a JSON object"),
None,
);
};
let Some(display_name) = v
.get("displayName")
.and_then(text)
.filter(|s| !s.is_empty() && s.len() <= 256)
else {
return (
error(400, Some("invalidValue"), "`displayName` is required"),
None,
);
};
let group = Group {
id: store.new_group_id(),
display_name,
external_id: v.get("externalId").and_then(text).unwrap_or_default(),
members: member_ids(v.get("members")),
created_at_unix_ms: store.now_unix_ms(),
};
store_group(store, call, group, 201, "group created")
}
fn groups_put(
store: &dyn DirectoryStore,
call: &Call<'_>,
id: &str,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let mut group = match find_group(store, id) {
Ok(g) => g,
Err(a) => return (a, None),
};
let Some(Value::Object(v)) = body else {
return (
error(400, Some("invalidSyntax"), "a Group is a JSON object"),
None,
);
};
if let Some(name) = v
.get("displayName")
.and_then(text)
.filter(|s| !s.is_empty())
{
group.display_name = name;
}
if let Some(ext) = v.get("externalId").and_then(text) {
group.external_id = ext;
}
group.members = member_ids(v.get("members"));
store_group(store, call, group, 200, "group replaced")
}
/// `members[value eq "x"]` → `x`.
fn member_in_path(path: &str) -> Option<String> {
let inner = path.trim().strip_prefix("members[")?.strip_suffix(']')?;
parse_filter(inner)
.ok()
.filter(|(a, _)| a.eq_ignore_ascii_case("value"))
.map(|(_, v)| v)
}
fn groups_patch(
store: &dyn DirectoryStore,
call: &Call<'_>,
id: &str,
body: Option<Value>,
) -> (Answer, Option<Effect>) {
let mut group = match find_group(store, id) {
Ok(g) => g,
Err(a) => return (a, None),
};
let ops = match operations(body) {
Ok(ops) => ops,
Err(a) => return (a, None),
};
for (op, path, value) in ops {
let path_l = path.as_deref().map(|p| p.trim().to_ascii_lowercase());
match (op.as_str(), path_l.as_deref()) {
("add", Some("members")) => {
for m in member_ids(value.as_ref()) {
if !group.members.contains(&m) {
group.members.push(m);
}
}
}
("replace", Some("members")) => group.members = member_ids(value.as_ref()),
("remove", Some("members")) => {
let gone = member_ids(value.as_ref());
if gone.is_empty() {
group.members.clear();
} else {
group.members.retain(|m| !gone.contains(m));
}
}
("remove", Some(p)) if p.starts_with("members[") => {
if let Some(m) = path.as_deref().and_then(member_in_path) {
group.members.retain(|x| *x != m);
}
}
("add" | "replace", Some("displayname")) => {
if let Some(name) = value.as_ref().and_then(text).filter(|s| !s.is_empty()) {
group.display_name = name;
}
}
("add" | "replace", Some("externalid")) => {
group.external_id = value.as_ref().and_then(text).unwrap_or_default()
}
("add" | "replace", None) => {
if let Some(Value::Object(map)) = &value {
if let Some(name) = map
.get("displayName")
.and_then(text)
.filter(|s| !s.is_empty())
{
group.display_name = name;
}
if let Some(ext) = map.get("externalId").and_then(text) {
group.external_id = ext;
}
if map.contains_key("members") {
let add = member_ids(map.get("members"));
if op == "replace" {
group.members = add;
} else {
for m in add {
if !group.members.contains(&m) {
group.members.push(m);
}
}
}
}
}
}
_ => {}
}
}
store_group(store, call, group, 200, "group changed")
}
fn groups_delete(store: &dyn DirectoryStore, id: &str) -> (Answer, Option<Effect>) {
match store.remove_group(id) {
Ok(true) => (
ok(204, Value::Null),
Some(Effect {
what: "group deleted",
principal: format!("group:{id}"),
}),
),
Ok(false) => (
error(404, None, format!("no group {id} in this directory")),
None,
),
Err(e) => (from_store(e), None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_are_eq_only_and_quoted() {
assert_eq!(
parse_filter("userName eq \"bob@acme.example\"").unwrap(),
("userName".into(), "bob@acme.example".into())
);
assert_eq!(parse_filter("externalId EQ \"a\\\"b\"").unwrap().1, "a\"b");
assert!(parse_filter("userName co \"bob\"").is_err());
assert!(parse_filter("userName eq bob").is_err());
assert_eq!(
member_in_path("members[value eq \"acme.bob\"]").as_deref(),
Some("acme.bob")
);
}
#[test]
fn query_and_percent_decoding() {
let (f, start, count) =
query_params("filter=userName%20eq%20%22bob%40acme.example%22&startIndex=2&count=1000");
assert_eq!(f.as_deref(), Some("userName eq \"bob@acme.example\""));
assert_eq!((start, count), (2, MAX_PAGE));
assert_eq!(percent_decode("a%2"), "a%2");
}
#[test]
fn entra_and_okta_patch_shapes() {
let mut p = UserPatch::default();
user_op(&mut p, "replace", Some("active"), Some(&json!("False"))).unwrap();
assert_eq!(p.active, Some(false));
let mut p = UserPatch::default();
user_op(
&mut p,
"replace",
None,
Some(&json!({"active": true, "displayName": "Bob B"})),
)
.unwrap();
assert_eq!(
(p.active, p.display_name.as_deref()),
(Some(true), Some("Bob B"))
);
let mut p = UserPatch::default();
user_op(
&mut p,
"replace",
Some("emails[type eq \"work\"].value"),
Some(&json!("b@acme.example")),
)
.unwrap();
assert_eq!(p.email.as_deref(), Some("b@acme.example"));
let mut p = UserPatch::default();
assert!(user_op(&mut p, "replace", Some("active"), Some(&json!("maybe"))).is_err());
user_op(
&mut p,
"add",
Some("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department"),
Some(&json!("R&D")),
)
.unwrap();
}
use crate::model::{Group, NewUser, StoreError, User};
use std::cell::RefCell;
#[derive(Default)]
struct Mem {
users: RefCell<Vec<User>>,
}
impl DirectoryStore for Mem {
fn users(&self) -> Result<Vec<User>, StoreError> {
Ok(self.users.borrow().clone())
}
fn provision(&self, u: &NewUser) -> Result<User, StoreError> {
if self.users.borrow().iter().any(|x| x.user_name == u.user_name) {
return Err(StoreError::AlreadyExists(u.user_name.clone()));
}
let user = User {
principal_id: format!("acme.{}", u.user_name),
user_name: u.user_name.clone(),
active: u.active,
..User::default()
};
self.users.borrow_mut().push(user.clone());
Ok(user)
}
fn update_user(&self, id: &str, p: &UserPatch) -> Result<(User, bool), StoreError> {
let mut users = self.users.borrow_mut();
let u = users.iter_mut().find(|u| u.principal_id == id).ok_or_else(|| StoreError::NotFound(id.into()))?;
let changed = p.active.is_some_and(|a| a != u.active);
if let Some(a) = p.active {
u.active = a;
}
Ok((u.clone(), changed))
}
fn delete_user(&self, id: &str) -> Result<User, StoreError> {
let mut users = self.users.borrow_mut();
let u = users.iter_mut().find(|u| u.principal_id == id).ok_or_else(|| StoreError::NotFound(id.into()))?;
u.active = false;
u.deleted = true;
Ok(u.clone())
}
fn groups(&self) -> Result<Vec<Group>, StoreError> {
Ok(Vec::new())
}
fn put_group(&self, g: Group) -> Result<Group, StoreError> {
Ok(g)
}
fn remove_group(&self, _: &str) -> Result<bool, StoreError> {
Ok(false)
}
fn new_group_id(&self) -> String {
"g1".into()
}
fn now_unix_ms(&self) -> i64 {
1_800_000_000_000
}
#[cfg(feature = "csv")]
fn apply_import(&self, _: &crate::import::ImportPlan, _: bool) -> Result<(), StoreError> {
Ok(())
}
}
fn call<'a>(method: &'a str, path: &'a str, body: &'a [u8]) -> Call<'a> {
Call { method, path, query: "", body, base_url: "https://console.example/scim/v2", read_only: false }
}
/// ★ **A SCIM deactivation and a delete name the ended session** (A34-9's
/// SCIM half, now in the shared door): the console signs the person out.
#[test]
fn deactivation_and_delete_name_the_ended_session() {
let store = Mem::default();
let (a, e) = handle(&store, &call("POST", "/Users", br#"{"userName":"Bob@Acme.example"}"#));
assert_eq!(a.status, 201, "{a:?}");
assert_eq!(e.as_ref().and_then(Effect::ended_session), None);
let (a, e) = handle(&store, &call("PATCH", "/Users/acme.bob@acme.example", br#"{"Operations":[{"op":"replace","path":"active","value":"False"}]}"#));
assert_eq!(a.status, 200, "{a:?}");
assert_eq!(e.as_ref().and_then(Effect::ended_session), Some("acme.bob@acme.example"));
let (a, e) = handle(&store, &call("DELETE", "/Users/acme.bob@acme.example", b""));
assert_eq!(a.status, 204);
assert_eq!(e.unwrap().ended_session(), Some("acme.bob@acme.example"));
let (a, _) = handle(&store, &call("GET", "/Users", b""));
assert_eq!(a.body["totalResults"], 0, "a deleted user is not listed");
}
/// ★ **A34-27: a read-only bearer reads and never writes.**
#[test]
fn a_read_only_bearer_cannot_write() {
let store = Mem::default();
let mut c = call("POST", "/Users", br#"{"userName":"bob"}"#);
c.read_only = true;
let (a, e) = handle(&store, &c);
assert_eq!((a.status, e), (403, None));
assert!(store.users.borrow().is_empty());
let mut c = call("GET", "/Users", b"");
c.read_only = true;
assert_eq!(handle(&store, &c).0.status, 200);
}
#[test]
fn a_look_alike_user_name_is_refused() {
let store = Mem::default();
let (a, _) = handle(&store, &call("POST", "/Users", "{\"userName\":\"\u{430}dmin\"}".as_bytes()));
assert_eq!(a.status, 400, "{a:?}");
}
#[test]
fn rfc3339_is_utc() {
assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
assert_eq!(rfc3339(1_800_000_000), "2027-01-15T08:00:00Z");
assert_eq!(rfc3339(951_782_400), "2000-02-29T00:00:00Z");
}
}