use crate::authn::{
factor::FactorTemplate,
store::{AuthMethod, FactorStore, IdentityStore},
types::{AuthnScope, Tenant},
};
#[derive(Debug)]
pub enum ProvisioningError<E: std::error::Error + Send + Sync + 'static> {
NoFactorsSpecified,
ReservedTenantId,
Store(E),
}
impl<E: std::error::Error + Send + Sync + 'static> std::fmt::Display for ProvisioningError<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProvisioningError::NoFactorsSpecified => {
f.write_str("tenant provisioning requires at least one factor template")
}
ProvisioningError::ReservedTenantId => {
f.write_str("tenant id matches the reserved system-tenant UUID; refused")
}
ProvisioningError::Store(e) => write!(f, "store error: {e}"),
}
}
}
impl<E: std::error::Error + Send + Sync + 'static> std::error::Error for ProvisioningError<E> {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ProvisioningError::NoFactorsSpecified => None,
ProvisioningError::ReservedTenantId => None,
ProvisioningError::Store(e) => Some(e),
}
}
}
#[derive(Debug, Clone)]
pub struct TenantBootstrap {
pub tenant: Tenant,
pub factors: Vec<FactorTemplate>,
pub method: Option<AuthMethod>,
}
impl TenantBootstrap {
pub fn minimal(tenant: Tenant) -> Self {
use crate::authn::factor::{FactorKind, default_catalog};
let factors = default_catalog()
.into_iter()
.filter(|t| t.kind == FactorKind::Password)
.collect();
Self {
tenant,
factors,
method: None,
}
}
}
pub async fn create_tenant<I, F>(
identity: &I,
factors: &F,
bootstrap: TenantBootstrap,
) -> Result<(Tenant, AuthMethod), ProvisioningError<I::Error>>
where
I: IdentityStore,
F: FactorStore<Error = I::Error>,
{
if bootstrap.factors.is_empty() {
return Err(ProvisioningError::NoFactorsSpecified);
}
if bootstrap.tenant.id.is_system() {
tracing::warn!(
tenant_id = %bootstrap.tenant.id,
identifier = %bootstrap.tenant.identifier,
"provisioning refused; tenant id is reserved system UUID"
);
return Err(ProvisioningError::ReservedTenantId);
}
let scope = AuthnScope::Tenant(bootstrap.tenant.id);
identity
.create_tenant(bootstrap.tenant.clone())
.await
.map_err(ProvisioningError::Store)?;
for template in &bootstrap.factors {
factors
.save_factor(&scope, template.default_config.clone())
.await
.map_err(ProvisioningError::Store)?;
}
let method = bootstrap.method.unwrap_or_else(|| {
AuthMethod::sequential(
"default",
bootstrap.factors.iter().map(|t| t.kind.clone()).collect(),
scope.clone(),
)
});
factors
.save_method(&scope, method.clone())
.await
.map_err(ProvisioningError::Store)?;
Ok((bootstrap.tenant, method))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authn::ids::{TenantId, UserId};
use crate::authn::types::EntityState;
use chrono::Utc;
fn test_tenant() -> Tenant {
let now = Utc::now();
Tenant {
id: axess_identity::testing::tenant("tenant-abc"),
identifier: "acme".into(),
display_name: "Acme Family Office".into(),
status: EntityState::Active,
created_by: UserId::system(),
created_at: now,
updated_by: UserId::system(),
updated_at: now,
}
}
#[test]
fn minimal_bootstrap_has_one_factor_and_no_method() {
let b = TenantBootstrap::minimal(test_tenant());
assert_eq!(b.factors.len(), 1, "minimal = password only");
assert!(b.method.is_none(), "method defaulted in create_tenant");
}
#[test]
fn provisioning_error_display_per_variant() {
use std::io;
let no_factors: ProvisioningError<io::Error> = ProvisioningError::NoFactorsSpecified;
let reserved: ProvisioningError<io::Error> = ProvisioningError::ReservedTenantId;
let store_err: ProvisioningError<io::Error> =
ProvisioningError::Store(io::Error::other("underlying-boom"));
assert!(
no_factors.to_string().contains("at least one factor"),
"NoFactorsSpecified message must mention factors"
);
assert!(
reserved.to_string().contains("system-tenant"),
"ReservedTenantId message must mention system-tenant"
);
let store_msg = store_err.to_string();
assert!(
store_msg.contains("store error") && store_msg.contains("underlying-boom"),
"Store(_) message must surface the wrapped error (got: {store_msg})"
);
}
#[test]
fn provisioning_error_source_only_for_store_variant() {
use std::error::Error as _;
use std::io;
let no_factors: ProvisioningError<io::Error> = ProvisioningError::NoFactorsSpecified;
let reserved: ProvisioningError<io::Error> = ProvisioningError::ReservedTenantId;
let store_err: ProvisioningError<io::Error> =
ProvisioningError::Store(io::Error::other("underlying"));
assert!(
no_factors.source().is_none(),
"NoFactorsSpecified must not have a source"
);
assert!(
reserved.source().is_none(),
"ReservedTenantId must not have a source"
);
assert!(
store_err.source().is_some(),
"Store(_) must expose the wrapped backend error as source"
);
}
#[tokio::test]
async fn create_tenant_refuses_reserved_system_tenant_id() {
use crate::testing::mock_authn::{MockFactorStore, MockIdentityStore};
let now = Utc::now();
let system_tenant = Tenant {
id: TenantId::system(),
identifier: "attacker-claim".into(),
display_name: "Spoofed System".into(),
status: EntityState::Active,
created_by: UserId::system(),
created_at: now,
updated_by: UserId::system(),
updated_at: now,
};
let bootstrap = TenantBootstrap::minimal(system_tenant);
let identity = MockIdentityStore::new();
let factors = MockFactorStore::new();
let res = create_tenant(&identity, &factors, bootstrap).await;
assert!(matches!(res, Err(ProvisioningError::ReservedTenantId)));
}
}