Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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");
    }
}