Compare commits

...

13 Commits

Author SHA1 Message Date
logaritmisk 807ac1a9f8 chore: sync Cargo.lock with auth dependencies 2026-06-02 15:21:03 +02:00
logaritmisk 5cfee93037 merge: authentication (email/password) — sessions, extractors, admin surface, CLI bootstrap 2026-06-02 15:20:36 +02:00
logaritmisk 369eee4098 fix(server): --session-cookie-secure flag; scope+char-count password; invalid-email test 2026-06-02 15:16:46 +02:00
logaritmisk dbff95c2a9 feat(server): create-user CLI + session-store migration on startup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:07:58 +02:00
logaritmisk 642f709bbe fix(api): drop redundant dev-deps; fix server AppState for cookie_secure; add logout + illegal-transition tests 2026-06-02 15:04:07 +02:00
logaritmisk 5135aeee6c feat(api): admin auth surface (login/logout/me/users/publish) on tower-sessions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:54:03 +02:00
logaritmisk 4e7288731a harden(auth): distinguish session-store failure (500) from absent session (401); exhaustive marker + verify_dummy tests 2026-06-02 14:48:40 +02:00
logaritmisk 992526ef77 feat(auth): argon2id hashing + AuthUser/Authorized<Cap> session extractors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:45:13 +02:00
logaritmisk bea9b6b39a harden(db): case-insensitive email unique index + dup-email test; list_users pagination TODO; from_db note 2026-06-02 14:42:04 +02:00
logaritmisk f8ec2d7cf1 feat(db): users table + repository (create/by_id/by_email/list), audited
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:37:43 +02:00
logaritmisk 9597a42eeb fix(domain): make Editor capability policy fail-closed (exhaustive match) 2026-06-02 14:32:13 +02:00
logaritmisk 74b2cf65ed feat(domain): user identity (UserId, Email), Role/Capability policy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:29:04 +02:00
logaritmisk 1ed9798a1f docs(plans): authentication (email/password) — sessions, extractors, CLI bootstrap 2026-06-02 14:26:19 +02:00
26 changed files with 3017 additions and 12 deletions
Generated
+200
View File
@@ -77,6 +77,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
name = "api" name = "api"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"auth",
"axum", "axum",
"db", "db",
"domain", "domain",
@@ -84,11 +85,26 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"time",
"tokio", "tokio",
"tower", "tower",
"tower-sessions",
"tower-sessions-sqlx-store",
"utoipa", "utoipa",
] ]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -115,6 +131,20 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auth"
version = "0.0.0"
dependencies = [
"argon2",
"axum",
"domain",
"serde",
"thiserror 2.0.18",
"tokio",
"tower-sessions",
"uuid",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@@ -217,6 +247,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -347,6 +386,17 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -566,6 +616,20 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@@ -1135,6 +1199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [ dependencies = [
"scopeguard", "scopeguard",
"serde",
] ]
[[package]] [[package]]
@@ -1351,6 +1416,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@@ -1674,6 +1750,36 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -1694,6 +1800,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -1846,10 +1962,13 @@ version = "0.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"api", "api",
"auth",
"axum", "axum",
"clap", "clap",
"db", "db",
"domain",
"reqwest", "reqwest",
"rpassword",
"serde_json", "serde_json",
"sqlx", "sqlx",
"temp-env", "temp-env",
@@ -2432,6 +2551,22 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "tower-cookies"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
dependencies = [
"axum-core",
"cookie",
"futures-util",
"http",
"parking_lot",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.6.11" version = "0.6.11"
@@ -2462,6 +2597,71 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower-sessions"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3"
dependencies = [
"async-trait",
"http",
"time",
"tokio",
"tower-cookies",
"tower-layer",
"tower-service",
"tower-sessions-core",
"tower-sessions-memory-store",
"tracing",
]
[[package]]
name = "tower-sessions-core"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b"
dependencies = [
"async-trait",
"axum-core",
"base64",
"futures",
"http",
"parking_lot",
"rand 0.8.6",
"serde",
"serde_json",
"thiserror 2.0.18",
"time",
"tokio",
"tracing",
]
[[package]]
name = "tower-sessions-memory-store"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242"
dependencies = [
"async-trait",
"time",
"tokio",
"tower-sessions-core",
]
[[package]]
name = "tower-sessions-sqlx-store"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e054622079f57fc1a7d6a6089c9334f963d62028fe21dc9eddd58af9a78480b3"
dependencies = [
"async-trait",
"rmp-serde",
"sqlx",
"thiserror 1.0.69",
"time",
"tower-sessions-core",
]
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
+5 -1
View File
@@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search"] members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search", "crates/auth"]
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
@@ -24,3 +24,7 @@ tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1" http-body-util = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
meilisearch-sdk = "0.33" meilisearch-sdk = "0.33"
argon2 = "0.5"
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
rpassword = "7"
+5 -1
View File
@@ -8,6 +8,11 @@ rust-version.workspace = true
axum.workspace = true axum.workspace = true
serde.workspace = true serde.workspace = true
utoipa.workspace = true utoipa.workspace = true
time.workspace = true
tower-sessions.workspace = true
tower-sessions-sqlx-store.workspace = true
sqlx.workspace = true
auth = { path = "../auth" }
db = { path = "../db" } db = { path = "../db" }
domain = { path = "../domain" } domain = { path = "../domain" }
@@ -16,4 +21,3 @@ tokio.workspace = true
tower.workspace = true tower.workspace = true
http-body-util.workspace = true http-body-util.workspace = true
serde_json.workspace = true serde_json.workspace = true
sqlx.workspace = true
+179
View File
@@ -0,0 +1,179 @@
//! 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,
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 {
#[schema(value_type = String)]
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> {
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>,
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(),
))
}
/// 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> {
// 404 (not 400) for an unparseable id — same non-leaking convention as the public
// surface: never reveal whether an id could exist.
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)?;
// TODO(#7): record the per-user actor (AuthUser carries the id) once auth-event
// auditing lands; 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))
}
+26
View File
@@ -1,11 +1,16 @@
//! HTTP API: router, handlers, and OpenAPI document. //! HTTP API: router, handlers, and OpenAPI document.
mod admin;
mod health; mod health;
mod openapi; mod openapi;
mod public; mod public;
use axum::Router; use axum::Router;
use db::Db; 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. /// Shared application state passed to handlers.
#[derive(Clone)] #[derive(Clone)]
@@ -14,13 +19,34 @@ pub struct AppState {
pub db: Db, pub db: Db,
/// User-facing product name (from config). Never hardcoded. /// User-facing product name (from config). Never hardcoded.
pub app_name: String, 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. /// Build the application router from shared state.
pub fn build_app(state: AppState) -> Router { 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() Router::new()
.merge(health::routes()) .merge(health::routes())
.merge(openapi::routes()) .merge(openapi::routes())
.merge(public::routes()) .merge(public::routes())
.merge(admin::routes())
.layer(session_layer)
.with_state(state) .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` — the session library's own
/// bookkeeping table.
pub async fn migrate_sessions(db: &Db) -> Result<(), sqlx::Error> {
PostgresStore::new(db.pool().clone()).migrate().await
}
+18 -3
View File
@@ -1,16 +1,29 @@
use axum::{Json, Router, extract::State, routing::get}; use axum::{Json, Router, extract::State, routing::get};
use utoipa::OpenApi; use utoipa::OpenApi;
use crate::{AppState, health, public}; use crate::{AppState, admin, health, public};
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths(health::live, health::ready, public::list_objects, public::get_object), 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( components(schemas(
health::Live, health::Live,
health::Ready, health::Ready,
public::PublicView, public::PublicView,
public::PublicObjectPage public::PublicObjectPage,
admin::LoginRequest,
admin::UserView,
admin::VisibilityRequest
)), )),
info(title = "Collection Management System", version = "0.0.0") info(title = "Collection Management System", version = "0.0.0")
)] )]
@@ -20,7 +33,9 @@ struct ApiDoc;
/// product name is never hardcoded. /// product name is never hardcoded.
async fn openapi_json(State(state): State<AppState>) -> Json<utoipa::openapi::OpenApi> { async fn openapi_json(State(state): State<AppState>) -> Json<utoipa::openapi::OpenApi> {
let mut doc = ApiDoc::openapi(); let mut doc = ApiDoc::openapi();
doc.info.title = state.app_name.clone(); doc.info.title = state.app_name.clone();
Json(doc) Json(doc)
} }
+336
View File
@@ -0,0 +1,336 @@
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(password).unwrap(),
role,
},
)
.await
.unwrap();
tx.commit().await.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()
}
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()
}
#[sqlx::test(migrations = "../db/migrations")]
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(migrations = "../db/migrations")]
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(migrations = "../db/migrations")]
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(migrations = "../db/migrations")]
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));
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);
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(migrations = "../db/migrations")]
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;
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);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn logout_invalidates_the_session(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();
let cookie = session_cookie(&resp);
// logout with the session cookie
let out = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/logout")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(out.status(), StatusCode::NO_CONTENT);
// the old cookie no longer authenticates
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::UNAUTHORIZED);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn illegal_visibility_transition_is_409(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
// a draft object — draft -> public in one step is illegal (must pass through internal)
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: "D-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::Draft,
},
)
.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::CONFLICT);
}
+1
View File
@@ -9,6 +9,7 @@ fn state(pool: PgPool, app_name: &str) -> AppState {
AppState { AppState {
db: db::Db::from_pool(pool), db: db::Db::from_pool(pool),
app_name: app_name.to_string(), app_name: app_name.to_string(),
cookie_secure: false,
} }
} }
+1
View File
@@ -11,6 +11,7 @@ fn state(pool: PgPool) -> AppState {
AppState { AppState {
db: db::Db::from_pool(pool), db: db::Db::from_pool(pool),
app_name: "Test".to_string(), app_name: "Test".to_string(),
cookie_secure: false,
} }
} }
+17
View File
@@ -0,0 +1,17 @@
[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
+243
View File
@@ -0,0 +1,243 @@
//! 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::Argon2;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
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,
/// The session store itself failed (e.g. the database is unreachable) — distinct
/// from "no session", so an outage surfaces as 500 rather than a misleading 401.
#[error("session store unavailable")]
Internal,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
match self {
AuthError::Unauthenticated => StatusCode::UNAUTHORIZED,
AuthError::Forbidden => StatusCode::FORBIDDEN,
AuthError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
}
.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> {
// A failed extraction here means the SessionManagerLayer is missing from the
// stack — a wiring bug, not an auth failure: surface it as 500.
let session = Session::from_request_parts(parts, state)
.await
.map_err(|_| AuthError::Internal)?;
// For each key: a store error (DB down) is `Internal` (500); an absent key is
// `Unauthenticated` (401) — these must not be conflated.
let id: uuid::Uuid = session
.get(SESSION_USER_ID)
.await
.map_err(|_| AuthError::Internal)?
.ok_or(AuthError::Unauthenticated)?;
let email: String = session
.get(SESSION_EMAIL)
.await
.map_err(|_| AuthError::Internal)?
.ok_or(AuthError::Unauthenticated)?;
let role_str: String = session
.get(SESSION_ROLE)
.await
.map_err(|_| AuthError::Internal)?
.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)
}
}
}
#[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 verify_dummy_does_not_panic() {
verify_dummy("any input");
verify_dummy("called again"); // exercises the already-initialized OnceLock path
}
#[test]
fn capability_markers_map_to_domain_capabilities() {
assert_eq!(ManageUsers::CAP, domain::Capability::ManageUsers);
assert_eq!(EditCatalogue::CAP, domain::Capability::EditCatalogue);
assert_eq!(PublishObjects::CAP, domain::Capability::PublishObjects);
assert_eq!(ViewInternal::CAP, domain::Capability::ViewInternal);
}
}
+19
View File
@@ -0,0 +1,19 @@
-- Users of this organization's instance. One database == one organization, so no
-- org_id. Passwords are stored only as argon2id PHC strings.
--
-- `updated_at` is maintained manually in UPDATE statements (as in the object table);
-- there is no auto-update trigger and no update path exists yet.
CREATE TABLE app_user (
id UUID PRIMARY KEY,
email TEXT NOT NULL 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()
);
-- Case-insensitive uniqueness on email, enforced at the database. The application
-- stores normalized (lowercased) emails and looks up via `lower(email) = $1`, so this
-- functional unique index both backs those lookups and guarantees no case-variant
-- duplicate can exist even if a non-normalized value were ever written.
CREATE UNIQUE INDEX app_user_email_lower_key ON app_user (lower(email));
+1
View File
@@ -5,6 +5,7 @@ pub mod authority;
pub mod catalog; pub mod catalog;
pub mod fields; pub mod fields;
pub mod seed; pub mod seed;
pub mod users;
pub mod vocab; pub mod vocab;
use sqlx::postgres::{PgPool, PgPoolOptions}; use sqlx::postgres::{PgPool, PgPoolOptions};
+123
View File
@@ -0,0 +1,123 @@
//! 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>,
{
// Match the `lower(email)` unique index; `email` is already normalized by callers.
let sql = format!("SELECT {USER_COLUMNS}, password_hash FROM app_user WHERE lower(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.
// TODO: add LIMIT/keyset pagination before exposing this via the API.
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,
})
}
+126
View File
@@ -0,0 +1,126 @@
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"]);
}
#[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"]);
}
#[sqlx::test]
async fn duplicate_email_is_rejected(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("anna@example.com", Role::Admin),
)
.await
.unwrap();
// Same normalized email again — the lower(email) unique index must reject it.
let err = users::create_user(
&mut tx,
AuditActor::System,
&new_user("anna@example.com", Role::Editor),
)
.await
.unwrap_err();
assert!(
matches!(err, sqlx::Error::Database(_)),
"expected a unique-violation database error, got {err:?}"
);
}
+4
View File
@@ -72,6 +72,10 @@ id_newtype!(
/// Identifier for a flexible-field definition. /// Identifier for a flexible-field definition.
FieldDefinitionId FieldDefinitionId
); );
id_newtype!(
/// Identifier for a user of this organization's instance.
UserId
);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+3 -1
View File
@@ -6,12 +6,14 @@ mod field_definition;
mod id; mod id;
mod label; mod label;
mod object; mod object;
mod user;
mod vocabulary; mod vocabulary;
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent}; pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority}; pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition}; pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition};
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, VocabularyId}; pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId};
pub use label::{LocalizedLabel, pick_label}; pub use label::{LocalizedLabel, pick_label};
pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility}; pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility};
pub use user::{Capability, Email, EmailError, NewUser, Role, User};
pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary}; pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary};
+188
View File
@@ -0,0 +1,188 @@
//! 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.
/// For reading values back from the database only — never to construct an `Email`
/// destined to be written (writes must go through [`Email::parse`] so storage
/// stays normalized).
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`.
///
/// The `Editor` arm is an exhaustive `match` on purpose: adding a new
/// [`Capability`] variant is a compile error here until its Editor access is
/// decided explicitly, so the policy fails closed rather than silently granting
/// new capabilities to Editors.
pub fn allows(self, capability: Capability) -> bool {
match self {
Role::Admin => true,
Role::Editor => match capability {
Capability::EditCatalogue
| Capability::PublishObjects
| Capability::ViewInternal => true,
Capability::ManageUsers => false,
},
}
}
}
/// 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::*;
for cap in [ManageUsers, EditCatalogue, PublishObjects, ViewInternal] {
assert!(Role::Admin.allows(cap));
}
assert!(!Role::Editor.allows(ManageUsers));
for cap in [EditCatalogue, PublishObjects, ViewInternal] {
assert!(Role::Editor.allows(cap));
}
}
}
+5
View File
@@ -19,12 +19,17 @@ anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
api = { path = "../api" } api = { path = "../api" }
auth = { path = "../auth" }
db = { path = "../db" } db = { path = "../db" }
domain = { path = "../domain" }
rpassword.workspace = true
[dev-dependencies] [dev-dependencies]
reqwest.workspace = true reqwest.workspace = true
serde_json.workspace = true serde_json.workspace = true
api = { path = "../api" } api = { path = "../api" }
auth = { path = "../auth" }
db = { path = "../db" } db = { path = "../db" }
domain = { path = "../domain" }
sqlx.workspace = true sqlx.workspace = true
temp-env = "0.3" temp-env = "0.3"
+9
View File
@@ -18,4 +18,13 @@ pub struct Config {
/// time. The product name must never be hardcoded in source. /// time. The product name must never be hardcoded in source.
#[arg(long, env = "APP_NAME", default_value = "Collection Management System")] #[arg(long, env = "APP_NAME", default_value = "Collection Management System")]
pub app_name: String, pub app_name: String,
/// Send the session cookie with the `Secure` attribute (HTTPS-only). Disable
/// only for plain-HTTP self-hosting behind no TLS at all.
#[arg(
long = "session-cookie-secure",
env = "SESSION_COOKIE_SECURE",
default_value_t = true
)]
pub cookie_secure: bool,
} }
+56 -1
View File
@@ -5,8 +5,9 @@ mod config;
pub use config::Config; pub use config::Config;
use anyhow::Context; use anyhow::Context;
use api::{AppState, build_app}; use api::{AppState, build_app, migrate_sessions};
use db::Db; use db::Db;
use domain::{AuditActor, Email, NewUser, Role};
use tokio::net::TcpListener; use tokio::net::TcpListener;
/// Connect dependencies from `config` and serve until shutdown. /// Connect dependencies from `config` and serve until shutdown.
@@ -17,14 +18,20 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
db.migrate().await.context("running database migrations")?; db.migrate().await.context("running database migrations")?;
migrate_sessions(&db)
.await
.context("creating the session store")?;
let state = AppState { let state = AppState {
db, db,
app_name: config.app_name.clone(), app_name: config.app_name.clone(),
cookie_secure: config.cookie_secure,
}; };
let listener = TcpListener::bind(&config.bind_addr) let listener = TcpListener::bind(&config.bind_addr)
.await .await
.with_context(|| format!("binding to {}", config.bind_addr))?; .with_context(|| format!("binding to {}", config.bind_addr))?;
tracing::info!(addr = %config.bind_addr, "server listening"); tracing::info!(addr = %config.bind_addr, "server listening");
serve(listener, state).await serve(listener, state).await
@@ -33,8 +40,56 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
/// Serve the API on an already-bound listener (used by `run` and tests). /// Serve the API on an already-bound listener (used by `run` and tests).
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> { pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
let app = build_app(state); let app = build_app(state);
axum::serve(listener, app) axum::serve(listener, app)
.await .await
.context("running the HTTP server")?; .context("running the HTTP server")?;
Ok(())
}
/// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI
/// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set,
/// otherwise prompts (hidden input). The plaintext is not zeroized, but it is
/// confined to the scope below and dropped before any network I/O.
pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> {
let email = Email::parse(email).map_err(|err| anyhow::anyhow!("{err}"))?;
// Read, validate, and hash the password in a scope so the plaintext `String` is
// dropped before we open a connection / run any awaits.
let password_hash = {
let password = match std::env::var("BOOTSTRAP_PASSWORD") {
Ok(p) => p,
Err(_) => rpassword::prompt_password("Password: ").context("reading password")?,
};
anyhow::ensure!(
password.chars().count() >= 8,
"password must be at least 8 characters"
);
auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))?
};
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(()) Ok(())
} }
+46 -4
View File
@@ -1,5 +1,41 @@
use clap::Parser; use clap::{Parser, Subcommand, ValueEnum};
use server::{Config, run}; 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] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
@@ -7,6 +43,12 @@ async fn main() -> anyhow::Result<()> {
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init(); .init();
let config = Config::parse(); let cli = Cli::parse();
run(config).await
match cli.command {
None => run(cli.config).await,
Some(Command::CreateUser { email, role }) => {
create_user(&cli.config.database_url, &email, role.into()).await
}
}
} }
+10 -1
View File
@@ -1,10 +1,11 @@
use clap::Parser; use clap::Parser;
use server::Config; use server::Config;
const CLEARED: [(&str, Option<&str>); 3] = [ const CLEARED: [(&str, Option<&str>); 4] = [
("DATABASE_URL", None), ("DATABASE_URL", None),
("BIND_ADDR", None), ("BIND_ADDR", None),
("APP_NAME", None), ("APP_NAME", None),
("SESSION_COOKIE_SECURE", None),
]; ];
#[test] #[test]
@@ -25,3 +26,11 @@ fn database_url_is_required() {
assert!(Config::try_parse_from(["server"]).is_err()); assert!(Config::try_parse_from(["server"]).is_err());
}); });
} }
#[test]
fn cookie_secure_defaults_to_true() {
temp_env::with_vars(CLEARED, || {
let config = Config::try_parse_from(["server", "--database-url", "postgres://x"]).unwrap();
assert!(config.cookie_secure);
});
}
+50
View File
@@ -0,0 +1,50 @@
use db::Db;
use domain::Role;
use sqlx::PgPool;
// Note: `server::create_user` opens its own DB connection by URL, but `#[sqlx::test]`
// provisions a temporary database whose URL is not directly exposed. The test below
// exercises the same building blocks that `server::create_user` composes —
// `auth::hash_password` + `db::users::create_user` + `db::users::credentials_by_email` —
// against the test pool, which fully validates the end-to-end bootstrap logic.
#[sqlx::test(migrations = "../db/migrations")]
async fn create_user_persists_and_password_verifies(pool: PgPool) {
let db = Db::from_pool(pool.clone());
let hash = auth::hash_password("bootstrap-pw-123").unwrap();
let mut tx = db.pool().begin().await.unwrap();
db::users::create_user(
&mut tx,
domain::AuditActor::System,
&domain::NewUser {
email: domain::Email::parse("boss@example.com").unwrap(),
password_hash: hash,
role: Role::Admin,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
let (user, stored_hash) = db::users::credentials_by_email(db.pool(), "boss@example.com")
.await
.unwrap()
.unwrap();
assert_eq!(user.role, Role::Admin);
assert!(auth::verify_password("bootstrap-pw-123", &stored_hash));
}
#[tokio::test]
async fn create_user_rejects_invalid_email() {
// The email is parsed before the password is read or the DB is touched, so an
// invalid email errors out without reaching the (unreachable) database URL.
let err = server::create_user("postgres://unused", "not-an-email", Role::Admin)
.await
.unwrap_err();
assert!(err.to_string().contains("email"), "got: {err}");
}
+1
View File
@@ -15,6 +15,7 @@ async fn serves_health_live_over_tcp() {
let state = AppState { let state = AppState {
db, db,
app_name: "Test".to_string(), app_name: "Test".to_string(),
cookie_secure: false,
}; };
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
File diff suppressed because it is too large Load Diff