use std::sync::Arc;
use crate::control::ControlStore;
use crate::doc::{impl_doc, DocRef, ShardedMap};
use crate::error::{Error, Result};
use crate::key_attestation::AttestingKey;
use crate::model::{
now_ms, AccessToken, Grant, Level, Principal, PrincipalKind, PublicKey, Repo, Scope, UnixMillis,
};
mod sso_table;
pub use sso_table::{
SsoImportOp, SsoImportRow, SsoLogin, SsoLoginDone, SsoProvision, SsoUserPatch,
};
pub const AUTH_REF: &str = "refs/gunnar/auth";
pub const BOOTSTRAP_ADMIN_ID: &str = "admin";
mod clan_table;
pub use clan_table::{
AccountRoom, Clan, ClanInvite, ClanMember, ClanRefusal, ClanRepoOffer, ClanRole,
};
pub const SHA256_PREFIX: &str = "SHA256:";
pub const PQ_RECIPIENT_ALGO: &str = "mlkem768x25519";
pub fn pq_recipient_fingerprint(line: &str) -> Option<Fingerprint> {
gunnar_crypt::members::pq_public_key_bytes(line.trim()).map(|bytes| fingerprint_of_blob(&bytes))
}
pub fn fingerprint_of_blob(ssh_wire_blob: &[u8]) -> Fingerprint {
use base64ct::Encoding as _;
use sha2::Digest as _;
let digest = sha2::Sha256::digest(ssh_wire_blob);
Fingerprint(format!(
"{SHA256_PREFIX}{}",
base64ct::Base64Unpadded::encode_string(&digest)
))
}
pub fn token_hash(secret: &str) -> String {
use sha2::Digest as _;
hex::encode(sha2::Sha256::digest(secret.as_bytes()))
}
pub const LAST_USED_GRANULARITY: UnixMillis = 60_000;
#[derive(Clone, Debug)]
pub struct BulkUser {
pub principal: Principal,
pub token_hash: Option<String>,
pub token_label: String,
pub openssh_public_key: Option<String>,
pub grants: Vec<Grant>,
}
pub fn mint_token_secret() -> String {
use base64ct::Encoding as _;
use rand::Rng as _;
let mut bytes = [0u8; 32];
crate::os_rng().fill_bytes(&mut bytes);
format!(
"gunnar_pat_{}",
base64ct::Base64UrlUnpadded::encode_string(&bytes)
)
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
pub struct Fingerprint(String);
pub fn certificate_der(raw: &[u8]) -> Result<Vec<u8>> {
use base64ct::Encoding as _;
let text = String::from_utf8_lossy(raw);
const BEGIN: &str = "-----BEGIN CERTIFICATE-----";
const END: &str = "-----END CERTIFICATE-----";
let Some(start) = text.find(BEGIN) else {
return Ok(raw.to_vec());
};
let body = &text[start + BEGIN.len()..];
let end = body
.find(END)
.ok_or_else(|| Error::Key("the PEM has a BEGIN line and no END line".to_owned()))?;
let b64: String = body[..end].chars().filter(|c| !c.is_whitespace()).collect();
base64ct::Base64::decode_vec(&b64)
.map_err(|e| Error::Key(format!("the PEM body is not valid base64: {e}")))
}
pub fn fingerprint_of_certificate(der: &[u8]) -> Fingerprint {
use base64ct::Encoding as _;
use sha2::Digest as _;
let digest = sha2::Sha256::digest(der);
Fingerprint(format!(
"{SHA256_PREFIX}{}",
base64ct::Base64Unpadded::encode_string(&digest)
))
}
impl Fingerprint {
pub fn of_key(key: &ssh_key::PublicKey) -> Self {
let blob = key
.to_bytes()
.expect("a parsed public key always re-encodes to its own wire form");
fingerprint_of_blob(&blob)
}
pub fn of_openssh(line: &str) -> Result<Self> {
Ok(Self::of_key(&parse_public_key(line)?))
}
pub fn parse(text: &str) -> Result<Self> {
let body = text.strip_prefix(SHA256_PREFIX).ok_or_else(|| {
Error::BadFingerprint(format!("{text:?}: missing the {SHA256_PREFIX} prefix"))
})?;
if body.len() != 43 {
return Err(Error::BadFingerprint(format!(
"{text:?}: expected 43 base64 characters after the prefix, got {}",
body.len()
)));
}
if !body
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
{
return Err(Error::BadFingerprint(format!(
"{text:?}: not unpadded base64"
)));
}
Ok(Fingerprint(text.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Fingerprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::fmt::Debug for Fingerprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Fingerprint({})", self.0)
}
}
impl std::str::FromStr for Fingerprint {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Fingerprint::parse(s)
}
}
pub fn parse_public_key(line: &str) -> Result<ssh_key::PublicKey> {
let line = line.trim();
if line.is_empty() {
return Err(Error::Key("empty".to_owned()));
}
ssh_key::PublicKey::from_openssh(line).map_err(Error::from)
}
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct AuthDoc {
#[serde(default)]
principals: ShardedMap<Principal>,
#[serde(default)]
keys: ShardedMap<PublicKey>,
#[serde(default)]
grants: ShardedMap<Vec<Grant>>,
#[serde(default)]
tokens: ShardedMap<AccessToken>,
#[serde(default)]
memberships: ShardedMap<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bootstrap: Option<BootstrapRecord>,
#[serde(default)]
gates: ShardedMap<crate::gate::AccountGate>,
#[serde(default)]
names: ShardedMap<String>,
#[serde(default)]
employers: ShardedMap<String>,
#[serde(default)]
rooms: ShardedMap<EmployeeRoom>,
#[serde(default)]
seats: ShardedMap<Vec<SeatLot>>,
#[serde(default)]
room_orders: ShardedMap<RoomGrant>,
#[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
clans: ShardedMap<clan_table::Clan>,
#[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
clan_index: ShardedMap<Vec<String>>,
#[serde(default)]
credentials: ShardedMap<crate::model::WebCredential>,
#[serde(default)]
invites: ShardedMap<crate::model::FirstInviteRow>,
#[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
redeemed_invites: ShardedMap<RedeemedInvite>,
#[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
operator_access: ShardedMap<operator_access::OperatorAccess>,
#[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
sso_providers: ShardedMap<crate::sso::SsoProvider>,
#[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
sso_domains: ShardedMap<String>,
#[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
sso_users: ShardedMap<crate::sso::SsoUser>,
#[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
sso_index: ShardedMap<String>,
#[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
scim_tokens: ShardedMap<crate::sso::ScimToken>,
#[serde(default, skip_serializing_if = "ShardedMap::is_empty")]
sso_groups: ShardedMap<crate::sso::SsoGroup>,
}
impl_doc!(AuthDoc {
principals,
keys,
grants,
tokens,
memberships,
gates,
names,
clans,
clan_index,
employers,
rooms,
seats,
room_orders,
credentials,
invites,
redeemed_invites,
operator_access,
sso_providers,
sso_domains,
sso_users,
sso_index,
scim_tokens,
sso_groups
});
pub const REDEEMED_INVITE_KEEP_MS: UnixMillis = 30 * 24 * 60 * 60 * 1000;
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct RedeemedInvite {
pub principal_id: String,
pub redeemed: UnixMillis,
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EmployeeRoom {
pub room_bytes: u64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub order: String,
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SeatLot {
pub room_bytes: u64,
pub total: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub filled: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub order: String,
pub at: UnixMillis,
}
impl SeatLot {
pub fn open(&self) -> u32 {
self.total
.saturating_sub(u32::try_from(self.filled.len()).unwrap_or(u32::MAX))
}
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RoomGrant {
pub account: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub principal: String,
#[serde(default)]
pub seats: u32,
pub room_bytes: u64,
#[serde(default)]
pub need_bytes: u64,
}
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct RoomAsk {
pub room_bytes: u64,
pub seats: u32,
pub from_seat: bool,
pub cap: Option<u64>,
pub stored: u64,
pub order: String,
pub dry_run: bool,
}
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct RoomAnswer {
pub principal: Option<Principal>,
pub room_bytes: u64,
pub seats: u32,
pub seats_open: u32,
pub need_bytes: u64,
pub unused_bytes: Option<u64>,
pub shortfall_bytes: u64,
pub unchanged: bool,
}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BootstrapRecord {
pub at: UnixMillis,
pub principal_id: String,
pub fingerprint: String,
pub account: String,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum Bootstrap {
Installed(Principal),
AlreadyProvisioned,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum Readiness {
Ready,
AwaitingBootstrap,
SealedAndEmpty {
record: Option<Box<BootstrapRecord>>,
},
}
impl Readiness {
pub fn bootstrap_window_open(&self) -> bool {
matches!(self, Readiness::AwaitingBootstrap)
}
pub fn has_an_identity(&self) -> bool {
matches!(self, Readiness::Ready)
}
}
impl std::fmt::Display for Readiness {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Readiness::Ready => f.write_str("ready"),
Readiness::AwaitingBootstrap => f.write_str(
"AWAITING BOOTSTRAP: no identity has ever been enrolled, so Bootstrap.Enroll is \
open to any caller that can reach the control port",
),
Readiness::SealedAndEmpty {
record: Some(record),
} => write!(
f,
"bootstrapped at {} by {} but every identity has since been removed; the \
enrolment window stays SHUT and nobody can reach this server",
record.at, record.principal_id
),
Readiness::SealedAndEmpty { record: None } => f.write_str(
"this identity table holds principals but no keys and carries no bootstrap \
record; the enrolment window stays SHUT and nobody can reach this server",
),
}
}
}
#[derive(Clone, Debug)]
pub struct AuthTable {
doc: DocRef<AuthDoc>,
}
impl AuthTable {
pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
Ok(AuthTable {
doc: DocRef::new(store, AUTH_REF)?,
})
}
pub fn ref_name(&self) -> &str {
self.doc.ref_name()
}
pub fn history(&self) -> Result<Vec<crate::control::DocVersion>> {
self.doc.history()
}
pub fn add_principal(&self, principal: Principal) -> Result<Principal> {
crate::sandbox::check_component(&principal.id, &principal.id)?;
if principal.id == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Err(Error::ReservedPrincipalId(principal.id));
}
let principal = Principal {
created: now_ms(),
..principal
};
self.doc.update(|doc| {
if doc.principals.contains_key(&principal.id)? {
return Err(Error::PrincipalExists(principal.id.clone()));
}
Self::refuse_taken_name(doc, &principal.name, &principal.id)?;
doc.principals
.insert(principal.id.clone(), principal.clone())?;
Self::index_name(doc, &principal)?;
Ok(principal.clone())
})
}
pub fn rename_principal(&self, id: &str, name: &str) -> Result<Principal> {
self.doc.update(|doc| {
let before = doc
.principals
.get(id)?
.ok_or_else(|| Error::NoSuchPrincipal(id.to_owned()))?;
if before.name == name {
return Ok(before);
}
Self::refuse_taken_name(doc, name, id)?;
let after = Principal {
name: name.to_owned(),
..before.clone()
};
if doc.names.get(&before.name)?.as_deref() == Some(id) {
doc.names.remove(&before.name)?;
}
doc.principals.insert(id.to_owned(), after.clone())?;
Self::index_name(doc, &after)?;
Ok(after)
})
}
pub fn principal_by_name(&self, name: &str) -> Result<Option<Principal>> {
let doc = self.doc.load()?;
Self::resolve_name(&doc, name)
}
fn resolve_name(doc: &AuthDoc, name: &str) -> Result<Option<Principal>> {
if let Some(id) = doc.names.get(name)? {
return doc.principals.get(&id);
}
Ok(doc
.principals
.get(name)?
.filter(|principal| principal.name == name))
}
fn refuse_taken_name(doc: &AuthDoc, name: &str, own_id: &str) -> Result<()> {
let holder = match Self::resolve_name(doc, name)? {
Some(principal) => Some(principal.id),
None if name != own_id && doc.principals.contains_key(name)? => Some(name.to_owned()),
None => None,
};
match holder {
Some(id) if id != own_id => Err(Error::NameExists {
name: name.to_owned(),
principal_id: id,
}),
_ => Ok(()),
}
}
fn index_name(doc: &mut AuthDoc, principal: &Principal) -> Result<()> {
if !doc.clans.is_empty() && doc.clans.contains_key(&principal.id)? {
return Err(Error::PrincipalExists(principal.id.clone()));
}
doc.names
.insert(principal.name.clone(), principal.id.clone())?;
Ok(())
}
pub fn remove_principal(&self, id: &str) -> Result<Principal> {
self.doc.update(|doc| {
let principal = doc
.principals
.remove(id)?
.ok_or_else(|| Error::NoSuchPrincipal(id.to_owned()))?;
if doc.names.get(&principal.name)?.as_deref() == Some(id) {
doc.names.remove(&principal.name)?;
}
doc.keys.retain(|_, key| key.principal_id != id)?;
doc.tokens.retain(|_, token| token.principal_id != id)?;
doc.grants.remove(id)?;
doc.memberships.remove(id)?;
let strip: Vec<(String, Vec<String>)> = doc
.memberships
.entries()?
.into_iter()
.filter(|(_, groups)| groups.iter().any(|g| g == id))
.map(|(member, mut groups)| {
groups.retain(|g| g != id);
(member, groups)
})
.collect();
for (member, groups) in strip {
if groups.is_empty() {
doc.memberships.remove(&member)?;
} else {
doc.memberships.insert(member, groups)?;
}
}
doc.gates.remove(id)?;
doc.employers.remove(id)?;
doc.rooms.remove(id)?;
let reopen: Vec<(String, Vec<SeatLot>)> = doc
.seats
.entries()?
.into_iter()
.filter(|(_, lots)| lots.iter().any(|lot| lot.filled.iter().any(|f| f == id)))
.map(|(account, mut lots)| {
for lot in &mut lots {
lot.filled.retain(|f| f != id);
}
(account, lots)
})
.collect();
for (account, lots) in reopen {
doc.seats.insert(account, lots)?;
}
doc.seats.remove(id)?;
doc.room_orders
.retain(|_, grant| grant.account != id && grant.principal != id)?;
Self::forget_clan_member(doc, id)?;
doc.credentials
.retain(|_, credential| credential.principal_id != id)?;
doc.invites.retain(|_, invite| invite.principal_id != id)?;
doc.redeemed_invites
.retain(|_, row| row.principal_id != id)?;
doc.operator_access
.retain(|_, look| look.account != id && look.operator != id)?;
doc.sso_users.remove(id)?;
doc.sso_index.retain(|_, held| held != id)?;
doc.sso_users.retain(|_, user| user.account != id)?;
doc.sso_index
.retain(|key, _| !Self::sso_index_of_account(key, id))?;
if let Some(provider) = doc.sso_providers.remove(id)? {
for domain in provider.domains {
doc.sso_domains.remove(&domain)?;
}
}
doc.scim_tokens.retain(|_, token| token.account != id)?;
doc.sso_groups.retain(|_, group| group.account != id)?;
for (gid, mut group) in doc.sso_groups.entries()? {
if group.members.iter().any(|m| m == id) {
group.members.retain(|m| m != id);
doc.sso_groups.insert(gid, group)?;
}
}
Ok(principal)
})
}
pub fn enroll_employee(&self, account: &str, principal: Principal) -> Result<Principal> {
let answer = self.enroll_with_room(account, Some(principal), &RoomAsk::default())?;
answer.principal.ok_or_else(|| Error::InvalidInput {
what: "employee",
reason: "the enrolment named nobody".to_owned(),
})
}
pub fn enroll_with_room(
&self,
account: &str,
principal: Option<Principal>,
ask: &RoomAsk,
) -> Result<RoomAnswer> {
let principal = match principal {
Some(principal) => {
crate::sandbox::check_component(&principal.id, &principal.id)?;
if principal.id == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Err(Error::ReservedPrincipalId(principal.id));
}
if principal.kind != crate::model::PrincipalKind::User {
return Err(Error::InvalidInput {
what: "employee",
reason: format!(
"an employee is a person; {} is a {:?}, enroll a user",
principal.id, principal.kind
),
});
}
if principal.id == account {
return Err(Error::InvalidInput {
what: "employee",
reason: format!("{account} is the account itself, not an employee of it"),
});
}
Some(Principal {
created: now_ms(),
..principal
})
}
None => None,
};
if principal.is_none() && (ask.seats == 0 || ask.from_seat) {
return Err(Error::InvalidInput {
what: "employee",
reason: "one employee, or a seat filled, needs a name".to_owned(),
});
}
if principal.is_some() && ask.seats > 0 {
return Err(Error::InvalidInput {
what: "employee",
reason: "seats name nobody: a seat is named when it is filled".to_owned(),
});
}
if ask.dry_run {
let doc = self.doc.load()?;
return Self::plan_room(&doc, account, principal.as_ref(), ask).map(|plan| plan.answer);
}
self.doc.update(|doc| {
let plan = Self::plan_room(doc, account, principal.as_ref(), ask)?;
if plan.answer.unchanged {
return Ok(plan.answer);
}
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 answer = plan.answer.clone();
let mut seats = doc.seats.get(account)?.unwrap_or_default();
match &principal {
Some(principal) => {
Self::write_employee(
doc,
account,
principal,
plan.lot,
&ask.order,
answer.room_bytes,
&mut seats,
)?;
answer.principal = Some(principal.clone());
}
None => seats.push(SeatLot {
room_bytes: answer.room_bytes,
total: ask.seats,
filled: Vec::new(),
order: ask.order.clone(),
at: now_ms(),
}),
}
answer.seats = seats.iter().map(|lot| lot.total).sum();
answer.seats_open = seats.iter().map(SeatLot::open).sum();
if !seats.is_empty() {
doc.seats.insert(account.to_owned(), seats)?;
}
if !ask.order.is_empty() {
doc.room_orders.insert(
ask.order.clone(),
RoomGrant {
account: account.to_owned(),
principal: principal.as_ref().map(|p| p.id.clone()).unwrap_or_default(),
seats: ask.seats,
room_bytes: answer.room_bytes,
need_bytes: answer.need_bytes,
},
)?;
}
Ok(answer)
})
}
fn write_employee(
doc: &mut AuthDoc,
account: &str,
principal: &Principal,
lot: Option<usize>,
order: &str,
room_bytes: u64,
seats: &mut [SeatLot],
) -> Result<()> {
doc.principals
.insert(principal.id.clone(), principal.clone())?;
Self::index_name(doc, principal)?;
doc.employers
.insert(principal.id.clone(), account.to_owned())?;
doc.grants.insert(
principal.id.clone(),
vec![Grant {
principal: principal.id.clone(),
scope: Scope::Namespace(account.to_owned()),
level: Level::Read,
}],
)?;
let order = match lot {
Some(lot) => {
seats[lot].filled.push(principal.id.clone());
seats[lot].order.clone()
}
None => order.to_owned(),
};
if room_bytes > 0 {
doc.rooms
.insert(principal.id.clone(), EmployeeRoom { room_bytes, order })?;
}
Ok(())
}
fn plan_room(
doc: &AuthDoc,
account: &str,
principal: Option<&Principal>,
ask: &RoomAsk,
) -> Result<RoomPlan> {
let lots = doc.seats.get(account)?.unwrap_or_default();
let seats = lots.iter().map(|lot| lot.total).sum();
let seats_open = lots.iter().map(SeatLot::open).sum();
if !ask.order.is_empty() {
if let Some(grant) = doc.room_orders.get(&ask.order)? {
if grant.account != account {
return Err(Error::InvalidInput {
what: "order",
reason: format!(
"order {} was granted to {}, not to {account}",
ask.order, grant.account
),
});
}
let principal = match grant.principal.as_str() {
"" => None,
id => doc.principals.get(id)?,
};
return Ok(RoomPlan {
answer: RoomAnswer {
principal,
room_bytes: grant.room_bytes,
seats,
seats_open,
need_bytes: grant.need_bytes,
unused_bytes: None,
shortfall_bytes: 0,
unchanged: true,
},
lot: None,
});
}
}
if let Some(principal) = principal {
if doc.principals.contains_key(&principal.id)? {
return Err(Error::PrincipalExists(principal.id.clone()));
}
Self::refuse_taken_name(doc, &principal.name, &principal.id)?;
}
let lot = if ask.from_seat {
Some(
lots.iter()
.position(|lot| lot.open() > 0)
.ok_or_else(|| Error::NoOpenSeat(account.to_owned()))?,
)
} else {
None
};
let room_bytes = match lot {
Some(lot) => lots[lot].room_bytes,
None => ask.room_bytes,
};
let need_bytes = match (lot, ask.seats) {
(Some(_), _) => 0,
(None, 0) => room_bytes,
(None, seats) => room_bytes.saturating_mul(u64::from(seats)),
};
let committed = match ask.cap {
None => None,
Some(_) => Some(
Self::promised_by(doc, account)?
.saturating_add(Self::contributed_in(doc, account, None)?)
.saturating_add(ask.stored),
),
};
let unused_bytes = ask
.cap
.zip(committed)
.map(|(cap, committed)| cap.saturating_sub(committed));
let shortfall_bytes = ask.cap.zip(committed).map_or(0, |(cap, committed)| {
need_bytes.saturating_add(committed).saturating_sub(cap)
});
Ok(RoomPlan {
answer: RoomAnswer {
principal: None,
room_bytes,
seats,
seats_open,
need_bytes,
unused_bytes,
shortfall_bytes,
unchanged: ask.dry_run,
},
lot,
})
}
fn promised_by(doc: &AuthDoc, account: &str) -> Result<u64> {
let lots = doc.seats.get(account)?.unwrap_or_default();
let mut promised: u64 = lots
.iter()
.map(|lot| lot.room_bytes.saturating_mul(u64::from(lot.open())))
.fold(0, u64::saturating_add);
for (id, employer) in doc.employers.entries()? {
if employer == account {
if let Some(room) = doc.rooms.get(&id)? {
promised = promised.saturating_add(room.room_bytes);
}
}
}
Ok(promised)
}
pub fn seats_of(&self, account: &str) -> Result<(u32, u32)> {
let lots = self.doc.load()?.seats.get(account)?.unwrap_or_default();
Ok((
lots.iter().map(|lot| lot.total).sum(),
lots.iter().map(SeatLot::open).sum(),
))
}
pub fn room_of(&self, principal_id: &str) -> Result<Option<EmployeeRoom>> {
self.doc.load()?.rooms.get(principal_id)
}
pub fn employer_of(&self, principal_id: &str) -> Result<Option<String>> {
self.doc.load()?.employers.get(principal_id)
}
pub fn employees_of(&self, account: &str) -> Result<Vec<Principal>> {
let doc = self.doc.load()?;
let mut out = Vec::new();
for (id, employer) in doc.employers.entries()? {
if employer == account {
if let Some(p) = doc.principals.get(&id)? {
out.push(p);
}
}
}
out.sort_by(|a, b| a.id.cmp(&b.id));
Ok(out)
}
pub fn is_enterprise(&self, account: &str) -> Result<bool> {
self.doc
.load()?
.employers
.any(|_, employer| employer == account)
}
pub fn principal(&self, id: &str) -> Result<Option<Principal>> {
self.doc.load()?.principals.get(id)
}
pub fn principals(&self) -> Result<Vec<Principal>> {
self.doc.load()?.principals.values()
}
pub fn register_certificate(
&self,
principal_id: &str,
der: &[u8],
expires: Option<UnixMillis>,
) -> Result<PublicKey> {
let fingerprint = fingerprint_of_certificate(der);
let record = PublicKey {
fingerprint_sha256: fingerprint.as_str().to_owned(),
algo: "x509".to_owned(),
principal_id: principal_id.to_owned(),
added: now_ms(),
last_used: None,
expires,
openssh: {
use base64ct::Encoding as _;
base64ct::Base64::encode_string(der)
},
};
self.doc.update(|doc| {
if !doc.principals.contains_key(principal_id)? {
return Err(Error::NoSuchPrincipal(principal_id.to_owned()));
}
if let Some(existing) = doc.keys.get(fingerprint.as_str())? {
return Err(Error::KeyExists {
fingerprint: fingerprint.as_str().to_owned(),
principal_id: existing.principal_id.clone(),
});
}
doc.keys
.insert(fingerprint.as_str().to_owned(), record.clone())?;
Ok(record.clone())
})
}
pub fn register_key(
&self,
principal_id: &str,
openssh: &str,
expires: Option<UnixMillis>,
) -> Result<PublicKey> {
let (fingerprint, algo, line) = match pq_recipient_fingerprint(openssh) {
Some(fingerprint) => (
fingerprint,
PQ_RECIPIENT_ALGO.to_owned(),
openssh.trim().to_owned(),
),
None => {
let parsed = parse_public_key(openssh)?;
(
Fingerprint::of_key(&parsed),
parsed.algorithm().as_str().to_owned(),
parsed.to_openssh().map_err(Error::from)?.trim().to_owned(),
)
}
};
let record = PublicKey {
fingerprint_sha256: fingerprint.as_str().to_owned(),
algo,
principal_id: principal_id.to_owned(),
added: now_ms(),
last_used: None,
expires,
openssh: line,
};
self.doc.update(|doc| {
if !doc.principals.contains_key(principal_id)? {
return Err(Error::NoSuchPrincipal(principal_id.to_owned()));
}
if let Some(existing) = doc.keys.get(fingerprint.as_str())? {
return Err(Error::KeyExists {
fingerprint: fingerprint.as_str().to_owned(),
principal_id: existing.principal_id.clone(),
});
}
doc.keys
.insert(fingerprint.as_str().to_owned(), record.clone())?;
Ok(record.clone())
})
}
pub fn add_web_credential(
&self,
credential: crate::model::WebCredential,
) -> Result<crate::model::WebCredential> {
self.doc.update(|doc| {
let principal = doc
.principals
.get(&credential.principal_id)?
.ok_or_else(|| Error::NoSuchPrincipal(credential.principal_id.clone()))?;
if principal.kind != crate::model::PrincipalKind::User {
return Err(Error::NotAPerson {
principal_id: principal.id.clone(),
kind: format!("{:?}", principal.kind).to_lowercase(),
});
}
if let Some(existing) = doc.credentials.get(&credential.id)? {
return Err(Error::CredentialExists {
id: credential.id.clone(),
principal_id: existing.principal_id,
});
}
doc.credentials
.insert(credential.id.clone(), credential.clone())?;
Ok(credential.clone())
})
}
pub fn touch_web_credential(
&self,
id: &str,
presented: u32,
now: UnixMillis,
) -> Result<crate::model::WebCredential> {
self.doc.update(|doc| {
let mut credential = doc
.credentials
.get(id)?
.ok_or_else(|| Error::NoSuchCredential(id.to_owned()))?;
if credential.sign_count != 0 && presented != 0 && presented <= credential.sign_count {
return Err(Error::CounterRollback {
id: id.to_owned(),
stored: credential.sign_count,
presented,
});
}
if presented != 0 {
credential.sign_count = presented;
}
credential.last_used = Some(now);
doc.credentials.insert(id.to_owned(), credential.clone())?;
Ok(credential)
})
}
pub fn web_credential(&self, id: &str) -> Result<Option<crate::model::WebCredential>> {
self.doc.load()?.credentials.get(id)
}
pub fn web_credentials(
&self,
principal_id: Option<&str>,
) -> Result<Vec<crate::model::WebCredential>> {
let mut rows: Vec<crate::model::WebCredential> = self
.doc
.load()?
.credentials
.values()?
.into_iter()
.filter(|c| principal_id.is_none_or(|want| c.principal_id == want))
.collect();
rows.sort_by(|a, b| {
(a.principal_id.as_str(), a.created, a.id.as_str()).cmp(&(
b.principal_id.as_str(),
b.created,
b.id.as_str(),
))
});
Ok(rows)
}
pub fn revoke_web_credential(&self, id: &str) -> Result<Option<crate::model::WebCredential>> {
let removed = self.doc.update(|doc| {
let removed = doc.credentials.remove(id)?;
if let Some(credential) = &removed {
for fingerprint in &credential.sessions {
if doc
.keys
.get(fingerprint)?
.is_some_and(|key| key.principal_id == credential.principal_id)
{
doc.keys.remove(fingerprint)?;
}
}
}
Ok(removed)
})?;
if let Some(removed) = &removed {
tracing::info!(credential = %id, principal = %removed.principal_id, "web credential revoked");
}
Ok(removed)
}
pub fn approve_machine_credential(
&self,
machine: &str,
level: Level,
mut credential: crate::model::WebCredential,
step_up: Option<(&str, u32)>,
now: UnixMillis,
) -> Result<(Principal, crate::model::WebCredential, bool)> {
crate::sandbox::check_component(machine, machine)?;
if machine == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Err(Error::ReservedPrincipalId(machine.to_owned()));
}
self.doc.update(|doc| {
if let Some(existing) = doc.credentials.get(&credential.id)? {
return Err(Error::CredentialExists {
id: credential.id.clone(),
principal_id: existing.principal_id,
});
}
let approver = match step_up {
Some((id, presented)) => {
let mut held = doc
.credentials
.get(id)?
.ok_or_else(|| Error::NoSuchCredential(id.to_owned()))?;
if held.sign_count != 0 && presented != 0 && presented <= held.sign_count {
return Err(Error::CounterRollback {
id: id.to_owned(),
stored: held.sign_count,
presented,
});
}
if presented != 0 {
held.sign_count = presented;
}
held.last_used = Some(now);
Some((id.to_owned(), held))
}
None => None,
};
let (principal, created) = match doc.principals.get(machine)? {
Some(held) if held.kind != crate::model::PrincipalKind::Machine => {
return Err(Error::NotAMachine {
principal_id: held.id.clone(),
kind: format!("{:?}", held.kind).to_lowercase(),
})
}
Some(held) => (held, false),
None => {
Self::refuse_taken_name(doc, machine, machine)?;
let made = Principal {
id: machine.to_owned(),
name: machine.to_owned(),
kind: crate::model::PrincipalKind::Machine,
created: now,
};
doc.principals.insert(made.id.clone(), made.clone())?;
Self::index_name(doc, &made)?;
(made, true)
}
};
if let Some((id, held)) = approver {
doc.credentials.insert(id, held)?;
}
let grant = Grant::server(machine.to_owned(), level);
let mut grants = doc.grants.get(machine)?.unwrap_or_default();
grants.retain(|old| old.scope != grant.scope);
grants.push(grant);
grants.sort();
doc.grants.insert(machine.to_owned(), grants)?;
credential.principal_id = machine.to_owned();
doc.credentials
.insert(credential.id.clone(), credential.clone())?;
Ok((principal, credential.clone(), created))
})
}
pub fn rename_web_credential(
&self,
id: &str,
label: &str,
) -> Result<Option<crate::model::WebCredential>> {
self.doc.update(|doc| {
let Some(mut credential) = doc.credentials.get(id)? else {
return Ok(None);
};
credential.label = label.to_owned();
doc.credentials.insert(id.to_owned(), credential.clone())?;
Ok(Some(credential))
})
}
fn invitable(doc: &AuthDoc, principal_id: &str) -> Result<Principal> {
let principal = doc
.principals
.get(principal_id)?
.ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
if principal.kind != crate::model::PrincipalKind::User {
return Err(Error::NotAPerson {
principal_id: principal.id.clone(),
kind: format!("{:?}", principal.kind).to_lowercase(),
});
}
Ok(principal)
}
pub fn put_first_invite(&self, invite: crate::model::FirstInviteRow) -> Result<()> {
self.doc.update(|doc| {
Self::invitable(doc, &invite.principal_id)?;
doc.invites.insert(invite.id.clone(), invite.clone())?;
Ok(())
})
}
pub fn first_invite(&self, id: &str) -> Result<Option<crate::model::FirstInviteRow>> {
self.doc.load()?.invites.get(id)
}
pub fn drop_first_invite(&self, id: &str) -> Result<Option<crate::model::FirstInviteRow>> {
self.doc.update(|doc| doc.invites.remove(id))
}
pub fn revoke_key(&self, fingerprint: &Fingerprint) -> Result<Option<PublicKey>> {
let removed = self
.doc
.update(|doc| doc.keys.remove(fingerprint.as_str()))?;
if removed.is_some() {
tracing::info!(%fingerprint, "public key revoked");
}
Ok(removed)
}
pub fn supersede_certificates(
&self,
principal_id: &str,
spki: &[u8],
) -> Result<Vec<PublicKey>> {
use base64ct::Encoding as _;
self.doc.update(|doc| {
let mut gone = Vec::new();
for row in doc.keys.values()? {
if row.principal_id != principal_id || row.algo != "x509" {
continue;
}
let same_key = base64ct::Base64::decode_vec(&row.openssh)
.ok()
.and_then(|der| crate::issuer::public_key_info_of_der(&der).ok())
.is_some_and(|key| key == spki);
if !same_key {
if let Some(removed) = doc.keys.remove(&row.fingerprint_sha256)? {
gone.push(removed);
}
}
}
Ok(gone)
})
}
pub fn keys(&self) -> Result<Vec<PublicKey>> {
self.doc.load()?.keys.values()
}
pub fn signing_keys(&self) -> Result<Vec<PublicKey>> {
self.signing_keys_at(now_ms())
}
pub fn signing_keys_at(&self, now: UnixMillis) -> Result<Vec<PublicKey>> {
Ok(self
.doc
.load()?
.keys
.values()?
.into_iter()
.filter(|key| key.is_valid_at(now))
.collect())
}
pub fn attesting_keys_of(&self, principal_id: &str) -> Result<Vec<AttestingKey>> {
let now = now_ms();
Ok(self
.keys_of(principal_id)?
.into_iter()
.filter(|key| {
key.is_valid_at(now) && key.algo != "x509" && key.algo != PQ_RECIPIENT_ALGO
})
.map(|key| AttestingKey {
fingerprint: key.fingerprint_sha256,
openssh: key.openssh,
})
.collect())
}
pub fn keys_of(&self, principal_id: &str) -> Result<Vec<PublicKey>> {
Ok(self
.doc
.load()?
.keys
.values()?
.into_iter()
.filter(|key| key.principal_id == principal_id)
.collect())
}
pub fn recipient_keys_of(&self, principal_id: &str) -> Result<Vec<PublicKey>> {
let now = now_ms();
Ok(self
.keys_of(principal_id)?
.into_iter()
.filter(|key| key.is_valid_at(now) && key.algo != "x509")
.collect())
}
pub fn principal_by_fingerprint(&self, fingerprint: &Fingerprint) -> Result<Option<Principal>> {
self.principal_by_fingerprint_at(fingerprint, now_ms())
}
pub fn principal_by_fingerprint_at(
&self,
fingerprint: &Fingerprint,
now: UnixMillis,
) -> Result<Option<Principal>> {
let doc = self.doc.load()?;
let Some(key) = doc.keys.get(fingerprint.as_str())? else {
return Ok(None);
};
if !key.is_valid_at(now) {
tracing::warn!(%fingerprint, "expired key offered");
return Ok(None);
}
doc.principals.get(&key.principal_id)
}
pub fn touch_key(&self, fingerprint: &Fingerprint, now: UnixMillis) -> Result<()> {
self.doc.update(|doc| {
doc.keys
.modify(fingerprint.as_str(), |key| key.last_used = Some(now))?;
Ok(())
})
}
pub fn register_token(
&self,
principal_id: &str,
secret: &str,
label: &str,
expires: Option<UnixMillis>,
) -> Result<AccessToken> {
if principal_id == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Err(Error::ReservedPrincipalId(principal_id.to_owned()));
}
let hash = token_hash(secret);
let record = AccessToken {
hash: hash.clone(),
principal_id: principal_id.to_owned(),
label: label.to_owned(),
created: now_ms(),
last_used: None,
expires,
};
self.doc.update(|doc| {
if !doc.principals.contains_key(principal_id)? {
return Err(Error::NoSuchPrincipal(principal_id.to_owned()));
}
if let Some(existing) = doc.tokens.get(&hash)? {
return Err(Error::TokenExists {
hash: hash.clone(),
principal_id: existing.principal_id.clone(),
});
}
doc.tokens.insert(hash.clone(), record.clone())?;
Ok(record.clone())
})
}
pub fn revoke_token(&self, hash: &str) -> Result<Option<AccessToken>> {
let removed = self.doc.update(|doc| doc.tokens.remove(hash))?;
if removed.is_some() {
tracing::info!(token = %hash, "access token revoked");
}
Ok(removed)
}
pub fn tokens(&self) -> Result<Vec<AccessToken>> {
self.doc.load()?.tokens.values()
}
pub fn principal_by_token(&self, secret: &str) -> Result<Option<Principal>> {
self.principal_by_token_at(secret, now_ms())
}
pub fn principal_by_token_at(
&self,
secret: &str,
now: UnixMillis,
) -> Result<Option<Principal>> {
let doc = self.doc.load()?;
let Some(token) = doc.tokens.get(&token_hash(secret))? else {
return Ok(None);
};
if !token.is_valid_at(now) {
tracing::warn!(token = %token.hash, "expired access token presented");
return Ok(None);
}
doc.principals.get(&token.principal_id)
}
pub fn touch_token(&self, hash: &str, now: UnixMillis) -> Result<()> {
if let Some(token) = self.doc.load()?.tokens.get(hash)? {
if let Some(last) = token.last_used {
if now.saturating_sub(last) < LAST_USED_GRANULARITY {
return Ok(());
}
}
}
self.doc.update(|doc| {
doc.tokens.modify(hash, |token| {
if token
.last_used
.is_none_or(|last| now.saturating_sub(last) >= LAST_USED_GRANULARITY)
{
token.last_used = Some(now);
}
})?;
Ok(())
})
}
pub fn bulk_add_users(&self, users: &[BulkUser], now: UnixMillis) -> Result<usize> {
for user in users {
crate::sandbox::check_component(&user.principal.id, &user.principal.id)?;
if user.principal.id == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Err(Error::ReservedPrincipalId(user.principal.id.clone()));
}
}
let mut keys: Vec<Option<PublicKey>> = Vec::with_capacity(users.len());
for user in users {
keys.push(match &user.openssh_public_key {
None => None,
Some(line) => {
let parsed = parse_public_key(line)?;
let fingerprint = Fingerprint::of_key(&parsed);
Some(PublicKey {
fingerprint_sha256: fingerprint.as_str().to_owned(),
algo: parsed.algorithm().as_str().to_owned(),
principal_id: user.principal.id.clone(),
added: now,
last_used: None,
expires: None,
openssh: parsed.to_openssh().map_err(Error::from)?.trim().to_owned(),
})
}
});
}
self.doc.update(|doc| {
for user in users {
let id = &user.principal.id;
if doc.principals.contains_key(id)? {
return Err(Error::PrincipalExists(id.clone()));
}
Self::refuse_taken_name(doc, &user.principal.name, id)?;
let principal = Principal {
created: now,
..user.principal.clone()
};
doc.principals.insert(id.clone(), principal.clone())?;
Self::index_name(doc, &principal)?;
if let Some(hash) = &user.token_hash {
if let Some(existing) = doc.tokens.get(hash)? {
return Err(Error::TokenExists {
hash: hash.clone(),
principal_id: existing.principal_id.clone(),
});
}
doc.tokens.insert(
hash.clone(),
AccessToken {
hash: hash.clone(),
principal_id: id.clone(),
label: user.token_label.clone(),
created: now,
last_used: None,
expires: None,
},
)?;
}
if !user.grants.is_empty() {
let mut held = user.grants.clone();
held.sort();
doc.grants.insert(id.clone(), held)?;
}
}
for record in keys.iter().flatten() {
if let Some(existing) = doc.keys.get(&record.fingerprint_sha256)? {
return Err(Error::KeyExists {
fingerprint: record.fingerprint_sha256.clone(),
principal_id: existing.principal_id.clone(),
});
}
doc.keys
.insert(record.fingerprint_sha256.clone(), record.clone())?;
}
Ok(users.len())
})
}
pub fn grant(&self, grant: Grant) -> Result<Grant> {
self.doc.update(|doc| {
if !doc.principals.contains_key(&grant.principal)? {
return Err(Error::NoSuchPrincipal(grant.principal.clone()));
}
let mut held = doc.grants.get(&grant.principal)?.unwrap_or_default();
held.retain(|old| old.scope != grant.scope);
held.push(grant.clone());
held.sort();
doc.grants.insert(grant.principal.clone(), held)?;
Ok(grant.clone())
})
}
pub fn revoke_grant(&self, principal_id: &str, scope: &Scope) -> Result<bool> {
self.doc.update(|doc| {
let Some(mut held) = doc.grants.get(principal_id)? else {
return Ok(false);
};
let before = held.len();
held.retain(|old| &old.scope != scope);
if held.len() == before {
return Ok(false);
}
if held.is_empty() {
doc.grants.remove(principal_id)?;
} else {
doc.grants.insert(principal_id.to_owned(), held)?;
}
Ok(true)
})
}
pub fn grants(&self, principal_id: Option<&str>) -> Result<Vec<Grant>> {
let doc = self.doc.load()?;
match principal_id {
Some(id) => Ok(doc.grants.get(id)?.unwrap_or_default()),
None => Ok(doc.grants.values()?.into_iter().flatten().collect()),
}
}
pub fn add_to_group(&self, group_id: &str, member_id: &str) -> Result<()> {
self.doc.update(|doc| {
let group = doc
.principals
.get(group_id)?
.ok_or_else(|| Error::NoSuchPrincipal(group_id.to_owned()))?;
if group.kind != PrincipalKind::Group {
return Err(Error::NotAGroup(group_id.to_owned()));
}
if !doc.principals.contains_key(member_id)? {
return Err(Error::NoSuchPrincipal(member_id.to_owned()));
}
let mut held = doc.memberships.get(member_id)?.unwrap_or_default();
if !held.iter().any(|g| g == group_id) {
held.push(group_id.to_owned());
held.sort();
doc.memberships.insert(member_id.to_owned(), held)?;
}
Ok(())
})
}
pub fn remove_from_group(&self, group_id: &str, member_id: &str) -> Result<bool> {
self.doc.update(|doc| {
let Some(mut held) = doc.memberships.get(member_id)? else {
return Ok(false);
};
let before = held.len();
held.retain(|g| g != group_id);
if held.len() == before {
return Ok(false);
}
if held.is_empty() {
doc.memberships.remove(member_id)?;
} else {
doc.memberships.insert(member_id.to_owned(), held)?;
}
Ok(true)
})
}
pub fn groups_of(&self, member_id: &str) -> Result<Vec<String>> {
Ok(self
.doc
.load()?
.memberships
.get(member_id)?
.unwrap_or_default())
}
pub fn members_of(&self, group_id: &str) -> Result<Vec<String>> {
let doc = self.doc.load()?;
let mut members: Vec<String> = doc
.memberships
.entries()?
.into_iter()
.filter(|(_, groups)| groups.iter().any(|g| g == group_id))
.map(|(member, _)| member)
.collect();
members.sort();
Ok(members)
}
pub fn groups(&self) -> Result<Vec<Principal>> {
Ok(self
.doc
.load()?
.principals
.values()?
.into_iter()
.filter(|p| p.kind == PrincipalKind::Group)
.collect())
}
fn grant_principals(doc: &AuthDoc, principal_id: &str) -> Result<Vec<String>> {
let mut seen = std::collections::BTreeSet::new();
let mut queue = std::collections::VecDeque::new();
let mut holders = Vec::new();
queue.push_back(principal_id.to_owned());
while let Some(id) = queue.pop_front() {
if !seen.insert(id.clone()) {
continue;
}
if let Some(groups) = doc.memberships.get(&id)? {
for group in groups {
if !seen.contains(&group) {
queue.push_back(group);
}
}
}
holders.push(id);
}
Ok(holders)
}
fn holder_grants(doc: &AuthDoc, principal_id: &str) -> Result<Vec<Grant>> {
let mut all = Vec::new();
for holder in Self::grant_principals(doc, principal_id)? {
if let Some(grants) = doc.grants.get(&holder)? {
all.extend(grants);
}
}
Ok(all)
}
pub fn may(&self, principal_id: &str, repo: &Repo, level: Level) -> Result<bool> {
self.may_at(principal_id, repo, level, now_ms())
}
pub fn may_at(&self, principal_id: &str, repo: &Repo, level: Level, now: i64) -> Result<bool> {
let doc = self.doc.load()?;
Self::may_in(&doc, principal_id, repo, level, now)
}
pub fn may_each(&self, principal_id: &str, repos: &[Repo], level: Level) -> Result<Vec<bool>> {
let doc = self.doc.load()?;
let now = now_ms();
repos
.iter()
.map(|repo| Self::may_in(&doc, principal_id, repo, level, now))
.collect()
}
fn may_in(
doc: &AuthDoc,
principal_id: &str,
repo: &Repo,
level: Level,
now: i64,
) -> Result<bool> {
if Self::gated(doc, principal_id)? || Self::gated(doc, &repo.namespace)? {
return Ok(false);
}
if principal_id == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Ok(level == Level::Read && repo.visibility == crate::model::Visibility::Public);
}
if !doc.principals.contains_key(principal_id)? {
return Ok(false);
}
if Self::clan_level(doc, principal_id, &repo.namespace)?.is_some_and(|have| have >= level) {
return Ok(true);
}
if Self::owns_account(principal_id, &repo.namespace) && !Self::is_member(doc, principal_id)?
{
return Ok(true);
}
if level == Level::Read && repo.visibility == crate::model::Visibility::Public {
return Ok(true);
}
let covering = Self::holder_grants(doc, principal_id)?
.into_iter()
.filter(|grant| grant.scope.covers(repo));
let best = Self::best_under(covering, || {
Self::shield_in(doc, principal_id, &repo.namespace, now)
})?;
Ok(best.is_some_and(|have| have >= level))
}
pub fn require(&self, principal_id: &str, repo: &Repo, level: Level) -> Result<()> {
if self.may(principal_id, repo, level)? {
return Ok(());
}
for account in [principal_id, repo.namespace.as_str()] {
if let Some(gate) = self.gate_covering(account)? {
return Err(Error::Gated {
account: gate.account,
reason: gate.reason.as_str().to_owned(),
note: gate.note,
});
}
}
Err(Error::Denied {
principal_id: principal_id.to_owned(),
level,
repo: repo.path(),
})
}
pub fn may_namespace(&self, principal_id: &str, namespace: &str, level: Level) -> Result<bool> {
if principal_id == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Ok(false);
}
let doc = self.doc.load()?;
if Self::gated(&doc, principal_id)? || Self::gated(&doc, namespace)? {
return Ok(false);
}
if !doc.principals.contains_key(principal_id)? {
return Ok(false);
}
if Self::clan_level(&doc, principal_id, namespace)?.is_some_and(|have| have >= level) {
return Ok(true);
}
if Self::owns_account(principal_id, namespace) && !Self::is_member(&doc, principal_id)? {
return Ok(true);
}
let covering = Self::holder_grants(&doc, principal_id)?
.into_iter()
.filter(|grant| grant.scope.covers_namespace(namespace));
let best = Self::best_under(covering, || {
Self::shield_in(&doc, principal_id, namespace, now_ms())
})?;
Ok(best.is_some_and(|have| have >= level))
}
pub fn may_server(&self, principal_id: &str, level: Level) -> Result<bool> {
if principal_id == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Ok(false);
}
let doc = self.doc.load()?;
if Self::gated(&doc, principal_id)? {
return Ok(false);
}
if !doc.principals.contains_key(principal_id)? {
return Ok(false);
}
let best = Self::holder_grants(&doc, principal_id)?
.into_iter()
.filter(|grant| grant.scope == Scope::Server)
.map(|grant| grant.level)
.max();
Ok(best.is_some_and(|have| have >= level))
}
pub fn holds_server_grant(&self, principal_id: &str) -> Result<bool> {
if principal_id == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Ok(false);
}
let doc = self.doc.load()?;
if !doc.principals.contains_key(principal_id)? {
return Ok(false);
}
Ok(Self::holder_grants(&doc, principal_id)?
.iter()
.any(|grant| grant.scope == Scope::Server))
}
pub fn owns_account(principal_id: &str, namespace: &str) -> bool {
!principal_id.is_empty()
&& principal_id != crate::model::ANONYMOUS_PRINCIPAL_ID
&& principal_id == namespace
}
fn is_member(doc: &AuthDoc, principal_id: &str) -> Result<bool> {
Ok(doc.employers.get(principal_id)?.is_some())
}
fn gated(doc: &AuthDoc, account: &str) -> Result<bool> {
let now = crate::model::now_ms();
let mut prefix = String::new();
for component in account.split('/') {
if !prefix.is_empty() {
prefix.push('/');
}
prefix.push_str(component);
if doc
.gates
.get(&prefix)?
.is_some_and(|gate| gate.is_live_at(now))
{
return Ok(true);
}
}
Ok(false)
}
pub fn gate(&self, gate: crate::gate::AccountGate) -> Result<crate::gate::AccountGate> {
crate::sandbox::check_component(&gate.account, &gate.account)?;
if gate.account == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Err(Error::ReservedPrincipalId(gate.account));
}
self.doc.update(|doc| {
doc.gates.insert(gate.account.clone(), gate.clone())?;
Ok(gate.clone())
})
}
pub fn gate_of(&self, account: &str) -> Result<Option<crate::gate::AccountGate>> {
Ok(self
.doc
.load()?
.gates
.get(account)?
.filter(|gate| gate.is_live_at(crate::model::now_ms())))
}
pub fn gate_covering(&self, account: &str) -> Result<Option<crate::gate::AccountGate>> {
let doc = self.doc.load()?;
let now = crate::model::now_ms();
let mut prefix = String::new();
for component in account.split('/') {
if !prefix.is_empty() {
prefix.push('/');
}
prefix.push_str(component);
if let Some(gate) = doc.gates.get(&prefix)?.filter(|g| g.is_live_at(now)) {
return Ok(Some(gate));
}
}
Ok(None)
}
pub fn gates(&self) -> Result<Vec<crate::gate::AccountGate>> {
let now = crate::model::now_ms();
let mut live: Vec<crate::gate::AccountGate> = self
.doc
.load()?
.gates
.values()?
.into_iter()
.filter(|gate| gate.is_live_at(now))
.collect();
live.sort_by(|a, b| a.account.cmp(&b.account));
Ok(live)
}
pub fn lift_gate(&self, account: &str) -> Result<Option<crate::gate::AccountGate>> {
self.doc.update(|doc| doc.gates.remove(account))
}
pub fn lift_gate_held_by(
&self,
account: &str,
holder: &str,
) -> Result<Option<crate::gate::AccountGate>> {
self.doc.update(|doc| {
match doc.gates.get(account)? {
Some(gate) if gate.holder == holder => doc.gates.remove(account),
_ => Ok(None),
}
})
}
pub fn renew_gate(&self, account: &str, holder: &str, expires: UnixMillis) -> Result<bool> {
self.doc.update(|doc| {
let renew = doc
.gates
.get(account)?
.is_some_and(|gate| gate.holder == holder);
if renew {
doc.gates
.modify(account, |gate| gate.expires = Some(expires))?;
}
Ok(renew)
})
}
pub fn is_empty(&self) -> Result<bool> {
Ok(self.doc.load()?.keys.is_empty())
}
pub fn bootstrap_record(&self) -> Result<Option<BootstrapRecord>> {
Ok(self.doc.load()?.bootstrap.clone())
}
pub fn counts(&self) -> Result<(usize, usize, usize)> {
let doc = self.doc.load()?;
let grants = doc.grants.values()?.iter().map(Vec::len).sum();
Ok((doc.principals.len(), doc.keys.len(), grants))
}
fn install_first_identity(&self, id: &str, name: &str, openssh: &str) -> Result<Enrolled> {
crate::sandbox::check_component(id, id)?;
if id == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Err(Error::ReservedPrincipalId(id.to_owned()));
}
let parsed = parse_public_key(openssh)?;
let fingerprint = Fingerprint::of_key(&parsed);
let algo = parsed.algorithm().as_str().to_owned();
let normalised = parsed.to_openssh().map_err(Error::from)?.trim().to_owned();
let enrolled = self.doc.update(|doc| {
if let Some(record) = &doc.bootstrap {
return Err(Error::AlreadyBootstrapped {
at: record.at,
principal_id: record.principal_id.clone(),
});
}
if !doc.principals.is_empty() || !doc.keys.is_empty() {
return Err(Error::AlreadyProvisioned {
principals: doc.principals.len(),
keys: doc.keys.len(),
});
}
let principal = Principal {
id: id.to_owned(),
name: name.to_owned(),
kind: PrincipalKind::User,
created: now_ms(),
};
doc.principals.insert(id.to_owned(), principal.clone())?;
Self::index_name(doc, &principal)?;
let key = PublicKey {
fingerprint_sha256: fingerprint.as_str().to_owned(),
algo: algo.clone(),
principal_id: id.to_owned(),
added: now_ms(),
last_used: None,
expires: None,
openssh: normalised.clone(),
};
doc.keys
.insert(fingerprint.as_str().to_owned(), key.clone())?;
doc.grants.insert(
id.to_owned(),
vec![Grant::server(id.to_owned(), Level::Admin)],
)?;
let record = BootstrapRecord {
at: now_ms(),
principal_id: id.to_owned(),
fingerprint: fingerprint.as_str().to_owned(),
account: id.to_owned(),
};
doc.bootstrap = Some(record.clone());
Ok(Enrolled {
principal,
key,
record,
})
})?;
tracing::warn!(
target: "gunnar::audit",
event = "bootstrap.enrolled",
principal = %enrolled.principal.id,
account = %enrolled.record.account,
%fingerprint,
"THE UNAUTHENTICATED ENROLMENT WINDOW IS NOW CLOSED. It cannot be reopened by \
removing this principal or this key: the record lives beside the identity table, \
not in it."
);
Ok(enrolled)
}
pub fn enroll_first_user(&self, name: &str, openssh: &str) -> Result<Enrolled> {
self.install_first_identity(name, name, openssh)
}
pub fn bootstrap_admin(&self, openssh: &str) -> Result<Bootstrap> {
match self.install_first_identity(BOOTSTRAP_ADMIN_ID, "bootstrap admin", openssh) {
Ok(enrolled) => Ok(Bootstrap::Installed(enrolled.principal)),
Err(Error::AlreadyBootstrapped { .. }) | Err(Error::AlreadyProvisioned { .. }) => {
tracing::debug!("identity table already provisioned; bootstrap key ignored");
Ok(Bootstrap::AlreadyProvisioned)
}
Err(err) => Err(err),
}
}
pub fn prepare_to_serve(&self, admin_key: Option<&str>) -> Result<Readiness> {
if let Some(key) = admin_key {
self.bootstrap_admin(key)?;
}
let doc = self.doc.load()?;
if !doc.keys.is_empty() {
return Ok(Readiness::Ready);
}
match &doc.bootstrap {
Some(record) => Ok(Readiness::SealedAndEmpty {
record: Some(Box::new(record.clone())),
}),
None if !doc.principals.is_empty() => Ok(Readiness::SealedAndEmpty { record: None }),
None => Ok(Readiness::AwaitingBootstrap),
}
}
pub fn bootstrap_window_open(&self) -> Result<bool> {
let doc = self.doc.load()?;
Ok(doc.bootstrap.is_none() && doc.principals.is_empty() && doc.keys.is_empty())
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Enrolled {
pub principal: Principal,
pub key: PublicKey,
pub record: BootstrapRecord,
}
#[cfg(test)]
mod name_index_tests {
use super::*;
#[test]
fn a_row_from_before_the_index_resolves_by_name_and_keeps_it() {
let store = crate::memory_control_store().expect("store");
let auth = AuthTable::new(store).expect("table");
let legacy = Principal::new("alice", "alice", PrincipalKind::User).unwrap();
auth.doc
.update(|doc| {
doc.principals.insert("alice".into(), legacy.clone())?;
Ok(())
})
.expect("write the old way");
assert!(
auth.doc.load().unwrap().names.is_empty(),
"the fixture must be a row with NO index entry, or the test proves nothing"
);
assert_eq!(
auth.principal_by_name("alice").expect("read").map(|p| p.id),
Some("alice".to_owned()),
"a pre-index row must be found by its name"
);
let err = auth
.add_principal(Principal::new("alice2", "alice", PrincipalKind::User).unwrap())
.expect_err("a pre-index row's name is still taken");
assert!(matches!(err, Error::NameExists { .. }), "{err}");
auth.rename_principal("alice", "Alice").expect("rename");
assert_eq!(
auth.principal_by_name("Alice").expect("read").map(|p| p.id),
Some("alice".to_owned())
);
assert!(auth.principal_by_name("alice").expect("read").is_none());
}
}
#[cfg(test)]
mod certificate_fingerprint_tests {
use super::{fingerprint_of_blob, fingerprint_of_certificate};
#[test]
fn a_certificate_fingerprint_is_stable_and_distinguishing() {
let a = b"-----BEGIN CERTIFICATE-----AAAA-----END CERTIFICATE-----";
let b = b"-----BEGIN CERTIFICATE-----AAAB-----END CERTIFICATE-----";
assert_eq!(
fingerprint_of_certificate(a),
fingerprint_of_certificate(a),
"the same certificate must fingerprint the same, or nothing could store one"
);
assert_ne!(
fingerprint_of_certificate(a),
fingerprint_of_certificate(b),
"two certificates differing by one byte must not share a fingerprint"
);
}
#[test]
fn the_two_credential_kinds_share_one_hash_space_and_that_is_recorded() {
let bytes = b"identical bytes, two meanings";
assert_eq!(
fingerprint_of_certificate(bytes),
fingerprint_of_blob(bytes),
"if these ever differ, the hash grew a domain separator and the AuthTable's \
existing entries are all invalidated — which is a migration, not a tidy-up"
);
}
}
struct RoomPlan {
answer: RoomAnswer,
lot: Option<usize>,
}
pub mod operator_access;
#[cfg(test)]
mod seller_mark_tests {
use super::*;
use crate::model::{Grant, Level};
fn table() -> AuthTable {
AuthTable::new(crate::memory_control_store().expect("store")).expect("table")
}
fn person(auth: &AuthTable, id: &str) {
auth.add_principal(Principal::new(id, id, PrincipalKind::User).unwrap())
.expect("a person");
}
#[test]
fn only_a_server_grant_marks_the_seller_and_a_normal_person_never_holds_it() {
let auth = table();
for id in ["cust", "boss", "emp", "op", "staff", "ops-group"] {
if id == "ops-group" {
auth.add_principal(Principal::new(id, id, PrincipalKind::Group).unwrap())
.unwrap();
} else {
person(&auth, id);
}
}
auth.grant(Grant::namespace("cust", "cust", Level::Admin))
.unwrap();
auth.grant(Grant::namespace("cust", "boss", Level::Admin))
.unwrap();
let _ = auth.enroll_employee(
"boss",
Principal::new("emp2", "emp2", PrincipalKind::User).unwrap(),
);
for normal in [
"cust",
"boss",
"emp",
"emp2",
"nobody",
crate::model::ANONYMOUS_PRINCIPAL_ID,
] {
assert!(
!auth.holds_server_grant(normal).unwrap(),
"{normal} is a normal person (or nobody): never the seller"
);
}
auth.grant(Grant::server("op", Level::Read)).unwrap();
assert!(
auth.holds_server_grant("op").unwrap(),
"a server grant at any level is the seller's mark"
);
auth.grant(Grant::server("ops-group", Level::Write))
.unwrap();
auth.add_to_group("ops-group", "staff").unwrap();
assert!(
auth.holds_server_grant("staff").unwrap(),
"through a group too"
);
assert!(
!auth.holds_server_grant("cust").unwrap(),
"and still never the customer"
);
}
}