use super::{AuthDoc, AuthTable, RoomAsk, SeatLot};
use crate::error::{Error, Result};
use crate::gate::{AccountGate, GateReason};
use crate::model::{Principal, PublicKey, UnixMillis};
use crate::sso::{ScimToken, SsoGroup, SsoProvider, SsoSource, SsoUser};
#[derive(Clone, Debug)]
pub struct SsoLogin {
pub account: String,
pub subject: String,
pub user_name: String,
pub display_name: String,
pub email: String,
pub candidates: Vec<Principal>,
pub room: RoomAsk,
pub session: Option<(String, UnixMillis)>,
pub now: UnixMillis,
}
#[derive(Clone, Debug)]
pub struct SsoLoginDone {
pub principal: Principal,
pub user: SsoUser,
pub created: bool,
pub claimed: bool,
pub session: Option<PublicKey>,
}
#[derive(Clone, Debug)]
pub struct SsoProvision {
pub account: String,
pub user_name: String,
pub display_name: String,
pub email: String,
pub external_id: String,
pub active: bool,
pub source: SsoSource,
pub candidates: Vec<Principal>,
pub room: RoomAsk,
pub actor: String,
pub now: UnixMillis,
}
#[derive(Clone, Debug, Default)]
pub struct SsoUserPatch {
pub user_name: Option<String>,
pub display_name: Option<String>,
pub email: Option<String>,
pub external_id: Option<String>,
pub active: Option<bool>,
}
fn subject_index(account: &str, subject: &str) -> String {
format!("s\u{1f}{account}\u{1f}{subject}")
}
fn user_index(account: &str, user_name: &str) -> String {
format!("u\u{1f}{account}\u{1f}{user_name}")
}
fn refused(reason: impl Into<String>) -> Error {
Error::SsoRefused(reason.into())
}
impl AuthTable {
pub(super) fn sso_index_of_account(key: &str, account: &str) -> bool {
let mut parts = key.splitn(3, '\u{1f}');
let _kind = parts.next();
parts.next() == Some(account)
}
pub fn sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
self.doc.load()?.sso_providers.get(account)
}
pub fn sso_providers(&self) -> Result<Vec<SsoProvider>> {
self.doc.load()?.sso_providers.values()
}
pub fn sso_account_for_domain(&self, domain: &str) -> Result<Option<String>> {
self.doc.load()?.sso_domains.get(domain)
}
pub fn set_sso_provider(&self, provider: SsoProvider) -> Result<SsoProvider> {
self.doc.update(|doc| {
if !doc.principals.contains_key(&provider.account)? {
return Err(Error::NoSuchPrincipal(provider.account.clone()));
}
if doc.employers.contains_key(&provider.account)? {
return Err(Error::InvalidInput {
what: "identity provider",
reason: format!(
"{} is an employee, not an account: only an account has an identity provider",
provider.account
),
});
}
for domain in &provider.domains {
if let Some(holder) = doc.sso_domains.get(domain)? {
if holder != provider.account {
return Err(Error::InvalidInput {
what: "identity provider",
reason: format!("the domain {domain} already leads to another account's sign-in"),
});
}
}
}
if let Some(old) = doc.sso_providers.get(&provider.account)? {
for domain in old.domains {
doc.sso_domains.remove(&domain)?;
}
}
for domain in &provider.domains {
doc.sso_domains.insert(domain.clone(), provider.account.clone())?;
}
doc.sso_providers.insert(provider.account.clone(), provider.clone())?;
Ok(provider.clone())
})
}
pub fn remove_sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
self.doc.update(|doc| {
let removed = doc.sso_providers.remove(account)?;
if let Some(provider) = &removed {
for domain in &provider.domains {
doc.sso_domains.remove(domain)?;
}
}
Ok(removed)
})
}
pub fn sso_user(&self, principal_id: &str) -> Result<Option<SsoUser>> {
self.doc.load()?.sso_users.get(principal_id)
}
pub fn sso_users_of(&self, account: &str) -> Result<Vec<SsoUser>> {
let mut rows: Vec<SsoUser> = self
.doc
.load()?
.sso_users
.values()?
.into_iter()
.filter(|u| u.account == account)
.collect();
rows.sort_by(|a, b| {
a.user_name
.cmp(&b.user_name)
.then_with(|| a.principal_id.cmp(&b.principal_id))
});
Ok(rows)
}
pub fn sso_principal_for_subject(
&self,
account: &str,
subject: &str,
) -> Result<Option<String>> {
self.doc
.load()?
.sso_index
.get(&subject_index(account, subject))
}
fn free_candidate(doc: &AuthDoc, candidates: &[Principal]) -> Result<Option<Principal>> {
for candidate in candidates {
if doc.principals.contains_key(&candidate.id)? {
continue;
}
if Self::refuse_taken_name(doc, &candidate.name, &candidate.id).is_err() {
continue;
}
if Self::resolve_name(doc, &candidate.id)?.is_some() {
continue;
}
return Ok(Some(candidate.clone()));
}
Ok(None)
}
fn create_directory_employee(
doc: &mut AuthDoc,
account: &str,
candidates: &[Principal],
room: &RoomAsk,
user: SsoUser,
) -> Result<(Principal, SsoUser)> {
let principal = Self::free_candidate(doc, candidates)?.ok_or_else(|| {
refused(format!(
"no free id for {:?} in {account}: every candidate is taken",
user.user_name
))
})?;
let principal = Principal {
created: user.created,
..principal
};
let lots: Vec<SeatLot> = doc.seats.get(account)?.unwrap_or_default();
let ask = RoomAsk {
from_seat: lots.iter().any(|lot| lot.open() > 0),
seats: 0,
dry_run: false,
order: String::new(),
..room.clone()
};
let plan = Self::plan_room(doc, account, Some(&principal), &ask)?;
if plan.answer.shortfall_bytes > 0 {
return Err(Error::RoomShort {
account: account.to_owned(),
need: plan.answer.need_bytes,
unused: plan.answer.unused_bytes.unwrap_or(0),
short: plan.answer.shortfall_bytes,
});
}
let mut seats = lots;
Self::write_employee(
doc,
account,
&principal,
plan.lot,
"",
plan.answer.room_bytes,
&mut seats,
)?;
if !seats.is_empty() {
doc.seats.insert(account.to_owned(), seats)?;
}
let user = SsoUser {
principal_id: principal.id.clone(),
..user
};
doc.sso_index
.insert(user_index(account, &user.user_name), principal.id.clone())?;
if let Some(subject) = &user.subject {
doc.sso_index
.insert(subject_index(account, subject), principal.id.clone())?;
}
doc.sso_users.insert(principal.id.clone(), user.clone())?;
Ok((principal, user))
}
pub fn sso_login(&self, login: &SsoLogin) -> Result<SsoLoginDone> {
let session_key = match &login.session {
Some((openssh, expires)) => {
let parsed = super::parse_public_key(openssh)?;
let fingerprint = super::Fingerprint::of_key(&parsed);
Some(PublicKey {
fingerprint_sha256: fingerprint.as_str().to_owned(),
algo: parsed.algorithm().as_str().to_owned(),
principal_id: String::new(),
added: login.now,
last_used: None,
expires: Some(*expires),
openssh: parsed.to_openssh().map_err(Error::from)?.trim().to_owned(),
})
}
None => None,
};
let account = login.account.as_str();
self.doc.update(|doc| {
if !doc.sso_providers.contains_key(account)? {
return Err(refused(format!("{account} has no identity provider")));
}
let mut created = false;
let mut claimed = false;
let bound = doc.sso_index.get(&subject_index(account, &login.subject))?;
let (principal, mut user) = match bound {
Some(id) => {
let user = doc
.sso_users
.get(&id)?
.filter(|u| u.account == account)
.ok_or_else(|| refused(format!("the subject is bound to {id}, which is not in {account}'s directory")))?;
let principal = doc.principals.get(&id)?.ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
(principal, user)
}
None => match doc.sso_index.get(&user_index(account, &login.user_name))? {
Some(id) => {
let mut user = doc
.sso_users
.get(&id)?
.filter(|u| u.account == account && !u.deleted)
.ok_or_else(|| refused(format!("{:?} is not in {account}'s directory", login.user_name)))?;
if user.subject.is_some() {
return Err(refused(format!(
"the directory user {:?} is already bound to another identity at the IdP; \
it is never re-pointed — the account admin removes it first",
login.user_name
)));
}
if user.source == SsoSource::Jit {
return Err(refused(format!("{:?} was created by another login", login.user_name)));
}
user.subject = Some(login.subject.clone());
doc.sso_index.insert(subject_index(account, &login.subject), id.clone())?;
claimed = true;
let principal = doc.principals.get(&id)?.ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
(principal, user)
}
None => {
let user = SsoUser {
principal_id: String::new(),
account: account.to_owned(),
user_name: login.user_name.clone(),
display_name: login.display_name.clone(),
email: login.email.clone(),
external_id: String::new(),
subject: Some(login.subject.clone()),
active: true,
source: SsoSource::Jit,
created: login.now,
last_login: None,
sessions: Vec::new(),
deleted: false,
};
created = true;
Self::create_directory_employee(doc, account, &login.candidates, &login.room, user)?
}
},
};
if doc.employers.get(&principal.id)?.as_deref() != Some(account) {
return Err(refused(format!("{} is not an employee of {account}", principal.id)));
}
if !user.active || user.deleted {
return Err(refused(format!(
"{} is deactivated in {account}'s directory",
principal.id
)));
}
if Self::gated(doc, &principal.id)? {
return Err(refused(format!("{} is locked out", principal.id)));
}
if !created {
if !login.display_name.is_empty() {
user.display_name = login.display_name.clone();
}
if !login.email.is_empty() {
user.email = login.email.clone();
}
}
user.last_login = Some(login.now);
let mut enrolled = None;
if let Some(key) = &session_key {
if let Some(existing) = doc.keys.get(&key.fingerprint_sha256)? {
return Err(Error::KeyExists {
fingerprint: key.fingerprint_sha256.clone(),
principal_id: existing.principal_id,
});
}
let key = PublicKey { principal_id: principal.id.clone(), ..key.clone() };
let mut live = Vec::with_capacity(user.sessions.len() + 1);
for held in std::mem::take(&mut user.sessions) {
if doc.keys.get(&held)?.is_some_and(|k| k.is_valid_at(login.now)) {
live.push(held);
}
}
live.push(key.fingerprint_sha256.clone());
user.sessions = live;
doc.keys.insert(key.fingerprint_sha256.clone(), key.clone())?;
enrolled = Some(key);
}
doc.sso_users.insert(principal.id.clone(), user.clone())?;
Ok(SsoLoginDone { principal, user, created, claimed, session: enrolled })
})
}
pub fn sso_provision(&self, provision: &SsoProvision) -> Result<SsoUser> {
let account = provision.account.as_str();
self.doc.update(|doc| {
if !doc.principals.contains_key(account)? {
return Err(Error::NoSuchPrincipal(account.to_owned()));
}
if let Some(held) = doc
.sso_index
.get(&user_index(account, &provision.user_name))?
{
return Err(Error::NameExists {
name: provision.user_name.clone(),
principal_id: held,
});
}
let user = SsoUser {
principal_id: String::new(),
account: account.to_owned(),
user_name: provision.user_name.clone(),
display_name: provision.display_name.clone(),
email: provision.email.clone(),
external_id: provision.external_id.clone(),
subject: None,
active: true,
source: provision.source,
created: provision.now,
last_login: None,
sessions: Vec::new(),
deleted: false,
};
let (_, mut user) = Self::create_directory_employee(
doc,
account,
&provision.candidates,
&provision.room,
user,
)?;
if !provision.active {
Self::deactivate(doc, &mut user, &provision.actor, provision.now)?;
doc.sso_users
.insert(user.principal_id.clone(), user.clone())?;
}
Ok(user)
})
}
pub fn sso_user_update(
&self,
account: &str,
principal_id: &str,
patch: &SsoUserPatch,
actor: &str,
now: UnixMillis,
) -> Result<(SsoUser, bool)> {
self.doc.update(|doc| {
let mut user = doc
.sso_users
.get(principal_id)?
.filter(|u| u.account == account && !u.deleted)
.ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
if let Some(name) = &patch.user_name {
if *name != user.user_name {
if let Some(held) = doc.sso_index.get(&user_index(account, name))? {
if held != user.principal_id {
return Err(Error::NameExists {
name: name.clone(),
principal_id: held,
});
}
}
doc.sso_index
.remove(&user_index(account, &user.user_name))?;
doc.sso_index
.insert(user_index(account, name), user.principal_id.clone())?;
user.user_name = name.clone();
}
}
if let Some(v) = &patch.display_name {
user.display_name = v.clone();
}
if let Some(v) = &patch.email {
user.email = v.clone();
}
if let Some(v) = &patch.external_id {
user.external_id = v.clone();
}
let mut changed = false;
match patch.active {
Some(false) if user.active => {
Self::deactivate(doc, &mut user, actor, now)?;
changed = true;
}
Some(true) if !user.active => {
user.active = true;
let holder = crate::sso::gate_holder(account);
if doc
.gates
.get(&user.principal_id)?
.is_some_and(|g| g.holder == holder)
{
doc.gates.remove(&user.principal_id)?;
}
changed = true;
}
_ => {}
}
doc.sso_users
.insert(user.principal_id.clone(), user.clone())?;
Ok((user, changed))
})
}
pub fn sso_user_delete(
&self,
account: &str,
principal_id: &str,
actor: &str,
now: UnixMillis,
) -> Result<SsoUser> {
self.doc.update(|doc| {
let mut user = doc
.sso_users
.get(principal_id)?
.filter(|u| u.account == account && !u.deleted)
.ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
if user.active {
Self::deactivate(doc, &mut user, actor, now)?;
}
user.deleted = true;
doc.sso_index
.remove(&user_index(account, &user.user_name))?;
for (gid, mut group) in doc.sso_groups.entries()? {
if group.account == account && group.members.contains(&user.principal_id) {
group.members.retain(|m| *m != user.principal_id);
doc.sso_groups.insert(gid, group)?;
}
}
doc.sso_users
.insert(user.principal_id.clone(), user.clone())?;
Ok(user)
})
}
fn deactivate(
doc: &mut AuthDoc,
user: &mut SsoUser,
actor: &str,
now: UnixMillis,
) -> Result<()> {
user.active = false;
let id = user.principal_id.clone();
let mut ended: Vec<String> = std::mem::take(&mut user.sessions);
for (cid, mut credential) in doc.credentials.entries()? {
if credential.principal_id == id && !credential.sessions.is_empty() {
ended.append(&mut credential.sessions);
doc.credentials.insert(cid, credential)?;
}
}
for fingerprint in ended {
if doc
.keys
.get(&fingerprint)?
.is_some_and(|k| k.principal_id == id)
{
doc.keys.remove(&fingerprint)?;
}
}
let holder = crate::sso::gate_holder(&user.account);
let held_by_another = doc
.gates
.get(&id)?
.is_some_and(|g| g.holder != holder && g.is_live_at(now));
if !held_by_another {
doc.gates.insert(
id.clone(),
AccountGate {
account: id.clone(),
reason: GateReason::Administrative,
holder,
actor: actor.to_owned(),
note: format!("deactivated in {}'s directory", user.account),
at: now,
expires: None,
},
)?;
}
Ok(())
}
pub fn create_scim_token(&self, token: ScimToken) -> Result<ScimToken> {
self.doc.update(|doc| {
if !doc.principals.contains_key(&token.account)? {
return Err(Error::NoSuchPrincipal(token.account.clone()));
}
if doc.scim_tokens.contains_key(&token.secret_hash)? {
return Err(Error::TokenExists {
hash: token.secret_hash.clone(),
principal_id: token.account.clone(),
});
}
doc.scim_tokens
.insert(token.secret_hash.clone(), token.clone())?;
Ok(token.clone())
})
}
pub fn scim_token_by_hash(&self, hash: &str) -> Result<Option<ScimToken>> {
self.doc.load()?.scim_tokens.get(hash)
}
pub fn scim_tokens_of(&self, account: &str) -> Result<Vec<ScimToken>> {
let mut rows: Vec<ScimToken> = self
.doc
.load()?
.scim_tokens
.values()?
.into_iter()
.filter(|t| t.account == account)
.collect();
rows.sort_by(|a, b| (a.created, &a.id).cmp(&(b.created, &b.id)));
Ok(rows)
}
pub fn revoke_scim_token(&self, account: &str, id: &str) -> Result<Option<ScimToken>> {
self.doc.update(|doc| {
let hit = doc
.scim_tokens
.values()?
.into_iter()
.find(|t| t.account == account && t.id == id);
match hit {
Some(token) => doc.scim_tokens.remove(&token.secret_hash),
None => Ok(None),
}
})
}
pub fn touch_scim_token(&self, hash: &str, now: UnixMillis) -> Result<()> {
let stale = self.doc.load()?.scim_tokens.get(hash)?.is_some_and(|t| {
t.last_used
.is_none_or(|at| now - at >= super::LAST_USED_GRANULARITY)
});
if !stale {
return Ok(());
}
self.doc.update(|doc| {
doc.scim_tokens.modify(hash, |t| t.last_used = Some(now))?;
Ok(())
})
}
pub fn sso_groups_of(&self, account: &str) -> Result<Vec<SsoGroup>> {
let mut rows: Vec<SsoGroup> = self
.doc
.load()?
.sso_groups
.values()?
.into_iter()
.filter(|g| g.account == account)
.collect();
rows.sort_by(|a, b| {
a.display_name
.cmp(&b.display_name)
.then_with(|| a.id.cmp(&b.id))
});
Ok(rows)
}
pub fn put_sso_group(&self, group: SsoGroup) -> Result<SsoGroup> {
self.doc.update(|doc| {
for member in &group.members {
let ok = doc
.sso_users
.get(member)?
.is_some_and(|u| u.account == group.account && !u.deleted);
if !ok {
return Err(Error::InvalidInput {
what: "group",
reason: format!("{member} is not a user of {}'s directory", group.account),
});
}
}
for other in doc.sso_groups.values()? {
if other.account == group.account
&& other.id != group.id
&& other.display_name.eq_ignore_ascii_case(&group.display_name)
{
return Err(Error::NameExists {
name: group.display_name.clone(),
principal_id: other.id,
});
}
}
if let Some(old) = doc.sso_groups.get(&group.id)? {
if old.account != group.account {
return Err(Error::InvalidInput {
what: "group",
reason: "the group is another account's".to_owned(),
});
}
}
doc.sso_groups.insert(group.id.clone(), group.clone())?;
Ok(group.clone())
})
}
pub fn remove_sso_group(&self, account: &str, id: &str) -> Result<Option<SsoGroup>> {
self.doc.update(|doc| match doc.sso_groups.get(id)? {
Some(group) if group.account == account => doc.sso_groups.remove(id),
_ => Ok(None),
})
}
pub fn sso_live_sessions(&self, principal_id: &str, now: UnixMillis) -> Result<usize> {
let doc = self.doc.load()?;
let Some(user) = doc.sso_users.get(principal_id)? else {
return Ok(0);
};
let mut n = 0;
for fp in &user.sessions {
if doc.keys.get(fp)?.is_some_and(|k| k.is_valid_at(now)) {
n += 1;
}
}
Ok(n)
}
}
#[cfg(test)]
mod older_reader_tests {
use std::collections::BTreeSet;
use std::sync::Arc;
use super::super::{AuthTable, AUTH_REF};
use crate::control::ControlStore;
use crate::model::{Principal, PrincipalKind};
use crate::sso::{OidcSettings, SsoProtocol, SsoProvider};
const OLDER: &[&str] = &[
"principals", "keys", "grants", "tokens", "memberships", "bootstrap", "gates", "names",
"employers", "rooms", "seats", "room_orders", "credentials", "invites",
];
fn stored_keys(store: &Arc<dyn ControlStore>) -> BTreeSet<String> {
let id = store.read_doc(AUTH_REF).unwrap().expect("the table was written");
let bytes = store.get_blob(&id).unwrap().expect("its blob");
let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
doc.as_object().expect("an object").keys().cloned().collect()
}
fn provider(account: &str) -> SsoProvider {
SsoProvider {
account: account.to_owned(),
protocol: SsoProtocol::Oidc,
display_name: format!("{account} corp"),
domains: vec![format!("{account}.example")],
oidc: Some(OidcSettings {
issuer: "https://idp.example.test".into(),
client_id: "c".into(),
..Default::default()
}),
saml: None,
require_sso: false,
require_passkey: false,
console_origin: "https://gunnar.example".into(),
updated: 1,
updated_by: account.to_owned(),
}
}
#[test]
fn sso_maps_are_written_only_when_used() {
let store = crate::memory_control_store().expect("store");
let auth = AuthTable::new(Arc::clone(&store)).expect("table");
auth.add_principal(Principal::new("acme", "acme", PrincipalKind::User).unwrap())
.expect("account");
let keys = stored_keys(&store);
let unknown: Vec<&String> = keys.iter().filter(|k| !OLDER.contains(&k.as_str())).collect();
assert!(
unknown.is_empty(),
"a table without SSO carries keys an older gunnar refuses: {unknown:?}"
);
auth.set_sso_provider(provider("acme")).expect("provider");
let keys = stored_keys(&store);
assert!(
keys.contains("sso_providers") && keys.contains("sso_domains"),
"a provider is written: {keys:?}"
);
auth.remove_sso_provider("acme").expect("removed");
let keys = stored_keys(&store);
let unknown: Vec<&String> = keys.iter().filter(|k| !OLDER.contains(&k.as_str())).collect();
assert!(unknown.is_empty(), "the provider is gone, its maps must be too: {unknown:?}");
}
}