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 行删除
+59 -1
查看文件
@@ -14,11 +14,17 @@
多用户约定(改代码前务必先读):
* `user_id = 0` 在 settings / collect_runs / audit_log 里表示**实例级**;
usage_records 里 0 是「历史遗留数据尚未归属」的兜底值,正常不会出现。
* **settings 里只有两个键是个人级:`cookie` 与 `user_agent`**(即
`config.USER_EDITABLE_KEYS`)。调度、采集参数、接口地址、注册策略全部
是实例级(`config.GLOBAL_KEYS`),`set_setting()` 会把它们强制写到
user_id=0 —— 因此「管理员改了但别人不生效」这类 bug 在结构上不存在。
* `get_settings()` 会把 `ENCRYPTED_KEYS`(Cookie)**一律置空**;
要拿明文只有 `get_secret()` 一条路。这样任何「顺手打印一下全部配置」
的代码都不可能把凭证带出去。
* `NO_FALLBACK_KEYS`(Cookie / User-Agent)**不参与实例级回退**:
Cookie 是账号凭证,回落等于串号,是最严重的一类越权。
* `slot:*` 调度簿记键是**个人级**(每个账号各自记「今天这个槽位跑过没」),
虽然时刻本身是实例级的 —— 这两个千万别一起改。
"""
import os
import sqlite3
@@ -34,7 +40,8 @@ _initialized = False
# 库结构版本。写在 PRAGMA user_version 里,用来判断是否需要迁移。
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
# 2 -> 多用户布局(见 schema.sql 顶部说明)
DB_SCHEMA_VERSION = 2
# 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS)
DB_SCHEMA_VERSION = 3
# 这些键即使个人作用域没有值,也**不**回落到实例级
NO_FALLBACK_KEYS = {"cookie", "user_agent"}
@@ -225,6 +232,38 @@ def _encrypt_legacy_secrets(conn):
return done
def _promote_personal_to_global(conn):
"""把「原本个人级、现在实例级」的配置收敛到 user_id=0。幂等,可反复执行。
v1.3.0 把调度与采集参数从个人级升为实例级(普通用户只读)。升级时
必须做两件事,否则会静默丢配置:
1. **把第一个管理员的个人值提升到实例级** —— 管理员此前设的
`schedule_times=08:00` 若不提升,读取路径会因为「全局键不再看个人
作用域」而直接跳过它,表现成「设置莫名其妙回到默认值」。
2. **删掉所有个人作用域里的全局键** —— 留着不会被读(get_settings 会
跳过),但会让后面排障的人以为「这个键是个人级的」。
对本来就属于实例级的键(api_base / allow_register 等)这是空操作。
"""
owner = _first_owner_uid(conn)
promoted, cleaned = [], 0
for k in sorted(config.GLOBAL_KEYS):
if owner:
mine = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(owner, k)).fetchone()
if mine is not None and mine["value"] is not None:
cur = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
(k,)).fetchone()
if cur is None or cur["value"] != mine["value"]:
set_setting(conn, k, mine["value"], 0) # 全局键强制落 uid=0
promoted.append(k)
# DELETE 的 rowcount 对「没有匹配行」返回 0,所以这里天然幂等
cur2 = conn.execute("DELETE FROM settings WHERE user_id<>0 AND key=?", (k,))
cleaned += cur2.rowcount or 0
return promoted, cleaned
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
"""建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。"""
global _initialized
@@ -244,9 +283,18 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non
ts = now_str()
# 默认配置灌在**实例级**(user_id=0)。个人作用域不预置行,
# 读取时按「个人 -> 实例 -> DEFAULTS」三级回落,语义更清楚。
#
# 凭证类键(cookie / user_agent)**刻意不灌**:它们是个人级的,
# 实例级存一份没有任何读取路径会用到(NO_FALLBACK_KEYS 挡住了回落),
# 只会让「读一下实例配置看看」的人拿到一个不该存在的凭证位。
for k, v in config.DEFAULTS.items():
if k in config.USER_EDITABLE_KEYS:
continue
conn.execute("INSERT OR IGNORE INTO settings(user_id,key,value,updated_at)"
" VALUES(0,?,?,?)", (k, v, ts))
# 顺手清掉历史遗留在实例级的凭证行(老版本曾把它们当实例级配置存过)
for k in sorted(config.USER_EDITABLE_KEYS):
conn.execute("DELETE FROM settings WHERE user_id=0 AND key=?", (k,))
# 顺手把历史明文凭证加密(幂等;新库无事可做)
enc = _encrypt_legacy_secrets(conn)
if enc:
@@ -255,6 +303,16 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non
" VALUES(0,?,?,?,?,?)",
(ts, "system", "encrypt_secrets",
"明文凭证已加密:%s" % ", ".join(enc)[:400], "127.0.0.1"))
# 调度/采集参数在 v1.3.0 升为实例级:把管理员那份提升上去并清掉个人残留
promoted, cleaned = _promote_personal_to_global(conn)
if promoted or cleaned:
note = "配置作用域收敛:提升 %s 到实例级%s" % (
", ".join(promoted) if promoted else "(无)",
",清理 %d 条个人级残留" % cleaned if cleaned else "")
migrated.append(note)
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(0,?,?,?,?,?)",
(ts, "system", "promote_global_settings", note[:400], "127.0.0.1"))
if create_admin:
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if n == 0: