Skip to main content

axess_core/authn/
store.rs

1//! Core storage traits for authentication: `IdentityStore` and `FactorStore`.
2//!
3//! These replace the monolithic `AuthnBackend` trait. They use native `async fn`
4//! (Rust 1.75+), no `async-trait`, and typed [`UserId`]/[`TenantId`] for identifiers.
5
6use crate::authn::{
7    event::AuthEvent,
8    factor::{FactorConfig, FactorKind, PasswordRules},
9    ids::{TenantId, UserId},
10    types::{AuthnScope, EntityState, LockoutPolicy, StatusDetail, Tenant, User},
11};
12use serde::{Deserialize, Serialize};
13use std::sync::Arc;
14
15// ── AuditQuery (capability trait) ─────────────────────────────────────────────
16
17/// Optional filters for [`AuditQuery::query_events`].
18///
19/// All fields are optional and combined with AND. The outer `tenant_id`
20/// is supplied as a separate argument and is always required.
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22pub struct EventQueryFilter {
23    /// Restrict to events for this user. Cross-checked against
24    /// `tenant_id` at query time; an out-of-tenant `user_id` returns
25    /// an empty result rather than leaking events.
26    pub user_id: Option<UserId>,
27    /// Restrict to events of this type. `None` means all types.
28    pub event_type: Option<crate::authn::event::AuthEventType>,
29    /// Restrict to events with this status. `None` means all statuses.
30    pub status: Option<crate::authn::event::AuthEventStatus>,
31    /// Inclusive lower bound on `event_time`.
32    pub from: Option<chrono::DateTime<chrono::Utc>>,
33    /// Exclusive upper bound on `event_time`.
34    pub until: Option<chrono::DateTime<chrono::Utc>>,
35    /// When `true`, also return events with `tenant_id IS NULL`
36    /// (platform-level rail events, e.g. login attempts for unknown
37    /// users that never resolved a tenant). Default `true` so the trait
38    /// matches the SQL most operators want for tenant audit screens.
39    /// Flip to `false` for views that should hide platform noise.
40    pub include_unscoped: bool,
41    /// Maximum number of rows to return (newest-first). 0 means
42    /// "backend default"; implementations should pick a safe cap
43    /// (e.g. 1000) so a forgotten limit cannot scan the whole table.
44    pub limit: u32,
45}
46
47/// Tenant-scoped audit-log query, exposed as an opt-in
48/// capability trait separate from [`IdentityStore`].
49///
50/// `IdentityStore` is required of every backend (mock, in-memory,
51/// SQLite, etc). Most of those backends have no audit table and no
52/// way to answer a query, and putting the method on `IdentityStore`
53/// with a default `Ok(Vec::new())` would silently turn unsupported
54/// backends into "no events found" lies on a SOC dashboard. The
55/// capability split makes support explicit: an application generic
56/// over `S: IdentityStore + AuditQuery` won't compile against a
57/// store that lacks audit support, and a runtime caller can probe
58/// via downcast or trait-object dispatch.
59///
60/// # Tenant scoping
61///
62/// Every returned event MUST satisfy
63/// `event.tenant_id == Some(tenant_id)
64/// OR (filter.include_unscoped AND event.tenant_id IS NULL)`.
65/// The outer `tenant_id` is required: this trait centralises the
66/// `WHERE tenant_id = ? OR tenant_id IS NULL` rail so applications
67/// no longer hand-roll it (the common bug: dropping the second
68/// clause hides platform-level events; dropping the first clause
69/// leaks events across tenants; both seen in the wild).
70pub trait AuditQuery: Send + Sync + 'static {
71    /// Backend error type. Typically aliased to the matching
72    /// `IdentityStore::Error` so downstream `Result` types stay
73    /// homogeneous.
74    type Error: std::error::Error + Send + Sync + 'static;
75
76    /// Tenant-scoped query over the audit log. Newest first, capped
77    /// at `filter.limit` (or the backend's safe default when 0).
78    fn query_events(
79        &self,
80        tenant_id: &TenantId,
81        filter: &EventQueryFilter,
82    ) -> impl std::future::Future<Output = Result<Vec<AuthEvent>, Self::Error>> + Send;
83}
84
85// ── Identity trait tier ──────────────────────────────────────────────────────
86//
87// The identity-storage role is split into three traits that form a linear
88// tower of capabilities:
89//
90//   IdentityLookup     pure read methods. Sufficient for middleware,
91//                      route guards, and read-only validators that take
92//                      `Arc<dyn IdentityLookup>` without dragging the
93//                      full write surface through their generics.
94//
95//   IdentityAuthnLog   extends IdentityLookup with the per-authn-attempt
96//                      writes (audit emit, lockout counter, last-login
97//                      timestamp). Required for any backend that drives
98//                      a live login pipeline; `AuthnService::new`
99//                      bounds on this tier.
100//
101//   IdentityAdmin      extends IdentityAuthnLog with the privileged
102//                      write surface: tenant/user provisioning,
103//                      password history, reset-token storage,
104//                      suspension, GDPR erasure. Adopters that provision
105//                      users out-of-band (SCIM, ops scripts,
106//                      read-replicas) skip this tier and lose only the
107//                      admin entry points on `AuthnService`; login and
108//                      audit keep working.
109//
110// `IdentityStore` is preserved as an umbrella alias for the all-three-tier
111// case (the typical production backend); the blanket impl below lets any
112// `T: IdentityAdmin` satisfy `IdentityStore` automatically. Most production
113// code that takes `T: IdentityStore` keeps compiling unchanged.
114
115/// Base read-only identity lookup. Every authentication path
116/// (middleware, validators, login flows, admin operations) needs at
117/// least this much.
118///
119/// **Dyn-compatibility.** This trait uses `impl Future` returns and is
120/// therefore *not* `dyn`-compatible; `Arc<dyn IdentityLookup>` will not
121/// compile. Middleware that wants to drop the `<I: IdentityStore>`
122/// generic should reach for [`SessionValidator`](super::service::SessionValidator)
123/// (and its `session_validator_with_identity_check` constructor), which
124/// erases the identity store behind a private dyn-safe wrapper trait.
125/// Custom middleware that needs the same trick should mirror that
126/// pattern: a local trait with `Pin<Box<dyn Future>>` returns and a
127/// blanket impl over `T: IdentityLookup + 'static`.
128pub trait IdentityLookup: Send + Sync + 'static {
129    /// Error type returned by storage operations. Reused by the
130    /// extension traits ([`IdentityAuthnLog`] / [`IdentityAdmin`])
131    /// via `Self::Error` so adopters declare the type exactly once
132    /// on their `IdentityLookup` impl.
133    type Error: std::error::Error + Send + Sync + 'static;
134
135    /// Look up a user by their login identifier within a tenant.
136    fn find_user(
137        &self,
138        identifier: &str,
139        tenant_id: &TenantId,
140    ) -> impl std::future::Future<Output = Result<Option<User>, Self::Error>> + Send;
141
142    /// Look up a user by their opaque ID.
143    ///
144    /// **Tenant scope is the caller's responsibility.** This
145    /// method does NOT filter by tenant; consumers must check
146    /// `user.tenant_id` against the caller's expected tenant before
147    /// acting on the result, or use the
148    /// [`get_user_in_tenant`](Self::get_user_in_tenant) convenience
149    /// wrapper which encodes the tenant guard at the trait surface.
150    /// The unscoped form remains available for system-tenant admin paths
151    /// (platform-operator console) where cross-tenant access is the
152    /// intent.
153    fn get_user(
154        &self,
155        user_id: &UserId,
156    ) -> impl std::future::Future<Output = Result<Option<User>, Self::Error>> + Send;
157
158    /// Look up a user by ID, requiring the result's `tenant_id` to match
159    /// `expected_tenant`. Returns `Ok(None)` when the user does not exist
160    /// OR when the user exists in a different tenant; the caller cannot
161    /// distinguish "no such user" from "wrong tenant", which is the
162    /// intended IDOR-mitigation behaviour. Use
163    /// [`get_user`](Self::get_user) only when cross-tenant lookup is
164    /// explicitly the intent.
165    fn get_user_in_tenant(
166        &self,
167        user_id: &UserId,
168        expected_tenant: &TenantId,
169    ) -> impl std::future::Future<Output = Result<Option<User>, Self::Error>> + Send {
170        async move {
171            match self.get_user(user_id).await? {
172                Some(u) if &u.tenant_id == expected_tenant => Ok(Some(u)),
173                _ => Ok(None),
174            }
175        }
176    }
177
178    /// Look up a tenant by its identifier (slug, domain, etc.).
179    fn find_tenant(
180        &self,
181        identifier: &str,
182    ) -> impl std::future::Future<Output = Result<Option<Tenant>, Self::Error>> + Send;
183
184    /// Return the default tenant. Used when the application is single-tenant.
185    fn default_tenant(
186        &self,
187    ) -> impl std::future::Future<Output = Result<Tenant, Self::Error>> + Send;
188
189    /// Return the current account status for a user. Called before each factor step.
190    fn account_status(
191        &self,
192        user_id: &UserId,
193    ) -> impl std::future::Future<Output = Result<EntityState, Self::Error>> + Send;
194
195    /// Return the global lockout policy. Default: 5 attempts, 15-minute lockout.
196    fn lockout_policy(&self) -> LockoutPolicy {
197        LockoutPolicy::default()
198    }
199
200    /// Return the lockout policy for a specific tenant.
201    ///
202    /// Override this to support per-tenant lockout configuration (e.g., Tenant A
203    /// allows 3 attempts, Tenant B allows 10). Falls back to the global
204    /// [`lockout_policy`](Self::lockout_policy) by default.
205    fn lockout_policy_for_tenant(&self, tenant_id: &TenantId) -> LockoutPolicy {
206        let _ = tenant_id;
207        self.lockout_policy()
208    }
209
210    /// Return the password rules for a specific tenant.
211    ///
212    /// Override this to support per-tenant password policies (e.g., Tenant A
213    /// requires 16-char passwords, Tenant B allows 12). Returns the global
214    /// default `PasswordRules` by default.
215    fn password_rules_for_tenant(
216        &self,
217        tenant_id: &TenantId,
218    ) -> impl std::future::Future<Output = Result<PasswordRules, Self::Error>> + Send {
219        let _ = tenant_id;
220        std::future::ready(Ok(PasswordRules::default()))
221    }
222
223    /// Return the IP access policy for a tenant.
224    ///
225    /// Override to load per-tenant allowlists/denylists from your database.
226    /// Default: empty policy (all IPs allowed).
227    fn ip_policy_for_tenant(
228        &self,
229        tenant_id: &TenantId,
230    ) -> impl std::future::Future<Output = Result<crate::authn::types::IpPolicy, Self::Error>> + Send
231    {
232        let _ = tenant_id;
233        std::future::ready(Ok(crate::authn::types::IpPolicy::default()))
234    }
235}
236
237// ── IdentityAuthnLog ─────────────────────────────────────────────────────────
238
239/// Per-authn-attempt writes the login pipeline emits: audit events,
240/// failed-attempt lockout counter, last-login timestamp.
241///
242/// Required by `AuthnService::new`; any backend that drives a live login
243/// flow must implement this tier. SCIM-provisioned deployments that handle
244/// user lifecycle out-of-band still implement this so login can record
245/// audit + enforce lockout, but skip [`IdentityAdmin`] entirely.
246///
247/// Adopters that explicitly do NOT want audit / lockout / last-login can
248/// wrap their [`IdentityLookup`] in [`NoopAuthnLog`] (see the testing
249/// module) to satisfy the bound with no-op behaviour.
250pub trait IdentityAuthnLog: IdentityLookup {
251    /// Record an authentication event (audit log).
252    fn record_event(
253        &self,
254        event: AuthEvent,
255    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
256
257    /// Increment the failed attempt counter for a user. Returns the new count.
258    fn record_failed_attempt(
259        &self,
260        user_id: &UserId,
261    ) -> impl std::future::Future<Output = Result<u32, Self::Error>> + Send;
262
263    /// Reset the failed attempt counter (call after successful authentication).
264    fn reset_failed_attempts(
265        &self,
266        user_id: &UserId,
267    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
268
269    /// Record the timestamp of a successful authentication for a user.
270    ///
271    /// Called automatically by `complete_factor_step` when all factors pass.
272    /// Default: no-op. Override to populate a "last login" column.
273    fn record_last_login(
274        &self,
275        user_id: &UserId,
276        at: chrono::DateTime<chrono::Utc>,
277    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
278        let _ = (user_id, at);
279        std::future::ready(Ok(()))
280    }
281}
282
283// ── IdentityAdmin ────────────────────────────────────────────────────────────
284
285/// Privileged write surface: tenant + user provisioning, password
286/// history, reset-token storage, suspension, and GDPR erasure.
287///
288/// Required by admin entry points on [`AuthnService`](crate::authn::AuthnService) (signup, activate,
289/// password reset, suspend, delete). Adopters whose user lifecycle is
290/// managed out-of-band (SCIM, ops scripts, externally-provisioned
291/// directories) skip this tier; login + audit + lockout still work
292/// through [`IdentityAuthnLog`].
293pub trait IdentityAdmin: IdentityAuthnLog {
294    /// Insert a new tenant row.
295    ///
296    /// Returns an error if a tenant with the same `id` or `identifier`
297    /// already exists. Callers typically invoke this through
298    /// [`create_tenant`](crate::authn::provisioning::create_tenant) so
299    /// factor and method rows are provisioned atomically alongside.
300    fn create_tenant(
301        &self,
302        tenant: Tenant,
303    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
304
305    /// Create a new user. The user should typically be in [`EntityState::Candidate`]
306    /// or [`EntityState::Pending`] state.
307    ///
308    /// Returns an error if a user with the same identifier already exists in the tenant.
309    ///
310    /// # Reserved IDs
311    ///
312    /// Implementations MUST reject calls where `user.id == UserId::system()`
313    /// or `user.tenant_id == TenantId::system()` from any code path
314    /// reachable by untrusted input (self-service signup, OAuth user
315    /// upsert, etc.). The reserved system UUIDs identify the platform
316    /// operator and must not be claimable through the same surface that
317    /// regular tenants/users use. axess provides
318    /// [`ensure_user_id_not_reserved`](super::ids::ensure_user_id_not_reserved)
319    /// as a convenience guard for the common case.
320    fn create_user(
321        &self,
322        user: User,
323    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
324
325    /// Transition a user to [`EntityState::Active`].
326    ///
327    /// Called after completing a signup workflow (e.g. email verification).
328    fn activate_user(
329        &self,
330        user_id: &UserId,
331    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
332
333    /// Store a password hash in the user's password history.
334    ///
335    /// Called automatically after a successful password change. The history
336    /// is the storage primitive behind password-reuse prevention required
337    /// by SOC2, PCI-DSS, and NIST SP 800-63B §5.1.1.2. The default impl
338    /// panics so a missing override surfaces loudly the first time an
339    /// operator changes a password; production backends serving regulated
340    /// users MUST override.
341    fn record_password_hash(
342        &self,
343        user_id: &UserId,
344        hash: &str,
345    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
346        async move {
347            unimplemented!(
348                "IdentityAdmin::record_password_hash({user_id}, hash[{}]) is required \
349                 for password-reuse prevention (SOC2, PCI-DSS, NIST SP 800-63B \
350                 §5.1.1.2). Override this method on your backend to persist the hash \
351                 to a per-user history table. See the trait method docs for the full \
352                 contract.",
353                hash.len(),
354            )
355        }
356    }
357
358    /// Return the last `count` password hashes for a user, most recent first.
359    ///
360    /// Used by [`password_history`](Self::password_history)'s consumer to
361    /// reject a new password whose hash collides with a previously-used
362    /// one; the read side of the password-reuse prevention loop required
363    /// by SOC2, PCI-DSS, and NIST SP 800-63B §5.1.1.2. The default impl
364    /// panics so a missing override surfaces loudly the first time the
365    /// rule fires; production backends MUST override.
366    fn password_history(
367        &self,
368        user_id: &UserId,
369        count: usize,
370    ) -> impl std::future::Future<Output = Result<Vec<String>, Self::Error>> + Send {
371        async move {
372            unimplemented!(
373                "IdentityAdmin::password_history({user_id}, {count}) is required for \
374                 password-reuse prevention (SOC2, PCI-DSS, NIST SP 800-63B \
375                 §5.1.1.2). Override this method on your backend to return the most \
376                 recent `count` hashes from the per-user history table. See the \
377                 trait method docs for the full contract.",
378            )
379        }
380    }
381
382    /// Store a password-reset token hash for a user.
383    ///
384    /// `token_hash` is the SHA-256 hash (URL-safe base64) of the plaintext
385    /// token. `expires_at` is the absolute expiry time. This method backs
386    /// the out-of-band password-recovery feature; the default impl panics
387    /// so an integration that wires the recovery flow without persistence
388    /// surfaces loudly. Production backends offering recovery MUST override.
389    fn store_reset_token(
390        &self,
391        user_id: &UserId,
392        token_hash: &str,
393        expires_at: chrono::DateTime<chrono::Utc>,
394    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
395        async move {
396            unimplemented!(
397                "IdentityAdmin::store_reset_token({user_id}, hash[{}], expires_at={expires_at}) \
398                 is required for the out-of-band password-recovery feature. Override \
399                 this method on your backend to persist (user_id, token_hash, \
400                 expires_at) atomically (single-row upsert). See the trait method \
401                 docs for the full contract.",
402                token_hash.len(),
403            )
404        }
405    }
406
407    /// Verify and consume a password-reset token.
408    ///
409    /// Returns `true` if the token hash matches a stored, non-expired token
410    /// for the user. The token MUST be deleted/consumed on success
411    /// (single-use). This method backs the out-of-band password-recovery
412    /// feature; the default impl panics so a recovery flow wired without a
413    /// verifier surfaces loudly. Production backends offering recovery
414    /// MUST override.
415    fn verify_reset_token(
416        &self,
417        user_id: &UserId,
418        token_hash: &str,
419    ) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send {
420        async move {
421            unimplemented!(
422                "IdentityAdmin::verify_reset_token({user_id}, hash[{}]) is required \
423                 for the out-of-band password-recovery feature. Override this method \
424                 on your backend to look up the stored hash, compare in constant \
425                 time, check the expiry, and delete the row on a successful match \
426                 (single-use). See the trait method docs for the full contract.",
427                token_hash.len(),
428            )
429        }
430    }
431
432    /// Transition a user to [`EntityState::Suspended`] with the given reason.
433    ///
434    /// Existing authenticated sessions are not automatically
435    /// invalidated; use middleware that checks
436    /// [`account_status`](IdentityLookup::account_status) on each
437    /// request, or combine with
438    /// [`SessionRegistry::invalidate_user`](crate::session::store::SessionRegistry::invalidate_user).
439    fn suspend_user(
440        &self,
441        user_id: &UserId,
442        detail: StatusDetail,
443    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
444
445    /// Permanently erase a user and all directly-attributable personal
446    /// data: the GDPR Article 17 "right to erasure" primitive.
447    /// Implementations MUST delete (or irreversibly anonymise) the user
448    /// row, all factor configs under `AuthnScope::User`, refresh tokens,
449    /// persisted sessions, recorded password history, and any
450    /// application-level rows whose retention basis is the user's
451    /// (now-withdrawn) consent. Audit logs and regulated records (KYC,
452    /// transactions) MAY be retained under independent lawful bases,
453    /// with user-identifying columns pseudonymised. After `Ok(())`,
454    /// `get_user` / `find_user` / `account_status` MUST report the user
455    /// gone and any in-flight session MUST fail its next `is_valid`
456    /// check. The default impl panics so a missing override surfaces
457    /// loudly the first time an admin tries to honour an erasure
458    /// request; production backends serving EU/UK users MUST override.
459    fn delete_user(
460        &self,
461        user_id: &UserId,
462    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
463        let _ = user_id;
464        async {
465            unimplemented!(
466                "IdentityAdmin::delete_user is required for GDPR Article 17 \
467                 (right to erasure). Override this method on your backend to \
468                 delete the user row, factor configs, refresh tokens, sessions, \
469                 and password history. See the trait method docs for the full \
470                 contract."
471            )
472        }
473    }
474}
475
476// ── IdentityStore (umbrella) ─────────────────────────────────────────────────
477
478/// Umbrella alias for the all-three-tier identity store: the typical
479/// production-backend shape. The blanket impl below makes any
480/// `T: IdentityAdmin` satisfy `IdentityStore` automatically, so any
481/// adopter that implements all three tiers (`IdentityLookup` +
482/// `IdentityAuthnLog` + `IdentityAdmin`) is automatically an
483/// `IdentityStore`. Most existing code that takes `T: IdentityStore`
484/// keeps compiling unchanged.
485pub trait IdentityStore: IdentityAdmin {}
486impl<T: IdentityAdmin> IdentityStore for T {}
487
488// ── NoopAuthnLog adopter helper ──────────────────────────────────────────────
489
490/// Wraps an [`IdentityLookup`] backend with a no-op [`IdentityAuthnLog`]
491/// impl on top, so it satisfies the `AuthnService::new` bound without
492/// the adopter writing audit + lockout impls.
493///
494/// **Semantics, read carefully before reaching for this:**
495///
496/// - `record_event` silently discards every audit event. No SOC trail.
497/// - `record_failed_attempt` always returns `Ok(1)`. Lockout policy is
498///   effectively disabled; the counter never accumulates beyond 1, so
499///   `max_attempts` thresholds are never crossed regardless of how
500///   many failures occur.
501/// - `reset_failed_attempts` is a no-op.
502/// - `record_last_login` inherits the trait default no-op.
503///
504/// Appropriate uses:
505///
506/// - Integration tests / fixtures that don't exercise the lockout or
507///   audit paths.
508/// - Read-replica integrations where audit goes through a separate
509///   pipeline (e.g. database CDC, a sidecar log forwarder).
510/// - Prototypes that want login working before the audit table is
511///   designed.
512///
513/// **Production deployments must override `IdentityAuthnLog` directly.**
514/// Routing failed attempts to `Ok(1)` is a security regression on any
515/// surface accepting untrusted input.
516pub struct NoopAuthnLog<L>(pub L);
517
518impl<L: IdentityLookup + Clone> Clone for NoopAuthnLog<L> {
519    fn clone(&self) -> Self {
520        Self(self.0.clone())
521    }
522}
523
524impl<L: IdentityLookup> IdentityLookup for NoopAuthnLog<L> {
525    type Error = L::Error;
526
527    fn find_user(
528        &self,
529        identifier: &str,
530        tenant_id: &TenantId,
531    ) -> impl std::future::Future<Output = Result<Option<User>, Self::Error>> + Send {
532        self.0.find_user(identifier, tenant_id)
533    }
534
535    fn get_user(
536        &self,
537        user_id: &UserId,
538    ) -> impl std::future::Future<Output = Result<Option<User>, Self::Error>> + Send {
539        self.0.get_user(user_id)
540    }
541
542    fn find_tenant(
543        &self,
544        identifier: &str,
545    ) -> impl std::future::Future<Output = Result<Option<Tenant>, Self::Error>> + Send {
546        self.0.find_tenant(identifier)
547    }
548
549    fn default_tenant(
550        &self,
551    ) -> impl std::future::Future<Output = Result<Tenant, Self::Error>> + Send {
552        self.0.default_tenant()
553    }
554
555    fn account_status(
556        &self,
557        user_id: &UserId,
558    ) -> impl std::future::Future<Output = Result<EntityState, Self::Error>> + Send {
559        self.0.account_status(user_id)
560    }
561
562    fn lockout_policy(&self) -> LockoutPolicy {
563        self.0.lockout_policy()
564    }
565
566    fn lockout_policy_for_tenant(&self, tenant_id: &TenantId) -> LockoutPolicy {
567        self.0.lockout_policy_for_tenant(tenant_id)
568    }
569
570    fn password_rules_for_tenant(
571        &self,
572        tenant_id: &TenantId,
573    ) -> impl std::future::Future<Output = Result<PasswordRules, Self::Error>> + Send {
574        self.0.password_rules_for_tenant(tenant_id)
575    }
576
577    fn ip_policy_for_tenant(
578        &self,
579        tenant_id: &TenantId,
580    ) -> impl std::future::Future<Output = Result<crate::authn::types::IpPolicy, Self::Error>> + Send
581    {
582        self.0.ip_policy_for_tenant(tenant_id)
583    }
584}
585
586impl<L: IdentityLookup> IdentityAuthnLog for NoopAuthnLog<L> {
587    async fn record_event(&self, event: AuthEvent) -> Result<(), Self::Error> {
588        tracing::trace!(
589            target: "axess::authn::noop_log",
590            event_type = ?event.event_type,
591            user_id = ?event.user_id,
592            "NoopAuthnLog: event discarded (no SOC trail wired up)",
593        );
594        Ok(())
595    }
596
597    async fn record_failed_attempt(&self, user_id: &UserId) -> Result<u32, Self::Error> {
598        tracing::trace!(
599            target: "axess::authn::noop_log",
600            %user_id,
601            "NoopAuthnLog: failed attempt not persisted; lockout policy disabled",
602        );
603        Ok(1)
604    }
605
606    async fn reset_failed_attempts(&self, user_id: &UserId) -> Result<(), Self::Error> {
607        tracing::trace!(
608            target: "axess::authn::noop_log",
609            %user_id,
610            "NoopAuthnLog: reset_failed_attempts is a no-op",
611        );
612        Ok(())
613    }
614}
615
616// ── FactorStore ───────────────────────────────────────────────────────────────
617
618/// Factor credential storage. Implement alongside [`IdentityStore`] (usually same DB struct).
619///
620/// Provides typed [`FactorConfig`], not `HashMap<String, JsonValue>`.
621pub trait FactorStore: Send + Sync + 'static {
622    /// Error type returned by storage operations.
623    type Error: std::error::Error + Send + Sync + 'static;
624
625    /// Load the factor configuration for a given scope and kind.
626    ///
627    /// ## Resolution contract
628    ///
629    /// - For [`AuthnScope::User { user_id, tenant_id }`]: try the
630    ///   user-scoped row first, then fall back to the tenant-scoped row.
631    ///   Return `None` if neither exists.
632    /// - For [`AuthnScope::Tenant`]: return the tenant-scoped row only.
633    ///   Return `None` if the tenant has not adopted this factor.
634    /// - For [`AuthnScope::Global`]: the contract is **no runtime fallback
635    ///   to a platform-wide default row**. Global config in axess is
636    ///   expressed via [`FactorTemplate`](crate::authn::factor::FactorTemplate)
637    ///   catalog entries that tenants adopt explicitly at provisioning
638    ///   time. Implementations may return the matching catalog template's
639    ///   `default_config` for display purposes, but must **not** route
640    ///   runtime auth decisions through a global row.
641    ///
642    /// The rationale is documented in `docs/identity/tenancy.md`: silent global
643    /// inheritance leaks information about factors a tenant admin chose
644    /// not to enable, and makes platform-wide config changes surprise
645    /// tenants. Tenants own their auth menu explicitly.
646    fn load_factor(
647        &self,
648        scope: &AuthnScope,
649        kind: FactorKind,
650    ) -> impl std::future::Future<Output = Result<Option<FactorConfig>, Self::Error>> + Send;
651
652    /// Persist an updated factor configuration (e.g., after TOTP counter increment or HOTP advance).
653    fn save_factor(
654        &self,
655        scope: &AuthnScope,
656        config: FactorConfig,
657    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
658
659    /// Atomically replace the stored factor config with `updated` only if the
660    /// currently stored config matches `prior`. Returns `Ok(true)` on
661    /// successful swap and `Ok(false)` if the stored value has changed since
662    /// it was loaded (indicating a concurrent update; for TOTP/HOTP this
663    /// means the credential has already been spent in another request).
664    ///
665    /// This method is **required**: implementations MUST make the conditional
666    /// update atomic with respect to other writes; typically a single
667    /// `UPDATE ... WHERE` statement using the prior values as a guard, or
668    /// an equivalent single-transaction primitive on the backend. A
669    /// non-atomic load-compare-save would re-open the TOTP/HOTP replay
670    /// window the trait exists to close.
671    ///
672    /// **Comparison semantic is backend-defined**, not byte-equality on the
673    /// serialized blob. SQL backends SHOULD compare on a monotonic
674    /// generation/version column, or on the specific mutable columns the
675    /// factor advances (`last_step` for TOTP, `counter`+`attempt_count` for
676    /// HOTP, etc.), not on the full serialized `FactorConfig`. Byte-equality
677    /// on the serialized form couples in-flight CAS attempts to the
678    /// serialized shape of every `FactorConfig` variant; a serde-compatible
679    /// change to an unrelated field would invalidate a concurrent
680    /// verification's CAS, masquerading as replay. The `prior` argument is
681    /// the value the caller loaded earlier; the backend decides what
682    /// "matches" means in its storage model.
683    fn compare_and_save_factor(
684        &self,
685        scope: &AuthnScope,
686        prior: &FactorConfig,
687        updated: FactorConfig,
688    ) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send;
689
690    /// Return the ordered list of **enabled** authentication methods
691    /// available for a user.
692    ///
693    /// Implementations MUST exclude methods whose `enabled` column is
694    /// `false`; a disabled method is not a valid login path, whether the
695    /// disable is temporary (rollout / maintenance) or permanent.
696    fn available_methods(
697        &self,
698        user_id: &UserId,
699        tenant_id: &TenantId,
700    ) -> impl std::future::Future<Output = Result<Vec<AuthMethod>, Self::Error>> + Send;
701
702    /// Persist an authentication method at the given scope. Idempotent on
703    /// `(scope, method.name)`; inserting a method with the same name
704    /// replaces the previous config.
705    ///
706    /// Implementations SHOULD set `enabled = true` for newly persisted
707    /// methods unless the `method`'s shape carries an explicit enabled
708    /// flag (axess-core's `AuthMethod` does not currently have one; the
709    /// enabled bit lives at the storage level in implementations such
710    /// as the example SQLite backend's `auth_methods.enabled` column).
711    fn save_method(
712        &self,
713        scope: &AuthnScope,
714        method: AuthMethod,
715    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
716
717    /// Remove an authentication method identified by `(scope, name)`.
718    ///
719    /// Returns `Ok(())` whether or not a matching row existed. For soft
720    /// lifecycle management (disable instead of delete), use
721    /// [`set_method_enabled`](Self::set_method_enabled).
722    fn remove_method(
723        &self,
724        scope: &AuthnScope,
725        name: &str,
726    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
727
728    /// Toggle an authentication method's `enabled` flag without
729    /// deleting the row. Used for maintenance windows, staged rollouts,
730    /// and as one of the operator levers for "lock this tenant out"
731    /// (see `docs/identity/tenancy.md`).
732    ///
733    /// Returns `Ok(false)` if no method matched, `Ok(true)` on update.
734    fn set_method_enabled(
735        &self,
736        scope: &AuthnScope,
737        name: &str,
738        enabled: bool,
739    ) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send;
740}
741
742// ── AuthMethod ────────────────────────────────────────────────────────────────
743
744/// An authentication method: a named sequence of factor steps.
745///
746/// Each step is either a required factor or a choice among alternatives.
747///
748/// # Examples
749///
750/// Sequential MFA (password then TOTP):
751/// ```text
752/// AuthMethod {
753///     name: "password+totp",
754///     steps: vec![FactorStep::Required(Password), FactorStep::Required(Totp)],
755///     ..
756/// }
757/// ```
758///
759/// Factor choice (FIDO2 or password):
760/// ```text
761/// AuthMethod {
762///     name: "passkey-or-password",
763///     steps: vec![FactorStep::AnyOf(vec![Fido2, Password])],
764///     ..
765/// }
766/// ```
767#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct AuthMethod {
769    /// Human-readable method name, e.g. `"password"`, `"password+totp"`.
770    pub name: Arc<str>,
771    /// Factor steps in the order they must be completed.
772    ///
773    /// Each step is either [`FactorStep::Required`](crate::authn::factor::FactorStep::Required) (exactly one factor) or
774    /// [`FactorStep::AnyOf`](crate::authn::factor::FactorStep::AnyOf) (user chooses one from the list).
775    ///
776    /// For backward compatibility, use the [`factors`](AuthMethod::factors)
777    /// convenience method if you only need simple sequential flows.
778    pub steps: Vec<crate::authn::factor::FactorStep>,
779    /// The scope at which this method is defined.
780    pub scope: AuthnScope,
781}
782
783impl AuthMethod {
784    /// Convenience: create a method from a flat list of required factors (sequential MFA).
785    pub fn sequential(
786        name: impl Into<Arc<str>>,
787        factors: Vec<FactorKind>,
788        scope: AuthnScope,
789    ) -> Self {
790        Self {
791            name: name.into(),
792            steps: factors
793                .into_iter()
794                .map(crate::authn::factor::FactorStep::Required)
795                .collect(),
796            scope,
797        }
798    }
799
800    /// Return the flat list of factor kinds for simple sequential methods.
801    ///
802    /// For methods using `AnyOf` steps, this returns the first choice of
803    /// each `AnyOf` step; use `steps` directly for full fidelity.
804    pub fn factors(&self) -> Vec<FactorKind> {
805        self.steps
806            .iter()
807            .map(|step| match step {
808                crate::authn::factor::FactorStep::Required(k) => k.clone(),
809                crate::authn::factor::FactorStep::AnyOf(choices) => {
810                    choices.first().cloned().unwrap_or(FactorKind::Password)
811                }
812            })
813            .collect()
814    }
815}
816
817// ── AuthnBackend convenience supertrait ───────────────────────────────────────
818
819/// Convenience supertrait for types that implement both [`IdentityStore`]
820/// and [`FactorStore`] with the same error type.
821///
822/// Most applications implement both traits on the same database-backed struct;
823/// `AuthnService::from_backend(backend)` accepts any `B: AuthnBackend + Clone`
824/// and avoids the universal `(b.clone(), b)` ceremony at every call site.
825///
826/// `IdentityStore` is itself an umbrella alias for the three-tier identity
827/// surface (`IdentityLookup + IdentityAuthnLog + IdentityAdmin`), so
828/// `AuthnBackend` resolves transitively to "all three identity tiers + the
829/// factor store, sharing one `Error` type". Adopters who skip
830/// [`IdentityAdmin`] (e.g. SCIM-provisioned deployments) construct the
831/// service with `AuthnService::new(identity, factors)` directly instead
832/// of the `from_backend` helper.
833pub trait AuthnBackend: IdentityStore<Error = <Self as FactorStore>::Error> + FactorStore {}
834
835impl<T> AuthnBackend for T where
836    T: IdentityStore + FactorStore + IdentityStore<Error = <T as FactorStore>::Error>
837{
838}
839
840#[cfg(test)]
841mod noop_authn_log_tests;
842
843#[cfg(test)]
844mod store_tests;