Skip to main content

max / makenotwork

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