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()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AuditOutcome {
#[default]
Recorded,
Shed,
}
pub trait IdentityAuthnLog: IdentityLookup {
fn record_event(
&self,
event: AuthEvent,
) -> impl std::future::Future<Output = Result<AuditOutcome, 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 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 trait IdentityPasswordReset: IdentityLookup {
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;
fn verify_reset_token(
&self,
user_id: &UserId,
token_hash: &str,
) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send;
}
pub trait IdentityPasswordHistory: IdentityLookup {
fn record_password_hash(
&self,
user_id: &UserId,
hash: &str,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn password_history(
&self,
user_id: &UserId,
count: usize,
) -> impl std::future::Future<Output = Result<Vec<String>, Self::Error>> + Send;
}
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<AuditOutcome, 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(AuditOutcome::Shed)
}
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(())
}
}
#[derive(Debug, Clone)]
pub struct ResolvedFactor {
pub config: FactorConfig,
pub resolved_from: AuthnScope,
}
pub trait FactorStore: Send + Sync + 'static {
type Error: std::error::Error + Send + Sync + 'static;
fn resolve_factor(
&self,
scope: &AuthnScope,
kind: FactorKind,
) -> impl std::future::Future<Output = Result<Option<ResolvedFactor>, Self::Error>> + Send;
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;
#[cfg(test)]
mod password_history_capability_tests {
#[test]
fn trait_has_no_default_bodies() {
let src = include_str!("store.rs");
let start = src
.find("pub trait IdentityPasswordHistory")
.expect("trait must exist");
let body = &src[start..];
let end = body.find("\n}\n").expect("trait must be closed");
let decl = &body[..end];
assert!(
!decl.contains('{') || decl.matches('{').count() == 1,
"IdentityPasswordHistory gained a method body; the point of the \
carve-out is that a store which cannot keep history fails to \
compile rather than panicking mid password change"
);
assert!(
!decl.contains("unimplemented!") && !decl.contains("todo!"),
"a panicking default reappeared in IdentityPasswordHistory"
);
for m in ["fn record_password_hash", "fn password_history"] {
assert!(decl.contains(m), "{m} must live on this trait");
}
}
}