Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.9 KiB
Tier 4 Hardening — Batch 1 (#1, #2, #21) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use
- [ ].
Goal: The mechanical, well-specified hardening items — graceful HTTP shutdown (#1), configurable DB pool size (#2), and audit logging for vocabulary/term/authority creation (#21). (The design-heavy Tier 4 items #20/#5/#7 are handled separately.)
Tech Stack: Rust (axum 0.8, sqlx, tokio, anyhow). Backend-only.
Conventions: nightly fmt; clippy -D warnings; no codename. Test infra: DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev, MEILI_URL=http://localhost:7700, MEILI_MASTER_KEY=masterKey (#[sqlx::test] provisions its own DB).
Task 1: #1 — graceful shutdown
Files: crates/server/src/lib.rs, crates/server/Cargo.toml (tokio signal feature if missing).
-
Step 1: Ensure tokio
signalfeature. Checkcrates/server/Cargo.toml'stokiodependency features include"signal". If the workspacetokioisfeatures = ["full"]it's already included; otherwise add"signal"(and"macros"/"rt-multi-thread"if not already). Verify withcargo build -p server. -
Step 2: Add a shutdown-signal future in
crates/server/src/lib.rs(aboveserve):
/// Resolves when the process receives SIGINT (Ctrl-C) or SIGTERM, so the server can
/// drain in-flight requests before exiting.
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("install Ctrl-C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received; draining");
}
- Step 3: Wire it into
serve. Change theaxum::serve(...)call:
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("running the HTTP server")?;
- Step 4: Verify.
cargo +nightly fmt;cargo clippy -p server --all-targets;DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p server(the existingserve.rssmoke test still passes — it aborts the handle, which is unaffected). Commit:
git add crates/server
git commit -m "feat(server): graceful shutdown on SIGINT/SIGTERM (#1)"
Task 2: #2 — configurable DB pool size
Files: crates/db/src/lib.rs, crates/server/src/config.rs, crates/server/src/lib.rs.
Db::connect currently hardcodes .max_connections(5).
- Step 1: Parameterize
Db::connect. Incrates/db/src/lib.rs:
/// Connect to the database at `database_url`, opening a connection pool with at most
/// `max_connections` connections.
pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await?;
Ok(Self { pool })
}
- Step 2: Add the config knob. In
crates/server/src/config.rs, add a field toConfig:
/// Maximum size of the PostgreSQL connection pool.
#[arg(long = "db-max-connections", env = "DB_MAX_CONNECTIONS", default_value_t = 5)]
pub db_max_connections: u32,
-
Step 3: Thread it through the two
Db::connectcall sites incrates/server/src/lib.rs:- In
run:Db::connect(&config.database_url, config.db_max_connections). - In
create_user(the CLI one-shot — it has onlydatabase_url: &str, noConfig): pass a small fixed default,Db::connect(database_url, 2)(a one-shot CLI needs minimal connections), and add a brief comment.
- In
-
Step 4: Verify.
cargo +nightly fmt;cargo clippy --workspace --all-targets;DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p server. Confirmcargo run -p server -- --helpshows the new--db-max-connectionsflag (optional). Commit:
git add crates/db crates/server
git commit -m "feat(server): configurable DB pool size via --db-max-connections/DB_MAX_CONNECTIONS (#2)"
Task 3: #21 — audit vocabulary/term/authority creation
Files: crates/db/src/vocab.rs, crates/db/src/authority.rs, crates/api/src/admin_vocab.rs, crates/api/src/admin_authorities.rs; Test in crates/api/tests/admin_catalog.rs.
The three admin create paths (create_vocabulary, add_term, create_authority) take no AuditActor and write no audit entry. The catalogue object writes do — db::catalog::create_object is the template: it takes actor: AuditActor and calls audit::record(&mut *conn, &NewAuditEvent { actor, action: AuditAction::Created, entity_type, entity_id, ... }) inside the same transaction. READ create_object (crates/db/src/catalog.rs) and audit::record / NewAuditEvent (crates/db/src/audit.rs, domain::NewAuditEvent) first to copy the exact shape.
-
Step 1: Add
actor+ audit to the db functions. Each must run the insert and the audit record in one transaction (so they're atomic), mirroringcreate_object:db::vocab::create_vocabulary— currently(executor: E, key: &str). Change to(conn: &mut sqlx::PgConnection, actor: AuditActor, key: &str)(tx-connection likeadd_term), insert the vocabulary, thenaudit::record(&mut *conn, &NewAuditEvent { actor, action: Created, entity_type: "vocabulary", entity_id: <new vocab id>, ... }). Return theVocabularyas before.db::vocab::add_term— currently(conn: &mut PgConnection, new: &NewTerm). Addactor: AuditActor; after inserting the term, record an audit entry (entity_type: "term",entity_id: <term id>).db::authority::create_authority— addactor: AuditActor; record (entity_type: "authority",entity_id: <authority id>). Matchcreate_object'sNewAuditEventfield names exactly (e.g.changes/metadatamay be empty/None — copy whatevercreate_objectpasses for a creation with no field diff).
-
Step 2: Thread the actor through the handlers. In
crates/api/src/admin_vocab.rs(create_vocabulary,add_term) andcrates/api/src/admin_authorities.rs(create_authority):- Change
_auth: Authorized<EditCatalogue>→auth: Authorized<EditCatalogue>. - Build the actor as the object handlers do:
AuditActor::User(auth.user.id.to_uuid()). To avoid duplicating the helper, either makeadmin_objects::actorpub(crate)and import it, or inlineAuditActor::User(auth.user.id.to_uuid())at each site (it's a one-liner — pick the cleaner option; if you make the helper shared, take&AuthUser). create_vocabularyhandler currently callsdb::vocab::create_vocabulary(state.db.pool(), &req.key)on the pool — change it to open a transaction (let mut tx = state.db.pool().begin().await...), call the newcreate_vocabulary(&mut tx, actor, &req.key), thentx.commit()(likeadd_term's handler already does).add_term/create_authorityhandlers already use a tx — just pass the actor.
- Change
-
Step 3: Test — add to
crates/api/tests/admin_catalog.rs(it already seeds an editor + logs in). After creating a vocabulary (or term/authority) via the API, assert an audit row exists attributing the user. Usedb::audit::history_for(or a directSELECTonaudit_log) to find the entry — read the file for how existing tests inspect audit rows (the object tests likely already do this; mirror them). Minimal: create a vocabulary, then queryaudit_logforentity_type='vocabulary'with the created id and assertactor_kind='user'+ the rightactor_id. Name it e.g.creating_a_vocabulary_writes_an_audit_entry. -
Step 4: Verify.
cargo +nightly fmt;cargo clippy --workspace --all-targets;DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db. All green. Commit:
git add crates/db crates/api
git commit -m "feat: audit vocabulary/term/authority creation, attributing the acting user (#21)"
Task 4: Verification
- Step 1:
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace— all green. - Step 2:
cargo clippy --workspace --all-targetsandcargo +nightly fmt --check— clean. - Step 3:
git grep -in 'biggus\|dickus' -- crates→ none. - Step 4: Confirm
Cargo.lockis committed if any dependency/feature changed (e.g. tokiosignalfeature does not add a new lockfile entry, but verifygit statusis clean after the commits — no danglingM Cargo.lock).
Self-Review (completed)
- Spec coverage: #1 (graceful shutdown) → T1; #2 (configurable pool) → T2; #21 (audit 3 admin creates) → T3. ✓
- Placeholder scan: none — concrete code for #1/#2; #21 points at
create_object/audit::recordas the exact template to mirror (the audit-event field names live there and must match, so copying beats guessing). - Type consistency:
Db::connect(url, max: u32)updated at both call sites (run + create_user);db_max_connections: u32matchesmax_connections(u32); the three db create fns gainactor: AuditActorand the handlers passAuditActor::User(auth.user.id.to_uuid())consistently withadmin_objects::actor.
Notes
- #21 keeps within the current audit model (
AuditAction::Created+ non-nullentity_type/entity_id) — no schema change needed (the auth-event model extension is the separate #7). - Watch the
Cargo.lock: if the tokiosignalfeature pulls a new transitive crate, stage the rootCargo.lockin the same commit (don't leave it dangling).