use crate::authn::{
event::AuthEvent,
factor::{FactorConfig, FactorKind, PasswordRules},
ids::{TenantId, UserId},
types::{AuthnScope, EntityState, LockoutPolicy, StatusDetail, Tenant, User},
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EventQueryFilter {
pub user_id: Option<UserId>,
pub event_type: Option<crate::authn::event::AuthEventType>,
pub status: Option<crate::authn::event::AuthEventStatus>,
pub from: Option<chrono::DateTime<chrono::Utc>>,
pub until: Option<chrono::DateTime<chrono::Utc>>,
pub include_unscoped: bool,
pub limit: u32,
}
pub trait AuditQuery: Send + Sync + 'static {
type Error: std::error::Error + Send + Sync + 'static;
fn query_events(
&self,
tenant_id: &TenantId,
filter: &EventQueryFilter,
) -> impl std::future::Future<Output = Result<Vec<AuthEvent>, Self::Error>> + Send;
}
pub trait IdentityLookup: Send + Sync + 'static {
type Error: std::error::Error + Send + Sync + 'static;
fn find_user(
&self,
identifier: &str,
tenant_id: &TenantId,
) -> impl std::future::Future<Output = Result<Option<User>, Self::Error>> + Send;
fn get_user(
&self,
user_id: &UserId,
) -> impl std::future::Future<Output = Result<Option<User>, Self::Error>> + Send;
fn get_user_in_tenant(
&self,
user_id: &UserId,
expected_tenant: &TenantId,
) -> impl std::future::Future<Output = Result<Option<User>, Self::Error>> + Send {
async move {
match self.get_user(user_id).await? {
Some(u) if &u.tenant_id == expected_tenant => Ok(Some(u)),
_ => Ok(None),
}
}
}
fn find_tenant(
&self,
identifier: &str,
) -> impl std::future::Future<Output = Result<Option<Tenant>, Self::Error>> + Send;
fn default_tenant(
&self,
) -> impl std::future::Future<Output = Result<Tenant, Self::Error>> + Send;
fn account_status(
&self,
user_id: &UserId,
) -> impl std::future::Future<Output = Result<EntityState, Self::Error>> + Send;
fn lockout_policy(&self) -> LockoutPolicy {
LockoutPolicy::default()
}
fn lockout_policy_for_tenant(&self, tenant_id: &TenantId) -> LockoutPolicy {
let _ = tenant_id;
self.lockout_policy()
}
fn password_rules_for_tenant(
&self,
tenant_id: &TenantId,
) -> impl std::future::Future<Output = Result<PasswordRules, Self::Error>> + Send {
let _ = tenant_id;
std::future::ready(Ok(PasswordRules::default()))
}
fn ip_policy_for_tenant(
&self,
tenant_id: &TenantId,
) -> impl std::future::Future<Output = Result<crate::authn::types::IpPolicy, Self::Error>> + Send
{
let _ = tenant_id;
std::future::ready(Ok(crate::authn::types::IpPolicy::default()))
}
}
pub trait IdentityAuthnLog: IdentityLookup {
fn record_event(
&self,
event: AuthEvent,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn record_failed_attempt(
&self,
user_id: &UserId,
) -> impl std::future::Future<Output = Result<u32, Self::Error>> + Send;
fn reset_failed_attempts(
&self,
user_id: &UserId,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn record_last_login(
&self,
user_id: &UserId,
at: chrono::DateTime<chrono::Utc>,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
let _ = (user_id, at);
std::future::ready(Ok(()))
}
}
pub trait IdentityAdmin: IdentityAuthnLog {
fn create_tenant(
&self,
tenant: Tenant,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn create_user(
&self,
user: User,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn activate_user(
&self,
user_id: &UserId,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn record_password_hash(
&self,
user_id: &UserId,
hash: &str,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
async move {
unimplemented!(
"IdentityAdmin::record_password_hash({user_id}, hash[{}]) is required \
for password-reuse prevention (SOC2, PCI-DSS, NIST SP 800-63B \
§5.1.1.2). Override this method on your backend to persist the hash \
to a per-user history table. See the trait method docs for the full \
contract.",
hash.len(),
)
}
}
fn password_history(
&self,
user_id: &UserId,
count: usize,
) -> impl std::future::Future<Output = Result<Vec<String>, Self::Error>> + Send {
async move {
unimplemented!(
"IdentityAdmin::password_history({user_id}, {count}) is required for \
password-reuse prevention (SOC2, PCI-DSS, NIST SP 800-63B \
§5.1.1.2). Override this method on your backend to return the most \
recent `count` hashes from the per-user history table. See the \
trait method docs for the full contract.",
)
}
}
fn store_reset_token(
&self,
user_id: &UserId,
token_hash: &str,
expires_at: chrono::DateTime<chrono::Utc>,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
async move {
unimplemented!(
"IdentityAdmin::store_reset_token({user_id}, hash[{}], expires_at={expires_at}) \
is required for the out-of-band password-recovery feature. Override \
this method on your backend to persist (user_id, token_hash, \
expires_at) atomically (single-row upsert). See the trait method \
docs for the full contract.",
token_hash.len(),
)
}
}
fn verify_reset_token(
&self,
user_id: &UserId,
token_hash: &str,
) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send {
async move {
unimplemented!(
"IdentityAdmin::verify_reset_token({user_id}, hash[{}]) is required \
for the out-of-band password-recovery feature. Override this method \
on your backend to look up the stored hash, compare in constant \
time, check the expiry, and delete the row on a successful match \
(single-use). See the trait method docs for the full contract.",
token_hash.len(),
)
}
}
fn suspend_user(
&self,
user_id: &UserId,
detail: StatusDetail,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn delete_user(
&self,
user_id: &UserId,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
let _ = user_id;
async {
unimplemented!(
"IdentityAdmin::delete_user is required for GDPR Article 17 \
(right to erasure). Override this method on your backend to \
delete the user row, factor configs, refresh tokens, sessions, \
and password history. See the trait method docs for the full \
contract."
)
}
}
}
pub trait IdentityStore: IdentityAdmin {}
impl<T: IdentityAdmin> IdentityStore for T {}
pub struct NoopAuthnLog<L>(pub L);
impl<L: IdentityLookup + Clone> Clone for NoopAuthnLog<L> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<L: IdentityLookup> IdentityLookup for NoopAuthnLog<L> {
type Error = L::Error;
fn find_user(
&self,
identifier: &str,
tenant_id: &TenantId,
) -> impl std::future::Future<Output = Result<Option<User>, Self::Error>> + Send {
self.0.find_user(identifier, tenant_id)
}
fn get_user(
&self,
user_id: &UserId,
) -> impl std::future::Future<Output = Result<Option<User>, Self::Error>> + Send {
self.0.get_user(user_id)
}
fn find_tenant(
&self,
identifier: &str,
) -> impl std::future::Future<Output = Result<Option<Tenant>, Self::Error>> + Send {
self.0.find_tenant(identifier)
}
fn default_tenant(
&self,
) -> impl std::future::Future<Output = Result<Tenant, Self::Error>> + Send {
self.0.default_tenant()
}
fn account_status(
&self,
user_id: &UserId,
) -> impl std::future::Future<Output = Result<EntityState, Self::Error>> + Send {
self.0.account_status(user_id)
}
fn lockout_policy(&self) -> LockoutPolicy {
self.0.lockout_policy()
}
fn lockout_policy_for_tenant(&self, tenant_id: &TenantId) -> LockoutPolicy {
self.0.lockout_policy_for_tenant(tenant_id)
}
fn password_rules_for_tenant(
&self,
tenant_id: &TenantId,
) -> impl std::future::Future<Output = Result<PasswordRules, Self::Error>> + Send {
self.0.password_rules_for_tenant(tenant_id)
}
fn ip_policy_for_tenant(
&self,
tenant_id: &TenantId,
) -> impl std::future::Future<Output = Result<crate::authn::types::IpPolicy, Self::Error>> + Send
{
self.0.ip_policy_for_tenant(tenant_id)
}
}
impl<L: IdentityLookup> IdentityAuthnLog for NoopAuthnLog<L> {
async fn record_event(&self, event: AuthEvent) -> Result<(), Self::Error> {
tracing::trace!(
target: "axess::authn::noop_log",
event_type = ?event.event_type,
user_id = ?event.user_id,
"NoopAuthnLog: event discarded (no SOC trail wired up)",
);
Ok(())
}
async fn record_failed_attempt(&self, user_id: &UserId) -> Result<u32, Self::Error> {
tracing::trace!(
target: "axess::authn::noop_log",
%user_id,
"NoopAuthnLog: failed attempt not persisted; lockout policy disabled",
);
Ok(1)
}
async fn reset_failed_attempts(&self, user_id: &UserId) -> Result<(), Self::Error> {
tracing::trace!(
target: "axess::authn::noop_log",
%user_id,
"NoopAuthnLog: reset_failed_attempts is a no-op",
);
Ok(())
}
}
pub trait FactorStore: Send + Sync + 'static {
type Error: std::error::Error + Send + Sync + 'static;
fn load_factor(
&self,
scope: &AuthnScope,
kind: FactorKind,
) -> impl std::future::Future<Output = Result<Option<FactorConfig>, Self::Error>> + Send;
fn save_factor(
&self,
scope: &AuthnScope,
config: FactorConfig,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn compare_and_save_factor(
&self,
scope: &AuthnScope,
prior: &FactorConfig,
updated: FactorConfig,
) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send;
fn available_methods(
&self,
user_id: &UserId,
tenant_id: &TenantId,
) -> impl std::future::Future<Output = Result<Vec<AuthMethod>, Self::Error>> + Send;
fn save_method(
&self,
scope: &AuthnScope,
method: AuthMethod,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn remove_method(
&self,
scope: &AuthnScope,
name: &str,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn set_method_enabled(
&self,
scope: &AuthnScope,
name: &str,
enabled: bool,
) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthMethod {
pub name: Arc<str>,
pub steps: Vec<crate::authn::factor::FactorStep>,
pub scope: AuthnScope,
}
impl AuthMethod {
pub fn sequential(
name: impl Into<Arc<str>>,
factors: Vec<FactorKind>,
scope: AuthnScope,
) -> Self {
Self {
name: name.into(),
steps: factors
.into_iter()
.map(crate::authn::factor::FactorStep::Required)
.collect(),
scope,
}
}
pub fn factors(&self) -> Vec<FactorKind> {
self.steps
.iter()
.map(|step| match step {
crate::authn::factor::FactorStep::Required(k) => k.clone(),
crate::authn::factor::FactorStep::AnyOf(choices) => {
choices.first().cloned().unwrap_or(FactorKind::Password)
}
})
.collect()
}
}
pub trait AuthnBackend: IdentityStore<Error = <Self as FactorStore>::Error> + FactorStore {}
impl<T> AuthnBackend for T where
T: IdentityStore + FactorStore + IdentityStore<Error = <T as FactorStore>::Error>
{
}
#[cfg(test)]
mod noop_authn_log_tests;
#[cfg(test)]
mod store_tests;