diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index cabb1d7f6..c7ce00df7 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -933,6 +933,22 @@ }, "description": "Filter by target entity ID (user, channel, role, invite code, etc.)" }, + { + "name": "access", + "in": "query", + "required": false, + "schema": { + "description": "Only return entries recorded by reads or only entries recorded by writes", + "x-enumNames": ["read", "write"], + "x-enumDescriptions": [ + "An entry recorded by an operation that only reads data", + "An entry recorded by an operation that changes data or triggers work" + ], + "enum": ["read", "write"], + "type": "string" + }, + "description": "Only return entries recorded by reads or only entries recorded by writes" + }, { "name": "sort_by", "in": "query", @@ -12627,6 +12643,16 @@ } }, "action": {"type": "string", "minLength": 1, "maxLength": 256}, + "access": { + "description": "Whether the recorded operation read data or changed it", + "x-enumNames": ["read", "write"], + "x-enumDescriptions": [ + "An entry recorded by an operation that only reads data", + "An entry recorded by an operation that changes data or triggers work" + ], + "enum": ["read", "write"], + "type": "string" + }, "audit_log_reason": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4000}, "metadata": {"type": "object", "additionalProperties": {"type": "string", "maxLength": 4000}}, "created_at": {"type": "string"} @@ -12644,6 +12670,7 @@ "related_guilds", "related_channels", "action", + "access", "audit_log_reason", "metadata", "created_at" diff --git a/fluxer_admin/src/api/audit.rs b/fluxer_admin/src/api/audit.rs index b10983402..4add6fb7e 100644 --- a/fluxer_admin/src/api/audit.rs +++ b/fluxer_admin/src/api/audit.rs @@ -10,6 +10,7 @@ pub struct SearchAuditLogsParams { pub admin_user_id: Option, pub target_id: Option, pub target_type: Option, + pub access: Option, pub sort_by: Option, pub sort_order: Option, pub limit: u32, @@ -21,6 +22,12 @@ impl AdminApiClient { &self, params: &SearchAuditLogsParams, ) -> ApiResult { + let access = params + .access + .as_deref() + .map(audit_access) + .transpose()? + .map(|value| value.to_string()); let sort_by = params .sort_by .as_deref() @@ -46,6 +53,7 @@ impl AdminApiClient { params.target_type.as_deref().unwrap_or_default(), ), ("target_id", params.target_id.as_deref().unwrap_or_default()), + ("access", access.as_deref().unwrap_or_default()), ("sort_by", sort_by.as_deref().unwrap_or_default()), ("sort_order", sort_order.as_deref().unwrap_or_default()), ("limit", limit.as_str()), @@ -55,6 +63,11 @@ impl AdminApiClient { } } +fn audit_access(value: &str) -> ApiResult { + generated_types::ListAdminAuditLogsAccess::try_from(value) + .map_err(|e| ApiError::Parse(e.to_string())) +} + fn audit_sort_by(value: &str) -> ApiResult { let value = match value { "created_at" => "createdAt", @@ -83,6 +96,13 @@ mod tests { assert_eq!(audit_sort_order("desc").unwrap().to_string(), "desc"); } + #[test] + fn accepts_only_known_access_filters() { + assert_eq!(audit_access("read").unwrap().to_string(), "read"); + assert_eq!(audit_access("write").unwrap().to_string(), "write"); + assert!(audit_access("all").is_err()); + } + #[test] fn rejects_lossy_audit_totals() { for total in [serde_json::json!(1.5), serde_json::json!(-1)] { @@ -99,6 +119,7 @@ mod tests { "admin_user_id": "234567890123456789", "admin_user": null, "action": "USER_UPDATE", + "access": "write", "target_id": "345678901234567890", "target_type": "user", "target_user": null, @@ -118,6 +139,26 @@ mod tests { assert_eq!(generated.logs[0].action.to_string(), "USER_UPDATE"); let response: AuditLogsListResponse = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(response.logs[0].access.as_deref(), Some("write")); assert_eq!(serde_json::to_value(response).unwrap(), json); } + + #[test] + fn deserializes_audit_entries_from_an_api_without_access() { + let json = serde_json::json!({ + "logs": [{ + "log_id": "123456789012345678", + "admin_user_id": "234567890123456789", + "action": "USER_UPDATE", + "target_id": "345678901234567890", + "target_type": "user", + "audit_log_reason": null, + "metadata": {}, + "created_at": "2026-09-11T12:00:00.000Z" + }], + "total": 1 + }); + let response: AuditLogsListResponse = serde_json::from_value(json).unwrap(); + assert_eq!(response.logs[0].access, None); + } } diff --git a/fluxer_admin/src/api/types/audit.rs b/fluxer_admin/src/api/types/audit.rs index fa50a27a5..44ef50850 100644 --- a/fluxer_admin/src/api/types/audit.rs +++ b/fluxer_admin/src/api/types/audit.rs @@ -9,6 +9,8 @@ pub struct AuditLogEntry { #[serde(default)] pub admin_user: Option, pub action: String, + #[serde(default)] + pub access: Option, pub target_id: String, pub target_type: String, #[serde(default)] diff --git a/fluxer_admin/src/routes/guild_tabs.rs b/fluxer_admin/src/routes/guild_tabs.rs index 619a4c4f0..f64b29cc7 100644 --- a/fluxer_admin/src/routes/guild_tabs.rs +++ b/fluxer_admin/src/routes/guild_tabs.rs @@ -121,6 +121,7 @@ pub async fn render( admin_user_id: None, target_id: Some(guild_id.to_owned()), target_type: Some("guild".to_owned()), + access: Some("write".to_owned()), sort_by: Some("created_at".to_owned()), sort_order: Some("desc".to_owned()), limit: 50, @@ -134,7 +135,7 @@ pub async fn render( @if let Some(resp) = resp { @if resp.logs.is_empty() { p class="text-sm text-neutral-500" { - "No admin audit log entries for this guild." + "No admin write actions have been recorded for this guild." } } @else { (table_container(table(maud::html! { diff --git a/fluxer_admin/src/routes/system.rs b/fluxer_admin/src/routes/system.rs index a2048bb04..7db6cd22c 100644 --- a/fluxer_admin/src/routes/system.rs +++ b/fluxer_admin/src/routes/system.rs @@ -28,6 +28,7 @@ struct AuditLogsQuery { admin_user_id: Option, target_id: Option, target_type: Option, + access: Option, sort_by: Option, sort_order: Option, limit: Option, @@ -119,6 +120,7 @@ async fn audit_logs_page( admin_user_id: query.admin_user_id.as_deref().unwrap_or(""), target_id: query.target_id.as_deref().unwrap_or(""), target_type: query.target_type.as_deref().unwrap_or(""), + access: query.access.as_deref().unwrap_or(""), sort_by: query.sort_by.as_deref().unwrap_or("createdAt"), sort_order: query.sort_order.as_deref().unwrap_or("desc"), limit, @@ -131,6 +133,7 @@ async fn audit_logs_page( admin_user_id: nonempty(params.admin_user_id), target_id: nonempty(params.target_id), target_type: nonempty(params.target_type), + access: nonempty(params.access), sort_by: Some(params.sort_by.to_owned()), sort_order: Some(params.sort_order.to_owned()), limit, diff --git a/fluxer_admin/src/routes/user_tabs.rs b/fluxer_admin/src/routes/user_tabs.rs index 8e40af06e..57bdb7792 100644 --- a/fluxer_admin/src/routes/user_tabs.rs +++ b/fluxer_admin/src/routes/user_tabs.rs @@ -245,6 +245,7 @@ pub async fn render( admin_user_id: None, target_id: Some(user_id.to_owned()), target_type: None, + access: Some("write".to_owned()), sort_by: Some("created_at".to_owned()), sort_order: Some("desc".to_owned()), limit, diff --git a/fluxer_admin/src/templates/components/audit_logs_for_target.rs b/fluxer_admin/src/templates/components/audit_logs_for_target.rs index 3d65b70e2..30d3adc52 100644 --- a/fluxer_admin/src/templates/components/audit_logs_for_target.rs +++ b/fluxer_admin/src/templates/components/audit_logs_for_target.rs @@ -72,7 +72,7 @@ pub fn audit_logs_for_target( let total_pages = total.div_ceil(u64::from(PAGE_SIZE)).max(1); let page_number = u64::from(current_page) + 1; let all_logs_href = format!( - "{base_path}/audit-logs?target_id={}", + "{base_path}/audit-logs?target_id={}&access=write", urlencoding::encode(target_id) ); @@ -94,7 +94,7 @@ pub fn audit_logs_for_target( } } @if entries.is_empty() { - (empty_state("No admin actions have been recorded against this entity.")) + (empty_state("No admin write actions have been recorded against this entity.")) } @else { (table_container(html! { (table(html! { diff --git a/fluxer_admin/src/templates/pages/audit_logs.rs b/fluxer_admin/src/templates/pages/audit_logs.rs index ca57921c2..c9bdc09d1 100644 --- a/fluxer_admin/src/templates/pages/audit_logs.rs +++ b/fluxer_admin/src/templates/pages/audit_logs.rs @@ -22,6 +22,7 @@ pub struct AuditLogsParams<'a> { pub admin_user_id: &'a str, pub target_id: &'a str, pub target_type: &'a str, + pub access: &'a str, pub sort_by: &'a str, pub sort_order: &'a str, pub limit: u32, @@ -42,6 +43,11 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup { ("file_sha", "File SHA"), ("email", "Email"), ]; + let access_options: &[(&str, &str)] = &[ + ("", "All entries"), + ("write", "Writes only"), + ("read", "Reads only"), + ]; let sort_options: &[(&str, &str)] = &[("createdAt", "Created at"), ("relevance", "Relevance")]; let order_options: &[(&str, &str)] = &[("desc", "Newest first"), ("asc", "Oldest first")]; let limit_options: &[(&str, &str)] = @@ -60,6 +66,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup { "Filter by admin user ID...")) (select_input("target_type", "Target type", target_type_options, params.target_type)) + (select_input("access", "Access", access_options, params.access)) (select_input("sort_by", "Sort by", sort_options, params.sort_by)) (select_input("sort_order", "Order", order_options, params.sort_order)) (select_input("limit", "Page size", limit_options, &limit_str)) @@ -81,6 +88,7 @@ fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) -> ("admin_user_id", params.admin_user_id), ("target_id", params.target_id), ("target_type", params.target_type), + ("access", params.access), ("sort_by", params.sort_by), ("sort_order", params.sort_order), ] @@ -169,6 +177,7 @@ mod tests { admin_user_id: "", target_id: "", target_type: "bulk_job", + access: "", sort_by: "createdAt", sort_order: "desc", limit: 50, @@ -176,5 +185,28 @@ mod tests { }; let markup = filters_section("/admin", ¶ms).into_string(); assert!(markup.contains(r#""#)); + assert!(markup.contains(r#""#)); + } + + #[test] + fn access_filter_survives_form_and_pagination() { + let params = AuditLogsParams { + query: "", + admin_user_id: "", + target_id: "1500000000000000001", + target_type: "", + access: "read", + sort_by: "createdAt", + sort_order: "desc", + limit: 50, + current_page: 0, + }; + let markup = filters_section("/admin", ¶ms).into_string(); + assert!(markup.contains(r#"