From 984be697acd895065a5ae6b59eb17350b5fddb2c Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 21:54:50 +0200 Subject: [PATCH] feat: audit vocabulary/term/authority creation, attributing the acting user (#21) Co-Authored-By: Claude Sonnet 4.6 --- crates/api/src/admin_authorities.rs | 11 +++--- crates/api/src/admin_vocab.rs | 40 ++++++++++++++------- crates/api/tests/admin_catalog.rs | 45 +++++++++++++++++++++-- crates/db/src/authority.rs | 25 +++++++++++-- crates/db/src/seed.rs | 10 ++++-- crates/db/src/vocab.rs | 56 +++++++++++++++++++++++------ crates/db/tests/authority.rs | 18 ++++++---- crates/db/tests/fields.rs | 6 ++-- crates/db/tests/object_fields.rs | 15 ++++++-- crates/db/tests/vocab.rs | 31 +++++++++++----- crates/search/tests/reindex.rs | 7 ++-- 11 files changed, 207 insertions(+), 57 deletions(-) diff --git a/crates/api/src/admin_authorities.rs b/crates/api/src/admin_authorities.rs index 58fc79d..22e1e45 100644 --- a/crates/api/src/admin_authorities.rs +++ b/crates/api/src/admin_authorities.rs @@ -7,7 +7,7 @@ use axum::{ http::StatusCode, routing::get, }; -use domain::{AuthorityKind, LocalizedLabel, NewAuthority}; +use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -91,7 +91,7 @@ pub(crate) async fn list_authorities( ) )] pub(crate) async fn create_authority( - _auth: Authorized, + auth: Authorized, State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), StatusCode> { @@ -117,9 +117,10 @@ pub(crate) async fn create_authority( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let id = db::authority::create_authority(&mut tx, &new) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let id = + db::authority::create_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; tx.commit() .await diff --git a/crates/api/src/admin_vocab.rs b/crates/api/src/admin_vocab.rs index 18a2e71..0b714e4 100644 --- a/crates/api/src/admin_vocab.rs +++ b/crates/api/src/admin_vocab.rs @@ -7,7 +7,7 @@ use axum::{ http::StatusCode, routing::get, }; -use domain::{LocalizedLabel, NewTerm, VocabularyId}; +use domain::{AuditActor, LocalizedLabel, NewTerm, VocabularyId}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -85,11 +85,23 @@ pub(crate) async fn list_vocabularies( ) )] pub(crate) async fn create_vocabulary( - _auth: Authorized, + auth: Authorized, State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), StatusCode> { - let vocab = db::vocab::create_vocabulary(state.db.pool(), &req.key) + let mut tx = state + .db + .pool() + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let vocab = + db::vocab::create_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &req.key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -156,7 +168,7 @@ pub(crate) async fn list_terms( ) )] pub(crate) async fn add_term( - _auth: Authorized, + auth: Authorized, State(state): State, Path(id): Path, Json(req): Json, @@ -185,15 +197,17 @@ pub(crate) async fn add_term( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let term_id = db::vocab::add_term(&mut tx, &new).await.map_err(|err| { - // A well-formed id for a missing vocabulary hits the FK constraint (23503). - if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") { - StatusCode::NOT_FOUND - } else { - tracing::error!(?err, "adding term"); - StatusCode::INTERNAL_SERVER_ERROR - } - })?; + let term_id = db::vocab::add_term(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new) + .await + .map_err(|err| { + // A well-formed id for a missing vocabulary hits the FK constraint (23503). + if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") { + StatusCode::NOT_FOUND + } else { + tracing::error!(?err, "adding term"); + StatusCode::INTERNAL_SERVER_ERROR + } + })?; tx.commit() .await diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs index d9e4594..413f098 100644 --- a/crates/api/tests/admin_catalog.rs +++ b/crates/api/tests/admin_catalog.rs @@ -1,8 +1,8 @@ use api::{AppState, build_app, migrate_sessions}; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use db::users; -use domain::{AuditActor, Email, NewUser, Role}; +use db::{audit, users}; +use domain::{AuditAction, AuditActor, Email, NewUser, Role}; use http_body_util::BodyExt; use sqlx::PgPool; use tower::ServiceExt; @@ -290,3 +290,44 @@ async fn add_term_to_missing_vocabulary_is_404(pool: PgPool) { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +#[sqlx::test(migrations = "../db/migrations")] +async fn creating_a_vocabulary_writes_an_audit_entry(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + + let app = build_app(state(pool.clone())); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/vocabularies") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"key":"audit-test"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + let body: serde_json::Value = + serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let vocab_id: uuid::Uuid = body["id"].as_str().unwrap().parse().unwrap(); + + let history = audit::history_for(&pool, "vocabulary", vocab_id) + .await + .unwrap(); + + assert_eq!(history.len(), 1); + assert_eq!(history[0].action, AuditAction::Created); + assert!( + matches!(history[0].actor, AuditActor::User(_)), + "expected actor to be a user" + ); +} diff --git a/crates/db/src/authority.rs b/crates/db/src/authority.rs index 311288f..2ee78d5 100644 --- a/crates/db/src/authority.rs +++ b/crates/db/src/authority.rs @@ -1,16 +1,23 @@ //! Authority records (person / organisation / place). -use domain::{Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel, NewAuthority}; +use domain::{ + AuditAction, AuditActor, Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel, + NewAuditEvent, NewAuthority, +}; use sqlx::Row; +use crate::audit; + /// Labels aggregated per row as JSON, to read an authority and its labels in one query. const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', al.lang, 'label', al.label) \ ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)"; -/// Insert an authority and its labels. Multiple statements — pass a transaction -/// connection (`&mut *tx`) for atomicity. +/// Insert an authority and its labels, then record a `created` audit entry. Multiple +/// statements — pass a transaction connection (`&mut *tx`) so everything commits +/// atomically. pub async fn create_authority( conn: &mut sqlx::PgConnection, + actor: AuditActor, new: &NewAuthority, ) -> Result { let id = AuthorityId::new(); @@ -31,6 +38,18 @@ pub async fn create_authority( .await?; } + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Created, + entity_type: "authority".to_owned(), + entity_id: id.to_uuid(), + changes: Vec::new(), + }, + ) + .await?; + Ok(id) } diff --git a/crates/db/src/seed.rs b/crates/db/src/seed.rs index 9c5febf..100e4a6 100644 --- a/crates/db/src/seed.rs +++ b/crates/db/src/seed.rs @@ -5,7 +5,9 @@ //! populated by the organization or a later import. The inventory-minimum fields //! (object number, name, location, …) live in the typed object core, not here. -use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId}; +use domain::{ + AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId, +}; use crate::{fields, vocab}; @@ -119,7 +121,11 @@ async fn ensure_vocabulary( if let Some(existing) = vocab::vocabulary_by_key(&mut *conn, key).await? { Ok(existing.id) } else { - Ok(vocab::create_vocabulary(&mut *conn, key).await?.id) + Ok( + vocab::create_vocabulary(&mut *conn, AuditActor::System, key) + .await? + .id, + ) } } diff --git a/crates/db/src/vocab.rs b/crates/db/src/vocab.rs index 6806296..660b8bb 100644 --- a/crates/db/src/vocab.rs +++ b/crates/db/src/vocab.rs @@ -1,25 +1,44 @@ //! Controlled vocabularies and terms. -use domain::{LocalizedLabel, NewTerm, Term, TermId, TermRef, Vocabulary, VocabularyId}; +use domain::{ + AuditAction, AuditActor, LocalizedLabel, NewAuditEvent, NewTerm, Term, TermId, TermRef, + Vocabulary, VocabularyId, +}; use sqlx::Row; +use crate::audit; + /// Labels aggregated per row as JSON, to read a term and its labels in one query. const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', tl.lang, 'label', tl.label) \ ORDER BY tl.lang) FILTER (WHERE tl.term_id IS NOT NULL), '[]'::json)"; -/// Create a vocabulary with the given key. -pub async fn create_vocabulary<'e, E>(executor: E, key: &str) -> Result -where - E: sqlx::PgExecutor<'e>, -{ +/// Create a vocabulary with the given key and record a `created` audit entry, both on +/// `conn` (pass a transaction connection `&mut *tx` so they commit atomically). +pub async fn create_vocabulary( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + key: &str, +) -> Result { let id = VocabularyId::new(); sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)") .bind(id.to_uuid()) .bind(key) - .execute(executor) + .execute(&mut *conn) .await?; + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Created, + entity_type: "vocabulary".to_owned(), + entity_id: id.to_uuid(), + changes: Vec::new(), + }, + ) + .await?; + Ok(Vocabulary { id, key: key.to_owned(), @@ -54,9 +73,14 @@ where row.map(map_vocabulary).transpose() } -/// Insert a term and its labels. Multiple statements — pass a transaction -/// connection (`&mut *tx`) so the term and its labels commit atomically. -pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result { +/// Insert a term and its labels, then record a `created` audit entry. Multiple +/// statements — pass a transaction connection (`&mut *tx`) so everything commits +/// atomically. +pub async fn add_term( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + new: &NewTerm, +) -> Result { let id = TermId::new(); sqlx::query("INSERT INTO term (id, vocabulary_id, external_uri) VALUES ($1, $2, $3)") @@ -75,6 +99,18 @@ pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result NewAuthority { @@ -24,9 +24,13 @@ async fn authority_round_trips_with_labels(pool: PgPool) { let db = Db::from_pool(pool); let mut tx = db.pool().begin().await.unwrap(); - let id = authority::create_authority(&mut tx, &new_person("Carl Larsson", "Carl Larsson")) - .await - .unwrap(); + let id = authority::create_authority( + &mut tx, + AuditActor::System, + &new_person("Carl Larsson", "Carl Larsson"), + ) + .await + .unwrap(); tx.commit().await.unwrap(); let got = authority::authority_by_id(db.pool(), id) @@ -47,11 +51,12 @@ async fn list_by_kind_filters(pool: PgPool) { let db = Db::from_pool(pool); let mut tx = db.pool().begin().await.unwrap(); - authority::create_authority(&mut tx, &new_person("A", "A")) + authority::create_authority(&mut tx, AuditActor::System, &new_person("A", "A")) .await .unwrap(); authority::create_authority( &mut tx, + AuditActor::System, &NewAuthority { kind: AuthorityKind::Place, external_uri: None, @@ -83,7 +88,7 @@ async fn resolve_authority_returns_kind(pool: PgPool) { let db = Db::from_pool(pool); let mut tx = db.pool().begin().await.unwrap(); - let id = authority::create_authority(&mut tx, &new_person("X", "X")) + let id = authority::create_authority(&mut tx, AuditActor::System, &new_person("X", "X")) .await .unwrap(); tx.commit().await.unwrap(); @@ -108,6 +113,7 @@ async fn authority_with_no_labels_round_trips_empty(pool: PgPool) { let mut tx = db.pool().begin().await.unwrap(); let id = authority::create_authority( &mut tx, + AuditActor::System, &NewAuthority { kind: AuthorityKind::Organisation, external_uri: None, diff --git a/crates/db/tests/fields.rs b/crates/db/tests/fields.rs index 0597979..faf8ecf 100644 --- a/crates/db/tests/fields.rs +++ b/crates/db/tests/fields.rs @@ -1,5 +1,5 @@ use db::{Db, fields, vocab}; -use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition}; +use domain::{AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition}; use sqlx::PgPool; fn labels() -> Vec { @@ -52,9 +52,11 @@ async fn text_field_round_trips(pool: PgPool) { #[sqlx::test] async fn term_and_authority_fields_round_trip_their_binding(pool: PgPool) { let db = Db::from_pool(pool); - let material = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); + tx.commit().await.unwrap(); let mut tx = db.pool().begin().await.unwrap(); fields::create_field_definition( diff --git a/crates/db/tests/object_fields.rs b/crates/db/tests/object_fields.rs index 8d8a24f..1092fd2 100644 --- a/crates/db/tests/object_fields.rs +++ b/crates/db/tests/object_fields.rs @@ -95,9 +95,12 @@ async fn sets_scalar_fields_and_audits(pool: PgPool) { async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) { let db = Db::from_pool(pool); let id = setup_object(&db).await; - let material = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); + tx.commit().await.unwrap(); + define( &db, "material", @@ -110,6 +113,7 @@ async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) { let mut tx = db.pool().begin().await.unwrap(); let wood = vocab::add_term( &mut tx, + AuditActor::System, &domain::NewTerm { vocabulary_id: material.id, external_uri: None, @@ -180,6 +184,7 @@ async fn authority_field_enforces_kind(pool: PgPool) { let mut tx = db.pool().begin().await.unwrap(); let person = db::authority::create_authority( &mut tx, + AuditActor::System, &domain::NewAuthority { kind: domain::AuthorityKind::Person, external_uri: None, @@ -190,6 +195,7 @@ async fn authority_field_enforces_kind(pool: PgPool) { .unwrap(); let place = db::authority::create_authority( &mut tx, + AuditActor::System, &domain::NewAuthority { kind: domain::AuthorityKind::Place, external_uri: None, @@ -219,12 +225,14 @@ async fn authority_field_enforces_kind(pool: PgPool) { async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) { let db = Db::from_pool(pool); let id = setup_object(&db).await; - let material = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); - let technique = vocab::create_vocabulary(db.pool(), "technique") + let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique") .await .unwrap(); + tx.commit().await.unwrap(); define( &db, "material", @@ -238,6 +246,7 @@ async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) { let mut tx = db.pool().begin().await.unwrap(); let other = vocab::add_term( &mut tx, + AuditActor::System, &domain::NewTerm { vocabulary_id: technique.id, external_uri: None, diff --git a/crates/db/tests/vocab.rs b/crates/db/tests/vocab.rs index dfaed97..d3edabb 100644 --- a/crates/db/tests/vocab.rs +++ b/crates/db/tests/vocab.rs @@ -1,13 +1,15 @@ use db::{Db, vocab}; -use domain::{LocalizedLabel, NewTerm}; +use domain::{AuditActor, LocalizedLabel, NewTerm}; use sqlx::PgPool; #[sqlx::test] async fn vocabulary_create_and_lookup(pool: PgPool) { let db = Db::from_pool(pool); - let v = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); + tx.commit().await.unwrap(); let found = vocab::vocabulary_by_key(db.pool(), "material") .await @@ -27,13 +29,16 @@ async fn vocabulary_create_and_lookup(pool: PgPool) { #[sqlx::test] async fn term_with_multilingual_labels_round_trips(pool: PgPool) { let db = Db::from_pool(pool); - let v = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); + tx.commit().await.unwrap(); let mut tx = db.pool().begin().await.unwrap(); let term_id = vocab::add_term( &mut tx, + AuditActor::System, &NewTerm { vocabulary_id: v.id, external_uri: Some("http://vocab.getty.edu/aat/300011914".into()), @@ -76,13 +81,16 @@ async fn term_with_multilingual_labels_round_trips(pool: PgPool) { #[sqlx::test] async fn term_with_no_labels_round_trips_empty(pool: PgPool) { let db = Db::from_pool(pool); - let v = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); + tx.commit().await.unwrap(); let mut tx = db.pool().begin().await.unwrap(); let term_id = vocab::add_term( &mut tx, + AuditActor::System, &NewTerm { vocabulary_id: v.id, external_uri: None, @@ -103,10 +111,14 @@ async fn term_with_no_labels_round_trips_empty(pool: PgPool) { #[sqlx::test] async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) { let db = Db::from_pool(pool); - vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); - let err = vocab::create_vocabulary(db.pool(), "material") + tx.commit().await.unwrap(); + + let mut tx = db.pool().begin().await.unwrap(); + let err = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap_err(); assert!( @@ -118,16 +130,19 @@ async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) { #[sqlx::test] async fn resolve_term_checks_vocabulary_membership(pool: PgPool) { let db = Db::from_pool(pool); - let material = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); - let technique = vocab::create_vocabulary(db.pool(), "technique") + let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique") .await .unwrap(); + tx.commit().await.unwrap(); let mut tx = db.pool().begin().await.unwrap(); let term_id = vocab::add_term( &mut tx, + AuditActor::System, &NewTerm { vocabulary_id: material.id, external_uri: None, diff --git a/crates/search/tests/reindex.rs b/crates/search/tests/reindex.rs index 6f2be2f..8b0d493 100644 --- a/crates/search/tests/reindex.rs +++ b/crates/search/tests/reindex.rs @@ -23,14 +23,15 @@ async fn reindex_resolves_term_labels_and_finds_by_label(pool: PgPool) { let db = Db::from_pool(pool); // a material vocabulary with a "wood" term - let material = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + + let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); - let mut tx = db.pool().begin().await.unwrap(); - let wood = vocab::add_term( &mut tx, + AuditActor::System, &NewTerm { vocabulary_id: material.id, external_uri: None,