feat(server): create-user CLI + session-store migration on startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 15:07:58 +02:00
parent 642f709bbe
commit dbff95c2a9
6 changed files with 159 additions and 8 deletions
+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"
+5
View File
@@ -18,4 +18,9 @@ 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, env = "SESSION_COOKIE_SECURE", default_value_t = true)]
pub cookie_secure: bool,
} }
+53 -3
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,16 +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(),
// Wired to config in the auth CLI task; Secure by default. cookie_secure: config.cookie_secure,
cookie_secure: true,
}; };
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
@@ -35,8 +40,53 @@ 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). Reads the password from the
/// `BOOTSTRAP_PASSWORD` env var if set, otherwise prompts (hidden input).
pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> {
let email = Email::parse(email).map_err(|err| anyhow::anyhow!("{err}"))?;
let password = match std::env::var("BOOTSTRAP_PASSWORD") {
Ok(p) => p,
Err(_) => rpassword::prompt_password("Password: ").context("reading password")?,
};
anyhow::ensure!(
password.len() >= 8,
"password must be at least 8 characters"
);
let password_hash =
auth::hash_password(&password).map_err(|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);
});
}
+40
View File
@@ -0,0 +1,40 @@
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));
}