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

1346 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 24. ✓ (OIDC deferred — own plan.)
- Sessions without Redis (server-side, in the org DB) → tower-sessions + PostgresStore. ✓
- Authorization via typed extractors (`AuthUser` / `Authorized<Cap>`); privileged handler can't compile without the capability → Task 3. ✓
- Role/permission model kept simple (Admin/Editor + capability policy) → Task 1. ✓
- Clean public/admin split (`/api/admin/**` authenticated; public untouched) → Task 4. ✓
- All SQL in `db`; `auth` is DB-free; `api`/`server` wire it → crate deps. ✓
- Audited user creation (email+role, never the hash) → Task 2 + test. ✓
**Placeholder scan:** the `list_users` "stub" block in Task 4 Step 3 is explicitly replaced by the version immediately below it (called out in prose). `<url>`/`<key>` are documented env values. No other placeholders.
**Type consistency:** `UserId`/`Email`/`Role`/`Capability`/`User`/`NewUser` defined in Task 1 and consumed by Tasks 25; `db::users::{create_user, user_by_id, credentials_by_email, list_users}` defined in Task 2 and consumed in Task 4; `auth::{hash_password, verify_password, verify_dummy, establish_session, AuthUser, Authorized, ManageUsers, PublishObjects, CapabilityMarker, AuthError}` defined in Task 3 and consumed in Task 4; `AppState` gains `cookie_secure` (Task 4) consistently updated in every constructor (api tests + server). Reuses Plan 7's `db::catalog::set_visibility` + `VisibilityError` for the publish endpoint.
## Notes for follow-on plans
- **Closes issue #15** (admin endpoint to trigger visibility transitions) via `POST /api/admin/objects/{id}/visibility`. Close #15 on merge.
- **Per-user audit actor (issue #7):** the publish handler records `AuditActor::System`; switch to `AuditActor::User(auth.user.id)` and add login/logout/auth-event auditing in the #7 work. The `AuthUser` already carries the id (one-line change, marked `// TODO(#7)`).
- **OIDC plan (next auth phase):** add an OIDC relying-party flow (discovery, authorize redirect, callback, token exchange) that, on success, calls the same `auth::establish_session` — the session/extractor layer is provider-agnostic.
- **Admin CRUD surface:** object/vocabulary/authority/field create-edit endpoints behind `Authorized<EditCatalogue>` — their own plan; the extractor framework is ready.
- **User management API:** create/disable/role-change users behind `Authorized<ManageUsers>` (this plan ships read-only `/users` + the CLI bootstrap).
- **Login hardening (post-MVP):** rate limiting / lockout, password strength policy, optional CSRF double-submit token on top of SameSite=Strict.