Files
biggus-dickus/docs/plans/2026-06-02-auth-email-password.md

52 KiB
Raw Permalink Blame History

Authentication (email/password) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Email/password authentication scoped to the single org the instance serves, with server-side sessions and a type-driven authorization layer: an auth crate providing argon2id password hashing + axum extractors (AuthUser, Authorized<C>), a users table + repository, an admin login/logout/me surface, the first capability-gated admin endpoints (list users, publish object), and a create-user CLI for bootstrapping the first admin. OIDC is a separate later plan.

Architecture: Sessions via tower-sessions (opaque session id in an httpOnly + SameSite=Strict cookie) backed by tower-sessions-sqlx-store's PostgresStore in the org DB. Authorization is type-driven: AuthUser is reconstructed from the session (no per-request DB hit); Authorized<C> takes a zero-sized capability marker so a privileged handler cannot compile without naming the capability it requires. Role policy (Role::allows) is pure domain logic. Dependency direction: auth → domain (auth does not depend on db); api → auth, db, domain; server → api, auth, db, domain.

Tech Stack: Rust 2024, axum 0.8, sqlx 0.8, argon2 = "0.5" (argon2id), tower-sessions = "0.14" + tower-sessions-sqlx-store = "0.15" (features ["postgres"]; this pair both resolve tower-sessions-core 0.14 — do not bump tower-sessions to 0.15, the store hasn't caught up), rpassword = "7" (CLI password prompt). Tests: #[sqlx::test] + axum oneshot.

Design decisions (approved)

  • Email/password now; OIDC deferred to its own plan.
  • Sessions via tower-sessions + Postgres store (server-side, revocable, no Redis); httpOnly + Secure + SameSite=Strict cookie; Secure is a config flag (default true) so plain-HTTP self-hosters can disable it.
  • Roles Admin / Editor only (RBAC mapped to a Capability enum); Admin = all, Editor = all except ManageUsers.
  • First admin via server create-user CLI (hidden rpassword prompt, or BOOTSTRAP_PASSWORD env); no public self-registration.
  • Protected endpoints this plan: GET /api/admin/me (any AuthUser), GET /api/admin/users (Authorized<ManageUsers>, Admin-only), and POST /api/admin/objects/{id}/visibility (Authorized<PublishObjects>, reusing Plan 7's set_visibility — closes issue #15).

Prerequisites

  • Postgres for tests; pass DATABASE_URL inline. Pass transaction connections as &mut tx.
  • cargo +nightly fmt (nightly). Clean clippy --all-targets -- -D warnings.
  • The codename "biggus"/"dickus" must appear nowhere in code/comments/identifiers.

Workspace dependency additions

Add to root Cargo.toml [workspace.dependencies] (verify latest patch via cratesio if desired; majors are pinned by the compatibility analysis above):

argon2 = "0.5"
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
rpassword = "7"

File Structure

Cargo.toml                       + auth member + 4 workspace deps above
crates/domain/src/id.rs          + UserId
crates/domain/src/user.rs        (new) Email, Role, Capability, User, NewUser
crates/domain/src/lib.rs         + mod user; exports
crates/db/migrations/0006_users.sql   (new) app_user table
crates/db/src/users.rs           (new) create_user, user_by_id, credentials_by_email, list_users
crates/db/src/lib.rs             + pub mod users;
crates/db/tests/users.rs         (new)
crates/auth/Cargo.toml           (new)
crates/auth/src/lib.rs           (new) password hashing + extractors + capability markers
crates/api/Cargo.toml            + auth, tower-sessions, tower-sessions-sqlx-store
crates/api/src/admin.rs          (new) login/logout/me/users/visibility handlers
crates/api/src/lib.rs            + mod admin; session layer in build_app; AppState.cookie_secure; migrate_sessions
crates/api/src/openapi.rs        + register admin paths + schemas
crates/api/tests/admin.rs        (new)
crates/api/tests/health.rs       (modify) AppState constructor gains cookie_secure
crates/api/tests/public.rs       (modify) AppState constructor gains cookie_secure
crates/server/Cargo.toml         + auth, domain, tower-sessions-sqlx-store, rpassword
crates/server/src/config.rs      + cookie_secure field
crates/server/src/lib.rs         + migrate_sessions on startup; create_user fn; AppState.cookie_secure
crates/server/src/main.rs        + Cli (subcommands): default serve, create-user
crates/server/tests/config.rs    (modify) cookie_secure default

Task 1: domain — user identity, role & capability policy

Files: modify crates/domain/src/id.rs, crates/domain/src/lib.rs; create crates/domain/src/user.rs.

  • Step 1: Add the UserId newtype. In crates/domain/src/id.rs, add another id_newtype! invocation alongside the others:
id_newtype!(
    /// Identifier for a user of this organization's instance.
    UserId
);
  • Step 2: Write the failing tests — create crates/domain/src/user.rs with the types and a test module:
//! User identity, roles, and the capability policy.
//!
//! `Role` is persisted; `Capability` is the vocabulary of guarded actions. The
//! role→capability mapping (`Role::allows`) is the single source of authorization
//! policy — pure and unit-tested. Password hashes live only at the `db`/`auth`
//! boundary, never in these types.

use serde::{Deserialize, Serialize};

use crate::UserId;

/// A validated email address (normalized to lowercase, trimmed).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Email(String);

/// The supplied string is not a syntactically acceptable email.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EmailError;

impl std::fmt::Display for EmailError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("invalid email address")
    }
}

impl std::error::Error for EmailError {}

impl Email {
    /// Parse and normalize an email. Light MVP validation: a single `@`, non-empty
    /// local part, a dotted non-edge domain, and no whitespace. (Fuller RFC 5321
    /// validation is deferred.)
    pub fn parse(raw: &str) -> Result<Email, EmailError> {
        let normalized = raw.trim().to_lowercase();
        if normalized.contains(char::is_whitespace) {
            return Err(EmailError);
        }
        let mut parts = normalized.split('@');
        let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else {
            return Err(EmailError);
        };
        let domain_ok = domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.');
        if local.is_empty() || !domain_ok {
            return Err(EmailError);
        }
        Ok(Email(normalized))
    }

    /// The normalized string.
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Reconstruct from a stored (already-validated) value, without re-validating.
    pub fn from_db(value: String) -> Email {
        Email(value)
    }
}

/// A user's role within the organization.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    /// Full access, including user management.
    Admin,
    /// Catalogue work: create/edit/publish records; cannot manage users.
    Editor,
}

impl Role {
    pub const fn as_str(&self) -> &'static str {
        match self {
            Role::Admin => "admin",
            Role::Editor => "editor",
        }
    }

    pub fn from_db(s: &str) -> Option<Self> {
        match s {
            "admin" => Some(Role::Admin),
            "editor" => Some(Role::Editor),
            _ => None,
        }
    }

    /// The authorization policy: whether this role may perform `capability`.
    pub fn allows(self, capability: Capability) -> bool {
        match self {
            Role::Admin => true,
            Role::Editor => !matches!(capability, Capability::ManageUsers),
        }
    }
}

/// A guarded action. `Authorized<C>` (in the `auth` crate) gates a handler on one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
    /// Create/list/modify users.
    ManageUsers,
    /// Create and edit catalogue records.
    EditCatalogue,
    /// Change a record's visibility (publish/unpublish).
    PublishObjects,
    /// Read internal (non-public) records.
    ViewInternal,
}

/// A user as read back from storage. Carries no password material.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
    pub id: UserId,
    pub email: Email,
    pub role: Role,
}

/// A new user to persist. `password_hash` is an argon2id PHC string (produced by `auth`).
#[derive(Debug, Clone)]
pub struct NewUser {
    pub email: Email,
    pub password_hash: String,
    pub role: Role,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn email_parses_and_normalizes() {
        assert_eq!(Email::parse("  Anna@Example.COM ").unwrap().as_str(), "anna@example.com");
    }

    #[test]
    fn email_rejects_garbage() {
        for bad in ["", "no-at", "a@b", "a@@b.com", "a b@c.com", "@example.com", "x@.com", "x@com."] {
            assert!(Email::parse(bad).is_err(), "should reject {bad:?}");
        }
    }

    #[test]
    fn role_round_trips() {
        for r in [Role::Admin, Role::Editor] {
            assert_eq!(Role::from_db(r.as_str()), Some(r));
        }
        assert_eq!(Role::from_db("superuser"), None);
    }

    #[test]
    fn capability_policy_matrix() {
        use Capability::*;
        // Admin can do everything.
        for cap in [ManageUsers, EditCatalogue, PublishObjects, ViewInternal] {
            assert!(Role::Admin.allows(cap));
        }
        // Editor can do everything except manage users.
        assert!(!Role::Editor.allows(ManageUsers));
        for cap in [EditCatalogue, PublishObjects, ViewInternal] {
            assert!(Role::Editor.allows(cap));
        }
    }
}
  • Step 3: Run to verify the tests fail / module is wired. Add to crates/domain/src/lib.rs: mod user; (with the others) and extend exports:
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId};
pub use user::{Capability, Email, EmailError, NewUser, Role, User};
  • Step 4: Run to verify it passes. cargo test -p domain → PASS (new email/role/capability tests + existing).

  • Step 5: Lint. cargo +nightly fmt; cargo clippy -p domain --all-targets -- -D warnings → clean.

  • Step 6: Commit.

git add crates/domain
git commit -m "feat(domain): user identity (UserId, Email), Role/Capability policy"

Task 2: db — users table + repository

Files: create crates/db/migrations/0006_users.sql, crates/db/src/users.rs, crates/db/tests/users.rs; modify crates/db/src/lib.rs.

  • Step 1: Migration crates/db/migrations/0006_users.sql:
-- Users of this organization's instance. One database == one organization, so no
-- org_id. Email is stored already-normalized (lowercase) by the application, so a
-- plain UNIQUE suffices. Passwords are stored only as argon2id PHC strings.
CREATE TABLE app_user (
    id            UUID PRIMARY KEY,
    email         TEXT NOT NULL UNIQUE CHECK (email <> ''),
    password_hash TEXT NOT NULL CHECK (password_hash <> ''),
    role          TEXT NOT NULL CHECK (role IN ('admin', 'editor')),
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
  • Step 2: Write the failing tests crates/db/tests/users.rs:
use db::{Db, audit, users};
use domain::{AuditAction, AuditActor, Email, NewUser, Role};
use sqlx::PgPool;

fn new_user(email: &str, role: Role) -> NewUser {
    NewUser {
        email: Email::parse(email).unwrap(),
        password_hash: "$argon2id$dummy".to_owned(),
        role,
    }
}

#[sqlx::test]
async fn create_then_fetch_by_id_and_email(pool: PgPool) {
    let db = Db::from_pool(pool);

    let mut tx = db.pool().begin().await.unwrap();
    let id = users::create_user(&mut tx, AuditActor::System, &new_user("anna@example.com", Role::Admin))
        .await
        .unwrap();
    tx.commit().await.unwrap();

    let user = users::user_by_id(db.pool(), id).await.unwrap().unwrap();
    assert_eq!(user.email.as_str(), "anna@example.com");
    assert_eq!(user.role, Role::Admin);

    let (by_email, hash) = users::credentials_by_email(db.pool(), "anna@example.com")
        .await
        .unwrap()
        .unwrap();
    assert_eq!(by_email.id, id);
    assert_eq!(hash, "$argon2id$dummy");
}

#[sqlx::test]
async fn create_user_audits_email_and_role_but_never_the_hash(pool: PgPool) {
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    let id = users::create_user(&mut tx, AuditActor::System, &new_user("anna@example.com", Role::Editor))
        .await
        .unwrap();
    tx.commit().await.unwrap();

    let history = audit::history_for(db.pool(), "user", id.to_uuid()).await.unwrap();
    assert_eq!(history.len(), 1);
    assert_eq!(history[0].action, AuditAction::Created);
    let mut fields: Vec<&str> = history[0].changes.iter().map(|c| c.field.as_str()).collect();
    fields.sort_unstable();
    assert_eq!(fields, vec!["email", "role"]); // password_hash must NOT appear
}

#[sqlx::test]
async fn missing_email_returns_none(pool: PgPool) {
    let db = Db::from_pool(pool);
    assert!(users::credentials_by_email(db.pool(), "nobody@example.com").await.unwrap().is_none());
}

#[sqlx::test]
async fn list_users_is_ordered_by_email(pool: PgPool) {
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    users::create_user(&mut tx, AuditActor::System, &new_user("zoe@example.com", Role::Editor)).await.unwrap();
    users::create_user(&mut tx, AuditActor::System, &new_user("amy@example.com", Role::Admin)).await.unwrap();
    tx.commit().await.unwrap();

    let users = users::list_users(db.pool()).await.unwrap();
    let emails: Vec<&str> = users.iter().map(|u| u.email.as_str()).collect();
    assert_eq!(emails, vec!["amy@example.com", "zoe@example.com"]);
}
  • Step 3: Run to verify it fails. DATABASE_URL=<url> cargo test -p db --test users → FAIL (db::users missing).

  • Step 4: Implement crates/db/src/users.rs:

//! Users of this organization's instance. All SQL for users lives here.

use domain::{AuditAction, AuditActor, Email, FieldChange, NewAuditEvent, NewUser, Role, User, UserId};
use serde_json::json;
use sqlx::Row;

use crate::audit;

const ENTITY_TYPE: &str = "user";

const USER_COLUMNS: &str = "id, email, role";

/// Create a user and record a `created` audit entry (email + role only — never the
/// password hash), both on `conn`. Pass a transaction connection.
pub async fn create_user(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    new: &NewUser,
) -> Result<UserId, sqlx::Error> {
    let id = UserId::new();

    sqlx::query("INSERT INTO app_user (id, email, password_hash, role) VALUES ($1, $2, $3, $4)")
        .bind(id.to_uuid())
        .bind(new.email.as_str())
        .bind(&new.password_hash)
        .bind(new.role.as_str())
        .execute(&mut *conn)
        .await?;

    audit::record(
        &mut *conn,
        &NewAuditEvent {
            actor,
            action: AuditAction::Created,
            entity_type: ENTITY_TYPE.to_owned(),
            entity_id: id.to_uuid(),
            changes: vec![
                FieldChange { field: "email".to_owned(), before: None, after: Some(json!(new.email.as_str())) },
                FieldChange { field: "role".to_owned(), before: None, after: Some(json!(new.role.as_str())) },
            ],
        },
    )
    .await?;

    Ok(id)
}

/// Fetch a user by id.
pub async fn user_by_id<'e, E>(executor: E, id: UserId) -> Result<Option<User>, sqlx::Error>
where
    E: sqlx::PgExecutor<'e>,
{
    let sql = format!("SELECT {USER_COLUMNS} FROM app_user WHERE id = $1");
    let row = sqlx::query(&sql).bind(id.to_uuid()).fetch_optional(executor).await?;
    row.map(map_user).transpose()
}

/// Fetch a user and their password hash by (normalized) email, for login.
pub async fn credentials_by_email<'e, E>(
    executor: E,
    email: &str,
) -> Result<Option<(User, String)>, sqlx::Error>
where
    E: sqlx::PgExecutor<'e>,
{
    let sql = format!("SELECT {USER_COLUMNS}, password_hash FROM app_user WHERE email = $1");
    let row = sqlx::query(&sql).bind(email).fetch_optional(executor).await?;
    match row {
        Some(row) => {
            let hash: String = row.try_get("password_hash")?;
            Ok(Some((map_user(row)?, hash)))
        }
        None => Ok(None),
    }
}

/// List all users, ordered by email.
pub async fn list_users<'e, E>(executor: E) -> Result<Vec<User>, sqlx::Error>
where
    E: sqlx::PgExecutor<'e>,
{
    let sql = format!("SELECT {USER_COLUMNS} FROM app_user ORDER BY email");
    let rows = sqlx::query(&sql).fetch_all(executor).await?;
    rows.into_iter().map(map_user).collect()
}

fn map_user(row: sqlx::postgres::PgRow) -> Result<User, sqlx::Error> {
    let role_str: String = row.try_get("role")?;
    let role = Role::from_db(&role_str)
        .ok_or_else(|| sqlx::Error::Decode(format!("unknown role: {role_str}").into()))?;
    Ok(User {
        id: UserId::from_uuid(row.try_get("id")?),
        email: Email::from_db(row.try_get("email")?),
        role,
    })
}

Add to crates/db/src/lib.rs: pub mod users; (alphabetical with the others).

  • Step 5: Run to verify it passes. DATABASE_URL=<url> cargo test -p db --test users → PASS (4 tests). Then DATABASE_URL=<url> cargo test -p db → all PASS.

  • Step 6: Lint. cargo +nightly fmt; DATABASE_URL=<url> cargo clippy -p db --all-targets -- -D warnings → clean.

  • Step 7: Commit.

git add crates/db
git commit -m "feat(db): users table + repository (create/by_id/by_email/list), audited"

Task 3: auth crate — password hashing + extractors

Files: modify root Cargo.toml (workspace member + deps); create crates/auth/Cargo.toml, crates/auth/src/lib.rs.

  • Step 1: Workspace + crate setup. Add "crates/auth" to members in root Cargo.toml, and add the four workspace deps listed under "Workspace dependency additions" above. Create crates/auth/Cargo.toml:
[package]
name = "auth"
version = "0.0.0"
edition.workspace = true
rust-version.workspace = true

[dependencies]
axum.workspace = true
domain = { path = "../domain" }
argon2.workspace = true
tower-sessions.workspace = true
serde.workspace = true
uuid.workspace = true
thiserror.workspace = true

[dev-dependencies]
tokio.workspace = true
  • Step 2: Write the failing tests — add a #[cfg(test)] mod tests to crates/auth/src/lib.rs (the password module is pure and unit-testable; the extractors are integration-tested over HTTP in Task 4):
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn hash_then_verify_round_trips() {
        let hash = hash_password("correct horse battery staple").unwrap();
        assert!(hash.starts_with("$argon2id$"));
        assert!(verify_password("correct horse battery staple", &hash));
    }

    #[test]
    fn verify_rejects_wrong_password() {
        let hash = hash_password("right").unwrap();
        assert!(!verify_password("wrong", &hash));
    }

    #[test]
    fn verify_rejects_malformed_hash() {
        assert!(!verify_password("anything", "not-a-phc-string"));
    }

    #[test]
    fn capability_markers_map_to_domain_capabilities() {
        assert_eq!(ManageUsers::CAP, domain::Capability::ManageUsers);
        assert_eq!(PublishObjects::CAP, domain::Capability::PublishObjects);
    }
}
  • Step 3: Run to verify it fails. cargo test -p auth → FAIL (items missing).

  • Step 4: Implement crates/auth/src/lib.rs (adapt argon2/tower-sessions calls to the installed versions if a signature differs; behavior is the contract):

//! Authentication & authorization: argon2id password hashing and the type-driven
//! axum extractors that gate handlers. Identity is read from the session (set at
//! login); these extractors do not touch the database.

use std::marker::PhantomData;
use std::sync::OnceLock;

use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use domain::{Capability, Email, Role, UserId};
use tower_sessions::Session;

const SESSION_USER_ID: &str = "user_id";
const SESSION_EMAIL: &str = "email";
const SESSION_ROLE: &str = "role";

/// Hash a plaintext password as an argon2id PHC string.
pub fn hash_password(plaintext: &str) -> Result<String, argon2::password_hash::Error> {
    let salt = SaltString::generate(&mut OsRng);
    Ok(Argon2::default().hash_password(plaintext.as_bytes(), &salt)?.to_string())
}

/// Verify a plaintext password against an argon2id PHC string. Returns `false` for a
/// wrong password OR a malformed/unparseable hash (never errors out).
pub fn verify_password(plaintext: &str, phc: &str) -> bool {
    let Ok(parsed) = PasswordHash::new(phc) else {
        return false;
    };
    Argon2::default().verify_password(plaintext.as_bytes(), &parsed).is_ok()
}

/// Spend a verify's worth of time against a fixed dummy hash. Call this on the
/// "user not found" login path to blunt user-enumeration via response timing.
pub fn verify_dummy(plaintext: &str) {
    static DUMMY: OnceLock<String> = OnceLock::new();
    let hash = DUMMY.get_or_init(|| hash_password("dummy-password-for-timing").expect("hash dummy"));
    let _ = verify_password(plaintext, hash);
}

/// Record the authenticated identity into the session (call after a successful
/// password check). Cycles the session id first to prevent session fixation.
pub async fn establish_session(
    session: &Session,
    id: UserId,
    email: &Email,
    role: Role,
) -> Result<(), tower_sessions::session::Error> {
    session.cycle_id().await?;
    session.insert(SESSION_USER_ID, id.to_uuid()).await?;
    session.insert(SESSION_EMAIL, email.as_str()).await?;
    session.insert(SESSION_ROLE, role.as_str()).await?;
    Ok(())
}

/// Rejection for the auth extractors.
#[derive(Debug, Clone, Copy, thiserror::Error)]
pub enum AuthError {
    #[error("authentication required")]
    Unauthenticated,
    #[error("insufficient permissions")]
    Forbidden,
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        match self {
            AuthError::Unauthenticated => StatusCode::UNAUTHORIZED,
            AuthError::Forbidden => StatusCode::FORBIDDEN,
        }
        .into_response()
    }
}

/// The authenticated user, reconstructed from the session. Extracting this proves
/// the request carries a valid session (else `401`).
#[derive(Debug, Clone)]
pub struct AuthUser {
    pub id: UserId,
    pub email: Email,
    pub role: Role,
}

impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let session = Session::from_request_parts(parts, state)
            .await
            .map_err(|_| AuthError::Unauthenticated)?;

        let id: uuid::Uuid = session
            .get(SESSION_USER_ID)
            .await
            .ok()
            .flatten()
            .ok_or(AuthError::Unauthenticated)?;
        let email: String = session
            .get(SESSION_EMAIL)
            .await
            .ok()
            .flatten()
            .ok_or(AuthError::Unauthenticated)?;
        let role_str: String = session
            .get(SESSION_ROLE)
            .await
            .ok()
            .flatten()
            .ok_or(AuthError::Unauthenticated)?;
        let role = Role::from_db(&role_str).ok_or(AuthError::Unauthenticated)?;

        Ok(AuthUser { id: UserId::from_uuid(id), email: Email::from_db(email), role })
    }
}

/// A zero-sized type naming a required [`Capability`]. Implementors are used as the
/// type parameter of [`Authorized`].
pub trait CapabilityMarker {
    const CAP: Capability;
}

/// Require `ManageUsers`.
pub struct ManageUsers;
impl CapabilityMarker for ManageUsers {
    const CAP: Capability = Capability::ManageUsers;
}

/// Require `EditCatalogue`.
pub struct EditCatalogue;
impl CapabilityMarker for EditCatalogue {
    const CAP: Capability = Capability::EditCatalogue;
}

/// Require `PublishObjects`.
pub struct PublishObjects;
impl CapabilityMarker for PublishObjects {
    const CAP: Capability = Capability::PublishObjects;
}

/// Require `ViewInternal`.
pub struct ViewInternal;
impl CapabilityMarker for ViewInternal {
    const CAP: Capability = Capability::ViewInternal;
}

/// An [`AuthUser`] proven to hold capability `C`. A handler taking `Authorized<C>`
/// cannot run without the request's role allowing `C` (else `403`).
#[derive(Debug, Clone)]
pub struct Authorized<C: CapabilityMarker> {
    pub user: AuthUser,
    _capability: PhantomData<C>,
}

impl<S, C> FromRequestParts<S> for Authorized<C>
where
    S: Send + Sync,
    C: CapabilityMarker,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let user = AuthUser::from_request_parts(parts, state).await?;
        if user.role.allows(C::CAP) {
            Ok(Authorized { user, _capability: PhantomData })
        } else {
            Err(AuthError::Forbidden)
        }
    }
}
  • Step 5: Run to verify it passes. cargo test -p auth → PASS (4 tests).

  • Step 6: Lint. cargo +nightly fmt; cargo clippy -p auth --all-targets -- -D warnings → clean. (If clippy flags an unused CapabilityMarker impl, that's expected to be fine since they are pub; do not delete them — they are the crate's capability vocabulary.)

  • Step 7: Commit.

git add Cargo.toml crates/auth
git commit -m "feat(auth): argon2id hashing + AuthUser/Authorized<Cap> session extractors"

Task 4: api — session layer + admin surface

Files: modify crates/api/Cargo.toml, crates/api/src/lib.rs, crates/api/src/openapi.rs, crates/api/tests/health.rs, crates/api/tests/public.rs; create crates/api/src/admin.rs, crates/api/tests/admin.rs.

  • Step 1: Cargo deps. In crates/api/Cargo.toml [dependencies] add:
auth = { path = "../auth" }
tower-sessions.workspace = true
tower-sessions-sqlx-store.workspace = true
  • Step 2: Add cookie_secure to AppState, wire the session layer, expose migrate_sessions in crates/api/src/lib.rs:
mod admin;
mod health;
mod openapi;
mod public;

use axum::Router;
use db::Db;
use time::Duration;
use tower_sessions::cookie::SameSite;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::PostgresStore;

/// Shared application state passed to handlers.
#[derive(Clone)]
pub struct AppState {
    /// Database handle for this organization.
    pub db: Db,
    /// User-facing product name (from config). Never hardcoded.
    pub app_name: String,
    /// Whether the session cookie carries the `Secure` attribute (default true;
    /// disable only for plain-HTTP self-hosting).
    pub cookie_secure: bool,
}

/// Build the application router from shared state.
pub fn build_app(state: AppState) -> Router {
    let store = PostgresStore::new(state.db.pool().clone());
    let session_layer = SessionManagerLayer::new(store)
        .with_name("id")
        .with_http_only(true)
        .with_secure(state.cookie_secure)
        .with_same_site(SameSite::Strict)
        .with_expiry(Expiry::OnInactivity(Duration::hours(8)));

    Router::new()
        .merge(health::routes())
        .merge(openapi::routes())
        .merge(public::routes())
        .merge(admin::routes())
        .layer(session_layer)
        .with_state(state)
}

/// Create the session store's table if absent. Run once at startup (and in tests
/// before exercising auth). Separate from `Db::migrate` — this is the session
/// library's own bookkeeping table.
pub async fn migrate_sessions(db: &Db) -> Result<(), sqlx::Error> {
    PostgresStore::new(db.pool().clone()).migrate().await
}

NOTE: if PostgresStore::migrate returns a non-sqlx::Error error type in the installed version, adapt the return type (e.g. map into anyhow at the call site) — keep the behavior (creates the table). time::Duration::hours is available via the time workspace dep (already a dependency of db/domain; add time.workspace = true to api [dependencies] if not present).

  • Step 3: Implement crates/api/src/admin.rs:
//! Admin (authenticated) surface: login/logout/session, user listing, and publishing.

use auth::{AuthUser, Authorized, ManageUsers, PublishObjects};
use axum::{
    Json, Router,
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    routing::{get, post},
};
use domain::{AuditActor, ObjectId, Visibility};
use serde::{Deserialize, Serialize};
use tower_sessions::Session;
use utoipa::ToSchema;

use crate::AppState;

/// Credentials for password login.
#[derive(Deserialize, ToSchema)]
pub(crate) struct LoginRequest {
    pub email: String,
    pub password: String,
}

/// A user as exposed on the admin surface (no password material).
#[derive(Serialize, ToSchema)]
pub(crate) struct UserView {
    pub id: String,
    pub email: String,
    pub role: String,
}

/// Desired visibility for a publish/unpublish request.
#[derive(Deserialize, ToSchema)]
pub(crate) struct VisibilityRequest {
    pub visibility: Visibility,
}

/// Log in with email + password. On success, establishes a session (Set-Cookie) and
/// returns 204. On failure, 401 with no detail (no user enumeration).
#[utoipa::path(
    post,
    path = "/api/admin/login",
    request_body = LoginRequest,
    responses((status = 204, description = "Logged in"), (status = 401, description = "Invalid credentials"))
)]
pub(crate) async fn login(
    State(state): State<AppState>,
    session: Session,
    Json(req): Json<LoginRequest>,
) -> Result<StatusCode, StatusCode> {
    // Normalize the email the same way storage does; an unparseable email simply
    // won't match, but we still spend verify time to resist enumeration.
    let normalized = req.email.trim().to_lowercase();

    let credentials = db::users::credentials_by_email(state.db.pool(), &normalized)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let verified = match &credentials {
        Some((_, hash)) => auth::verify_password(&req.password, hash),
        None => {
            auth::verify_dummy(&req.password);
            false
        }
    };
    if !verified {
        return Err(StatusCode::UNAUTHORIZED);
    }

    let (user, _) = credentials.expect("verified implies Some");
    auth::establish_session(&session, user.id, &user.email, user.role)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(StatusCode::NO_CONTENT)
}

/// Log out: clear the session.
#[utoipa::path(post, path = "/api/admin/logout", responses((status = 204, description = "Logged out")))]
pub(crate) async fn logout(session: Session) -> Result<StatusCode, StatusCode> {
    session.flush().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(StatusCode::NO_CONTENT)
}

/// The current authenticated user.
#[utoipa::path(get, path = "/api/admin/me", responses((status = 200, body = UserView), (status = 401)))]
pub(crate) async fn me(user: AuthUser) -> Json<UserView> {
    Json(UserView {
        id: user.id.to_string(),
        email: user.email.as_str().to_owned(),
        role: user.role.as_str().to_owned(),
    })
}

/// List all users (Admin only).
#[utoipa::path(get, path = "/api/admin/users", responses((status = 200, body = [UserView]), (status = 401), (status = 403)))]
pub(crate) async fn list_users(
    auth: Authorized<ManageUsers>,
) -> Result<Json<Vec<UserView>>, StatusCode> {
    let _ = auth; // capability proven by the extractor
    Err(StatusCode::INTERNAL_SERVER_ERROR) // replaced below
}

WAIT — list_users needs State to reach the DB. Write it as:

pub(crate) async fn list_users(
    _auth: Authorized<ManageUsers>,
    State(state): State<AppState>,
) -> Result<Json<Vec<UserView>>, StatusCode> {
    let users = db::users::list_users(state.db.pool())
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(
        users
            .into_iter()
            .map(|u| UserView { id: u.id.to_string(), email: u.email.as_str().to_owned(), role: u.role.as_str().to_owned() })
            .collect(),
    ))
}

(Use this version; the stub above is illustrative. An extractor that is only needed for its guard is conventionally named _auth.)

Continue with the publish handler and routes:

/// Change an object's visibility (publish/unpublish). Requires `PublishObjects`.
#[utoipa::path(
    post,
    path = "/api/admin/objects/{id}/visibility",
    params(("id" = String, Path, description = "Object id (UUID)")),
    request_body = VisibilityRequest,
    responses(
        (status = 204, description = "Visibility changed"),
        (status = 401), (status = 403),
        (status = 404, description = "No such object"),
        (status = 409, description = "Illegal visibility transition")
    )
)]
pub(crate) async fn set_visibility(
    _auth: Authorized<PublishObjects>,
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(req): Json<VisibilityRequest>,
) -> Result<StatusCode, StatusCode> {
    let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?;

    let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    // Auth events / per-user actor wiring arrive with richer auditing; use System for now.
    let result = db::catalog::set_visibility(&mut tx, AuditActor::System, object_id, req.visibility).await;
    match result {
        Ok(()) => {
            tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
            Ok(StatusCode::NO_CONTENT)
        }
        Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND),
        Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT),
        Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    }
}

/// Admin routes, parameterized over [`AppState`].
pub(crate) fn routes() -> Router<AppState> {
    Router::new()
        .route("/api/admin/login", post(login))
        .route("/api/admin/logout", post(logout))
        .route("/api/admin/me", get(me))
        .route("/api/admin/users", get(list_users))
        .route("/api/admin/objects/{id}/visibility", post(set_visibility))
}

NOTE on actor: this plan records the visibility change with AuditActor::System (the per-user AuditActor::User(id) wiring belongs with the auth-events work, issue #7 — the AuthUser already carries the id, so this is a one-line change later). Leave a // TODO(#7) comment.

  • Step 4: Register OpenAPI paths + schemas in crates/api/src/openapi.rs:
use crate::{AppState, admin, health, public};
#[openapi(
    paths(
        health::live, health::ready,
        public::list_objects, public::get_object,
        admin::login, admin::logout, admin::me, admin::list_users, admin::set_visibility
    ),
    components(schemas(
        health::Live, health::Ready,
        public::PublicView, public::PublicObjectPage,
        admin::LoginRequest, admin::UserView, admin::VisibilityRequest
    )),
    info(title = "Collection Management System", version = "0.0.0")
)]
struct ApiDoc;
  • Step 5: Update existing test state builders. In crates/api/tests/health.rs and crates/api/tests/public.rs, the state(...) helpers construct AppState; add cookie_secure: false:
    AppState { db: db::Db::from_pool(pool), app_name: app_name.to_string(), cookie_secure: false }

(health.rs takes an app_name arg; public.rs hardcodes "Test" — match each file's existing shape, just add the field.)

  • Step 6: Write the failing admin tests crates/api/tests/admin.rs:
use api::{AppState, build_app, migrate_sessions};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use db::{catalog, users};
use domain::{AuditActor, Email, NewUser, ObjectInput, Role, Visibility};
use http_body_util::BodyExt;
use sqlx::PgPool;
use tower::ServiceExt;

fn state(pool: PgPool) -> AppState {
    AppState { db: db::Db::from_pool(pool), app_name: "Test".into(), cookie_secure: false }
}

async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
    let db = db::Db::from_pool(pool.clone());
    let mut tx = db.pool().begin().await.unwrap();
    users::create_user(
        &mut tx,
        AuditActor::System,
        &NewUser {
            email: Email::parse(email).unwrap(),
            password_hash: auth_hash(password),
            role,
        },
    )
    .await
    .unwrap();
    tx.commit().await.unwrap();
}

// Hash via the auth crate so the stored hash verifies. `auth` is a dev-dependency here.
fn auth_hash(password: &str) -> String {
    auth::hash_password(password).unwrap()
}

fn login_request(email: &str, password: &str) -> Request<Body> {
    Request::builder()
        .method("POST")
        .uri("/api/admin/login")
        .header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(format!(r#"{{"email":"{email}","password":"{password}"}}"#)))
        .unwrap()
}

/// Extract the session cookie value from a login response's Set-Cookie header.
fn session_cookie(resp: &axum::http::Response<Body>) -> String {
    let raw = resp.headers().get(header::SET_COOKIE).expect("Set-Cookie").to_str().unwrap();
    raw.split(';').next().unwrap().to_owned() // "id=..."
}

#[sqlx::test]
async fn login_then_me_returns_identity(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    seed_user(&pool, "admin@example.com", "s3cret-passw0rd", Role::Admin).await;
    let app = build_app(state(pool));

    let resp = app.clone().oneshot(login_request("admin@example.com", "s3cret-passw0rd")).await.unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    let cookie = session_cookie(&resp);

    let me = app
        .oneshot(Request::builder().uri("/api/admin/me").header(header::COOKIE, &cookie).body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(me.status(), StatusCode::OK);
    let json: serde_json::Value = serde_json::from_slice(&me.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert_eq!(json["email"], "admin@example.com");
    assert_eq!(json["role"], "admin");
}

#[sqlx::test]
async fn me_without_session_is_401(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    let app = build_app(state(pool));
    let resp = app
        .oneshot(Request::builder().uri("/api/admin/me").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[sqlx::test]
async fn wrong_password_is_401(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    seed_user(&pool, "admin@example.com", "right", Role::Admin).await;
    let app = build_app(state(pool));
    let resp = app.oneshot(login_request("admin@example.com", "wrong")).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

#[sqlx::test]
async fn editor_cannot_list_users_but_admin_can(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
    seed_user(&pool, "admin@example.com", "pw-admin-123", Role::Admin).await;
    let app = build_app(state(pool));

    // Editor: 403
    let resp = app.clone().oneshot(login_request("editor@example.com", "pw-editor-123")).await.unwrap();
    let editor_cookie = session_cookie(&resp);
    let listed = app
        .clone()
        .oneshot(Request::builder().uri("/api/admin/users").header(header::COOKIE, &editor_cookie).body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(listed.status(), StatusCode::FORBIDDEN);

    // Admin: 200
    let resp = app.clone().oneshot(login_request("admin@example.com", "pw-admin-123")).await.unwrap();
    let admin_cookie = session_cookie(&resp);
    let listed = app
        .oneshot(Request::builder().uri("/api/admin/users").header(header::COOKIE, &admin_cookie).body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(listed.status(), StatusCode::OK);
}

#[sqlx::test]
async fn editor_can_publish_via_admin_endpoint(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;

    // an internal object the editor will publish (draft -> internal done here, then -> public via API)
    let db = db::Db::from_pool(pool.clone());
    let mut tx = db.pool().begin().await.unwrap();
    let id = catalog::create_object(
        &mut tx,
        AuditActor::System,
        &ObjectInput {
            object_number: "P-1".into(),
            object_name: "vase".into(),
            number_of_objects: 1,
            brief_description: None,
            current_location: None,
            current_owner: None,
            recorder: None,
            recording_date: None,
            visibility: Visibility::Internal,
        },
    )
    .await
    .unwrap();
    tx.commit().await.unwrap();

    let app = build_app(state(pool));
    let resp = app.clone().oneshot(login_request("editor@example.com", "pw-editor-123")).await.unwrap();
    let cookie = session_cookie(&resp);

    let publish = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri(format!("/api/admin/objects/{id}/visibility"))
                .header(header::COOKIE, &cookie)
                .header(header::CONTENT_TYPE, "application/json")
                .body(Body::from(r#"{"visibility":"public"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(publish.status(), StatusCode::NO_CONTENT);

    let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
    assert_eq!(obj.visibility, Visibility::Public);
}

Add auth = { path = "../auth" } to crates/api/Cargo.toml [dev-dependencies] (the tests hash passwords via auth::hash_password).

  • Step 7: Run to verify it passes. DATABASE_URL=<url> cargo test -p api → all PASS (health + public + the 5 admin tests). Iterate on any tower-sessions/axum signature mismatches until green (the tests are the contract).

  • Step 8: Lint. cargo +nightly fmt; DATABASE_URL=<url> cargo clippy -p api --all-targets -- -D warnings → clean.

  • Step 9: Commit.

git add crates/api
git commit -m "feat(api): admin auth surface (login/logout/me/users/publish) on tower-sessions"

Task 5: server — session migration + create-user CLI

Files: modify crates/server/Cargo.toml, crates/server/src/config.rs, crates/server/src/lib.rs, crates/server/src/main.rs, crates/server/tests/config.rs.

  • Step 1: Cargo deps. In crates/server/Cargo.toml [dependencies] add:
auth = { path = "../auth" }
domain = { path = "../domain" }
tower-sessions-sqlx-store.workspace = true
rpassword.workspace = true

(Add tower-sessions-sqlx-store only if create_user/startup references it directly; the session migration goes through api::migrate_sessions, so it likely is NOT needed here — omit unless the compiler asks.)

  • Step 2: Add cookie_secure to Config in crates/server/src/config.rs:
    /// Send the session cookie with the `Secure` attribute (HTTPS-only). Disable
    /// only for plain-HTTP self-hosting behind no TLS at all.
    #[arg(long, env = "SESSION_COOKIE_SECURE", default_value_t = true)]
    pub cookie_secure: bool,
  • Step 3: Wire session migration + the create_user function in crates/server/src/lib.rs. Update run to migrate the session store and pass cookie_secure, and add create_user:
use anyhow::Context;
use api::{AppState, build_app, migrate_sessions};
use db::Db;
use domain::{AuditActor, Email, NewUser, Role};
use tokio::net::TcpListener;

pub async fn run(config: Config) -> anyhow::Result<()> {
    let db = Db::connect(&config.database_url).await.context("connecting to the database")?;
    db.migrate().await.context("running database migrations")?;
    migrate_sessions(&db).await.context("creating the session store")?;

    let state = AppState {
        db,
        app_name: config.app_name.clone(),
        cookie_secure: config.cookie_secure,
    };

    let listener = TcpListener::bind(&config.bind_addr)
        .await
        .with_context(|| format!("binding to {}", config.bind_addr))?;
    tracing::info!(addr = %config.bind_addr, "server listening");
    serve(listener, state).await
}

/// Create a user from the CLI (admin bootstrap). Reads the password from the
/// `BOOTSTRAP_PASSWORD` env var if set, otherwise prompts (hidden input).
pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> {
    let email = Email::parse(email).map_err(|e| anyhow::anyhow!("{e}"))?;

    let password = match std::env::var("BOOTSTRAP_PASSWORD") {
        Ok(p) => p,
        Err(_) => rpassword::prompt_password("Password: ").context("reading password")?,
    };
    anyhow::ensure!(password.len() >= 8, "password must be at least 8 characters");

    let password_hash = auth::hash_password(&password).map_err(|e| anyhow::anyhow!("hashing password: {e}"))?;

    let db = Db::connect(database_url).await.context("connecting to the database")?;
    let mut tx = db.pool().begin().await?;
    let id = db::users::create_user(&mut tx, AuditActor::System, &NewUser { email, password_hash, role })
        .await
        .context("creating the user (is the email already taken?)")?;
    tx.commit().await?;

    println!("created user {id} ({role:?})");
    Ok(())
}

Keep the existing serve function as-is.

  • Step 4: Add the subcommand CLI in crates/server/src/main.rs:
use clap::{Parser, Subcommand, ValueEnum};
use domain::Role;
use server::{Config, create_user, run};

#[derive(Parser)]
#[command(version, about = "Collection management system server")]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,
    #[command(flatten)]
    config: Config,
}

#[derive(Subcommand)]
enum Command {
    /// Create a user (admin bootstrap).
    CreateUser {
        #[arg(long)]
        email: String,
        #[arg(long, value_enum)]
        role: RoleArg,
    },
}

#[derive(Clone, Copy, ValueEnum)]
enum RoleArg {
    Admin,
    Editor,
}

impl From<RoleArg> for Role {
    fn from(r: RoleArg) -> Self {
        match r {
            RoleArg::Admin => Role::Admin,
            RoleArg::Editor => Role::Editor,
        }
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();

    let cli = Cli::parse();
    match cli.command {
        None => run(cli.config).await,
        Some(Command::CreateUser { email, role }) => {
            create_user(&cli.config.database_url, &email, role.into()).await
        }
    }
}

NOTE: flattening Config means create-user also accepts --database-url/DATABASE_URL (which it needs) and the serve-only options keep their defaults (unused for create-user). This preserves the existing default-to-serve behavior (server with no subcommand still serves).

  • Step 5: Update the config test crates/server/tests/config.rs — the existing tests parse Config; cookie_secure has a default so they should still pass, but add a check and ensure parsing still works. If a test asserts an exact field set, add cookie_secure defaulting to true. Add:
#[test]
fn cookie_secure_defaults_to_true() {
    let config = Config::try_parse_from(["server", "--database-url", "postgres://x"]).unwrap();
    assert!(config.cookie_secure);
}

(Match the file's existing import of Config/clap::Parser and the temp-env isolation pattern used by the other config tests if DATABASE_URL is read from env.)

  • Step 6: Run to verify it passes. Build + tests:
DATABASE_URL=<url> cargo test -p server

Expected: PASS (config tests + the existing serve test). If the serve test builds an AppState, add cookie_secure: false there too.

  • Step 7: Manual smoke (optional, document only). BOOTSTRAP_PASSWORD=changeme cargo run -p server -- create-user --email admin@example.com --role admin creates the first admin; cargo run -p server then serves. (Not a CI step.)

  • Step 8: Full workspace check.

cargo +nightly fmt --check
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=<url> MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo test --workspace

Expected: all green.

  • Step 9: Commit.
git add crates/server Cargo.toml
git commit -m "feat(server): create-user CLI + session-store migration on startup"

Self-Review (completed)

Spec coverage (VISION "Authentication & access control" [MVP]; arch spec §10, §9, §7):

  • Email/password scoped to the single org → Tasks 24. ✓ (OIDC deferred — own plan.)
  • Sessions without Redis (server-side, in the org DB) → tower-sessions + PostgresStore. ✓
  • Authorization via typed extractors (AuthUser / Authorized<Cap>); privileged handler can't compile without the capability → Task 3. ✓
  • Role/permission model kept simple (Admin/Editor + capability policy) → Task 1. ✓
  • Clean public/admin split (/api/admin/** authenticated; public untouched) → Task 4. ✓
  • All SQL in db; auth is DB-free; api/server wire it → crate deps. ✓
  • Audited user creation (email+role, never the hash) → Task 2 + test. ✓

Placeholder scan: the list_users "stub" block in Task 4 Step 3 is explicitly replaced by the version immediately below it (called out in prose). <url>/<key> are documented env values. No other placeholders.

Type consistency: UserId/Email/Role/Capability/User/NewUser defined in Task 1 and consumed by Tasks 25; db::users::{create_user, user_by_id, credentials_by_email, list_users} defined in Task 2 and consumed in Task 4; auth::{hash_password, verify_password, verify_dummy, establish_session, AuthUser, Authorized, ManageUsers, PublishObjects, CapabilityMarker, AuthError} defined in Task 3 and consumed in Task 4; AppState gains cookie_secure (Task 4) consistently updated in every constructor (api tests + server). Reuses Plan 7's db::catalog::set_visibility + VisibilityError for the publish endpoint.

Notes for follow-on plans

  • Closes issue #15 (admin endpoint to trigger visibility transitions) via POST /api/admin/objects/{id}/visibility. Close #15 on merge.
  • Per-user audit actor (issue #7): the publish handler records AuditActor::System; switch to AuditActor::User(auth.user.id) and add login/logout/auth-event auditing in the #7 work. The AuthUser already carries the id (one-line change, marked // TODO(#7)).
  • OIDC plan (next auth phase): add an OIDC relying-party flow (discovery, authorize redirect, callback, token exchange) that, on success, calls the same auth::establish_session — the session/extractor layer is provider-agnostic.
  • Admin CRUD surface: object/vocabulary/authority/field create-edit endpoints behind Authorized<EditCatalogue> — their own plan; the extractor framework is ready.
  • User management API: create/disable/role-change users behind Authorized<ManageUsers> (this plan ships read-only /users + the CLI bootstrap).
  • Login hardening (post-MVP): rate limiting / lockout, password strength policy, optional CSRF double-submit token on top of SameSite=Strict.