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。
这个提交包含在:
+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:
|
||||
|
||||
在新工单中引用
屏蔽一个用户