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
109fn subject_index(account: &str, subject: &str) -> String {
110    format!("s\u{1f}{account}\u{1f}{subject}")
111}
112
113fn user_index(account: &str, user_name: &str) -> String {
114    format!("u\u{1f}{account}\u{1f}{user_name}")
115}
116
117fn refused(reason: impl Into<String>) -> Error {
118    Error::SsoRefused(reason.into())
119}
120
121/// **May this login claim the unbound provisioned user `user` (A34-2)?** The
122/// basis, for the audit line, or the refusal by name. Two bases only:
123///
124/// * `external_id` — the admin bound the person: the row's `externalId` is
125///   one of the IdP's stable ids for this login (OIDC `sub`/`oid`, SAML
126///   persistent NameID);
127/// * `verified_email` — the IdP ASSERTS the e-mail is verified, it is the
128///   row's address (or user name), and its domain is one of the account's
129///   e-mail domains.
130///
131/// Never the user name alone: `preferred_username` (and an unverified e-mail)
132/// is editable at many IdPs, so it found the row and proves nothing.
133///
134/// **A refusal names nobody** (RA4-2): it is answered to an unauthenticated
135/// caller and written, as one `reason`, into the append-only audit chain and the
136/// warn log. So it carries its code ([`claim_refused`]) and a plain sentence,
137/// never the person's e-mail, user name or domain, nor the employee's id.
138fn claim_basis(user: &SsoUser, login: &SsoLogin, domains: &[String]) -> Result<&'static str> {
139    let external_id = user.external_id.trim();
140    if !external_id.is_empty() && login.idp_subjects.iter().any(|s| s.trim() == external_id) {
141        return Ok("external_id");
142    }
143    let email = login.email.trim().to_ascii_lowercase();
144    let row = [user.email.trim().to_ascii_lowercase(), user.user_name.trim().to_ascii_lowercase()];
145    if !login.email_verified || email.is_empty() {
146        return Err(claim_refused(
147            "sso-claim-unverified",
148            "the identity provider did not assert a verified e-mail (email_verified), and the \
149             employee's externalId is not the person's id there",
150        ));
151    }
152    if !row.contains(&email) {
153        return Err(claim_refused("sso-claim-not-address", "the verified e-mail is not the employee's address"));
154    }
155    let domain = email.rsplit_once('@').map(|(_, d)| d).unwrap_or_default();
156    if domain.is_empty() || !domains.iter().any(|d| d.trim().eq_ignore_ascii_case(domain)) {
157        return Err(claim_refused(
158            "sso-claim-domain",
159            "the verified e-mail's domain is not one of the account's e-mail domains",
160        ));
161    }
162    Ok("verified_email")
163}
164
165/// **A claim refusal, by code** (RA4-2): `[code] this sign-in cannot claim a
166/// provisioned employee: <why>.` plus how the admin binds one. No identifier.
167fn claim_refused(code: &str, why: &str) -> Error {
168    refused(format!(
169        "[{code}] this sign-in cannot claim a provisioned employee: {why}. A user name \
170         (preferred_username) alone never claims; the account admin can bind the employee by \
171         setting its externalId to the person's id at the identity provider"
172    ))
173}
174
175impl AuthTable {
176    /// Does this `sso_index` key belong to `account`?
177    pub(super) fn sso_index_of_account(key: &str, account: &str) -> bool {
178        let mut parts = key.splitn(3, '\u{1f}');
179        let _kind = parts.next();
180        parts.next() == Some(account)
181    }
182
183    // ── providers ─────────────────────────────────────────────────────────
184
185    /// An account's identity provider, if it has one.
186    pub fn sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
187        self.doc.load()?.sso_providers.get(account)
188    }
189
190    /// Every account's identity provider. A listing.
191    pub fn sso_providers(&self) -> Result<Vec<SsoProvider>> {
192        self.doc.load()?.sso_providers.values()
193    }
194
195    /// The account an email domain leads to.
196    pub fn sso_account_for_domain(&self, domain: &str) -> Result<Option<String>> {
197        self.doc.load()?.sso_domains.get(domain)
198    }
199
200    /// **Configure an account's identity provider** (replacing the one it
201    /// had). The account must be a principal; its domains must be free or
202    /// already its own — one domain, one company.
203    pub fn set_sso_provider(&self, provider: SsoProvider) -> Result<SsoProvider> {
204        self.doc.update(|doc| {
205            if !doc.principals.contains_key(&provider.account)? {
206                return Err(Error::NoSuchPrincipal(provider.account.clone()));
207            }
208            if doc.employers.contains_key(&provider.account)? {
209                return Err(Error::InvalidInput {
210                    what: "identity provider",
211                    reason: format!(
212                        "{} is an employee, not an account: only an account has an identity provider",
213                        provider.account
214                    ),
215                });
216            }
217            for domain in &provider.domains {
218                if let Some(holder) = doc.sso_domains.get(domain)? {
219                    if holder != provider.account {
220                        return Err(Error::InvalidInput {
221                            what: "identity provider",
222                            reason: format!("the domain {domain} already leads to another account's sign-in"),
223                        });
224                    }
225                }
226            }
227            if let Some(old) = doc.sso_providers.get(&provider.account)? {
228                for domain in old.domains {
229                    doc.sso_domains.remove(&domain)?;
230                }
231            }
232            for domain in &provider.domains {
233                doc.sso_domains.insert(domain.clone(), provider.account.clone())?;
234            }
235            doc.sso_providers.insert(provider.account.clone(), provider.clone())?;
236            Ok(provider.clone())
237        })
238    }
239
240    /// Remove an account's identity provider. Its directory stays: its
241    /// employees are still employees (and a re-configured IdP finds them).
242    pub fn remove_sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
243        self.doc.update(|doc| {
244            let removed = doc.sso_providers.remove(account)?;
245            if let Some(provider) = &removed {
246                for domain in &provider.domains {
247                    doc.sso_domains.remove(domain)?;
248                }
249            }
250            Ok(removed)
251        })
252    }
253
254    // ── the directory ─────────────────────────────────────────────────────
255
256    /// One directory user, by principal id.
257    pub fn sso_user(&self, principal_id: &str) -> Result<Option<SsoUser>> {
258        self.doc.load()?.sso_users.get(principal_id)
259    }
260
261    /// An account's directory (deleted users included; the caller filters).
262    pub fn sso_users_of(&self, account: &str) -> Result<Vec<SsoUser>> {
263        let mut rows: Vec<SsoUser> = self
264            .doc
265            .load()?
266            .sso_users
267            .values()?
268            .into_iter()
269            .filter(|u| u.account == account)
270            .collect();
271        rows.sort_by(|a, b| {
272            a.user_name
273                .cmp(&b.user_name)
274                .then_with(|| a.principal_id.cmp(&b.principal_id))
275        });
276        Ok(rows)
277    }
278
279    /// The principal a subject is bound to in `account`, if any.
280    pub fn sso_principal_for_subject(
281        &self,
282        account: &str,
283        subject: &str,
284    ) -> Result<Option<String>> {
285        self.doc
286            .load()?
287            .sso_index
288            .get(&subject_index(account, subject))
289    }
290
291    /// The first free candidate id: no principal of that id, no principal
292    /// named so.
293    fn free_candidate(doc: &AuthDoc, candidates: &[Principal]) -> Result<Option<Principal>> {
294        for candidate in candidates {
295            if doc.principals.contains_key(&candidate.id)? {
296                continue;
297            }
298            if Self::refuse_taken_name(doc, &candidate.name, &candidate.id).is_err() {
299                continue;
300            }
301            if Self::resolve_name(doc, &candidate.id)?.is_some() {
302                continue;
303            }
304            return Ok(Some(candidate.clone()));
305        }
306        Ok(None)
307    }
308
309    /// **A NEW employee for the directory**, inside the caller's update: the
310    /// room planned (an open seat first), the employee written by the one
311    /// writer ([`AuthTable::write_employee`]), its directory row and index.
312    fn create_directory_employee(
313        doc: &mut AuthDoc,
314        account: &str,
315        candidates: &[Principal],
316        room: &RoomAsk,
317        user: SsoUser,
318    ) -> Result<(Principal, SsoUser)> {
319        let principal = Self::free_candidate(doc, candidates)?.ok_or_else(|| {
320            refused(format!(
321                "no free id for {:?} in {account}: every candidate is taken",
322                user.user_name
323            ))
324        })?;
325        let principal = Principal {
326            created: user.created,
327            ..principal
328        };
329        let lots: Vec<SeatLot> = doc.seats.get(account)?.unwrap_or_default();
330        let ask = RoomAsk {
331            from_seat: lots.iter().any(|lot| lot.open() > 0),
332            seats: 0,
333            dry_run: false,
334            order: String::new(),
335            ..room.clone()
336        };
337        let plan = Self::plan_room(doc, account, Some(&principal), &ask)?;
338        if plan.answer.shortfall_bytes > 0 {
339            return Err(Error::RoomShort {
340                account: account.to_owned(),
341                need: plan.answer.need_bytes,
342                unused: plan.answer.unused_bytes.unwrap_or(0),
343                short: plan.answer.shortfall_bytes,
344            });
345        }
346        let mut seats = lots;
347        Self::write_employee(
348            doc,
349            account,
350            &principal,
351            plan.lot,
352            "",
353            plan.answer.room_bytes,
354            &mut seats,
355        )?;
356        if !seats.is_empty() {
357            doc.seats.insert(account.to_owned(), seats)?;
358        }
359        let user = SsoUser {
360            principal_id: principal.id.clone(),
361            ..user
362        };
363        doc.sso_index
364            .insert(user_index(account, &user.user_name), principal.id.clone())?;
365        if let Some(subject) = &user.subject {
366            doc.sso_index
367                .insert(subject_index(account, subject), principal.id.clone())?;
368        }
369        doc.sso_users.insert(principal.id.clone(), user.clone())?;
370        Ok((principal, user))
371    }
372
373    /// **Commit one verified SSO login.** In one update:
374    ///
375    /// 1. the subject is looked up in THIS account's index; a bound user must
376    ///    be an employee of the account and active;
377    /// 2. else a provisioned user (SCIM/CSV) of the IdP's user name that no
378    ///    subject has claimed yet is bound to this subject — but ONLY on the
379    ///    admin's binding (its `externalId` is one of the IdP's stable ids for
380    ///    the person) or on an e-mail the IdP asserts is verified, that is the
381    ///    row's address, in one of the account's e-mail domains (A34-2). A
382    ///    user name (`preferred_username`) alone never claims; a user name
383    ///    another subject already holds is refused — never re-pointed;
384    /// 3. else a NEW employee is created (never an existing principal
385    ///    attached), bound, with room;
386    ///
387    /// and the session key, when asked, is enrolled on the employee.
388    pub fn sso_login(&self, login: &SsoLogin) -> Result<SsoLoginDone> {
389        let session_key = match &login.session {
390            Some((openssh, expires)) => {
391                let parsed = super::parse_public_key(openssh)?;
392                let fingerprint = super::Fingerprint::of_key(&parsed);
393                Some(PublicKey {
394                    fingerprint_sha256: fingerprint.as_str().to_owned(),
395                    algo: parsed.algorithm().as_str().to_owned(),
396                    principal_id: String::new(),
397                    added: login.now,
398                    last_used: None,
399                    expires: Some(*expires),
400                    openssh: parsed.to_openssh().map_err(Error::from)?.trim().to_owned(),
401                })
402            }
403            None => None,
404        };
405        let account = login.account.as_str();
406        self.doc.update(|doc| {
407            if !doc.sso_providers.contains_key(account)? {
408                return Err(refused(format!("{account} has no identity provider")));
409            }
410            let mut created = false;
411            let mut claimed = false;
412            let mut claimed_by = None;
413            let bound = doc.sso_index.get(&subject_index(account, &login.subject))?;
414            let (principal, mut user) = match bound {
415                Some(id) => {
416                    let user = doc
417                        .sso_users
418                        .get(&id)?
419                        .filter(|u| u.account == account)
420                        .ok_or_else(|| refused("[sso-subject-elsewhere] this identity is bound to an employee outside this account's directory".to_owned()))?;
421                    let principal = doc.principals.get(&id)?.ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
422                    (principal, user)
423                }
424                None => match doc.sso_index.get(&user_index(account, &login.user_name))? {
425                    Some(id) => {
426                        let mut user = doc
427                            .sso_users
428                            .get(&id)?
429                            .filter(|u| u.account == account && !u.deleted)
430                            .ok_or_else(|| refused("[sso-claim-not-member] this sign-in's user is not in the account's directory".to_owned()))?;
431                        if user.subject.is_some() {
432                            return Err(refused(
433                                "[sso-claim-bound] this directory user is already bound to another identity at \
434                                 the IdP; it is never re-pointed — the account admin removes it first"
435                                    .to_owned(),
436                            ));
437                        }
438                        if user.source == SsoSource::Jit {
439                            return Err(refused("[sso-claim-jit] this directory user was created by another login".to_owned()));
440                        }
441                        let domains = doc
442                            .sso_providers
443                            .get(account)?
444                            .map(|p| p.domains)
445                            .unwrap_or_default();
446                        claimed_by = Some(claim_basis(&user, login, &domains)?);
447                        user.subject = Some(login.subject.clone());
448                        doc.sso_index.insert(subject_index(account, &login.subject), id.clone())?;
449                        claimed = true;
450                        let principal = doc.principals.get(&id)?.ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
451                        (principal, user)
452                    }
453                    None => {
454                        let user = SsoUser {
455                            principal_id: String::new(),
456                            account: account.to_owned(),
457                            user_name: login.user_name.clone(),
458                            display_name: login.display_name.clone(),
459                            email: login.email.clone(),
460                            external_id: String::new(),
461                            subject: Some(login.subject.clone()),
462                            active: true,
463                            source: SsoSource::Jit,
464                            created: login.now,
465                            last_login: None,
466                            sessions: Vec::new(),
467                            deleted: false,
468                        };
469                        created = true;
470                        Self::create_directory_employee(doc, account, &login.candidates, &login.room, user)?
471                    }
472                },
473            };
474            // Never across accounts, and only an employee: the index is the
475            // account's own, and this is the second lock on the same door.
476            if doc.employers.get(&principal.id)?.as_deref() != Some(account) {
477                return Err(refused(format!("{} is not an employee of {account}", principal.id)));
478            }
479            if !user.active || user.deleted {
480                return Err(refused(format!(
481                    "{} is deactivated in {account}'s directory",
482                    principal.id
483                )));
484            }
485            if Self::gated(doc, &principal.id)? {
486                return Err(refused(format!("{} is locked out", principal.id)));
487            }
488            if !created {
489                if !login.display_name.is_empty() {
490                    user.display_name = login.display_name.clone();
491                }
492                if !login.email.is_empty() {
493                    user.email = login.email.clone();
494                }
495            }
496            user.last_login = Some(login.now);
497            let mut enrolled = None;
498            if let Some(key) = &session_key {
499                if let Some(existing) = doc.keys.get(&key.fingerprint_sha256)? {
500                    return Err(Error::KeyExists {
501                        fingerprint: key.fingerprint_sha256.clone(),
502                        principal_id: existing.principal_id,
503                    });
504                }
505                let key = PublicKey { principal_id: principal.id.clone(), ..key.clone() };
506                let mut live = Vec::with_capacity(user.sessions.len() + 1);
507                for held in std::mem::take(&mut user.sessions) {
508                    if doc.keys.get(&held)?.is_some_and(|k| k.is_valid_at(login.now)) {
509                        live.push(held);
510                    }
511                }
512                live.push(key.fingerprint_sha256.clone());
513                user.sessions = live;
514                doc.keys.insert(key.fingerprint_sha256.clone(), key.clone())?;
515                enrolled = Some(key);
516            }
517            doc.sso_users.insert(principal.id.clone(), user.clone())?;
518            Ok(SsoLoginDone { principal, user, created, claimed, claimed_by, session: enrolled })
519        })
520    }
521
522    /// **Provision a NEW employee** from the company's directory (SCIM
523    /// create, a CSV row): unbound until its first login claims it. A user
524    /// name the account's directory already holds is refused
525    /// ([`Error::NameExists`]): SCIM's `uniqueness`.
526    pub fn sso_provision(&self, provision: &SsoProvision) -> Result<SsoUser> {
527        let account = provision.account.as_str();
528        self.doc.update(|doc| {
529            if !doc.principals.contains_key(account)? {
530                return Err(Error::NoSuchPrincipal(account.to_owned()));
531            }
532            if let Some(held) = doc
533                .sso_index
534                .get(&user_index(account, &provision.user_name))?
535            {
536                return Err(Error::NameExists {
537                    name: provision.user_name.clone(),
538                    principal_id: held,
539                });
540            }
541            let user = SsoUser {
542                principal_id: String::new(),
543                account: account.to_owned(),
544                user_name: provision.user_name.clone(),
545                display_name: provision.display_name.clone(),
546                email: provision.email.clone(),
547                external_id: provision.external_id.clone(),
548                subject: None,
549                active: true,
550                source: provision.source,
551                created: provision.now,
552                last_login: None,
553                sessions: Vec::new(),
554                deleted: false,
555            };
556            let (_, mut user) = Self::create_directory_employee(
557                doc,
558                account,
559                &provision.candidates,
560                &provision.room,
561                user,
562            )?;
563            if !provision.active {
564                Self::deactivate(doc, &mut user, &provision.actor, provision.now)?;
565                doc.sso_users
566                    .insert(user.principal_id.clone(), user.clone())?;
567            }
568            Ok(user)
569        })
570    }
571
572    /// **Change a directory user**: its names and, with `active`, whether it
573    /// may log in — a deactivation gates the employee (holder
574    /// [`crate::sso::gate_holder`]) and revokes its web sessions in the same
575    /// update; a reactivation lifts that gate only (an operator's suspension
576    /// stays). Returns the row as it stands and whether `active` changed.
577    pub fn sso_user_update(
578        &self,
579        account: &str,
580        principal_id: &str,
581        patch: &SsoUserPatch,
582        actor: &str,
583        now: UnixMillis,
584    ) -> Result<(SsoUser, bool)> {
585        self.doc.update(|doc| {
586            let mut user = doc
587                .sso_users
588                .get(principal_id)?
589                .filter(|u| u.account == account && !u.deleted)
590                .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
591            if let Some(name) = &patch.user_name {
592                if *name != user.user_name {
593                    if let Some(held) = doc.sso_index.get(&user_index(account, name))? {
594                        if held != user.principal_id {
595                            return Err(Error::NameExists {
596                                name: name.clone(),
597                                principal_id: held,
598                            });
599                        }
600                    }
601                    doc.sso_index
602                        .remove(&user_index(account, &user.user_name))?;
603                    doc.sso_index
604                        .insert(user_index(account, name), user.principal_id.clone())?;
605                    user.user_name = name.clone();
606                }
607            }
608            if let Some(v) = &patch.display_name {
609                user.display_name = v.clone();
610            }
611            if let Some(v) = &patch.email {
612                user.email = v.clone();
613            }
614            if let Some(v) = &patch.external_id {
615                user.external_id = v.clone();
616            }
617            let mut changed = false;
618            match patch.active {
619                Some(false) if user.active => {
620                    Self::deactivate(doc, &mut user, actor, now)?;
621                    changed = true;
622                }
623                Some(true) if !user.active => {
624                    user.active = true;
625                    let holder = crate::sso::gate_holder(account);
626                    if doc
627                        .gates
628                        .get(&user.principal_id)?
629                        .is_some_and(|g| g.holder == holder)
630                    {
631                        doc.gates.remove(&user.principal_id)?;
632                    }
633                    changed = true;
634                }
635                _ => {}
636            }
637            doc.sso_users
638                .insert(user.principal_id.clone(), user.clone())?;
639            Ok((user, changed))
640        })
641    }
642
643    /// **Remove a user from the directory** (SCIM DELETE): deactivated —
644    /// gated, sessions revoked — and no longer listed or claimable. The
645    /// employee and everything it pushed stay; its user name is free for a
646    /// NEW person, never for this one again.
647    pub fn sso_user_delete(
648        &self,
649        account: &str,
650        principal_id: &str,
651        actor: &str,
652        now: UnixMillis,
653    ) -> Result<SsoUser> {
654        self.doc.update(|doc| {
655            let mut user = doc
656                .sso_users
657                .get(principal_id)?
658                .filter(|u| u.account == account && !u.deleted)
659                .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
660            if user.active {
661                Self::deactivate(doc, &mut user, actor, now)?;
662            }
663            user.deleted = true;
664            doc.sso_index
665                .remove(&user_index(account, &user.user_name))?;
666            for (gid, mut group) in doc.sso_groups.entries()? {
667                if group.account == account && group.members.contains(&user.principal_id) {
668                    group.members.retain(|m| *m != user.principal_id);
669                    doc.sso_groups.insert(gid, group)?;
670                }
671            }
672            doc.sso_users
673                .insert(user.principal_id.clone(), user.clone())?;
674            Ok(user)
675        })
676    }
677
678    /// Inside an update: `active = false`, every web session key of the
679    /// person revoked (SSO's and its passkeys'), and the directory's gate —
680    /// unless another holder's gate is already in force, which stays.
681    fn deactivate(
682        doc: &mut AuthDoc,
683        user: &mut SsoUser,
684        actor: &str,
685        now: UnixMillis,
686    ) -> Result<()> {
687        user.active = false;
688        let id = user.principal_id.clone();
689        let mut ended: Vec<String> = std::mem::take(&mut user.sessions);
690        for (cid, mut credential) in doc.credentials.entries()? {
691            if credential.principal_id == id && !credential.sessions.is_empty() {
692                ended.append(&mut credential.sessions);
693                doc.credentials.insert(cid, credential)?;
694            }
695        }
696        for fingerprint in ended {
697            if doc
698                .keys
699                .get(&fingerprint)?
700                .is_some_and(|k| k.principal_id == id)
701            {
702                doc.keys.remove(&fingerprint)?;
703            }
704        }
705        let holder = crate::sso::gate_holder(&user.account);
706        let held_by_another = doc
707            .gates
708            .get(&id)?
709            .is_some_and(|g| g.holder != holder && g.is_live_at(now));
710        if !held_by_another {
711            doc.gates.insert(
712                id.clone(),
713                AccountGate {
714                    account: id.clone(),
715                    reason: GateReason::Administrative,
716                    holder,
717                    actor: actor.to_owned(),
718                    note: format!("deactivated in {}'s directory", user.account),
719                    at: now,
720                    expires: None,
721                },
722            )?;
723        }
724        Ok(())
725    }
726
727    // ── SCIM bearers ──────────────────────────────────────────────────────
728
729    /// Store a new SCIM bearer (its hash).
730    pub fn create_scim_token(&self, token: ScimToken) -> Result<ScimToken> {
731        self.doc.update(|doc| {
732            if !doc.principals.contains_key(&token.account)? {
733                return Err(Error::NoSuchPrincipal(token.account.clone()));
734            }
735            if doc.scim_tokens.contains_key(&token.secret_hash)? {
736                return Err(Error::TokenExists {
737                    hash: token.secret_hash.clone(),
738                    principal_id: token.account.clone(),
739                });
740            }
741            doc.scim_tokens
742                .insert(token.secret_hash.clone(), token.clone())?;
743            Ok(token.clone())
744        })
745    }
746
747    /// The bearer a secret's hash names, if any. One probe.
748    pub fn scim_token_by_hash(&self, hash: &str) -> Result<Option<ScimToken>> {
749        self.doc.load()?.scim_tokens.get(hash)
750    }
751
752    /// An account's SCIM bearers.
753    pub fn scim_tokens_of(&self, account: &str) -> Result<Vec<ScimToken>> {
754        let mut rows: Vec<ScimToken> = self
755            .doc
756            .load()?
757            .scim_tokens
758            .values()?
759            .into_iter()
760            .filter(|t| t.account == account)
761            .collect();
762        rows.sort_by(|a, b| (a.created, &a.id).cmp(&(b.created, &b.id)));
763        Ok(rows)
764    }
765
766    /// Revoke one of an account's bearers by its public id.
767    pub fn revoke_scim_token(&self, account: &str, id: &str) -> Result<Option<ScimToken>> {
768        self.doc.update(|doc| {
769            let hit = doc
770                .scim_tokens
771                .values()?
772                .into_iter()
773                .find(|t| t.account == account && t.id == id);
774            match hit {
775                Some(token) => doc.scim_tokens.remove(&token.secret_hash),
776                None => Ok(None),
777            }
778        })
779    }
780
781    /// Note a bearer's use, at most once a minute.
782    pub fn touch_scim_token(&self, hash: &str, now: UnixMillis) -> Result<()> {
783        let stale = self.doc.load()?.scim_tokens.get(hash)?.is_some_and(|t| {
784            t.last_used
785                .is_none_or(|at| now - at >= super::LAST_USED_GRANULARITY)
786        });
787        if !stale {
788            return Ok(());
789        }
790        self.doc.update(|doc| {
791            doc.scim_tokens.modify(hash, |t| t.last_used = Some(now))?;
792            Ok(())
793        })
794    }
795
796    // ── SCIM groups ───────────────────────────────────────────────────────
797
798    /// An account's groups.
799    pub fn sso_groups_of(&self, account: &str) -> Result<Vec<SsoGroup>> {
800        let mut rows: Vec<SsoGroup> = self
801            .doc
802            .load()?
803            .sso_groups
804            .values()?
805            .into_iter()
806            .filter(|g| g.account == account)
807            .collect();
808        rows.sort_by(|a, b| {
809            a.display_name
810                .cmp(&b.display_name)
811                .then_with(|| a.id.cmp(&b.id))
812        });
813        Ok(rows)
814    }
815
816    /// Write a group (create or replace). Members must be the account's own
817    /// directory users; a display name is unique within the account.
818    pub fn put_sso_group(&self, group: SsoGroup) -> Result<SsoGroup> {
819        self.doc.update(|doc| {
820            for member in &group.members {
821                let ok = doc
822                    .sso_users
823                    .get(member)?
824                    .is_some_and(|u| u.account == group.account && !u.deleted);
825                if !ok {
826                    return Err(Error::InvalidInput {
827                        what: "group",
828                        reason: format!("{member} is not a user of {}'s directory", group.account),
829                    });
830                }
831            }
832            for other in doc.sso_groups.values()? {
833                if other.account == group.account
834                    && other.id != group.id
835                    && other.display_name.eq_ignore_ascii_case(&group.display_name)
836                {
837                    return Err(Error::NameExists {
838                        name: group.display_name.clone(),
839                        principal_id: other.id,
840                    });
841                }
842            }
843            if let Some(old) = doc.sso_groups.get(&group.id)? {
844                if old.account != group.account {
845                    return Err(Error::InvalidInput {
846                        what: "group",
847                        reason: "the group is another account's".to_owned(),
848                    });
849                }
850            }
851            doc.sso_groups.insert(group.id.clone(), group.clone())?;
852            Ok(group.clone())
853        })
854    }
855
856    /// Remove one of an account's groups.
857    pub fn remove_sso_group(&self, account: &str, id: &str) -> Result<Option<SsoGroup>> {
858        self.doc.update(|doc| match doc.sso_groups.get(id)? {
859            Some(group) if group.account == account => doc.sso_groups.remove(id),
860            _ => Ok(None),
861        })
862    }
863
864    /// A pruned copy of the live web sessions of a directory user (for a
865    /// listing): the ones still held and unexpired.
866    pub fn sso_live_sessions(&self, principal_id: &str, now: UnixMillis) -> Result<usize> {
867        let doc = self.doc.load()?;
868        let Some(user) = doc.sso_users.get(principal_id)? else {
869            return Ok(0);
870        };
871        let mut n = 0;
872        for fp in &user.sessions {
873            if doc.keys.get(fp)?.is_some_and(|k| k.is_valid_at(now)) {
874                n += 1;
875            }
876        }
877        Ok(n)
878    }
879}
880
881#[cfg(test)]
882mod older_reader_tests {
883    use std::collections::BTreeSet;
884    use std::sync::Arc;
885
886    use super::super::{AuthTable, AUTH_REF};
887    use crate::control::ControlStore;
888    use crate::model::{Principal, PrincipalKind};
889    use crate::sso::{OidcSettings, SsoProtocol, SsoProvider};
890
891    /// The identity table's keys a gunnar from BEFORE company SSO and clans
892    /// knows (test2's line, feat/test2-next-2026-10-05) — its `AuthDoc` is
893    /// `deny_unknown_fields`, so a key outside this list is a table it cannot
894    /// read at all. It guards every map added since — SSO's six, and the
895    /// enterprise list's `operator_access` — the same way.
896    const OLDER: &[&str] = &[
897        "principals", "keys", "grants", "tokens", "memberships", "bootstrap", "gates", "names",
898        "employers", "rooms", "seats", "room_orders", "credentials", "invites",
899    ];
900
901    fn stored_keys(store: &Arc<dyn ControlStore>) -> BTreeSet<String> {
902        let id = store.read_doc(AUTH_REF).unwrap().expect("the table was written");
903        let bytes = store.get_blob(&id).unwrap().expect("its blob");
904        let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
905        doc.as_object().expect("an object").keys().cloned().collect()
906    }
907
908    fn provider(account: &str) -> SsoProvider {
909        SsoProvider {
910            account: account.to_owned(),
911            protocol: SsoProtocol::Oidc,
912            display_name: format!("{account} corp"),
913            domains: vec![format!("{account}.example")],
914            oidc: Some(OidcSettings {
915                issuer: "https://idp.example.test".into(),
916                client_id: "c".into(),
917                ..Default::default()
918            }),
919            saml: None,
920            require_sso: false,
921            require_passkey: false,
922            console_origin: "https://gunnar.example".into(),
923            updated: 1,
924            updated_by: account.to_owned(),
925        }
926    }
927
928    /// ★ **A table that never used SSO is one an older gunnar still reads**
929    /// (a downgrade, or a twin still on the older line): SSO's maps are
930    /// written only once there is something in them, as clans' are — and
931    /// gone again once the last row is.
932    #[test]
933    fn sso_maps_are_written_only_when_used() {
934        let store = crate::memory_control_store().expect("store");
935        let auth = AuthTable::new(Arc::clone(&store)).expect("table");
936        auth.add_principal(Principal::new("acme", "acme", PrincipalKind::User).unwrap())
937            .expect("account");
938        let keys = stored_keys(&store);
939        let unknown: Vec<&String> = keys.iter().filter(|k| !OLDER.contains(&k.as_str())).collect();
940        assert!(
941            unknown.is_empty(),
942            "a table without SSO carries keys an older gunnar refuses: {unknown:?}"
943        );
944
945        auth.set_sso_provider(provider("acme")).expect("provider");
946        let keys = stored_keys(&store);
947        assert!(
948            keys.contains("sso_providers") && keys.contains("sso_domains"),
949            "a provider is written: {keys:?}"
950        );
951
952        auth.remove_sso_provider("acme").expect("removed");
953        let keys = stored_keys(&store);
954        let unknown: Vec<&String> = keys.iter().filter(|k| !OLDER.contains(&k.as_str())).collect();
955        assert!(unknown.is_empty(), "the provider is gone, its maps must be too: {unknown:?}");
956    }
957}