feat(db): schema bootstrap with append-only audit_log table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@ rust-version = "1.85"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "macros"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "macros", "time", "json"] }
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Append-only audit log. One database == one organization, so there is no org_id.
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
seq BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user', 'system')),
|
||||||
|
actor_id UUID,
|
||||||
|
action TEXT NOT NULL CHECK (action IN ('created', 'updated', 'deleted')),
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id UUID NOT NULL,
|
||||||
|
changes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
CONSTRAINT actor_id_matches_kind CHECK (
|
||||||
|
(actor_kind = 'user' AND actor_id IS NOT NULL) OR
|
||||||
|
(actor_kind = 'system' AND actor_id IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX audit_log_entity_idx ON audit_log (entity_type, entity_id, seq);
|
||||||
|
|
||||||
|
-- Enforce append-only at the database level: reject any UPDATE or DELETE.
|
||||||
|
CREATE OR REPLACE FUNCTION audit_log_reject_mutation() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'audit_log is append-only; % is not permitted', TG_OP;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER audit_log_immutable
|
||||||
|
BEFORE UPDATE OR DELETE ON audit_log
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION audit_log_reject_mutation();
|
||||||
@@ -37,4 +37,13 @@ impl Db {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply all pending schema migrations (embedded at compile time).
|
||||||
|
///
|
||||||
|
/// Pre-1.0 the migration files are rewritten freely and dev databases are
|
||||||
|
/// recreated; this is the schema-bootstrap mechanism, not forward-migration
|
||||||
|
/// discipline.
|
||||||
|
pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> {
|
||||||
|
sqlx::migrate!().run(&self.pool).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use db::Db;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn migrate_is_idempotent_and_creates_audit_log(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
|
// sqlx::test already applied migrations to this temp DB; re-running must be a
|
||||||
|
// no-op success (idempotent).
|
||||||
|
db.migrate()
|
||||||
|
.await
|
||||||
|
.expect("re-running migrate is idempotent");
|
||||||
|
|
||||||
|
let regclass: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT to_regclass('public.audit_log')::text")
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(regclass.as_deref(), Some("audit_log"));
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
|
|||||||
let db = Db::connect(&config.database_url)
|
let db = Db::connect(&config.database_url)
|
||||||
.await
|
.await
|
||||||
.context("connecting to the database")?;
|
.context("connecting to the database")?;
|
||||||
|
|
||||||
|
db.migrate().await.context("running database migrations")?;
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
app_name: config.app_name.clone(),
|
app_name: config.app_name.clone(),
|
||||||
|
|||||||
Reference in New Issue
Block a user