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_proto::{
DirectoryUser, OidcSettings, SamlSettings, ScimToken, SsoProtocol, SsoProvider,
};
use super::{internal, principal_out, CoreControlPlane};
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(),
})
}
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(),
}
}
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(),
},
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),
}
}
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 {
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 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) => {
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();
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> {
if self
.stores
.catalog()
.namespace(&ask.account)
.map_err(internal)?
.is_none()
{
return Err(ControlError::NotFound(format!("account {}", ask.account)));
}
let candidates = self.sso_candidates(&ask.account, &ask.user_name)?;
let user = self
.stores
.auth()
.sso_provision(&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,
room: room_ask(&ask.room),
actor: ask.actor.clone(),
now: ask.now_unix_ms,
})
.map_err(sso_error)?;
self.stores.mirror_identity();
Ok(user_out(&user))
}
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,
) -> 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,
})
.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>> {
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());
Ok(Some(token.account))
}
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())
}
}