# 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):
```toml
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:
```rust
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:
```rust
//! 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:
```rust
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.**
```bash
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`:
```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`:
```rust
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`:
```rust
//! 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.**
```bash
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`:
```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):
```rust
#[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):
```rust
//! 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.**
```bash
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:
```toml
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`:
```rust
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`:
```rust
//! 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:
```rust
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:
```rust
/// 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`:
```rust
use crate::{AppState, admin, health, public};
```
```rust
#[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`:
```rust
    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`:
```rust
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.**
```bash
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:
```toml
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`:
```rust
    /// 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`:
```rust
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`:
```rust
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:
```rust
#[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:
```bash
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.**
```bash
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.**
```bash
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 2–4. ✓ (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 2–5; `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.
