feat: audit vocabulary/term/authority creation, attributing the acting user (#21)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 21:54:50 +02:00
parent 7181437625
commit 984be697ac
11 changed files with 207 additions and 57 deletions
+22 -3
View File
@@ -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<AuthorityId, sqlx::Error> {
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)
}
+8 -2
View File
@@ -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,
)
}
}
+46 -10
View File
@@ -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<Vocabulary, sqlx::Error>
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<Vocabulary, sqlx::Error> {
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<TermId, sqlx::Error> {
/// 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<TermId, sqlx::Error> {
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<Te
.await?;
}
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Created,
entity_type: "term".to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(id)
}