Skip to main content

gunnar_core/auth/
sso_table.rs

1//! **Company SSO in the identity table** — the writers of the rows
2//! [`crate::sso`] describes. Every one is a single compare-and-swap on
3//! `refs/gunnar/auth`, so a first login's NEW employee and the subject it is
4//! bound to, or a deactivation's gate and the sessions it ends, are never seen
5//! half-written.
6
7use super::{AuthDoc, AuthTable, RoomAsk, SeatLot};
8use crate::error::{Error, Result};
9use crate::gate::{AccountGate, GateReason};
10use crate::model::{Principal, PublicKey, UnixMillis};
11use crate::sso::{ScimToken, SsoGroup, SsoProvider, SsoSource, SsoUser};
12
13/// What one SSO login asks of the table.
14#[derive(Clone, Debug)]
15pub struct SsoLogin {
16    /// The account whose identity provider verified the person.
17    pub account: String,
18    /// [`crate::sso::subject_key`] of the verified IdP subject.
19    pub subject: String,
20    /// The directory user name the IdP named (normalized), for a first
21    /// login: a provisioned user of that name is claimed, or a NEW employee
22    /// is created under it.
23    pub user_name: String,
24    /// The person's name, as the IdP says it.
25    pub display_name: String,
26    /// The person's address, as the IdP says it.
27    pub email: String,
28    /// The IdP ASSERTED that [`email`](Self::email) is verified (OIDC
29    /// `email_verified: true`). SAML carries no such assertion: false.
30    pub email_verified: bool,
31    /// The IdP's own stable ids for the person, raw (OIDC `sub`, and Entra's
32    /// `oid` when sent; the SAML persistent NameID). A provisioned user whose
33    /// `externalId` is one of them was bound to this person by the admin.
34    pub idp_subjects: Vec<String>,
35    /// The ids a NEW employee may take, in order, already filtered against
36    /// the namespaces (which the table does not hold). The first one that is
37    /// no principal and no name is taken.
38    pub candidates: Vec<Principal>,
39    /// The room a NEW employee holds: an open seat when the account has one,
40    /// else `room.room_bytes` out of its unused room.
41    pub room: RoomAsk,
42    /// The web session key to enrol (one OpenSSH line) and when it expires;
43    /// `None` when a second factor must come first.
44    pub session: Option<(String, UnixMillis)>,
45    /// The caller's clock.
46    pub now: UnixMillis,
47}
48
49/// What a committed SSO login answers.
50#[derive(Clone, Debug)]
51pub struct SsoLoginDone {
52    /// The employee who logged in.
53    pub principal: Principal,
54    /// Its directory row, as written.
55    pub user: SsoUser,
56    /// This login created the employee (just in time).
57    pub created: bool,
58    /// This login bound a provisioned user (SCIM/CSV) to its subject.
59    pub claimed: bool,
60    /// How the provisioned user was bound, for the audit line:
61    /// `external_id` (the admin's binding) or `verified_email`.
62    pub claimed_by: Option<&'static str>,
63    /// The session key enrolled, when one was asked.
64    pub session: Option<PublicKey>,
65}
66
67/// A NEW provisioned employee (SCIM create, a CSV row).
68#[derive(Clone, Debug)]
69pub struct SsoProvision {
70    /// The account the employee is provisioned into.
71    pub account: String,
72    /// The directory user name (normalized, unique within the account).
73    pub user_name: String,
74    /// The person's name.
75    pub display_name: String,
76    /// The person's address.
77    pub email: String,
78    /// The directory's own id for the person (SCIM `externalId`).
79    pub external_id: String,
80    /// False: provisioned deactivated.
81    pub active: bool,
82    /// SCIM or CSV.
83    pub source: SsoSource,
84    /// The ids the NEW employee may take, in order (see [`SsoLogin::candidates`]).
85    pub candidates: Vec<Principal>,
86    /// The room it holds (see [`SsoLogin::room`]).
87    pub room: RoomAsk,
88    /// Who provisioned it (the bearer's account admin, or the importer).
89    pub actor: String,
90    /// The caller's clock.
91    pub now: UnixMillis,
92}
93
94/// A change to a directory user; `None` leaves a field as it is.
95#[derive(Clone, Debug, Default)]
96pub struct SsoUserPatch {
97    /// A new directory user name.
98    pub user_name: Option<String>,
99    /// A new name.
100    pub display_name: Option<String>,
101    /// A new address.
102    pub email: Option<String>,
103    /// A new external id.
104    pub external_id: Option<String>,
105    /// Deactivate (false) or reactivate (true).
106    pub active: Option<bool>,
107}
108
109/// One row of [`AuthTable::sso_import`].
110#[derive(Clone, Debug)]
111pub enum SsoImportOp {
112    /// A NEW employee.
113    Create(SsoProvision),
114    /// A change to a directory user of the import's account.
115    Update {
116        /// The directory user's principal id.
117        principal_id: String,
118        /// What changes.
119        patch: SsoUserPatch,
120    },
121}
122
123/// A row and the words its refusal is named by (`line 7: bob@acme.example`).
124#[derive(Clone, Debug)]
125pub struct SsoImportRow {
126    /// What a refusal of this row is named by.
127    pub label: String,
128    /// What the row does.
129    pub op: SsoImportOp,
130}
131
132fn subject_index(account: &str, subject: &str) -> String {
133    format!("s\u{1f}{account}\u{1f}{subject}")
134}
135
136fn user_index(account: &str, user_name: &str) -> String {
137    format!("u\u{1f}{account}\u{1f}{user_name}")
138}
139
140fn refused(reason: impl Into<String>) -> Error {
141    Error::SsoRefused(reason.into())
142}
143
144/// The rules' view of a directory row.
145fn rule_row(user: &SsoUser) -> nornir_identity_sso::rules::Row<'_> {
146    use nornir_identity_sso::rules::Source;
147    nornir_identity_sso::rules::Row {
148        external_id: &user.external_id,
149        email: &user.email,
150        user_name: &user.user_name,
151        source: match user.source {
152            SsoSource::Jit => Source::Jit,
153            SsoSource::Scim => Source::Scim,
154            SsoSource::Csv => Source::Csv,
155        },
156        bound: user.subject.is_some(),
157        rebind: user.rebind,
158    }
159}
160
161/// The rules' view of a login.
162fn rule_login(login: &SsoLogin) -> nornir_identity_sso::rules::Login<'_> {
163    nornir_identity_sso::rules::Login {
164        email: &login.email,
165        email_verified: login.email_verified,
166        idp_ids: &login.idp_subjects,
167    }
168}
169
170/// **A refusal, by code** (A34-46, RA4-2): `[code] sentence`, naming nobody — it is
171/// answered to an unauthenticated caller and written into the append-only audit
172/// chain. A claim refusal says how the admin binds an employee.
173fn refused_by(r: nornir_identity_sso::Refusal) -> Error {
174    use nornir_identity_sso::Refusal as R;
175    match r {
176        R::ClaimUnverified | R::ClaimNotAddress | R::ClaimDomain => refused(format!(
177            "{}. A user name (preferred_username) alone never claims; the account admin can bind the employee by \
178             setting its externalId to the person's id at the identity provider",
179            r.message()
180        )),
181        other => refused(other.message()),
182    }
183}
184
185impl AuthTable {
186    /// Does this `sso_index` key belong to `account`?
187    pub(super) fn sso_index_of_account(key: &str, account: &str) -> bool {
188        let mut parts = key.splitn(3, '\u{1f}');
189        let _kind = parts.next();
190        parts.next() == Some(account)
191    }
192
193    // ── providers ─────────────────────────────────────────────────────────
194
195    /// An account's identity provider, if it has one.
196    pub fn sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
197        self.doc.load()?.sso_providers.get(account)
198    }
199
200    /// Every account's identity provider. A listing.
201    pub fn sso_providers(&self) -> Result<Vec<SsoProvider>> {
202        self.doc.load()?.sso_providers.values()
203    }
204
205    /// The account an email domain leads to.
206    pub fn sso_account_for_domain(&self, domain: &str) -> Result<Option<String>> {
207        self.doc.load()?.sso_domains.get(domain)
208    }
209
210    /// **Configure an account's identity provider** (replacing the one it
211    /// had). The account must be a principal; its domains must be free or
212    /// already its own — one domain, one company.
213    pub fn set_sso_provider(&self, provider: SsoProvider) -> Result<SsoProvider> {
214        self.doc.update(|doc| {
215            if !doc.principals.contains_key(&provider.account)? {
216                return Err(Error::NoSuchPrincipal(provider.account.clone()));
217            }
218            if doc.employers.contains_key(&provider.account)? {
219                return Err(Error::InvalidInput {
220                    what: "identity provider",
221                    reason: format!(
222                        "{} is an employee, not an account: only an account has an identity provider",
223                        provider.account
224                    ),
225                });
226            }
227            // **A typed domain routes nothing** (A34-5): any account may NAME a
228            // domain; only its DNS proof ([`AuthTable::verify_sso_domain`]) puts
229            // it in the index. Proofs of the domains still named are kept.
230            let mut provider = provider.clone();
231            let old = doc.sso_providers.get(&provider.account)?;
232            provider.verified_domains = old
233                .as_ref()
234                .map(|o| o.verified_domains.iter().filter(|d| provider.domains.contains(d)).cloned().collect())
235                .unwrap_or_default();
236            if let Some(old) = old {
237                for domain in old.verified_domains {
238                    if doc.sso_domains.get(&domain)?.as_deref() == Some(provider.account.as_str()) {
239                        doc.sso_domains.remove(&domain)?;
240                    }
241                }
242            }
243            for domain in &provider.verified_domains {
244                doc.sso_domains.insert(domain.clone(), provider.account.clone())?;
245            }
246            doc.sso_providers.insert(provider.account.clone(), provider.clone())?;
247            Ok(provider)
248        })
249    }
250
251    /// Remove an account's identity provider. Its directory stays: its
252    /// employees are still employees (and a re-configured IdP finds them).
253    pub fn remove_sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
254        self.doc.update(|doc| {
255            let removed = doc.sso_providers.remove(account)?;
256            if let Some(provider) = &removed {
257                // Only the domains this account proved (the index holds no other).
258                for domain in &provider.verified_domains {
259                    if doc.sso_domains.get(domain)?.as_deref() == Some(account) {
260                        doc.sso_domains.remove(domain)?;
261                    }
262                }
263            }
264            Ok(removed)
265        })
266    }
267
268    /// **`domain` is proven for `account`** (A34-5), after the caller read its
269    /// DNS proof (`nornir_identity_sso::domains::verify`). The domain must be one
270    /// the provider names, and no other account may have proven it first.
271    pub fn verify_sso_domain(&self, account: &str, domain: &str) -> Result<SsoProvider> {
272        use nornir_identity_sso::Refusal;
273        self.doc.update(|doc| {
274            let mut provider = doc
275                .sso_providers
276                .get(account)?
277                .ok_or_else(|| refused(format!("{account} has no identity provider")))?;
278            if !provider.domains.iter().any(|d| d == domain) {
279                return Err(refused_by(Refusal::DomainUnknown));
280            }
281            match doc.sso_domains.get(domain)? {
282                Some(holder) if holder != account => return Err(refused_by(Refusal::DomainTaken)),
283                _ => {}
284            }
285            if !provider.verified_domains.iter().any(|d| d == domain) {
286                provider.verified_domains.push(domain.to_owned());
287            }
288            doc.sso_domains
289                .insert(domain.to_owned(), account.to_owned())?;
290            doc.sso_providers
291                .insert(account.to_owned(), provider.clone())?;
292            Ok(provider)
293        })
294    }
295
296    /// **Open a rebind** (A34-11, an IdP migration): each named directory user of
297    /// `account` (every bound one when `principal_ids` is empty) is unbound from
298    /// its old IdP subject — the old subject signs it in no more — and marked so
299    /// that its next sign-in binds it afresh, on the claim rules. `external_id`,
300    /// with exactly one principal, is its id at the NEW identity provider (the
301    /// admin's binding). Deleted users are never rebound.
302    pub fn sso_user_rebind(
303        &self,
304        account: &str,
305        principal_ids: &[String],
306        external_id: Option<&str>,
307    ) -> Result<Vec<SsoUser>> {
308        if external_id.is_some() && principal_ids.len() != 1 {
309            return Err(Error::InvalidInput {
310                what: "rebind",
311                reason: "an externalId names exactly one employee".to_owned(),
312            });
313        }
314        self.doc.update(|doc| {
315            let targets: Vec<SsoUser> = if principal_ids.is_empty() {
316                doc.sso_users
317                    .values()?
318                    .into_iter()
319                    .filter(|u| u.account == account && !u.deleted && u.subject.is_some())
320                    .collect()
321            } else {
322                let mut rows = Vec::new();
323                for id in principal_ids {
324                    let user = doc
325                        .sso_users
326                        .get(id)?
327                        .filter(|u| u.account == account && !u.deleted)
328                        .ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
329                    rows.push(user);
330                }
331                rows
332            };
333            let mut out = Vec::with_capacity(targets.len());
334            for mut user in targets {
335                if let Some(subject) = user.subject.take() {
336                    if doc
337                        .sso_index
338                        .get(&subject_index(account, &subject))?
339                        .as_deref()
340                        == Some(user.principal_id.as_str())
341                    {
342                        doc.sso_index.remove(&subject_index(account, &subject))?;
343                    }
344                }
345                user.rebind = true;
346                if let Some(id) = external_id {
347                    user.external_id = id.trim().to_owned();
348                }
349                doc.sso_users
350                    .insert(user.principal_id.clone(), user.clone())?;
351                out.push(user);
352            }
353            Ok(out)
354        })
355    }
356
357    // ── the directory ─────────────────────────────────────────────────────
358
359    /// One directory user, by principal id.
360    pub fn sso_user(&self, principal_id: &str) -> Result<Option<SsoUser>> {
361        self.doc.load()?.sso_users.get(principal_id)
362    }
363
364    /// An account's directory (deleted users included; the caller filters).
365    pub fn sso_users_of(&self, account: &str) -> Result<Vec<SsoUser>> {
366        let mut rows: Vec<SsoUser> = self
367            .doc
368            .load()?
369            .sso_users
370            .values()?
371            .into_iter()
372            .filter(|u| u.account == account)
373            .collect();
374        rows.sort_by(|a, b| {
375            a.user_name
376                .cmp(&b.user_name)
377                .then_with(|| a.principal_id.cmp(&b.principal_id))
378        });
379        Ok(rows)
380    }
381
382    /// The principal a subject is bound to in `account`, if any.
383    pub fn sso_principal_for_subject(
384        &self,
385        account: &str,
386        subject: &str,
387    ) -> Result<Option<String>> {
388        self.doc
389            .load()?
390            .sso_index
391            .get(&subject_index(account, subject))
392    }
393
394    /// The first free candidate id: no principal of that id, no principal
395    /// named so.
396    fn free_candidate(doc: &AuthDoc, candidates: &[Principal]) -> Result<Option<Principal>> {
397        for candidate in candidates {
398            if doc.principals.contains_key(&candidate.id)? {
399                continue;
400            }
401            if Self::refuse_taken_name(doc, &candidate.name, &candidate.id).is_err() {
402                continue;
403            }
404            if Self::resolve_name(doc, &candidate.id)?.is_some() {
405                continue;
406            }
407            return Ok(Some(candidate.clone()));
408        }
409        Ok(None)
410    }
411
412    /// **A NEW employee for the directory**, inside the caller's update: the
413    /// room planned (an open seat first), the employee written by the one
414    /// writer ([`AuthTable::write_employee`]), its directory row and index.
415    fn create_directory_employee(
416        doc: &mut AuthDoc,
417        account: &str,
418        candidates: &[Principal],
419        room: &RoomAsk,
420        user: SsoUser,
421    ) -> Result<(Principal, SsoUser)> {
422        let principal = Self::free_candidate(doc, candidates)?.ok_or_else(|| {
423            refused(format!(
424                "no free id for {:?} in {account}: every candidate is taken",
425                user.user_name
426            ))
427        })?;
428        let principal = Principal {
429            created: user.created,
430            ..principal
431        };
432        let lots: Vec<SeatLot> = doc.seats.get(account)?.unwrap_or_default();
433        let ask = RoomAsk {
434            from_seat: lots.iter().any(|lot| lot.open() > 0),
435            seats: 0,
436            dry_run: false,
437            order: String::new(),
438            ..room.clone()
439        };
440        let plan = Self::plan_room(doc, account, Some(&principal), &ask)?;
441        if plan.answer.shortfall_bytes > 0 {
442            return Err(Error::RoomShort {
443                account: account.to_owned(),
444                need: plan.answer.need_bytes,
445                unused: plan.answer.unused_bytes.unwrap_or(0),
446                short: plan.answer.shortfall_bytes,
447            });
448        }
449        let mut seats = lots;
450        Self::write_employee(
451            doc,
452            account,
453            &principal,
454            plan.lot,
455            "",
456            plan.answer.room_bytes,
457            &mut seats,
458        )?;
459        if !seats.is_empty() {
460            doc.seats.insert(account.to_owned(), seats)?;
461        }
462        let user = SsoUser {
463            principal_id: principal.id.clone(),
464            ..user
465        };
466        doc.sso_index
467            .insert(user_index(account, &user.user_name), principal.id.clone())?;
468        if let Some(subject) = &user.subject {
469            doc.sso_index
470                .insert(subject_index(account, subject), principal.id.clone())?;
471        }
472        doc.sso_users.insert(principal.id.clone(), user.clone())?;
473        Ok((principal, user))
474    }
475
476    /// **Commit one verified SSO login.** In one update:
477    ///
478    /// 1. the subject is looked up in THIS account's index; a bound user must
479    ///    be an employee of the account and active;
480    /// 2. else a provisioned user (SCIM/CSV) of the IdP's user name that no
481    ///    subject has claimed yet is bound to this subject — but ONLY on the
482    ///    admin's binding (its `externalId` is one of the IdP's stable ids for
483    ///    the person) or on an e-mail the IdP asserts is verified, that is the
484    ///    row's address, in one of the account's e-mail domains (A34-2). A
485    ///    user name (`preferred_username`) alone never claims; a user name
486    ///    another subject already holds is refused — never re-pointed;
487    /// 3. else a NEW employee is created (never an existing principal
488    ///    attached), bound, with room;
489    ///
490    /// and the session key, when asked, is enrolled on the employee.
491    pub fn sso_login(&self, login: &SsoLogin) -> Result<SsoLoginDone> {
492        let session_key = match &login.session {
493            Some((openssh, expires)) => {
494                let parsed = super::parse_public_key(openssh)?;
495                let fingerprint = super::Fingerprint::of_key(&parsed);
496                Some(PublicKey {
497                    fingerprint_sha256: fingerprint.as_str().to_owned(),
498                    algo: parsed.algorithm().as_str().to_owned(),
499                    principal_id: String::new(),
500                    added: login.now,
501                    last_used: None,
502                    expires: Some(*expires),
503                    openssh: parsed.to_openssh().map_err(Error::from)?.trim().to_owned(),
504                })
505            }
506            None => None,
507        };
508        let account = login.account.as_str();
509        self.doc.update(|doc| {
510            let provider = doc
511                .sso_providers
512                .get(account)?
513                .ok_or_else(|| refused(format!("{account} has no identity provider")))?;
514            let proven = provider.verified_domains.clone();
515            let mut created = false;
516            let mut claimed = false;
517            let mut claimed_by = None;
518            let bound = doc.sso_index.get(&subject_index(account, &login.subject))?;
519            let (principal, mut user) = match bound {
520                Some(id) => {
521                    let user = doc
522                        .sso_users
523                        .get(&id)?
524                        .filter(|u| u.account == account)
525                        .ok_or_else(|| {
526                            refused_by(nornir_identity_sso::Refusal::SubjectElsewhere)
527                        })?;
528                    let principal = doc
529                        .principals
530                        .get(&id)?
531                        .ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
532                    (principal, user)
533                }
534                None => match doc.sso_index.get(&user_index(account, &login.user_name))? {
535                    Some(id) => {
536                        let mut user = doc
537                            .sso_users
538                            .get(&id)?
539                            .filter(|u| u.account == account && !u.deleted)
540                            .ok_or_else(|| {
541                                refused_by(nornir_identity_sso::Refusal::ClaimNotMember)
542                            })?;
543                        // The claim, the rebind (A34-11) and the proven domains (A34-5):
544                        // edda's one rule.
545                        let basis = nornir_identity_sso::rules::may_claim(
546                            &rule_row(&user),
547                            &rule_login(login),
548                            &proven,
549                        )
550                        .map_err(refused_by)?;
551                        claimed_by = Some(basis.as_str());
552                        user.rebind = false;
553                        user.subject = Some(login.subject.clone());
554                        doc.sso_index
555                            .insert(subject_index(account, &login.subject), id.clone())?;
556                        claimed = true;
557                        let principal = doc
558                            .principals
559                            .get(&id)?
560                            .ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
561                        (principal, user)
562                    }
563                    None => {
564                        // Who may become a new employee (A34-25).
565                        nornir_identity_sso::rules::jit_allowed(
566                            nornir_identity_sso::rules::JitPolicy::parse(&provider.jit),
567                            &rule_login(login),
568                            &proven,
569                        )
570                        .map_err(refused_by)?;
571                        let user = SsoUser {
572                            principal_id: String::new(),
573                            account: account.to_owned(),
574                            user_name: login.user_name.clone(),
575                            display_name: login.display_name.clone(),
576                            email: login.email.clone(),
577                            external_id: String::new(),
578                            subject: Some(login.subject.clone()),
579                            active: true,
580                            source: SsoSource::Jit,
581                            created: login.now,
582                            last_login: None,
583                            sessions: Vec::new(),
584                            deleted: false,
585                            rebind: false,
586                        };
587                        created = true;
588                        Self::create_directory_employee(
589                            doc,
590                            account,
591                            &login.candidates,
592                            &login.room,
593                            user,
594                        )?
595                    }
596                },
597            };
598            // Never across accounts, and only an employee: the index is the
599            // account's own, and this is the second lock on the same door.
600            if doc.employers.get(&principal.id)?.as_deref() != Some(account) {
601                return Err(refused_by(nornir_identity_sso::Refusal::SubjectElsewhere));
602            }
603            if !user.active || user.deleted {
604                return Err(refused_by(nornir_identity_sso::Refusal::Deactivated));
605            }
606            if Self::gated(doc, &principal.id)? {
607                return Err(refused_by(nornir_identity_sso::Refusal::Locked));
608            }
609            if !created {
610                if !login.display_name.is_empty() {
611                    user.display_name = login.display_name.clone();
612                }
613                if !login.email.is_empty() {
614                    user.email = login.email.clone();
615                }
616            }
617            user.last_login = Some(login.now);
618            let mut enrolled = None;
619            if let Some(key) = &session_key {
620                if let Some(existing) = doc.keys.get(&key.fingerprint_sha256)? {
621                    return Err(Error::KeyExists {
622                        fingerprint: key.fingerprint_sha256.clone(),
623                        principal_id: existing.principal_id,
624                    });
625                }
626                let key = PublicKey {
627                    principal_id: principal.id.clone(),
628                    ..key.clone()
629                };
630                let mut live = Vec::with_capacity(user.sessions.len() + 1);
631                for held in std::mem::take(&mut user.sessions) {
632                    if doc
633                        .keys
634                        .get(&held)?
635                        .is_some_and(|k| k.is_valid_at(login.now))
636                    {
637                        live.push(held);
638                    }
639                }
640                live.push(key.fingerprint_sha256.clone());
641                user.sessions = live;
642                doc.keys
643                    .insert(key.fingerprint_sha256.clone(), key.clone())?;
644                enrolled = Some(key);
645            }
646            doc.sso_users.insert(principal.id.clone(), user.clone())?;
647            Ok(SsoLoginDone {
648                principal,
649                user,
650                created,
651                claimed,
652                claimed_by,
653                session: enrolled,
654            })
655        })
656    }
657
658    /// **Provision a NEW employee** from the company's directory (SCIM
659    /// create, a CSV row): unbound until its first login claims it. A user
660    /// name the account's directory already holds is refused
661    /// ([`Error::NameExists`]): SCIM's `uniqueness`.
662    pub fn sso_provision(&self, provision: &SsoProvision) -> Result<SsoUser> {
663        self.doc.update(|doc| Self::provision_in(doc, provision))
664    }
665
666    /// [`Self::sso_provision`] inside an update (one row of [`Self::sso_import`] too).
667    fn provision_in(doc: &mut AuthDoc, provision: &SsoProvision) -> Result<SsoUser> {
668        let account = provision.account.as_str();
669        if !doc.principals.contains_key(account)? {
670            return Err(Error::NoSuchPrincipal(account.to_owned()));
671        }
672        if let Some(held) = doc
673            .sso_index
674            .get(&user_index(account, &provision.user_name))?
675        {
676            return Err(Error::NameExists {
677                name: provision.user_name.clone(),
678                principal_id: held,
679            });
680        }
681        let user = SsoUser {
682            principal_id: String::new(),
683            account: account.to_owned(),
684            user_name: provision.user_name.clone(),
685            display_name: provision.display_name.clone(),
686            email: provision.email.clone(),
687            external_id: provision.external_id.clone(),
688            subject: None,
689            active: true,
690            source: provision.source,
691            created: provision.now,
692            last_login: None,
693            sessions: Vec::new(),
694            deleted: false,
695            rebind: false,
696        };
697        let (_, mut user) = Self::create_directory_employee(
698            doc,
699            account,
700            &provision.candidates,
701            &provision.room,
702            user,
703        )?;
704        if !provision.active {
705            Self::deactivate(doc, &mut user, &provision.actor, provision.now)?;
706            doc.sso_users
707                .insert(user.principal_id.clone(), user.clone())?;
708        }
709        Ok(user)
710    }
711
712    /// **Change a directory user**: its names and, with `active`, whether it
713    /// may log in — a deactivation gates the employee (holder
714    /// [`crate::sso::gate_holder`]) and revokes its web sessions in the same
715    /// update; a reactivation lifts that gate only (an operator's suspension
716    /// stays). Returns the row as it stands and whether `active` changed.
717    pub fn sso_user_update(
718        &self,
719        account: &str,
720        principal_id: &str,
721        patch: &SsoUserPatch,
722        actor: &str,
723        now: UnixMillis,
724    ) -> Result<(SsoUser, bool)> {
725        self.doc
726            .update(|doc| Self::user_update_in(doc, account, principal_id, patch, actor, now))
727    }
728
729    /// [`Self::sso_user_update`] inside an update (one row of [`Self::sso_import`] too).
730    fn user_update_in(
731        doc: &mut AuthDoc,
732        account: &str,
733        principal_id: &str,
734        patch: &SsoUserPatch,
735        actor: &str,
736        now: UnixMillis,
737    ) -> Result<(SsoUser, bool)> {
738        let mut user = doc
739            .sso_users
740            .get(principal_id)?
741            .filter(|u| u.account == account && !u.deleted)
742            .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
743        if let Some(name) = &patch.user_name {
744            if *name != user.user_name {
745                if let Some(held) = doc.sso_index.get(&user_index(account, name))? {
746                    if held != user.principal_id {
747                        return Err(Error::NameExists {
748                            name: name.clone(),
749                            principal_id: held,
750                        });
751                    }
752                }
753                doc.sso_index
754                    .remove(&user_index(account, &user.user_name))?;
755                doc.sso_index
756                    .insert(user_index(account, name), user.principal_id.clone())?;
757                user.user_name = name.clone();
758            }
759        }
760        if let Some(v) = &patch.display_name {
761            user.display_name = v.clone();
762        }
763        if let Some(v) = &patch.email {
764            user.email = v.clone();
765        }
766        if let Some(v) = &patch.external_id {
767            user.external_id = v.clone();
768        }
769        let mut changed = false;
770        match patch.active {
771            Some(false) if user.active => {
772                Self::deactivate(doc, &mut user, actor, now)?;
773                changed = true;
774            }
775            Some(true) if !user.active => {
776                user.active = true;
777                let holder = crate::sso::gate_holder(account);
778                if doc
779                    .gates
780                    .get(&user.principal_id)?
781                    .is_some_and(|g| g.holder == holder)
782                {
783                    doc.gates.remove(&user.principal_id)?;
784                }
785                changed = true;
786            }
787            _ => {}
788        }
789        doc.sso_users
790            .insert(user.principal_id.clone(), user.clone())?;
791        Ok((user, changed))
792    }
793
794    /// **A whole directory import in ONE update** (A34-17): every row, or
795    /// none. A row the table refuses (no room, a name taken, no free id)
796    /// refuses the import, and the refusal names that row's label. With
797    /// `commit` false the same rows run on a copy of the table and nothing is
798    /// written: the dry run sees the same refusals, room included.
799    pub fn sso_import(
800        &self,
801        account: &str,
802        rows: &[SsoImportRow],
803        actor: &str,
804        now: UnixMillis,
805        commit: bool,
806    ) -> std::result::Result<(), (String, Error)> {
807        let mut at = String::new();
808        let mut apply = |doc: &mut AuthDoc| -> Result<()> {
809            for row in rows {
810                at.clone_from(&row.label);
811                match &row.op {
812                    SsoImportOp::Create(provision) => {
813                        if provision.account != account {
814                            return Err(Error::NoSuchPrincipal(provision.account.clone()));
815                        }
816                        Self::provision_in(doc, provision)?;
817                    }
818                    SsoImportOp::Update {
819                        principal_id,
820                        patch,
821                    } => {
822                        Self::user_update_in(doc, account, principal_id, patch, actor, now)?;
823                    }
824                }
825            }
826            at.clear();
827            Ok(())
828        };
829        let result = if commit {
830            self.doc.update(&mut apply)
831        } else {
832            match self.doc.load() {
833                Ok(current) => {
834                    let mut copy = (*current).clone();
835                    apply(&mut copy)
836                }
837                Err(e) => Err(e),
838            }
839        };
840        result.map_err(|e| (at, e))
841    }
842
843    /// **Remove a user from the directory** (SCIM DELETE): deactivated —
844    /// gated, sessions revoked — and no longer listed or claimable. The
845    /// employee and everything it pushed stay; its user name is free for a
846    /// NEW person, never for this one again.
847    pub fn sso_user_delete(
848        &self,
849        account: &str,
850        principal_id: &str,
851        actor: &str,
852        now: UnixMillis,
853    ) -> Result<SsoUser> {
854        self.doc.update(|doc| {
855            let mut user = doc
856                .sso_users
857                .get(principal_id)?
858                .filter(|u| u.account == account && !u.deleted)
859                .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
860            if user.active {
861                Self::deactivate(doc, &mut user, actor, now)?;
862            }
863            user.deleted = true;
864            doc.sso_index
865                .remove(&user_index(account, &user.user_name))?;
866            for (gid, mut group) in doc.sso_groups.entries()? {
867                if group.account == account && group.members.contains(&user.principal_id) {
868                    group.members.retain(|m| *m != user.principal_id);
869                    doc.sso_groups.insert(gid, group)?;
870                }
871            }
872            doc.sso_users
873                .insert(user.principal_id.clone(), user.clone())?;
874            Ok(user)
875        })
876    }
877
878    /// Inside an update: `active = false`, every web session key of the
879    /// person revoked (SSO's and its passkeys'), and the directory's gate —
880    /// unless another holder's gate is already in force, which stays.
881    fn deactivate(
882        doc: &mut AuthDoc,
883        user: &mut SsoUser,
884        actor: &str,
885        now: UnixMillis,
886    ) -> Result<()> {
887        user.active = false;
888        let id = user.principal_id.clone();
889        let mut ended: Vec<String> = std::mem::take(&mut user.sessions);
890        for (cid, mut credential) in doc.credentials.entries()? {
891            if credential.principal_id == id && !credential.sessions.is_empty() {
892                ended.append(&mut credential.sessions);
893                doc.credentials.insert(cid, credential)?;
894            }
895        }
896        for fingerprint in ended {
897            if doc
898                .keys
899                .get(&fingerprint)?
900                .is_some_and(|k| k.principal_id == id)
901            {
902                doc.keys.remove(&fingerprint)?;
903            }
904        }
905        let holder = crate::sso::gate_holder(&user.account);
906        let held_by_another = doc
907            .gates
908            .get(&id)?
909            .is_some_and(|g| g.holder != holder && g.is_live_at(now));
910        if !held_by_another {
911            doc.gates.insert(
912                id.clone(),
913                AccountGate {
914                    account: id.clone(),
915                    reason: GateReason::Administrative,
916                    holder,
917                    actor: actor.to_owned(),
918                    note: format!("deactivated in {}'s directory", user.account),
919                    at: now,
920                    expires: None,
921                },
922            )?;
923        }
924        Ok(())
925    }
926
927    // ── SCIM bearers ──────────────────────────────────────────────────────
928
929    /// Store a new SCIM bearer (its hash).
930    pub fn create_scim_token(&self, token: ScimToken) -> Result<ScimToken> {
931        self.doc.update(|doc| {
932            if !doc.principals.contains_key(&token.account)? {
933                return Err(Error::NoSuchPrincipal(token.account.clone()));
934            }
935            if doc.scim_tokens.contains_key(&token.secret_hash)? {
936                return Err(Error::TokenExists {
937                    hash: token.secret_hash.clone(),
938                    principal_id: token.account.clone(),
939                });
940            }
941            doc.scim_tokens
942                .insert(token.secret_hash.clone(), token.clone())?;
943            Ok(token.clone())
944        })
945    }
946
947    /// The bearer a secret's hash names, if any. One probe.
948    pub fn scim_token_by_hash(&self, hash: &str) -> Result<Option<ScimToken>> {
949        self.doc.load()?.scim_tokens.get(hash)
950    }
951
952    /// An account's SCIM bearers.
953    pub fn scim_tokens_of(&self, account: &str) -> Result<Vec<ScimToken>> {
954        let mut rows: Vec<ScimToken> = self
955            .doc
956            .load()?
957            .scim_tokens
958            .values()?
959            .into_iter()
960            .filter(|t| t.account == account)
961            .collect();
962        rows.sort_by(|a, b| (a.created, &a.id).cmp(&(b.created, &b.id)));
963        Ok(rows)
964    }
965
966    /// Revoke one of an account's bearers by its public id.
967    pub fn revoke_scim_token(&self, account: &str, id: &str) -> Result<Option<ScimToken>> {
968        self.doc.update(|doc| {
969            let hit = doc
970                .scim_tokens
971                .values()?
972                .into_iter()
973                .find(|t| t.account == account && t.id == id);
974            match hit {
975                Some(token) => doc.scim_tokens.remove(&token.secret_hash),
976                None => Ok(None),
977            }
978        })
979    }
980
981    /// Note a bearer's use, at most once a minute.
982    pub fn touch_scim_token(&self, hash: &str, now: UnixMillis) -> Result<()> {
983        let stale = self.doc.load()?.scim_tokens.get(hash)?.is_some_and(|t| {
984            t.last_used
985                .is_none_or(|at| now - at >= super::LAST_USED_GRANULARITY)
986        });
987        if !stale {
988            return Ok(());
989        }
990        self.doc.update(|doc| {
991            doc.scim_tokens.modify(hash, |t| t.last_used = Some(now))?;
992            Ok(())
993        })
994    }
995
996    // ── SCIM groups ───────────────────────────────────────────────────────
997
998    /// An account's groups.
999    pub fn sso_groups_of(&self, account: &str) -> Result<Vec<SsoGroup>> {
1000        let mut rows: Vec<SsoGroup> = self
1001            .doc
1002            .load()?
1003            .sso_groups
1004            .values()?
1005            .into_iter()
1006            .filter(|g| g.account == account)
1007            .collect();
1008        rows.sort_by(|a, b| {
1009            a.display_name
1010                .cmp(&b.display_name)
1011                .then_with(|| a.id.cmp(&b.id))
1012        });
1013        Ok(rows)
1014    }
1015
1016    /// Write a group (create or replace). Members must be the account's own
1017    /// directory users; a display name is unique within the account.
1018    pub fn put_sso_group(&self, group: SsoGroup) -> Result<SsoGroup> {
1019        self.doc.update(|doc| {
1020            for member in &group.members {
1021                let ok = doc
1022                    .sso_users
1023                    .get(member)?
1024                    .is_some_and(|u| u.account == group.account && !u.deleted);
1025                if !ok {
1026                    return Err(Error::InvalidInput {
1027                        what: "group",
1028                        reason: format!("{member} is not a user of {}'s directory", group.account),
1029                    });
1030                }
1031            }
1032            for other in doc.sso_groups.values()? {
1033                if other.account == group.account
1034                    && other.id != group.id
1035                    && other.display_name.eq_ignore_ascii_case(&group.display_name)
1036                {
1037                    return Err(Error::NameExists {
1038                        name: group.display_name.clone(),
1039                        principal_id: other.id,
1040                    });
1041                }
1042            }
1043            if let Some(old) = doc.sso_groups.get(&group.id)? {
1044                if old.account != group.account {
1045                    return Err(Error::InvalidInput {
1046                        what: "group",
1047                        reason: "the group is another account's".to_owned(),
1048                    });
1049                }
1050            }
1051            doc.sso_groups.insert(group.id.clone(), group.clone())?;
1052            Ok(group.clone())
1053        })
1054    }
1055
1056    /// Remove one of an account's groups.
1057    pub fn remove_sso_group(&self, account: &str, id: &str) -> Result<Option<SsoGroup>> {
1058        self.doc.update(|doc| match doc.sso_groups.get(id)? {
1059            Some(group) if group.account == account => doc.sso_groups.remove(id),
1060            _ => Ok(None),
1061        })
1062    }
1063
1064    /// A pruned copy of the live web sessions of a directory user (for a
1065    /// listing): the ones still held and unexpired.
1066    pub fn sso_live_sessions(&self, principal_id: &str, now: UnixMillis) -> Result<usize> {
1067        let doc = self.doc.load()?;
1068        let Some(user) = doc.sso_users.get(principal_id)? else {
1069            return Ok(0);
1070        };
1071        let mut n = 0;
1072        for fp in &user.sessions {
1073            if doc.keys.get(fp)?.is_some_and(|k| k.is_valid_at(now)) {
1074                n += 1;
1075            }
1076        }
1077        Ok(n)
1078    }
1079}
1080
1081#[cfg(test)]
1082mod older_reader_tests {
1083    use std::collections::BTreeSet;
1084    use std::sync::Arc;
1085
1086    use super::super::{AuthTable, AUTH_REF};
1087    use crate::control::ControlStore;
1088    use crate::model::{Principal, PrincipalKind};
1089    use crate::sso::{OidcSettings, SsoProtocol, SsoProvider};
1090
1091    /// The identity table's keys a gunnar from BEFORE company SSO and clans
1092    /// knows (test2's line, feat/test2-next-2026-10-05) — its `AuthDoc` is
1093    /// `deny_unknown_fields`, so a key outside this list is a table it cannot
1094    /// read at all. It guards every map added since — SSO's six, and the
1095    /// enterprise list's `operator_access` — the same way.
1096    const OLDER: &[&str] = &[
1097        "principals",
1098        "keys",
1099        "grants",
1100        "tokens",
1101        "memberships",
1102        "bootstrap",
1103        "gates",
1104        "names",
1105        "employers",
1106        "rooms",
1107        "seats",
1108        "room_orders",
1109        "credentials",
1110        "invites",
1111    ];
1112
1113    fn stored_keys(store: &Arc<dyn ControlStore>) -> BTreeSet<String> {
1114        let id = store
1115            .read_doc(AUTH_REF)
1116            .unwrap()
1117            .expect("the table was written");
1118        let bytes = store.get_blob(&id).unwrap().expect("its blob");
1119        let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1120        doc.as_object()
1121            .expect("an object")
1122            .keys()
1123            .cloned()
1124            .collect()
1125    }
1126
1127    fn provider(account: &str) -> SsoProvider {
1128        SsoProvider {
1129            account: account.to_owned(),
1130            protocol: SsoProtocol::Oidc,
1131            display_name: format!("{account} corp"),
1132            domains: vec![format!("{account}.example")],
1133            oidc: Some(OidcSettings {
1134                issuer: "https://idp.example.test".into(),
1135                client_id: "c".into(),
1136                ..Default::default()
1137            }),
1138            saml: None,
1139            require_sso: false,
1140            require_passkey: false,
1141            console_origin: "https://gunnar.example".into(),
1142            updated: 1,
1143            updated_by: account.to_owned(),
1144            verified_domains: Vec::new(),
1145            jit: String::new(),
1146        }
1147    }
1148
1149    /// ★ **A table that never used SSO is one an older gunnar still reads**
1150    /// (a downgrade, or a twin still on the older line): SSO's maps are
1151    /// written only once there is something in them, as clans' are — and
1152    /// gone again once the last row is.
1153    #[test]
1154    fn sso_maps_are_written_only_when_used() {
1155        let store = crate::memory_control_store().expect("store");
1156        let auth = AuthTable::new(Arc::clone(&store)).expect("table");
1157        auth.add_principal(Principal::new("acme", "acme", PrincipalKind::User).unwrap())
1158            .expect("account");
1159        let keys = stored_keys(&store);
1160        let unknown: Vec<&String> = keys
1161            .iter()
1162            .filter(|k| !OLDER.contains(&k.as_str()))
1163            .collect();
1164        assert!(
1165            unknown.is_empty(),
1166            "a table without SSO carries keys an older gunnar refuses: {unknown:?}"
1167        );
1168
1169        auth.set_sso_provider(provider("acme")).expect("provider");
1170        // The index holds proven domains only (A34-5).
1171        auth.verify_sso_domain("acme", "acme.example")
1172            .expect("proven");
1173        let keys = stored_keys(&store);
1174        assert!(
1175            keys.contains("sso_providers") && keys.contains("sso_domains"),
1176            "a provider is written: {keys:?}"
1177        );
1178
1179        auth.remove_sso_provider("acme").expect("removed");
1180        let keys = stored_keys(&store);
1181        let unknown: Vec<&String> = keys
1182            .iter()
1183            .filter(|k| !OLDER.contains(&k.as_str()))
1184            .collect();
1185        assert!(
1186            unknown.is_empty(),
1187            "the provider is gone, its maps must be too: {unknown:?}"
1188        );
1189    }
1190}