feat(admin): audit admin reads and filter the log by access (#2811)

This commit is contained in:
Hampus
2026-09-16 17:23:03 +02:00
committed by GitHub
parent 03d1354562
commit 3276039e41
100 changed files with 5615 additions and 307 deletions
+27
View File
@@ -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"
+41
View File
@@ -10,6 +10,7 @@ pub struct SearchAuditLogsParams {
pub admin_user_id: Option<String>,
pub target_id: Option<String>,
pub target_type: Option<String>,
pub access: Option<String>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
pub limit: u32,
@@ -21,6 +22,12 @@ impl AdminApiClient {
&self,
params: &SearchAuditLogsParams,
) -> ApiResult<AuditLogsListResponse> {
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> {
generated_types::ListAdminAuditLogsAccess::try_from(value)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
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);
}
}
+2
View File
@@ -9,6 +9,8 @@ pub struct AuditLogEntry {
#[serde(default)]
pub admin_user: Option<AuditLogUserSummary>,
pub action: String,
#[serde(default)]
pub access: Option<String>,
pub target_id: String,
pub target_type: String,
#[serde(default)]
+2 -1
View File
@@ -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! {
+3
View File
@@ -28,6 +28,7 @@ struct AuditLogsQuery {
admin_user_id: Option<String>,
target_id: Option<String>,
target_type: Option<String>,
access: Option<String>,
sort_by: Option<String>,
sort_order: Option<String>,
limit: Option<u32>,
@@ -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,
+1
View File
@@ -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,
@@ -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! {
@@ -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", &params).into_string();
assert!(markup.contains(r#"<option value="bulk_job" selected>Bulk job</option>"#));
assert!(markup.contains(r#"<option value="" selected>All entries</option>"#));
}
#[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", &params).into_string();
assert!(markup.contains(r#"<select id="access" name="access""#));
assert!(markup.contains(r#"<option value="read" selected>Reads only</option>"#));
assert_eq!(
build_pagination_url("/admin", 1, &params),
"/admin/audit-logs?page=1&target_id=1500000000000000001&access=read&sort_by=createdAt&sort_order=desc&limit=50"
);
}
}
@@ -230,6 +230,7 @@ fn deserialize_audit_logs_response() {
"target_type": "user",
"target_id": "1130958221824557056",
"action": "list_user_sessions",
"access": "read",
"audit_log_reason": null,
"metadata": {"session_count": "3"},
"created_at": "2026-05-26T13:21:47.138Z"
@@ -243,6 +244,7 @@ fn deserialize_audit_logs_response() {
assert_eq!(resp.logs.len(), 1);
assert_eq!(resp.logs[0].log_id, "1508822460457747580");
assert_eq!(resp.logs[0].action, "list_user_sessions");
assert_eq!(resp.logs[0].access.as_deref(), Some("read"));
assert_eq!(resp.logs[0].target_type, "user");
assert!(resp.logs[0].audit_log_reason.is_none());
assert_eq!(resp.logs[0].metadata.get("session_count").unwrap(), "3");
+78
View File
@@ -168,6 +168,39 @@ async fn detail_tab_routes_return_layout_or_fragments_by_route_shape() {
}
}
#[tokio::test]
async fn target_audit_log_tabs_request_write_entries_only() {
let app = setup().await;
for path in [
"/users/1500000000000000001/tabs/audit_logs",
"/guilds/1600000000000000001/tabs/audit_logs",
] {
let fragment = get(&app, path, &[]).await;
assert!(fragment.contains("Temp ban"), "{path}\n{fragment}");
assert!(!fragment.contains("Get user"), "{path}\n{fragment}");
}
}
#[tokio::test]
async fn audit_log_page_forwards_the_access_filter() {
let app = setup().await;
let all = get(&app, "/audit-logs", &[]).await;
assert!(all.contains("Temp ban"), "{all}");
assert!(all.contains("Get user"), "{all}");
assert!(
all.contains(r#"<option value="" selected>All entries</option>"#),
"{all}"
);
let reads = get(&app, "/audit-logs?access=read", &[]).await;
assert!(reads.contains("Get user"), "{reads}");
assert!(!reads.contains("Temp ban"), "{reads}");
assert!(
reads.contains(r#"<option value="read" selected>Reads only</option>"#),
"{reads}"
);
}
#[tokio::test]
async fn report_routes_keep_layout_and_fragment_contract() {
let app = setup().await;
@@ -844,6 +877,25 @@ async fn mock_api(method: Method, uri: Uri) -> Response {
json_response(instance_config_without_pending_registrations())
}
(Method::GET, "/admin/limit-config") => json_response(limit_config()),
(Method::GET, "/admin/audit-logs") => {
let access = uri.query().and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == "access")
.map(|(_, value)| value.into_owned())
});
let logs = [
audit_log_entry("1900000000000000101", "get_user", "read"),
audit_log_entry("1900000000000000102", "temp_ban", "write"),
]
.into_iter()
.filter(|entry| {
access
.as_deref()
.is_none_or(|access| entry.access.to_string() == access)
})
.collect::<Vec<_>>();
json_response(json!({ "total": logs.len(), "logs": logs }))
}
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
}
}
@@ -975,6 +1027,32 @@ fn guild_fixtures_match_generated_response_contracts() {
assert_eq!(detail.member_count, 12);
}
fn audit_log_entry(
log_id: &str,
action: &str,
access: &str,
) -> generated_types::AdminAuditLogResponseSchema {
serde_json::from_value(json!({
"log_id": log_id,
"admin_user_id": "1500000000000000000",
"admin_user": null,
"target_type": "user",
"target_id": "1500000000000000001",
"target_user": null,
"target_guild": null,
"target_channel": null,
"related_users": {},
"related_guilds": {},
"related_channels": {},
"action": action,
"access": access,
"audit_log_reason": null,
"metadata": {},
"created_at": "2026-09-16T12:00:00.000Z"
}))
.expect("audit log fixture must match the generated response contract")
}
fn searched_application() -> Value {
json!({
"id": "1700000000000000001",