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

nordisk/gunnar

crates/gunnar-core/src/auth/sso_table.rs

//! **Company SSO in the identity table** — the writers of the rows
//! [`crate::sso`] describes. Every one is a single compare-and-swap on
//! `refs/gunnar/auth`, so a first login's NEW employee and the subject it is
//! bound to, or a deactivation's gate and the sessions it ends, are never seen
//! half-written.

use super::{AuthDoc, AuthTable, RoomAsk, SeatLot};
use crate::error::{Error, Result};
use crate::gate::{AccountGate, GateReason};
use crate::model::{Principal, PublicKey, UnixMillis};
use crate::sso::{ScimToken, SsoGroup, SsoProvider, SsoSource, SsoUser};

/// What one SSO login asks of the table.
#[derive(Clone, Debug)]
pub struct SsoLogin {
    /// The account whose identity provider verified the person.
    pub account: String,
    /// [`crate::sso::subject_key`] of the verified IdP subject.
    pub subject: String,
    /// The directory user name the IdP named (normalized), for a first
    /// login: a provisioned user of that name is claimed, or a NEW employee
    /// is created under it.
    pub user_name: String,
    /// The person's name, as the IdP says it.
    pub display_name: String,
    /// The person's address, as the IdP says it.
    pub email: String,
    /// The IdP ASSERTED that [`email`](Self::email) is verified (OIDC
    /// `email_verified: true`). SAML carries no such assertion: false.
    pub email_verified: bool,
    /// The IdP's own stable ids for the person, raw (OIDC `sub`, and Entra's
    /// `oid` when sent; the SAML persistent NameID). A provisioned user whose
    /// `externalId` is one of them was bound to this person by the admin.
    pub idp_subjects: Vec<String>,
    /// The ids a NEW employee may take, in order, already filtered against
    /// the namespaces (which the table does not hold). The first one that is
    /// no principal and no name is taken.
    pub candidates: Vec<Principal>,
    /// The room a NEW employee holds: an open seat when the account has one,
    /// else `room.room_bytes` out of its unused room.
    pub room: RoomAsk,
    /// The web session key to enrol (one OpenSSH line) and when it expires;
    /// `None` when a second factor must come first.
    pub session: Option<(String, UnixMillis)>,
    /// The caller's clock.
    pub now: UnixMillis,
}

/// What a committed SSO login answers.
#[derive(Clone, Debug)]
pub struct SsoLoginDone {
    /// The employee who logged in.
    pub principal: Principal,
    /// Its directory row, as written.
    pub user: SsoUser,
    /// This login created the employee (just in time).
    pub created: bool,
    /// This login bound a provisioned user (SCIM/CSV) to its subject.
    pub claimed: bool,
    /// How the provisioned user was bound, for the audit line:
    /// `external_id` (the admin's binding) or `verified_email`.
    pub claimed_by: Option<&'static str>,
    /// The session key enrolled, when one was asked.
    pub session: Option<PublicKey>,
}

/// A NEW provisioned employee (SCIM create, a CSV row).
#[derive(Clone, Debug)]
pub struct SsoProvision {
    /// The account the employee is provisioned into.
    pub account: String,
    /// The directory user name (normalized, unique within the account).
    pub user_name: String,
    /// The person's name.
    pub display_name: String,
    /// The person's address.
    pub email: String,
    /// The directory's own id for the person (SCIM `externalId`).
    pub external_id: String,
    /// False: provisioned deactivated.
    pub active: bool,
    /// SCIM or CSV.
    pub source: SsoSource,
    /// The ids the NEW employee may take, in order (see [`SsoLogin::candidates`]).
    pub candidates: Vec<Principal>,
    /// The room it holds (see [`SsoLogin::room`]).
    pub room: RoomAsk,
    /// Who provisioned it (the bearer's account admin, or the importer).
    pub actor: String,
    /// The caller's clock.
    pub now: UnixMillis,
}

/// A change to a directory user; `None` leaves a field as it is.
#[derive(Clone, Debug, Default)]
pub struct SsoUserPatch {
    /// A new directory user name.
    pub user_name: Option<String>,
    /// A new name.
    pub display_name: Option<String>,
    /// A new address.
    pub email: Option<String>,
    /// A new external id.
    pub external_id: Option<String>,
    /// Deactivate (false) or reactivate (true).
    pub active: Option<bool>,
}

/// One row of [`AuthTable::sso_import`].
#[derive(Clone, Debug)]
pub enum SsoImportOp {
    /// A NEW employee.
    Create(SsoProvision),
    /// A change to a directory user of the import's account.
    Update {
        /// The directory user's principal id.
        principal_id: String,
        /// What changes.
        patch: SsoUserPatch,
    },
}

/// A row and the words its refusal is named by (`line 7: bob@acme.example`).
#[derive(Clone, Debug)]
pub struct SsoImportRow {
    /// What a refusal of this row is named by.
    pub label: String,
    /// What the row does.
    pub op: SsoImportOp,
}

fn subject_index(account: &str, subject: &str) -> String {
    format!("s\u{1f}{account}\u{1f}{subject}")
}

fn user_index(account: &str, user_name: &str) -> String {
    format!("u\u{1f}{account}\u{1f}{user_name}")
}

fn refused(reason: impl Into<String>) -> Error {
    Error::SsoRefused(reason.into())
}

/// The rules' view of a directory row.
fn rule_row(user: &SsoUser) -> nornir_identity_sso::rules::Row<'_> {
    use nornir_identity_sso::rules::Source;
    nornir_identity_sso::rules::Row {
        external_id: &user.external_id,
        email: &user.email,
        user_name: &user.user_name,
        source: match user.source {
            SsoSource::Jit => Source::Jit,
            SsoSource::Scim => Source::Scim,
            SsoSource::Csv => Source::Csv,
        },
        bound: user.subject.is_some(),
        rebind: user.rebind,
    }
}

/// The rules' view of a login.
fn rule_login(login: &SsoLogin) -> nornir_identity_sso::rules::Login<'_> {
    nornir_identity_sso::rules::Login {
        email: &login.email,
        email_verified: login.email_verified,
        idp_ids: &login.idp_subjects,
    }
}

/// **A refusal, by code** (A34-46, RA4-2): `[code] sentence`, naming nobody — it is
/// answered to an unauthenticated caller and written into the append-only audit
/// chain. A claim refusal says how the admin binds an employee.
fn refused_by(r: nornir_identity_sso::Refusal) -> Error {
    use nornir_identity_sso::Refusal as R;
    match r {
        R::ClaimUnverified | R::ClaimNotAddress | R::ClaimDomain => refused(format!(
            "{}. A user name (preferred_username) alone never claims; the account admin can bind the employee by \
             setting its externalId to the person's id at the identity provider",
            r.message()
        )),
        other => refused(other.message()),
    }
}

impl AuthTable {
    /// Does this `sso_index` key belong to `account`?
    pub(super) fn sso_index_of_account(key: &str, account: &str) -> bool {
        let mut parts = key.splitn(3, '\u{1f}');
        let _kind = parts.next();
        parts.next() == Some(account)
    }

    // ── providers ─────────────────────────────────────────────────────────

    /// An account's identity provider, if it has one.
    pub fn sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
        self.doc.load()?.sso_providers.get(account)
    }

    /// Every account's identity provider. A listing.
    pub fn sso_providers(&self) -> Result<Vec<SsoProvider>> {
        self.doc.load()?.sso_providers.values()
    }

    /// The account an email domain leads to.
    pub fn sso_account_for_domain(&self, domain: &str) -> Result<Option<String>> {
        self.doc.load()?.sso_domains.get(domain)
    }

    /// **Configure an account's identity provider** (replacing the one it
    /// had). The account must be a principal; its domains must be free or
    /// already its own — one domain, one company.
    pub fn set_sso_provider(&self, provider: SsoProvider) -> Result<SsoProvider> {
        self.doc.update(|doc| {
            if !doc.principals.contains_key(&provider.account)? {
                return Err(Error::NoSuchPrincipal(provider.account.clone()));
            }
            if doc.employers.contains_key(&provider.account)? {
                return Err(Error::InvalidInput {
                    what: "identity provider",
                    reason: format!(
                        "{} is an employee, not an account: only an account has an identity provider",
                        provider.account
                    ),
                });
            }
            // **A typed domain routes nothing** (A34-5): any account may NAME a
            // domain; only its DNS proof ([`AuthTable::verify_sso_domain`]) puts
            // it in the index. Proofs of the domains still named are kept.
            let mut provider = provider.clone();
            let old = doc.sso_providers.get(&provider.account)?;
            provider.verified_domains = old
                .as_ref()
                .map(|o| o.verified_domains.iter().filter(|d| provider.domains.contains(d)).cloned().collect())
                .unwrap_or_default();
            if let Some(old) = old {
                for domain in old.verified_domains {
                    if doc.sso_domains.get(&domain)?.as_deref() == Some(provider.account.as_str()) {
                        doc.sso_domains.remove(&domain)?;
                    }
                }
            }
            for domain in &provider.verified_domains {
                doc.sso_domains.insert(domain.clone(), provider.account.clone())?;
            }
            doc.sso_providers.insert(provider.account.clone(), provider.clone())?;
            Ok(provider)
        })
    }

    /// Remove an account's identity provider. Its directory stays: its
    /// employees are still employees (and a re-configured IdP finds them).
    pub fn remove_sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
        self.doc.update(|doc| {
            let removed = doc.sso_providers.remove(account)?;
            if let Some(provider) = &removed {
                // Only the domains this account proved (the index holds no other).
                for domain in &provider.verified_domains {
                    if doc.sso_domains.get(domain)?.as_deref() == Some(account) {
                        doc.sso_domains.remove(domain)?;
                    }
                }
            }
            Ok(removed)
        })
    }

    /// **`domain` is proven for `account`** (A34-5), after the caller read its
    /// DNS proof (`nornir_identity_sso::domains::verify`). The domain must be one
    /// the provider names, and no other account may have proven it first.
    pub fn verify_sso_domain(&self, account: &str, domain: &str) -> Result<SsoProvider> {
        use nornir_identity_sso::Refusal;
        self.doc.update(|doc| {
            let mut provider = doc
                .sso_providers
                .get(account)?
                .ok_or_else(|| refused(format!("{account} has no identity provider")))?;
            if !provider.domains.iter().any(|d| d == domain) {
                return Err(refused_by(Refusal::DomainUnknown));
            }
            match doc.sso_domains.get(domain)? {
                Some(holder) if holder != account => return Err(refused_by(Refusal::DomainTaken)),
                _ => {}
            }
            if !provider.verified_domains.iter().any(|d| d == domain) {
                provider.verified_domains.push(domain.to_owned());
            }
            doc.sso_domains
                .insert(domain.to_owned(), account.to_owned())?;
            doc.sso_providers
                .insert(account.to_owned(), provider.clone())?;
            Ok(provider)
        })
    }

    /// **Open a rebind** (A34-11, an IdP migration): each named directory user of
    /// `account` (every bound one when `principal_ids` is empty) is unbound from
    /// its old IdP subject — the old subject signs it in no more — and marked so
    /// that its next sign-in binds it afresh, on the claim rules. `external_id`,
    /// with exactly one principal, is its id at the NEW identity provider (the
    /// admin's binding). Deleted users are never rebound.
    pub fn sso_user_rebind(
        &self,
        account: &str,
        principal_ids: &[String],
        external_id: Option<&str>,
    ) -> Result<Vec<SsoUser>> {
        if external_id.is_some() && principal_ids.len() != 1 {
            return Err(Error::InvalidInput {
                what: "rebind",
                reason: "an externalId names exactly one employee".to_owned(),
            });
        }
        self.doc.update(|doc| {
            let targets: Vec<SsoUser> = if principal_ids.is_empty() {
                doc.sso_users
                    .values()?
                    .into_iter()
                    .filter(|u| u.account == account && !u.deleted && u.subject.is_some())
                    .collect()
            } else {
                let mut rows = Vec::new();
                for id in principal_ids {
                    let user = doc
                        .sso_users
                        .get(id)?
                        .filter(|u| u.account == account && !u.deleted)
                        .ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
                    rows.push(user);
                }
                rows
            };
            let mut out = Vec::with_capacity(targets.len());
            for mut user in targets {
                if let Some(subject) = user.subject.take() {
                    if doc
                        .sso_index
                        .get(&subject_index(account, &subject))?
                        .as_deref()
                        == Some(user.principal_id.as_str())
                    {
                        doc.sso_index.remove(&subject_index(account, &subject))?;
                    }
                }
                user.rebind = true;
                if let Some(id) = external_id {
                    user.external_id = id.trim().to_owned();
                }
                doc.sso_users
                    .insert(user.principal_id.clone(), user.clone())?;
                out.push(user);
            }
            Ok(out)
        })
    }

    // ── the directory ─────────────────────────────────────────────────────

    /// One directory user, by principal id.
    pub fn sso_user(&self, principal_id: &str) -> Result<Option<SsoUser>> {
        self.doc.load()?.sso_users.get(principal_id)
    }

    /// An account's directory (deleted users included; the caller filters).
    pub fn sso_users_of(&self, account: &str) -> Result<Vec<SsoUser>> {
        let mut rows: Vec<SsoUser> = self
            .doc
            .load()?
            .sso_users
            .values()?
            .into_iter()
            .filter(|u| u.account == account)
            .collect();
        rows.sort_by(|a, b| {
            a.user_name
                .cmp(&b.user_name)
                .then_with(|| a.principal_id.cmp(&b.principal_id))
        });
        Ok(rows)
    }

    /// The principal a subject is bound to in `account`, if any.
    pub fn sso_principal_for_subject(
        &self,
        account: &str,
        subject: &str,
    ) -> Result<Option<String>> {
        self.doc
            .load()?
            .sso_index
            .get(&subject_index(account, subject))
    }

    /// The first free candidate id: no principal of that id, no principal
    /// named so.
    fn free_candidate(doc: &AuthDoc, candidates: &[Principal]) -> Result<Option<Principal>> {
        for candidate in candidates {
            if doc.principals.contains_key(&candidate.id)? {
                continue;
            }
            if Self::refuse_taken_name(doc, &candidate.name, &candidate.id).is_err() {
                continue;
            }
            if Self::resolve_name(doc, &candidate.id)?.is_some() {
                continue;
            }
            return Ok(Some(candidate.clone()));
        }
        Ok(None)
    }

    /// **A NEW employee for the directory**, inside the caller's update: the
    /// room planned (an open seat first), the employee written by the one
    /// writer ([`AuthTable::write_employee`]), its directory row and index.
    fn create_directory_employee(
        doc: &mut AuthDoc,
        account: &str,
        candidates: &[Principal],
        room: &RoomAsk,
        user: SsoUser,
    ) -> Result<(Principal, SsoUser)> {
        let principal = Self::free_candidate(doc, candidates)?.ok_or_else(|| {
            refused(format!(
                "no free id for {:?} in {account}: every candidate is taken",
                user.user_name
            ))
        })?;
        let principal = Principal {
            created: user.created,
            ..principal
        };
        let lots: Vec<SeatLot> = doc.seats.get(account)?.unwrap_or_default();
        let ask = RoomAsk {
            from_seat: lots.iter().any(|lot| lot.open() > 0),
            seats: 0,
            dry_run: false,
            order: String::new(),
            ..room.clone()
        };
        let plan = Self::plan_room(doc, account, Some(&principal), &ask)?;
        if plan.answer.shortfall_bytes > 0 {
            return Err(Error::RoomShort {
                account: account.to_owned(),
                need: plan.answer.need_bytes,
                unused: plan.answer.unused_bytes.unwrap_or(0),
                short: plan.answer.shortfall_bytes,
            });
        }
        let mut seats = lots;
        Self::write_employee(
            doc,
            account,
            &principal,
            plan.lot,
            "",
            plan.answer.room_bytes,
            &mut seats,
        )?;
        if !seats.is_empty() {
            doc.seats.insert(account.to_owned(), seats)?;
        }
        let user = SsoUser {
            principal_id: principal.id.clone(),
            ..user
        };
        doc.sso_index
            .insert(user_index(account, &user.user_name), principal.id.clone())?;
        if let Some(subject) = &user.subject {
            doc.sso_index
                .insert(subject_index(account, subject), principal.id.clone())?;
        }
        doc.sso_users.insert(principal.id.clone(), user.clone())?;
        Ok((principal, user))
    }

    /// **Commit one verified SSO login.** In one update:
    ///
    /// 1. the subject is looked up in THIS account's index; a bound user must
    ///    be an employee of the account and active;
    /// 2. else a provisioned user (SCIM/CSV) of the IdP's user name that no
    ///    subject has claimed yet is bound to this subject — but ONLY on the
    ///    admin's binding (its `externalId` is one of the IdP's stable ids for
    ///    the person) or on an e-mail the IdP asserts is verified, that is the
    ///    row's address, in one of the account's e-mail domains (A34-2). A
    ///    user name (`preferred_username`) alone never claims; a user name
    ///    another subject already holds is refused — never re-pointed;
    /// 3. else a NEW employee is created (never an existing principal
    ///    attached), bound, with room;
    ///
    /// and the session key, when asked, is enrolled on the employee.
    pub fn sso_login(&self, login: &SsoLogin) -> Result<SsoLoginDone> {
        let session_key = match &login.session {
            Some((openssh, expires)) => {
                let parsed = super::parse_public_key(openssh)?;
                let fingerprint = super::Fingerprint::of_key(&parsed);
                Some(PublicKey {
                    fingerprint_sha256: fingerprint.as_str().to_owned(),
                    algo: parsed.algorithm().as_str().to_owned(),
                    principal_id: String::new(),
                    added: login.now,
                    last_used: None,
                    expires: Some(*expires),
                    openssh: parsed.to_openssh().map_err(Error::from)?.trim().to_owned(),
                })
            }
            None => None,
        };
        let account = login.account.as_str();
        self.doc.update(|doc| {
            let provider = doc
                .sso_providers
                .get(account)?
                .ok_or_else(|| refused(format!("{account} has no identity provider")))?;
            let proven = provider.verified_domains.clone();
            let mut created = false;
            let mut claimed = false;
            let mut claimed_by = None;
            let bound = doc.sso_index.get(&subject_index(account, &login.subject))?;
            let (principal, mut user) = match bound {
                Some(id) => {
                    let user = doc
                        .sso_users
                        .get(&id)?
                        .filter(|u| u.account == account)
                        .ok_or_else(|| {
                            refused_by(nornir_identity_sso::Refusal::SubjectElsewhere)
                        })?;
                    let principal = doc
                        .principals
                        .get(&id)?
                        .ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
                    (principal, user)
                }
                None => match doc.sso_index.get(&user_index(account, &login.user_name))? {
                    Some(id) => {
                        let mut user = doc
                            .sso_users
                            .get(&id)?
                            .filter(|u| u.account == account && !u.deleted)
                            .ok_or_else(|| {
                                refused_by(nornir_identity_sso::Refusal::ClaimNotMember)
                            })?;
                        // The claim, the rebind (A34-11) and the proven domains (A34-5):
                        // edda's one rule.
                        let basis = nornir_identity_sso::rules::may_claim(
                            &rule_row(&user),
                            &rule_login(login),
                            &proven,
                        )
                        .map_err(refused_by)?;
                        claimed_by = Some(basis.as_str());
                        user.rebind = false;
                        user.subject = Some(login.subject.clone());
                        doc.sso_index
                            .insert(subject_index(account, &login.subject), id.clone())?;
                        claimed = true;
                        let principal = doc
                            .principals
                            .get(&id)?
                            .ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
                        (principal, user)
                    }
                    None => {
                        // Who may become a new employee (A34-25).
                        nornir_identity_sso::rules::jit_allowed(
                            nornir_identity_sso::rules::JitPolicy::parse(&provider.jit),
                            &rule_login(login),
                            &proven,
                        )
                        .map_err(refused_by)?;
                        let user = SsoUser {
                            principal_id: String::new(),
                            account: account.to_owned(),
                            user_name: login.user_name.clone(),
                            display_name: login.display_name.clone(),
                            email: login.email.clone(),
                            external_id: String::new(),
                            subject: Some(login.subject.clone()),
                            active: true,
                            source: SsoSource::Jit,
                            created: login.now,
                            last_login: None,
                            sessions: Vec::new(),
                            deleted: false,
                            rebind: false,
                        };
                        created = true;
                        Self::create_directory_employee(
                            doc,
                            account,
                            &login.candidates,
                            &login.room,
                            user,
                        )?
                    }
                },
            };
            // Never across accounts, and only an employee: the index is the
            // account's own, and this is the second lock on the same door.
            if doc.employers.get(&principal.id)?.as_deref() != Some(account) {
                return Err(refused_by(nornir_identity_sso::Refusal::SubjectElsewhere));
            }
            if !user.active || user.deleted {
                return Err(refused_by(nornir_identity_sso::Refusal::Deactivated));
            }
            if Self::gated(doc, &principal.id)? {
                return Err(refused_by(nornir_identity_sso::Refusal::Locked));
            }
            if !created {
                if !login.display_name.is_empty() {
                    user.display_name = login.display_name.clone();
                }
                if !login.email.is_empty() {
                    user.email = login.email.clone();
                }
            }
            user.last_login = Some(login.now);
            let mut enrolled = None;
            if let Some(key) = &session_key {
                if let Some(existing) = doc.keys.get(&key.fingerprint_sha256)? {
                    return Err(Error::KeyExists {
                        fingerprint: key.fingerprint_sha256.clone(),
                        principal_id: existing.principal_id,
                    });
                }
                let key = PublicKey {
                    principal_id: principal.id.clone(),
                    ..key.clone()
                };
                let mut live = Vec::with_capacity(user.sessions.len() + 1);
                for held in std::mem::take(&mut user.sessions) {
                    if doc
                        .keys
                        .get(&held)?
                        .is_some_and(|k| k.is_valid_at(login.now))
                    {
                        live.push(held);
                    }
                }
                live.push(key.fingerprint_sha256.clone());
                user.sessions = live;
                doc.keys
                    .insert(key.fingerprint_sha256.clone(), key.clone())?;
                enrolled = Some(key);
            }
            doc.sso_users.insert(principal.id.clone(), user.clone())?;
            Ok(SsoLoginDone {
                principal,
                user,
                created,
                claimed,
                claimed_by,
                session: enrolled,
            })
        })
    }

    /// **Provision a NEW employee** from the company's directory (SCIM
    /// create, a CSV row): unbound until its first login claims it. A user
    /// name the account's directory already holds is refused
    /// ([`Error::NameExists`]): SCIM's `uniqueness`.
    pub fn sso_provision(&self, provision: &SsoProvision) -> Result<SsoUser> {
        self.doc.update(|doc| Self::provision_in(doc, provision))
    }

    /// [`Self::sso_provision`] inside an update (one row of [`Self::sso_import`] too).
    fn provision_in(doc: &mut AuthDoc, provision: &SsoProvision) -> Result<SsoUser> {
        let account = provision.account.as_str();
        if !doc.principals.contains_key(account)? {
            return Err(Error::NoSuchPrincipal(account.to_owned()));
        }
        if let Some(held) = doc
            .sso_index
            .get(&user_index(account, &provision.user_name))?
        {
            return Err(Error::NameExists {
                name: provision.user_name.clone(),
                principal_id: held,
            });
        }
        let user = SsoUser {
            principal_id: String::new(),
            account: account.to_owned(),
            user_name: provision.user_name.clone(),
            display_name: provision.display_name.clone(),
            email: provision.email.clone(),
            external_id: provision.external_id.clone(),
            subject: None,
            active: true,
            source: provision.source,
            created: provision.now,
            last_login: None,
            sessions: Vec::new(),
            deleted: false,
            rebind: false,
        };
        let (_, mut user) = Self::create_directory_employee(
            doc,
            account,
            &provision.candidates,
            &provision.room,
            user,
        )?;
        if !provision.active {
            Self::deactivate(doc, &mut user, &provision.actor, provision.now)?;
            doc.sso_users
                .insert(user.principal_id.clone(), user.clone())?;
        }
        Ok(user)
    }

    /// **Change a directory user**: its names and, with `active`, whether it
    /// may log in — a deactivation gates the employee (holder
    /// [`crate::sso::gate_holder`]) and revokes its web sessions in the same
    /// update; a reactivation lifts that gate only (an operator's suspension
    /// stays). Returns the row as it stands and whether `active` changed.
    pub fn sso_user_update(
        &self,
        account: &str,
        principal_id: &str,
        patch: &SsoUserPatch,
        actor: &str,
        now: UnixMillis,
    ) -> Result<(SsoUser, bool)> {
        self.doc
            .update(|doc| Self::user_update_in(doc, account, principal_id, patch, actor, now))
    }

    /// [`Self::sso_user_update`] inside an update (one row of [`Self::sso_import`] too).
    fn user_update_in(
        doc: &mut AuthDoc,
        account: &str,
        principal_id: &str,
        patch: &SsoUserPatch,
        actor: &str,
        now: UnixMillis,
    ) -> Result<(SsoUser, bool)> {
        let mut user = doc
            .sso_users
            .get(principal_id)?
            .filter(|u| u.account == account && !u.deleted)
            .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
        if let Some(name) = &patch.user_name {
            if *name != user.user_name {
                if let Some(held) = doc.sso_index.get(&user_index(account, name))? {
                    if held != user.principal_id {
                        return Err(Error::NameExists {
                            name: name.clone(),
                            principal_id: held,
                        });
                    }
                }
                doc.sso_index
                    .remove(&user_index(account, &user.user_name))?;
                doc.sso_index
                    .insert(user_index(account, name), user.principal_id.clone())?;
                user.user_name = name.clone();
            }
        }
        if let Some(v) = &patch.display_name {
            user.display_name = v.clone();
        }
        if let Some(v) = &patch.email {
            user.email = v.clone();
        }
        if let Some(v) = &patch.external_id {
            user.external_id = v.clone();
        }
        let mut changed = false;
        match patch.active {
            Some(false) if user.active => {
                Self::deactivate(doc, &mut user, actor, now)?;
                changed = true;
            }
            Some(true) if !user.active => {
                user.active = true;
                let holder = crate::sso::gate_holder(account);
                if doc
                    .gates
                    .get(&user.principal_id)?
                    .is_some_and(|g| g.holder == holder)
                {
                    doc.gates.remove(&user.principal_id)?;
                }
                changed = true;
            }
            _ => {}
        }
        doc.sso_users
            .insert(user.principal_id.clone(), user.clone())?;
        Ok((user, changed))
    }

    /// **A whole directory import in ONE update** (A34-17): every row, or
    /// none. A row the table refuses (no room, a name taken, no free id)
    /// refuses the import, and the refusal names that row's label. With
    /// `commit` false the same rows run on a copy of the table and nothing is
    /// written: the dry run sees the same refusals, room included.
    pub fn sso_import(
        &self,
        account: &str,
        rows: &[SsoImportRow],
        actor: &str,
        now: UnixMillis,
        commit: bool,
    ) -> std::result::Result<(), (String, Error)> {
        let mut at = String::new();
        let mut apply = |doc: &mut AuthDoc| -> Result<()> {
            for row in rows {
                at.clone_from(&row.label);
                match &row.op {
                    SsoImportOp::Create(provision) => {
                        if provision.account != account {
                            return Err(Error::NoSuchPrincipal(provision.account.clone()));
                        }
                        Self::provision_in(doc, provision)?;
                    }
                    SsoImportOp::Update {
                        principal_id,
                        patch,
                    } => {
                        Self::user_update_in(doc, account, principal_id, patch, actor, now)?;
                    }
                }
            }
            at.clear();
            Ok(())
        };
        let result = if commit {
            self.doc.update(&mut apply)
        } else {
            match self.doc.load() {
                Ok(current) => {
                    let mut copy = (*current).clone();
                    apply(&mut copy)
                }
                Err(e) => Err(e),
            }
        };
        result.map_err(|e| (at, e))
    }

    /// **Remove a user from the directory** (SCIM DELETE): deactivated —
    /// gated, sessions revoked — and no longer listed or claimable. The
    /// employee and everything it pushed stay; its user name is free for a
    /// NEW person, never for this one again.
    pub fn sso_user_delete(
        &self,
        account: &str,
        principal_id: &str,
        actor: &str,
        now: UnixMillis,
    ) -> Result<SsoUser> {
        self.doc.update(|doc| {
            let mut user = doc
                .sso_users
                .get(principal_id)?
                .filter(|u| u.account == account && !u.deleted)
                .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
            if user.active {
                Self::deactivate(doc, &mut user, actor, now)?;
            }
            user.deleted = true;
            doc.sso_index
                .remove(&user_index(account, &user.user_name))?;
            for (gid, mut group) in doc.sso_groups.entries()? {
                if group.account == account && group.members.contains(&user.principal_id) {
                    group.members.retain(|m| *m != user.principal_id);
                    doc.sso_groups.insert(gid, group)?;
                }
            }
            doc.sso_users
                .insert(user.principal_id.clone(), user.clone())?;
            Ok(user)
        })
    }

    /// Inside an update: `active = false`, every web session key of the
    /// person revoked (SSO's and its passkeys'), and the directory's gate —
    /// unless another holder's gate is already in force, which stays.
    fn deactivate(
        doc: &mut AuthDoc,
        user: &mut SsoUser,
        actor: &str,
        now: UnixMillis,
    ) -> Result<()> {
        user.active = false;
        let id = user.principal_id.clone();
        let mut ended: Vec<String> = std::mem::take(&mut user.sessions);
        for (cid, mut credential) in doc.credentials.entries()? {
            if credential.principal_id == id && !credential.sessions.is_empty() {
                ended.append(&mut credential.sessions);
                doc.credentials.insert(cid, credential)?;
            }
        }
        for fingerprint in ended {
            if doc
                .keys
                .get(&fingerprint)?
                .is_some_and(|k| k.principal_id == id)
            {
                doc.keys.remove(&fingerprint)?;
            }
        }
        let holder = crate::sso::gate_holder(&user.account);
        let held_by_another = doc
            .gates
            .get(&id)?
            .is_some_and(|g| g.holder != holder && g.is_live_at(now));
        if !held_by_another {
            doc.gates.insert(
                id.clone(),
                AccountGate {
                    account: id.clone(),
                    reason: GateReason::Administrative,
                    holder,
                    actor: actor.to_owned(),
                    note: format!("deactivated in {}'s directory", user.account),
                    at: now,
                    expires: None,
                },
            )?;
        }
        Ok(())
    }

    // ── SCIM bearers ──────────────────────────────────────────────────────

    /// Store a new SCIM bearer (its hash).
    pub fn create_scim_token(&self, token: ScimToken) -> Result<ScimToken> {
        self.doc.update(|doc| {
            if !doc.principals.contains_key(&token.account)? {
                return Err(Error::NoSuchPrincipal(token.account.clone()));
            }
            if doc.scim_tokens.contains_key(&token.secret_hash)? {
                return Err(Error::TokenExists {
                    hash: token.secret_hash.clone(),
                    principal_id: token.account.clone(),
                });
            }
            doc.scim_tokens
                .insert(token.secret_hash.clone(), token.clone())?;
            Ok(token.clone())
        })
    }

    /// The bearer a secret's hash names, if any. One probe.
    pub fn scim_token_by_hash(&self, hash: &str) -> Result<Option<ScimToken>> {
        self.doc.load()?.scim_tokens.get(hash)
    }

    /// An account's SCIM bearers.
    pub fn scim_tokens_of(&self, account: &str) -> Result<Vec<ScimToken>> {
        let mut rows: Vec<ScimToken> = self
            .doc
            .load()?
            .scim_tokens
            .values()?
            .into_iter()
            .filter(|t| t.account == account)
            .collect();
        rows.sort_by(|a, b| (a.created, &a.id).cmp(&(b.created, &b.id)));
        Ok(rows)
    }

    /// Revoke one of an account's bearers by its public id.
    pub fn revoke_scim_token(&self, account: &str, id: &str) -> Result<Option<ScimToken>> {
        self.doc.update(|doc| {
            let hit = doc
                .scim_tokens
                .values()?
                .into_iter()
                .find(|t| t.account == account && t.id == id);
            match hit {
                Some(token) => doc.scim_tokens.remove(&token.secret_hash),
                None => Ok(None),
            }
        })
    }

    /// Note a bearer's use, at most once a minute.
    pub fn touch_scim_token(&self, hash: &str, now: UnixMillis) -> Result<()> {
        let stale = self.doc.load()?.scim_tokens.get(hash)?.is_some_and(|t| {
            t.last_used
                .is_none_or(|at| now - at >= super::LAST_USED_GRANULARITY)
        });
        if !stale {
            return Ok(());
        }
        self.doc.update(|doc| {
            doc.scim_tokens.modify(hash, |t| t.last_used = Some(now))?;
            Ok(())
        })
    }

    // ── SCIM groups ───────────────────────────────────────────────────────

    /// An account's groups.
    pub fn sso_groups_of(&self, account: &str) -> Result<Vec<SsoGroup>> {
        let mut rows: Vec<SsoGroup> = self
            .doc
            .load()?
            .sso_groups
            .values()?
            .into_iter()
            .filter(|g| g.account == account)
            .collect();
        rows.sort_by(|a, b| {
            a.display_name
                .cmp(&b.display_name)
                .then_with(|| a.id.cmp(&b.id))
        });
        Ok(rows)
    }

    /// Write a group (create or replace). Members must be the account's own
    /// directory users; a display name is unique within the account.
    pub fn put_sso_group(&self, group: SsoGroup) -> Result<SsoGroup> {
        self.doc.update(|doc| {
            for member in &group.members {
                let ok = doc
                    .sso_users
                    .get(member)?
                    .is_some_and(|u| u.account == group.account && !u.deleted);
                if !ok {
                    return Err(Error::InvalidInput {
                        what: "group",
                        reason: format!("{member} is not a user of {}'s directory", group.account),
                    });
                }
            }
            for other in doc.sso_groups.values()? {
                if other.account == group.account
                    && other.id != group.id
                    && other.display_name.eq_ignore_ascii_case(&group.display_name)
                {
                    return Err(Error::NameExists {
                        name: group.display_name.clone(),
                        principal_id: other.id,
                    });
                }
            }
            if let Some(old) = doc.sso_groups.get(&group.id)? {
                if old.account != group.account {
                    return Err(Error::InvalidInput {
                        what: "group",
                        reason: "the group is another account's".to_owned(),
                    });
                }
            }
            doc.sso_groups.insert(group.id.clone(), group.clone())?;
            Ok(group.clone())
        })
    }

    /// Remove one of an account's groups.
    pub fn remove_sso_group(&self, account: &str, id: &str) -> Result<Option<SsoGroup>> {
        self.doc.update(|doc| match doc.sso_groups.get(id)? {
            Some(group) if group.account == account => doc.sso_groups.remove(id),
            _ => Ok(None),
        })
    }

    /// A pruned copy of the live web sessions of a directory user (for a
    /// listing): the ones still held and unexpired.
    pub fn sso_live_sessions(&self, principal_id: &str, now: UnixMillis) -> Result<usize> {
        let doc = self.doc.load()?;
        let Some(user) = doc.sso_users.get(principal_id)? else {
            return Ok(0);
        };
        let mut n = 0;
        for fp in &user.sessions {
            if doc.keys.get(fp)?.is_some_and(|k| k.is_valid_at(now)) {
                n += 1;
            }
        }
        Ok(n)
    }
}

#[cfg(test)]
mod older_reader_tests {
    use std::collections::BTreeSet;
    use std::sync::Arc;

    use super::super::{AuthTable, AUTH_REF};
    use crate::control::ControlStore;
    use crate::model::{Principal, PrincipalKind};
    use crate::sso::{OidcSettings, SsoProtocol, SsoProvider};

    /// The identity table's keys a gunnar from BEFORE company SSO and clans
    /// knows (test2's line, feat/test2-next-2026-10-05) — its `AuthDoc` is
    /// `deny_unknown_fields`, so a key outside this list is a table it cannot
    /// read at all. It guards every map added since — SSO's six, and the
    /// enterprise list's `operator_access` — the same way.
    const OLDER: &[&str] = &[
        "principals",
        "keys",
        "grants",
        "tokens",
        "memberships",
        "bootstrap",
        "gates",
        "names",
        "employers",
        "rooms",
        "seats",
        "room_orders",
        "credentials",
        "invites",
    ];

    fn stored_keys(store: &Arc<dyn ControlStore>) -> BTreeSet<String> {
        let id = store
            .read_doc(AUTH_REF)
            .unwrap()
            .expect("the table was written");
        let bytes = store.get_blob(&id).unwrap().expect("its blob");
        let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        doc.as_object()
            .expect("an object")
            .keys()
            .cloned()
            .collect()
    }

    fn provider(account: &str) -> SsoProvider {
        SsoProvider {
            account: account.to_owned(),
            protocol: SsoProtocol::Oidc,
            display_name: format!("{account} corp"),
            domains: vec![format!("{account}.example")],
            oidc: Some(OidcSettings {
                issuer: "https://idp.example.test".into(),
                client_id: "c".into(),
                ..Default::default()
            }),
            saml: None,
            require_sso: false,
            require_passkey: false,
            console_origin: "https://gunnar.example".into(),
            updated: 1,
            updated_by: account.to_owned(),
            verified_domains: Vec::new(),
            jit: String::new(),
        }
    }

    /// ★ **A table that never used SSO is one an older gunnar still reads**
    /// (a downgrade, or a twin still on the older line): SSO's maps are
    /// written only once there is something in them, as clans' are — and
    /// gone again once the last row is.
    #[test]
    fn sso_maps_are_written_only_when_used() {
        let store = crate::memory_control_store().expect("store");
        let auth = AuthTable::new(Arc::clone(&store)).expect("table");
        auth.add_principal(Principal::new("acme", "acme", PrincipalKind::User).unwrap())
            .expect("account");
        let keys = stored_keys(&store);
        let unknown: Vec<&String> = keys
            .iter()
            .filter(|k| !OLDER.contains(&k.as_str()))
            .collect();
        assert!(
            unknown.is_empty(),
            "a table without SSO carries keys an older gunnar refuses: {unknown:?}"
        );

        auth.set_sso_provider(provider("acme")).expect("provider");
        // The index holds proven domains only (A34-5).
        auth.verify_sso_domain("acme", "acme.example")
            .expect("proven");
        let keys = stored_keys(&store);
        assert!(
            keys.contains("sso_providers") && keys.contains("sso_domains"),
            "a provider is written: {keys:?}"
        );

        auth.remove_sso_provider("acme").expect("removed");
        let keys = stored_keys(&store);
        let unknown: Vec<&String> = keys
            .iter()
            .filter(|k| !OLDER.contains(&k.as_str()))
            .collect();
        assert!(
            unknown.is_empty(),
            "the provider is gone, its maps must be too: {unknown:?}"
        );
    }
}