Identity store implementation
Most of axess works against traits, and the identity store is the
most consequential of them. The library does not prescribe a user
schema, a tenant schema, or a factor schema; it prescribes a set
of trait methods you implement against whatever
schema it already has. The three-tier split is by privilege, so an
adopter implements only the tier it needs, down to the
read-replica-and-fixtures variant that the NoopAuthnLog adapter
enables.
The traits
Split by privilege, so an adopter implements only the tier it needs.
The three tiers
The identity store is split into three trait tiers, in order of increasing privilege. An adopter that needs only read access implements the narrowest tier; an adopter that needs write access for audit purposes implements the middle tier; an adopter that needs full administrative control implements the widest tier.
// Tier 1: read-only.
pub trait IdentityLookup: Send + Sync + 'static {
/// Your error type, not ours. Every method below returns it.
type Error: std::error::Error + Send + Sync + 'static;
fn find_user(
&self,
identifier: &str,
tenant_id: &TenantId,
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
fn get_user(
&self,
user_id: &UserId,
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
fn find_tenant(
&self,
identifier: &str,
) -> impl Future<Output = Result<Option<Tenant>, Self::Error>> + Send;
fn account_status(
&self,
user_id: &UserId,
) -> impl Future<Output = Result<EntityState, Self::Error>> + Send;
// `get_user_in_tenant`, `default_tenant`, and the per-tenant policy
// getters (`lockout_policy_for_tenant`, `password_rules_for_tenant`,
// `ip_policy_for_tenant`) have defaults you can take as given.
}
// Tier 2: read + the writes the login flow makes as it runs.
pub trait IdentityAuthnLog: IdentityLookup {
fn record_event(
&self,
event: AuthEvent,
) -> impl Future<Output = Result<AuditOutcome, Self::Error>> + Send;
/// Returns the running count *after* this failure, which is what
/// the lockout policy compares against its threshold.
fn record_failed_attempt(
&self,
user_id: &UserId,
) -> impl Future<Output = Result<u32, Self::Error>> + Send;
fn reset_failed_attempts(
&self,
user_id: &UserId,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
// `record_last_login` defaults to a no-op.
}
// Tier 3: read + audit + administrative writes.
pub trait IdentityAdmin: IdentityAuthnLog {
fn create_tenant(
&self,
tenant: Tenant,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn create_user(
&self,
user: User,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn activate_user(
&self,
user_id: &UserId,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
// Plus the suspension verbs. `delete_user` is the one method here
// that still defaults to `unimplemented!()`; nothing in axess calls
// it, so the panic fires only if you call it yourself. See below.
}
// The umbrella for production: all three tiers.
pub trait IdentityStore: IdentityAdmin {}
impl<T: IdentityAdmin> IdentityStore for T {}
// Separate, and required rather than defaulted. Only the password-reset
// flow asks for it, and only a store that implements it can call that
// flow.
pub trait IdentityPasswordReset: IdentityLookup {
fn store_reset_token(
&self,
user_id: &UserId,
token_hash: &str,
expires_at: DateTime<Utc>,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn verify_reset_token(
&self,
user_id: &UserId,
token_hash: &str,
) -> impl Future<Output = Result<bool, Self::Error>> + Send;
}
Four things about that surface are worth stating plainly, because each one shapes an implementation.
The futures are native impl Future, not #[async_trait] boxes. Your
methods are ordinary async fn bodies and cost no allocation per call.
The error is an associated Self::Error, declared once on
IdentityLookup and inherited by the other two tiers. Axess never asks
you to convert into an error type of its own; sqlx::Error is a
perfectly good Self::Error.
There is no AttemptRecord, LockoutRecord or NewUser. The audit
tier writes a single flat AuthEvent through record_event, and
failure counting is a pair of numeric verbs rather than a record type.
record_failed_attempt returns the count after the increment, which is
the number the lockout policy compares against its threshold. Lockout
state is derived from that count and the policy, not stored as its own
row. create_user takes a fully-formed User, so identifier
generation and defaulting happen in your code, before the call.
Four methods that once had unimplemented!() defaults on IdentityAdmin
no longer do. They live on two capability traits with no default bodies,
and the flows that need them are bounded on those traits, so a store that
has not implemented them cannot reach the flow and the omission is a
compile error rather than a panic.
IdentityPasswordReset holds store_reset_token and
verify_reset_token. The reason is the shape of a mistake rather than a
preference. begin_password_reset answers Ok(None) for an identifier
it cannot find and reaches the store only for one it can, so a panicking
default answered an unauthenticated "forgot password" request with a 200
for an address that is not registered and a 500 for one that is: a
user-enumeration oracle, in the function that equalizes its own timing
specifically to avoid leaking that. A silently-succeeding default would
have been worse, since recovery would appear to work while no token was
ever stored.
IdentityPasswordHistory holds record_password_hash and
password_history, for a plainer reason. record_password_hash is
called on every password change with no guard, so a backend that had
not overridden it panicked the first time any user changed their
password, on a method that looked optional because a defaulted trait
method does.
A deployment with no password-reuse policy implements neither trait and loses nothing.
The hierarchy reads from narrowest to widest. An
IdentityAuthnLog is an IdentityLookup plus the audit writes.
An IdentityAdmin is an IdentityAuthnLog plus the
administrative writes. The umbrella IdentityStore is the
all-three-tiers shape that production backends implement.
Why three tiers
The split is the answer to two adopter situations the library has seen often enough to model explicitly.
The first situation is a read-replica deployment. A high-traffic
application runs the login flow against a read-replica of the
user database for latency reasons. The replica cannot accept
writes, so you need the read verbs without the write
verbs. The IdentityLookup tier covers this. The application
implements IdentityLookup against the replica and
IdentityAuthnLog (which needs writes) against the primary.
The second situation is a fixture deployment. A test or an
embedded usage of axess does not have a real database; the
application uses an in-memory backend for the read verbs and does
not care about the audit writes. The NoopAuthnLog adapter
wraps an IdentityLookup and provides no-op implementations of
the IdentityAuthnLog write verbs. The fixture has the trait
surface it needs without writing an audit-table mock.
The third situation, less common, is a deployment with a
separation between the code that handles login and
the administrative code that creates users. The application
implements IdentityAuthnLog; the admin code separately
implements IdentityAdmin. The split prevents the login path
code from accidentally calling delete_user or suspend_user
because it never has the trait method in scope.
What the verbs actually do
The verbs split cleanly across the tiers.
IdentityLookup is reads. get_user is a primary-key lookup by
UserId. find_user is a credentials-side lookup by identifier
and tenant: the user typed alice@example.com, and you
needs to know if this is a real user in this tenant. Other read
verbs cover the variants: looking up a user by email when email
is separately indexed, looking up a user by a federated identity
key when you support federated login, listing the
users in a tenant for admin tooling.
IdentityAuthnLog is the writes the login flow makes as it runs.
record_event takes one flat AuthEvent, carrying the event type, the
status, the timestamp in epoch microseconds, and whatever attribution
was resolvable: user, tenant, session, factor kind, the client address
as an IpAddr, user agent, request id, and the failure reason as an
AuthFailureReason rather than free text. It is called throughout the
flow, not only after a factor check, and its argument is the same type
the audit query surface returns.
Three of those fields deserve a nullable column and usually get a
surprised implementer: the address, user agent and request id are None
on every event unless the request path called
AuthnService::with_audit_context. Axess cannot resolve a trustworthy
client address on its own, since that needs your TCP peer and your
trusted-proxy set, so it records nothing rather than something
forgeable. Cookies, fingerprinting, hijack detection covers the
wiring.
What you return decides whether the login succeeds. Err fails the
authentication: an authentication that leaves no evidence has not, for
evidence purposes, happened. Reserve it for a sink that is genuinely
broken, and expect logins to fail while yours is. Ok(Shed) says you
dropped the event deliberately under load, and the flow continues.
Shed on something identifier-independent, a global rate or a queue depth
or a disk watermark. A shed decision derived from which identifier was
tried is observable per identifier, and rebuilds the user-enumeration
oracle that writing unattributable rejections exists to close. record_failed_attempt increments the user's failure
counter and returns the new count, which is the number the
LockoutPolicy compares against its threshold;
reset_failed_attempts zeroes it on a successful login or an
administrative clear. Lockout keeps no record of its own: the locked
state is derived from the counter and the policy.
IdentityAdmin is the privileged writes. create_tenant and
create_user are the provisioning verbs, and create_user takes an
already-built User, so identifier generation and defaulting happen in
your code, not behind the trait. activate_user moves a user out of
the pending state. The rest of the tier is suspension and deletion.
Password history and reset tokens used to live here; they are capability
traits of their own now, for the reasons above.
Implementing them
Against SQL, and the two places the contract is easy to get wrong.
Implementing against SQL
The typical implementation against a SQL database is verbose but mechanical. The pattern is to implement each verb as one query (or one transaction), with the right indexes on the user table to keep the reads fast.
A reference implementation against PostgreSQL is in
examples/sqlite/
(the SQLite version of the pattern). The shape:
struct OurBackend {
pool: SqlitePool,
}
impl IdentityLookup for OurBackend {
type Error = BackendError;
async fn get_user(&self, user_id: &UserId) -> Result<Option<User>, Self::Error> {
let row = sqlx::query(
"SELECT id, tenant_id, identifier, display_name, status,
failed_attempts, locked_until, created_at, updated_at
FROM users
WHERE id = ?1",
)
.bind(user_id.to_string())
.fetch_optional(self.pool())
.await?;
Ok(row.map(|r| user_from_row(self.clock(), &r)))
}
async fn find_user(
&self,
identifier: &str,
tenant_id: &TenantId,
) -> Result<Option<User>, Self::Error> {
let row = sqlx::query(
"SELECT id, tenant_id, identifier, display_name, status,
failed_attempts, locked_until, created_at, updated_at
FROM users
WHERE tenant_id = ?1 AND identifier = ?2",
)
.bind(tenant_id.to_string())
.bind(identifier)
.fetch_optional(self.pool())
.await?;
Ok(row.map(|r| user_from_row(self.clock(), &r)))
}
// ... find_tenant, default_tenant, account_status
}
The patterns to note:
The tenant scope is on every query. find_user filters by both
identifier and tenant id; the same identifier in a different
tenant is not returned. The discipline is what enforces cross-tenant
refusal at the storage layer.
The identifier comparison is whatever the deployment chose. The
example treats the identifier as case-sensitive; deployments that
want case-insensitive matching apply LOWER() to both sides (and
index on LOWER(identifier)). The trait does not opinionate; the
implementation decides.
The error type is the implementation's own, declared once as
type Error = BackendError on IdentityLookup and inherited by the
other two tiers. ? on a sqlx call works because BackendError
implements From<sqlx::Error>; nothing converts into an axess type.
Keep the kind of failure (connection, query, constraint violation)
distinguishable in that enum, because your own callers will want to act
on it even though axess only logs it.
Both read verbs return Option<User>. A missing user is Ok(None),
not an error: the login flow treats "no such user" and "wrong password"
identically, on purpose, so that a failed login does not reveal which
identifiers exist.
Implementing the audit writes
IdentityAuthnLog is the layer that requires care. Its verbs fire on
every login attempt, so a slow implementation is the bottleneck of the
whole authentication flow.
There are three of them, and they divide neatly. record_event appends
one flat AuthEvent row. record_failed_attempt and
reset_failed_attempts maintain a single counter on the user.
impl axess::authn::IdentityAuthnLog for OurBackend {
async fn record_event(&self, event: AuthEvent) -> Result<AuditOutcome, Self::Error> {
let event_id = Uuid::new_v4().to_string();
// Unresolved attribution (pre-auth failures, malformed OAuth claims)
// persists as NULL so audit queries can distinguish "we don't know"
// from a real principal. The schema's `user_id` / `tenant_id`
// columns allow NULL for this reason.
let user_id: Option<String> = event.user_id.as_ref().map(|u| u.to_string());
let tenant_id: Option<String> = event.tenant_id.as_ref().map(|t| t.to_string());
let session_id = event.session_id.map(|sid| sid.to_string());
let event_type = event.event_type.to_string();
let event_status = event.event_status.to_string();
let event_time = DateTime::<Utc>::from_timestamp_micros(event.event_time)
.expect("event_time micros in range")
.to_rfc3339();
let factor_kind = event.factor_kind.as_ref().map(|k| k.as_str().to_string());
let ip_address = event.ip_address.map(|ip| ip.to_string());
let ip_source = event.ip_source.as_str();
let user_agent = event.user_agent.as_deref().map(|s| s.to_string());
let request_id = event.request_id.as_deref().map(|s| s.to_string());
let trace_id = event.trace_id.as_deref().map(|s| s.to_string());
let geo_country = event.geo_country.as_deref().map(|s| s.to_string());
let error = event.error.as_ref().map(|r| r.as_str().to_string());
sqlx::query(
"INSERT INTO auth_events
(id, user_id, tenant_id, session_id, event_type, event_status, event_time,
factor_kind, ip_address, ip_source, user_agent, request_id, trace_id,
geo_country, error)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)
.bind(&event_id)
.bind(&user_id)
.bind(&tenant_id)
.bind(&session_id)
.bind(&event_type)
.bind(&event_status)
.bind(&event_time)
.bind(&factor_kind)
.bind(&ip_address)
.bind(ip_source)
.bind(&user_agent)
.bind(&request_id)
.bind(&trace_id)
.bind(&geo_country)
.bind(&error)
.execute(self.pool())
.await?;
debug!(
event_type = %event_type,
event_status = %event_status,
user_id = user_id.as_deref().unwrap_or("<unattributed>"),
"auth event recorded"
);
// This example writes every event. A sink that needs to protect
// its storage under a flood of unauthenticated login attempts
// returns `AuditOutcome::Shed` instead, on a criterion that does
// not depend on the identifier.
Ok(AuditOutcome::Recorded)
}
async fn record_failed_attempt(&self, user_id: &UserId) -> Result<u32, Self::Error> {
sqlx::query("UPDATE users SET failed_attempts = failed_attempts + 1 WHERE id = ?1")
.bind(user_id.to_string())
.execute(self.pool())
.await?;
let row = sqlx::query("SELECT failed_attempts FROM users WHERE id = ?1")
.bind(user_id.to_string())
.fetch_one(self.pool())
.await?;
let count: i64 = row.get("failed_attempts");
Ok(count as u32)
}
async fn reset_failed_attempts(&self, user_id: &UserId) -> Result<(), Self::Error> {
sqlx::query("UPDATE users SET failed_attempts = 0 WHERE id = ?1")
.bind(user_id.to_string())
.execute(self.pool())
.await?;
Ok(())
}
}
Two properties of that shape matter operationally.
auth_events is append-only and flat. Every event lands in that one table,
and no join to reconstruct an attempt; every event carries its own
event_type, event_status and nullable attribution. Index it on
(user_id, tenant_id, event_time DESC) for the "what happened to this
account" query, and on (tenant_id, event_time DESC) for the
tenant-wide one.
The failure counter is a column on users, not a row count over
auth_events. Lockout is derived from that counter and the
LockoutPolicy, so there is no lockout table to upsert and no
clear_lockout verb: reset_failed_attempts is the whole of it. The
increment-then-read above is two statements; under contention, prefer
whatever single-statement form your database offers (UPDATE ... RETURNING failed_attempts on PostgreSQL) so two concurrent failures
cannot both read the same count.
The audit read is the hottest query in this layer. The index (user_id, tenant_id, event_time DESC) makes it cheap; without the index, the query degrades to a table scan and the login flow slows under load.
The append-only attempts table grows. The retention story for it is in Audit pipeline: typically a hot/cold split where recent attempts (the ones the lockout policy consults) stay in the attempts table and older attempts archive to a cold store.
The NoopAuthnLog adapter
NoopAuthnLog<L> wraps an IdentityLookup and provides no-op
implementations of the IdentityAuthnLog write verbs. The
wrapper exists for two cases.
The first is fixtures. A test uses MockIdentityStore
(implementing IdentityLookup), and verify_factor needs
IdentityAuthnLog. The test wraps the mock in NoopAuthnLog,
satisfies the trait, and runs without recording anything.
The second is read-replica deployments where the audit writes go
through a different code path (an out-of-band log shipper, a
Kafka topic, an external SIEM). The application implements
IdentityLookup against the replica, wraps in NoopAuthnLog,
and routes the audit writes through the side channel.
The trade-off is that the lockout policy will not function correctly
under NoopAuthnLog. The policy decides from the count
record_failed_attempt returns, and the noop discards the increment,
so the count never rises and the threshold is never reached.
Deployments that use NoopAuthnLog for the read-replica case must
accept a degraded lockout policy unless they implement an alternative.
The chapter warns about this in the docstring of NoopAuthnLog;
the warning is worth repeating: do not use NoopAuthnLog in
production without an alternative lockout source.
Fitting an existing system
Where axess bends to a schema you already run.
What about workload identities
Workloads have their own identity surface, not the same one
humans use. The IdentityStore traits do not cover workloads;
the workload identity resolvers (Workload identity overview)
have their own machinery.
The split is deliberate. Humans live in a user table; workloads live in a workload table (or do not live anywhere durable, when they are short-lived service-to-service callers). The audit events for workloads route differently from human events. The lockout policy does not apply to workloads at all. Trying to unify the two would produce a trait that does too many jobs.
The same is true for the principal model: the Principal enum
has two variants, the read paths for the two variants go through
two different stores. The application implements both stores and
the resolver code routes appropriately.
Schema migration
The identity store is the part of your system most likely to need migrations over time: a new factor adds a column to the factor configurations table, a regulatory change requires a new field on the audit-attempts table, a refactor renames a column.
The migration mechanism is yours, not axess's.
sqlx::migrate! is the standard pattern; alternative migration
tools (Diesel migrations, Atlas, custom SQL) work the same way.
Axess does not need to know about the migrations; the
implementation just needs to keep satisfying the trait against
the new schema.
The pattern in examples/sqlite/ is the reference. The
migrations/ directory carries the SQL files; the main.rs
runs them at startup; the implementation queries against the
latest schema.
Fitting into a schema you already have
The trait split is what lets axess fit into existing applications without forcing a schema rewrite. The library knows nothing about the user table; it knows only that there is a trait it can call to look up users. The application's data model is the source of truth, and the trait surface is the bridge.
The three tiers and the noop adapter give you enough flexibility to fit the awkward shapes (read replicas, fixtures, split admin) without forcing every adopter to implement the full set of verbs.
Further reading
Multi-tenancy covers the per-tenant configuration that the
identity store reads and writes. Audit events covers the
AuthEvent variants the audit-log verbs emit. Audit pipeline
covers the hot/cold retention story for the attempts table.
Migration guide covers the cross-version migrations that affect
the user table.