max / makenotwork
8 files changed,
+217 insertions,
-5 deletions
| @@ -198,7 +198,10 @@ pub struct AuditLogRow { | |||
| 198 | 198 | } | |
| 199 | 199 | ||
| 200 | 200 | /// Recent audit entries joined with the admin's username, newest first. | |
| 201 | - | /// Phase 2b consumer for the audit-log page. | |
| 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)] | |
| 202 | 205 | pub async fn list_recent_with_admin(db: &PgPool, limit: i64) -> Result<Vec<AuditLogRow>, sqlx::Error> { | |
| 203 | 206 | sqlx::query_as::<_, AuditLogRow>( | |
| 204 | 207 | r#" | |
| @@ -215,6 +218,38 @@ pub async fn list_recent_with_admin(db: &PgPool, limit: i64) -> Result<Vec<Audit | |||
| 215 | 218 | .await | |
| 216 | 219 | } | |
| 217 | 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 | + | ||
| 218 | 253 | /// Recent audit entries for the full-log page. Phase 2 surface. | |
| 219 | 254 | #[allow(dead_code)] | |
| 220 | 255 | pub async fn list_recent(db: &PgPool, limit: i64) -> Result<Vec<ScanAdminActionRow>, sqlx::Error> { |
| @@ -46,6 +46,7 @@ pub fn admin_routes() -> Router<AppState> { | |||
| 46 | 46 | .route("/api/admin/uploads/versions/{id}/quarantine", post(uploads::admin_quarantine_version)) | |
| 47 | 47 | .route("/api/admin/uploads/versions/{id}/rescan", post(uploads::admin_rescan_version)) | |
| 48 | 48 | .route("/api/admin/uploads/bulk/rescan", post(uploads::admin_bulk_rescan_held)) | |
| 49 | + | .route("/api/admin/uploads/bulk/promote", post(uploads::admin_bulk_promote_held)) | |
| 49 | 50 | .route("/admin/uploads/queue-summary", get(uploads::admin_queue_summary_partial)) | |
| 50 | 51 | .route("/admin/uploads/audit", get(uploads::admin_scan_audit)) | |
| 51 | 52 | .route("/api/admin/users/{id}/trust", post(users::admin_trust_user)) |
| @@ -189,16 +189,32 @@ async fn attach_last_actions(state: &AppState, rows: &mut [AdminHeldUploadRow]) | |||
| 189 | 189 | } | |
| 190 | 190 | } | |
| 191 | 191 | ||
| 192 | - | /// Full audit-log page. | |
| 192 | + | #[derive(serde::Deserialize, Default)] | |
| 193 | + | pub(super) struct AuditFilters { | |
| 194 | + | #[serde(default)] | |
| 195 | + | pub action: Option<String>, | |
| 196 | + | #[serde(default)] | |
| 197 | + | pub admin: Option<String>, | |
| 198 | + | #[serde(default)] | |
| 199 | + | pub since_days: Option<i64>, | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// Full audit-log page with query-string filters. | |
| 203 | + | /// | |
| 204 | + | /// `?action=promote&admin=max&since_days=30` is the canonical example. All | |
| 205 | + | /// filters are optional; empty/absent means no constraint on that column. | |
| 193 | 206 | #[tracing::instrument(skip_all, name = "admin::scan_audit")] | |
| 194 | 207 | pub(super) async fn admin_scan_audit( | |
| 195 | 208 | State(state): State<AppState>, | |
| 196 | 209 | session: tower_sessions::Session, | |
| 197 | 210 | AdminUser(user): AdminUser, | |
| 211 | + | axum::extract::Query(filters): axum::extract::Query<AuditFilters>, | |
| 198 | 212 | ) -> Result<impl IntoResponse> { | |
| 199 | 213 | let csrf_token = get_csrf_token(&session).await; | |
| 200 | - | let entries: Vec<AdminAuditLogRow> = db::scan_admin_actions::list_recent_with_admin( | |
| 201 | - | &state.db, AUDIT_LOG_ROW_LIMIT, | |
| 214 | + | let action = filters.action.as_deref().filter(|s| !s.is_empty()); | |
| 215 | + | let admin = filters.admin.as_deref().filter(|s| !s.is_empty()); | |
| 216 | + | let entries: Vec<AdminAuditLogRow> = db::scan_admin_actions::list_filtered( | |
| 217 | + | &state.db, action, admin, filters.since_days, AUDIT_LOG_ROW_LIMIT, | |
| 202 | 218 | ).await | |
| 203 | 219 | .unwrap_or_default() | |
| 204 | 220 | .iter() | |
| @@ -209,6 +225,9 @@ pub(super) async fn admin_scan_audit( | |||
| 209 | 225 | session_user: Some(user), | |
| 210 | 226 | admin_active_page: "uploads", | |
| 211 | 227 | entries, | |
| 228 | + | filter_action: filters.action.unwrap_or_default(), | |
| 229 | + | filter_admin: filters.admin.unwrap_or_default(), | |
| 230 | + | filter_since_days: filters.since_days.map(|d| d.to_string()).unwrap_or_default(), | |
| 212 | 231 | }) | |
| 213 | 232 | } | |
| 214 | 233 | ||
| @@ -406,6 +425,58 @@ pub(super) async fn admin_bulk_rescan_held( | |||
| 406 | 425 | refresh_held_uploads_partial(&state).await | |
| 407 | 426 | } | |
| 408 | 427 | ||
| 428 | + | /// Bulk-promote every currently-held item and version to Clean. Sticky | |
| 429 | + | /// decision; requires a non-empty note for audit-trail attribution. Use | |
| 430 | + | /// after a manual review pass where the admin has eyes on the held set and | |
| 431 | + | /// has confirmed each is safe. | |
| 432 | + | /// | |
| 433 | + | /// Distinct from bulk-rescan: rescan re-runs the pipeline (system decides); | |
| 434 | + | /// promote bypasses the pipeline (admin decides). The note is the only | |
| 435 | + | /// record of *why*, so we require it. | |
| 436 | + | #[tracing::instrument(skip_all, name = "admin::bulk_promote_held")] | |
| 437 | + | pub(super) async fn admin_bulk_promote_held( | |
| 438 | + | State(state): State<AppState>, | |
| 439 | + | AdminUser(admin): AdminUser, | |
| 440 | + | axum::Form(form): axum::Form<BulkPromoteForm>, | |
| 441 | + | ) -> Result<Response> { | |
| 442 | + | let note = form.note.trim(); | |
| 443 | + | if note.is_empty() { | |
| 444 | + | return Err(crate::error::AppError::BadRequest( | |
| 445 | + | "Bulk promote requires a note explaining why every held file is safe to clear.".to_string(), | |
| 446 | + | )); | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | let held_items = db::scanning::get_held_items(&state.db).await?; | |
| 450 | + | let held_versions = db::scanning::get_held_versions(&state.db).await?; | |
| 451 | + | let mut total = 0usize; | |
| 452 | + | ||
| 453 | + | for v in &held_versions { | |
| 454 | + | if db::scanning::update_version_scan_status(&state.db, v.version_id, FileScanStatus::Clean).await.is_ok() { | |
| 455 | + | db::scan_admin_actions::log_version( | |
| 456 | + | &state.db, v.version_id, admin.id, AdminAction::BulkPromote, | |
| 457 | + | Some("held_for_review"), Some("clean"), Some(note), | |
| 458 | + | ).await.ok(); | |
| 459 | + | total += 1; | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | for i in &held_items { | |
| 463 | + | if db::scanning::update_item_scan_status(&state.db, i.item_id, FileScanStatus::Clean).await.is_ok() { | |
| 464 | + | db::scan_admin_actions::log_item( | |
| 465 | + | &state.db, i.item_id, admin.id, AdminAction::BulkPromote, | |
| 466 | + | Some("held_for_review"), Some("clean"), Some(note), | |
| 467 | + | ).await.ok(); | |
| 468 | + | total += 1; | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | tracing::warn!(total, admin_id = %admin.id, note = %note, "bulk promote of held queue executed"); | |
| 472 | + | refresh_held_uploads_partial(&state).await | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | #[derive(serde::Deserialize)] | |
| 476 | + | pub(super) struct BulkPromoteForm { | |
| 477 | + | pub note: String, | |
| 478 | + | } | |
| 479 | + | ||
| 409 | 480 | // ─── Live partials ─────────────────────────────────────────────────────────── | |
| 410 | 481 | ||
| 411 | 482 | /// HTMX partial: current pending + running counts. Polled by the dashboard. |
| @@ -143,6 +143,9 @@ pub struct AdminScanAuditTemplate { | |||
| 143 | 143 | pub session_user: Option<SessionUser>, | |
| 144 | 144 | pub admin_active_page: &'static str, | |
| 145 | 145 | pub entries: Vec<AdminAuditLogRow>, | |
| 146 | + | pub filter_action: String, | |
| 147 | + | pub filter_admin: String, | |
| 148 | + | pub filter_since_days: String, | |
| 146 | 149 | } | |
| 147 | 150 | ||
| 148 | 151 | /// Admin appeals queue page. |
| @@ -4142,6 +4142,57 @@ form button:active { | |||
| 4142 | 4142 | margin-top: 2px; | |
| 4143 | 4143 | } | |
| 4144 | 4144 | ||
| 4145 | + | .audit-filter-form { | |
| 4146 | + | display: flex; | |
| 4147 | + | flex-wrap: wrap; | |
| 4148 | + | align-items: flex-end; | |
| 4149 | + | gap: var(--space-3); | |
| 4150 | + | padding: var(--space-3) 0; | |
| 4151 | + | margin-bottom: var(--space-3); | |
| 4152 | + | border-bottom: 1px solid var(--border); | |
| 4153 | + | } | |
| 4154 | + | ||
| 4155 | + | .audit-filter-form label { | |
| 4156 | + | display: flex; | |
| 4157 | + | flex-direction: column; | |
| 4158 | + | gap: 2px; | |
| 4159 | + | } | |
| 4160 | + | ||
| 4161 | + | .audit-filter-form input, | |
| 4162 | + | .audit-filter-form select { | |
| 4163 | + | font-size: 0.9rem; | |
| 4164 | + | } | |
| 4165 | + | ||
| 4166 | + | .layer-chips-cell { vertical-align: top; } | |
| 4167 | + | ||
| 4168 | + | .layer-detail-disclosure { | |
| 4169 | + | margin-top: var(--space-2); | |
| 4170 | + | } | |
| 4171 | + | ||
| 4172 | + | .layer-detail-disclosure summary { | |
| 4173 | + | cursor: pointer; | |
| 4174 | + | user-select: none; | |
| 4175 | + | padding: 2px 0; | |
| 4176 | + | } | |
| 4177 | + | ||
| 4178 | + | .layer-detail-list { | |
| 4179 | + | margin: var(--space-2) 0 0; | |
| 4180 | + | display: grid; | |
| 4181 | + | grid-template-columns: max-content 1fr; | |
| 4182 | + | gap: 2px var(--space-3); | |
| 4183 | + | font-size: 0.8rem; | |
| 4184 | + | } | |
| 4185 | + | ||
| 4186 | + | .layer-detail-list dt { | |
| 4187 | + | font-family: var(--font-mono); | |
| 4188 | + | } | |
| 4189 | + | ||
| 4190 | + | .layer-detail-list dd { | |
| 4191 | + | margin: 0; | |
| 4192 | + | color: var(--text-muted); | |
| 4193 | + | word-break: break-word; | |
| 4194 | + | } | |
| 4195 | + | ||
| 4145 | 4196 | .admin-page table { | |
| 4146 | 4197 | width: 100%; | |
| 4147 | 4198 | border-collapse: collapse; |
| @@ -15,6 +15,38 @@ | |||
| 15 | 15 | </div> | |
| 16 | 16 | <p class="dimmed">Every admin action against the scan pipeline. Newest first.</p> | |
| 17 | 17 | ||
| 18 | + | <form method="get" action="/admin/uploads/audit" class="audit-filter-form"> | |
| 19 | + | <label> | |
| 20 | + | <span class="text-xs dimmed">Action</span> | |
| 21 | + | <select name="action"> | |
| 22 | + | <option value=""{% if filter_action.is_empty() %} selected{% endif %}>Any</option> | |
| 23 | + | <option value="promote"{% if filter_action == "promote" %} selected{% endif %}>promote</option> | |
| 24 | + | <option value="quarantine"{% if filter_action == "quarantine" %} selected{% endif %}>quarantine</option> | |
| 25 | + | <option value="rescan"{% if filter_action == "rescan" %} selected{% endif %}>rescan</option> | |
| 26 | + | <option value="bulk_promote"{% if filter_action == "bulk_promote" %} selected{% endif %}>bulk_promote</option> | |
| 27 | + | <option value="bulk_rescan"{% if filter_action == "bulk_rescan" %} selected{% endif %}>bulk_rescan</option> | |
| 28 | + | </select> | |
| 29 | + | </label> | |
| 30 | + | <label> | |
| 31 | + | <span class="text-xs dimmed">Admin</span> | |
| 32 | + | <input type="text" name="admin" value="{{ filter_admin }}" placeholder="username"> | |
| 33 | + | </label> | |
| 34 | + | <label> | |
| 35 | + | <span class="text-xs dimmed">Within</span> | |
| 36 | + | <select name="since_days"> | |
| 37 | + | <option value=""{% if filter_since_days.is_empty() %} selected{% endif %}>All time</option> | |
| 38 | + | <option value="1"{% if filter_since_days == "1" %} selected{% endif %}>last 24h</option> | |
| 39 | + | <option value="7"{% if filter_since_days == "7" %} selected{% endif %}>last 7d</option> | |
| 40 | + | <option value="30"{% if filter_since_days == "30" %} selected{% endif %}>last 30d</option> | |
| 41 | + | <option value="90"{% if filter_since_days == "90" %} selected{% endif %}>last 90d</option> | |
| 42 | + | </select> | |
| 43 | + | </label> | |
| 44 | + | <button type="submit" class="btn-secondary small">Filter</button> | |
| 45 | + | {% if !filter_action.is_empty() || !filter_admin.is_empty() || !filter_since_days.is_empty() %} | |
| 46 | + | <a href="/admin/uploads/audit" class="text-sm dimmed">Reset</a> | |
| 47 | + | {% endif %} | |
| 48 | + | </form> | |
| 49 | + | ||
| 18 | 50 | {% if entries.is_empty() %} | |
| 19 | 51 | <div class="text-center dimmed empty-state">No audit entries yet.</div> | |
| 20 | 52 | {% else %} |
| @@ -78,6 +78,14 @@ | |||
| 78 | 78 | hx-confirm="Re-scan every held file under the current pipeline? Workers will pick them up shortly."> | |
| 79 | 79 | Re-scan all held | |
| 80 | 80 | </button> | |
| 81 | + | <button class="btn-secondary small" | |
| 82 | + | hx-post="/api/admin/uploads/bulk/promote" | |
| 83 | + | hx-target="#uploads-table" | |
| 84 | + | hx-swap="innerHTML" | |
| 85 | + | hx-prompt="Note explaining why every held file is safe to clear (required, audit-logged):" | |
| 86 | + | hx-vals='js:{ note: event.detail.prompt }'> | |
| 87 | + | Promote all held | |
| 88 | + | </button> | |
| 81 | 89 | {% endif %} | |
| 82 | 90 | </div> | |
| 83 | 91 | </div> |
| @@ -39,16 +39,27 @@ | |||
| 39 | 39 | </div> | |
| 40 | 40 | {% endif %} | |
| 41 | 41 | </td> | |
| 42 | - | <td class="layer-chips"> | |
| 42 | + | <td class="layer-chips-cell"> | |
| 43 | 43 | {% if upload.layers.is_empty() %} | |
| 44 | 44 | <span class="dimmed text-xs">no scan record</span> | |
| 45 | 45 | {% else %} | |
| 46 | + | <div class="layer-chips"> | |
| 46 | 47 | {% for chip in upload.layers %} | |
| 47 | 48 | <span class="layer-chip layer-chip-{{ chip.verdict }}" | |
| 48 | 49 | title="{{ chip.layer }}: {{ chip.verdict }}{% if let Some(d) = chip.detail %} — {{ d }}{% endif %}"> | |
| 49 | 50 | {{ chip.layer }} | |
| 50 | 51 | </span> | |
| 51 | 52 | {% endfor %} | |
| 53 | + | </div> | |
| 54 | + | <details class="layer-detail-disclosure"> | |
| 55 | + | <summary class="text-xs dimmed">scan detail</summary> | |
| 56 | + | <dl class="layer-detail-list"> | |
| 57 | + | {% for chip in upload.layers %} | |
| 58 | + | <dt><code class="layer-chip layer-chip-{{ chip.verdict }}">{{ chip.layer }}</code> <span class="dimmed">— {{ chip.verdict }}</span></dt> | |
| 59 | + | <dd>{% if let Some(d) = chip.detail %}{{ d }}{% else %}<span class="dimmed">no detail</span>{% endif %}</dd> | |
| 60 | + | {% endfor %} | |
| 61 | + | </dl> | |
| 62 | + | </details> | |
| 52 | 63 | {% endif %} | |
| 53 | 64 | </td> | |
| 54 | 65 | <td class="nowrap"> |