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 行删除
+39 -40
查看文件
@@ -8,17 +8,23 @@
/ 概览(KPI + 入口)
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
/records 数据明细:分页、筛选、搜索、导出
/tasks 任务管理:调度开关/时刻、手动触发、运行历史
/config 配置管理:本人的 Cookie / UA / 采集参数
/logs 日志管理:采集逐次明细 + 应用日志尾部
/tasks 任务管理:运行历史、手动采集、补采(**调度配置仅管理员可改**)
/config 配置管理:本人的 Cookie / UA(**其余参数仅管理员可改**)
/logs 日志管理(**仅管理员**)
/profile 个人中心:资料、密码、凭证状态
/users 用户管理(仅管理员)
/register 自助注册(受 allow_register 开关约束)
/captcha.png 图形验证码
**多用户约定**
所有数据类页面都只取 `current_user()["id"]` 那份数据;管理员在
「用户管理」里能看到账号列表,但**看不到别人的用量与凭证**。
**多用户约定(两条)**
1. **数据作用域**:所有数据类页面都只取 `current_user()["id"]` 那份数据;
管理员在「用户管理」里能看到账号列表,但**看不到别人的用量与凭证**。
2. **写权限**:普通用户只能写 `config.USER_EDITABLE_KEYS`(本人的 cookie /
user_agent),其余配置(调度时刻、采集参数、接口地址、注册策略)都归
管理员。页面上的 disabled / 隐藏只是「不给误导性按钮」,真正的闸门在
`@admin_required` 与 `config.writable_by()`,两端共用一个判断。
"""
import csv
import io
@@ -310,6 +316,7 @@ def tasks():
return render_template("tasks.html", runs=runs, sch=_sch_info(conn, uid),
s_times=s.get("schedule_times") or "",
s_grace=s.get("catch_up_grace_hours") or "12",
can_edit=is_admin(),
page=page, pages=pages, total=total,
page_window=_page_window(page, pages),
active="tasks")
@@ -393,43 +400,33 @@ def users_page():
active="users")
# ---------------- 日志管理 ----------------
# ---------------- 日志管理(仅管理员) ----------------
@bp.get("/logs")
@login_required
@admin_required
def logs():
conn = db.get_db()
u = current_user()
uid = u["id"]
adm = bool(u["is_admin"])
"""日志管理 —— **仅管理员**。
这一页同时呈现「全实例采集日志」「进程级应用日志」「全实例操作审计」,
都是实例运行信息(会带数据库路径、账号名、采集区间、来源 IP)。
普通账号不该读到这些:他们要看自己的采集历史走「任务管理」,
那页只查本人的数据。服务端用 @admin_required 兜底,导航里也会隐藏入口。
"""
conn = db.get_db()
run_id = request.args.get("run")
detail = None
if run_id and str(run_id).isdigit():
# 明细也必须限本人:否则改一个 ?run= 就能看到别人的采集日志
if adm:
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?",
(int(run_id),)).fetchone()
else:
detail = conn.execute("SELECT * FROM collect_runs WHERE id=? AND user_id=?",
(int(run_id), uid)).fetchone()
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?",
(int(run_id),)).fetchone()
status = request.args.get("status") or ""
w, p = ("WHERE user_id = ? AND status = ?", [uid, status]) \
if status in ("ok", "warn", "error", "running") else ("WHERE user_id = ?", [uid])
if status in ("ok", "warn", "error", "running"):
w, p = "WHERE status = ?", [status]
else:
status, w, p = "", "", []
# 操作审计:管理员看全部(便于追责),普通用户只看自己触发的
aw, ap = [], []
if not adm:
aw.append("user_id = ?")
ap.append(uid)
act = request.args.get("act") or ""
if act:
aw.append("action = ?")
ap.append(act)
aw_sql = ("WHERE " + " AND ".join(aw)) if aw else ""
# 动作清单的统计基数不能带 action 条件(否则永远只剩一个动作可选)
base = ("WHERE user_id = ?" if not adm else "")
base_p = [uid] if not adm else []
ap = [act] if act else []
aw_sql = "WHERE action = ?" if act else ""
apage = _int_arg("apage", 1, 1, 10 ** 6)
asize = 20
@@ -439,23 +436,25 @@ def logs():
# 注意传的是 sqlite3.Row 列表而不是纯字符串列表:模板要用 a[0]=动作、a[1]=次数,
# 若在这里就用推导式取 r[0],模板里的 a[0] 会变成「字符串的第一个字符」。
actions = conn.execute(
"SELECT action, COUNT(*) n FROM audit_log %s GROUP BY action ORDER BY n DESC, action"
% base, base_p).fetchall()
"SELECT action, COUNT(*) n FROM audit_log GROUP BY action ORDER BY n DESC, action").fetchall()
page = _int_arg("page", 1, 1, 10 ** 6)
size = 30
total = conn.execute("SELECT COUNT(*) FROM collect_runs %s" % w, p).fetchone()[0]
runs = conn.execute("SELECT id,trigger,status,started_at,duration_ms,fetched,added,dup,total,"
"conflicts,exit_code,message FROM collect_runs %s"
" ORDER BY id DESC LIMIT ? OFFSET ?" % w, p + [size, (page - 1) * size]).fetchall()
# 带上账号名:这是实例级视图,一行没有归属人根本没法读
runs = conn.execute("SELECT r.id,r.user_id,COALESCE(u.username,'—') AS uname,"
" r.trigger,r.status,r.started_at,r.duration_ms,r.fetched,r.added,"
" r.dup,r.total,r.conflicts,r.exit_code,r.message"
" FROM collect_runs r LEFT JOIN users u ON u.id=r.user_id %s"
" ORDER BY r.id DESC LIMIT ? OFFSET ?" % w,
p + [size, (page - 1) * size]).fetchall()
apages = max(1, (atotal + asize - 1) // asize)
return render_template("logs.html", runs=runs, detail=detail, audits=audits,
actions=actions, act=act, apage=apage, apages=apages, atotal=atotal,
apage_window=_page_window(apage, apages, span=7),
page=page, pages=max(1, (total + size - 1) // size), total=total,
page_window=_page_window(page, max(1, (total + size - 1) // size)),
status=status, audit_all=adm,
active="logs")
status=status, active="logs")
@bp.get("/logs/tail")