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