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。
这个提交包含在:
+41
-12
@@ -13,8 +13,12 @@
|
||||
**多用户约定**
|
||||
每个接口都只操作 `current_user()["id"]` 那份数据。查询函数要求显式传 uid,
|
||||
所以这里漏传会直接 TypeError(而不是静默返回全量)。
|
||||
`/api/settings` 是唯一的例外:它会回传实例级配置供非管理员只读展示,
|
||||
但**拒绝**非管理员写入实例级键。
|
||||
|
||||
写权限只有两条规则(`config.writable_by`,前后端与测试共用同一判断):
|
||||
|
||||
* 普通用户**只能**写 `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
|
||||
* 其余键(接口地址、注册策略、采集参数、调度时刻)只有管理员能写,
|
||||
任何越权写入都会被 `/api/settings` 拒绝并在审计里记一笔 `settings_rejected`。
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime
|
||||
@@ -76,7 +80,13 @@ def _int(name, default, lo=1, hi=2000):
|
||||
@bp.get("/manifest")
|
||||
@login_required
|
||||
def api_manifest():
|
||||
return jsonify(query.manifest(db.get_db(), _uid()))
|
||||
u = current_user()
|
||||
m = query.manifest(db.get_db(), u["id"])
|
||||
# 角色标记只在这里补:大屏是**静态页**,拿不到 Jinja 上下文,
|
||||
# 只能靠数据接口知道自己该不该渲染「日志管理」这类管理员入口。
|
||||
# 前端隐藏只是不给死链,真正的闸门始终是服务端的 @admin_required。
|
||||
m["role"] = "admin" if u["is_admin"] else "user"
|
||||
return jsonify(m)
|
||||
|
||||
|
||||
@bp.get("/bundle")
|
||||
@@ -174,7 +184,8 @@ def api_run(run_id):
|
||||
@login_required
|
||||
def api_status():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
u = current_user()
|
||||
uid = u["id"]
|
||||
sch = scheduler.get_scheduler()
|
||||
nxt = scheduler.next_run_at(conn, uid)
|
||||
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||
@@ -184,6 +195,12 @@ def api_status():
|
||||
cred = db.secret_state(conn, "cookie", uid)
|
||||
return jsonify({
|
||||
"server_time": db.now_str(),
|
||||
# 角色能力:大屏等**静态页**拿不到 Jinja 上下文,只能靠这个字段
|
||||
# 决定要不要渲染管理员专属入口(如「日志管理」)。服务端仍会
|
||||
# 对这些入口再做一次鉴权,前端隐藏只是为了不给出误导性的按钮。
|
||||
"is_admin": bool(u["is_admin"]),
|
||||
"can_edit_schedule": bool(u["is_admin"]),
|
||||
"can_view_logs": bool(u["is_admin"]),
|
||||
"scheduler": {
|
||||
"running": sch.running,
|
||||
"enabled": db.get_bool(conn, "schedule_enabled", True, uid),
|
||||
@@ -344,7 +361,9 @@ def api_settings_get():
|
||||
for k in [k for k in list(s) if config.is_internal_key(k)]:
|
||||
s.pop(k, None)
|
||||
s["_globalKeys"] = sorted(config.GLOBAL_KEYS)
|
||||
s["_userKeys"] = sorted(config.USER_EDITABLE_KEYS)
|
||||
s["_canEditGlobal"] = bool(u["is_admin"])
|
||||
s["_role"] = "admin" if u["is_admin"] else "user"
|
||||
return jsonify(s)
|
||||
|
||||
|
||||
@@ -363,9 +382,11 @@ def api_settings_post():
|
||||
if k not in config.DEFAULTS:
|
||||
errors.append("未知配置项:%s" % k)
|
||||
continue
|
||||
if config.is_global_key(k) and not u["is_admin"]:
|
||||
# 实例级配置(接口基址、注册开关)只有管理员能改 ——
|
||||
# 否则任意注册用户都能把大家的数据采集指向别的服务器
|
||||
if not config.writable_by(k, u["is_admin"]):
|
||||
# 普通用户只能写**本人凭证**(cookie / user_agent)。其余键 ——
|
||||
# 接口基址、注册策略、采集参数、调度时刻 —— 一律归管理员:
|
||||
# 否则任意注册用户就能把大家的数据采集指向别的服务器,
|
||||
# 或者把 page_size 调到 1000 去 hammer 云端接口。
|
||||
denied.append(k)
|
||||
continue
|
||||
if k == "cookie":
|
||||
@@ -386,16 +407,24 @@ def api_settings_post():
|
||||
db.set_setting(conn, k, val, uid)
|
||||
changed.append(k)
|
||||
if denied:
|
||||
errors.append("以下为实例级配置,仅管理员可修改:%s" % "、".join(sorted(denied)))
|
||||
errors.append("以下配置仅管理员可修改,本账号无法保存:%s。"
|
||||
"普通账号可以维护的是本人凭证(Cookie / User-Agent)。"
|
||||
% "、".join(sorted(denied)))
|
||||
if errors:
|
||||
db.audit(conn, "settings_rejected", u["username"], ";".join(errors)[:500],
|
||||
request.remote_addr, uid)
|
||||
return jsonify({"ok": False, "error": "invalid", "message": ";".join(errors),
|
||||
"errors": errors, "changed": sorted(changed)}), 400
|
||||
# 调整调度配置后清掉槽位标记,让新时刻立即生效(只清自己的)
|
||||
if {"schedule_times", "schedule_enabled"} & set(changed):
|
||||
conn.execute("DELETE FROM settings WHERE user_id=? AND key LIKE ?",
|
||||
(uid, scheduler.SLOT_PREFIX + "%"))
|
||||
# 改了每日时刻:清掉**不再存在的**槽位标记(所有账号一起清)。
|
||||
# 这里刻意不做「全清」——时刻是实例级的,全清会让全部账号在宽限期内
|
||||
# 一起重采一遍;只清失效槽位,既让新时刻立即生效,又不会造成批量重采。
|
||||
if "schedule_times" in changed:
|
||||
keep = set(scheduler.slots(conn, 0))
|
||||
stale = [(r["user_id"], r["key"]) for r in conn.execute(
|
||||
"SELECT user_id,key FROM settings WHERE key LIKE ?", (scheduler.SLOT_PREFIX + "%",))
|
||||
if r["key"][len(scheduler.SLOT_PREFIX):] not in keep]
|
||||
for row_uid, skey in stale:
|
||||
conn.execute("DELETE FROM settings WHERE user_id=? AND key=?", (row_uid, skey))
|
||||
db.audit(conn, "settings", u["username"],
|
||||
"修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr, uid)
|
||||
return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)})
|
||||
|
||||
在新工单中引用
屏蔽一个用户