feat(权限): 收敛普通账号写权限至本人凭证

将调度时刻、采集参数等实例级配置收归管理员,普通账号仅可维护本人 Cookie 与 User-Agent。
新增 config.writable_by 作为唯一写权限入口,set_setting 强制全局键落到 user_id=0,
消除「管理员改了只有自己生效」的静默缺陷。新增 tools/check_docs.py 文档自检,
smoke 断言扩至 215 项、check_live 扩至 122 项并支持普通账号越权验收,
忽略 backups/、data/*.bak* 与 legacy-v1/,版本升至 v1.3.0。
这个提交包含在:
2026-09-18 08:46:00 +08:00
父节点 df7db3582e
当前提交 1bf961f6b3
共修改 42 个文件,包含 2615 行新增和 854 行删除
+42 -9
查看文件
@@ -88,8 +88,13 @@ audit_log(id, user_id, at, actor, action, detail, ip)
```
**`user_id = 0` 的含义**:在 `settings` / `collect_runs` / `audit_log` 里表示
**实例级**(所有账号共用,例如 `api_base`、系统迁移审计);在 `usage_records` 里
是「尚未归属」的兜底值,正常不会出现。
**实例级**(所有账号共用,例如 `api_base`、`schedule_times`、`page_size`、系统迁移审计);
在 `usage_records` 里是「尚未归属」的兜底值,正常不会出现。
> ⚠️ 实例级**不是**「孤儿行」。清理孤儿数据的 SQL 必须显式排除 `user_id = 0`,
> 例如 `DELETE FROM settings WHERE user_id <> 0 AND user_id NOT IN (SELECT id FROM users)`。
> 写成 `user_id NOT IN (SELECT id FROM users)` 会一次性删光整片实例级配置 ——
> 表现是「所有账号的调度、采集参数、注册策略突然全部回到默认值」。
索引全部以 `user_id` 打头,覆盖六类热点查询:
`(user_id, day)`、`(user_id, day, hour)`、`(user_id, model, day)`、`(user_id, client, day)`、
@@ -298,7 +303,12 @@ records: id c(credits) m(model) cl(client) t(ts) px(prompt)
### 授权与数据隔离
`@login_required`(`/api/*` 未登录返回 401 JSON,页面跳登录)+
`@admin_required`(403)两层。`/users`、`/api/users*`、`/logs/tail`、`vacuum` 要管理员。
`@admin_required`(403)两层。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum`。
两者之间还有一层「同一个页面、两种形态」:`/tasks` 与 `/config` 对普通账号**仍然可达**,
但渲染成**只读形态**(不给表单,换成只读表格 + 一句说明为什么只读),
写接口也会拒绝。页面形态与接口判断**共用 `config.writable_by()`**,
所以不存在「界面上没按钮、构造请求却能改」的空隙 —— 这是本次权限收敛刻意保证的性质。
**多租户隔离靠「显式传参」而不是「隐式全局」**,这是本节最重要的一条设计:
@@ -319,7 +329,7 @@ scheduler.slots(conn, uid=0)
| 项 | 做法 |
|---|---|
| 单条读取也过滤 | `/api/records/<id>`、`/api/runs/<id>` 的 `WHERE` 都带 `user_id` |
| 配置作用域 | 三级回落 `个人 → 实例 → DEFAULTS`;`GLOBAL_KEYS` 只有管理员能改 |
| 配置作用域 | 三级回落 `个人 → 实例 → DEFAULTS`;`GLOBAL_KEYS`(云端接口 + 注册策略 + 调度 + 采集参数)一律存**实例级 `user_id=0`** 且仅管理员可写;普通账号可写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}` |
| **凭证不回落** | `NO_FALLBACK_KEYS = {cookie, user_agent}` 跳过实例级回落 —— 否则新账号会「继承」管理员的 Cookie,这是最严重的串号越权 |
| 导出隔离 | CSV 文件名带账号名(多用户下同目录同名会互相覆盖) |
| 审计归属 | `audit_log` / `collect_runs` 都带 `user_id`;`/api/audit` 普通账号只看自己 |
@@ -421,9 +431,30 @@ page_size = db.get_int(conn, "page_size", 200) # 任何异常都回落默认
└── NO_FALLBACK_KEYS(cookie / user_agent)到此为止,不回落到实例级
```
`GLOBAL_KEYS`(`api_base` / `api_path` / `allow_register` / `register_max_per_ip` /
`captcha_policy` / `captcha_length`)在读写两侧都被强制折算到 `user_id = 0`,
所以它们天然只有一份,非管理员改不了。
`GLOBAL_KEYS`(共 17 个键,分四组)在读写两侧都被强制折算到 `user_id = 0`,
所以它们天然只有一份,非管理员改不了:
| 分组 | 键 | 为什么归实例级 |
|---|---|---|
| 云端接口(2) | `api_base`、`api_path` | 一台部署连的就是那一个云端,逐账号配没有意义 |
| 注册策略(4) | `allow_register`、`register_max_per_ip`、`captcha_policy`、`captcha_length` | 敞开注册与否是运营决策,不能由任意账号开关 |
| 调度(4) | `schedule_enabled`、`schedule_times`、`catch_up`、`catch_up_grace_hours` | **普通账号不能设置定时任务频率**(本轮需求) |
| 采集参数(7) | `page_size`、`rewind_minutes`、`drift_tolerance_minutes`、`max_prompt`、`verify_days`、`timeout`、`ssl_verify` | 关掉 `ssl_verify` 就能把所有人的 Cookie 发到中间人手里 |
这三样东西必须**对齐**,缺一不可:
1. **键存在哪一级** —— `GLOBAL_KEYS` 一律 `user_id = 0`;
2. **谁能写** —— `config.writable_by(key, is_admin)`:只有 `USER_EDITABLE_KEYS` 对普通账号放行;
3. **读时落哪** —— 三级回落 `个人 → 实例 → DEFAULTS`。
> 🔴 最常见的静默 bug 是「只做到第 2 条」:管理员能改,但值写进了**管理员自己的 `user_id=n`**。
> 别的账号读同一个键时会**落回 `DEFAULTS`**,于是「管理员改了,但只有他自己那边生效」,
> 界面上完全看不出来。所以 `set_setting()` 内部会把全局键**强制重定向**到 `user_id = 0`,
> 从结构上消除这种可能,而不是靠调用方记得传 `0`。
有个 **`slot:*` 例外**:`slot:09:00` 这类簿记键表示「**本账号**今天这个槽位跑过没有」,
它是**个人级**(每个账号各记一份),而它所指向的时刻 `schedule_times` 是**实例级**。
这两者千万别一起改 —— 把 `slot:*` 也搬到实例级,会让两个账号互相以为对方已经跑过当天采集。
有个**容易误判**的细节:`NO_FALLBACK_KEYS` 只拦住「实例级那一行」,不拦 `DEFAULTS`。
所以一个全新账号读 `user_agent` 拿到的是 `DEFAULTS` 里的**通用 Chrome UA**(非空),
@@ -513,13 +544,15 @@ GMT+8 下 `new Date("2026-08-15T00:00:00")` 的 UTC 时刻是前一天 16:00,
| 层 | 手段 | 抓什么 |
|---|---|---|
| 1 | 独立聚合对账(直读 CSV 不走 `query.py`) | 口径错、少算。热力图要**逐格**比,历史上出过「同格覆盖少算 84%」 |
| 2 | `tools/smoke.py`(**165 项断言**,离线) | 模板残留、历史缺陷防回归 ①~⑭、**多用户隔离 / 凭证保密 / 注册与验证码全链路**、静态资源 404、class↔CSS 对账 |
| 3 | `tools/check_live.py`(**83 项断言**,真实 HTTP) | `test_client` 覆盖不到的:waitress、端口、cookie 往返、开放重定向、CSRF、验证码、安全响应头 |
| 2 | `tools/smoke.py`(**215 项断言**,离线) | 模板残留、历史缺陷防回归 ①~⑭、**多用户隔离 / 凭证保密 / 注册与验证码全链路**、**非管理员越权面全关死**、**全局键必须落在实例级**、静态资源 404、class↔CSS 对账 |
| 3 | `tools/check_live.py`(**122 项断言**,真实 HTTP) | `test_client` 覆盖不到的:waitress、端口、cookie 往返、开放重定向、CSRF、验证码、安全响应头;`--as 账号:密码` 追加普通账号越权验收 |
| 4 | Node DOM stub + `vm.runInContext` 跑大屏真实脚本 | 「页面聚合 == 独立算出的聚合」、切区间只发一次请求 |
| 5 | `tools/shots.py`(Playwright 截图 + console/pageerror) | **界面层**。本轮最有价值的 bug(大屏全白)只有它抓到 |
| 附 | `tools/check_docs.py`(**文档层**,不属于上面五层) | 内部链接 / 跨文件锚点 / 图片引用 / **绝对路径泄漏** / 版本一致性 / 产品名硬编码。**章节一重排,锚点就静默失效**,Markdown 自己不报错,只有它抓得到。文档清单自动发现,不写死文件名 |
```bash
python tools/smoke.py # 1~2 层,随时跑
python tools/check_docs.py # 文档层,改过 md 就跑
python manage.py serve --port 8849 --no-scheduler # 另开终端
python tools/check_live.py --base http://127.0.0.1:8849 # 3 层
python tools/shots.py --base http://127.0.0.1:8849 --full # 5 层