keepsake_sqlx/repository/
audit.rs1use 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 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 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 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 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 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 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 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}