diff --git a/.cargo/audit.toml b/.cargo/audit.toml index de5de63e1..81843d9f8 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -29,4 +29,11 @@ ignore = [ # `cargo tree --workspace --all-features --target all -i rkyv@0.7.46` 无运行或构建路径。 # 上游 rust_decimal 仍声明 rkyv 0.7;待其迁移到 rkyv >= 0.8.17 后删除本项。 "RUSTSEC-2026-0235", + + # h2 0.3.27 unbounded empty DATA frames (RUSTSEC-2026-0258) + # actix-http 3.13.3(当前 crates.io 最新版)仍锁定 h2 ^0.3;AsterDrive + # 没有可直接替换的 h2 版本,因为 h2 0.4 不满足该依赖的 semver 约束。 + # 保留审计阻塞并不能修复上游协议栈;待 actix-http 发布切换到 h2 >= 0.4.16 + # 的版本后立即删除本项并刷新 Cargo.lock。当前 PR 不扩大为替换 Actix HTTP 栈。 + "RUSTSEC-2026-0258", ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 0765f223a..f00c09329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- **存储策略与策略组生命周期** — 首次 setup 创建的存储策略和默认策略组不再按固定 ID 作为永久系统对象;解除 blob、上传 session、策略组项以及用户/团队绑定等引用后可删除首条或最后一条默认策略,删除最后一个默认策略组会使系统回到 `needs_storage`,重新配置默认存储拓扑后恢复 `ready`,不会静默清空业务绑定。默认切换、删除与重新 setup 使用稳定数据库锁协调多 Primary,现有数据保护保持不变。 +### Added -## [v0.5.1] - 2026-08-20 +- **团队与系统审计 CSV 导出** — 新增用户团队、管理员团队和管理员系统三类服务端流式导出接口;导出复用服务端筛选条件,采用 keyset 游标分批读取,固定 16 列 UTF-8 / RFC 4180 CSV 契约,系统审计保留排序参数,团队审计固定按 `created_at DESC, id DESC` 输出,并设置单次 100000 行上限。 +- **内置登录方式控制** — 新增可热更新的密码登录开关,并继续与 Passkey 开关独立组合;关闭密码登录会同时关闭公开注册、激活重发、密码邀请接受、密码重置和外部身份密码绑定,未完成的密码第一因子 MFA flow 会在完成时重新检查策略,外部认证和 Passkey 登录不再被遗留的强制改密标记阻塞。后端仅在存在已启用外部认证 provider 时允许同时关闭密码与 Passkey,并阻止禁用或删除最后一个外部 provider,避免保存后失去全部登录入口。 +- **远端节点连接生命周期审计** — reverse tunnel 连接、正常下线、异常断线和心跳超时现在会按 remote node / binding 聚合写入系统 audit;四条 streaming lane 的同时变化只产生一次节点级状态转换,并记录连接次数、中断次数、lane 数量、transport 和稳定 reason code,不包含 access key、secret、signature、URL 凭据或 token。 ### Changed +- **存储策略与策略组生命周期** — 首次 setup 创建的存储策略和默认策略组不再按固定 ID 作为永久系统对象;解除 blob、上传 session、策略组项以及用户/团队绑定等引用后可删除首条或最后一条默认策略,删除最后一个默认策略组会使系统回到 `needs_storage`,重新配置默认存储拓扑后恢复 `ready`,不会静默清空业务绑定。默认切换、删除与重新 setup 使用稳定数据库锁协调多 Primary,现有数据保护保持不变。 - **存储策略凭据兼容层完成收口** — 移除 0.5.x 启动阶段的 legacy credential importer、connector legacy import hook、OneDrive 旧 OAuth 转换、deprecated credential entities / repositories,以及 `database-migrate` 的旧凭据复制与导入路径。当前运行时只消费 `connector_id`、typed `storage_config` 和 `storage_policy_connector_credentials`。 - **存储策略最终 schema migration** — 新增 `m20260820_000001_remove_storage_policy_legacy`。迁移会在任何 DDL 前检查旧凭据表和旧静态凭据列;发现未完成 0.5.x 转换时硬失败并保留原 schema / 数据,检查通过后删除两个旧凭据表、旧 `storage_policies` 列、索引和远端节点外键。 - **跨数据库迁移边界** — `database-migrate` 只复制当前 policy envelope 与 connector credential;带有未迁移 legacy credential 的 source database 会在复制前拒绝,空的历史 legacy stores 不再进入目标库。 @@ -23,14 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **迁移幂等与回滚边界** — 覆盖 SQLite、PostgreSQL 和 MySQL 的旧列 / 索引 / foreign-key 清理路径,保持 SQLite foreign-key 状态并验证引用 `storage_policies` 的现有数据不丢失。 - **schema drift 与历史测试边界** — 区分历史 migration、0.5.x compatibility schema 和最终 schema,补充未迁移凭据硬失败、空旧表清理、最终列集合和重复执行测试。 -### Added - -- **内置登录方式控制** — 新增可热更新的密码登录开关,并继续与 Passkey 开关独立组合;关闭密码登录会同时关闭公开注册、激活重发、密码邀请接受、密码重置和外部身份密码绑定,未完成的密码第一因子 MFA flow 会在完成时重新检查策略,外部认证和 Passkey 登录不再被遗留的强制改密标记阻塞。后端仅在存在已启用外部认证 provider 时允许同时关闭密码与 Passkey,并阻止禁用或删除最后一个外部 provider,避免保存后失去全部登录入口。 -- **远端节点连接生命周期审计** — reverse tunnel 连接、正常下线、异常断线和心跳超时现在会按 remote node / binding 聚合写入系统 audit;四条 streaming lane 的同时变化只产生一次节点级状态转换,并记录连接次数、中断次数、lane 数量、transport 和稳定 reason code,不包含 access key、secret、signature、URL 凭据或 token。 +- **Slim 镜像媒体处理能力与派生缓存** — full 与 slim 镜像切换时保留已有媒体处理配置,管理端分别展示已配置、运行时可用和有效启用状态;公开缩略图能力只声明当前可生成的格式,并与媒体元数据能力独立。已有缩略图和图片预览缓存继续可读,缺少 `vips`、`ffmpeg` 或 `ffprobe` 时仅阻止新的相关派生并返回结构化处理器不可用错误;Docker 发布流程也保证所有 slim 变体先于 full 变体推送。 -### Fixed +### Security -- **Slim 镜像媒体处理能力与派生缓存** — full 与 slim 镜像切换时保留已有媒体处理配置,管理端分别展示已配置、运行时可用和有效启用状态;公开缩略图能力只声明当前可生成的格式,并与媒体元数据能力独立。已有缩略图和图片预览缓存继续可读,缺少 `vips`、`ffmpeg` 或 `ffprobe` 时仅阻止新的相关派生并返回结构化处理器不可用错误;Docker 发布流程也保证所有 slim 变体先于 full 变体推送。 +- **审计导出敏感数据与表格注入防护** — 审计列表和 CSV 导出统一递归移除密码、token、secret、credential、session、MFA、外部认证、WOPI 和存储凭据字段,不输出分享 token;用户可控 CSV 文本字段会中和公式前缀,避免在桌面表格软件中被解释为公式。 ## [v0.5.0] - 2026-08-20 diff --git a/Cargo.lock b/Cargo.lock index f91b20da9..20d33e062 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -807,6 +807,7 @@ dependencies = [ "clap", "config", "criterion", + "csv", "dashmap", "data-encoding", "dotenvy", @@ -2817,6 +2818,27 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.9.2" diff --git a/Cargo.toml b/Cargo.toml index 7788a3810..872aa71e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -218,6 +218,7 @@ chrono-tz = "0.10" clap = { version = "4", features = ["derive", "env"], optional = true } config = { version = "0.15", default-features = false, features = ["toml"] } criterion = { version = "0.8", optional = true } +csv = "1.4" dashmap = "6" data-encoding = "2.11" dotenvy = "0.15" diff --git a/developer-docs/en/api/admin.md b/developer-docs/en/api/admin.md index 73e7391d9..c34eaa044 100644 --- a/developer-docs/en/api/admin.md +++ b/developer-docs/en/api/admin.md @@ -553,6 +553,7 @@ The issued access token is scoped to password change only. It can call `GET /aut | `DELETE` | `/admin/teams/{id}` | Archive a team | | `POST` | `/admin/teams/{id}/restore` | Restore an archived team | | `GET` | `/admin/teams/{id}/audit-logs` | Read team audit logs | +| `GET` | `/admin/teams/{id}/audit-logs/export` | Stream a CSV export for a team | | `GET` | `/admin/teams/{id}/members` | Paginated team members | | `POST` | `/admin/teams/{id}/members` | Add team member | | `PATCH` | `/admin/teams/{id}/members/{member_user_id}` | Update member role | @@ -580,6 +581,13 @@ Admin team creation can create a team for another user and give that user the in | `DELETE` | `/admin/locks/{id}` | Release a lock | | `DELETE` | `/admin/locks/expired` | Delete expired locks | | `GET` | `/admin/audit-logs` | Paginated audit logs | +| `GET` | `/admin/audit-logs/export` | Stream a CSV export using filters and sorting | + +The export endpoint ignores `limit` and `offset`, reads keyset cursor batches, and rejects matches over 100,000 rows with `operation.resource_limit_exceeded`. CSV output is UTF-8 with RFC 4180 escaping and this fixed column order: + +`id,created_at,actor_user_id,actor_username,action,entity_type,entity_id,entity_name,detail,ip_address,user_agent,member_user_id,member_username,role,previous_role,next_role` + +`created_at` uses UTC RFC3339. `detail` contains stored JSON after recursive removal of password, token, secret, credential, authorization, cookie, recovery-code, key, and API-key fields. Empty values remain empty. Runtime config entries defined by the system cannot be deleted; custom entries can. The single source of truth for system config definitions is `src/config/definitions.rs`. diff --git a/developer-docs/en/api/teams.md b/developer-docs/en/api/teams.md index 184da6f57..1c4a46e33 100644 --- a/developer-docs/en/api/teams.md +++ b/developer-docs/en/api/teams.md @@ -18,6 +18,7 @@ Team capabilities have two layers: | `DELETE` | `/teams/{id}` | Archive a team | | `POST` | `/teams/{id}/restore` | Restore an archived team | | `GET` | `/teams/{id}/audit-logs` | Read team audit logs | +| `GET` | `/teams/{id}/audit-logs/export` | Stream a CSV export using the same team audit filters | | `GET` | `/teams/{id}/members` | Paginated team members | | `POST` | `/teams/{id}/members` | Add a team member | | `PATCH` | `/teams/{id}/members/{member_user_id}` | Change member role | @@ -30,6 +31,8 @@ Current behavior: - admins can create a team for someone else through `/admin/teams`; that entry adds the target user with `admin` role - `DELETE /teams/{id}` archives rather than physically deletes; cleanup happens after `team_archive_retention_days` - `GET /teams/{id}/audit-logs` requires team `owner` or `admin` and supports filters such as `user_id`, `action`, `after`, `before`, `limit`, `offset` +- `GET /teams/{id}/audit-logs/export` reuses `user_id`, `action`, `entity_type`, `entity_id`, `after`, and `before`. The server uses deterministic `created_at + id` ordering and keyset cursor batches. A single export is capped at 100,000 rows and returns `operation.resource_limit_exceeded` when exceeded; CSV columns and detail redaction are fixed by the admin API contract. +- Team exports intentionally use fixed `created_at DESC, id DESC` ordering; `sort_by` and `sort_order` apply only to the system-wide admin export. - `GET /teams/{id}/members` supports `keyword`, `role`, `status`, `limit`, `offset`, `sort_by`, `sort_order` - `POST /teams/{id}/members` accepts either `user_id` or `identifier`, exactly one of them; omitted `role` defaults to `member` - member pagination returns `owner_count` and `manager_count` in addition to `items` / `total` / `limit` / `offset` diff --git a/developer-docs/zh-CN/api/admin.md b/developer-docs/zh-CN/api/admin.md index 50b5f11ed..7a994b45d 100644 --- a/developer-docs/zh-CN/api/admin.md +++ b/developer-docs/zh-CN/api/admin.md @@ -631,6 +631,7 @@ POST /api/v1/admin/policies/action | `DELETE` | `/admin/teams/{id}` |归档团队 | | `POST` | `/admin/teams/{id}/restore` | 恢复已归档团队 | | `GET` | `/admin/teams/{id}/audit-logs` | 查看团队审计记录 | +| `GET` | `/admin/teams/{id}/audit-logs/export` | 流式导出指定团队审计 CSV | | `GET` | `/admin/teams/{id}/members` | 分页查看团队成员 | | `POST` | `/admin/teams/{id}/members` | 添加团队成员 | | `PATCH` | `/admin/teams/{id}/members/{member_user_id}` | 调整成员角色 | @@ -995,6 +996,7 @@ POST /api/v1/admin/policies/action | 方法 | 路径 | 说明 | | --- | --- | --- | | `GET` | `/admin/audit-logs` | 分页查询审计日志 | +| `GET` | `/admin/audit-logs/export` | 按筛选和排序条件流式导出全站审计 CSV | 当前实现支持这些查询参数: @@ -1010,6 +1012,12 @@ POST /api/v1/admin/policies/action 其中 `after` 和 `before` 使用 RFC3339 时间字符串。 +导出接口忽略 `limit` 和 `offset`,使用 keyset 游标分批流式读取;匹配结果超过 100000 行时返回 `operation.resource_limit_exceeded`。CSV 默认 UTF-8,按 RFC 4180 转义,固定列顺序为: + +`id,created_at,actor_user_id,actor_username,action,entity_type,entity_id,entity_name,detail,ip_address,user_agent,member_user_id,member_username,role,previous_role,next_role` + +`created_at` 使用 UTC RFC3339。`detail` 是服务端递归移除 password、token、secret、credential、authorization、cookie、恢复码、密钥和 API key 字段后的结构化 JSON;空值保持为空。 + 返回结果包含分页信息与日志项,日志项里会带时间、用户、动作、实体、名称、IP 等字段。 日志项同时包含 `presentation` 字段,给前端做结构化展示: diff --git a/developer-docs/zh-CN/api/teams.md b/developer-docs/zh-CN/api/teams.md index 8696971bd..357b1897d 100644 --- a/developer-docs/zh-CN/api/teams.md +++ b/developer-docs/zh-CN/api/teams.md @@ -18,6 +18,7 @@ | `DELETE` | `/teams/{id}` |归档团队 | | `POST` | `/teams/{id}/restore` | 恢复已归档团队 | | `GET` | `/teams/{id}/audit-logs` | 查看团队审计记录 | +| `GET` | `/teams/{id}/audit-logs/export` | 按相同筛选条件流式导出团队审计 CSV | | `GET` | `/teams/{id}/members` | 分页查看团队成员 | | `POST` | `/teams/{id}/members` | 添加团队成员 | | `PATCH` | `/teams/{id}/members/{member_user_id}` | 调整成员角色 | @@ -30,6 +31,8 @@ - 如果要由系统管理员“替别人创建团队并指定初始团队管理员”,使用 `/admin/teams`;admin 创建入口会把目标用户加入团队并赋予 `admin` 角色 - `DELETE /teams/{id}` 是归档,不是物理删除;超过 `team_archive_retention_days` 后才会被后台清理 - `GET /teams/{id}/audit-logs` 需要团队 `owner` 或 `admin`,支持 `user_id`、`action`、`after`、`before`、`limit`、`offset` +- `GET /teams/{id}/audit-logs/export` 复用 `user_id`、`action`、`entity_type`、`entity_id`、`after`、`before` 筛选,服务端以 `created_at + id` 稳定排序并使用 keyset 游标流式返回 CSV。单次最多导出 100000 行,超过上限返回 `operation.resource_limit_exceeded`;固定 CSV 列和敏感详情脱敏规则见管理员 API 文档。 +- 团队导出固定使用 `created_at DESC, id DESC` 顺序;`sort_by` 和 `sort_order` 仅适用于管理员系统审计导出。 - `GET /teams/{id}/members` 支持 `keyword`、`role`、`status`、`limit`、`offset`、`sort_by`、`sort_order` - `POST /teams/{id}/members` 可用 `user_id` 或 `identifier` 指定目标用户,二选一;`role` 不传时默认 `member` - 成员分页返回除了 `items` / `total` / `limit` / `offset`,还会带 `owner_count` 和 `manager_count` diff --git a/frontend-panel/src/components/admin/AdminTeamDetailDialog.tsx b/frontend-panel/src/components/admin/AdminTeamDetailDialog.tsx index 1ec8e595a..cb011a363 100644 --- a/frontend-panel/src/components/admin/AdminTeamDetailDialog.tsx +++ b/frontend-panel/src/components/admin/AdminTeamDetailDialog.tsx @@ -643,6 +643,7 @@ export function AdminTeamDetailDialog({ const auditSection = ( ({ + exportAuditLogs: vi.fn(), + handleApiError: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/hooks/useApiError", () => ({ + handleApiError: (...args: unknown[]) => mocks.handleApiError(...args), +})); + +vi.mock("@/services/adminService", () => ({ + adminTeamService: { + exportAuditLogs: (...args: unknown[]) => mocks.exportAuditLogs(...args), + }, +})); + +function renderSection() { + return render( + + role} + setAuditOffset={vi.fn()} + /> + , + ); +} + +describe("AdminTeamDetailAuditSection", () => { + beforeEach(() => { + mocks.exportAuditLogs.mockReset(); + mocks.handleApiError.mockReset(); + }); + + it("exports the team scope and disables duplicate clicks while pending", async () => { + let resolveExport!: () => void; + mocks.exportAuditLogs.mockReturnValue( + new Promise((resolve) => { + resolveExport = resolve; + }), + ); + renderSection(); + const button = screen.getByRole("button", { name: /core:export_csv/ }); + fireEvent.click(button); + + await waitFor(() => { + expect(mocks.exportAuditLogs).toHaveBeenCalledWith(42); + expect(button).toBeDisabled(); + }); + fireEvent.click(button); + expect(mocks.exportAuditLogs).toHaveBeenCalledTimes(1); + resolveExport(); + await waitFor(() => expect(button).not.toBeDisabled()); + }); + + it("routes export failures through the shared API error handler", async () => { + const error = new Error("export failed"); + mocks.exportAuditLogs.mockRejectedValue(error); + renderSection(); + fireEvent.click(screen.getByRole("button", { name: /core:export_csv/ })); + await waitFor(() => + expect(mocks.handleApiError).toHaveBeenCalledWith(error), + ); + }); +}); diff --git a/frontend-panel/src/components/admin/admin-team-detail/AdminTeamDetailAuditSection.tsx b/frontend-panel/src/components/admin/admin-team-detail/AdminTeamDetailAuditSection.tsx index bba40ee41..f04f177bc 100644 --- a/frontend-panel/src/components/admin/admin-team-detail/AdminTeamDetailAuditSection.tsx +++ b/frontend-panel/src/components/admin/admin-team-detail/AdminTeamDetailAuditSection.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { EmptyState } from "@/components/common/EmptyState"; import { SkeletonTable } from "@/components/common/SkeletonTable"; @@ -5,12 +6,15 @@ import { UserIdentity } from "@/components/common/UserIdentity"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; +import { handleApiError } from "@/hooks/useApiError"; import { formatAuditDetail, formatAuditSummary } from "@/lib/audit"; import { formatDateAbsolute } from "@/lib/format"; import { formatTeamAuditSummary } from "@/lib/team"; +import { adminTeamService } from "@/services/adminService"; import type { TeamAuditEntryInfo, TeamMemberRole } from "@/types/api"; interface AuditSectionProps { + teamId: number; auditCurrentPage: number; auditEntries: TeamAuditEntryInfo[]; auditLoading: boolean; @@ -24,6 +28,7 @@ interface AuditSectionProps { } export function AdminTeamDetailAuditSection({ + teamId, auditCurrentPage, auditEntries, auditLoading, @@ -36,16 +41,50 @@ export function AdminTeamDetailAuditSection({ setAuditOffset, }: AuditSectionProps) { const { t } = useTranslation(["admin", "core", "settings"]); + const [exporting, setExporting] = useState(false); + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + const handleExport = async () => { + if (exporting) return; + setExporting(true); + try { + await adminTeamService.exportAuditLogs(teamId); + } catch (error) { + handleApiError(error); + } finally { + if (mountedRef.current) setExporting(false); + } + }; return (
-
-

- {t("team_audit_title")} -

-

- {t("team_audit_desc")} -

+
+
+

+ {t("team_audit_title")} +

+

+ {t("team_audit_desc")} +

+
+
{auditLoading && auditEntries.length === 0 ? ( diff --git a/frontend-panel/src/components/settings/team-manage-detail/TeamManageAuditSection.tsx b/frontend-panel/src/components/settings/team-manage-detail/TeamManageAuditSection.tsx index fbd1acfaa..63c882cda 100644 --- a/frontend-panel/src/components/settings/team-manage-detail/TeamManageAuditSection.tsx +++ b/frontend-panel/src/components/settings/team-manage-detail/TeamManageAuditSection.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { EmptyState } from "@/components/common/EmptyState"; import { SkeletonTable } from "@/components/common/SkeletonTable"; @@ -5,12 +6,15 @@ import { UserIdentity } from "@/components/common/UserIdentity"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; +import { handleApiError } from "@/hooks/useApiError"; import { formatAuditDetail, formatAuditSummary } from "@/lib/audit"; import { formatDateAbsolute } from "@/lib/format"; import { formatTeamAuditSummary } from "@/lib/team"; +import { teamService } from "@/services/teamService"; import type { TeamAuditEntryInfo, TeamMemberRole } from "@/types/api"; interface AuditSectionProps { + teamId: number; auditCurrentPage: number; auditEntries: TeamAuditEntryInfo[]; auditLoading: boolean; @@ -25,6 +29,7 @@ interface AuditSectionProps { } export function TeamManageAuditSection({ + teamId, auditCurrentPage, auditEntries, auditLoading, @@ -38,18 +43,52 @@ export function TeamManageAuditSection({ setAuditOffset, }: AuditSectionProps) { const { t } = useTranslation(["core", "settings", "admin"]); + const [exporting, setExporting] = useState(false); + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + const handleExport = async () => { + if (exporting) return; + setExporting(true); + try { + await teamService.exportAuditLogs(teamId); + } catch (error) { + handleApiError(error); + } finally { + if (mountedRef.current) setExporting(false); + } + }; const prevAuditOffset = Math.max(0, auditOffset - auditPageSize); const nextAuditOffset = auditOffset + auditPageSize; return (
-
-

- {t("settings:settings_team_audit_title")} -

-

- {t("settings:settings_team_audit_desc")} -

+
+
+

+ {t("settings:settings_team_audit_title")} +

+

+ {t("settings:settings_team_audit_desc")} +

+
+
{auditLoading && auditEntries.length === 0 ? ( diff --git a/frontend-panel/src/components/settings/team-manage-detail/TeamManageDetail.test.tsx b/frontend-panel/src/components/settings/team-manage-detail/TeamManageDetail.test.tsx index 42a329f50..131d5d909 100644 --- a/frontend-panel/src/components/settings/team-manage-detail/TeamManageDetail.test.tsx +++ b/frontend-panel/src/components/settings/team-manage-detail/TeamManageDetail.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { StrictMode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { TeamManageDetail } from "@/components/settings/team-manage-detail/TeamManageDetail"; import type { UserSummary } from "@/types/api"; @@ -11,6 +12,7 @@ const mockState = vi.hoisted(() => ({ const teamServiceMocks = vi.hoisted(() => ({ addMember: vi.fn(), delete: vi.fn(), + exportAuditLogs: vi.fn(), get: vi.fn(), listAuditLogs: vi.fn(), listMembers: vi.fn(), @@ -75,17 +77,21 @@ const teamSummary = { updated_at: "2026-04-02T00:00:00Z", }; -function renderDetail() { +function renderDetail( + pageTab: "overview" | "members" | "webdav" | "audit" = "overview", +) { return render( - undefined} - pageTab="overview" - teamId={11} - teamSummary={teamSummary} - />, + + undefined} + pageTab={pageTab} + teamId={11} + teamSummary={teamSummary} + /> + , ); } @@ -95,6 +101,7 @@ describe("TeamManageDetail", () => { mockState.navigate.mockReset(); teamServiceMocks.addMember.mockReset(); teamServiceMocks.delete.mockReset(); + teamServiceMocks.exportAuditLogs.mockReset(); teamServiceMocks.get.mockReset(); teamServiceMocks.listAuditLogs.mockReset(); teamServiceMocks.listMembers.mockReset(); @@ -126,6 +133,7 @@ describe("TeamManageDetail", () => { items: [], total: 0, }); + teamServiceMocks.exportAuditLogs.mockResolvedValue(undefined); }); it("uses a fixed shell and a native scrollable detail column", async () => { @@ -190,4 +198,40 @@ describe("TeamManageDetail", () => { expect(screen.getByLabelText("core:name")).toBe(input); expect(input.value).toBe("Product Ops"); }); + + it("exports the current team audit scope and disables the button while pending", async () => { + let resolveExport!: () => void; + teamServiceMocks.exportAuditLogs.mockReturnValue( + new Promise((resolve) => { + resolveExport = resolve; + }), + ); + renderDetail("audit"); + await waitFor(() => + expect(teamServiceMocks.listAuditLogs).toHaveBeenCalled(), + ); + const button = await screen.findByRole("button", { + name: /core:export_csv/, + }); + fireEvent.click(button); + await waitFor(() => { + expect(teamServiceMocks.exportAuditLogs).toHaveBeenCalledWith(11); + expect(button).toBeDisabled(); + }); + resolveExport(); + await waitFor(() => expect(button).not.toBeDisabled()); + }); + + it("routes team audit export failures through the shared API error handler", async () => { + const error = new Error("team export failed"); + teamServiceMocks.exportAuditLogs.mockRejectedValue(error); + renderDetail("audit"); + const button = await screen.findByRole("button", { + name: /core:export_csv/, + }); + fireEvent.click(button); + await waitFor(() => + expect(mockState.handleApiError).toHaveBeenCalledWith(error), + ); + }); }); diff --git a/frontend-panel/src/components/settings/team-manage-detail/useTeamManageSections.tsx b/frontend-panel/src/components/settings/team-manage-detail/useTeamManageSections.tsx index 9e4d4f3f5..257b24df2 100644 --- a/frontend-panel/src/components/settings/team-manage-detail/useTeamManageSections.tsx +++ b/frontend-panel/src/components/settings/team-manage-detail/useTeamManageSections.tsx @@ -199,6 +199,7 @@ export function buildTeamManageSections({ ); const auditSection = canManageTeam ? ( ({ + export: vi.fn(), handleApiError: vi.fn(), list: vi.fn(), })); @@ -300,6 +301,7 @@ vi.mock("@/lib/format", () => ({ vi.mock("@/services/auditService", () => ({ auditService: { + export: (...args: unknown[]) => mockState.export(...args), list: (...args: unknown[]) => mockState.list(...args), }, })); @@ -330,6 +332,7 @@ function renderPage(initialEntry = "/admin/audit") { describe("AdminAuditPage", () => { beforeEach(() => { + mockState.export.mockReset(); mockState.handleApiError.mockReset(); mockState.list.mockReset(); mockState.list.mockResolvedValue({ @@ -338,6 +341,51 @@ describe("AdminAuditPage", () => { }); }); + it("exports the current filters and sort without using the visible page", async () => { + renderPage("/admin/audit?action=file_delete&entityType=folder"); + + await waitFor(() => expect(mockState.list).toHaveBeenCalled()); + fireEvent.click(screen.getByRole("button", { name: /core:export_csv/i })); + + await waitFor(() => { + expect(mockState.export).toHaveBeenCalledWith({ + action: "file_delete", + entity_type: "folder", + sort_by: "created_at", + sort_order: "desc", + }); + }); + }); + + it("disables export while the request is pending and restores it afterward", async () => { + let resolveExport!: () => void; + mockState.export.mockReturnValue( + new Promise((resolve) => { + resolveExport = resolve; + }), + ); + renderPage(); + const button = await screen.findByRole("button", { + name: /core:export_csv/i, + }); + fireEvent.click(button); + await waitFor(() => expect(button).toBeDisabled()); + resolveExport(); + await waitFor(() => expect(button).not.toBeDisabled()); + }); + + it("routes export failures through handleApiError", async () => { + const error = new Error("export failed"); + mockState.export.mockRejectedValue(error); + renderPage(); + fireEvent.click( + await screen.findByRole("button", { name: /core:export_csv/i }), + ); + await waitFor(() => + expect(mockState.handleApiError).toHaveBeenCalledWith(error), + ); + }); + it("shows a loading skeleton while the audit request is pending", () => { mockState.list.mockImplementationOnce(() => new Promise(() => undefined)); diff --git a/frontend-panel/src/pages/admin/AdminAuditPage.tsx b/frontend-panel/src/pages/admin/AdminAuditPage.tsx index 0d85fdaa3..2a3d060a4 100644 --- a/frontend-panel/src/pages/admin/AdminAuditPage.tsx +++ b/frontend-panel/src/pages/admin/AdminAuditPage.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { AdminOffsetPagination } from "@/components/admin/AdminOffsetPagination"; @@ -27,6 +28,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { handleApiError } from "@/hooks/useApiError"; import { useManagedAdminList, useManagedOffset, @@ -134,6 +136,24 @@ export default function AdminAuditPage() { sortOrder, } = query; const setOffset = useManagedOffset(setQuery); + const [exporting, setExporting] = useState(false); + const handleExport = async () => { + if (exporting) return; + setExporting(true); + try { + await auditService.export({ + action: actionFilter.trim() || undefined, + entity_type: + entityTypeFilter === "__all__" ? undefined : entityTypeFilter, + sort_by: sortBy, + sort_order: sortOrder, + }); + } catch (error) { + handleApiError(error); + } finally { + setExporting(false); + } + }; const { currentPage, @@ -291,19 +311,34 @@ export default function AdminAuditPage() { description={t("audit_intro")} className="px-0 md:px-0" actions={ - +
+ + +
} toolbar={ ({ patch: vi.fn(), post: vi.fn(), put: vi.fn(), + downloadFile: vi.fn(), })); vi.mock("@/services/http", () => ({ @@ -31,6 +33,7 @@ vi.mock("@/services/http", () => ({ post: mockState.post, put: mockState.put, }, + downloadFile: mockState.downloadFile, })); describe("adminService", () => { @@ -40,6 +43,7 @@ describe("adminService", () => { mockState.patch.mockReset(); mockState.post.mockReset(); mockState.put.mockReset(); + mockState.downloadFile.mockReset(); }); it("loads media processor runtime status separately from persisted config", () => { @@ -99,6 +103,15 @@ describe("adminService", () => { expect(mockState.get).toHaveBeenNthCalledWith(7, "/admin/config?offset=3"); }); + it("exports a team audit log through the binary download service", () => { + adminTeamService.exportAuditLogs(42, { action: "team_update" }); + expect(mockState.downloadFile).toHaveBeenCalledWith( + "/admin/teams/42/audit-logs/export?action=team_update", + undefined, + "asterdrive-admin-team-42-audit.csv", + ); + }); + it("uses bare list endpoints when no query params are provided", () => { adminSystemService.getInfo(); adminUserService.list(); diff --git a/frontend-panel/src/services/adminService.ts b/frontend-panel/src/services/adminService.ts index 0d6740a43..32782c60b 100644 --- a/frontend-panel/src/services/adminService.ts +++ b/frontend-panel/src/services/adminService.ts @@ -1,4 +1,5 @@ import { withQuery } from "@/lib/queryParams"; +import { api, downloadFile } from "@/services/http"; import type { ActionMessageResp, AddTeamMemberRequest, @@ -105,7 +106,6 @@ import type { UserInfo, UserPage, } from "@/types/api"; -import { api } from "./http"; export interface SetFolderPolicyRequest { policy_id: number | null; @@ -227,6 +227,15 @@ export const adminTeamService = { }), ); }, + exportAuditLogs: ( + id: number, + params: Omit = {}, + ) => + downloadFile( + withQuery(`/admin/teams/${id}/audit-logs/export`, params), + undefined, + `asterdrive-admin-team-${id}-audit.csv`, + ), listMembers: (id: number, params: AdminTeamMemberListQuery = {}) => { const { limit, offset, sort_by, sort_order, ...filters } = params; diff --git a/frontend-panel/src/services/api.generated.ts b/frontend-panel/src/services/api.generated.ts index f517f77d6..f2ccf36d0 100644 --- a/frontend-panel/src/services/api.generated.ts +++ b/frontend-panel/src/services/api.generated.ts @@ -20,6 +20,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/audit-logs/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["export_audit_logs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/config": { parameters: { query?: never; @@ -900,6 +916,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/teams/{id}/audit-logs/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["admin_export_team_audit_logs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/teams/{id}/members": { parameters: { query?: never; @@ -3065,6 +3097,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/teams/{id}/audit-logs/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["export_team_audit_logs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/teams/{id}/members": { parameters: { query?: never; @@ -9434,6 +9482,56 @@ export interface operations { }; }; }; + export_audit_logs: { + parameters: { + query?: { + user_id?: number | null; + action?: string | null; + entity_type?: null | components["schemas"]["AuditEntityType"]; + entity_id?: number | null; + after?: string | null; + before?: string | null; + sort_by?: null | components["schemas"]["AdminAuditLogSortBy"]; + sort_order?: null | components["schemas"]["SortOrder"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description UTF-8 CSV stream with at most 100000 audit rows */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The 100000-row export limit was exceeded */ + 507: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_config: { parameters: { query?: { @@ -14245,6 +14343,64 @@ export interface operations { }; }; }; + admin_export_team_audit_logs: { + parameters: { + query?: { + user_id?: number | null; + action?: string | null; + entity_type?: null | components["schemas"]["AuditEntityType"]; + entity_id?: number | null; + after?: string | null; + before?: string | null; + }; + header?: never; + path: { + /** @description Team ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description UTF-8 CSV stream with at most 100000 audit rows */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The 100000-row export limit was exceeded */ + 507: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; admin_list_team_members: { parameters: { query?: { @@ -22788,6 +22944,64 @@ export interface operations { }; }; }; + export_team_audit_logs: { + parameters: { + query?: { + user_id?: number | null; + action?: string | null; + entity_type?: null | components["schemas"]["AuditEntityType"]; + entity_id?: number | null; + after?: string | null; + before?: string | null; + }; + header?: never; + path: { + /** @description Team ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description UTF-8 CSV stream with at most 100000 audit rows */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Team owner or admin role is required */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The 100000-row export limit was exceeded */ + 507: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_team_members: { parameters: { query?: { diff --git a/frontend-panel/src/services/auditService.test.ts b/frontend-panel/src/services/auditService.test.ts index 43fd7e5a0..6a99c78f1 100644 --- a/frontend-panel/src/services/auditService.test.ts +++ b/frontend-panel/src/services/auditService.test.ts @@ -2,16 +2,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { auditService } from "@/services/auditService"; const apiGet = vi.hoisted(() => vi.fn()); +const downloadFile = vi.hoisted(() => vi.fn()); vi.mock("@/services/http", () => ({ api: { get: apiGet, }, + downloadFile, })); describe("auditService", () => { beforeEach(() => { apiGet.mockReset(); + downloadFile.mockReset(); }); it("builds filtered audit log queries and omits empty values", () => { @@ -38,4 +41,19 @@ describe("auditService", () => { expect(apiGet).toHaveBeenCalledWith("/admin/audit-logs"); }); + + it("exports the complete filtered result without pagination parameters", () => { + auditService.export({ + action: "team_update", + entity_type: "team" as never, + sort_by: "created_at" as never, + sort_order: "desc" as never, + }); + + expect(downloadFile).toHaveBeenCalledWith( + "/admin/audit-logs/export?action=team_update&entity_type=team&sort_by=created_at&sort_order=desc", + undefined, + "asterdrive-audit.csv", + ); + }); }); diff --git a/frontend-panel/src/services/auditService.ts b/frontend-panel/src/services/auditService.ts index 3a41e06dc..e41418457 100644 --- a/frontend-panel/src/services/auditService.ts +++ b/frontend-panel/src/services/auditService.ts @@ -1,5 +1,5 @@ import { withQuery } from "@/lib/queryParams"; -import { api } from "@/services/http"; +import { api, downloadFile } from "@/services/http"; import type { AuditLogListQuery, AuditLogPage } from "@/types/api"; export const auditService = { @@ -16,4 +16,10 @@ export const auditService = { }), ); }, + export: (params: Omit = {}) => + downloadFile( + withQuery("/admin/audit-logs/export", params), + undefined, + "asterdrive-audit.csv", + ), }; diff --git a/frontend-panel/src/services/http.test.ts b/frontend-panel/src/services/http.test.ts index 661e14318..4a71ffb9c 100644 --- a/frontend-panel/src/services/http.test.ts +++ b/frontend-panel/src/services/http.test.ts @@ -792,4 +792,54 @@ describe("http api helpers", () => { expect(mockState.refreshToken).not.toHaveBeenCalled(); expect(mockState.client).not.toHaveBeenCalled(); }); + + it("downloads blobs, honors RFC5987 filenames, and releases object URLs", async () => { + const createObjectURL = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:audit"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL"); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click"); + mockState.client.get.mockResolvedValue({ + data: new Blob(["id\n1\n"], { type: "text/csv" }), + headers: { + "content-disposition": + "attachment; filename*=UTF-8''audit%20%E6%B5%8B%E8%AF%95.csv", + }, + }); + + const { downloadFile } = await loadHttpModule(); + await downloadFile("/admin/audit-logs/export", undefined, "fallback.csv"); + + expect(mockState.client.get).toHaveBeenCalledWith( + "/admin/audit-logs/export", + { responseType: "blob", timeout: 0 }, + ); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(click).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:audit"); + createObjectURL.mockRestore(); + revokeObjectURL.mockRestore(); + click.mockRestore(); + }); + + it("uses the fallback filename when Content-Disposition is absent", async () => { + const createObjectURL = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:fallback"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL"); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click"); + mockState.client.get.mockResolvedValue({ + data: new Blob(["id\n1\n"], { type: "text/csv" }), + headers: {}, + }); + + const { downloadFile } = await loadHttpModule(); + await downloadFile("/admin/audit-logs/export", undefined, "fallback.csv"); + + expect(click).toHaveBeenCalledTimes(1); + expect(click.mock.instances[0]?.download).toBe("fallback.csv"); + createObjectURL.mockRestore(); + revokeObjectURL.mockRestore(); + click.mockRestore(); + }); }); diff --git a/frontend-panel/src/services/http.ts b/frontend-panel/src/services/http.ts index f3deb6c6a..c12df028f 100644 --- a/frontend-panel/src/services/http.ts +++ b/frontend-panel/src/services/http.ts @@ -112,7 +112,7 @@ let refreshPromise: Promise | null = null; export type ApiRequestConfig = Pick< AxiosRequestConfig, - "data" | "headers" | "params" | "signal" + "data" | "headers" | "params" | "signal" | "responseType" | "timeout" > & { /** Optional auth may refresh a session, but must never redirect a public page. */ optionalAuth?: boolean; @@ -401,5 +401,50 @@ export const api = { unwrap(client.patch(url, data, config)), delete: (url: string, config?: ApiRequestConfig) => unwrap(client.delete(url, config)), + download: (url: string, config?: ApiRequestConfig) => + client.get(url, { + ...config, + responseType: "blob", + timeout: config?.timeout ?? 0, + }), client, }; + +function filenameFromContentDisposition(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; + if (encoded) { + try { + return decodeURIComponent(encoded.replace(/^"|"$/g, "")); + } catch { + return undefined; + } + } + return ( + value.match(/filename="([^"]+)"/i)?.[1] ?? + value.match(/filename=([^;]+)/i)?.[1]?.trim() + ); +} + +export async function downloadFile( + url: string, + config: ApiRequestConfig | undefined, + fallbackFilename: string, +): Promise { + const response = await api.download(url, config); + const filename = + filenameFromContentDisposition(response.headers["content-disposition"]) ?? + fallbackFilename; + const objectUrl = URL.createObjectURL(response.data); + try { + const link = document.createElement("a"); + link.href = objectUrl; + link.download = filename; + link.rel = "noopener"; + document.body.appendChild(link); + link.click(); + link.remove(); + } finally { + URL.revokeObjectURL(objectUrl); + } +} diff --git a/frontend-panel/src/services/teamService.test.ts b/frontend-panel/src/services/teamService.test.ts index 2aba9dca3..f0a9d8307 100644 --- a/frontend-panel/src/services/teamService.test.ts +++ b/frontend-panel/src/services/teamService.test.ts @@ -3,6 +3,7 @@ import { teamService } from "@/services/teamService"; const mockState = vi.hoisted(() => ({ delete: vi.fn(), + downloadFile: vi.fn(), get: vi.fn(), patch: vi.fn(), post: vi.fn(), @@ -15,11 +16,13 @@ vi.mock("@/services/http", () => ({ patch: mockState.patch, post: mockState.post, }, + downloadFile: mockState.downloadFile, })); describe("teamService", () => { beforeEach(() => { mockState.delete.mockReset(); + mockState.downloadFile.mockReset(); mockState.get.mockReset(); mockState.patch.mockReset(); mockState.post.mockReset(); @@ -102,4 +105,17 @@ describe("teamService", () => { }); expect(mockState.delete).toHaveBeenNthCalledWith(2, "/teams/5/members/8"); }); + + it("exports team audit logs without pagination parameters", () => { + teamService.exportAuditLogs(7, { + action: "team_update", + before: "2026-01-02T00:00:00Z", + }); + + expect(mockState.downloadFile).toHaveBeenCalledWith( + "/teams/7/audit-logs/export?action=team_update&before=2026-01-02T00%3A00%3A00Z", + undefined, + "asterdrive-team-7-audit.csv", + ); + }); }); diff --git a/frontend-panel/src/services/teamService.ts b/frontend-panel/src/services/teamService.ts index 2175cf3ff..2aebceac3 100644 --- a/frontend-panel/src/services/teamService.ts +++ b/frontend-panel/src/services/teamService.ts @@ -1,5 +1,5 @@ import { withQuery } from "@/lib/queryParams"; -import { api } from "@/services/http"; +import { api, downloadFile } from "@/services/http"; import type { AddTeamMemberRequest, CreateTeamRequest, @@ -40,6 +40,15 @@ export const teamService = { }), ); }, + exportAuditLogs: ( + id: number, + params: Omit = {}, + ) => + downloadFile( + withQuery(`/teams/${id}/audit-logs/export`, params), + undefined, + `asterdrive-team-${id}-audit.csv`, + ), listMembers: (id: number, params: TeamMemberListQuery = {}) => { const { limit, offset, ...filters } = params; diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 6241398e8..8cba8ebd8 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -172,6 +172,7 @@ use utoipa::{Modify, OpenApi}; crate::api::routes::teams::delete_team, crate::api::routes::teams::restore_team, crate::api::routes::teams::list_audit_logs, + crate::api::routes::teams::export_audit_logs, crate::api::routes::teams::list_members, crate::api::routes::teams::add_member, crate::api::routes::teams::patch_member, @@ -346,6 +347,7 @@ use utoipa::{Modify, OpenApi}; crate::api::routes::admin::teams::delete_team, crate::api::routes::admin::teams::restore_team, crate::api::routes::admin::teams::list_team_audit_logs, + crate::api::routes::admin::teams::export_team_audit_logs, crate::api::routes::admin::teams::list_team_members, crate::api::routes::admin::teams::add_team_member, crate::api::routes::admin::teams::patch_team_member, @@ -382,6 +384,7 @@ use utoipa::{Modify, OpenApi}; // admin::audit_logs:全站审计日志查询。 crate::api::routes::admin::audit_logs::list_audit_logs, + crate::api::routes::admin::audit_logs::export_audit_logs, // share_public:匿名访问公开分享时使用的浏览、下载、鉴权和缩略图接口。 crate::api::routes::share_public::get_share_info, diff --git a/src/api/routes/admin/audit_logs.rs b/src/api/routes/admin/audit_logs.rs index 6f3d8092a..0e3ec20b0 100644 --- a/src/api/routes/admin/audit_logs.rs +++ b/src/api/routes/admin/audit_logs.rs @@ -42,3 +42,33 @@ pub async fn list_audit_logs( Ok(HttpResponse::Ok().json(ApiResponse::ok(page))) } + +#[aster_forge_api_docs_macros::path( + get, + path = "/api/v1/admin/audit-logs/export", + tag = "admin", + operation_id = "export_audit_logs", + params(audit::AuditLogFilterQuery, AdminAuditLogSortQuery), + responses( + (status = 200, description = "UTF-8 CSV stream with at most 100000 audit rows", content_type = "text/csv"), + (status = 401, description = crate::api::constants::OPENAPI_UNAUTHORIZED), + (status = 403, description = "Forbidden"), + (status = 507, description = "The 100000-row export limit was exceeded"), + ), + security(("bearer" = [])), +)] +pub async fn export_audit_logs( + state: web::Data, + query: web::Query, + sort: web::Query, +) -> Result { + let export = audit::prepare_csv_export( + state.get_ref().clone(), + audit::AuditExportKind::System, + audit::AuditLogFilters::from_query(&query), + sort.sort_by(), + sort.sort_order(), + ) + .await?; + Ok(crate::api::routes::audit_csv::response(export)) +} diff --git a/src/api/routes/admin/mod.rs b/src/api/routes/admin/mod.rs index db03cef54..a92508df3 100644 --- a/src/api/routes/admin/mod.rs +++ b/src/api/routes/admin/mod.rs @@ -38,7 +38,7 @@ pub(crate) mod tasks; pub(crate) mod teams; pub(crate) mod users; -pub use audit_logs::list_audit_logs; +pub use audit_logs::{export_audit_logs, list_audit_logs}; pub use config::{ config_schema, config_template_variables, delete_config, execute_config_action, get_config, get_media_processing_runtime_status, list_config, set_config, @@ -78,8 +78,9 @@ pub use storage_migrations::{ pub use system_info::get_system_info; pub use tasks::{cleanup_tasks, list_tasks}; pub use teams::{ - add_team_member, create_team, delete_team, delete_team_member, get_team, list_team_audit_logs, - list_team_members, list_teams, patch_team_member, restore_team, update_team, + add_team_member, create_team, delete_team, delete_team_member, export_team_audit_logs, + get_team, list_team_audit_logs, list_team_members, list_teams, patch_team_member, restore_team, + update_team, }; pub use users::{ create_user, create_user_invitation, force_delete_user, get_user, get_user_avatar, @@ -260,6 +261,10 @@ pub fn routes( "/teams/{id}/audit-logs", web::get().to(list_team_audit_logs), ) + .route( + "/teams/{id}/audit-logs/export", + web::get().to(export_team_audit_logs), + ) .route("/teams/{id}/members", web::get().to(list_team_members)) .route("/teams/{id}/members", web::post().to(add_team_member)) .route( @@ -318,6 +323,7 @@ pub fn routes( ) // audit logs .route("/audit-logs", web::get().to(list_audit_logs)) + .route("/audit-logs/export", web::get().to(export_audit_logs)) // webdav locks .route("/locks", web::get().to(list_locks)) .route("/locks/expired", web::delete().to(cleanup_expired_locks)) diff --git a/src/api/routes/admin/teams.rs b/src/api/routes/admin/teams.rs index cdbaffb94..c4422aded 100644 --- a/src/api/routes/admin/teams.rs +++ b/src/api/routes/admin/teams.rs @@ -230,6 +230,38 @@ pub async fn list_team_audit_logs( Ok(HttpResponse::Ok().json(ApiResponse::ok(page))) } +#[aster_forge_api_docs_macros::path( + get, + path = "/api/v1/admin/teams/{id}/audit-logs/export", + tag = "admin", + operation_id = "admin_export_team_audit_logs", + params( + ("id" = i64, Path, description = "Team ID"), + audit::AuditLogFilterQuery + ), + responses( + (status = 200, description = "UTF-8 CSV stream with at most 100000 audit rows", content_type = "text/csv"), + (status = 401, description = crate::api::constants::OPENAPI_UNAUTHORIZED), + (status = 403, description = "Forbidden"), + (status = 404, description = crate::api::constants::OPENAPI_NOT_FOUND), + (status = 507, description = "The 100000-row export limit was exceeded"), + ), + security(("bearer" = [])), +)] +pub async fn export_team_audit_logs( + state: web::Data, + path: web::Path, + query: web::Query, +) -> Result { + let export = team::export_admin_team_audit_entries( + state.get_ref(), + *path, + audit::AuditLogFilters::from_query(&query), + ) + .await?; + Ok(crate::api::routes::audit_csv::response(export)) +} + #[aster_forge_api_docs_macros::path( get, path = "/api/v1/admin/teams/{id}/members", diff --git a/src/api/routes/audit_csv.rs b/src/api/routes/audit_csv.rs new file mode 100644 index 000000000..e9262143b --- /dev/null +++ b/src/api/routes/audit_csv.rs @@ -0,0 +1,56 @@ +use actix_web::{HttpResponse, http::header}; +use chrono::Utc; + +use crate::services::ops::audit::{AuditExportKind, PreparedAuditCsvExport}; + +fn safe_filename_component(value: &str) -> String { + let normalized = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '_' + } + }) + .collect::(); + let normalized = normalized.trim_matches('_'); + if normalized.is_empty() { + "audit".to_string() + } else { + normalized.to_string() + } +} + +pub(crate) fn response(export: PreparedAuditCsvExport) -> HttpResponse { + let export_name = match export.kind { + AuditExportKind::System => "system".to_string(), + AuditExportKind::Team { team_id } => format!("team_{team_id}"), + }; + let filename = format!( + "asterdrive_audit_{}_{}.csv", + safe_filename_component(&export_name), + Utc::now().format("%Y%m%dT%H%M%SZ") + ); + + HttpResponse::Ok() + .insert_header((header::CONTENT_TYPE, "text/csv; charset=utf-8")) + .insert_header(( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{filename}\""), + )) + .insert_header(("X-Audit-Export-Rows", export.total.to_string())) + .streaming(export.stream) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filename_components_strip_header_metacharacters() { + assert_eq!(safe_filename_component("team_42"), "team_42"); + assert_eq!(safe_filename_component("../../bad\r\nname"), "bad__name"); + assert_eq!(safe_filename_component("***"), "audit"); + } +} diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs index 352d717f1..9242de8b2 100644 --- a/src/api/routes/mod.rs +++ b/src/api/routes/mod.rs @@ -3,6 +3,7 @@ use crate::services::workspace::storage::WorkspaceStorageScope; pub mod admin; +pub(crate) mod audit_csv; pub mod auth; pub mod batch; pub mod files; diff --git a/src/api/routes/teams.rs b/src/api/routes/teams.rs index ce442caf4..eff8554da 100644 --- a/src/api/routes/teams.rs +++ b/src/api/routes/teams.rs @@ -32,6 +32,7 @@ pub fn routes( .route("/{id}", web::delete().to(delete_team)) .route("/{id}/restore", web::post().to(restore_team)) .route("/{id}/audit-logs", web::get().to(list_audit_logs)) + .route("/{id}/audit-logs/export", web::get().to(export_audit_logs)) .route( "/{team_id}/webdav-accounts", web::get().to(webdav_accounts::list_team_accounts), @@ -280,6 +281,40 @@ pub async fn list_audit_logs( Ok(HttpResponse::Ok().json(ApiResponse::ok(page))) } +#[aster_forge_api_docs_macros::path( + get, + path = "/api/v1/teams/{id}/audit-logs/export", + tag = "teams", + operation_id = "export_team_audit_logs", + params( + ("id" = i64, Path, description = "Team ID"), + audit::AuditLogFilterQuery + ), + responses( + (status = 200, description = "UTF-8 CSV stream with at most 100000 audit rows", content_type = "text/csv"), + (status = 401, description = crate::api::constants::OPENAPI_UNAUTHORIZED), + (status = 403, description = "Team owner or admin role is required"), + (status = 404, description = crate::api::constants::OPENAPI_NOT_FOUND), + (status = 507, description = "The 100000-row export limit was exceeded"), + ), + security(("bearer" = [])), +)] +pub async fn export_audit_logs( + state: web::Data, + claims: web::ReqData, + path: web::Path, + query: web::Query, +) -> Result { + let export = team::export_team_audit_entries( + state.get_ref(), + *path, + claims.user_id, + audit::AuditLogFilters::from_query(&query), + ) + .await?; + Ok(crate::api::routes::audit_csv::response(export)) +} + #[aster_forge_api_docs_macros::path( get, path = "/api/v1/teams/{id}/members", diff --git a/src/db/repository/audit_log_repo.rs b/src/db/repository/audit_log_repo.rs index 16f004ba6..3c490f078 100644 --- a/src/db/repository/audit_log_repo.rs +++ b/src/db/repository/audit_log_repo.rs @@ -27,6 +27,24 @@ pub struct AuditLogQuery<'a> { pub sort_order: SortOrder, } +#[derive(Debug, Clone)] +pub struct AuditLogExportQuery { + pub user_id: Option, + pub action: Option, + pub entity_type: Option, + pub entity_id: Option, + pub after: Option>, + pub before: Option>, + pub sort_by: AdminAuditLogSortBy, + pub sort_order: SortOrder, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuditLogExportSnapshot { + pub max_id: i64, + pub total: u64, +} + /// 带过滤条件的分页查询 pub async fn find_with_filters( db: &DatabaseConnection, @@ -139,6 +157,204 @@ fn apply_admin_audit_log_sort( } } +fn apply_export_filters( + mut query: Select, + filters: &AuditLogExportQuery, +) -> Select { + if let Some(user_id) = filters.user_id { + query = query.filter(audit_log::Column::UserId.eq(user_id)); + } + if let Some(action) = filters.action.as_deref() { + query = query.filter(audit_log::Column::Action.eq(action)); + } + if let Some(entity_type) = filters.entity_type.as_deref() { + query = query.filter(audit_log::Column::EntityType.eq(entity_type)); + } + if let Some(entity_id) = filters.entity_id { + query = query.filter(audit_log::Column::EntityId.eq(entity_id)); + } + if let Some(after) = filters.after { + query = query.filter(audit_log::Column::CreatedAt.gte(after)); + } + if let Some(before) = filters.before { + query = query.filter(audit_log::Column::CreatedAt.lte(before)); + } + query +} + +pub async fn export_snapshot( + db: &DatabaseConnection, + filters: &AuditLogExportQuery, +) -> Result> { + let max_id = apply_export_filters(AuditLog::find(), filters) + .select_only() + .column(audit_log::Column::Id) + .order_by_desc(audit_log::Column::Id) + .limit(1) + .into_tuple::() + .one(db) + .await + .map_err(AsterError::from)?; + let Some(max_id) = max_id else { + return Ok(None); + }; + + let total = apply_export_filters(AuditLog::find(), filters) + .filter(audit_log::Column::Id.lte(max_id)) + .count(db) + .await + .map_err(AsterError::from)?; + + Ok(Some(AuditLogExportSnapshot { max_id, total })) +} + +fn id_cursor_condition(id: i64, sort_order: SortOrder) -> Condition { + match sort_order { + SortOrder::Asc => Condition::all().add(audit_log::Column::Id.gt(id)), + SortOrder::Desc => Condition::all().add(audit_log::Column::Id.lt(id)), + } +} + +fn non_null_cursor_condition( + column: audit_log::Column, + value: V, + id: i64, + sort_order: SortOrder, +) -> Condition +where + V: Clone + Into, +{ + match sort_order { + SortOrder::Asc => Condition::any().add(column.gt(value.clone())).add( + Condition::all() + .add(column.eq(value)) + .add(audit_log::Column::Id.gt(id)), + ), + SortOrder::Desc => Condition::any().add(column.lt(value.clone())).add( + Condition::all() + .add(column.eq(value)) + .add(audit_log::Column::Id.lt(id)), + ), + } +} + +fn nullable_string_cursor_condition( + column: audit_log::Column, + value: Option<&str>, + id: i64, + sort_order: SortOrder, +) -> Condition { + let id_condition = match sort_order { + SortOrder::Asc => audit_log::Column::Id.gt(id), + SortOrder::Desc => audit_log::Column::Id.lt(id), + }; + let Some(value) = value else { + return Condition::all().add(column.is_null()).add(id_condition); + }; + + let ordered_after = match sort_order { + SortOrder::Asc => column.gt(value), + SortOrder::Desc => column.lt(value), + }; + Condition::any() + .add(column.is_null()) + .add(ordered_after) + .add(Condition::all().add(column.eq(value)).add(id_condition)) +} + +fn apply_export_sort( + mut query: Select, + sort_by: AdminAuditLogSortBy, + sort_order: SortOrder, +) -> Select { + let column = match sort_by { + AdminAuditLogSortBy::Id => return order_by_id(query, audit_log::Column::Id, sort_order), + AdminAuditLogSortBy::CreatedAt => audit_log::Column::CreatedAt, + AdminAuditLogSortBy::UserId => audit_log::Column::UserId, + AdminAuditLogSortBy::Action => audit_log::Column::Action, + AdminAuditLogSortBy::EntityType => audit_log::Column::EntityType, + AdminAuditLogSortBy::EntityName => { + query = query.order_by_asc(audit_log::Column::EntityName.is_null()); + audit_log::Column::EntityName + } + AdminAuditLogSortBy::IpAddress => { + query = query.order_by_asc(audit_log::Column::IpAddress.is_null()); + audit_log::Column::IpAddress + } + }; + order_by_column_with_id(query, column, sort_order, audit_log::Column::Id) +} + +fn export_cursor_condition( + cursor: &product_audit_log::Model, + sort_by: AdminAuditLogSortBy, + sort_order: SortOrder, +) -> Condition { + match sort_by { + AdminAuditLogSortBy::Id => id_cursor_condition(cursor.id, sort_order), + AdminAuditLogSortBy::CreatedAt => non_null_cursor_condition( + audit_log::Column::CreatedAt, + cursor.created_at, + cursor.id, + sort_order, + ), + AdminAuditLogSortBy::UserId => non_null_cursor_condition( + audit_log::Column::UserId, + cursor.user_id, + cursor.id, + sort_order, + ), + AdminAuditLogSortBy::Action => non_null_cursor_condition( + audit_log::Column::Action, + cursor.action.as_str(), + cursor.id, + sort_order, + ), + AdminAuditLogSortBy::EntityType => non_null_cursor_condition( + audit_log::Column::EntityType, + cursor.entity_type.as_str(), + cursor.id, + sort_order, + ), + AdminAuditLogSortBy::EntityName => nullable_string_cursor_condition( + audit_log::Column::EntityName, + cursor.entity_name.as_deref(), + cursor.id, + sort_order, + ), + AdminAuditLogSortBy::IpAddress => nullable_string_cursor_condition( + audit_log::Column::IpAddress, + cursor.ip_address.as_deref(), + cursor.id, + sort_order, + ), + } +} + +pub async fn find_export_page( + db: &DatabaseConnection, + filters: &AuditLogExportQuery, + snapshot: AuditLogExportSnapshot, + cursor: Option<&product_audit_log::Model>, + limit: u64, +) -> Result> { + let mut query = apply_export_filters(AuditLog::find(), filters) + .filter(audit_log::Column::Id.lte(snapshot.max_id)); + if let Some(cursor) = cursor { + query = query.filter(export_cursor_condition( + cursor, + filters.sort_by, + filters.sort_order, + )); + } + let rows = apply_export_sort(query, filters.sort_by, filters.sort_order) + .limit(limit) + .all(db) + .await + .map_err(AsterError::from)?; + rows.into_iter().map(product_audit_log_from_forge).collect() +} + /// Cursor page for admin overview daily aggregation. /// /// Overview only needs `action` and `created_at`, but the cursor also carries diff --git a/src/services/ops/audit/export.rs b/src/services/ops/audit/export.rs new file mode 100644 index 000000000..0c526940e --- /dev/null +++ b/src/services/ops/audit/export.rs @@ -0,0 +1,546 @@ +use std::collections::{HashMap, HashSet}; +use std::pin::Pin; + +use bytes::Bytes; +use chrono::SecondsFormat; +use futures::Stream; +use serde::Serialize; + +use crate::api::pagination::AdminAuditLogSortBy; +use crate::db::repository::{audit_log_repo, user_repo}; +use crate::errors::{AsterError, Result}; +use crate::runtime::PrimaryAppState; +use aster_drive_model::entities::audit_log; +use aster_drive_model::types::TeamMemberRole; +use aster_forge_api::SortOrder; + +use super::AuditLogFilters; + +pub const AUDIT_CSV_EXPORT_MAX_ROWS: u64 = 100_000; +pub const AUDIT_CSV_EXPORT_BATCH_SIZE: u64 = 500; + +const CSV_HEADERS: [&str; 16] = [ + "id", + "created_at", + "actor_user_id", + "actor_username", + "action", + "entity_type", + "entity_id", + "entity_name", + "detail", + "ip_address", + "user_agent", + "member_user_id", + "member_username", + "role", + "previous_role", + "next_role", +]; + +pub type AuditCsvStream = Pin> + Send + 'static>>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuditExportKind { + System, + Team { team_id: i64 }, +} + +impl AuditExportKind { + fn log_name(self) -> &'static str { + match self { + Self::System => "system", + Self::Team { .. } => "team", + } + } +} + +pub struct PreparedAuditCsvExport { + pub kind: AuditExportKind, + pub total: u64, + pub stream: AuditCsvStream, +} + +#[derive(Serialize)] +struct AuditCsvRow { + id: i64, + created_at: String, + actor_user_id: i64, + actor_username: Option, + action: String, + entity_type: String, + entity_id: Option, + entity_name: Option, + detail: Option, + ip_address: Option, + user_agent: Option, + member_user_id: Option, + member_username: Option, + role: Option<&'static str>, + previous_role: Option<&'static str>, + next_role: Option<&'static str>, +} + +struct ExportProgress { + kind: AuditExportKind, + expected: u64, + sent: u64, + completed: bool, + failed: bool, +} + +impl Drop for ExportProgress { + fn drop(&mut self) { + if !self.completed && !self.failed { + tracing::warn!( + export_kind = self.kind.log_name(), + team_id = match self.kind { + AuditExportKind::Team { team_id } => Some(team_id), + AuditExportKind::System => None, + }, + expected_rows = self.expected, + streamed_rows = self.sent, + "audit CSV export stream cancelled before completion" + ); + } + } +} + +fn export_query( + filters: AuditLogFilters, + sort_by: AdminAuditLogSortBy, + sort_order: SortOrder, +) -> audit_log_repo::AuditLogExportQuery { + audit_log_repo::AuditLogExportQuery { + user_id: filters.user_id, + action: filters.action, + entity_type: filters.entity_type.map(|value| value.as_str().to_string()), + entity_id: filters.entity_id, + after: filters.after, + before: filters.before, + sort_by, + sort_order, + } +} + +fn csv_header() -> Result { + let mut writer = csv::WriterBuilder::new() + .has_headers(false) + .from_writer(Vec::new()); + writer + .write_record(CSV_HEADERS) + .map_err(|error| AsterError::internal_error(format!("write audit CSV header: {error}")))?; + writer + .into_inner() + .map(Bytes::from) + .map_err(|error| AsterError::internal_error(format!("finalize audit CSV header: {error}"))) +} + +fn csv_chunk(rows: Vec) -> Result { + let mut writer = csv::WriterBuilder::new() + .has_headers(false) + .from_writer(Vec::new()); + for row in rows { + writer.serialize(row).map_err(|error| { + AsterError::internal_error(format!("serialize audit CSV row: {error}")) + })?; + } + writer + .into_inner() + .map(Bytes::from) + .map_err(|error| AsterError::internal_error(format!("finalize audit CSV chunk: {error}"))) +} + +fn is_sensitive_detail_key(key: &str) -> bool { + let normalized = key + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::(); + if matches!( + normalized.as_str(), + "haspassword" | "mustchangepassword" | "temporarypasswordgenerated" + ) { + return false; + } + + [ + "password", + "passwd", + "token", + "secret", + "credential", + "authorization", + "cookie", + "recoverycode", + "privatekey", + "accesskey", + "apikey", + "session", + "mfa", + "otp", + "totp", + "bearer", + "appkey", + "wopikey", + "sharetoken", + "storagecredential", + ] + .iter() + .any(|sensitive| normalized.contains(sensitive)) +} + +fn neutralize_csv_formula(value: String) -> String { + if value.starts_with(['=', '+', '-', '@', '\t', '\r']) { + format!("'{value}") + } else { + value + } +} + +fn redact_sensitive_details(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(object) => { + object.retain(|key, _| !is_sensitive_detail_key(key)); + for nested in object.values_mut() { + redact_sensitive_details(nested); + } + } + serde_json::Value::Array(values) => { + for nested in values { + redact_sensitive_details(nested); + } + } + _ => {} + } +} + +fn parse_raw_details(raw: Option<&str>, audit_log_id: i64) -> Option { + let raw = raw?; + match serde_json::from_str(raw) { + Ok(mut details) => { + redact_sensitive_details(&mut details); + Some(details) + } + Err(error) => { + tracing::warn!( + audit_log_id, + %error, + "omitting invalid audit details from CSV export" + ); + None + } + } +} + +fn parse_details(model: &audit_log::Model) -> Option { + parse_raw_details(model.details.as_deref(), model.id) +} + +fn detail_i64(details: Option<&serde_json::Value>, key: &str) -> Option { + details?.get(key)?.as_i64() +} + +fn detail_role(details: Option<&serde_json::Value>, key: &str) -> Option { + serde_json::from_value(details?.get(key)?.clone()).ok() +} + +fn role_name(role: Option) -> Option<&'static str> { + match role { + Some(TeamMemberRole::Owner) => Some("owner"), + Some(TeamMemberRole::Admin) => Some("admin"), + Some(TeamMemberRole::Member) => Some("member"), + None => None, + } +} + +fn parsed_details_for_batch(models: &[audit_log::Model]) -> Vec> { + models.iter().map(parse_details).collect() +} + +fn ensure_export_size(total: u64) -> Result<()> { + if total > AUDIT_CSV_EXPORT_MAX_ROWS { + return Err(AsterError::operation_resource_limit_exceeded(format!( + "audit CSV export matched {total} rows; the maximum is {AUDIT_CSV_EXPORT_MAX_ROWS}" + ))); + } + Ok(()) +} + +fn member_user_id(details: Option<&serde_json::Value>) -> Option { + detail_i64(details, "member_user_id") +} + +fn rows_for_batch( + models: &[audit_log::Model], + parsed_details: &[Option], + usernames: &HashMap, +) -> Vec { + models + .iter() + .zip(parsed_details) + .map(|(model, details)| { + let member_user_id = member_user_id(details.as_ref()); + let role = detail_role(details.as_ref(), "role") + .or_else(|| detail_role(details.as_ref(), "removed_role")); + AuditCsvRow { + id: model.id, + created_at: model + .created_at + .to_rfc3339_opts(SecondsFormat::Millis, true), + actor_user_id: model.user_id, + actor_username: usernames + .get(&model.user_id) + .cloned() + .map(neutralize_csv_formula), + action: model.action.as_str().to_string(), + entity_type: model.entity_type.clone(), + entity_id: model.entity_id, + entity_name: super::presentation::sanitize_entity_name( + &model.entity_type, + model.entity_name.clone(), + ) + .map(neutralize_csv_formula), + detail: details + .as_ref() + .map(serde_json::Value::to_string) + .map(neutralize_csv_formula), + ip_address: model.ip_address.clone(), + user_agent: model.user_agent.clone().map(neutralize_csv_formula), + member_user_id, + member_username: member_user_id + .and_then(|id| usernames.get(&id).cloned()) + .map(neutralize_csv_formula), + role: role_name(role), + previous_role: role_name(detail_role(details.as_ref(), "previous_role")), + next_role: role_name(detail_role(details.as_ref(), "next_role")), + } + }) + .collect() +} + +async fn usernames_for_batch( + state: &PrimaryAppState, + models: &[audit_log::Model], + parsed_details: &[Option], +) -> Result> { + let mut ids = HashSet::new(); + for (model, details) in models.iter().zip(parsed_details) { + ids.insert(model.user_id); + if let Some(id) = member_user_id(details.as_ref()) { + ids.insert(id); + } + } + let ids = ids.into_iter().filter(|id| *id > 0).collect::>(); + Ok(user_repo::find_by_ids(state.writer_db(), &ids) + .await? + .into_iter() + .map(|user| (user.id, user.username)) + .collect()) +} + +pub async fn prepare_csv_export( + state: PrimaryAppState, + kind: AuditExportKind, + filters: AuditLogFilters, + sort_by: AdminAuditLogSortBy, + sort_order: SortOrder, +) -> Result { + aster_forge_audit::flush_global_audit_log_manager().await; + let query = export_query(filters, sort_by, sort_order); + let snapshot = audit_log_repo::export_snapshot(state.writer_db(), &query).await?; + let total = snapshot.map_or(0, |snapshot| snapshot.total); + if let Err(error) = ensure_export_size(total) { + tracing::warn!( + export_kind = kind.log_name(), + total, + limit = AUDIT_CSV_EXPORT_MAX_ROWS, + "audit CSV export rejected because the row limit was exceeded" + ); + return Err(error); + } + + let stream = Box::pin(async_stream::try_stream! { + let mut progress = ExportProgress { + kind, + expected: total, + sent: 0, + completed: false, + failed: false, + }; + let header = csv_header().inspect_err(|_| { + progress.failed = true; + })?; + yield header; + + if let Some(snapshot) = snapshot { + let mut cursor = None; + loop { + let models = audit_log_repo::find_export_page( + state.writer_db(), + &query, + snapshot, + cursor.as_ref(), + AUDIT_CSV_EXPORT_BATCH_SIZE, + ) + .await + .inspect_err(|error| { + progress.failed = true; + tracing::error!( + export_kind = kind.log_name(), + streamed_rows = progress.sent, + %error, + "audit CSV export database stream failed" + ); + })?; + if models.is_empty() { + break; + } + + let parsed_details = parsed_details_for_batch(&models); + let usernames = usernames_for_batch(&state, &models, &parsed_details) + .await + .inspect_err(|error| { + progress.failed = true; + tracing::error!( + export_kind = kind.log_name(), + streamed_rows = progress.sent, + %error, + "audit CSV export user lookup failed" + ); + })?; + let batch_len = u64::try_from(models.len()).map_err(|error| { + progress.failed = true; + AsterError::internal_error(format!("audit CSV batch length overflow: {error}")) + })?; + cursor = models.last().cloned(); + let chunk = csv_chunk(rows_for_batch(&models, &parsed_details, &usernames)) + .inspect_err(|_| { + progress.failed = true; + })?; + yield chunk; + progress.sent += batch_len; + + if batch_len < AUDIT_CSV_EXPORT_BATCH_SIZE { + break; + } + } + } + + progress.completed = true; + if progress.sent == progress.expected { + tracing::info!( + export_kind = kind.log_name(), + rows = progress.sent, + "audit CSV export stream completed" + ); + } else { + tracing::warn!( + export_kind = kind.log_name(), + expected_rows = progress.expected, + streamed_rows = progress.sent, + "audit CSV export completed after the snapshot changed" + ); + } + }); + + if total == 0 { + tracing::info!( + export_kind = kind.log_name(), + "prepared empty audit CSV export" + ); + } + Ok(PreparedAuditCsvExport { + kind, + total, + stream, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sensitive_detail_keys_are_removed_recursively_without_dropping_safe_flags() { + let mut value = serde_json::json!({ + "password": "plain", + "has_password": true, + "nested": { + "access_token": "token-value", + "client_secret": "secret-value", + "safe": "kept" + }, + "items": [{"authorization": "Bearer value", "count": 2}], + "temporary_password_generated": true + }); + + redact_sensitive_details(&mut value); + + assert_eq!(value["has_password"], true); + assert_eq!(value["temporary_password_generated"], true); + assert_eq!(value["nested"]["safe"], "kept"); + assert_eq!(value["items"][0]["count"], 2); + let encoded = value.to_string(); + for secret in ["plain", "token-value", "secret-value", "Bearer value"] { + assert!(!encoded.contains(secret)); + } + } + + #[test] + fn csv_writer_preserves_fixed_columns_and_rfc4180_escaping() { + let row = AuditCsvRow { + id: 1, + created_at: "2026-08-20T12:00:00.000Z".to_string(), + actor_user_id: 7, + actor_username: Some("猫,\"admin\"\nname".to_string()), + action: "team_update".to_string(), + entity_type: "team".to_string(), + entity_id: Some(9), + entity_name: Some("A, B".to_string()), + detail: Some("{\"note\":\"line 1\\nline 2\"}".to_string()), + ip_address: None, + user_agent: Some("agent\r\nnext".to_string()), + member_user_id: None, + member_username: None, + role: None, + previous_role: None, + next_role: None, + }; + let mut bytes = csv_header().unwrap().to_vec(); + bytes.extend(csv_chunk(vec![row]).unwrap()); + assert!(!bytes.starts_with(&[0xef, 0xbb, 0xbf])); + + let mut reader = csv::Reader::from_reader(bytes.as_slice()); + assert_eq!(reader.headers().unwrap().len(), CSV_HEADERS.len()); + let records = reader + .records() + .collect::, _>>() + .unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].len(), CSV_HEADERS.len()); + assert_eq!(records[0].get(3), Some("猫,\"admin\"\nname")); + assert_eq!(records[0].get(9), Some("")); + assert_eq!(records[0].get(10), Some("agent\r\nnext")); + } + + #[test] + fn export_row_limit_is_inclusive_at_the_boundary() { + assert!(ensure_export_size(AUDIT_CSV_EXPORT_MAX_ROWS).is_ok()); + let error = ensure_export_size(AUDIT_CSV_EXPORT_MAX_ROWS + 1).unwrap_err(); + assert_eq!( + error.api_error_code().as_str(), + "operation.resource_limit_exceeded" + ); + assert!(error.message().contains("100000")); + } + + #[test] + fn csv_formula_prefixes_are_neutralized() { + assert_eq!( + neutralize_csv_formula("=HYPERLINK(\"x\")".to_string()), + "'=HYPERLINK(\"x\")" + ); + assert_eq!(neutralize_csv_formula("ordinary".to_string()), "ordinary"); + } +} diff --git a/src/services/ops/audit/filters.rs b/src/services/ops/audit/filters.rs index f7e0aea65..0aac95781 100644 --- a/src/services/ops/audit/filters.rs +++ b/src/services/ops/audit/filters.rs @@ -19,6 +19,7 @@ pub struct AuditLogFilterQuery { pub before: Option, } +#[derive(Debug, Clone, Default)] pub struct AuditLogFilters { pub user_id: Option, pub action: Option, diff --git a/src/services/ops/audit/mod.rs b/src/services/ops/audit/mod.rs index 43456e1f3..6a1fd433f 100644 --- a/src/services/ops/audit/mod.rs +++ b/src/services/ops/audit/mod.rs @@ -2,6 +2,7 @@ mod context; mod details; +mod export; mod filters; mod manager; mod models; @@ -35,6 +36,10 @@ pub use details::{ WorkspaceTransferCopyDetails, WorkspaceTransferMoveDetails, WorkspaceTransferScopeDetails, details, }; +pub use export::{ + AUDIT_CSV_EXPORT_BATCH_SIZE, AUDIT_CSV_EXPORT_MAX_ROWS, AuditExportKind, + PreparedAuditCsvExport, prepare_csv_export, +}; pub use filters::{AuditLogFilterQuery, AuditLogFilters}; pub use manager::{ AuditLogInput, log, log_with_db_and_config, log_with_details, log_with_transaction, diff --git a/src/services/ops/audit/presentation.rs b/src/services/ops/audit/presentation.rs index 283bb0780..02c66073e 100644 --- a/src/services/ops/audit/presentation.rs +++ b/src/services/ops/audit/presentation.rs @@ -6,16 +6,6 @@ use aster_drive_model::types::{AuditAction, AuditEntityType}; use super::models::{AuditPresentation, AuditPresentationMessage}; -/// Neutralize spreadsheet formula prefixes before user-controlled audit names -/// reach the admin UI or an exported representation. -fn neutralize_formula(value: String) -> String { - if value.starts_with(['=', '+', '-', '@', '\t', '\r']) { - format!("'{value}") - } else { - value - } -} - fn sensitive_detail_key(key: &str) -> bool { let normalized = key .chars() @@ -70,13 +60,10 @@ pub fn sanitize_details(raw: Option<&str>) -> Option { } pub fn sanitize_entity_name(entity_type: &str, value: Option) -> Option { - value.map(|value| { - if entity_type == "share" { - value - } else { - neutralize_formula(value) - } - }) + if entity_type == "share" { + return None; + } + value } pub fn build_audit_presentation( @@ -1462,11 +1449,11 @@ mod tests { fn audit_query_sanitizers_redact_sensitive_details_and_formula_names() { assert_eq!( sanitize_entity_name("remote_node", Some("=HYPERLINK(\"x\")".to_string())), - Some("'=HYPERLINK(\"x\")".to_string()) + Some("=HYPERLINK(\"x\")".to_string()) ); assert_eq!( sanitize_entity_name("share", Some("=share-token".to_string())), - Some("=share-token".to_string()) + None ); let safe = sanitize_details(Some( r#"{"remote_node_id":42,"access_key":"access","secret_key":"secret","nested":[{"token":"tok"}],"temporary_password_generated":true,"transport":"reverse_tunnel"}"#, diff --git a/src/services/workspace/team/mod.rs b/src/services/workspace/team/mod.rs index 4848c41ac..7340191dc 100644 --- a/src/services/workspace/team/mod.rs +++ b/src/services/workspace/team/mod.rs @@ -14,7 +14,7 @@ mod models; mod shared; use crate::errors::{AsterError, Result}; -use crate::runtime::SharedRuntimeState; +use crate::runtime::{PrimaryAppState, SharedRuntimeState}; use crate::services::{ auth::local, ops::audit::{self, AuditContext}, @@ -181,6 +181,31 @@ pub(crate) async fn list_team_audit_entries( audit::query_team_entries(state, filters, limit, offset).await } +pub(crate) async fn export_team_audit_entries( + state: &PrimaryAppState, + team_id: i64, + actor_user_id: i64, + mut filters: audit::AuditLogFilters, +) -> Result { + let team = get_team(state, team_id, actor_user_id).await?; + if !team.my_role.can_manage_team() { + return Err(AsterError::auth_forbidden( + "team owner or admin role is required", + )); + } + + filters.entity_type = Some(audit::AuditEntityType::Team); + filters.entity_id = Some(team.id); + audit::prepare_csv_export( + state.clone(), + audit::AuditExportKind::Team { team_id: team.id }, + filters, + crate::api::pagination::AdminAuditLogSortBy::CreatedAt, + aster_forge_api::SortOrder::Desc, + ) + .await +} + pub(crate) async fn add_member_with_audit( state: &impl SharedRuntimeState, team_id: i64, @@ -382,6 +407,24 @@ pub(crate) async fn list_admin_team_audit_entries( audit::query_team_entries(state, filters, limit, offset).await } +pub(crate) async fn export_admin_team_audit_entries( + state: &PrimaryAppState, + team_id: i64, + mut filters: audit::AuditLogFilters, +) -> Result { + let team = get_admin_team(state, team_id).await?; + filters.entity_type = Some(audit::AuditEntityType::Team); + filters.entity_id = Some(team.id); + audit::prepare_csv_export( + state.clone(), + audit::AuditExportKind::Team { team_id: team.id }, + filters, + crate::api::pagination::AdminAuditLogSortBy::CreatedAt, + aster_forge_api::SortOrder::Desc, + ) + .await +} + pub(crate) async fn add_admin_member_with_audit( state: &impl SharedRuntimeState, team_id: i64, diff --git a/tests/operations/audit.rs b/tests/operations/audit.rs index bcb1fbb21..89254c8c1 100644 --- a/tests/operations/audit.rs +++ b/tests/operations/audit.rs @@ -361,6 +361,374 @@ async fn test_audit_log_limit_is_clamped() { assert_eq!(body["data"]["offset"], 0); } +#[actix_web::test] +async fn test_audit_csv_export_streams_fixed_schema_filters_and_redacts_secrets() { + let state = common::setup().await; + let app = create_test_app!(state.clone()); + let (token, _) = register_and_login!(app); + let marker = uuid::Uuid::new_v4().to_string(); + + for (entity_name, created_at, details) in [ + ( + format!("CSV {marker} first"), + chrono::Utc::now() - chrono::Duration::seconds(2), + Some(serde_json::json!({ + "password": "plain-secret", + "nested": {"access_token": "token-secret", "safe": "kept"}, + "has_password": true, + "member_user_id": 1, + "role": "admin" + })), + ), + ( + format!("CSV {marker}, \"quoted\"\nname"), + chrono::Utc::now() - chrono::Duration::seconds(1), + Some(serde_json::json!({"note": "unicode 猫\nline"})), + ), + ] { + aster_forge_db::create_audit_log_row( + state.writer_db(), + aster_forge_db::AuditLogCreate { + user_id: 1, + action: AuditAction::TeamUpdate.as_str().to_string(), + entity_type: "team".to_string(), + entity_id: Some(42), + entity_name: Some(entity_name), + details: details.map(|value| value.to_string()), + ip_address: Some("127.0.0.1".to_string()), + user_agent: Some("audit-test\r\nagent".to_string()), + created_at, + }, + ) + .await + .expect("audit CSV fixture should insert"); + } + for index in 0..501 { + aster_forge_db::create_audit_log_row( + state.writer_db(), + aster_forge_db::AuditLogCreate { + user_id: 1, + action: AuditAction::TeamUpdate.as_str().to_string(), + entity_type: "team".to_string(), + entity_id: Some(42), + entity_name: Some(format!("CSV {marker} batch-{index}")), + details: None, + ip_address: None, + user_agent: None, + created_at: chrono::Utc::now(), + }, + ) + .await + .expect("cursor boundary audit fixture should insert"); + } + + let formula_name = format!("=HYPERLINK(\"{marker}\")"); + aster_forge_db::create_audit_log_row( + state.writer_db(), + aster_forge_db::AuditLogCreate { + user_id: 1, + action: AuditAction::TeamUpdate.as_str().to_string(), + entity_type: "team".to_string(), + entity_id: Some(42), + entity_name: Some(formula_name.clone()), + details: None, + ip_address: None, + user_agent: None, + created_at: chrono::Utc::now(), + }, + ) + .await + .expect("formula audit fixture should insert"); + + let share_token = format!("share-secret-{marker}"); + aster_forge_db::create_audit_log_row( + state.writer_db(), + aster_forge_db::AuditLogCreate { + user_id: 1, + action: AuditAction::ShareCreate.as_str().to_string(), + entity_type: "share".to_string(), + entity_id: Some(77), + entity_name: Some(share_token.clone()), + details: Some( + serde_json::json!({"token": share_token, "session_id": "session-secret", "safe": "kept"}) + .to_string(), + ), + ip_address: None, + user_agent: None, + created_at: chrono::Utc::now(), + }, + ) + .await + .expect("share secret audit fixture should insert"); + + let list_request = test::TestRequest::get() + .uri("/api/v1/admin/audit-logs?entity_type=share&limit=10") + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let list_body: Value = test::call_and_read_body_json(&app, list_request).await; + let list_text = list_body["data"]["items"].to_string(); + assert!(!list_text.contains("share-secret-")); + assert!(!list_text.contains("session-secret")); + + let formula_list_request = test::TestRequest::get() + .uri("/api/v1/admin/audit-logs?action=team_update&entity_type=team&entity_id=42&limit=600") + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let formula_list_body: Value = test::call_and_read_body_json(&app, formula_list_request).await; + assert!( + formula_list_body["data"]["items"] + .as_array() + .unwrap() + .iter() + .any(|item| item["entity_name"] == formula_name), + "formula name missing from list: {formula_list_body}" + ); + + let req = test::TestRequest::get() + .uri("/api/v1/admin/audit-logs/export?action=team_update&entity_type=team&entity_id=42&sort_by=created_at&sort_order=asc") + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "text/csv; charset=utf-8" + ); + let disposition = resp + .headers() + .get("content-disposition") + .unwrap() + .to_str() + .unwrap(); + assert!(disposition.starts_with("attachment; filename=\"asterdrive_audit_system_")); + let body = test::read_body(resp).await; + let mut reader = csv::Reader::from_reader(body.as_ref()); + assert_eq!(reader.headers().unwrap().len(), 16); + let records = reader + .records() + .collect::, _>>() + .unwrap(); + let records: Vec<_> = records + .into_iter() + .filter(|record| record.get(7).is_some_and(|name| name.contains(&marker))) + .collect(); + assert_eq!(records.len(), 504); + assert_eq!(records[0].len(), 16); + assert_eq!(records[0].get(3), Some("testuser")); + assert_eq!(records[0].get(11), Some("1")); + assert_eq!(records[0].get(13), Some("admin")); + let quoted_name = format!("CSV {marker}, \"quoted\"\nname"); + assert!( + records + .iter() + .any(|record| record.get(7) == Some(quoted_name.as_str())) + ); + let csv_text = String::from_utf8(body.to_vec()).unwrap(); + for secret in ["plain-secret", "token-secret"] { + assert!(!csv_text.contains(secret)); + } + let neutralized_formula_name = format!("'{formula_name}"); + assert!( + records + .iter() + .any(|record| record.get(7) == Some(neutralized_formula_name.as_str())) + ); + assert!(records.iter().any(|record| { + record + .get(8) + .is_some_and(|detail| detail.contains("\"safe\":\"kept\"")) + })); + + let share_export = test::TestRequest::get() + .uri("/api/v1/admin/audit-logs/export?action=share_create&entity_type=share") + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let share_csv = String::from_utf8( + test::read_body(test::call_service(&app, share_export).await) + .await + .to_vec(), + ) + .unwrap(); + assert!(!share_csv.contains("share-secret-")); + assert!(!share_csv.contains("session-secret")); + let mut share_reader = csv::Reader::from_reader(share_csv.as_bytes()); + let share_record = share_reader.records().next().unwrap().unwrap(); + assert_eq!(share_record.get(8), Some("{\"safe\":\"kept\"}")); + assert!(csv_text.contains("CSV ")); +} + +#[actix_web::test] +async fn test_audit_csv_export_preserves_admin_and_team_permissions() { + let state = common::setup().await; + let app = create_test_app!(state.clone()); + let (admin_token, _) = register_and_login!(app); + let team_admin_user_id = admin_create_user!( + app, + admin_token, + "csv-team-admin", + "csv-team-admin@example.com", + "password123" + ); + let create = test::TestRequest::post() + .uri("/api/v1/admin/teams") + .insert_header(("Cookie", common::access_cookie_header(&admin_token))) + .insert_header(common::csrf_header_for(&admin_token)) + .set_json(serde_json::json!({ + "name": "CSV Permission Team", + "admin_user_id": team_admin_user_id + })) + .to_request(); + let body: Value = test::call_and_read_body_json(&app, create).await; + let team_id = body["data"]["id"].as_i64().unwrap(); + + let bait = format!("CSV bait outside team {team_id}"); + aster_forge_db::create_audit_log_row( + state.writer_db(), + aster_forge_db::AuditLogCreate { + user_id: 1, + action: AuditAction::FileUpload.as_str().to_string(), + entity_type: "file".to_string(), + entity_id: Some(999_999), + entity_name: Some(bait.clone()), + details: None, + ip_address: None, + user_agent: None, + created_at: chrono::Utc::now(), + }, + ) + .await + .expect("team export bait audit fixture should insert"); + + let admin_export = test::TestRequest::get() + .uri(&format!("/api/v1/admin/teams/{team_id}/audit-logs/export")) + .insert_header(("Cookie", common::access_cookie_header(&admin_token))) + .insert_header(common::csrf_header_for(&admin_token)) + .to_request(); + let admin_response = test::call_service(&app, admin_export).await; + assert_eq!(admin_response.status(), 200); + let admin_csv = test::read_body(admin_response).await; + assert!(!String::from_utf8_lossy(&admin_csv).contains(&bait)); + + let (member_token, _) = login_user!(app, "csv-team-admin", "password123"); + let team_export = test::TestRequest::get() + .uri(&format!("/api/v1/teams/{team_id}/audit-logs/export")) + .insert_header(("Cookie", common::access_cookie_header(&member_token))) + .insert_header(common::csrf_header_for(&member_token)) + .to_request(); + let team_response = test::call_service(&app, team_export).await; + assert_eq!(team_response.status(), 200); + let team_csv = test::read_body(team_response).await; + assert!(!String::from_utf8_lossy(&team_csv).contains(&bait)); + + let member_user_id = admin_create_user!( + app, + admin_token, + "csv-team-member", + "csv-team-member@example.com", + "password123" + ); + let add_member = test::TestRequest::post() + .uri(&format!("/api/v1/admin/teams/{team_id}/members")) + .insert_header(("Cookie", common::access_cookie_header(&admin_token))) + .insert_header(common::csrf_header_for(&admin_token)) + .set_json(serde_json::json!({"user_id": member_user_id, "role": "member"})) + .to_request(); + assert_eq!(test::call_service(&app, add_member).await.status(), 201); + let (ordinary_token, _) = login_user!(app, "csv-team-member", "password123"); + let forbidden = test::TestRequest::get() + .uri(&format!("/api/v1/teams/{team_id}/audit-logs/export")) + .insert_header(("Cookie", common::access_cookie_header(&ordinary_token))) + .insert_header(common::csrf_header_for(&ordinary_token)) + .to_request(); + assert_eq!(test::call_service(&app, forbidden).await.status(), 403); +} + +#[actix_web::test] +async fn test_audit_csv_export_nullable_sort_cursors_cover_both_directions() { + let state = common::setup().await; + let app = create_test_app!(state.clone()); + let (token, _) = register_and_login!(app); + for index in 0..501 { + aster_forge_db::create_audit_log_row( + state.writer_db(), + aster_forge_db::AuditLogCreate { + user_id: 1, + action: AuditAction::TeamUpdate.as_str().to_string(), + entity_type: "user".to_string(), + entity_id: Some(index), + entity_name: (index % 2 == 0).then(|| format!("cursor-{index:04}")), + details: None, + ip_address: (index % 3 == 0).then(|| format!("192.0.2.{index}")), + user_agent: None, + created_at: chrono::Utc::now(), + }, + ) + .await + .expect("nullable cursor fixture should insert"); + } + + for sort_by in [ + "id", + "created_at", + "user_id", + "action", + "entity_type", + "entity_name", + "ip_address", + ] { + for sort_order in ["asc", "desc"] { + let req = test::TestRequest::get() + .uri(&format!( + "/api/v1/admin/audit-logs/export?action=team_update&entity_type=user&sort_by={sort_by}&sort_order={sort_order}" + )) + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let response = test::call_service(&app, req).await; + assert_eq!(response.status(), 200); + let body = test::read_body(response).await; + let mut reader = csv::Reader::from_reader(body.as_ref()); + let records = reader + .records() + .collect::, _>>() + .unwrap(); + let ids = records + .iter() + .map(|record| record.get(0).unwrap().to_string()) + .collect::>(); + assert_eq!( + records.len(), + 501, + "{sort_by} {sort_order} should emit every row" + ); + assert_eq!( + ids.len(), + 501, + "{sort_by} {sort_order} should scan every row exactly once" + ); + } + } + + let empty = test::TestRequest::get() + .uri("/api/v1/admin/audit-logs/export?action=does_not_exist") + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let empty_response = test::call_service(&app, empty).await; + assert_eq!(empty_response.status(), 200); + let empty_body = test::read_body(empty_response).await; + assert_eq!( + csv::Reader::from_reader(empty_body.as_ref()) + .records() + .count(), + 0 + ); +} + #[actix_web::test] async fn test_audit_log_admin_only() { let state = common::setup().await;