use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fmt;
use std::time::SystemTime;
use ring::rand::{SecureRandom, SystemRandom};
use super::canonical::{Canonical, CanonicalValue, Checksum};
use super::ids::{AuditEventId, MutationId, PrincipalId, ProjectId, ResourceId, Slug, TenantId};
use super::mutation::{Actor, AuditEvent, IdempotencyKey, Mutation, MutationKind};
use super::record::{DISPLAY_NAME_FIELD, Record, SCHEMA_FIELD};
use super::resource::{
ResourceBody, ResourceKind, ResourceRef, ResourceScope, ResourceVersion, ResourceVersionNumber,
};
use super::revision::DesiredState;
use super::tenancy::{DisplayName, Tenancy, TenancyError};
use crate::principals::constant_time_eq;
pub const IDENTITY_SCHEMA: &str = "axond.identity.v1";
const PRINCIPAL_ID_FIELD: &str = "principal_id";
const KIND_FIELD: &str = "identity_kind";
const ROLES_FIELD: &str = "roles";
const ISSUER_FIELD: &str = "issuer";
const SUBJECT_FIELD: &str = "subject";
const KEY_DIGEST_FIELD: &str = "key_digest";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Surface {
Tenant,
Project,
Principal,
Provider,
Credential,
Model,
Price,
Alias,
Policy,
AuditTrail,
Billing,
}
impl Surface {
pub const ALL: &'static [Self] = &[
Self::Tenant,
Self::Project,
Self::Principal,
Self::Provider,
Self::Credential,
Self::Model,
Self::Price,
Self::Alias,
Self::Policy,
Self::AuditTrail,
Self::Billing,
];
pub fn parse(text: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|surface| surface.as_str() == text)
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Tenant => "tenant",
Self::Project => "project",
Self::Principal => "principal",
Self::Provider => "provider",
Self::Credential => "credential",
Self::Model => "model",
Self::Price => "price",
Self::Alias => "alias",
Self::Policy => "policy",
Self::AuditTrail => "audit-trail",
Self::Billing => "billing",
}
}
pub const fn of(kind: ResourceKind) -> Self {
match kind {
ResourceKind::Tenant => Self::Tenant,
ResourceKind::Project => Self::Project,
ResourceKind::Identity => Self::Principal,
ResourceKind::Provider => Self::Provider,
ResourceKind::ProviderCredential => Self::Credential,
ResourceKind::CatalogModel | ResourceKind::ModelEnablement => Self::Model,
ResourceKind::Price => Self::Price,
ResourceKind::Alias => Self::Alias,
ResourceKind::Policy => Self::Policy,
}
}
}
impl fmt::Display for Surface {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Action {
Read,
Create,
Update,
Delete,
Rotate,
}
impl Action {
pub const ALL: &'static [Self] = &[
Self::Read,
Self::Create,
Self::Update,
Self::Delete,
Self::Rotate,
];
pub fn parse(text: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|action| action.as_str() == text)
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Read => "read",
Self::Create => "create",
Self::Update => "update",
Self::Delete => "delete",
Self::Rotate => "rotate",
}
}
pub const fn is_write(self) -> bool {
!matches!(self, Self::Read)
}
pub const fn mutation_kind(self) -> Option<MutationKind> {
match self {
Self::Read => None,
Self::Create => Some(MutationKind::Create),
Self::Update => Some(MutationKind::Update),
Self::Delete => Some(MutationKind::Delete),
Self::Rotate => Some(MutationKind::Rotate),
}
}
}
impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Role {
PlatformAdmin,
TenantAdmin,
Operator,
BillingViewer,
Developer,
}
impl Role {
pub const ALL: &'static [Self] = &[
Self::PlatformAdmin,
Self::TenantAdmin,
Self::Operator,
Self::BillingViewer,
Self::Developer,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::PlatformAdmin => "platform-admin",
Self::TenantAdmin => "tenant-admin",
Self::Operator => "operator",
Self::BillingViewer => "billing-viewer",
Self::Developer => "developer",
}
}
pub fn parse(text: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|role| role.as_str() == text)
}
pub const fn permits_scope(self, scope: &ResourceScope) -> bool {
match self {
Self::PlatformAdmin => matches!(scope, ResourceScope::Deployment),
Self::TenantAdmin => matches!(scope, ResourceScope::Tenant(_)),
Self::Operator | Self::BillingViewer | Self::Developer => matches!(
scope,
ResourceScope::Tenant(_) | ResourceScope::Project { .. }
),
}
}
pub const fn actions(self, surface: Surface) -> &'static [Action] {
const NONE: &[Action] = &[];
const READ: &[Action] = &[Action::Read];
const MANAGE: &[Action] = &[Action::Read, Action::Create, Action::Update, Action::Delete];
const OPERATE: &[Action] = &[
Action::Read,
Action::Create,
Action::Update,
Action::Delete,
Action::Rotate,
];
match (self, surface) {
(Self::BillingViewer | Self::Developer, Surface::AuditTrail) => NONE,
(_, Surface::AuditTrail) => READ,
(Self::PlatformAdmin, _) => Action::ALL,
(Self::TenantAdmin, Surface::Tenant) => &[Action::Read, Action::Update],
(Self::TenantAdmin, Surface::Provider | Surface::Credential) => OPERATE,
(Self::TenantAdmin, Surface::Billing) => READ,
(Self::TenantAdmin, _) => MANAGE,
(Self::Operator, Surface::Provider | Surface::Credential) => OPERATE,
(Self::Operator, Surface::Model | Surface::Alias) => MANAGE,
(
Self::Operator,
Surface::Tenant
| Surface::Project
| Surface::Principal
| Surface::Price
| Surface::Policy
| Surface::Billing,
) => READ,
(
Self::BillingViewer,
Surface::Tenant | Surface::Project | Surface::Price | Surface::Billing,
) => READ,
(Self::BillingViewer, _) => NONE,
(Self::Developer, Surface::Alias) => MANAGE,
(Self::Developer, Surface::Project | Surface::Model | Surface::Price) => READ,
(Self::Developer, _) => NONE,
}
}
pub fn permits(self, surface: Surface, action: Action) -> bool {
self.actions(surface).contains(&action)
}
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum IdentityKind {
Human,
Workload,
}
impl IdentityKind {
pub const ALL: &'static [Self] = &[Self::Human, Self::Workload];
pub const fn as_str(self) -> &'static str {
match self {
Self::Human => "human",
Self::Workload => "workload",
}
}
}
impl fmt::Display for IdentityKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Credential {
Oidc { issuer: String, subject: String },
MintedKey { digest: Option<Checksum> },
}
impl Credential {
pub const fn kind(&self) -> IdentityKind {
match self {
Self::Oidc { .. } => IdentityKind::Human,
Self::MintedKey { .. } => IdentityKind::Workload,
}
}
}
pub struct WorkloadKey(String);
impl WorkloadKey {
pub const PREFIX: &'static str = "axw1.";
const ENTROPY_BYTES: usize = 32;
pub fn generate() -> Result<Self, KeyError> {
let mut bytes = [0u8; Self::ENTROPY_BYTES];
SystemRandom::new()
.fill(&mut bytes)
.map_err(|_| KeyError::Randomness)?;
let mut text = String::with_capacity(Self::PREFIX.len() + Self::ENTROPY_BYTES * 2);
text.push_str(Self::PREFIX);
for byte in bytes {
text.push_str(&format!("{byte:02x}"));
}
Ok(Self(text))
}
pub fn parse(text: &str) -> Result<Self, KeyError> {
let digits = text.strip_prefix(Self::PREFIX).ok_or(KeyError::Prefix)?;
if digits.len() != Self::ENTROPY_BYTES * 2
|| !digits
.bytes()
.all(|digit| digit.is_ascii_digit() || (b'a'..=b'f').contains(&digit))
{
return Err(KeyError::Shape);
}
Ok(Self(text.to_owned()))
}
pub fn digest(&self) -> Checksum {
Checksum::of(self.0.as_bytes())
}
pub fn expose_once(self) -> String {
self.0
}
pub fn verifies(digest: &Checksum, presented: &str) -> bool {
let Ok(key) = Self::parse(presented) else {
return false;
};
constant_time_eq(digest.as_bytes(), key.digest().as_bytes())
}
}
impl fmt::Debug for WorkloadKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("WorkloadKey(<redacted>)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum KeyError {
#[error("a workload key must start with `{prefix}`", prefix = WorkloadKey::PREFIX)]
Prefix,
#[error("a workload key must be 64 lowercase hex digits")]
Shape,
#[error("the system random number generator is unavailable")]
Randomness,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentityBody {
principal: PrincipalId,
display_name: DisplayName,
credential: Credential,
roles: BTreeSet<Role>,
}
impl IdentityBody {
pub const SCHEMA: &'static str = IDENTITY_SCHEMA;
const KNOWN_FIELDS: &'static [&'static str] = &[
PRINCIPAL_ID_FIELD,
DISPLAY_NAME_FIELD,
KIND_FIELD,
ROLES_FIELD,
ISSUER_FIELD,
SUBJECT_FIELD,
KEY_DIGEST_FIELD,
];
pub fn new(
principal: PrincipalId,
display_name: DisplayName,
credential: Credential,
roles: impl IntoIterator<Item = Role>,
) -> Result<Self, IdentityError> {
let roles: BTreeSet<Role> = roles.into_iter().collect();
if roles.is_empty() {
return Err(IdentityError::NoRoles);
}
Ok(Self {
principal,
display_name,
credential,
roles,
})
}
pub const fn principal(&self) -> PrincipalId {
self.principal
}
pub const fn display_name(&self) -> &DisplayName {
&self.display_name
}
pub const fn credential(&self) -> &Credential {
&self.credential
}
pub const fn kind(&self) -> IdentityKind {
self.credential.kind()
}
pub fn roles(&self) -> impl ExactSizeIterator<Item = Role> + '_ {
self.roles.iter().copied()
}
pub fn with_key_digest(self, digest: Option<Checksum>) -> Result<Self, IdentityError> {
match self.credential {
Credential::MintedKey { .. } => Ok(Self {
credential: Credential::MintedKey { digest },
..self
}),
Credential::Oidc { .. } => Err(IdentityError::NotAWorkload {
principal: self.principal,
}),
}
}
pub fn with_roles(self, roles: impl IntoIterator<Item = Role>) -> Result<Self, IdentityError> {
let roles: BTreeSet<Role> = roles.into_iter().collect();
if roles.is_empty() {
return Err(IdentityError::NoRoles);
}
Ok(Self { roles, ..self })
}
pub const fn resource_id(&self) -> ResourceId {
ResourceId::new(self.principal.uuid())
}
pub fn body(&self) -> ResourceBody {
ResourceBody::Inline(self.canonical())
}
pub fn version(&self, scope: ResourceScope, slug: Slug) -> ResourceVersion {
self.version_at(scope, slug, ResourceVersionNumber::FIRST)
}
pub fn version_at(
&self,
scope: ResourceScope,
slug: Slug,
version: ResourceVersionNumber,
) -> ResourceVersion {
ResourceVersion::new(
ResourceRef::new(ResourceKind::Identity, self.resource_id(), version),
scope,
slug,
self.body(),
)
}
pub fn read(resource: &ResourceVersion) -> Result<Self, TenancyError> {
let record = Record::<TenancyError>::open(
resource,
ResourceKind::Identity,
Self::SCHEMA,
Self::KNOWN_FIELDS,
)?;
let reference = record.reference();
let principal =
PrincipalId::parse(record.string(PRINCIPAL_ID_FIELD)?).map_err(|source| {
TenancyError::MalformedId {
reference,
field: PRINCIPAL_ID_FIELD,
source,
}
})?;
record.identity(principal, ResourceId::new(principal.uuid()))?;
let declared = record.string(KIND_FIELD)?;
let kind = IdentityKind::ALL
.iter()
.copied()
.find(|kind| kind.as_str() == declared)
.ok_or_else(|| TenancyError::UnknownVocabulary {
reference,
vocabulary: "identity kind",
value: declared.to_owned(),
})?;
let credential = match kind {
IdentityKind::Human => {
Self::refuse_field(&record, KEY_DIGEST_FIELD, kind)?;
Credential::Oidc {
issuer: record.string(ISSUER_FIELD)?.to_owned(),
subject: record.string(SUBJECT_FIELD)?.to_owned(),
}
}
IdentityKind::Workload => {
Self::refuse_field(&record, ISSUER_FIELD, kind)?;
Self::refuse_field(&record, SUBJECT_FIELD, kind)?;
Credential::MintedKey {
digest: record.optional_checksum(KEY_DIGEST_FIELD)?,
}
}
};
let mut roles = BTreeSet::new();
for spelling in record.string_set(ROLES_FIELD)? {
let role = Role::parse(spelling).ok_or_else(|| TenancyError::UnknownVocabulary {
reference,
vocabulary: "role",
value: spelling.to_owned(),
})?;
roles.insert(role);
}
if roles.is_empty() {
return Err(TenancyError::NoRoles { reference });
}
Ok(Self {
principal,
display_name: record.display_name()?,
credential,
roles,
})
}
fn refuse_field(
record: &Record<'_, TenancyError>,
field: &'static str,
kind: IdentityKind,
) -> Result<(), TenancyError> {
if record.optional_string(field)?.is_some() {
return Err(TenancyError::FieldNotForKind {
reference: record.reference(),
field,
kind: kind.as_str(),
});
}
Ok(())
}
}
impl Canonical for IdentityBody {
fn canonical(&self) -> CanonicalValue {
let mut fields = vec![
(
SCHEMA_FIELD.to_owned(),
CanonicalValue::string(Self::SCHEMA),
),
(
PRINCIPAL_ID_FIELD.to_owned(),
CanonicalValue::string(self.principal.to_string()),
),
(
DISPLAY_NAME_FIELD.to_owned(),
CanonicalValue::string(self.display_name.as_str()),
),
(
KIND_FIELD.to_owned(),
CanonicalValue::string(self.kind().as_str()),
),
(ROLES_FIELD.to_owned(), role_set(&self.roles)),
];
match &self.credential {
Credential::Oidc { issuer, subject } => {
fields.push((
ISSUER_FIELD.to_owned(),
CanonicalValue::string(issuer.clone()),
));
fields.push((
SUBJECT_FIELD.to_owned(),
CanonicalValue::string(subject.clone()),
));
}
Credential::MintedKey { digest } => {
if let Some(digest) = digest {
fields.push((
KEY_DIGEST_FIELD.to_owned(),
CanonicalValue::string(digest.to_string()),
));
}
}
}
CanonicalValue::map(fields)
}
}
fn role_set(roles: &BTreeSet<Role>) -> CanonicalValue {
let mut members: Vec<&'static str> = roles.iter().map(|role| role.as_str()).collect();
members.sort_unstable_by_key(|role| (role.len(), *role));
CanonicalValue::set(members.into_iter().map(CanonicalValue::string))
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum IdentityError {
#[error("an identity must grant at least one role")]
NoRoles,
#[error("{principal} is a human identity, whose credential Axond does not hold")]
NotAWorkload { principal: PrincipalId },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
pub reference: ResourceRef,
pub scope: ResourceScope,
pub slug: Slug,
pub body: IdentityBody,
}
impl Principal {
pub const fn tenant(&self) -> Option<TenantId> {
self.scope.tenant()
}
pub fn actor(&self) -> Option<Actor> {
match self.body.credential() {
Credential::Oidc { issuer, subject } => Some(Actor::Human {
issuer: issuer.clone(),
subject: subject.clone(),
}),
Credential::MintedKey { .. } => Some(Actor::Workload {
tenant: self.scope.tenant()?,
principal: self.body.principal(),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Directory {
principals: BTreeMap<PrincipalId, Principal>,
humans: BTreeMap<(String, String), PrincipalId>,
keys: BTreeMap<Checksum, PrincipalId>,
}
impl Directory {
pub fn of(state: &DesiredState, tenancy: &Tenancy) -> Result<Self, TenancyError> {
let mut directory = Self::default();
for resource in state.resources() {
if resource.reference.kind != ResourceKind::Identity {
continue;
}
let body = IdentityBody::read(resource)?;
let reference = resource.reference;
let principal = Principal {
reference,
scope: resource.scope.clone(),
slug: resource.slug.clone(),
body,
};
if principal.body.kind() == IdentityKind::Workload
&& matches!(principal.scope, ResourceScope::Deployment)
{
return Err(TenancyError::IdentityScope {
reference,
kind: IdentityKind::Workload.as_str(),
scope: principal.scope.to_string(),
});
}
if let Some(tenant) = principal.tenant()
&& tenancy.tenant(tenant).is_none()
{
return Err(TenancyError::UnknownTenant { reference, tenant });
}
if let ResourceScope::Project { project, .. } = &principal.scope
&& tenancy.project(*project).is_none()
{
return Err(TenancyError::UnknownProject {
reference,
project: *project,
});
}
for role in principal.body.roles() {
if !role.permits_scope(&principal.scope) {
return Err(TenancyError::RoleScope {
reference,
role: role.as_str(),
scope: principal.scope.to_string(),
});
}
}
if let Credential::Oidc { issuer, subject } = principal.body.credential() {
let key = (issuer.clone(), subject.clone());
if let Some(first) = directory.humans.get(&key) {
let first = directory.principals[first].reference;
return Err(TenancyError::DuplicatePrincipal {
reference,
first,
detail: format!("{subject} at {issuer}"),
});
}
directory.humans.insert(key, principal.body.principal());
}
if let Credential::MintedKey {
digest: Some(digest),
} = principal.body.credential()
{
if let Some(first) = directory.keys.get(digest) {
let first = directory.principals[first].reference;
return Err(TenancyError::DuplicateKey {
reference,
first,
digest: digest.to_string(),
});
}
directory.keys.insert(*digest, principal.body.principal());
}
directory
.principals
.insert(principal.body.principal(), principal);
}
Ok(directory)
}
pub fn principals(&self) -> impl ExactSizeIterator<Item = &Principal> {
self.principals.values()
}
pub fn principal(&self, id: PrincipalId) -> Option<&Principal> {
self.principals.get(&id)
}
pub fn human(&self, issuer: &str, subject: &str) -> Option<&Principal> {
let id = self.humans.get(&(issuer.to_owned(), subject.to_owned()))?;
self.principals.get(id)
}
pub fn authenticate_workload(&self, presented: &str) -> Option<&Principal> {
self.principals.values().find(|principal| {
matches!(
principal.body.credential(),
Credential::MintedKey { digest: Some(digest) } if WorkloadKey::verifies(digest, presented)
)
})
}
pub fn authorize(
&self,
tenancy: &Tenancy,
caller: &Caller,
request: AccessRequest,
) -> Result<Authorization, Denial> {
let deny = |reason: DenialReason| {
Err(Denial {
actor: caller.actor(),
request: request.clone(),
reason,
})
};
if let Some(tenant) = request.scope.tenant() {
match tenancy.lifecycle(tenant) {
None => return deny(DenialReason::UnknownTenant),
Some(lifecycle) if !lifecycle.is_administrable() => {
return deny(DenialReason::TenantNotAdministrable);
}
Some(_) => {}
}
}
if let ResourceScope::Project { tenant, project } = request.scope
&& tenancy.project(project).map(|owned| owned.body.tenant()) != Some(tenant)
{
return deny(DenialReason::UnknownProject);
}
let principal = match caller {
Caller::Breakglass => {
return Ok(Authorization {
actor: caller.actor(),
request,
basis: Basis::Breakglass,
});
}
Caller::System { .. } => {
return if system_permits(&request) {
Ok(Authorization {
actor: caller.actor(),
request,
basis: Basis::System,
})
} else {
deny(DenialReason::RoleLacksAction)
};
}
Caller::Human { issuer, subject } => self.human(issuer, subject),
Caller::Workload { principal, tenant } => self.principal(*principal).filter(|found| {
found.body.kind() == IdentityKind::Workload && found.tenant() == Some(*tenant)
}),
};
let Some(principal) = principal else {
return deny(DenialReason::UnknownPrincipal);
};
if let Some(tenant) = principal.tenant()
&& !tenancy
.lifecycle(tenant)
.is_some_and(|lifecycle| lifecycle.is_administrable())
{
return deny(DenialReason::TenantNotAdministrable);
}
if !principal.scope.contains(&request.scope) {
let crossing = match (principal.tenant(), request.scope.tenant()) {
(Some(held), Some(wanted)) => held != wanted,
_ => false,
};
return deny(if crossing {
DenialReason::CrossTenant
} else {
DenialReason::OutOfScope
});
}
let role = principal
.body
.roles()
.find(|role| role.permits(request.surface, request.action));
let Some(role) = role else {
return deny(DenialReason::RoleLacksAction);
};
let Some(actor) = principal.actor() else {
return deny(DenialReason::UnknownPrincipal);
};
Ok(Authorization {
actor,
request,
basis: Basis::Role {
role,
principal: principal.body.principal(),
},
})
}
}
fn system_permits(request: &AccessRequest) -> bool {
if !request.action.is_write() {
return true;
}
matches!(request.scope, ResourceScope::Deployment)
&& matches!(request.surface, Surface::Model | Surface::Price)
&& matches!(request.action, Action::Create | Action::Update)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Caller {
Human { issuer: String, subject: String },
Workload {
tenant: TenantId,
principal: PrincipalId,
},
Breakglass,
System { component: String },
}
impl Caller {
pub fn actor(&self) -> Actor {
match self {
Self::Human { issuer, subject } => Actor::Human {
issuer: issuer.clone(),
subject: subject.clone(),
},
Self::Workload { tenant, principal } => Actor::Workload {
tenant: *tenant,
principal: *principal,
},
Self::Breakglass => Actor::Breakglass,
Self::System { component } => Actor::System {
component: component.clone(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessRequest {
pub surface: Surface,
pub action: Action,
pub scope: ResourceScope,
}
impl AccessRequest {
pub fn new(surface: Surface, action: Action, scope: ResourceScope) -> Self {
Self {
surface,
action,
scope,
}
}
pub fn of(reference: &ResourceRef, action: Action, scope: ResourceScope) -> Self {
Self::new(Surface::of(reference.kind), action, scope)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Basis {
Role { role: Role, principal: PrincipalId },
Breakglass,
System,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Authorization {
actor: Actor,
request: AccessRequest,
basis: Basis,
}
impl Authorization {
pub const fn actor(&self) -> &Actor {
&self.actor
}
pub const fn request(&self) -> &AccessRequest {
&self.request
}
pub const fn basis(&self) -> &Basis {
&self.basis
}
pub const fn is_breakglass(&self) -> bool {
matches!(self.basis, Basis::Breakglass)
}
pub fn mutation(
&self,
id: MutationId,
idempotency_key: IdempotencyKey,
submitted_at: SystemTime,
) -> Option<Mutation> {
Some(Mutation {
id,
actor: self.actor.clone(),
kind: self.request.action.mutation_kind()?,
scope: self.request.scope.clone(),
idempotency_key,
submitted_at,
})
}
pub fn audit(
&self,
id: AuditEventId,
mutation: MutationId,
target: Option<ResourceRef>,
summary: impl Into<String>,
recorded_at: SystemTime,
) -> Option<AuditEvent> {
Some(AuditEvent {
id,
mutation,
actor: self.actor.clone(),
kind: self.request.action.mutation_kind()?,
target,
summary: summary.into(),
recorded_at,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DenialPage {
tenant: Option<TenantId>,
}
impl DenialPage {
pub fn of(authorization: &Authorization) -> Option<Self> {
if authorization.request.surface != Surface::AuditTrail
|| authorization.request.action != Action::Read
{
return None;
}
match authorization.request.scope {
ResourceScope::Deployment => Some(Self { tenant: None }),
ResourceScope::Tenant(tenant) => Some(Self {
tenant: Some(tenant),
}),
ResourceScope::Project { .. } => None,
}
}
pub const fn tenant(&self) -> Option<TenantId> {
self.tenant
}
#[cfg(test)]
pub(crate) const fn for_scope(tenant: Option<TenantId>) -> Self {
Self { tenant }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DenialReason {
UnknownPrincipal,
UnknownTenant,
UnknownProject,
TenantNotAdministrable,
CrossTenant,
OutOfScope,
RoleLacksAction,
}
impl DenialReason {
pub const ALL: &'static [Self] = &[
Self::UnknownPrincipal,
Self::UnknownTenant,
Self::UnknownProject,
Self::TenantNotAdministrable,
Self::CrossTenant,
Self::OutOfScope,
Self::RoleLacksAction,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::UnknownPrincipal => "unknown-principal",
Self::UnknownTenant => "unknown-tenant",
Self::UnknownProject => "unknown-project",
Self::TenantNotAdministrable => "tenant-not-administrable",
Self::CrossTenant => "cross-tenant",
Self::OutOfScope => "out-of-scope",
Self::RoleLacksAction => "role-lacks-action",
}
}
pub fn parse(text: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|reason| reason.as_str() == text)
}
}
impl fmt::Display for DenialReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Denial {
actor: Actor,
request: AccessRequest,
reason: DenialReason,
}
impl Denial {
pub const fn actor(&self) -> &Actor {
&self.actor
}
pub const fn request(&self) -> &AccessRequest {
&self.request
}
pub const fn reason(&self) -> DenialReason {
self.reason
}
pub const fn public_reason(&self) -> &'static str {
"forbidden"
}
pub fn record(&self, id: AuditEventId, recorded_at: SystemTime) -> AccessDenial {
AccessDenial {
id,
actor: self.actor.clone(),
surface: self.request.surface,
action: self.request.action,
scope: self.request.scope.clone(),
reason: self.reason,
recorded_at,
}
}
}
impl fmt::Display for Denial {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} may not {} {} at {}: {}",
self.actor, self.request.action, self.request.surface, self.request.scope, self.reason
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessDenial {
pub id: AuditEventId,
pub actor: Actor,
pub surface: Surface,
pub action: Action,
pub scope: ResourceScope,
pub reason: DenialReason,
pub recorded_at: SystemTime,
}
impl Canonical for AccessDenial {
fn canonical(&self) -> CanonicalValue {
CanonicalValue::map([
("id", CanonicalValue::string(self.id.to_string())),
("actor", self.actor.canonical()),
("surface", CanonicalValue::string(self.surface.as_str())),
("action", CanonicalValue::string(self.action.as_str())),
("scope", self.scope.canonical()),
("reason", CanonicalValue::string(self.reason.as_str())),
])
}
}
impl AccessDenial {
pub const fn tenant(&self) -> Option<TenantId> {
self.scope.tenant()
}
pub fn project(&self) -> Option<ProjectId> {
match self.scope {
ResourceScope::Project { project, .. } => Some(project),
ResourceScope::Deployment | ResourceScope::Tenant(_) => None,
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::super::fixtures::{
display_name, human, principal_id, project, project_id, state, state_with_directory,
tenant, tenant_id, workload, workload_key,
};
use super::*;
use crate::desired_state::TenantLifecycle;
fn directory(state: &DesiredState) -> (Tenancy, Directory) {
let tenancy = Tenancy::of(state).expect("fixture tenancy is valid");
let directory = Directory::of(state, &tenancy).expect("fixture directory is valid");
(tenancy, directory)
}
fn request(surface: Surface, action: Action, scope: ResourceScope) -> AccessRequest {
AccessRequest::new(surface, action, scope)
}
fn caller_human(subject: &str) -> Caller {
Caller::Human {
issuer: "https://idp.example".to_owned(),
subject: subject.to_owned(),
}
}
#[test]
fn the_authorization_matrix_is_exactly_the_intended_one() {
for &role in Role::ALL {
for &surface in Surface::ALL {
for &action in Action::ALL {
let expected = if surface == Surface::AuditTrail {
action == Action::Read
&& !matches!(role, Role::BillingViewer | Role::Developer)
} else {
match role {
Role::PlatformAdmin => true,
Role::TenantAdmin => match surface {
Surface::Tenant => {
matches!(action, Action::Read | Action::Update)
}
Surface::Billing => action == Action::Read,
Surface::Provider | Surface::Credential => true,
_ => action != Action::Rotate,
},
Role::Operator => match surface {
Surface::Provider | Surface::Credential => true,
Surface::Model | Surface::Alias => action != Action::Rotate,
_ => action == Action::Read,
},
Role::BillingViewer => {
action == Action::Read
&& matches!(
surface,
Surface::Tenant
| Surface::Project
| Surface::Price
| Surface::Billing
)
}
Role::Developer => match surface {
Surface::Alias => action != Action::Rotate,
Surface::Project | Surface::Model | Surface::Price => {
action == Action::Read
}
_ => false,
},
}
};
assert_eq!(
role.permits(surface, action),
expected,
"{role} on {surface}/{action}"
);
}
}
}
}
#[test]
fn only_a_platform_admin_creates_tenants_and_only_admins_grant_roles() {
for &role in Role::ALL {
let platform = role == Role::PlatformAdmin;
assert_eq!(role.permits(Surface::Tenant, Action::Create), platform);
assert_eq!(role.permits(Surface::Tenant, Action::Delete), platform);
let grants = matches!(role, Role::PlatformAdmin | Role::TenantAdmin);
for &action in Action::ALL {
if action.is_write() {
assert_eq!(
role.permits(Surface::Principal, action),
grants && action != Action::Rotate || platform,
"{role} writing the directory with {action}"
);
}
}
}
for &role in Role::ALL {
for &action in Action::ALL {
if action.is_write() {
assert!(
!role.permits(Surface::AuditTrail, action),
"{role} may {action} the audit trail"
);
}
}
}
for &role in Role::ALL {
assert_eq!(
role.permits(Surface::AuditTrail, Action::Read),
!matches!(role, Role::BillingViewer | Role::Developer),
"{role} reads the audit trail"
);
}
}
#[test]
fn only_a_deployment_scoped_decision_reads_the_unscoped_refusal_trail() {
let state = state_with_directory();
let (tenancy, directory) = directory(&state);
let tenant = tenant_id(1);
let read = |caller: &Caller, scope: ResourceScope| {
directory.authorize(
&tenancy,
caller,
request(Surface::AuditTrail, Action::Read, scope),
)
};
let platform = read(&caller_human("root"), ResourceScope::Deployment)
.expect("a platform administrator reads the deployment trail");
assert_eq!(
DenialPage::of(&platform).map(|page| page.tenant()),
Some(None),
"the deployment page is the unscoped one"
);
let scoped = read(&caller_human("admin"), ResourceScope::Tenant(tenant))
.expect("a tenant administrator reads their own trail");
assert_eq!(
DenialPage::of(&scoped).map(|page| page.tenant()),
Some(Some(tenant)),
);
let denial = read(&caller_human("admin"), ResourceScope::Deployment)
.expect_err("a tenant administrator does not read the deployment trail");
assert_eq!(denial.reason, DenialReason::OutOfScope);
let mut narrow = state.clone();
narrow
.supersede(
IdentityBody::new(
principal_id(32),
display_name("Auditor"),
Credential::Oidc {
issuer: "https://idp.example".to_owned(),
subject: "dev".to_owned(),
},
[Role::Operator],
)
.expect("an identity granting a role")
.version_at(
ResourceScope::Project {
tenant,
project: project_id(2),
},
Slug::parse("dev").expect("a slug"),
ResourceVersionNumber::FIRST.next(),
),
)
.expect("granting an existing principal a wider role is valid");
let narrow_tenancy = Tenancy::of(&narrow).expect("valid tenancy");
let narrow_directory = Directory::of(&narrow, &narrow_tenancy).expect("valid directory");
let project = narrow_directory
.authorize(
&narrow_tenancy,
&caller_human("dev"),
request(
Surface::AuditTrail,
Action::Read,
ResourceScope::Project {
tenant,
project: project_id(2),
},
),
)
.expect("an operator in a project may read the surface");
assert_eq!(
DenialPage::of(&project),
None,
"a project scope has no page of the trail"
);
let write = directory
.authorize(
&tenancy,
&caller_human("root"),
request(Surface::Tenant, Action::Create, ResourceScope::Deployment),
)
.expect("a platform administrator creates tenants");
assert_eq!(DenialPage::of(&write), None);
}
#[test]
fn a_role_is_grantable_only_at_the_scopes_it_means() {
let tenant = tenant_id(1);
let project = ResourceScope::Project {
tenant,
project: project_id(2),
};
assert!(Role::PlatformAdmin.permits_scope(&ResourceScope::Deployment));
assert!(!Role::PlatformAdmin.permits_scope(&ResourceScope::Tenant(tenant)));
assert!(Role::TenantAdmin.permits_scope(&ResourceScope::Tenant(tenant)));
assert!(!Role::TenantAdmin.permits_scope(&project));
assert!(!Role::TenantAdmin.permits_scope(&ResourceScope::Deployment));
for role in [Role::Operator, Role::BillingViewer, Role::Developer] {
assert!(role.permits_scope(&ResourceScope::Tenant(tenant)));
assert!(role.permits_scope(&project));
assert!(!role.permits_scope(&ResourceScope::Deployment));
}
}
#[test]
fn every_vocabulary_round_trips_through_its_stored_spelling() {
for &role in Role::ALL {
assert_eq!(Role::parse(role.as_str()), Some(role));
}
for &surface in Surface::ALL {
assert_eq!(Surface::parse(surface.as_str()), Some(surface));
}
for &action in Action::ALL {
assert_eq!(Action::parse(action.as_str()), Some(action));
}
for &reason in DenialReason::ALL {
assert_eq!(DenialReason::parse(reason.as_str()), Some(reason));
}
assert_eq!(Role::parse("root"), None);
assert_eq!(Surface::parse("everything"), None);
let roles: BTreeSet<&str> = Role::ALL.iter().map(|role| role.as_str()).collect();
assert_eq!(roles.len(), Role::ALL.len());
let surfaces: BTreeSet<&str> = Surface::ALL
.iter()
.map(|surface| surface.as_str())
.collect();
assert_eq!(surfaces.len(), Surface::ALL.len());
}
#[test]
fn an_identity_body_round_trips_and_binds_to_its_envelope() {
for resource in [
human(
30,
"root",
ResourceScope::Deployment,
&[Role::PlatformAdmin],
),
workload(
33,
"deployer",
ResourceScope::Tenant(tenant_id(1)),
&[Role::Operator, Role::Developer],
Some(&workload_key(0xd0)),
),
workload(
34,
"revoked",
ResourceScope::Tenant(tenant_id(1)),
&[Role::Operator],
None,
),
] {
let body = IdentityBody::read(&resource).expect("a written identity reads back");
assert_eq!(body.body(), resource.body);
assert_eq!(
ResourceId::new(body.principal().uuid()),
resource.reference.id
);
}
}
#[test]
fn an_identity_cannot_be_half_a_human_and_cannot_hold_no_role() {
assert_eq!(
IdentityBody::new(
principal_id(30),
display_name("Nobody"),
Credential::MintedKey { digest: None },
[],
),
Err(IdentityError::NoRoles)
);
let person = IdentityBody::new(
principal_id(31),
display_name("Ada"),
Credential::Oidc {
issuer: "https://idp.example".to_owned(),
subject: "ada".to_owned(),
},
[Role::TenantAdmin],
)
.expect("a granted human");
assert_eq!(
person.clone().with_key_digest(None),
Err(IdentityError::NotAWorkload {
principal: principal_id(31)
})
);
assert_eq!(person.with_roles([]), Err(IdentityError::NoRoles));
let encoded = format!(
"{:?}",
human(31, "ada", ResourceScope::Deployment, &[Role::PlatformAdmin]).body
);
assert!(!encoded.contains(KEY_DIGEST_FIELD), "{encoded}");
}
#[test]
fn a_minted_key_is_shown_once_hashed_at_rest_and_verified_in_constant_time() {
let key = WorkloadKey::generate().expect("system randomness");
let digest = key.digest();
let redacted = format!("{key:?}");
assert_eq!(redacted, "WorkloadKey(<redacted>)");
let material = key.expose_once();
assert!(material.starts_with(WorkloadKey::PREFIX));
assert!(!redacted.contains(&material));
assert!(!digest.to_string().contains(&material));
assert!(WorkloadKey::verifies(&digest, &material));
assert!(!WorkloadKey::verifies(&digest, "axw1.not-a-key"));
let other = WorkloadKey::generate().expect("system randomness");
assert_ne!(other.digest(), digest);
assert!(!WorkloadKey::verifies(&digest, &other.expose_once()));
assert_eq!(WorkloadKey::parse("nope").unwrap_err(), KeyError::Prefix);
assert_eq!(WorkloadKey::parse("axw1.XYZ").unwrap_err(), KeyError::Shape);
}
#[test]
fn a_workload_authenticates_by_digest_and_a_revoked_one_authenticates_with_nothing() {
let mut state = state_with_directory();
state
.insert(workload(
34,
"revoked",
ResourceScope::Tenant(tenant_id(1)),
&[Role::Operator],
None,
))
.expect("a second workload");
let (_, directory) = directory(&state);
let found = directory
.authenticate_workload(&workload_key(0xd0))
.expect("the digest matches");
assert_eq!(found.body.principal(), principal_id(33));
assert!(
directory
.authenticate_workload(&workload_key(0xd1))
.is_none()
);
assert!(directory.authenticate_workload("axw1.deployer").is_none());
let revoked = directory
.principal(principal_id(34))
.expect("still in the directory");
assert_eq!(
revoked.body.credential(),
&Credential::MintedKey { digest: None }
);
}
#[test]
fn a_directory_refuses_a_cross_tenant_or_unscoped_identity() {
let tenant = tenant_id(1);
let mut deployment_workload = state();
deployment_workload
.insert(workload(
33,
"deployer",
ResourceScope::Deployment,
&[Role::PlatformAdmin],
Some(&workload_key(0xd0)),
))
.expect("insertion is not validation");
let tenancy = Tenancy::of(&deployment_workload).expect("valid tenancy");
assert!(matches!(
Directory::of(&deployment_workload, &tenancy),
Err(TenancyError::IdentityScope { .. })
));
let mut unknown_tenant = state();
unknown_tenant
.insert(human(
31,
"admin",
ResourceScope::Tenant(tenant_id(99)),
&[Role::TenantAdmin],
))
.expect("insertion is not validation");
let tenancy = Tenancy::of(&unknown_tenant).expect("valid tenancy");
assert!(matches!(
Directory::of(&unknown_tenant, &tenancy),
Err(TenancyError::UnknownTenant { .. })
));
let mut misscoped_role = state();
misscoped_role
.insert(human(
31,
"admin",
ResourceScope::Tenant(tenant),
&[Role::PlatformAdmin],
))
.expect("insertion is not validation");
let tenancy = Tenancy::of(&misscoped_role).expect("valid tenancy");
assert!(matches!(
Directory::of(&misscoped_role, &tenancy),
Err(TenancyError::RoleScope { .. })
));
}
#[test]
fn one_person_is_one_principal() {
let mut state = state_with_directory();
state
.insert(human(
35,
"root",
ResourceScope::Deployment,
&[Role::PlatformAdmin],
))
.expect("insertion is not validation");
let tenancy = Tenancy::of(&state).expect("valid tenancy");
assert!(matches!(
Directory::of(&state, &tenancy),
Err(TenancyError::DuplicatePrincipal { .. })
));
}
#[test]
fn one_key_is_one_workload() {
let mut state = state_with_directory();
state
.insert(workload(
36,
"second-runner",
ResourceScope::Tenant(tenant_id(1)),
&[Role::TenantAdmin],
Some(&workload_key(0xd0)),
))
.expect("insertion is not validation");
let tenancy = Tenancy::of(&state).expect("valid tenancy");
assert!(matches!(
Directory::of(&state, &tenancy),
Err(TenancyError::DuplicateKey { .. })
));
}
#[test]
fn scope_containment_is_one_way() {
let tenant = tenant_id(1);
let other = tenant_id(11);
let project = ResourceScope::Project {
tenant,
project: project_id(2),
};
assert!(ResourceScope::Deployment.contains(&project));
assert!(ResourceScope::Tenant(tenant).contains(&project));
assert!(!project.contains(&ResourceScope::Tenant(tenant)));
assert!(!ResourceScope::Tenant(tenant).contains(&ResourceScope::Deployment));
assert!(!ResourceScope::Tenant(tenant).contains(&ResourceScope::Tenant(other)));
assert!(!project.contains(&ResourceScope::Project {
tenant: other,
project: project_id(2),
}));
}
#[test]
fn a_decision_names_who_and_why_and_produces_the_mutation() {
let state = state_with_directory();
let (tenancy, directory) = directory(&state);
let scope = ResourceScope::Tenant(tenant_id(1));
let granted = directory
.authorize(
&tenancy,
&caller_human("admin"),
request(Surface::Alias, Action::Create, scope.clone()),
)
.expect("a tenant admin creates an alias in its own tenant");
assert_eq!(
granted.basis(),
&Basis::Role {
role: Role::TenantAdmin,
principal: principal_id(31),
}
);
assert!(!granted.is_breakglass());
let mutation = granted
.mutation(
MutationId::new(super::super::ids::Uuid7::from_parts(7, 7, 7).expect("parts")),
IdempotencyKey::parse("create-alias").expect("a key"),
SystemTime::UNIX_EPOCH,
)
.expect("a write has a mutation");
assert_eq!(mutation.kind, MutationKind::Create);
assert_eq!(mutation.scope, scope);
assert_eq!(
mutation.actor,
Actor::Human {
issuer: "https://idp.example".to_owned(),
subject: "admin".to_owned(),
}
);
let read = directory
.authorize(
&tenancy,
&caller_human("admin"),
request(Surface::Alias, Action::Read, scope),
)
.expect("a tenant admin reads its own aliases");
assert!(
read.mutation(
MutationId::new(super::super::ids::Uuid7::from_parts(7, 7, 8).expect("parts")),
IdempotencyKey::parse("read").expect("a key"),
SystemTime::UNIX_EPOCH,
)
.is_none()
);
}
#[test]
fn a_workload_is_attributed_to_its_tenant_and_its_principal() {
let state = state_with_directory();
let (tenancy, directory) = directory(&state);
let tenant = tenant_id(1);
let granted = directory
.authorize(
&tenancy,
&Caller::Workload {
tenant,
principal: principal_id(33),
},
request(
Surface::Credential,
Action::Rotate,
ResourceScope::Tenant(tenant),
),
)
.expect("an operator workload rotates its tenant's credential");
assert_eq!(
granted.actor(),
&Actor::Workload {
tenant,
principal: principal_id(33),
}
);
}
#[test]
fn a_workload_claim_naming_a_human_authorizes_as_nobody() {
let state = state_with_directory();
let (tenancy, directory) = directory(&state);
let tenant = tenant_id(1);
let denial = directory
.authorize(
&tenancy,
&Caller::Workload {
tenant,
principal: principal_id(31),
},
request(
Surface::Principal,
Action::Create,
ResourceScope::Tenant(tenant),
),
)
.expect_err("a human's id does not authenticate a workload");
assert_eq!(denial.reason(), DenialReason::UnknownPrincipal);
assert_eq!(denial.public_reason(), "forbidden");
}
#[test]
fn a_caller_of_one_tenant_cannot_reach_another() {
let mut state = state_with_directory();
state.insert(tenant(11, "globex")).expect("a second tenant");
let (tenancy, directory) = directory(&state);
let denial = directory
.authorize(
&tenancy,
&caller_human("admin"),
request(
Surface::Alias,
Action::Read,
ResourceScope::Tenant(tenant_id(11)),
),
)
.expect_err("another tenant is not reachable");
assert_eq!(denial.reason(), DenialReason::CrossTenant);
assert_eq!(denial.public_reason(), "forbidden");
let unknown = directory
.authorize(
&tenancy,
&caller_human("admin"),
request(
Surface::Alias,
Action::Read,
ResourceScope::Tenant(tenant_id(98)),
),
)
.expect_err("a tenant that does not exist is not reachable either");
assert_eq!(unknown.reason(), DenialReason::UnknownTenant);
assert_eq!(unknown.public_reason(), denial.public_reason());
}
#[test]
fn a_caller_cannot_pair_its_tenant_with_a_project_it_does_not_own() {
let mut state = state_with_directory();
state
.insert(tenant(11, "globex"))
.and_then(|state| state.insert(project(&tenant_id(11), 12, "edge")))
.expect("a second tenant with a project of its own");
let (tenancy, directory) = directory(&state);
let tenant = tenant_id(1);
for project in [
project_id(12),
project_id(97),
] {
let denial = directory
.authorize(
&tenancy,
&caller_human("admin"),
request(
Surface::Alias,
Action::Read,
ResourceScope::Project { tenant, project },
),
)
.expect_err("a project of another tenant is not this tenant's project");
assert_eq!(denial.reason(), DenialReason::UnknownProject);
assert_eq!(denial.public_reason(), "forbidden");
}
directory
.authorize(
&tenancy,
&caller_human("admin"),
request(
Surface::Alias,
Action::Read,
ResourceScope::Project {
tenant,
project: project_id(2),
},
),
)
.expect("its own tenant's project is still reachable");
}
#[test]
fn a_project_scoped_caller_cannot_reach_its_tenant_and_a_narrow_role_cannot_widen() {
let state = state_with_directory();
let (tenancy, directory) = directory(&state);
let tenant = tenant_id(1);
let denial = directory
.authorize(
&tenancy,
&caller_human("dev"),
request(
Surface::Alias,
Action::Create,
ResourceScope::Tenant(tenant),
),
)
.expect_err("a project-scoped developer cannot write its tenant");
assert_eq!(denial.reason(), DenialReason::OutOfScope);
let denial = directory
.authorize(
&tenancy,
&caller_human("dev"),
request(
Surface::Credential,
Action::Read,
ResourceScope::Project {
tenant,
project: project_id(2),
},
),
)
.expect_err("a developer holds nothing on credentials");
assert_eq!(denial.reason(), DenialReason::RoleLacksAction);
}
#[test]
fn an_unresolvable_caller_is_refused_without_saying_which_half_was_wrong() {
let state = state_with_directory();
let (tenancy, directory) = directory(&state);
let tenant = tenant_id(1);
for caller in [
caller_human("nobody"),
Caller::Human {
issuer: "https://other.example".to_owned(),
subject: "admin".to_owned(),
},
Caller::Workload {
tenant: tenant_id(11),
principal: principal_id(33),
},
] {
let denial = directory
.authorize(
&tenancy,
&caller,
request(Surface::Alias, Action::Read, ResourceScope::Tenant(tenant)),
)
.expect_err("no principal matches");
assert_eq!(denial.reason(), DenialReason::UnknownPrincipal);
}
}
#[test]
fn a_disabled_tenant_is_administrable_and_a_deleted_one_is_not() {
let tenant = tenant_id(1);
for (lifecycle, expected) in [
(TenantLifecycle::Disabled, None),
(
TenantLifecycle::Deleted,
Some(DenialReason::TenantNotAdministrable),
),
] {
let mut state = state_with_directory();
let body = super::super::fixtures::tenant_body(1, "Acme").in_lifecycle(lifecycle);
state
.supersede(body.version_at(
Slug::parse("acme").expect("a slug"),
ResourceVersionNumber::FIRST.next(),
))
.expect("a later version of the same tenant");
let (tenancy, directory) = directory(&state);
let decision = directory.authorize(
&tenancy,
&caller_human("admin"),
request(
Surface::Billing,
Action::Read,
ResourceScope::Tenant(tenant),
),
);
match expected {
None => {
decision.expect("a disabled tenant is still administrable");
}
Some(reason) => {
assert_eq!(decision.expect_err("a tombstone").reason(), reason);
}
}
}
}
#[test]
fn breakglass_is_allowed_everything_and_recorded_as_itself() {
let state = state_with_directory();
let (tenancy, directory) = directory(&state);
for &surface in Surface::ALL {
for &action in Action::ALL {
let granted = directory
.authorize(
&tenancy,
&Caller::Breakglass,
request(surface, action, ResourceScope::Tenant(tenant_id(1))),
)
.expect("breakglass is the way back in");
assert!(granted.is_breakglass());
assert_eq!(granted.actor(), &Actor::Breakglass);
}
}
}
#[test]
fn breakglass_and_the_gateway_recover_the_deployment_not_a_deleted_tenant() {
let tenant = tenant_id(1);
let mut state = state_with_directory();
let body =
super::super::fixtures::tenant_body(1, "Acme").in_lifecycle(TenantLifecycle::Deleted);
state
.supersede(body.version_at(
Slug::parse("acme").expect("a slug"),
ResourceVersionNumber::FIRST.next(),
))
.expect("a later version of the same tenant");
let (tenancy, directory) = directory(&state);
let system = Caller::System {
component: "catalog-refresh".to_owned(),
};
for caller in [Caller::Breakglass, system] {
for scope in [
ResourceScope::Tenant(tenant),
ResourceScope::Tenant(tenant_id(97)),
] {
let denial = directory
.authorize(
&tenancy,
&caller,
request(Surface::AuditTrail, Action::Read, scope),
)
.expect_err("a tombstone and a stranger are both closed");
assert!(matches!(
denial.reason(),
DenialReason::TenantNotAdministrable | DenialReason::UnknownTenant
));
assert_eq!(denial.public_reason(), "forbidden");
}
directory
.authorize(
&tenancy,
&caller,
request(Surface::Model, Action::Read, ResourceScope::Deployment),
)
.expect("deployment scope is where recovery happens");
}
}
#[test]
fn the_gateways_own_work_reads_anywhere_and_writes_only_its_catalogues() {
let state = state_with_directory();
let (tenancy, directory) = directory(&state);
let system = Caller::System {
component: "catalog-refresh".to_owned(),
};
for &surface in Surface::ALL {
directory
.authorize(
&tenancy,
&system,
request(surface, Action::Read, ResourceScope::Tenant(tenant_id(1))),
)
.expect("convergence reads");
}
directory
.authorize(
&tenancy,
&system,
request(Surface::Model, Action::Update, ResourceScope::Deployment),
)
.expect("the catalogue is the gateway's own");
for bad in [
request(Surface::Alias, Action::Create, ResourceScope::Deployment),
request(
Surface::Model,
Action::Update,
ResourceScope::Tenant(tenant_id(1)),
),
request(Surface::Model, Action::Delete, ResourceScope::Deployment),
] {
let denial = directory
.authorize(&tenancy, &system, bad)
.expect_err("a compromised refresher is not a compromised tenant");
assert_eq!(denial.reason(), DenialReason::RoleLacksAction);
}
}
#[test]
fn a_denial_is_recorded_with_its_reason_and_no_caller_supplied_bytes() {
let state = state_with_directory();
let (tenancy, directory) = directory(&state);
let scope = ResourceScope::Tenant(tenant_id(1));
let denial = directory
.authorize(
&tenancy,
&caller_human("dev"),
request(Surface::Credential, Action::Rotate, scope.clone()),
)
.expect_err("a developer holds nothing on credentials");
let id = AuditEventId::new(super::super::ids::Uuid7::from_parts(5, 5, 5).expect("parts"));
let record = denial.record(id, SystemTime::UNIX_EPOCH);
assert_eq!(record.id, id);
assert_eq!(record.reason, DenialReason::OutOfScope);
assert_eq!(record.surface, Surface::Credential);
assert_eq!(record.action, Action::Rotate);
assert_eq!(record.tenant(), Some(tenant_id(1)));
assert_eq!(record.project(), None);
assert_eq!(
record.actor,
Actor::Human {
issuer: "https://idp.example".to_owned(),
subject: "dev".to_owned(),
}
);
let later = denial.record(
id,
SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(60),
);
assert_eq!(record.canonical(), later.canonical());
}
#[test]
fn a_deployment_with_no_published_directory_grants_nothing_but_breakglass() {
let empty = DesiredState::new();
let tenancy = Tenancy::of(&empty).expect("an empty state has an empty tenancy");
let directory = Directory::of(&empty, &tenancy).expect("and an empty directory");
assert_eq!(directory.principals().count(), 0);
assert!(
directory
.authenticate_workload(&workload_key(0xd0))
.is_none()
);
let scope = ResourceScope::Tenant(tenant_id(1));
for caller in [
caller_human("root"),
Caller::Workload {
tenant: tenant_id(1),
principal: principal_id(33),
},
] {
let denial = directory
.authorize(
&tenancy,
&caller,
request(Surface::Tenant, Action::Read, scope.clone()),
)
.expect_err("an empty directory authorizes nobody");
assert!(matches!(
denial.reason(),
DenialReason::UnknownPrincipal | DenialReason::UnknownTenant
));
}
directory
.authorize(
&tenancy,
&Caller::Breakglass,
request(Surface::Tenant, Action::Create, ResourceScope::Deployment),
)
.expect("breakglass creates the first tenant");
}
#[test]
fn a_denial_carries_no_secret_material() {
let state = state_with_directory();
let (tenancy, directory) = directory(&state);
let denial = directory
.authorize(
&tenancy,
&Caller::Workload {
tenant: tenant_id(1),
principal: principal_id(33),
},
request(Surface::Tenant, Action::Delete, ResourceScope::Deployment),
)
.expect_err("no tenant-scoped role deletes a tenant");
let record = denial.record(
AuditEventId::new(super::super::ids::Uuid7::from_parts(5, 5, 6).expect("parts")),
SystemTime::UNIX_EPOCH,
);
let rendered = format!("{record:?} {denial}");
assert!(!rendered.contains("axw1."), "{rendered}");
}
}