Skip to main content

max / makenotwork

8.5 KB · 283 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 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 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 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 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 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 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 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(id, LastActionSummary {
144 action: row.try_get("action")?,
145 admin_username: row.try_get("admin_username")?,
146 created_at: row.try_get("created_at")?,
147 });
148 }
149 Ok(out)
150 }
151
152 /// Latest admin action per item_id in the given set.
153 pub async fn latest_per_item(
154 db: &PgPool,
155 item_ids: &[Uuid],
156 ) -> Result<std::collections::HashMap<Uuid, LastActionSummary>, sqlx::Error> {
157 if item_ids.is_empty() {
158 return Ok(std::collections::HashMap::new());
159 }
160 let rows = sqlx::query(
161 r#"
162 SELECT DISTINCT ON (saa.item_id)
163 saa.item_id, saa.action, saa.created_at, u.username AS admin_username
164 FROM scan_admin_actions saa
165 JOIN users u ON u.id = saa.admin_id
166 WHERE saa.item_id = ANY($1)
167 ORDER BY saa.item_id, saa.created_at DESC
168 "#,
169 )
170 .bind(item_ids)
171 .fetch_all(db)
172 .await?;
173
174 use sqlx::Row;
175 let mut out = std::collections::HashMap::with_capacity(rows.len());
176 for row in rows {
177 let id: Uuid = row.try_get("item_id")?;
178 out.insert(id, LastActionSummary {
179 action: row.try_get("action")?,
180 admin_username: row.try_get("admin_username")?,
181 created_at: row.try_get("created_at")?,
182 });
183 }
184 Ok(out)
185 }
186
187 /// Audit-log row joined with the actor's username, for the audit page.
188 #[derive(Debug, Clone, FromRow)]
189 pub struct AuditLogRow {
190 pub version_id: Option<Uuid>,
191 pub item_id: Option<Uuid>,
192 pub admin_username: String,
193 pub action: String,
194 pub prev_status: Option<String>,
195 pub new_status: Option<String>,
196 pub note: Option<String>,
197 pub created_at: DateTime<Utc>,
198 }
199
200 /// Recent audit entries joined with the admin's username, newest first.
201 /// Phase 2b consumer for the audit-log page. Superseded by `list_filtered`
202 /// for the live route; retained as a simpler no-filter accessor for tests +
203 /// future read-only consumers.
204 #[allow(dead_code)]
205 pub async fn list_recent_with_admin(db: &PgPool, limit: i64) -> Result<Vec<AuditLogRow>, sqlx::Error> {
206 sqlx::query_as::<_, AuditLogRow>(
207 r#"
208 SELECT saa.version_id, saa.item_id, u.username AS admin_username,
209 saa.action, saa.prev_status, saa.new_status, saa.note, saa.created_at
210 FROM scan_admin_actions saa
211 JOIN users u ON u.id = saa.admin_id
212 ORDER BY saa.created_at DESC
213 LIMIT $1
214 "#,
215 )
216 .bind(limit)
217 .fetch_all(db)
218 .await
219 }
220
221 /// Filtered audit entries for the dashboard `/admin/uploads/audit` page.
222 /// All filters are optional; a `None` for any field means no constraint on
223 /// that column. Newest first, capped at `limit`.
224 #[allow(clippy::too_many_arguments)]
225 pub async fn list_filtered(
226 db: &PgPool,
227 action: Option<&str>,
228 admin_username: Option<&str>,
229 since_days: Option<i64>,
230 limit: i64,
231 ) -> Result<Vec<AuditLogRow>, sqlx::Error> {
232 sqlx::query_as::<_, AuditLogRow>(
233 r#"
234 SELECT saa.version_id, saa.item_id, u.username AS admin_username,
235 saa.action, saa.prev_status, saa.new_status, saa.note, saa.created_at
236 FROM scan_admin_actions saa
237 JOIN users u ON u.id = saa.admin_id
238 WHERE ($1::TEXT IS NULL OR saa.action = $1)
239 AND ($2::TEXT IS NULL OR u.username = $2)
240 AND ($3::BIGINT IS NULL OR saa.created_at > NOW() - ($3 || ' days')::interval)
241 ORDER BY saa.created_at DESC
242 LIMIT $4
243 "#,
244 )
245 .bind(action)
246 .bind(admin_username)
247 .bind(since_days)
248 .bind(limit)
249 .fetch_all(db)
250 .await
251 }
252
253 /// Recent audit entries for the full-log page. Phase 2 surface.
254 #[allow(dead_code)]
255 pub async fn list_recent(db: &PgPool, limit: i64) -> Result<Vec<ScanAdminActionRow>, sqlx::Error> {
256 sqlx::query_as::<_, ScanAdminActionRow>(
257 r#"
258 SELECT id, version_id, item_id, admin_id, action,
259 prev_status, new_status, note, created_at
260 FROM scan_admin_actions
261 ORDER BY created_at DESC
262 LIMIT $1
263 "#,
264 )
265 .bind(limit)
266 .fetch_all(db)
267 .await
268 }
269
270 #[cfg(test)]
271 mod tests {
272 use super::*;
273
274 #[test]
275 fn admin_action_as_str() {
276 assert_eq!(AdminAction::Promote.as_str(), "promote");
277 assert_eq!(AdminAction::Quarantine.as_str(), "quarantine");
278 assert_eq!(AdminAction::Rescan.as_str(), "rescan");
279 assert_eq!(AdminAction::BulkPromote.as_str(), "bulk_promote");
280 assert_eq!(AdminAction::BulkRescan.as_str(), "bulk_rescan");
281 }
282 }
283