Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 807ac1a9f8 | |||
| 5cfee93037 | |||
| 369eee4098 | |||
| dbff95c2a9 | |||
| 642f709bbe | |||
| 5135aeee6c | |||
| 4e7288731a | |||
| 992526ef77 | |||
| bea9b6b39a | |||
| f8ec2d7cf1 | |||
| 9597a42eeb | |||
| 74b2cf65ed | |||
| 1ed9798a1f |
Generated
+200
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user