mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(admin): audit admin reads and filter the log by access (#2811)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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! {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", ¶ms).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", ¶ms).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, ¶ms),
|
||||
"/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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user