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 that the application implements against whatever schema it already has. This chapter walks through the three-tier trait split, the verbs each tier carries, the patterns for implementing them against a SQL backend, and the read-replica-and-fixtures variant that the NoopAuthnLog adapter enables.

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.
#[async_trait]
pub trait IdentityLookup: Send + Sync {
    async fn get_user(&self, user_id: &UserId) -> Result<User, StoreError>;
    async fn find_user(
        &self,
        identifier: &str,
        tenant_id: &TenantId,
    ) -> Result<Option<User>, StoreError>;
    // ... eight more verbs
}

// Tier 2: read + per-attempt audit writes.
#[async_trait]
pub trait IdentityAuthnLog: IdentityLookup {
    async fn record_attempt(
        &self,
        attempt: AttemptRecord,
    ) -> Result<(), StoreError>;
    async fn record_lockout(
        &self,
        lockout: LockoutRecord,
    ) -> Result<(), StoreError>;
    async fn clear_lockout(
        &self,
        user_id: &UserId,
        tenant_id: &TenantId,
    ) -> Result<(), StoreError>;
    async fn last_attempts(
        &self,
        user_id: &UserId,
        tenant_id: &TenantId,
        limit: usize,
    ) -> Result<Vec<AttemptRecord>, StoreError>;
}

// Tier 3: read + audit + administrative writes.
#[async_trait]
pub trait IdentityAdmin: IdentityAuthnLog {
    async fn create_user(&self, user: NewUser) -> Result<User, StoreError>;
    async fn suspend_user(&self, user_id: &UserId, at: DateTime<Utc>) -> Result<(), StoreError>;
    async fn erase_user(&self, user_id: &UserId, gdpr_reason: &str) -> Result<(), StoreError>;
    // ... six more verbs covering admin lifecycle
}

// The umbrella for production: all three tiers.
pub trait IdentityStore: IdentityAdmin {}
impl<T: IdentityAdmin> IdentityStore for T {}

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; the application needs 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 application 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 application 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, the application 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 the application supports federated login, listing the users in a tenant for admin tooling.

IdentityAuthnLog is the audit writes the lockout system depends on. record_attempt is called by verify_factor after every factor check; the record carries the user id, the tenant id, the factor kind, the outcome (success, failure, locked), the timestamp, the IP. record_lockout is called when the lockout policy fires; the record marks the user as locked until a specific moment. clear_lockout is called when the lockout window expires or when an administrator manually clears the state. last_attempts is the read verb the policy consults to make the next lockout decision.

IdentityAdmin is the privileged writes. create_user is the verb behind signup or admin provisioning. suspend_user is the verb behind administrative suspension (compliance, fraud investigation). erase_user is the GDPR-shaped verb: the user has invoked their right to be forgotten, and the verb cascades through every record that references them. Other admin verbs cover password reset (administrative, not user-initiated), identifier changes, and the per-user method override.

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,
}

#[async_trait]
impl IdentityLookup for OurBackend {
    async fn get_user(&self, user_id: &UserId) -> Result<User, StoreError> {
        let row = sqlx::query_as::<_, UserRow>(
            "SELECT id, tenant_id, identifier, display_name, status, created_at
             FROM users
             WHERE id = ?1"
        )
        .bind(user_id.to_string())
        .fetch_one(&self.pool)
        .await?;
        Ok(row.into())
    }

    async fn find_user(
        &self,
        identifier: &str,
        tenant_id: &TenantId,
    ) -> Result<Option<User>, StoreError> {
        let row = sqlx::query_as::<_, UserRow>(
            "SELECT id, tenant_id, identifier, display_name, status, created_at
             FROM users
             WHERE identifier = ?1 AND tenant_id = ?2"
        )
        .bind(identifier)
        .bind(tenant_id.to_string())
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(Into::into))
    }
    // ... eight more verbs
}

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. The trait returns StoreError; the implementation maps sqlx::Error into it. The mapping preserves the kind of failure (connection error, query error, constraint violation) so the upstream callers can act on specific cases.

Implementing the audit writes

IdentityAuthnLog is the layer that requires care. The verbs fire on every login attempt; a slow implementation is the bottleneck of the entire authentication flow.

The pattern is to batch where possible and to keep each write small. The record_attempt table is append-only and indexed on (user_id, tenant_id, timestamp) for the last_attempts query. The lockout state lives in a separate table keyed by user; the record_lockout and clear_lockout verbs are upserts.

#[async_trait]
impl IdentityAuthnLog for OurBackend {
    async fn record_attempt(&self, attempt: AttemptRecord) -> Result<(), StoreError> {
        sqlx::query(
            "INSERT INTO authn_attempts (user_id, tenant_id, factor_kind, outcome, ip, ts)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
        )
        .bind(attempt.user_id.to_string())
        .bind(attempt.tenant_id.to_string())
        .bind(attempt.factor_kind.as_str())
        .bind(attempt.outcome.as_str())
        .bind(attempt.ip.map(|ip| ip.to_string()))
        .bind(attempt.ts)
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn last_attempts(
        &self,
        user_id: &UserId,
        tenant_id: &TenantId,
        limit: usize,
    ) -> Result<Vec<AttemptRecord>, StoreError> {
        let rows = sqlx::query_as::<_, AttemptRow>(
            "SELECT user_id, tenant_id, factor_kind, outcome, ip, ts
             FROM authn_attempts
             WHERE user_id = ?1 AND tenant_id = ?2
             ORDER BY ts DESC
             LIMIT ?3"
        )
        .bind(user_id.to_string())
        .bind(tenant_id.to_string())
        .bind(limit as i64)
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().map(Into::into).collect())
    }
    // ... record_lockout, clear_lockout
}

The last_attempts query is the hottest read in the audit layer. The index (user_id, tenant_id, ts 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 consults last_attempts, which depends on the audit writes the noop silently discarded. Deployments that use NoopAuthnLog for the read-replica case must accept that the lockout policy is degraded 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.

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 the application 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 the application's, 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.

What this enables

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 the application 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.