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;