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。
这个提交包含在:
+156
-18
@@ -11,13 +11,18 @@
|
||||
因此能覆盖到「页面模板渲染是否正确」,且不需要先起服务、不需要密码。
|
||||
|
||||
覆盖内容:
|
||||
1. 全页面渲染(含 /users,需管理员身份)——模板报错会直接暴露成 500
|
||||
1. 全页面渲染(含 /users 与 /logs,均需管理员身份)——模板报错会直接暴露成 500
|
||||
2. 模板未渲染残留(HTML 里出现 {{ / {% 说明有变量名写错)
|
||||
3. 历史缺陷防回归(见 4. 的 ①~⑭)
|
||||
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / 权限边界
|
||||
3. 历史缺陷防回归(见 5. 的 ①~⑭)
|
||||
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / **普通账号的越权面**(4. 与 4b.)
|
||||
5. CSV 导出可被标准 csv 解析、列数一致
|
||||
6. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
|
||||
|
||||
权限模型(改断言前先读这一行):
|
||||
普通账号**只能写** `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
|
||||
调度、采集参数、接口地址、注册策略全部只有管理员能写,且一律存在实例级
|
||||
`user_id=0`。所以「越权写」的期望结果是 **400**,而不是「写进去但看不到」。
|
||||
|
||||
写库说明:会写少量 audit_log 行;另外会**临时**建两个普通账号
|
||||
(一个用来验权限边界,一个用来走完整注册链路),无论成功失败都在 finally 里删掉。
|
||||
不会改动任何用量数据。
|
||||
@@ -107,6 +112,17 @@ def run() -> None:
|
||||
return
|
||||
ADMIN = admin_row["id"]
|
||||
|
||||
# 实例级配置快照:整轮跑完必须一条不少。
|
||||
# 历史缺陷:清理语句写成 `user_id NOT IN (SELECT id FROM users)`,会把
|
||||
# user_id=0(实例级)当成孤儿一起删掉 —— 表现为「跑一次 smoke,全实例的
|
||||
# 调度/采集参数被重置」,而且不报任何错。所以这里前后各取一次快照。
|
||||
inst_before = {r["key"] for r in conn.execute("SELECT key FROM settings WHERE user_id=0")}
|
||||
chk("实例级配置非空(否则下面那条断言会空转)", len(inst_before) > 0,
|
||||
"keys=%d" % len(inst_before))
|
||||
chk("实例级不含凭证类键(cookie/user_agent 恒为个人级)",
|
||||
not (inst_before & config.USER_EDITABLE_KEYS),
|
||||
"混入=%s" % sorted(inst_before & config.USER_EDITABLE_KEYS))
|
||||
|
||||
# ---------------- 1. 未登录 ----------------
|
||||
print("== 1. 未登录:受保护页应跳登录、API 应 401 ==")
|
||||
with app.test_client() as cli:
|
||||
@@ -241,15 +257,31 @@ def run() -> None:
|
||||
# User-Agent 本身不是秘密,新账号拿到 DEFAULTS 里的**通用** UA 是对的;
|
||||
# 要守住的是「不能继承别人存下来的那一份」。用一个哨兵值把这点钉死:
|
||||
sentinel = "SMOKE-SENTINEL-UA/%s" % _rand()
|
||||
saved_ua_inst = db.get_setting(conn, "user_agent", "", 0)
|
||||
# 注意:get_setting 在没有行时会回落到 DEFAULTS,所以「还原是否成功」
|
||||
# 要拿**行为**比(读出来一样),而不是拿「行在不在」比 —— 这两件事
|
||||
# 在实例级是分开的,混起来会让断言永远失败。
|
||||
before_ua = db.get_setting(conn, "user_agent", "", 0)
|
||||
had_row = conn.execute("SELECT 1 FROM settings WHERE user_id=0 AND key='user_agent'"
|
||||
).fetchone() is not None
|
||||
saved_ua_inst = before_ua if had_row else None
|
||||
db.set_setting(conn, "user_agent", sentinel, 0) # 写实例级
|
||||
chk("② 实例级放哨兵后,新账号仍看不到它",
|
||||
db.get_setting(conn, "user_agent", "", VIEWER) != sentinel,
|
||||
"new=%s…" % (db.get_setting(conn, "user_agent", "", VIEWER) or "")[:22])
|
||||
chk("② 哨兵在实例级确实生效(证明上面的断言不是在空跑)",
|
||||
db.get_setting(conn, "user_agent", "", 0) == sentinel)
|
||||
db.set_setting(conn, "user_agent", saved_ua_inst, 0) # 还原
|
||||
chk("② 已还原实例级 UA", db.get_setting(conn, "user_agent", "", 0) == saved_ua_inst)
|
||||
# 还原:**原本没有这一行就删掉**。实例级本不该存在凭证类键
|
||||
# (v1.3.0 起 cookie / user_agent 恒为个人级),写回空串只会留下
|
||||
# 一个多余行,下次跑就会让「实例级不含凭证键」的断言失败。
|
||||
if had_row:
|
||||
db.set_setting(conn, "user_agent", before_ua, 0)
|
||||
else:
|
||||
conn.execute("DELETE FROM settings WHERE user_id=0 AND key='user_agent'")
|
||||
chk("② 已还原实例级 UA(读出来与放哨兵前一致)",
|
||||
db.get_setting(conn, "user_agent", "", 0) == before_ua)
|
||||
chk("② 还原后实例级不留 user_agent 行(原本有则保留)",
|
||||
(conn.execute("SELECT 1 FROM settings WHERE user_id=0 AND key='user_agent'"
|
||||
).fetchone() is not None) == had_row)
|
||||
# 普通配置应当能回落到实例级(否则每个新账号都拿到空配置)
|
||||
chk("② 普通配置仍回落实例级",
|
||||
db.get_setting(conn, "page_size", None, VIEWER) ==
|
||||
@@ -362,13 +394,22 @@ def run() -> None:
|
||||
chk("⑦ totals(uid=0) 不含任何人的数据",
|
||||
query.totals(conn, 0)["records"] == 0)
|
||||
finally:
|
||||
# 哨兵 UA 一定要还原(否则下次真采集会带着测试字符串发出去)
|
||||
# 哨兵 UA 一定要还原(否则下次真采集会带着测试字符串发出去)。
|
||||
# 原本实例级没有这一行时,**删掉**而不是写回空串 —— 见第 ② 条断言。
|
||||
if saved_ua_inst is not None:
|
||||
db.set_setting(conn, "user_agent", saved_ua_inst, 0)
|
||||
else:
|
||||
conn.execute("DELETE FROM settings WHERE user_id=0 AND key='user_agent'")
|
||||
for name in created:
|
||||
conn.execute("DELETE FROM users WHERE username=?", (name,))
|
||||
conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)")
|
||||
conn.execute("DELETE FROM usage_records WHERE user_id NOT IN (SELECT id FROM users)")
|
||||
# 注意 `user_id<>0` 不能省:user_id=0 是**实例级配置**(调度、采集参数、
|
||||
# 接口地址、注册策略都在那里),它不属于任何账号,所以
|
||||
# `NOT IN (SELECT id FROM users)` 会把它当孤儿一起删掉 ——
|
||||
# 表现成「跑一次 smoke,全实例的配置被重置」,且不报任何错。
|
||||
conn.execute("DELETE FROM settings WHERE user_id<>0"
|
||||
" AND user_id NOT IN (SELECT id FROM users)")
|
||||
conn.execute("DELETE FROM usage_records WHERE user_id<>0"
|
||||
" AND user_id NOT IN (SELECT id FROM users)")
|
||||
|
||||
# 确认清理干净
|
||||
left = conn.execute("SELECT COUNT(*) FROM users WHERE username LIKE 'smoke\\_%' ESCAPE '\\'"
|
||||
@@ -376,7 +417,10 @@ def run() -> None:
|
||||
chk("3. 临时账号已清理", left == 0, "残留=%d" % left)
|
||||
|
||||
# ---------------- 4. 普通账号的权限边界 ----------------
|
||||
print("== 4. 非管理员:/users 必须 403,导航不出现该入口 ==")
|
||||
# 规则只有一条(config.writable_by):普通账号只能写本人的 cookie / user_agent,
|
||||
# 其余(调度、采集参数、接口地址、注册策略)一律 400。页面隐藏 / disabled
|
||||
# 只是「不给出误导性按钮」,真正的闸门在服务端,所以这里全部走真实请求。
|
||||
print("== 4. 非管理员:越权面必须全部关死 ==")
|
||||
viewer2 = "smoke_w_%s" % _rand()
|
||||
try:
|
||||
V2 = _mk_user(viewer2)
|
||||
@@ -386,18 +430,112 @@ def run() -> None:
|
||||
chk("GET /users 非管理员=403", st == 403, "status=%s" % st)
|
||||
st, _ = page(cli, "/api/users")
|
||||
chk("GET /api/users 非管理员=403", st == 403, "status=%s" % st)
|
||||
st, html = page(cli, "/")
|
||||
chk("概览导航不含「用户管理」", "用户管理" not in html)
|
||||
chk("普通账号导航含「个人中心」入口", 'class="who"' in html)
|
||||
for p in ("/", "/records", "/tasks", "/logs", "/config", "/profile"):
|
||||
st, _ = page(cli, p)
|
||||
chk("GET %-10s 非管理员=200" % p, st == 200, "status=%s" % st)
|
||||
# 日志尾部是管理员专属
|
||||
# ④ 日志是**实例级**运行信息(含数据库路径 / 账号名 / 来源 IP),
|
||||
# 普通账号整页 403 —— 不是「只看到自己那份」。
|
||||
st, _ = page(cli, "/logs")
|
||||
chk("GET /logs 非管理员=403", st == 403, "status=%s" % st)
|
||||
st, _ = page(cli, "/logs/tail?lines=10")
|
||||
chk("GET /logs/tail 非管理员=403", st == 403, "status=%s" % st)
|
||||
# ⑤ 其余页面(都只渲染本人数据)必须照常能开
|
||||
for p in ("/", "/dashboard", "/records", "/tasks", "/config", "/profile"):
|
||||
st, _ = page(cli, p)
|
||||
chk("GET %-11s 非管理员=200" % p, st == 200, "status=%s" % st)
|
||||
st, html = page(cli, "/")
|
||||
chk("概览导航不含「用户管理」", "用户管理" not in html)
|
||||
chk("概览导航不含「日志管理」", "日志管理" not in html)
|
||||
chk("普通账号导航含「个人中心」入口", 'class="who"' in html)
|
||||
# ⑥ 越权写:调度 / 采集参数 / 实例级键,逐个试,全部必须 400
|
||||
keep_times = db.get_setting(conn, "schedule_times", "", 0)
|
||||
for key, val in (("schedule_times", "23:59"),
|
||||
("schedule_enabled", "0"),
|
||||
("catch_up", "0"),
|
||||
("page_size", "1000"),
|
||||
("timeout", "300"),
|
||||
("ssl_verify", "0"),
|
||||
("max_prompt", "0"),
|
||||
("api_base", "http://evil.invalid"),
|
||||
("allow_register", "0")):
|
||||
st, body = page(cli, "/api/settings", method="POST", json={key: val},
|
||||
headers={"X-CSRF-Token": "smoke-csrf-token"})
|
||||
chk("⑥ 越权写 %-16s =400" % key, st == 400, "status=%s" % st)
|
||||
chk(" └ 报错里点名 %s" % key, key in body)
|
||||
chk("⑥ 越权尝试确实没落库(schedule_times 未变)",
|
||||
db.get_setting(conn, "schedule_times", "", 0) == keep_times)
|
||||
chk("⑥ 实例级 api_base 未被改写",
|
||||
"evil" not in db.get_setting(conn, "api_base", "", 0))
|
||||
# ⑦ 但本人凭证必须写得进去(否则普通账号根本没法采集)
|
||||
st, _ = page(cli, "/api/settings", method="POST",
|
||||
json={"user_agent": "SMOKE-VIEWER-UA/1.0"},
|
||||
headers={"X-CSRF-Token": "smoke-csrf-token"})
|
||||
chk("⑦ 普通账号写本人 user_agent=200", st == 200, "status=%s" % st)
|
||||
chk("⑦ 且只写进了自己名下",
|
||||
db.get_setting(conn, "user_agent", "", V2) == "SMOKE-VIEWER-UA/1.0")
|
||||
# ⑧ 任务页给普通账号渲染的是只读表,且没有「保存调度配置」按钮
|
||||
st, tk = page(cli, "/tasks")
|
||||
chk("⑧ 任务页标注调度只读", "仅管理员可改" in tk)
|
||||
chk("⑧ 任务页无调度保存按钮", "保存调度配置" not in tk)
|
||||
# ⑨ 配置页对普通账号只给凭证表单,采集参数渲染成只读表
|
||||
st, cf = page(cli, "/config")
|
||||
chk("⑨ 配置页有凭证表单", 'id="formCred"' in cf)
|
||||
chk("⑨ 配置页无采集参数表单", 'id="formCollect"' not in cf)
|
||||
chk("⑨ 配置页无实例级设置表单", 'id="formGlobal"' not in cf)
|
||||
chk("⑨ 配置页说明范围", "唯一可以修改" in cf)
|
||||
# ⑩ /api/status 的角色字段(大屏与前端靠它显隐管理员入口)
|
||||
st, sj = page(cli, "/api/status")
|
||||
chk("⑩ GET /api/status 普通账号=200", st == 200, "status=%s" % st)
|
||||
if st == 200:
|
||||
j = json.loads(sj)
|
||||
chk("⑩ is_admin=False", j.get("is_admin") is False, "%s" % j.get("is_admin"))
|
||||
chk("⑩ can_edit_schedule=False", j.get("can_edit_schedule") is False)
|
||||
chk("⑩ can_view_logs=False", j.get("can_view_logs") is False)
|
||||
finally:
|
||||
conn.execute("DELETE FROM users WHERE username=?", (viewer2,))
|
||||
conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)")
|
||||
# `user_id<>0` 是必须的:0 是实例级配置,不能当孤儿清理(见第 3 节的说明)
|
||||
conn.execute("DELETE FROM settings WHERE user_id<>0"
|
||||
" AND user_id NOT IN (SELECT id FROM users)")
|
||||
|
||||
# ---------------- 4b. 全局键的落库位置 ----------------
|
||||
# 这一节盯的是「管理员改了但只有自己生效」这类**静默** bug:
|
||||
# 全局键若被写进管理员的 user_id,其它账号读取时会回落到 DEFAULTS,
|
||||
# 表现成「设置莫名其妙不生效」,而且不报任何错。
|
||||
print("== 4b. 全局键必须落在实例级 user_id=0 ==")
|
||||
chk("schedule_times 是全局键", config.is_global_key("schedule_times"))
|
||||
chk("page_size 是全局键", config.is_global_key("page_size"))
|
||||
chk("cookie 不是全局键(本人凭证)", not config.is_global_key("cookie"))
|
||||
chk("slot:* 仍是个人级(每人各自记今天跑过没)",
|
||||
not config.is_global_key("slot:09:00"))
|
||||
chk("普通账号只被允许写 cookie/user_agent",
|
||||
config.writable_by("cookie", False) and config.writable_by("user_agent", False)
|
||||
and not config.writable_by("schedule_times", False)
|
||||
and not config.writable_by("page_size", False))
|
||||
with app.test_client() as cli:
|
||||
login(cli, ADMIN)
|
||||
same = db.get_setting(conn, "schedule_times", "", 0)
|
||||
st, _ = page(cli, "/api/settings", method="POST",
|
||||
json={"schedule_times": same or "09:00"},
|
||||
headers={"X-CSRF-Token": "smoke-csrf-token"})
|
||||
chk("管理员写 schedule_times=200", st == 200, "status=%s" % st)
|
||||
chk("只存在实例级那一份",
|
||||
conn.execute("SELECT COUNT(*) FROM settings WHERE user_id=0"
|
||||
" AND key='schedule_times'").fetchone()[0] == 1)
|
||||
chk("管理员名下不留个人级副本(否则别人读不到)",
|
||||
conn.execute("SELECT COUNT(*) FROM settings WHERE user_id<>0"
|
||||
" AND key='schedule_times'").fetchone()[0] == 0)
|
||||
# /api/status 是大屏与前端判断角色用的接口,必须真的能开且角色正确
|
||||
# (它曾经因为改字段时引用了未定义的变量而 500,两层测试都没覆盖到)
|
||||
st, sj = page(cli, "/api/status")
|
||||
chk("GET /api/status 管理员=200", st == 200, "status=%s" % st)
|
||||
if st == 200:
|
||||
j = json.loads(sj)
|
||||
chk("└ is_admin=True", j.get("is_admin") is True, "%s" % j.get("is_admin"))
|
||||
chk("└ can_edit_schedule=True", j.get("can_edit_schedule") is True)
|
||||
chk("└ 凭证只回「有没有 / 多少字符」",
|
||||
all(k in j for k in ("cookie_set", "cookie_chars", "cookie_broken"))
|
||||
and "cookie" not in j)
|
||||
# 收尾自检:实例级配置必须还在(对照开头那份快照)
|
||||
inst_after = {r["key"] for r in conn.execute("SELECT key FROM settings WHERE user_id=0")}
|
||||
chk("跑完整轮 smoke 后,实例级配置一条不少", inst_before <= inst_after,
|
||||
"丢失=%s" % sorted(inst_before - inst_after))
|
||||
conn.close()
|
||||
|
||||
# ---------------- 5. 历史缺陷防回归 ----------------
|
||||
|
||||
在新工单中引用
屏蔽一个用户