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:
@@ -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,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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user