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_proto::{
15    DirectoryUser, OidcSettings, SamlSettings, ScimToken, SsoProtocol, SsoProvider,
16};
17
18use super::{internal, principal_out, CoreControlPlane};
19
20/// A refusal the table made, as the seam says it.
21fn sso_error(err: gunnar_core::Error) -> ControlError {
22    use gunnar_core::Error as E;
23    match err {
24        E::SsoRefused(m) => ControlError::PermissionDenied(m),
25        e @ E::RoomShort { .. } => ControlError::FailedPrecondition(format!(
26            "{e} — the account's admin adds room or users before a new employee can sign in"
27        )),
28        e @ E::NoOpenSeat(_) => ControlError::FailedPrecondition(e.to_string()),
29        e @ E::NameExists { .. } => ControlError::AlreadyExists(e.to_string()),
30        e @ E::KeyExists { .. } => ControlError::AlreadyExists(e.to_string()),
31        E::NoSuchPrincipal(id) => ControlError::NotFound(format!("principal {id}")),
32        e @ E::InvalidInput { .. } => ControlError::InvalidArgument(e.to_string()),
33        other => internal(other),
34    }
35}
36
37fn provider_in(p: &SsoProvider) -> ControlResult<core_sso::SsoProvider> {
38    let protocol = match SsoProtocol::try_from(p.protocol).unwrap_or(SsoProtocol::Unspecified) {
39        SsoProtocol::Oidc => core_sso::SsoProtocol::Oidc,
40        SsoProtocol::Saml => core_sso::SsoProtocol::Saml,
41        SsoProtocol::Unspecified => {
42            return Err(ControlError::InvalidArgument(
43                "`protocol` is OIDC or SAML".into(),
44            ))
45        }
46    };
47    Ok(core_sso::SsoProvider {
48        account: p.account.clone(),
49        protocol,
50        display_name: p.display_name.clone(),
51        domains: p.domains.clone(),
52        oidc: p.oidc.as_ref().map(|o| core_sso::OidcSettings {
53            issuer: o.issuer.clone(),
54            client_id: o.client_id.clone(),
55            scopes: o.scopes.clone(),
56            username_claim: o.username_claim.clone(),
57            display_name_claim: o.display_name_claim.clone(),
58            idp_ca: core_sso::idp_ca_stored(&gunnar_grpc::sso::idp_ca::idp_ca_blocks(&o.idp_ca_pem)),
59        }),
60        saml: p.saml.as_ref().map(|s| core_sso::SamlSettings {
61            idp_entity_id: s.idp_entity_id.clone(),
62            idp_sso_url: s.idp_sso_url.clone(),
63            idp_certificates: s.idp_certificates.clone(),
64            metadata_url: s.idp_metadata_url.clone(),
65            username_attribute: s.username_attribute.clone(),
66            display_name_attribute: s.display_name_attribute.clone(),
67        }),
68        require_sso: p.require_sso,
69        require_passkey: p.require_passkey,
70        console_origin: p.console_origin.clone(),
71        updated: p.updated_at_unix_ms,
72        updated_by: p.updated_by.clone(),
73    })
74}
75
76fn provider_out(p: &core_sso::SsoProvider, has_client_secret: bool) -> SsoProvider {
77    SsoProvider {
78        account: p.account.clone(),
79        protocol: match p.protocol {
80            core_sso::SsoProtocol::Oidc => SsoProtocol::Oidc,
81            core_sso::SsoProtocol::Saml => SsoProtocol::Saml,
82        } as i32,
83        display_name: p.display_name.clone(),
84        domains: p.domains.clone(),
85        oidc: p.oidc.as_ref().map(|o| OidcSettings {
86            issuer: o.issuer.clone(),
87            client_id: o.client_id.clone(),
88            scopes: o.scopes.clone(),
89            username_claim: o.username_claim.clone(),
90            display_name_claim: o.display_name_claim.clone(),
91            idp_ca_pem: gunnar_grpc::sso::idp_ca::idp_ca_pem(&core_sso::idp_ca_from_stored(&o.idp_ca)),
92            idp_ca_fingerprints: Vec::new(),
93        }),
94        saml: p.saml.as_ref().map(|s| SamlSettings {
95            idp_metadata_xml: String::new(),
96            idp_metadata_url: s.metadata_url.clone(),
97            idp_entity_id: s.idp_entity_id.clone(),
98            idp_sso_url: s.idp_sso_url.clone(),
99            idp_certificates: s.idp_certificates.clone(),
100            username_attribute: s.username_attribute.clone(),
101            display_name_attribute: s.display_name_attribute.clone(),
102        }),
103        require_sso: p.require_sso,
104        require_passkey: p.require_passkey,
105        console_origin: p.console_origin.clone(),
106        has_client_secret,
107        updated_at_unix_ms: p.updated,
108        updated_by: p.updated_by.clone(),
109    }
110}
111
112fn user_out(u: &core_sso::SsoUser) -> DirUser {
113    DirUser {
114        user: DirectoryUser {
115            principal_id: u.principal_id.clone(),
116            user_name: u.user_name.clone(),
117            display_name: u.display_name.clone(),
118            email: u.email.clone(),
119            active: u.active,
120            source: u.source.as_str().to_owned(),
121            bound: u.subject.is_some(),
122            created_at_unix_ms: u.created,
123            last_login_unix_ms: u.last_login.unwrap_or(0),
124            external_id: u.external_id.clone(),
125        },
126        deleted: u.deleted,
127    }
128}
129
130fn token_out(t: &core_sso::ScimToken) -> ScimToken {
131    ScimToken {
132        token_id: t.id.clone(),
133        account: t.account.clone(),
134        label: t.label.clone(),
135        created_at_unix_ms: t.created,
136        created_by: t.created_by.clone(),
137        last_used_unix_ms: t.last_used.unwrap_or(0),
138    }
139}
140
141fn group_out(g: &core_sso::SsoGroup) -> GroupRow {
142    GroupRow {
143        id: g.id.clone(),
144        account: g.account.clone(),
145        display_name: g.display_name.clone(),
146        external_id: g.external_id.clone(),
147        members: g.members.clone(),
148        created_at_unix_ms: g.created,
149    }
150}
151
152fn room_ask(room: &gunnar_grpc::sso::plane::Room) -> gunnar_core::RoomAsk {
153    gunnar_core::RoomAsk {
154        room_bytes: room.room_bytes,
155        cap: room.cap,
156        stored: room.stored,
157        ..gunnar_core::RoomAsk::default()
158    }
159}
160
161impl CoreControlPlane {
162    /// The ids a NEW employee for `user_name` may take: the table's
163    /// candidates, minus any that names a namespace (that would make the
164    /// employee its admin by name) or a console's reserved name.
165    fn sso_candidates(
166        &self,
167        account: &str,
168        user_name: &str,
169    ) -> ControlResult<Vec<gunnar_core::Principal>> {
170        let mut out = Vec::new();
171        for id in core_sso::principal_id_candidates(account, user_name) {
172            if gunnar_grpc::service::access::is_reserved_principal_name(&id) {
173                continue;
174            }
175            if self
176                .stores
177                .catalog()
178                .namespace(&id)
179                .map_err(internal)?
180                .is_some()
181            {
182                continue;
183            }
184            out.push(
185                gunnar_core::Principal::new(&id, &id, gunnar_core::PrincipalKind::User)
186                    .map_err(internal)?,
187            );
188        }
189        Ok(out)
190    }
191
192    fn client_secret_stored(&self, account: &str) -> ControlResult<bool> {
193        let secrets = self.stores.secrets().map_err(|err| {
194            ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
195        })?;
196        Ok(
197            gunnar_store::SecretStore::get(&secrets, &core_sso::client_secret_key(account))
198                .map_err(|err| {
199                    ControlError::Internal(format!("the client secret could not be read: {err}"))
200                })?
201                .is_some(),
202        )
203    }
204}
205
206impl SsoPlane for CoreControlPlane {
207    fn provider(&self, account: &str) -> ControlResult<Option<SsoProvider>> {
208        match self.stores.auth().sso_provider(account).map_err(internal)? {
209            Some(p) => Ok(Some(provider_out(&p, self.client_secret_stored(account)?))),
210            None => Ok(None),
211        }
212    }
213
214    fn any_provider(&self) -> ControlResult<bool> {
215        Ok(!self.stores.auth().sso_providers().map_err(internal)?.is_empty())
216    }
217
218    fn set_provider(
219        &self,
220        provider: SsoProvider,
221        secret: SecretChange,
222        actor: &str,
223    ) -> ControlResult<SsoProvider> {
224        let mut row = provider_in(&provider)?;
225        row.updated_by = actor.to_owned();
226        if row.updated == 0 {
227            row.updated = gunnar_core::now_ms();
228        }
229        let account = row.account.clone();
230        let saved = self
231            .stores
232            .auth()
233            .set_sso_provider(row)
234            .map_err(sso_error)?;
235        let key = core_sso::client_secret_key(&account);
236        let secrets = self.stores.secrets().map_err(|err| {
237            ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
238        })?;
239        match secret {
240            SecretChange::Keep => {}
241            SecretChange::Clear => {
242                gunnar_store::SecretStore::delete(&secrets, &key).map_err(|err| {
243                    ControlError::Internal(format!("the client secret could not be removed: {err}"))
244                })?;
245            }
246            SecretChange::Set(value) => {
247                // `{err}` and never the value: this string reaches a client.
248                gunnar_store::SecretStore::put(&secrets, &key, value.as_bytes()).map_err(
249                    |err| {
250                        ControlError::Internal(format!(
251                            "the client secret could not be sealed: {err}"
252                        ))
253                    },
254                )?;
255            }
256        }
257        self.stores.mirror_identity();
258        Ok(provider_out(&saved, self.client_secret_stored(&account)?))
259    }
260
261    fn remove_provider(&self, account: &str) -> ControlResult<bool> {
262        let removed = self
263            .stores
264            .auth()
265            .remove_sso_provider(account)
266            .map_err(internal)?
267            .is_some();
268        if let Ok(secrets) = self.stores.secrets() {
269            let _ =
270                gunnar_store::SecretStore::delete(&secrets, &core_sso::client_secret_key(account));
271        }
272        self.stores.mirror_identity();
273        Ok(removed)
274    }
275
276    fn account_for_domain(&self, domain: &str) -> ControlResult<Option<String>> {
277        self.stores
278            .auth()
279            .sso_account_for_domain(domain)
280            .map_err(internal)
281    }
282
283    fn client_secret(&self, account: &str) -> ControlResult<Option<String>> {
284        let secrets = self.stores.secrets().map_err(|err| {
285            ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
286        })?;
287        let raw = gunnar_store::SecretStore::get(&secrets, &core_sso::client_secret_key(account))
288            .map_err(|err| {
289            ControlError::Internal(format!("the client secret could not be opened: {err}"))
290        })?;
291        raw.map(|bytes| {
292            String::from_utf8(bytes)
293                .map_err(|_| ControlError::Internal("the stored client secret is not text".into()))
294        })
295        .transpose()
296    }
297
298    fn login(&self, ask: SsoLoginAsk) -> ControlResult<SsoLoginOutcome> {
299        let candidates = self.sso_candidates(&ask.account, &ask.user_name)?;
300        let done = self
301            .stores
302            .auth()
303            .sso_login(&gunnar_core::SsoLogin {
304                account: ask.account.clone(),
305                subject: core_sso::subject_key(&ask.issuer, &ask.subject),
306                user_name: ask.user_name.clone(),
307                display_name: ask.display_name.clone(),
308                email: ask.email.clone(),
309                candidates,
310                room: room_ask(&ask.room),
311                session: ask.session.clone(),
312                now: ask.now_unix_ms,
313            })
314            .map_err(sso_error)?;
315        self.stores.mirror_identity();
316        // A session was issued (no second factor owed): the person's last
317        // sign-in (2026-10-21). A login that still owes a passkey is stamped
318        // when that passkey login commits (`web_login`).
319        if done.session.is_some() {
320            self.stores.activity().signed_in(&done.principal.id, ask.now_unix_ms);
321        }
322        Ok(SsoLoginOutcome {
323            principal: principal_out(&done.principal),
324            user: user_out(&done.user),
325            created: done.created,
326            claimed: done.claimed,
327            fingerprint: done.session.map(|k| k.fingerprint_sha256),
328        })
329    }
330
331    fn directory(&self, account: &str) -> ControlResult<Vec<DirUser>> {
332        Ok(self
333            .stores
334            .auth()
335            .sso_users_of(account)
336            .map_err(internal)?
337            .iter()
338            .map(user_out)
339            .collect())
340    }
341
342    fn provision(&self, ask: ProvisionAsk) -> ControlResult<DirUser> {
343        if self
344            .stores
345            .catalog()
346            .namespace(&ask.account)
347            .map_err(internal)?
348            .is_none()
349        {
350            return Err(ControlError::NotFound(format!("account {}", ask.account)));
351        }
352        let candidates = self.sso_candidates(&ask.account, &ask.user_name)?;
353        let user = self
354            .stores
355            .auth()
356            .sso_provision(&gunnar_core::SsoProvision {
357                account: ask.account.clone(),
358                user_name: ask.user_name.clone(),
359                display_name: ask.display_name.clone(),
360                email: ask.email.clone(),
361                external_id: ask.external_id.clone(),
362                active: ask.active,
363                source: if ask.source == "csv" {
364                    core_sso::SsoSource::Csv
365                } else {
366                    core_sso::SsoSource::Scim
367                },
368                candidates,
369                room: room_ask(&ask.room),
370                actor: ask.actor.clone(),
371                now: ask.now_unix_ms,
372            })
373            .map_err(sso_error)?;
374        self.stores.mirror_identity();
375        Ok(user_out(&user))
376    }
377
378    fn update_user(
379        &self,
380        account: &str,
381        principal_id: &str,
382        patch: UserPatch,
383        actor: &str,
384    ) -> ControlResult<(DirUser, bool)> {
385        let (user, changed) = self
386            .stores
387            .auth()
388            .sso_user_update(
389                account,
390                principal_id,
391                &gunnar_core::SsoUserPatch {
392                    user_name: patch.user_name,
393                    display_name: patch.display_name,
394                    email: patch.email,
395                    external_id: patch.external_id,
396                    active: patch.active,
397                },
398                actor,
399                gunnar_core::now_ms(),
400            )
401            .map_err(sso_error)?;
402        self.stores.mirror_identity();
403        Ok((user_out(&user), changed))
404    }
405
406    fn delete_user(
407        &self,
408        account: &str,
409        principal_id: &str,
410        actor: &str,
411    ) -> ControlResult<DirUser> {
412        let user = self
413            .stores
414            .auth()
415            .sso_user_delete(account, principal_id, actor, gunnar_core::now_ms())
416            .map_err(sso_error)?;
417        self.stores.mirror_identity();
418        Ok(user_out(&user))
419    }
420
421    fn create_scim_token(
422        &self,
423        account: &str,
424        id: &str,
425        label: &str,
426        secret_hash: &str,
427        created_by: &str,
428    ) -> ControlResult<ScimToken> {
429        let token = self
430            .stores
431            .auth()
432            .create_scim_token(core_sso::ScimToken {
433                id: id.to_owned(),
434                account: account.to_owned(),
435                secret_hash: secret_hash.to_owned(),
436                label: label.to_owned(),
437                created: gunnar_core::now_ms(),
438                created_by: created_by.to_owned(),
439                last_used: None,
440            })
441            .map_err(sso_error)?;
442        self.stores.mirror_identity();
443        Ok(token_out(&token))
444    }
445
446    fn scim_tokens(&self, account: &str) -> ControlResult<Vec<ScimToken>> {
447        Ok(self
448            .stores
449            .auth()
450            .scim_tokens_of(account)
451            .map_err(internal)?
452            .iter()
453            .map(token_out)
454            .collect())
455    }
456
457    fn revoke_scim_token(&self, account: &str, id: &str) -> ControlResult<bool> {
458        let revoked = self
459            .stores
460            .auth()
461            .revoke_scim_token(account, id)
462            .map_err(internal)?
463            .is_some();
464        self.stores.mirror_identity();
465        Ok(revoked)
466    }
467
468    fn scim_account(&self, secret_hash: &str) -> ControlResult<Option<String>> {
469        let Some(token) = self
470            .stores
471            .auth()
472            .scim_token_by_hash(secret_hash)
473            .map_err(internal)?
474        else {
475            return Ok(None);
476        };
477        let _ = self
478            .stores
479            .auth()
480            .touch_scim_token(secret_hash, gunnar_core::now_ms());
481        Ok(Some(token.account))
482    }
483
484    fn groups(&self, account: &str) -> ControlResult<Vec<GroupRow>> {
485        Ok(self
486            .stores
487            .auth()
488            .sso_groups_of(account)
489            .map_err(internal)?
490            .iter()
491            .map(group_out)
492            .collect())
493    }
494
495    fn put_group(&self, group: GroupRow) -> ControlResult<GroupRow> {
496        let saved = self
497            .stores
498            .auth()
499            .put_sso_group(core_sso::SsoGroup {
500                id: group.id,
501                account: group.account,
502                display_name: group.display_name,
503                external_id: group.external_id,
504                members: group.members,
505                created: group.created_at_unix_ms,
506            })
507            .map_err(sso_error)?;
508        Ok(group_out(&saved))
509    }
510
511    fn remove_group(&self, account: &str, id: &str) -> ControlResult<bool> {
512        Ok(self
513            .stores
514            .auth()
515            .remove_sso_group(account, id)
516            .map_err(internal)?
517            .is_some())
518    }
519
520    fn passkey_login_refused(&self, principal_id: &str) -> ControlResult<Option<String>> {
521        let Some(account) = self
522            .stores
523            .auth()
524            .employer_of(principal_id)
525            .map_err(internal)?
526        else {
527            return Ok(None);
528        };
529        let Some(provider) = self
530            .stores
531            .auth()
532            .sso_provider(&account)
533            .map_err(internal)?
534        else {
535            return Ok(None);
536        };
537        Ok(provider.require_sso.then(|| {
538            format!(
539                "{principal_id} is an employee of {account}, which signs its people in through its identity provider: \
540                 use \"Sign in with {}\"",
541                provider.display_name
542            )
543        }))
544    }
545
546    fn passkeys_of(&self, principal_id: &str) -> ControlResult<Vec<Vec<u8>>> {
547        Ok(self
548            .stores
549            .auth()
550            .web_credentials(Some(principal_id))
551            .map_err(internal)?
552            .iter()
553            .filter_map(|c| gunnar_grpc::webauthn::from_b64url(&c.id))
554            .collect())
555    }
556}