Skip to main content

max / makenotwork

8.7 KB · 295 lines History Blame Raw
1 //! Audit log for admin scan-pipeline actions.
2 //!
3 //! Every promote / quarantine / rescan from the `/admin/uploads` dashboard
4 //! writes one row here. Bulk operations write one row per affected target
5 //! (the `action` column distinguishes per-row from bulk). See
6 //! `docs/scan-pipeline-audit.md` ยง 5.2 (Audit Trail).
7
8 use chrono::{DateTime, Utc};
9 use sqlx::{FromRow, PgPool};
10 use uuid::Uuid;
11
12 use super::{ItemId, UserId, VersionId};
13
14 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
15 pub(crate) enum AdminAction {
16 Promote,
17 Quarantine,
18 Rescan,
19 /// Reserved for the Phase 2b bulk-promote action; not yet wired into routes.
20 #[allow(dead_code)]
21 BulkPromote,
22 BulkRescan,
23 }
24
25 impl AdminAction {
26 pub(crate) fn as_str(self) -> &'static str {
27 match self {
28 AdminAction::Promote => "promote",
29 AdminAction::Quarantine => "quarantine",
30 AdminAction::Rescan => "rescan",
31 AdminAction::BulkPromote => "bulk_promote",
32 AdminAction::BulkRescan => "bulk_rescan",
33 }
34 }
35 }
36
37 /// An audit-log row. Either `version_id` or `item_id` is populated.
38 #[allow(dead_code)]
39 #[derive(Debug, Clone, FromRow)]
40 pub(crate) struct ScanAdminActionRow {
41 pub id: Uuid,
42 pub version_id: Option<Uuid>,
43 pub item_id: Option<Uuid>,
44 pub admin_id: UserId,
45 pub action: String,
46 pub prev_status: Option<String>,
47 pub new_status: Option<String>,
48 pub note: Option<String>,
49 pub created_at: DateTime<Utc>,
50 }
51
52 /// Log an admin action against a version.
53 #[tracing::instrument(skip_all, fields(%version_id, %admin_id, action = action.as_str()))]
54 pub(crate) async fn log_version(
55 db: &PgPool,
56 version_id: VersionId,
57 admin_id: UserId,
58 action: AdminAction,
59 prev_status: Option<&str>,
60 new_status: Option<&str>,
61 note: Option<&str>,
62 ) -> Result<(), sqlx::Error> {
63 sqlx::query(
64 r"
65 INSERT INTO scan_admin_actions (version_id, admin_id, action, prev_status, new_status, note)
66 VALUES ($1, $2, $3, $4, $5, $6)
67 ",
68 )
69 .bind(*version_id.as_uuid())
70 .bind(admin_id)
71 .bind(action.as_str())
72 .bind(prev_status)
73 .bind(new_status)
74 .bind(note)
75 .execute(db)
76 .await?;
77 Ok(())
78 }
79
80 /// Log an admin action against an item.
81 #[tracing::instrument(skip_all, fields(%item_id, %admin_id, action = action.as_str()))]
82 pub(crate) async fn log_item(
83 db: &PgPool,
84 item_id: ItemId,
85 admin_id: UserId,
86 action: AdminAction,
87 prev_status: Option<&str>,
88 new_status: Option<&str>,
89 note: Option<&str>,
90 ) -> Result<(), sqlx::Error> {
91 sqlx::query(
92 r"
93 INSERT INTO scan_admin_actions (item_id, admin_id, action, prev_status, new_status, note)
94 VALUES ($1, $2, $3, $4, $5, $6)
95 ",
96 )
97 .bind(*item_id.as_uuid())
98 .bind(admin_id)
99 .bind(action.as_str())
100 .bind(prev_status)
101 .bind(new_status)
102 .bind(note)
103 .execute(db)
104 .await?;
105 Ok(())
106 }
107
108 /// Brief "last action" summary attached to a held row inline.
109 #[derive(Debug, Clone, FromRow)]
110 pub(crate) struct LastActionSummary {
111 pub action: String,
112 pub admin_username: String,
113 pub created_at: DateTime<Utc>,
114 }
115
116 /// Latest admin action recorded against each version_id in the given set.
117 /// Returns a map keyed by version_id string. Empty input returns empty map.
118 pub(crate) async fn latest_per_version(
119 db: &PgPool,
120 version_ids: &[Uuid],
121 ) -> Result<std::collections::HashMap<Uuid, LastActionSummary>, sqlx::Error> {
122 if version_ids.is_empty() {
123 return Ok(std::collections::HashMap::new());
124 }
125 let rows = sqlx::query(
126 r"
127 SELECT DISTINCT ON (saa.version_id)
128 saa.version_id, saa.action, saa.created_at, u.username AS admin_username
129 FROM scan_admin_actions saa
130 JOIN users u ON u.id = saa.admin_id
131 WHERE saa.version_id = ANY($1)
132 ORDER BY saa.version_id, saa.created_at DESC
133 ",
134 )
135 .bind(version_ids)
136 .fetch_all(db)
137 .await?;
138
139 use sqlx::Row;
140 let mut out = std::collections::HashMap::with_capacity(rows.len());
141 for row in rows {
142 let id: Uuid = row.try_get("version_id")?;
143 out.insert(
144 id,
145 LastActionSummary {
146 action: row.try_get("action")?,
147 admin_username: row.try_get("admin_username")?,
148 created_at: row.try_get("created_at")?,
149 },
150 );
151 }
152 Ok(out)
153 }
154
155 /// Latest admin action per item_id in the given set.
156 pub(crate) async fn latest_per_item(
157 db: &PgPool,
158 item_ids: &[Uuid],
159 ) -> Result<std::collections::HashMap<Uuid, LastActionSummary>, sqlx::Error> {
160 if item_ids.is_empty() {
161 return Ok(std::collections::HashMap::new());
162 }
163 let rows = sqlx::query(
164 r"
165 SELECT DISTINCT ON (saa.item_id)
166 saa.item_id, saa.action, saa.created_at, u.username AS admin_username
167 FROM scan_admin_actions saa
168 JOIN users u ON u.id = saa.admin_id
169 WHERE saa.item_id = ANY($1)
170 ORDER BY saa.item_id, saa.created_at DESC
171 ",
172 )
173 .bind(item_ids)
174 .fetch_all(db)
175 .await?;
176
177 use sqlx::Row;
178 let mut out = std::collections::HashMap::with_capacity(rows.len());
179 for row in rows {
180 let id: Uuid = row.try_get("item_id")?;
181 out.insert(
182 id,
183 LastActionSummary {
184 action: row.try_get("action")?,
185 admin_username: row.try_get("admin_username")?,
186 created_at: row.try_get("created_at")?,
187 },
188 );
189 }
190 Ok(out)
191 }
192
193 /// Audit-log row joined with the actor's username, for the audit page.
194 #[derive(Debug, Clone, FromRow)]
195 pub struct AuditLogRow {
196 pub version_id: Option<Uuid>,
197 pub item_id: Option<Uuid>,
198 pub admin_username: String,
199 pub action: String,
200 pub prev_status: Option<String>,
201 pub new_status: Option<String>,
202 pub note: Option<String>,
203 pub created_at: DateTime<Utc>,
204 }
205
206 /// Recent audit entries joined with the admin's username, newest first.
207 /// Phase 2b consumer for the audit-log page. Superseded by `list_filtered`
208 /// for the live route; retained as a simpler no-filter accessor for tests +
209 /// future read-only consumers.
210 #[allow(dead_code)]
211 pub(crate) async fn list_recent_with_admin(
212 db: &PgPool,
213 limit: i64,
214 ) -> Result<Vec<AuditLogRow>, sqlx::Error> {
215 sqlx::query_as::<_, AuditLogRow>(
216 r"
217 SELECT saa.version_id, saa.item_id, u.username AS admin_username,
218 saa.action, saa.prev_status, saa.new_status, saa.note, saa.created_at
219 FROM scan_admin_actions saa
220 JOIN users u ON u.id = saa.admin_id
221 ORDER BY saa.created_at DESC
222 LIMIT $1
223 ",
224 )
225 .bind(limit)
226 .fetch_all(db)
227 .await
228 }
229
230 /// Filtered audit entries for the dashboard `/admin/uploads/audit` page.
231 /// All filters are optional; a `None` for any field means no constraint on
232 /// that column. Newest first, capped at `limit`.
233 #[allow(clippy::too_many_arguments)]
234 pub(crate) async fn list_filtered(
235 db: &PgPool,
236 action: Option<&str>,
237 admin_username: Option<&str>,
238 since_days: Option<i64>,
239 limit: i64,
240 ) -> Result<Vec<AuditLogRow>, sqlx::Error> {
241 sqlx::query_as::<_, AuditLogRow>(
242 r"
243 SELECT saa.version_id, saa.item_id, u.username AS admin_username,
244 saa.action, saa.prev_status, saa.new_status, saa.note, saa.created_at
245 FROM scan_admin_actions saa
246 JOIN users u ON u.id = saa.admin_id
247 WHERE ($1::TEXT IS NULL OR saa.action = $1)
248 AND ($2::TEXT IS NULL OR u.username = $2)
249 AND ($3::BIGINT IS NULL OR saa.created_at > NOW() - ($3 || ' days')::interval)
250 ORDER BY saa.created_at DESC
251 LIMIT $4
252 ",
253 )
254 .bind(action)
255 .bind(admin_username)
256 .bind(since_days)
257 .bind(limit)
258 .fetch_all(db)
259 .await
260 }
261
262 /// Recent audit entries for the full-log page. Phase 2 surface.
263 #[allow(dead_code)]
264 pub(crate) async fn list_recent(
265 db: &PgPool,
266 limit: i64,
267 ) -> Result<Vec<ScanAdminActionRow>, sqlx::Error> {
268 sqlx::query_as::<_, ScanAdminActionRow>(
269 r"
270 SELECT id, version_id, item_id, admin_id, action,
271 prev_status, new_status, note, created_at
272 FROM scan_admin_actions
273 ORDER BY created_at DESC
274 LIMIT $1
275 ",
276 )
277 .bind(limit)
278 .fetch_all(db)
279 .await
280 }
281
282 #[cfg(test)]
283 mod tests {
284 use super::*;
285
286 #[test]
287 fn admin_action_as_str() {
288 assert_eq!(AdminAction::Promote.as_str(), "promote");
289 assert_eq!(AdminAction::Quarantine.as_str(), "quarantine");
290 assert_eq!(AdminAction::Rescan.as_str(), "rescan");
291 assert_eq!(AdminAction::BulkPromote.as_str(), "bulk_promote");
292 assert_eq!(AdminAction::BulkRescan.as_str(), "bulk_rescan");
293 }
294 }
295