# 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`), 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` 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`, Admin-only), and `POST /api/admin/objects/{id}/visibility` (`Authorized`, 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 { 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 { 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` (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= 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 { 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, 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, 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, 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 { 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= cargo test -p db --test users` → PASS (4 tests). Then `DATABASE_URL= cargo test -p db` → all PASS. - [ ] **Step 6: Lint.** `cargo +nightly fmt`; `DATABASE_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 { 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 = 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 FromRequestParts for AuthUser where S: Send + Sync, { type Rejection = AuthError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { 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` /// cannot run without the request's role allowing `C` (else `403`). #[derive(Debug, Clone)] pub struct Authorized { pub user: AuthUser, _capability: PhantomData, } impl FromRequestParts for Authorized where S: Send + Sync, C: CapabilityMarker, { type Rejection = AuthError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { 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 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, session: Session, Json(req): Json, ) -> Result { // 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 { 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 { 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, ) -> Result>, 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, State(state): State, ) -> Result>, 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, State(state): State, Path(id): Path, Json(req): Json, ) -> Result { let object_id = id.parse::().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 { 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 { 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) -> 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= 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= 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(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 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= 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= cargo clippy --workspace --all-targets -- -D warnings DATABASE_URL= MEILI_URL= MEILI_MASTER_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`); 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). ``/`` 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` — their own plan; the extractor framework is ready. - **User management API:** create/disable/role-change users behind `Authorized` (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.