Skip to main content

gunnar_server/control_plane/
sso_plane.rs

1//! **Company SSO on the identity table** — [`gunnar_grpc::sso::plane::SsoPlane`]
2//! for [`CoreControlPlane`]: the rows are `gunnar_core::AuthTable`'s (one
3//! compare-and-swap with the employee they name), the OIDC client secret is
4//! sealed in the box's secret store (the KEK, replicated sealed to the twin),
5//! and a new employee's id is checked against the namespaces here, where the
6//! catalog is.
7
8use gunnar_core::sso as core_sso;
9use gunnar_grpc::seam::{ControlError, ControlResult};
10use gunnar_grpc::sso::plane::{
11    DirUser, GroupRow, ProvisionAsk, SecretChange, SsoLoginAsk, SsoLoginOutcome, SsoPlane,
12    UserPatch,
13};
14use gunnar_grpc::sso::scim::{Bearer, ImportRow, Scope};
15use gunnar_proto::{
16    DirectoryUser, OidcSettings, SamlSettings, ScimToken, SsoProtocol, SsoProvider,
17};
18
19use super::{internal, principal_out, CoreControlPlane};
20
21/// A refusal the table made, as the seam says it.
22fn sso_error(err: gunnar_core::Error) -> ControlError {
23    use gunnar_core::Error as E;
24    match err {
25        E::SsoRefused(m) => ControlError::PermissionDenied(m),
26        e @ E::RoomShort { .. } => ControlError::FailedPrecondition(format!(
27            "{e} — the account's admin adds room or users before a new employee can sign in"
28        )),
29        e @ E::NoOpenSeat(_) => ControlError::FailedPrecondition(e.to_string()),
30        e @ E::NameExists { .. } => ControlError::AlreadyExists(e.to_string()),
31        e @ E::KeyExists { .. } => ControlError::AlreadyExists(e.to_string()),
32        E::NoSuchPrincipal(id) => ControlError::NotFound(format!("principal {id}")),
33        e @ E::InvalidInput { .. } => ControlError::InvalidArgument(e.to_string()),
34        other => internal(other),
35    }
36}
37
38fn provider_in(p: &SsoProvider) -> ControlResult<core_sso::SsoProvider> {
39    let protocol = match SsoProtocol::try_from(p.protocol).unwrap_or(SsoProtocol::Unspecified) {
40        SsoProtocol::Oidc => core_sso::SsoProtocol::Oidc,
41        SsoProtocol::Saml => core_sso::SsoProtocol::Saml,
42        SsoProtocol::Unspecified => {
43            return Err(ControlError::InvalidArgument(
44                "`protocol` is OIDC or SAML".into(),
45            ))
46        }
47    };
48    Ok(core_sso::SsoProvider {
49        account: p.account.clone(),
50        protocol,
51        display_name: p.display_name.clone(),
52        domains: p.domains.clone(),
53        oidc: p.oidc.as_ref().map(|o| core_sso::OidcSettings {
54            issuer: o.issuer.clone(),
55            client_id: o.client_id.clone(),
56            scopes: o.scopes.clone(),
57            username_claim: o.username_claim.clone(),
58            display_name_claim: o.display_name_claim.clone(),
59            idp_ca: core_sso::idp_ca_stored(&gunnar_grpc::sso::idp_ca::idp_ca_blocks(
60                &o.idp_ca_pem,
61            )),
62        }),
63        saml: p.saml.as_ref().map(|s| core_sso::SamlSettings {
64            idp_entity_id: s.idp_entity_id.clone(),
65            idp_sso_url: s.idp_sso_url.clone(),
66            idp_certificates: s.idp_certificates.clone(),
67            metadata_url: s.idp_metadata_url.clone(),
68            username_attribute: s.username_attribute.clone(),
69            display_name_attribute: s.display_name_attribute.clone(),
70        }),
71        require_sso: p.require_sso,
72        require_passkey: p.require_passkey,
73        console_origin: p.console_origin.clone(),
74        updated: p.updated_at_unix_ms,
75        updated_by: p.updated_by.clone(),
76        // The table keeps the proofs itself (A34-5); an input names none.
77        verified_domains: Vec::new(),
78        jit: match gunnar_proto::SsoJit::try_from(p.jit).unwrap_or(gunnar_proto::SsoJit::Off) {
79            gunnar_proto::SsoJit::Open => String::new(),
80            gunnar_proto::SsoJit::Domains => gunnar_grpc::sso::rules::JitPolicy::Domains
81                .as_str()
82                .to_owned(),
83            gunnar_proto::SsoJit::Off => {
84                gunnar_grpc::sso::rules::JitPolicy::Off.as_str().to_owned()
85            }
86        },
87    })
88}
89
90fn provider_out(p: &core_sso::SsoProvider, has_client_secret: bool) -> SsoProvider {
91    SsoProvider {
92        account: p.account.clone(),
93        protocol: match p.protocol {
94            core_sso::SsoProtocol::Oidc => SsoProtocol::Oidc,
95            core_sso::SsoProtocol::Saml => SsoProtocol::Saml,
96        } as i32,
97        display_name: p.display_name.clone(),
98        domains: p.domains.clone(),
99        oidc: p.oidc.as_ref().map(|o| OidcSettings {
100            issuer: o.issuer.clone(),
101            client_id: o.client_id.clone(),
102            scopes: o.scopes.clone(),
103            username_claim: o.username_claim.clone(),
104            display_name_claim: o.display_name_claim.clone(),
105            idp_ca_pem: gunnar_grpc::sso::idp_ca::idp_ca_pem(&core_sso::idp_ca_from_stored(
106                &o.idp_ca,
107            )),
108            idp_ca_fingerprints: Vec::new(),
109        }),
110        saml: p.saml.as_ref().map(|s| SamlSettings {
111            idp_metadata_xml: String::new(),
112            idp_metadata_url: s.metadata_url.clone(),
113            idp_entity_id: s.idp_entity_id.clone(),
114            idp_sso_url: s.idp_sso_url.clone(),
115            idp_certificates: s.idp_certificates.clone(),
116            username_attribute: s.username_attribute.clone(),
117            display_name_attribute: s.display_name_attribute.clone(),
118        }),
119        require_sso: p.require_sso,
120        require_passkey: p.require_passkey,
121        console_origin: p.console_origin.clone(),
122        has_client_secret,
123        updated_at_unix_ms: p.updated,
124        updated_by: p.updated_by.clone(),
125        verified_domains: p.verified_domains.clone(),
126        jit: match gunnar_grpc::sso::rules::JitPolicy::parse(&p.jit) {
127            gunnar_grpc::sso::rules::JitPolicy::Open => gunnar_proto::SsoJit::Open,
128            gunnar_grpc::sso::rules::JitPolicy::Domains => gunnar_proto::SsoJit::Domains,
129            gunnar_grpc::sso::rules::JitPolicy::Off => gunnar_proto::SsoJit::Off,
130        } as i32,
131    }
132}
133
134fn user_out(u: &core_sso::SsoUser) -> DirUser {
135    DirUser {
136        user: DirectoryUser {
137            principal_id: u.principal_id.clone(),
138            user_name: u.user_name.clone(),
139            display_name: u.display_name.clone(),
140            email: u.email.clone(),
141            active: u.active,
142            source: u.source.as_str().to_owned(),
143            bound: u.subject.is_some(),
144            created_at_unix_ms: u.created,
145            last_login_unix_ms: u.last_login.unwrap_or(0),
146            external_id: u.external_id.clone(),
147            rebind: u.rebind,
148        },
149        deleted: u.deleted,
150    }
151}
152
153fn token_out(t: &core_sso::ScimToken) -> ScimToken {
154    ScimToken {
155        token_id: t.id.clone(),
156        account: t.account.clone(),
157        label: t.label.clone(),
158        created_at_unix_ms: t.created,
159        created_by: t.created_by.clone(),
160        last_used_unix_ms: t.last_used.unwrap_or(0),
161        expires_at_unix_ms: t.expires.unwrap_or(0),
162        read_only: t.read_only,
163        attention: String::new(),
164    }
165}
166
167fn group_out(g: &core_sso::SsoGroup) -> GroupRow {
168    GroupRow {
169        id: g.id.clone(),
170        account: g.account.clone(),
171        display_name: g.display_name.clone(),
172        external_id: g.external_id.clone(),
173        members: g.members.clone(),
174        created_at_unix_ms: g.created,
175    }
176}
177
178fn room_ask(room: &gunnar_grpc::sso::plane::Room) -> gunnar_core::RoomAsk {
179    gunnar_core::RoomAsk {
180        room_bytes: room.room_bytes,
181        cap: room.cap,
182        stored: room.stored,
183        ..gunnar_core::RoomAsk::default()
184    }
185}
186
187impl CoreControlPlane {
188    /// The ids a NEW employee for `user_name` may take: the table's
189    /// candidates, minus any that names a namespace (that would make the
190    /// employee its admin by name) or a console's reserved name.
191    fn sso_candidates(
192        &self,
193        account: &str,
194        user_name: &str,
195    ) -> ControlResult<Vec<gunnar_core::Principal>> {
196        let mut out = Vec::new();
197        for id in core_sso::principal_id_candidates(account, user_name) {
198            if gunnar_grpc::service::access::is_reserved_principal_name(&id) {
199                continue;
200            }
201            if self
202                .stores
203                .catalog()
204                .namespace(&id)
205                .map_err(internal)?
206                .is_some()
207            {
208                continue;
209            }
210            out.push(
211                gunnar_core::Principal::new(&id, &id, gunnar_core::PrincipalKind::User)
212                    .map_err(internal)?,
213            );
214        }
215        Ok(out)
216    }
217
218    fn account_exists(&self, account: &str) -> ControlResult<()> {
219        if self
220            .stores
221            .catalog()
222            .namespace(account)
223            .map_err(internal)?
224            .is_none()
225        {
226            return Err(ControlError::NotFound(format!("account {account}")));
227        }
228        Ok(())
229    }
230
231    /// A provisioning ask as the table takes it, its candidate ids found.
232    fn provision_of(&self, ask: &ProvisionAsk) -> ControlResult<gunnar_core::SsoProvision> {
233        Ok(gunnar_core::SsoProvision {
234            account: ask.account.clone(),
235            user_name: ask.user_name.clone(),
236            display_name: ask.display_name.clone(),
237            email: ask.email.clone(),
238            external_id: ask.external_id.clone(),
239            active: ask.active,
240            source: if ask.source == "csv" {
241                core_sso::SsoSource::Csv
242            } else {
243                core_sso::SsoSource::Scim
244            },
245            candidates: self.sso_candidates(&ask.account, &ask.user_name)?,
246            room: room_ask(&ask.room),
247            actor: ask.actor.clone(),
248            now: ask.now_unix_ms,
249        })
250    }
251
252    fn client_secret_stored(&self, account: &str) -> ControlResult<bool> {
253        let secrets = self.stores.secrets().map_err(|err| {
254            ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
255        })?;
256        Ok(
257            gunnar_store::SecretStore::get(&secrets, &core_sso::client_secret_key(account))
258                .map_err(|err| {
259                    ControlError::Internal(format!("the client secret could not be read: {err}"))
260                })?
261                .is_some(),
262        )
263    }
264}
265
266impl SsoPlane for CoreControlPlane {
267    fn provider(&self, account: &str) -> ControlResult<Option<SsoProvider>> {
268        match self.stores.auth().sso_provider(account).map_err(internal)? {
269            Some(p) => Ok(Some(provider_out(&p, self.client_secret_stored(account)?))),
270            None => Ok(None),
271        }
272    }
273
274    fn any_provider(&self) -> ControlResult<bool> {
275        Ok(!self
276            .stores
277            .auth()
278            .sso_providers()
279            .map_err(internal)?
280            .is_empty())
281    }
282
283    fn set_provider(
284        &self,
285        provider: SsoProvider,
286        secret: SecretChange,
287        actor: &str,
288    ) -> ControlResult<SsoProvider> {
289        let mut row = provider_in(&provider)?;
290        row.updated_by = actor.to_owned();
291        if row.updated == 0 {
292            row.updated = gunnar_core::now_ms();
293        }
294        let account = row.account.clone();
295        let saved = self
296            .stores
297            .auth()
298            .set_sso_provider(row)
299            .map_err(sso_error)?;
300        let key = core_sso::client_secret_key(&account);
301        let secrets = self.stores.secrets().map_err(|err| {
302            ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
303        })?;
304        match secret {
305            SecretChange::Keep => {}
306            SecretChange::Clear => {
307                gunnar_store::SecretStore::delete(&secrets, &key).map_err(|err| {
308                    ControlError::Internal(format!("the client secret could not be removed: {err}"))
309                })?;
310            }
311            SecretChange::Set(value) => {
312                // `{err}` and never the value: this string reaches a client.
313                gunnar_store::SecretStore::put(&secrets, &key, value.as_bytes()).map_err(
314                    |err| {
315                        ControlError::Internal(format!(
316                            "the client secret could not be sealed: {err}"
317                        ))
318                    },
319                )?;
320            }
321        }
322        self.stores.mirror_identity();
323        Ok(provider_out(&saved, self.client_secret_stored(&account)?))
324    }
325
326    fn remove_provider(&self, account: &str) -> ControlResult<bool> {
327        let removed = self
328            .stores
329            .auth()
330            .remove_sso_provider(account)
331            .map_err(internal)?
332            .is_some();
333        if let Ok(secrets) = self.stores.secrets() {
334            let _ =
335                gunnar_store::SecretStore::delete(&secrets, &core_sso::client_secret_key(account));
336        }
337        self.stores.mirror_identity();
338        Ok(removed)
339    }
340
341    fn account_for_domain(&self, domain: &str) -> ControlResult<Option<String>> {
342        self.stores
343            .auth()
344            .sso_account_for_domain(domain)
345            .map_err(internal)
346    }
347
348    fn client_secret(&self, account: &str) -> ControlResult<Option<String>> {
349        let secrets = self.stores.secrets().map_err(|err| {
350            ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
351        })?;
352        let raw = gunnar_store::SecretStore::get(&secrets, &core_sso::client_secret_key(account))
353            .map_err(|err| {
354            ControlError::Internal(format!("the client secret could not be opened: {err}"))
355        })?;
356        raw.map(|bytes| {
357            String::from_utf8(bytes)
358                .map_err(|_| ControlError::Internal("the stored client secret is not text".into()))
359        })
360        .transpose()
361    }
362
363    fn login(&self, ask: SsoLoginAsk) -> ControlResult<SsoLoginOutcome> {
364        let candidates = self.sso_candidates(&ask.account, &ask.user_name)?;
365        let done = self
366            .stores
367            .auth()
368            .sso_login(&gunnar_core::SsoLogin {
369                account: ask.account.clone(),
370                subject: core_sso::subject_key(&ask.issuer, &ask.subject),
371                user_name: ask.user_name.clone(),
372                display_name: ask.display_name.clone(),
373                email: ask.email.clone(),
374                email_verified: ask.email_verified,
375                idp_subjects: ask.idp_ids.clone(),
376                candidates,
377                room: room_ask(&ask.room),
378                session: ask.session.clone(),
379                now: ask.now_unix_ms,
380            })
381            .map_err(sso_error)?;
382        self.stores.mirror_identity();
383        // A session was issued (no second factor owed): the person's last
384        // sign-in (2026-10-21). A login that still owes a passkey is stamped
385        // when that passkey login commits (`web_login`).
386        if done.session.is_some() {
387            self.stores
388                .activity()
389                .signed_in(&done.principal.id, ask.now_unix_ms);
390        }
391        Ok(SsoLoginOutcome {
392            principal: principal_out(&done.principal),
393            user: user_out(&done.user),
394            created: done.created,
395            claimed: done.claimed,
396            claimed_by: done.claimed_by.unwrap_or_default().to_owned(),
397            fingerprint: done.session.map(|k| k.fingerprint_sha256),
398        })
399    }
400
401    fn directory(&self, account: &str) -> ControlResult<Vec<DirUser>> {
402        Ok(self
403            .stores
404            .auth()
405            .sso_users_of(account)
406            .map_err(internal)?
407            .iter()
408            .map(user_out)
409            .collect())
410    }
411
412    fn provision(&self, ask: ProvisionAsk) -> ControlResult<DirUser> {
413        self.account_exists(&ask.account)?;
414        let provision = self.provision_of(&ask)?;
415        let user = self
416            .stores
417            .auth()
418            .sso_provision(&provision)
419            .map_err(sso_error)?;
420        self.stores.mirror_identity();
421        Ok(user_out(&user))
422    }
423
424    fn import(
425        &self,
426        account: &str,
427        rows: &[(String, ImportRow)],
428        actor: &str,
429        now_unix_ms: i64,
430        commit: bool,
431    ) -> ControlResult<()> {
432        self.account_exists(account)?;
433        let mut table = Vec::with_capacity(rows.len());
434        for (label, row) in rows {
435            let op = match row {
436                ImportRow::Create(ask) => gunnar_core::SsoImportOp::Create(self.provision_of(ask)?),
437                ImportRow::Update {
438                    principal_id,
439                    patch,
440                } => gunnar_core::SsoImportOp::Update {
441                    principal_id: principal_id.clone(),
442                    patch: gunnar_core::SsoUserPatch {
443                        user_name: patch.user_name.clone(),
444                        display_name: patch.display_name.clone(),
445                        email: patch.email.clone(),
446                        external_id: patch.external_id.clone(),
447                        active: patch.active,
448                    },
449                },
450            };
451            table.push(gunnar_core::SsoImportRow {
452                label: label.clone(),
453                op,
454            });
455        }
456        self.stores
457            .auth()
458            .sso_import(account, &table, actor, now_unix_ms, commit)
459            .map_err(|(label, err)| match sso_error(err) {
460                ControlError::NotFound(m) => ControlError::NotFound(format!("{label}: {m}")),
461                ControlError::AlreadyExists(m) => {
462                    ControlError::AlreadyExists(format!("{label}: {m}"))
463                }
464                ControlError::InvalidArgument(m) => {
465                    ControlError::InvalidArgument(format!("{label}: {m}"))
466                }
467                ControlError::FailedPrecondition(m) => {
468                    ControlError::FailedPrecondition(format!("{label}: {m}"))
469                }
470                ControlError::PermissionDenied(m) => {
471                    ControlError::PermissionDenied(format!("{label}: {m}"))
472                }
473                other => other,
474            })?;
475        if commit {
476            self.stores.mirror_identity();
477        }
478        Ok(())
479    }
480
481    fn update_user(
482        &self,
483        account: &str,
484        principal_id: &str,
485        patch: UserPatch,
486        actor: &str,
487    ) -> ControlResult<(DirUser, bool)> {
488        let (user, changed) = self
489            .stores
490            .auth()
491            .sso_user_update(
492                account,
493                principal_id,
494                &gunnar_core::SsoUserPatch {
495                    user_name: patch.user_name,
496                    display_name: patch.display_name,
497                    email: patch.email,
498                    external_id: patch.external_id,
499                    active: patch.active,
500                },
501                actor,
502                gunnar_core::now_ms(),
503            )
504            .map_err(sso_error)?;
505        self.stores.mirror_identity();
506        Ok((user_out(&user), changed))
507    }
508
509    fn delete_user(
510        &self,
511        account: &str,
512        principal_id: &str,
513        actor: &str,
514    ) -> ControlResult<DirUser> {
515        let user = self
516            .stores
517            .auth()
518            .sso_user_delete(account, principal_id, actor, gunnar_core::now_ms())
519            .map_err(sso_error)?;
520        self.stores.mirror_identity();
521        Ok(user_out(&user))
522    }
523
524    fn create_scim_token(
525        &self,
526        account: &str,
527        id: &str,
528        label: &str,
529        secret_hash: &str,
530        created_by: &str,
531        expires_at_unix_ms: i64,
532        read_only: bool,
533    ) -> ControlResult<ScimToken> {
534        let token = self
535            .stores
536            .auth()
537            .create_scim_token(core_sso::ScimToken {
538                id: id.to_owned(),
539                account: account.to_owned(),
540                secret_hash: secret_hash.to_owned(),
541                label: label.to_owned(),
542                created: gunnar_core::now_ms(),
543                created_by: created_by.to_owned(),
544                last_used: None,
545                expires: (expires_at_unix_ms > 0).then_some(expires_at_unix_ms),
546                read_only,
547            })
548            .map_err(sso_error)?;
549        self.stores.mirror_identity();
550        Ok(token_out(&token))
551    }
552
553    fn scim_tokens(&self, account: &str) -> ControlResult<Vec<ScimToken>> {
554        Ok(self
555            .stores
556            .auth()
557            .scim_tokens_of(account)
558            .map_err(internal)?
559            .iter()
560            .map(token_out)
561            .collect())
562    }
563
564    fn revoke_scim_token(&self, account: &str, id: &str) -> ControlResult<bool> {
565        let revoked = self
566            .stores
567            .auth()
568            .revoke_scim_token(account, id)
569            .map_err(internal)?
570            .is_some();
571        self.stores.mirror_identity();
572        Ok(revoked)
573    }
574
575    fn scim_account(&self, secret_hash: &str) -> ControlResult<Option<(String, Bearer)>> {
576        let Some(token) = self
577            .stores
578            .auth()
579            .scim_token_by_hash(secret_hash)
580            .map_err(internal)?
581        else {
582            return Ok(None);
583        };
584        let _ = self
585            .stores
586            .auth()
587            .touch_scim_token(secret_hash, gunnar_core::now_ms());
588        let bearer = Bearer {
589            expires_at_unix_ms: token.expires.unwrap_or(0),
590            scope: if token.read_only {
591                Scope::Read
592            } else {
593                Scope::ReadWrite
594            },
595        };
596        Ok(Some((token.account, bearer)))
597    }
598
599    fn groups(&self, account: &str) -> ControlResult<Vec<GroupRow>> {
600        Ok(self
601            .stores
602            .auth()
603            .sso_groups_of(account)
604            .map_err(internal)?
605            .iter()
606            .map(group_out)
607            .collect())
608    }
609
610    fn put_group(&self, group: GroupRow) -> ControlResult<GroupRow> {
611        let saved = self
612            .stores
613            .auth()
614            .put_sso_group(core_sso::SsoGroup {
615                id: group.id,
616                account: group.account,
617                display_name: group.display_name,
618                external_id: group.external_id,
619                members: group.members,
620                created: group.created_at_unix_ms,
621            })
622            .map_err(sso_error)?;
623        Ok(group_out(&saved))
624    }
625
626    fn remove_group(&self, account: &str, id: &str) -> ControlResult<bool> {
627        Ok(self
628            .stores
629            .auth()
630            .remove_sso_group(account, id)
631            .map_err(internal)?
632            .is_some())
633    }
634
635    fn passkey_login_refused(&self, principal_id: &str) -> ControlResult<Option<String>> {
636        let Some(account) = self
637            .stores
638            .auth()
639            .employer_of(principal_id)
640            .map_err(internal)?
641        else {
642            return Ok(None);
643        };
644        let Some(provider) = self
645            .stores
646            .auth()
647            .sso_provider(&account)
648            .map_err(internal)?
649        else {
650            return Ok(None);
651        };
652        Ok(provider.require_sso.then(|| {
653            format!(
654                "{principal_id} is an employee of {account}, which signs its people in through its identity provider: \
655                 use \"Sign in with {}\"",
656                provider.display_name
657            )
658        }))
659    }
660
661    fn passkeys_of(&self, principal_id: &str) -> ControlResult<Vec<Vec<u8>>> {
662        Ok(self
663            .stores
664            .auth()
665            .web_credentials(Some(principal_id))
666            .map_err(internal)?
667            .iter()
668            .filter_map(|c| gunnar_grpc::webauthn::from_b64url(&c.id))
669            .collect())
670    }
671
672    fn verify_domain(&self, account: &str, domain: &str) -> ControlResult<SsoProvider> {
673        let saved = self
674            .stores
675            .auth()
676            .verify_sso_domain(account, domain)
677            .map_err(sso_error)?;
678        self.stores.mirror_identity();
679        Ok(provider_out(&saved, self.client_secret_stored(account)?))
680    }
681
682    fn rebind_users(
683        &self,
684        account: &str,
685        principal_ids: &[String],
686        external_id: &str,
687    ) -> ControlResult<Vec<DirUser>> {
688        let external = Some(external_id).filter(|e| !e.is_empty());
689        let rows = self
690            .stores
691            .auth()
692            .sso_user_rebind(account, principal_ids, external)
693            .map_err(sso_error)?;
694        self.stores.mirror_identity();
695        Ok(rows.iter().map(user_out).collect())
696    }
697}