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