crates/gunnar-grpc/src/sso/plane.rs
//! **The SSO half of the storage seam.** A control plane with an identity
//! table answers [`crate::seam::ControlPlane::sso`] with one of these; one
//! without (a bare test plane) answers `None`, and every `Sso` verb is then
//! `UNIMPLEMENTED` rather than a guess.
//!
//! The rows cross as proto messages where one exists; the asks are plain
//! structs. Nothing here carries a secret except [`SsoPlane::client_secret`]'s
//! answer, which never leaves gunnar.
use gunnar_proto::{DirectoryUser, Principal, ScimToken, SsoProvider};
use crate::seam::ControlResult;
/// What happens to the stored OIDC client secret.
#[derive(Clone, PartialEq, Eq)]
pub enum SecretChange {
Keep,
Clear,
Set(String),
}
impl std::fmt::Debug for SecretChange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
SecretChange::Keep => "Keep",
SecretChange::Clear => "Clear",
SecretChange::Set(_) => "Set(<redacted>)",
})
}
}
/// The room a NEW employee holds (computed by the service from the account's
/// entitlement and usage, as `Accounts.EnrollEmployee` does).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Room {
pub room_bytes: u64,
pub cap: Option<u64>,
pub stored: u64,
}
/// One verified SSO login, to commit.
#[derive(Clone)]
pub struct SsoLoginAsk {
pub account: String,
/// The OIDC issuer or the SAML IdP entity id.
pub issuer: String,
/// The IdP's subject (OIDC `sub`, the SAML NameID or mapped attribute).
pub subject: String,
/// The directory user name (normalized lowercase).
pub user_name: String,
pub display_name: String,
pub email: String,
/// The IdP asserted the e-mail is verified (OIDC `email_verified`).
pub email_verified: bool,
/// The IdP's stable ids for the person (`sub`/`oid`, the SAML NameID): a
/// provisioned user whose `externalId` is one of them is the admin's
/// binding (A34-2).
pub idp_ids: Vec<String>,
pub room: Room,
/// The session key (one OpenSSH line) and its expiry; `None` when a
/// second factor is owed first.
pub session: Option<(String, i64)>,
pub now_unix_ms: i64,
}
impl std::fmt::Debug for SsoLoginAsk {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SsoLoginAsk")
.field("account", &self.account)
.field("user_name", &self.user_name)
.finish_non_exhaustive()
}
}
/// A directory row as the seam carries it.
#[derive(Debug, Clone, PartialEq)]
pub struct DirUser {
pub user: DirectoryUser,
/// Removed from the directory (SCIM DELETE): kept gated, never listed.
pub deleted: bool,
}
/// What a committed SSO login answers.
#[derive(Debug, Clone)]
pub struct SsoLoginOutcome {
pub principal: Principal,
pub user: DirUser,
pub created: bool,
pub claimed: bool,
/// How a provisioned user was claimed (`external_id`, `verified_email`);
/// empty when none was.
pub claimed_by: String,
/// The enrolled session key's fingerprint, when one was asked.
pub fingerprint: Option<String>,
}
/// A NEW employee from the directory (SCIM create, a CSV row).
#[derive(Debug, Clone)]
pub struct ProvisionAsk {
pub account: String,
pub user_name: String,
pub display_name: String,
pub email: String,
pub external_id: String,
pub active: bool,
/// `scim` or `csv`.
pub source: &'static str,
pub room: Room,
pub actor: String,
pub now_unix_ms: i64,
}
/// A change to a directory user; `None` leaves a field.
#[derive(Debug, Clone, Default)]
pub struct UserPatch {
pub user_name: Option<String>,
pub display_name: Option<String>,
pub email: Option<String>,
pub external_id: Option<String>,
pub active: Option<bool>,
}
/// A SCIM group as the seam carries it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupRow {
pub id: String,
pub account: String,
pub display_name: String,
pub external_id: String,
pub members: Vec<String>,
pub created_at_unix_ms: i64,
}
/// **The SSO storage seam.** Errors are the seam's: `NotFound`,
/// `AlreadyExists` (a user name taken — SCIM `uniqueness`), `PermissionDenied`
/// (a login the table refuses), `FailedPrecondition` (no room for a new
/// employee), `InvalidArgument`.
pub trait SsoPlane: Send + Sync {
/// An account's provider, `has_client_secret` filled, no secret.
fn provider(&self, account: &str) -> ControlResult<Option<SsoProvider>>;
/// Store a provider (validated by the service) and change the secret.
fn set_provider(
&self,
provider: SsoProvider,
secret: SecretChange,
actor: &str,
) -> ControlResult<SsoProvider>;
fn remove_provider(&self, account: &str) -> ControlResult<bool>;
/// **Whether any account has a provider configured** — an existence bit
/// for the login page's company field (`DiscoverResponse.any_provider`).
fn any_provider(&self) -> ControlResult<bool>;
fn account_for_domain(&self, domain: &str) -> ControlResult<Option<String>>;
/// The unsealed OIDC client secret, for the token request only.
fn client_secret(&self, account: &str) -> ControlResult<Option<String>>;
fn login(&self, ask: SsoLoginAsk) -> ControlResult<SsoLoginOutcome>;
/// The account's directory, deleted rows included.
fn directory(&self, account: &str) -> ControlResult<Vec<DirUser>>;
fn provision(&self, ask: ProvisionAsk) -> ControlResult<DirUser>;
/// Returns the row and whether `active` changed.
fn update_user(
&self,
account: &str,
principal_id: &str,
patch: UserPatch,
actor: &str,
) -> ControlResult<(DirUser, bool)>;
fn delete_user(&self, account: &str, principal_id: &str, actor: &str)
-> ControlResult<DirUser>;
/// A new SCIM bearer by its hash; `expires_at_unix_ms` 0 never expires,
/// `read_only` may only read (A34-27).
#[allow(clippy::too_many_arguments)]
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>;
fn scim_tokens(&self, account: &str) -> ControlResult<Vec<ScimToken>>;
fn revoke_scim_token(&self, account: &str, id: &str) -> ControlResult<bool>;
/// The account a bearer's hash provisions and the bearer's own rules
/// (expiry, scope: A34-27), and its use noted.
fn scim_account(
&self,
secret_hash: &str,
) -> ControlResult<Option<(String, super::scim::Bearer)>>;
/// **A whole directory import in ONE write** (A34-17): every row or
/// none; `commit` false checks the same rows (room, names, ids) and
/// writes nothing. A refusal names the row (`line 7: bob`).
fn import(
&self,
account: &str,
rows: &[(String, super::scim::ImportRow)],
actor: &str,
now_unix_ms: i64,
commit: bool,
) -> ControlResult<()>;
fn groups(&self, account: &str) -> ControlResult<Vec<GroupRow>>;
fn put_group(&self, group: GroupRow) -> ControlResult<GroupRow>;
fn remove_group(&self, account: &str, id: &str) -> ControlResult<bool>;
/// **`require_sso`**: the sentence a passkey-only login of this principal
/// is refused with, or `None` (not an employee, or its account does not
/// require SSO). The account admin is never an employee.
fn passkey_login_refused(&self, principal_id: &str) -> ControlResult<Option<String>>;
/// The principal's passkeys (ids), for a second factor's
/// `allowCredentials`.
fn passkeys_of(&self, principal_id: &str) -> ControlResult<Vec<Vec<u8>>>;
/// **`domain` is proven for `account`** (A34-5), after the service read its
/// DNS proof: it now leads sign-ins there and counts for claims and JIT.
/// Refused (`PermissionDenied`, by code) when it is not one of the
/// provider's domains or another account proved it first.
fn verify_domain(&self, account: &str, domain: &str) -> ControlResult<SsoProvider>;
/// **Open a rebind** (A34-11): the named employees (every bound one when
/// none is named) are unbound from their old IdP subject and bind afresh on
/// their next sign-in; `external_id` (one employee only) is its id at the new IdP.
fn rebind_users(
&self,
account: &str,
principal_ids: &[String],
external_id: &str,
) -> ControlResult<Vec<DirUser>>;
}