Skip to main content

keepsake_sqlx/repository/
audit.rs

1use std::collections::BTreeMap;
2
3use keepsake::{AuditEvent, RelationId};
4use sqlx::{Postgres, Transaction};
5use uuid::Uuid;
6
7use super::{
8    AuditCursor, AuditEventRecord, AuditEventRow, AuditOutboxCursor, AuditOutboxRecord,
9    KeepsakeRepository, RelationCache, RepositoryResult, validate_limit,
10};
11
12impl<C> KeepsakeRepository<C>
13where
14    C: RelationCache,
15{
16    /// Appends an explicit audit event without mutating lifecycle state.
17    ///
18    /// Prefer `apply` and `revoke` for lifecycle mutations so state and audit
19    /// rows commit together. This helper is for application-owned audit events
20    /// that do not have a built-in repository command.
21    pub async fn append_audit_event(&self, event: &AuditEvent) -> RepositoryResult<i64> {
22        event.subject.validate()?;
23        event.actor.validate()?;
24
25        let mut tx = self.pool.begin().await?;
26        let audit_event_id = record_audit_event_tx(&mut tx, event).await?;
27        tx.commit().await?;
28        Ok(audit_event_id)
29    }
30
31    /// Reads audit events for a keepsake in stable `(occurred_at, id)` order.
32    pub async fn audit_events_for_keepsake(
33        &self,
34        keepsake_id: Uuid,
35        after: Option<&AuditCursor>,
36        limit: i64,
37    ) -> RepositoryResult<Vec<AuditEventRecord>> {
38        let limit = validate_limit(limit)?;
39        let rows = sqlx::query_as::<_, AuditEventRow>(
40            r"
41            select id, keepsake_id, relation_id, subject_kind, subject_id, actor_kind, actor_id,
42                event_type, decision, occurred_at
43            from keepsake_audit_events
44            where keepsake_id = $1
45              and ($2::timestamptz is null or (occurred_at, id) > ($2, $3))
46            order by occurred_at, id
47            limit $4
48            ",
49        )
50        .bind(keepsake_id)
51        .bind(after.map(|cursor| cursor.occurred_at))
52        .bind(after.map(|cursor| cursor.id))
53        .bind(limit)
54        .fetch_all(&self.pool)
55        .await?;
56        self.hydrate_audit_records(rows).await
57    }
58
59    /// Reads audit events for a relation in stable `(occurred_at, id)` order.
60    pub async fn audit_events_for_relation(
61        &self,
62        relation_id: RelationId,
63        after: Option<&AuditCursor>,
64        limit: i64,
65    ) -> RepositoryResult<Vec<AuditEventRecord>> {
66        let limit = validate_limit(limit)?;
67        let rows = sqlx::query_as::<_, AuditEventRow>(
68            r"
69            select id, keepsake_id, relation_id, subject_kind, subject_id, actor_kind, actor_id,
70                event_type, decision, occurred_at
71            from keepsake_audit_events
72            where relation_id = $1
73              and ($2::timestamptz is null or (occurred_at, id) > ($2, $3))
74            order by occurred_at, id
75            limit $4
76            ",
77        )
78        .bind(relation_id)
79        .bind(after.map(|cursor| cursor.occurred_at))
80        .bind(after.map(|cursor| cursor.id))
81        .bind(limit)
82        .fetch_all(&self.pool)
83        .await?;
84        self.hydrate_audit_records(rows).await
85    }
86
87    /// Exports undelivered audit outbox rows in stable id order.
88    pub async fn audit_outbox(
89        &self,
90        after: Option<&AuditOutboxCursor>,
91        limit: i64,
92    ) -> RepositoryResult<Vec<AuditOutboxRecord>> {
93        let limit = validate_limit(limit)?;
94        let rows = sqlx::query(
95            r"
96            select id, audit_event_id, event_type, payload, claimed_by, claimed_until, delivered_at
97            from keepsake_audit_outbox
98            where delivered_at is null and ($1::bigint is null or id > $1)
99            order by id
100            limit $2
101            ",
102        )
103        .bind(after.map(|cursor| cursor.id))
104        .bind(limit)
105        .fetch_all(&self.pool)
106        .await?;
107        rows.iter().map(outbox_record_from_pg_row).collect()
108    }
109
110    /// Claims a stable batch of undelivered audit outbox rows until `lease_until`.
111    pub async fn claim_audit_outbox(
112        &self,
113        worker_id: &str,
114        now: chrono::DateTime<chrono::Utc>,
115        lease_until: chrono::DateTime<chrono::Utc>,
116        limit: i64,
117    ) -> RepositoryResult<Vec<AuditOutboxRecord>> {
118        let limit = validate_limit(limit)?;
119        let rows = sqlx::query(
120            r"
121            with claimable as (
122              select id
123              from keepsake_audit_outbox
124              where delivered_at is null
125                and (claimed_until is null or claimed_until <= $3)
126              order by id
127              limit $4
128              for update skip locked
129            ),
130            updated as (
131              update keepsake_audit_outbox
132              set claimed_by = $1, claimed_until = $2
133              where id in (select id from claimable)
134              returning id, audit_event_id, event_type, payload, claimed_by, claimed_until, delivered_at
135            )
136            select id, audit_event_id, event_type, payload, claimed_by, claimed_until, delivered_at
137            from updated
138            order by id
139            ",
140        )
141        .bind(worker_id)
142        .bind(lease_until)
143        .bind(now)
144        .bind(limit)
145        .fetch_all(&self.pool)
146        .await?;
147        rows.iter().map(outbox_record_from_pg_row).collect()
148    }
149
150    /// Acknowledges delivery of a claimed outbox row.
151    pub async fn ack_audit_outbox(
152        &self,
153        outbox_id: i64,
154        delivered_at: chrono::DateTime<chrono::Utc>,
155    ) -> RepositoryResult<bool> {
156        let result = sqlx::query(
157            r"
158            update keepsake_audit_outbox
159            set delivered_at = $2, claimed_by = null, claimed_until = null
160            where id = $1 and delivered_at is null and claimed_by is not null
161            ",
162        )
163        .bind(outbox_id)
164        .bind(delivered_at)
165        .execute(&self.pool)
166        .await?;
167        Ok(result.rows_affected() == 1)
168    }
169
170    /// Releases a claimed outbox row for another worker to retry.
171    pub async fn release_audit_outbox(&self, outbox_id: i64) -> RepositoryResult<bool> {
172        let result = sqlx::query(
173            r"
174            update keepsake_audit_outbox
175            set claimed_by = null, claimed_until = null
176            where id = $1 and delivered_at is null and claimed_by is not null
177            ",
178        )
179        .bind(outbox_id)
180        .execute(&self.pool)
181        .await?;
182        Ok(result.rows_affected() == 1)
183    }
184
185    async fn hydrate_audit_records(
186        &self,
187        rows: Vec<AuditEventRow>,
188    ) -> RepositoryResult<Vec<AuditEventRecord>> {
189        if rows.is_empty() {
190            return Ok(Vec::new());
191        }
192        let ids = rows.iter().map(|row| row.id).collect::<Vec<i64>>();
193        let attribute_rows = sqlx::query_as::<_, (i64, String, String)>(
194            r"
195            select audit_event_id, key, value
196            from keepsake_audit_context_attributes
197            where audit_event_id = any($1)
198            ",
199        )
200        .bind(&ids)
201        .fetch_all(&self.pool)
202        .await?;
203        let mut attributes = BTreeMap::<i64, BTreeMap<String, String>>::new();
204        for (event_id, key, value) in attribute_rows {
205            attributes.entry(event_id).or_default().insert(key, value);
206        }
207        rows.into_iter()
208            .map(|row| {
209                let id = row.id;
210                row.into_record(attributes.remove(&id).unwrap_or_default())
211            })
212            .collect()
213    }
214}
215
216fn outbox_record_from_pg_row(row: &sqlx::postgres::PgRow) -> RepositoryResult<AuditOutboxRecord> {
217    use sqlx::Row;
218
219    let payload = serde_json::from_value::<AuditEvent>(row.try_get("payload")?)?;
220    Ok(AuditOutboxRecord {
221        id: row.try_get("id")?,
222        audit_event_id: row.try_get("audit_event_id")?,
223        event_type: row.try_get("event_type")?,
224        payload,
225        claimed_by: row.try_get("claimed_by")?,
226        claimed_until: row.try_get("claimed_until")?,
227        delivered_at: row.try_get("delivered_at")?,
228    })
229}
230
231pub(super) async fn record_audit_event_tx(
232    tx: &mut Transaction<'_, Postgres>,
233    event: &AuditEvent,
234) -> RepositoryResult<i64> {
235    let decision = serde_json::to_value(&event.decision)?;
236    let audit_event_id = sqlx::query_scalar::<_, i64>(
237        r"
238        insert into keepsake_audit_events
239            (keepsake_id, relation_id, subject_kind, subject_id, actor_kind, actor_id,
240             event_type, decision, occurred_at)
241        values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
242        returning id
243        ",
244    )
245    .bind(event.keepsake_id)
246    .bind(event.relation_id)
247    .bind(event.subject.kind())
248    .bind(event.subject.id())
249    .bind(event.actor.kind())
250    .bind(event.actor.id())
251    .bind(event.event_type.as_str())
252    .bind(decision)
253    .bind(event.at)
254    .fetch_one(&mut **tx)
255    .await?;
256
257    record_audit_outbox_tx(tx, audit_event_id, event).await?;
258
259    if event.context.attributes.is_empty() {
260        return Ok(audit_event_id);
261    }
262
263    let keys = event.context.attributes.keys().cloned().collect::<Vec<_>>();
264    let values = event
265        .context
266        .attributes
267        .values()
268        .cloned()
269        .collect::<Vec<_>>();
270    sqlx::query(
271        r"
272        insert into keepsake_audit_context_attributes (audit_event_id, key, value)
273        select $1, attribute.key, attribute.value
274        from unnest($2::text[], $3::text[]) as attribute(key, value)
275        ",
276    )
277    .bind(audit_event_id)
278    .bind(&keys)
279    .bind(&values)
280    .execute(&mut **tx)
281    .await?;
282
283    Ok(audit_event_id)
284}
285
286async fn record_audit_outbox_tx(
287    tx: &mut Transaction<'_, Postgres>,
288    audit_event_id: i64,
289    event: &AuditEvent,
290) -> RepositoryResult<()> {
291    sqlx::query(
292        r"
293        insert into keepsake_audit_outbox (audit_event_id, payload)
294        values ($1, $2)
295        ",
296    )
297    .bind(audit_event_id)
298    .bind(serde_json::to_value(event)?)
299    .execute(&mut **tx)
300    .await?;
301    Ok(())
302}