Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-server/src/control_plane/sso_plane.rs

//! **Company SSO on the identity table** — [`gunnar_grpc::sso::plane::SsoPlane`]
//! for [`CoreControlPlane`]: the rows are `gunnar_core::AuthTable`'s (one
//! compare-and-swap with the employee they name), the OIDC client secret is
//! sealed in the box's secret store (the KEK, replicated sealed to the twin),
//! and a new employee's id is checked against the namespaces here, where the
//! catalog is.

use gunnar_core::sso as core_sso;
use gunnar_grpc::seam::{ControlError, ControlResult};
use gunnar_grpc::sso::plane::{
    DirUser, GroupRow, ProvisionAsk, SecretChange, SsoLoginAsk, SsoLoginOutcome, SsoPlane,
    UserPatch,
};
use gunnar_grpc::sso::scim::{Bearer, ImportRow, Scope};
use gunnar_proto::{
    DirectoryUser, OidcSettings, SamlSettings, ScimToken, SsoProtocol, SsoProvider,
};

use super::{internal, principal_out, CoreControlPlane};

/// A refusal the table made, as the seam says it.
fn sso_error(err: gunnar_core::Error) -> ControlError {
    use gunnar_core::Error as E;
    match err {
        E::SsoRefused(m) => ControlError::PermissionDenied(m),
        e @ E::RoomShort { .. } => ControlError::FailedPrecondition(format!(
            "{e} — the account's admin adds room or users before a new employee can sign in"
        )),
        e @ E::NoOpenSeat(_) => ControlError::FailedPrecondition(e.to_string()),
        e @ E::NameExists { .. } => ControlError::AlreadyExists(e.to_string()),
        e @ E::KeyExists { .. } => ControlError::AlreadyExists(e.to_string()),
        E::NoSuchPrincipal(id) => ControlError::NotFound(format!("principal {id}")),
        e @ E::InvalidInput { .. } => ControlError::InvalidArgument(e.to_string()),
        other => internal(other),
    }
}

fn provider_in(p: &SsoProvider) -> ControlResult<core_sso::SsoProvider> {
    let protocol = match SsoProtocol::try_from(p.protocol).unwrap_or(SsoProtocol::Unspecified) {
        SsoProtocol::Oidc => core_sso::SsoProtocol::Oidc,
        SsoProtocol::Saml => core_sso::SsoProtocol::Saml,
        SsoProtocol::Unspecified => {
            return Err(ControlError::InvalidArgument(
                "`protocol` is OIDC or SAML".into(),
            ))
        }
    };
    Ok(core_sso::SsoProvider {
        account: p.account.clone(),
        protocol,
        display_name: p.display_name.clone(),
        domains: p.domains.clone(),
        oidc: p.oidc.as_ref().map(|o| core_sso::OidcSettings {
            issuer: o.issuer.clone(),
            client_id: o.client_id.clone(),
            scopes: o.scopes.clone(),
            username_claim: o.username_claim.clone(),
            display_name_claim: o.display_name_claim.clone(),
            idp_ca: core_sso::idp_ca_stored(&gunnar_grpc::sso::idp_ca::idp_ca_blocks(
                &o.idp_ca_pem,
            )),
        }),
        saml: p.saml.as_ref().map(|s| core_sso::SamlSettings {
            idp_entity_id: s.idp_entity_id.clone(),
            idp_sso_url: s.idp_sso_url.clone(),
            idp_certificates: s.idp_certificates.clone(),
            metadata_url: s.idp_metadata_url.clone(),
            username_attribute: s.username_attribute.clone(),
            display_name_attribute: s.display_name_attribute.clone(),
        }),
        require_sso: p.require_sso,
        require_passkey: p.require_passkey,
        console_origin: p.console_origin.clone(),
        updated: p.updated_at_unix_ms,
        updated_by: p.updated_by.clone(),
        // The table keeps the proofs itself (A34-5); an input names none.
        verified_domains: Vec::new(),
        jit: match gunnar_proto::SsoJit::try_from(p.jit).unwrap_or(gunnar_proto::SsoJit::Off) {
            gunnar_proto::SsoJit::Open => String::new(),
            gunnar_proto::SsoJit::Domains => gunnar_grpc::sso::rules::JitPolicy::Domains
                .as_str()
                .to_owned(),
            gunnar_proto::SsoJit::Off => {
                gunnar_grpc::sso::rules::JitPolicy::Off.as_str().to_owned()
            }
        },
    })
}

fn provider_out(p: &core_sso::SsoProvider, has_client_secret: bool) -> SsoProvider {
    SsoProvider {
        account: p.account.clone(),
        protocol: match p.protocol {
            core_sso::SsoProtocol::Oidc => SsoProtocol::Oidc,
            core_sso::SsoProtocol::Saml => SsoProtocol::Saml,
        } as i32,
        display_name: p.display_name.clone(),
        domains: p.domains.clone(),
        oidc: p.oidc.as_ref().map(|o| OidcSettings {
            issuer: o.issuer.clone(),
            client_id: o.client_id.clone(),
            scopes: o.scopes.clone(),
            username_claim: o.username_claim.clone(),
            display_name_claim: o.display_name_claim.clone(),
            idp_ca_pem: gunnar_grpc::sso::idp_ca::idp_ca_pem(&core_sso::idp_ca_from_stored(
                &o.idp_ca,
            )),
            idp_ca_fingerprints: Vec::new(),
        }),
        saml: p.saml.as_ref().map(|s| SamlSettings {
            idp_metadata_xml: String::new(),
            idp_metadata_url: s.metadata_url.clone(),
            idp_entity_id: s.idp_entity_id.clone(),
            idp_sso_url: s.idp_sso_url.clone(),
            idp_certificates: s.idp_certificates.clone(),
            username_attribute: s.username_attribute.clone(),
            display_name_attribute: s.display_name_attribute.clone(),
        }),
        require_sso: p.require_sso,
        require_passkey: p.require_passkey,
        console_origin: p.console_origin.clone(),
        has_client_secret,
        updated_at_unix_ms: p.updated,
        updated_by: p.updated_by.clone(),
        verified_domains: p.verified_domains.clone(),
        jit: match gunnar_grpc::sso::rules::JitPolicy::parse(&p.jit) {
            gunnar_grpc::sso::rules::JitPolicy::Open => gunnar_proto::SsoJit::Open,
            gunnar_grpc::sso::rules::JitPolicy::Domains => gunnar_proto::SsoJit::Domains,
            gunnar_grpc::sso::rules::JitPolicy::Off => gunnar_proto::SsoJit::Off,
        } as i32,
    }
}

fn user_out(u: &core_sso::SsoUser) -> DirUser {
    DirUser {
        user: DirectoryUser {
            principal_id: u.principal_id.clone(),
            user_name: u.user_name.clone(),
            display_name: u.display_name.clone(),
            email: u.email.clone(),
            active: u.active,
            source: u.source.as_str().to_owned(),
            bound: u.subject.is_some(),
            created_at_unix_ms: u.created,
            last_login_unix_ms: u.last_login.unwrap_or(0),
            external_id: u.external_id.clone(),
            rebind: u.rebind,
        },
        deleted: u.deleted,
    }
}

fn token_out(t: &core_sso::ScimToken) -> ScimToken {
    ScimToken {
        token_id: t.id.clone(),
        account: t.account.clone(),
        label: t.label.clone(),
        created_at_unix_ms: t.created,
        created_by: t.created_by.clone(),
        last_used_unix_ms: t.last_used.unwrap_or(0),
        expires_at_unix_ms: t.expires.unwrap_or(0),
        read_only: t.read_only,
        attention: String::new(),
    }
}

fn group_out(g: &core_sso::SsoGroup) -> GroupRow {
    GroupRow {
        id: g.id.clone(),
        account: g.account.clone(),
        display_name: g.display_name.clone(),
        external_id: g.external_id.clone(),
        members: g.members.clone(),
        created_at_unix_ms: g.created,
    }
}

fn room_ask(room: &gunnar_grpc::sso::plane::Room) -> gunnar_core::RoomAsk {
    gunnar_core::RoomAsk {
        room_bytes: room.room_bytes,
        cap: room.cap,
        stored: room.stored,
        ..gunnar_core::RoomAsk::default()
    }
}

impl CoreControlPlane {
    /// The ids a NEW employee for `user_name` may take: the table's
    /// candidates, minus any that names a namespace (that would make the
    /// employee its admin by name) or a console's reserved name.
    fn sso_candidates(
        &self,
        account: &str,
        user_name: &str,
    ) -> ControlResult<Vec<gunnar_core::Principal>> {
        let mut out = Vec::new();
        for id in core_sso::principal_id_candidates(account, user_name) {
            if gunnar_grpc::service::access::is_reserved_principal_name(&id) {
                continue;
            }
            if self
                .stores
                .catalog()
                .namespace(&id)
                .map_err(internal)?
                .is_some()
            {
                continue;
            }
            out.push(
                gunnar_core::Principal::new(&id, &id, gunnar_core::PrincipalKind::User)
                    .map_err(internal)?,
            );
        }
        Ok(out)
    }

    fn account_exists(&self, account: &str) -> ControlResult<()> {
        if self
            .stores
            .catalog()
            .namespace(account)
            .map_err(internal)?
            .is_none()
        {
            return Err(ControlError::NotFound(format!("account {account}")));
        }
        Ok(())
    }

    /// A provisioning ask as the table takes it, its candidate ids found.
    fn provision_of(&self, ask: &ProvisionAsk) -> ControlResult<gunnar_core::SsoProvision> {
        Ok(gunnar_core::SsoProvision {
            account: ask.account.clone(),
            user_name: ask.user_name.clone(),
            display_name: ask.display_name.clone(),
            email: ask.email.clone(),
            external_id: ask.external_id.clone(),
            active: ask.active,
            source: if ask.source == "csv" {
                core_sso::SsoSource::Csv
            } else {
                core_sso::SsoSource::Scim
            },
            candidates: self.sso_candidates(&ask.account, &ask.user_name)?,
            room: room_ask(&ask.room),
            actor: ask.actor.clone(),
            now: ask.now_unix_ms,
        })
    }

    fn client_secret_stored(&self, account: &str) -> ControlResult<bool> {
        let secrets = self.stores.secrets().map_err(|err| {
            ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
        })?;
        Ok(
            gunnar_store::SecretStore::get(&secrets, &core_sso::client_secret_key(account))
                .map_err(|err| {
                    ControlError::Internal(format!("the client secret could not be read: {err}"))
                })?
                .is_some(),
        )
    }
}

impl SsoPlane for CoreControlPlane {
    fn provider(&self, account: &str) -> ControlResult<Option<SsoProvider>> {
        match self.stores.auth().sso_provider(account).map_err(internal)? {
            Some(p) => Ok(Some(provider_out(&p, self.client_secret_stored(account)?))),
            None => Ok(None),
        }
    }

    fn any_provider(&self) -> ControlResult<bool> {
        Ok(!self
            .stores
            .auth()
            .sso_providers()
            .map_err(internal)?
            .is_empty())
    }

    fn set_provider(
        &self,
        provider: SsoProvider,
        secret: SecretChange,
        actor: &str,
    ) -> ControlResult<SsoProvider> {
        let mut row = provider_in(&provider)?;
        row.updated_by = actor.to_owned();
        if row.updated == 0 {
            row.updated = gunnar_core::now_ms();
        }
        let account = row.account.clone();
        let saved = self
            .stores
            .auth()
            .set_sso_provider(row)
            .map_err(sso_error)?;
        let key = core_sso::client_secret_key(&account);
        let secrets = self.stores.secrets().map_err(|err| {
            ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
        })?;
        match secret {
            SecretChange::Keep => {}
            SecretChange::Clear => {
                gunnar_store::SecretStore::delete(&secrets, &key).map_err(|err| {
                    ControlError::Internal(format!("the client secret could not be removed: {err}"))
                })?;
            }
            SecretChange::Set(value) => {
                // `{err}` and never the value: this string reaches a client.
                gunnar_store::SecretStore::put(&secrets, &key, value.as_bytes()).map_err(
                    |err| {
                        ControlError::Internal(format!(
                            "the client secret could not be sealed: {err}"
                        ))
                    },
                )?;
            }
        }
        self.stores.mirror_identity();
        Ok(provider_out(&saved, self.client_secret_stored(&account)?))
    }

    fn remove_provider(&self, account: &str) -> ControlResult<bool> {
        let removed = self
            .stores
            .auth()
            .remove_sso_provider(account)
            .map_err(internal)?
            .is_some();
        if let Ok(secrets) = self.stores.secrets() {
            let _ =
                gunnar_store::SecretStore::delete(&secrets, &core_sso::client_secret_key(account));
        }
        self.stores.mirror_identity();
        Ok(removed)
    }

    fn account_for_domain(&self, domain: &str) -> ControlResult<Option<String>> {
        self.stores
            .auth()
            .sso_account_for_domain(domain)
            .map_err(internal)
    }

    fn client_secret(&self, account: &str) -> ControlResult<Option<String>> {
        let secrets = self.stores.secrets().map_err(|err| {
            ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
        })?;
        let raw = gunnar_store::SecretStore::get(&secrets, &core_sso::client_secret_key(account))
            .map_err(|err| {
            ControlError::Internal(format!("the client secret could not be opened: {err}"))
        })?;
        raw.map(|bytes| {
            String::from_utf8(bytes)
                .map_err(|_| ControlError::Internal("the stored client secret is not text".into()))
        })
        .transpose()
    }

    fn login(&self, ask: SsoLoginAsk) -> ControlResult<SsoLoginOutcome> {
        let candidates = self.sso_candidates(&ask.account, &ask.user_name)?;
        let done = self
            .stores
            .auth()
            .sso_login(&gunnar_core::SsoLogin {
                account: ask.account.clone(),
                subject: core_sso::subject_key(&ask.issuer, &ask.subject),
                user_name: ask.user_name.clone(),
                display_name: ask.display_name.clone(),
                email: ask.email.clone(),
                email_verified: ask.email_verified,
                idp_subjects: ask.idp_ids.clone(),
                candidates,
                room: room_ask(&ask.room),
                session: ask.session.clone(),
                now: ask.now_unix_ms,
            })
            .map_err(sso_error)?;
        self.stores.mirror_identity();
        // A session was issued (no second factor owed): the person's last
        // sign-in (2026-10-21). A login that still owes a passkey is stamped
        // when that passkey login commits (`web_login`).
        if done.session.is_some() {
            self.stores
                .activity()
                .signed_in(&done.principal.id, ask.now_unix_ms);
        }
        Ok(SsoLoginOutcome {
            principal: principal_out(&done.principal),
            user: user_out(&done.user),
            created: done.created,
            claimed: done.claimed,
            claimed_by: done.claimed_by.unwrap_or_default().to_owned(),
            fingerprint: done.session.map(|k| k.fingerprint_sha256),
        })
    }

    fn directory(&self, account: &str) -> ControlResult<Vec<DirUser>> {
        Ok(self
            .stores
            .auth()
            .sso_users_of(account)
            .map_err(internal)?
            .iter()
            .map(user_out)
            .collect())
    }

    fn provision(&self, ask: ProvisionAsk) -> ControlResult<DirUser> {
        self.account_exists(&ask.account)?;
        let provision = self.provision_of(&ask)?;
        let user = self
            .stores
            .auth()
            .sso_provision(&provision)
            .map_err(sso_error)?;
        self.stores.mirror_identity();
        Ok(user_out(&user))
    }

    fn import(
        &self,
        account: &str,
        rows: &[(String, ImportRow)],
        actor: &str,
        now_unix_ms: i64,
        commit: bool,
    ) -> ControlResult<()> {
        self.account_exists(account)?;
        let mut table = Vec::with_capacity(rows.len());
        for (label, row) in rows {
            let op = match row {
                ImportRow::Create(ask) => gunnar_core::SsoImportOp::Create(self.provision_of(ask)?),
                ImportRow::Update {
                    principal_id,
                    patch,
                } => gunnar_core::SsoImportOp::Update {
                    principal_id: principal_id.clone(),
                    patch: gunnar_core::SsoUserPatch {
                        user_name: patch.user_name.clone(),
                        display_name: patch.display_name.clone(),
                        email: patch.email.clone(),
                        external_id: patch.external_id.clone(),
                        active: patch.active,
                    },
                },
            };
            table.push(gunnar_core::SsoImportRow {
                label: label.clone(),
                op,
            });
        }
        self.stores
            .auth()
            .sso_import(account, &table, actor, now_unix_ms, commit)
            .map_err(|(label, err)| match sso_error(err) {
                ControlError::NotFound(m) => ControlError::NotFound(format!("{label}: {m}")),
                ControlError::AlreadyExists(m) => {
                    ControlError::AlreadyExists(format!("{label}: {m}"))
                }
                ControlError::InvalidArgument(m) => {
                    ControlError::InvalidArgument(format!("{label}: {m}"))
                }
                ControlError::FailedPrecondition(m) => {
                    ControlError::FailedPrecondition(format!("{label}: {m}"))
                }
                ControlError::PermissionDenied(m) => {
                    ControlError::PermissionDenied(format!("{label}: {m}"))
                }
                other => other,
            })?;
        if commit {
            self.stores.mirror_identity();
        }
        Ok(())
    }

    fn update_user(
        &self,
        account: &str,
        principal_id: &str,
        patch: UserPatch,
        actor: &str,
    ) -> ControlResult<(DirUser, bool)> {
        let (user, changed) = self
            .stores
            .auth()
            .sso_user_update(
                account,
                principal_id,
                &gunnar_core::SsoUserPatch {
                    user_name: patch.user_name,
                    display_name: patch.display_name,
                    email: patch.email,
                    external_id: patch.external_id,
                    active: patch.active,
                },
                actor,
                gunnar_core::now_ms(),
            )
            .map_err(sso_error)?;
        self.stores.mirror_identity();
        Ok((user_out(&user), changed))
    }

    fn delete_user(
        &self,
        account: &str,
        principal_id: &str,
        actor: &str,
    ) -> ControlResult<DirUser> {
        let user = self
            .stores
            .auth()
            .sso_user_delete(account, principal_id, actor, gunnar_core::now_ms())
            .map_err(sso_error)?;
        self.stores.mirror_identity();
        Ok(user_out(&user))
    }

    fn create_scim_token(
        &self,
        account: &str,
        id: &str,
        label: &str,
        secret_hash: &str,
        created_by: &str,
        expires_at_unix_ms: i64,
        read_only: bool,
    ) -> ControlResult<ScimToken> {
        let token = self
            .stores
            .auth()
            .create_scim_token(core_sso::ScimToken {
                id: id.to_owned(),
                account: account.to_owned(),
                secret_hash: secret_hash.to_owned(),
                label: label.to_owned(),
                created: gunnar_core::now_ms(),
                created_by: created_by.to_owned(),
                last_used: None,
                expires: (expires_at_unix_ms > 0).then_some(expires_at_unix_ms),
                read_only,
            })
            .map_err(sso_error)?;
        self.stores.mirror_identity();
        Ok(token_out(&token))
    }

    fn scim_tokens(&self, account: &str) -> ControlResult<Vec<ScimToken>> {
        Ok(self
            .stores
            .auth()
            .scim_tokens_of(account)
            .map_err(internal)?
            .iter()
            .map(token_out)
            .collect())
    }

    fn revoke_scim_token(&self, account: &str, id: &str) -> ControlResult<bool> {
        let revoked = self
            .stores
            .auth()
            .revoke_scim_token(account, id)
            .map_err(internal)?
            .is_some();
        self.stores.mirror_identity();
        Ok(revoked)
    }

    fn scim_account(&self, secret_hash: &str) -> ControlResult<Option<(String, Bearer)>> {
        let Some(token) = self
            .stores
            .auth()
            .scim_token_by_hash(secret_hash)
            .map_err(internal)?
        else {
            return Ok(None);
        };
        let _ = self
            .stores
            .auth()
            .touch_scim_token(secret_hash, gunnar_core::now_ms());
        let bearer = Bearer {
            expires_at_unix_ms: token.expires.unwrap_or(0),
            scope: if token.read_only {
                Scope::Read
            } else {
                Scope::ReadWrite
            },
        };
        Ok(Some((token.account, bearer)))
    }

    fn groups(&self, account: &str) -> ControlResult<Vec<GroupRow>> {
        Ok(self
            .stores
            .auth()
            .sso_groups_of(account)
            .map_err(internal)?
            .iter()
            .map(group_out)
            .collect())
    }

    fn put_group(&self, group: GroupRow) -> ControlResult<GroupRow> {
        let saved = self
            .stores
            .auth()
            .put_sso_group(core_sso::SsoGroup {
                id: group.id,
                account: group.account,
                display_name: group.display_name,
                external_id: group.external_id,
                members: group.members,
                created: group.created_at_unix_ms,
            })
            .map_err(sso_error)?;
        Ok(group_out(&saved))
    }

    fn remove_group(&self, account: &str, id: &str) -> ControlResult<bool> {
        Ok(self
            .stores
            .auth()
            .remove_sso_group(account, id)
            .map_err(internal)?
            .is_some())
    }

    fn passkey_login_refused(&self, principal_id: &str) -> ControlResult<Option<String>> {
        let Some(account) = self
            .stores
            .auth()
            .employer_of(principal_id)
            .map_err(internal)?
        else {
            return Ok(None);
        };
        let Some(provider) = self
            .stores
            .auth()
            .sso_provider(&account)
            .map_err(internal)?
        else {
            return Ok(None);
        };
        Ok(provider.require_sso.then(|| {
            format!(
                "{principal_id} is an employee of {account}, which signs its people in through its identity provider: \
                 use \"Sign in with {}\"",
                provider.display_name
            )
        }))
    }

    fn passkeys_of(&self, principal_id: &str) -> ControlResult<Vec<Vec<u8>>> {
        Ok(self
            .stores
            .auth()
            .web_credentials(Some(principal_id))
            .map_err(internal)?
            .iter()
            .filter_map(|c| gunnar_grpc::webauthn::from_b64url(&c.id))
            .collect())
    }

    fn verify_domain(&self, account: &str, domain: &str) -> ControlResult<SsoProvider> {
        let saved = self
            .stores
            .auth()
            .verify_sso_domain(account, domain)
            .map_err(sso_error)?;
        self.stores.mirror_identity();
        Ok(provider_out(&saved, self.client_secret_stored(account)?))
    }

    fn rebind_users(
        &self,
        account: &str,
        principal_ids: &[String],
        external_id: &str,
    ) -> ControlResult<Vec<DirUser>> {
        let external = Some(external_id).filter(|e| !e.is_empty());
        let rows = self
            .stores
            .auth()
            .sso_user_rebind(account, principal_ids, external)
            .map_err(sso_error)?;
        self.stores.mirror_identity();
        Ok(rows.iter().map(user_out).collect())
    }
}