Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .cargo/audit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
18 changes: 8 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 不再进入目标库。
Expand All @@ -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

Expand Down
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions developer-docs/en/api/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`.

Expand Down
3 changes: 3 additions & 0 deletions developer-docs/en/api/teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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`
Expand Down
8 changes: 8 additions & 0 deletions developer-docs/zh-CN/api/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}` | 调整成员角色 |
Expand Down Expand Up @@ -995,6 +996,7 @@ POST /api/v1/admin/policies/action
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/audit-logs` | 分页查询审计日志 |
| `GET` | `/admin/audit-logs/export` | 按筛选和排序条件流式导出全站审计 CSV |

当前实现支持这些查询参数:

Expand All @@ -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` 字段,给前端做结构化展示:
Expand Down
3 changes: 3 additions & 0 deletions developer-docs/zh-CN/api/teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}` | 调整成员角色 |
Expand All @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@ export function AdminTeamDetailDialog({

const auditSection = (
<AdminTeamDetailAuditSection
teamId={teamId ?? 0}
auditCurrentPage={auditCurrentPage}
auditEntries={auditEntries}
auditLoading={auditLoading}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { StrictMode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AdminTeamDetailAuditSection } from "@/components/admin/admin-team-detail/AdminTeamDetailAuditSection";

const mocks = vi.hoisted(() => ({
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(
<StrictMode>
<AdminTeamDetailAuditSection
teamId={42}
auditCurrentPage={1}
auditEntries={[]}
auditLoading={false}
auditOffset={0}
auditTotal={0}
auditTotalPages={1}
nextAuditPageDisabled
prevAuditPageDisabled
roleLabel={(role) => role}
setAuditOffset={vi.fn()}
/>
</StrictMode>,
);
}

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<void>((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),
);
});
});
Loading
Loading