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