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