52 KiB
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;Secureis a config flag (default true) so plain-HTTP self-hosters can disable it. - Roles Admin / Editor only (RBAC mapped to a
Capabilityenum); Admin = all, Editor = all exceptManageUsers. - First admin via
server create-userCLI (hiddenrpasswordprompt, orBOOTSTRAP_PASSWORDenv); no public self-registration. - Protected endpoints this plan:
GET /api/admin/me(anyAuthUser),GET /api/admin/users(Authorized<ManageUsers>, Admin-only), andPOST /api/admin/objects/{id}/visibility(Authorized<PublishObjects>, reusing Plan 7'sset_visibility— closes issue #15).
Prerequisites
- Postgres for tests; pass
DATABASE_URLinline. Pass transaction connections as&mut tx. cargo +nightly fmt(nightly). Clean clippy--all-targets -- -D warnings.- The codename "biggus"/"dickus" must appear nowhere in code/comments/identifiers.
Workspace dependency additions
Add to root Cargo.toml [workspace.dependencies] (verify latest patch via cratesio if desired; majors are pinned by the compatibility analysis above):
argon2 = "0.5"
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
rpassword = "7"
File Structure
Cargo.toml + auth member + 4 workspace deps above
crates/domain/src/id.rs + UserId
crates/domain/src/user.rs (new) Email, Role, Capability, User, NewUser
crates/domain/src/lib.rs + mod user; exports
crates/db/migrations/0006_users.sql (new) app_user table
crates/db/src/users.rs (new) create_user, user_by_id, credentials_by_email, list_users
crates/db/src/lib.rs + pub mod users;
crates/db/tests/users.rs (new)
crates/auth/Cargo.toml (new)
crates/auth/src/lib.rs (new) password hashing + extractors + capability markers
crates/api/Cargo.toml + auth, tower-sessions, tower-sessions-sqlx-store
crates/api/src/admin.rs (new) login/logout/me/users/visibility handlers
crates/api/src/lib.rs + mod admin; session layer in build_app; AppState.cookie_secure; migrate_sessions
crates/api/src/openapi.rs + register admin paths + schemas
crates/api/tests/admin.rs (new)
crates/api/tests/health.rs (modify) AppState constructor gains cookie_secure
crates/api/tests/public.rs (modify) AppState constructor gains cookie_secure
crates/server/Cargo.toml + auth, domain, tower-sessions-sqlx-store, rpassword
crates/server/src/config.rs + cookie_secure field
crates/server/src/lib.rs + migrate_sessions on startup; create_user fn; AppState.cookie_secure
crates/server/src/main.rs + Cli (subcommands): default serve, create-user
crates/server/tests/config.rs (modify) cookie_secure default
Task 1: domain — user identity, role & capability policy
Files: modify crates/domain/src/id.rs, crates/domain/src/lib.rs; create crates/domain/src/user.rs.
- Step 1: Add the
UserIdnewtype. Incrates/domain/src/id.rs, add anotherid_newtype!invocation alongside the others:
id_newtype!(
/// Identifier for a user of this organization's instance.
UserId
);
- Step 2: Write the failing tests — create
crates/domain/src/user.rswith the types and a test module:
//! User identity, roles, and the capability policy.
//!
//! `Role` is persisted; `Capability` is the vocabulary of guarded actions. The
//! role→capability mapping (`Role::allows`) is the single source of authorization
//! policy — pure and unit-tested. Password hashes live only at the `db`/`auth`
//! boundary, never in these types.
use serde::{Deserialize, Serialize};
use crate::UserId;
/// A validated email address (normalized to lowercase, trimmed).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Email(String);
/// The supplied string is not a syntactically acceptable email.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EmailError;
impl std::fmt::Display for EmailError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("invalid email address")
}
}
impl std::error::Error for EmailError {}
impl Email {
/// Parse and normalize an email. Light MVP validation: a single `@`, non-empty
/// local part, a dotted non-edge domain, and no whitespace. (Fuller RFC 5321
/// validation is deferred.)
pub fn parse(raw: &str) -> Result<Email, EmailError> {
let normalized = raw.trim().to_lowercase();
if normalized.contains(char::is_whitespace) {
return Err(EmailError);
}
let mut parts = normalized.split('@');
let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else {
return Err(EmailError);
};
let domain_ok = domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.');
if local.is_empty() || !domain_ok {
return Err(EmailError);
}
Ok(Email(normalized))
}
/// The normalized string.
pub fn as_str(&self) -> &str {
&self.0
}
/// Reconstruct from a stored (already-validated) value, without re-validating.
pub fn from_db(value: String) -> Email {
Email(value)
}
}
/// A user's role within the organization.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
/// Full access, including user management.
Admin,
/// Catalogue work: create/edit/publish records; cannot manage users.
Editor,
}
impl Role {
pub const fn as_str(&self) -> &'static str {
match self {
Role::Admin => "admin",
Role::Editor => "editor",
}
}
pub fn from_db(s: &str) -> Option<Self> {
match s {
"admin" => Some(Role::Admin),
"editor" => Some(Role::Editor),
_ => None,
}
}
/// The authorization policy: whether this role may perform `capability`.
pub fn allows(self, capability: Capability) -> bool {
match self {
Role::Admin => true,
Role::Editor => !matches!(capability, Capability::ManageUsers),
}
}
}
/// A guarded action. `Authorized<C>` (in the `auth` crate) gates a handler on one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
/// Create/list/modify users.
ManageUsers,
/// Create and edit catalogue records.
EditCatalogue,
/// Change a record's visibility (publish/unpublish).
PublishObjects,
/// Read internal (non-public) records.
ViewInternal,
}
/// A user as read back from storage. Carries no password material.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
pub id: UserId,
pub email: Email,
pub role: Role,
}
/// A new user to persist. `password_hash` is an argon2id PHC string (produced by `auth`).
#[derive(Debug, Clone)]
pub struct NewUser {
pub email: Email,
pub password_hash: String,
pub role: Role,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn email_parses_and_normalizes() {
assert_eq!(Email::parse(" Anna@Example.COM ").unwrap().as_str(), "anna@example.com");
}
#[test]
fn email_rejects_garbage() {
for bad in ["", "no-at", "a@b", "a@@b.com", "a b@c.com", "@example.com", "x@.com", "x@com."] {
assert!(Email::parse(bad).is_err(), "should reject {bad:?}");
}
}
#[test]
fn role_round_trips() {
for r in [Role::Admin, Role::Editor] {
assert_eq!(Role::from_db(r.as_str()), Some(r));
}
assert_eq!(Role::from_db("superuser"), None);
}
#[test]
fn capability_policy_matrix() {
use Capability::*;
// Admin can do everything.
for cap in [ManageUsers, EditCatalogue, PublishObjects, ViewInternal] {
assert!(Role::Admin.allows(cap));
}
// Editor can do everything except manage users.
assert!(!Role::Editor.allows(ManageUsers));
for cap in [EditCatalogue, PublishObjects, ViewInternal] {
assert!(Role::Editor.allows(cap));
}
}
}
- Step 3: Run to verify the tests fail / module is wired. Add to
crates/domain/src/lib.rs:mod user;(with the others) and extend exports:
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId};
pub use user::{Capability, Email, EmailError, NewUser, Role, User};
-
Step 4: Run to verify it passes.
cargo test -p domain→ PASS (new email/role/capability tests + existing). -
Step 5: Lint.
cargo +nightly fmt;cargo clippy -p domain --all-targets -- -D warnings→ clean. -
Step 6: Commit.
git add crates/domain
git commit -m "feat(domain): user identity (UserId, Email), Role/Capability policy"
Task 2: db — users table + repository
Files: create crates/db/migrations/0006_users.sql, crates/db/src/users.rs, crates/db/tests/users.rs; modify crates/db/src/lib.rs.
- Step 1: Migration
crates/db/migrations/0006_users.sql:
-- Users of this organization's instance. One database == one organization, so no
-- org_id. Email is stored already-normalized (lowercase) by the application, so a
-- plain UNIQUE suffices. Passwords are stored only as argon2id PHC strings.
CREATE TABLE app_user (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE CHECK (email <> ''),
password_hash TEXT NOT NULL CHECK (password_hash <> ''),
role TEXT NOT NULL CHECK (role IN ('admin', 'editor')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
- Step 2: Write the failing tests
crates/db/tests/users.rs:
use db::{Db, audit, users};
use domain::{AuditAction, AuditActor, Email, NewUser, Role};
use sqlx::PgPool;
fn new_user(email: &str, role: Role) -> NewUser {
NewUser {
email: Email::parse(email).unwrap(),
password_hash: "$argon2id$dummy".to_owned(),
role,
}
}
#[sqlx::test]
async fn create_then_fetch_by_id_and_email(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = users::create_user(&mut tx, AuditActor::System, &new_user("anna@example.com", Role::Admin))
.await
.unwrap();
tx.commit().await.unwrap();
let user = users::user_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(user.email.as_str(), "anna@example.com");
assert_eq!(user.role, Role::Admin);
let (by_email, hash) = users::credentials_by_email(db.pool(), "anna@example.com")
.await
.unwrap()
.unwrap();
assert_eq!(by_email.id, id);
assert_eq!(hash, "$argon2id$dummy");
}
#[sqlx::test]
async fn create_user_audits_email_and_role_but_never_the_hash(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = users::create_user(&mut tx, AuditActor::System, &new_user("anna@example.com", Role::Editor))
.await
.unwrap();
tx.commit().await.unwrap();
let history = audit::history_for(db.pool(), "user", id.to_uuid()).await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].action, AuditAction::Created);
let mut fields: Vec<&str> = history[0].changes.iter().map(|c| c.field.as_str()).collect();
fields.sort_unstable();
assert_eq!(fields, vec!["email", "role"]); // password_hash must NOT appear
}
#[sqlx::test]
async fn missing_email_returns_none(pool: PgPool) {
let db = Db::from_pool(pool);
assert!(users::credentials_by_email(db.pool(), "nobody@example.com").await.unwrap().is_none());
}
#[sqlx::test]
async fn list_users_is_ordered_by_email(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
users::create_user(&mut tx, AuditActor::System, &new_user("zoe@example.com", Role::Editor)).await.unwrap();
users::create_user(&mut tx, AuditActor::System, &new_user("amy@example.com", Role::Admin)).await.unwrap();
tx.commit().await.unwrap();
let users = users::list_users(db.pool()).await.unwrap();
let emails: Vec<&str> = users.iter().map(|u| u.email.as_str()).collect();
assert_eq!(emails, vec!["amy@example.com", "zoe@example.com"]);
}
-
Step 3: Run to verify it fails.
DATABASE_URL=<url> cargo test -p db --test users→ FAIL (db::usersmissing). -
Step 4: Implement
crates/db/src/users.rs:
//! Users of this organization's instance. All SQL for users lives here.
use domain::{AuditAction, AuditActor, Email, FieldChange, NewAuditEvent, NewUser, Role, User, UserId};
use serde_json::json;
use sqlx::Row;
use crate::audit;
const ENTITY_TYPE: &str = "user";
const USER_COLUMNS: &str = "id, email, role";
/// Create a user and record a `created` audit entry (email + role only — never the
/// password hash), both on `conn`. Pass a transaction connection.
pub async fn create_user(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
new: &NewUser,
) -> Result<UserId, sqlx::Error> {
let id = UserId::new();
sqlx::query("INSERT INTO app_user (id, email, password_hash, role) VALUES ($1, $2, $3, $4)")
.bind(id.to_uuid())
.bind(new.email.as_str())
.bind(&new.password_hash)
.bind(new.role.as_str())
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Created,
entity_type: ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: vec![
FieldChange { field: "email".to_owned(), before: None, after: Some(json!(new.email.as_str())) },
FieldChange { field: "role".to_owned(), before: None, after: Some(json!(new.role.as_str())) },
],
},
)
.await?;
Ok(id)
}
/// Fetch a user by id.
pub async fn user_by_id<'e, E>(executor: E, id: UserId) -> Result<Option<User>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let sql = format!("SELECT {USER_COLUMNS} FROM app_user WHERE id = $1");
let row = sqlx::query(&sql).bind(id.to_uuid()).fetch_optional(executor).await?;
row.map(map_user).transpose()
}
/// Fetch a user and their password hash by (normalized) email, for login.
pub async fn credentials_by_email<'e, E>(
executor: E,
email: &str,
) -> Result<Option<(User, String)>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let sql = format!("SELECT {USER_COLUMNS}, password_hash FROM app_user WHERE email = $1");
let row = sqlx::query(&sql).bind(email).fetch_optional(executor).await?;
match row {
Some(row) => {
let hash: String = row.try_get("password_hash")?;
Ok(Some((map_user(row)?, hash)))
}
None => Ok(None),
}
}
/// List all users, ordered by email.
pub async fn list_users<'e, E>(executor: E) -> Result<Vec<User>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let sql = format!("SELECT {USER_COLUMNS} FROM app_user ORDER BY email");
let rows = sqlx::query(&sql).fetch_all(executor).await?;
rows.into_iter().map(map_user).collect()
}
fn map_user(row: sqlx::postgres::PgRow) -> Result<User, sqlx::Error> {
let role_str: String = row.try_get("role")?;
let role = Role::from_db(&role_str)
.ok_or_else(|| sqlx::Error::Decode(format!("unknown role: {role_str}").into()))?;
Ok(User {
id: UserId::from_uuid(row.try_get("id")?),
email: Email::from_db(row.try_get("email")?),
role,
})
}
Add to crates/db/src/lib.rs: pub mod users; (alphabetical with the others).
-
Step 5: Run to verify it passes.
DATABASE_URL=<url> cargo test -p db --test users→ PASS (4 tests). ThenDATABASE_URL=<url> cargo test -p db→ all PASS. -
Step 6: Lint.
cargo +nightly fmt;DATABASE_URL=<url> cargo clippy -p db --all-targets -- -D warnings→ clean. -
Step 7: Commit.
git add crates/db
git commit -m "feat(db): users table + repository (create/by_id/by_email/list), audited"
Task 3: auth crate — password hashing + extractors
Files: modify root Cargo.toml (workspace member + deps); create crates/auth/Cargo.toml, crates/auth/src/lib.rs.
- Step 1: Workspace + crate setup. Add
"crates/auth"tomembersin rootCargo.toml, and add the four workspace deps listed under "Workspace dependency additions" above. Createcrates/auth/Cargo.toml:
[package]
name = "auth"
version = "0.0.0"
edition.workspace = true
rust-version.workspace = true
[dependencies]
axum.workspace = true
domain = { path = "../domain" }
argon2.workspace = true
tower-sessions.workspace = true
serde.workspace = true
uuid.workspace = true
thiserror.workspace = true
[dev-dependencies]
tokio.workspace = true
- Step 2: Write the failing tests — add a
#[cfg(test)] mod teststocrates/auth/src/lib.rs(the password module is pure and unit-testable; the extractors are integration-tested over HTTP in Task 4):
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_then_verify_round_trips() {
let hash = hash_password("correct horse battery staple").unwrap();
assert!(hash.starts_with("$argon2id$"));
assert!(verify_password("correct horse battery staple", &hash));
}
#[test]
fn verify_rejects_wrong_password() {
let hash = hash_password("right").unwrap();
assert!(!verify_password("wrong", &hash));
}
#[test]
fn verify_rejects_malformed_hash() {
assert!(!verify_password("anything", "not-a-phc-string"));
}
#[test]
fn capability_markers_map_to_domain_capabilities() {
assert_eq!(ManageUsers::CAP, domain::Capability::ManageUsers);
assert_eq!(PublishObjects::CAP, domain::Capability::PublishObjects);
}
}
-
Step 3: Run to verify it fails.
cargo test -p auth→ FAIL (items missing). -
Step 4: Implement
crates/auth/src/lib.rs(adapt argon2/tower-sessions calls to the installed versions if a signature differs; behavior is the contract):
//! Authentication & authorization: argon2id password hashing and the type-driven
//! axum extractors that gate handlers. Identity is read from the session (set at
//! login); these extractors do not touch the database.
use std::marker::PhantomData;
use std::sync::OnceLock;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use domain::{Capability, Email, Role, UserId};
use tower_sessions::Session;
const SESSION_USER_ID: &str = "user_id";
const SESSION_EMAIL: &str = "email";
const SESSION_ROLE: &str = "role";
/// Hash a plaintext password as an argon2id PHC string.
pub fn hash_password(plaintext: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
Ok(Argon2::default().hash_password(plaintext.as_bytes(), &salt)?.to_string())
}
/// Verify a plaintext password against an argon2id PHC string. Returns `false` for a
/// wrong password OR a malformed/unparseable hash (never errors out).
pub fn verify_password(plaintext: &str, phc: &str) -> bool {
let Ok(parsed) = PasswordHash::new(phc) else {
return false;
};
Argon2::default().verify_password(plaintext.as_bytes(), &parsed).is_ok()
}
/// Spend a verify's worth of time against a fixed dummy hash. Call this on the
/// "user not found" login path to blunt user-enumeration via response timing.
pub fn verify_dummy(plaintext: &str) {
static DUMMY: OnceLock<String> = OnceLock::new();
let hash = DUMMY.get_or_init(|| hash_password("dummy-password-for-timing").expect("hash dummy"));
let _ = verify_password(plaintext, hash);
}
/// Record the authenticated identity into the session (call after a successful
/// password check). Cycles the session id first to prevent session fixation.
pub async fn establish_session(
session: &Session,
id: UserId,
email: &Email,
role: Role,
) -> Result<(), tower_sessions::session::Error> {
session.cycle_id().await?;
session.insert(SESSION_USER_ID, id.to_uuid()).await?;
session.insert(SESSION_EMAIL, email.as_str()).await?;
session.insert(SESSION_ROLE, role.as_str()).await?;
Ok(())
}
/// Rejection for the auth extractors.
#[derive(Debug, Clone, Copy, thiserror::Error)]
pub enum AuthError {
#[error("authentication required")]
Unauthenticated,
#[error("insufficient permissions")]
Forbidden,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
match self {
AuthError::Unauthenticated => StatusCode::UNAUTHORIZED,
AuthError::Forbidden => StatusCode::FORBIDDEN,
}
.into_response()
}
}
/// The authenticated user, reconstructed from the session. Extracting this proves
/// the request carries a valid session (else `401`).
#[derive(Debug, Clone)]
pub struct AuthUser {
pub id: UserId,
pub email: Email,
pub role: Role,
}
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let session = Session::from_request_parts(parts, state)
.await
.map_err(|_| AuthError::Unauthenticated)?;
let id: uuid::Uuid = session
.get(SESSION_USER_ID)
.await
.ok()
.flatten()
.ok_or(AuthError::Unauthenticated)?;
let email: String = session
.get(SESSION_EMAIL)
.await
.ok()
.flatten()
.ok_or(AuthError::Unauthenticated)?;
let role_str: String = session
.get(SESSION_ROLE)
.await
.ok()
.flatten()
.ok_or(AuthError::Unauthenticated)?;
let role = Role::from_db(&role_str).ok_or(AuthError::Unauthenticated)?;
Ok(AuthUser { id: UserId::from_uuid(id), email: Email::from_db(email), role })
}
}
/// A zero-sized type naming a required [`Capability`]. Implementors are used as the
/// type parameter of [`Authorized`].
pub trait CapabilityMarker {
const CAP: Capability;
}
/// Require `ManageUsers`.
pub struct ManageUsers;
impl CapabilityMarker for ManageUsers {
const CAP: Capability = Capability::ManageUsers;
}
/// Require `EditCatalogue`.
pub struct EditCatalogue;
impl CapabilityMarker for EditCatalogue {
const CAP: Capability = Capability::EditCatalogue;
}
/// Require `PublishObjects`.
pub struct PublishObjects;
impl CapabilityMarker for PublishObjects {
const CAP: Capability = Capability::PublishObjects;
}
/// Require `ViewInternal`.
pub struct ViewInternal;
impl CapabilityMarker for ViewInternal {
const CAP: Capability = Capability::ViewInternal;
}
/// An [`AuthUser`] proven to hold capability `C`. A handler taking `Authorized<C>`
/// cannot run without the request's role allowing `C` (else `403`).
#[derive(Debug, Clone)]
pub struct Authorized<C: CapabilityMarker> {
pub user: AuthUser,
_capability: PhantomData<C>,
}
impl<S, C> FromRequestParts<S> for Authorized<C>
where
S: Send + Sync,
C: CapabilityMarker,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let user = AuthUser::from_request_parts(parts, state).await?;
if user.role.allows(C::CAP) {
Ok(Authorized { user, _capability: PhantomData })
} else {
Err(AuthError::Forbidden)
}
}
}
-
Step 5: Run to verify it passes.
cargo test -p auth→ PASS (4 tests). -
Step 6: Lint.
cargo +nightly fmt;cargo clippy -p auth --all-targets -- -D warnings→ clean. (If clippy flags an unusedCapabilityMarkerimpl, that's expected to be fine since they arepub; do not delete them — they are the crate's capability vocabulary.) -
Step 7: Commit.
git add Cargo.toml crates/auth
git commit -m "feat(auth): argon2id hashing + AuthUser/Authorized<Cap> session extractors"
Task 4: api — session layer + admin surface
Files: modify crates/api/Cargo.toml, crates/api/src/lib.rs, crates/api/src/openapi.rs, crates/api/tests/health.rs, crates/api/tests/public.rs; create crates/api/src/admin.rs, crates/api/tests/admin.rs.
- Step 1: Cargo deps. In
crates/api/Cargo.toml[dependencies]add:
auth = { path = "../auth" }
tower-sessions.workspace = true
tower-sessions-sqlx-store.workspace = true
- Step 2: Add
cookie_securetoAppState, wire the session layer, exposemigrate_sessionsincrates/api/src/lib.rs:
mod admin;
mod health;
mod openapi;
mod public;
use axum::Router;
use db::Db;
use time::Duration;
use tower_sessions::cookie::SameSite;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::PostgresStore;
/// Shared application state passed to handlers.
#[derive(Clone)]
pub struct AppState {
/// Database handle for this organization.
pub db: Db,
/// User-facing product name (from config). Never hardcoded.
pub app_name: String,
/// Whether the session cookie carries the `Secure` attribute (default true;
/// disable only for plain-HTTP self-hosting).
pub cookie_secure: bool,
}
/// Build the application router from shared state.
pub fn build_app(state: AppState) -> Router {
let store = PostgresStore::new(state.db.pool().clone());
let session_layer = SessionManagerLayer::new(store)
.with_name("id")
.with_http_only(true)
.with_secure(state.cookie_secure)
.with_same_site(SameSite::Strict)
.with_expiry(Expiry::OnInactivity(Duration::hours(8)));
Router::new()
.merge(health::routes())
.merge(openapi::routes())
.merge(public::routes())
.merge(admin::routes())
.layer(session_layer)
.with_state(state)
}
/// Create the session store's table if absent. Run once at startup (and in tests
/// before exercising auth). Separate from `Db::migrate` — this is the session
/// library's own bookkeeping table.
pub async fn migrate_sessions(db: &Db) -> Result<(), sqlx::Error> {
PostgresStore::new(db.pool().clone()).migrate().await
}
NOTE: if PostgresStore::migrate returns a non-sqlx::Error error type in the installed version, adapt the return type (e.g. map into anyhow at the call site) — keep the behavior (creates the table). time::Duration::hours is available via the time workspace dep (already a dependency of db/domain; add time.workspace = true to api [dependencies] if not present).
- Step 3: Implement
crates/api/src/admin.rs:
//! Admin (authenticated) surface: login/logout/session, user listing, and publishing.
use auth::{AuthUser, Authorized, ManageUsers, PublishObjects};
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use domain::{AuditActor, ObjectId, Visibility};
use serde::{Deserialize, Serialize};
use tower_sessions::Session;
use utoipa::ToSchema;
use crate::AppState;
/// Credentials for password login.
#[derive(Deserialize, ToSchema)]
pub(crate) struct LoginRequest {
pub email: String,
pub password: String,
}
/// A user as exposed on the admin surface (no password material).
#[derive(Serialize, ToSchema)]
pub(crate) struct UserView {
pub id: String,
pub email: String,
pub role: String,
}
/// Desired visibility for a publish/unpublish request.
#[derive(Deserialize, ToSchema)]
pub(crate) struct VisibilityRequest {
pub visibility: Visibility,
}
/// Log in with email + password. On success, establishes a session (Set-Cookie) and
/// returns 204. On failure, 401 with no detail (no user enumeration).
#[utoipa::path(
post,
path = "/api/admin/login",
request_body = LoginRequest,
responses((status = 204, description = "Logged in"), (status = 401, description = "Invalid credentials"))
)]
pub(crate) async fn login(
State(state): State<AppState>,
session: Session,
Json(req): Json<LoginRequest>,
) -> Result<StatusCode, StatusCode> {
// Normalize the email the same way storage does; an unparseable email simply
// won't match, but we still spend verify time to resist enumeration.
let normalized = req.email.trim().to_lowercase();
let credentials = db::users::credentials_by_email(state.db.pool(), &normalized)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let verified = match &credentials {
Some((_, hash)) => auth::verify_password(&req.password, hash),
None => {
auth::verify_dummy(&req.password);
false
}
};
if !verified {
return Err(StatusCode::UNAUTHORIZED);
}
let (user, _) = credentials.expect("verified implies Some");
auth::establish_session(&session, user.id, &user.email, user.role)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
/// Log out: clear the session.
#[utoipa::path(post, path = "/api/admin/logout", responses((status = 204, description = "Logged out")))]
pub(crate) async fn logout(session: Session) -> Result<StatusCode, StatusCode> {
session.flush().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
/// The current authenticated user.
#[utoipa::path(get, path = "/api/admin/me", responses((status = 200, body = UserView), (status = 401)))]
pub(crate) async fn me(user: AuthUser) -> Json<UserView> {
Json(UserView {
id: user.id.to_string(),
email: user.email.as_str().to_owned(),
role: user.role.as_str().to_owned(),
})
}
/// List all users (Admin only).
#[utoipa::path(get, path = "/api/admin/users", responses((status = 200, body = [UserView]), (status = 401), (status = 403)))]
pub(crate) async fn list_users(
auth: Authorized<ManageUsers>,
) -> Result<Json<Vec<UserView>>, StatusCode> {
let _ = auth; // capability proven by the extractor
Err(StatusCode::INTERNAL_SERVER_ERROR) // replaced below
}
WAIT — list_users needs State to reach the DB. Write it as:
pub(crate) async fn list_users(
_auth: Authorized<ManageUsers>,
State(state): State<AppState>,
) -> Result<Json<Vec<UserView>>, StatusCode> {
let users = db::users::list_users(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(
users
.into_iter()
.map(|u| UserView { id: u.id.to_string(), email: u.email.as_str().to_owned(), role: u.role.as_str().to_owned() })
.collect(),
))
}
(Use this version; the stub above is illustrative. An extractor that is only needed for its guard is conventionally named _auth.)
Continue with the publish handler and routes:
/// Change an object's visibility (publish/unpublish). Requires `PublishObjects`.
#[utoipa::path(
post,
path = "/api/admin/objects/{id}/visibility",
params(("id" = String, Path, description = "Object id (UUID)")),
request_body = VisibilityRequest,
responses(
(status = 204, description = "Visibility changed"),
(status = 401), (status = 403),
(status = 404, description = "No such object"),
(status = 409, description = "Illegal visibility transition")
)
)]
pub(crate) async fn set_visibility(
_auth: Authorized<PublishObjects>,
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<VisibilityRequest>,
) -> Result<StatusCode, StatusCode> {
let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?;
let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Auth events / per-user actor wiring arrive with richer auditing; use System for now.
let result = db::catalog::set_visibility(&mut tx, AuditActor::System, object_id, req.visibility).await;
match result {
Ok(()) => {
tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND),
Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT),
Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
/// Admin routes, parameterized over [`AppState`].
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route("/api/admin/login", post(login))
.route("/api/admin/logout", post(logout))
.route("/api/admin/me", get(me))
.route("/api/admin/users", get(list_users))
.route("/api/admin/objects/{id}/visibility", post(set_visibility))
}
NOTE on actor: this plan records the visibility change with AuditActor::System (the per-user AuditActor::User(id) wiring belongs with the auth-events work, issue #7 — the AuthUser already carries the id, so this is a one-line change later). Leave a // TODO(#7) comment.
- Step 4: Register OpenAPI paths + schemas in
crates/api/src/openapi.rs:
use crate::{AppState, admin, health, public};
#[openapi(
paths(
health::live, health::ready,
public::list_objects, public::get_object,
admin::login, admin::logout, admin::me, admin::list_users, admin::set_visibility
),
components(schemas(
health::Live, health::Ready,
public::PublicView, public::PublicObjectPage,
admin::LoginRequest, admin::UserView, admin::VisibilityRequest
)),
info(title = "Collection Management System", version = "0.0.0")
)]
struct ApiDoc;
- Step 5: Update existing test state builders. In
crates/api/tests/health.rsandcrates/api/tests/public.rs, thestate(...)helpers constructAppState; addcookie_secure: false:
AppState { db: db::Db::from_pool(pool), app_name: app_name.to_string(), cookie_secure: false }
(health.rs takes an app_name arg; public.rs hardcodes "Test" — match each file's existing shape, just add the field.)
- Step 6: Write the failing admin tests
crates/api/tests/admin.rs:
use api::{AppState, build_app, migrate_sessions};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use db::{catalog, users};
use domain::{AuditActor, Email, NewUser, ObjectInput, Role, Visibility};
use http_body_util::BodyExt;
use sqlx::PgPool;
use tower::ServiceExt;
fn state(pool: PgPool) -> AppState {
AppState { db: db::Db::from_pool(pool), app_name: "Test".into(), cookie_secure: false }
}
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
let db = db::Db::from_pool(pool.clone());
let mut tx = db.pool().begin().await.unwrap();
users::create_user(
&mut tx,
AuditActor::System,
&NewUser {
email: Email::parse(email).unwrap(),
password_hash: auth_hash(password),
role,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
}
// Hash via the auth crate so the stored hash verifies. `auth` is a dev-dependency here.
fn auth_hash(password: &str) -> String {
auth::hash_password(password).unwrap()
}
fn login_request(email: &str, password: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri("/api/admin/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(r#"{{"email":"{email}","password":"{password}"}}"#)))
.unwrap()
}
/// Extract the session cookie value from a login response's Set-Cookie header.
fn session_cookie(resp: &axum::http::Response<Body>) -> String {
let raw = resp.headers().get(header::SET_COOKIE).expect("Set-Cookie").to_str().unwrap();
raw.split(';').next().unwrap().to_owned() // "id=..."
}
#[sqlx::test]
async fn login_then_me_returns_identity(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
seed_user(&pool, "admin@example.com", "s3cret-passw0rd", Role::Admin).await;
let app = build_app(state(pool));
let resp = app.clone().oneshot(login_request("admin@example.com", "s3cret-passw0rd")).await.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let cookie = session_cookie(&resp);
let me = app
.oneshot(Request::builder().uri("/api/admin/me").header(header::COOKIE, &cookie).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(me.status(), StatusCode::OK);
let json: serde_json::Value = serde_json::from_slice(&me.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(json["email"], "admin@example.com");
assert_eq!(json["role"], "admin");
}
#[sqlx::test]
async fn me_without_session_is_401(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
let app = build_app(state(pool));
let resp = app
.oneshot(Request::builder().uri("/api/admin/me").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test]
async fn wrong_password_is_401(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
seed_user(&pool, "admin@example.com", "right", Role::Admin).await;
let app = build_app(state(pool));
let resp = app.oneshot(login_request("admin@example.com", "wrong")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test]
async fn editor_cannot_list_users_but_admin_can(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
seed_user(&pool, "admin@example.com", "pw-admin-123", Role::Admin).await;
let app = build_app(state(pool));
// Editor: 403
let resp = app.clone().oneshot(login_request("editor@example.com", "pw-editor-123")).await.unwrap();
let editor_cookie = session_cookie(&resp);
let listed = app
.clone()
.oneshot(Request::builder().uri("/api/admin/users").header(header::COOKIE, &editor_cookie).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(listed.status(), StatusCode::FORBIDDEN);
// Admin: 200
let resp = app.clone().oneshot(login_request("admin@example.com", "pw-admin-123")).await.unwrap();
let admin_cookie = session_cookie(&resp);
let listed = app
.oneshot(Request::builder().uri("/api/admin/users").header(header::COOKIE, &admin_cookie).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(listed.status(), StatusCode::OK);
}
#[sqlx::test]
async fn editor_can_publish_via_admin_endpoint(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
// an internal object the editor will publish (draft -> internal done here, then -> public via API)
let db = db::Db::from_pool(pool.clone());
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(
&mut tx,
AuditActor::System,
&ObjectInput {
object_number: "P-1".into(),
object_name: "vase".into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility: Visibility::Internal,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
let app = build_app(state(pool));
let resp = app.clone().oneshot(login_request("editor@example.com", "pw-editor-123")).await.unwrap();
let cookie = session_cookie(&resp);
let publish = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/admin/objects/{id}/visibility"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"visibility":"public"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(publish.status(), StatusCode::NO_CONTENT);
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(obj.visibility, Visibility::Public);
}
Add auth = { path = "../auth" } to crates/api/Cargo.toml [dev-dependencies] (the tests hash passwords via auth::hash_password).
-
Step 7: Run to verify it passes.
DATABASE_URL=<url> cargo test -p api→ all PASS (health + public + the 5 admin tests). Iterate on any tower-sessions/axum signature mismatches until green (the tests are the contract). -
Step 8: Lint.
cargo +nightly fmt;DATABASE_URL=<url> cargo clippy -p api --all-targets -- -D warnings→ clean. -
Step 9: Commit.
git add crates/api
git commit -m "feat(api): admin auth surface (login/logout/me/users/publish) on tower-sessions"
Task 5: server — session migration + create-user CLI
Files: modify crates/server/Cargo.toml, crates/server/src/config.rs, crates/server/src/lib.rs, crates/server/src/main.rs, crates/server/tests/config.rs.
- Step 1: Cargo deps. In
crates/server/Cargo.toml[dependencies]add:
auth = { path = "../auth" }
domain = { path = "../domain" }
tower-sessions-sqlx-store.workspace = true
rpassword.workspace = true
(Add tower-sessions-sqlx-store only if create_user/startup references it directly; the session migration goes through api::migrate_sessions, so it likely is NOT needed here — omit unless the compiler asks.)
- Step 2: Add
cookie_securetoConfigincrates/server/src/config.rs:
/// Send the session cookie with the `Secure` attribute (HTTPS-only). Disable
/// only for plain-HTTP self-hosting behind no TLS at all.
#[arg(long, env = "SESSION_COOKIE_SECURE", default_value_t = true)]
pub cookie_secure: bool,
- Step 3: Wire session migration + the
create_userfunction incrates/server/src/lib.rs. Updaterunto migrate the session store and passcookie_secure, and addcreate_user:
use anyhow::Context;
use api::{AppState, build_app, migrate_sessions};
use db::Db;
use domain::{AuditActor, Email, NewUser, Role};
use tokio::net::TcpListener;
pub async fn run(config: Config) -> anyhow::Result<()> {
let db = Db::connect(&config.database_url).await.context("connecting to the database")?;
db.migrate().await.context("running database migrations")?;
migrate_sessions(&db).await.context("creating the session store")?;
let state = AppState {
db,
app_name: config.app_name.clone(),
cookie_secure: config.cookie_secure,
};
let listener = TcpListener::bind(&config.bind_addr)
.await
.with_context(|| format!("binding to {}", config.bind_addr))?;
tracing::info!(addr = %config.bind_addr, "server listening");
serve(listener, state).await
}
/// Create a user from the CLI (admin bootstrap). Reads the password from the
/// `BOOTSTRAP_PASSWORD` env var if set, otherwise prompts (hidden input).
pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> {
let email = Email::parse(email).map_err(|e| anyhow::anyhow!("{e}"))?;
let password = match std::env::var("BOOTSTRAP_PASSWORD") {
Ok(p) => p,
Err(_) => rpassword::prompt_password("Password: ").context("reading password")?,
};
anyhow::ensure!(password.len() >= 8, "password must be at least 8 characters");
let password_hash = auth::hash_password(&password).map_err(|e| anyhow::anyhow!("hashing password: {e}"))?;
let db = Db::connect(database_url).await.context("connecting to the database")?;
let mut tx = db.pool().begin().await?;
let id = db::users::create_user(&mut tx, AuditActor::System, &NewUser { email, password_hash, role })
.await
.context("creating the user (is the email already taken?)")?;
tx.commit().await?;
println!("created user {id} ({role:?})");
Ok(())
}
Keep the existing serve function as-is.
- Step 4: Add the subcommand CLI in
crates/server/src/main.rs:
use clap::{Parser, Subcommand, ValueEnum};
use domain::Role;
use server::{Config, create_user, run};
#[derive(Parser)]
#[command(version, about = "Collection management system server")]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
config: Config,
}
#[derive(Subcommand)]
enum Command {
/// Create a user (admin bootstrap).
CreateUser {
#[arg(long)]
email: String,
#[arg(long, value_enum)]
role: RoleArg,
},
}
#[derive(Clone, Copy, ValueEnum)]
enum RoleArg {
Admin,
Editor,
}
impl From<RoleArg> for Role {
fn from(r: RoleArg) -> Self {
match r {
RoleArg::Admin => Role::Admin,
RoleArg::Editor => Role::Editor,
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
match cli.command {
None => run(cli.config).await,
Some(Command::CreateUser { email, role }) => {
create_user(&cli.config.database_url, &email, role.into()).await
}
}
}
NOTE: flattening Config means create-user also accepts --database-url/DATABASE_URL (which it needs) and the serve-only options keep their defaults (unused for create-user). This preserves the existing default-to-serve behavior (server with no subcommand still serves).
- Step 5: Update the config test
crates/server/tests/config.rs— the existing tests parseConfig;cookie_securehas a default so they should still pass, but add a check and ensure parsing still works. If a test asserts an exact field set, addcookie_securedefaulting totrue. Add:
#[test]
fn cookie_secure_defaults_to_true() {
let config = Config::try_parse_from(["server", "--database-url", "postgres://x"]).unwrap();
assert!(config.cookie_secure);
}
(Match the file's existing import of Config/clap::Parser and the temp-env isolation pattern used by the other config tests if DATABASE_URL is read from env.)
- Step 6: Run to verify it passes. Build + tests:
DATABASE_URL=<url> cargo test -p server
Expected: PASS (config tests + the existing serve test). If the serve test builds an AppState, add cookie_secure: false there too.
-
Step 7: Manual smoke (optional, document only).
BOOTSTRAP_PASSWORD=changeme cargo run -p server -- create-user --email admin@example.com --role admincreates the first admin;cargo run -p serverthen serves. (Not a CI step.) -
Step 8: Full workspace check.
cargo +nightly fmt --check
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=<url> MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo test --workspace
Expected: all green.
- Step 9: Commit.
git add crates/server Cargo.toml
git commit -m "feat(server): create-user CLI + session-store migration on startup"
Self-Review (completed)
Spec coverage (VISION "Authentication & access control" [MVP]; arch spec §10, §9, §7):
- Email/password scoped to the single org → Tasks 2–4. ✓ (OIDC deferred — own plan.)
- Sessions without Redis (server-side, in the org DB) → tower-sessions + PostgresStore. ✓
- Authorization via typed extractors (
AuthUser/Authorized<Cap>); privileged handler can't compile without the capability → Task 3. ✓ - Role/permission model kept simple (Admin/Editor + capability policy) → Task 1. ✓
- Clean public/admin split (
/api/admin/**authenticated; public untouched) → Task 4. ✓ - All SQL in
db;authis DB-free;api/serverwire it → crate deps. ✓ - Audited user creation (email+role, never the hash) → Task 2 + test. ✓
Placeholder scan: the list_users "stub" block in Task 4 Step 3 is explicitly replaced by the version immediately below it (called out in prose). <url>/<key> are documented env values. No other placeholders.
Type consistency: UserId/Email/Role/Capability/User/NewUser defined in Task 1 and consumed by Tasks 2–5; db::users::{create_user, user_by_id, credentials_by_email, list_users} defined in Task 2 and consumed in Task 4; auth::{hash_password, verify_password, verify_dummy, establish_session, AuthUser, Authorized, ManageUsers, PublishObjects, CapabilityMarker, AuthError} defined in Task 3 and consumed in Task 4; AppState gains cookie_secure (Task 4) consistently updated in every constructor (api tests + server). Reuses Plan 7's db::catalog::set_visibility + VisibilityError for the publish endpoint.
Notes for follow-on plans
- Closes issue #15 (admin endpoint to trigger visibility transitions) via
POST /api/admin/objects/{id}/visibility. Close #15 on merge. - Per-user audit actor (issue #7): the publish handler records
AuditActor::System; switch toAuditActor::User(auth.user.id)and add login/logout/auth-event auditing in the #7 work. TheAuthUseralready 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.