代码提交

This commit is contained in:
xwt
2026-09-21 19:03:31 +08:00
parent 34d656492c
commit 97986d9a1a
217 changed files with 12042 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
"""只读账号 / 管理员的权限边界对账。
做三件事:
1. 用 viewer 登录,把「读接口」逐个打一遍 —— 必须全部 code=0。
2. 用 admin 登录,同样的读接口再打一遍 —— 结果必须与 viewer 逐项一致,
以此证明只读账号没有被削掉任何读取能力。
(只读 bug 的典型形态是「某人少了一个口子」,只有拿两个角色横向对比才看得出来。)
3. 用 viewer 打所有写接口 —— 必须全部被拒,且拒绝理由必须是 403(权限),
不能是 404/405 这种「路由压根不存在」的巧合。
设计上的两个讲究:
- 所有业务 ID 都在运行时从列表接口里现取,不写死 1 号。硬编码 ID 一旦数据库
重新播种就会全线 404,届时看起来像权限炸了,实际只是测试自己过期了。
- 写接口的 body 一律填足必要字段。若因缺参数返回 422,虽然也是「被拒」,
但那证明不了权限生效 —— 所以额外断言「必须是 403」。
用法:python verify/perm_check.py [--base http://127.0.0.1:8010]
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE = "http://127.0.0.1:8010"
PROBE_NAME = "权限对账临时顾问"
passed = 0
failed: list[str] = []
def purge_probe() -> None:
"""收尾:把本脚本造的探针尸体物理删掉。
第 4 节的写权限验证真的建了一条顾问记录(否则证明不了 admin 没被门禁拦住),
然后走接口把它删了 —— 但接口删的是逻辑删除,每跑一次就多留一行 is_del=1。
演示库是给人看的,不能越跑越脏,所以这里复用 purge_selftest 的清理逻辑硬删。
"""
try:
sys.path.insert(0, str(Path(__file__).resolve().parent))
import purge_selftest # noqa: PLC0415
findings = purge_selftest.collect()
deleted = purge_selftest.purge(findings)
n = sum(deleted.values())
print(f" [清理] 探针数据已物理删除 {n} 行" if n else " [清理] 无探针残留")
except Exception as exc: # noqa: BLE001
print(f" [WARN] 探针清理失败(不影响权限结论):{exc}")
def call(method: str, path: str, token: str, body: dict | None = None) -> tuple[int, dict]:
url = BASE + path
data = None
headers = {"Authorization": f"Bearer {token}"}
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
raw = e.read().decode()
try:
return e.code, json.loads(raw)
except Exception:
return e.code, {"raw": raw[:200]}
except Exception as exc: # noqa: BLE001
return -1, {"raw": f"{type(exc).__name__}: {exc}"}
def check(cond: bool, label: str, extra: str = "") -> None:
global passed
if cond:
passed += 1
print(f" [OK] {label}")
else:
failed.append(label)
print(f" [FAIL] {label} {extra}")
def call_raw(method: str, path: str, token: str) -> tuple[int, bytes, str]:
"""打二进制接口(比如 xlsx 模板下载),返回 (状态码, 原始字节, content-type)。"""
req = urllib.request.Request(BASE + path, headers={"Authorization": f"Bearer {token}"}, method=method)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.status, resp.read(), resp.headers.get("content-type", "")
except urllib.error.HTTPError as e:
return e.code, e.read(), e.headers.get("content-type", "")
except Exception as exc: # noqa: BLE001
return -1, str(exc).encode(), ""
def login(username: str, password: str) -> str:
status, body = call("POST", "/auth/login", "", {"username": username, "password": password})
if status != 200 or body.get("code") != 0:
print(f"登录失败 {username}: HTTP {status} {body}")
sys.exit(1)
return body["data"]["access_token"]
def first_id(token: str, path: str, id_key: str = "id") -> int | None:
status, body = call("GET", path, token)
data = body.get("data") or {}
items = data.get("items") if isinstance(data, dict) else data
if not items:
return None
row = items[0]
return row.get(id_key)
def main() -> int:
global BASE
ap = argparse.ArgumentParser()
ap.add_argument("--base", default=BASE)
args = ap.parse_args()
BASE = args.base
print("=" * 74)
print(f"权限边界对账 @ {BASE}")
print("=" * 74)
vtoken = login("viewer", "viewer123")
atoken = login("admin", "admin123")
# ---------- 运行时取真实 ID(用 admin,它权限最全)----------
sid = first_id(atoken, "/students?page=1&page_size=1")
cid = first_id(atoken, "/classes?page=1&page_size=1")
tid = first_id(atoken, "/teachers?page=1&page_size=1")
aid = first_id(atoken, "/advisors?page=1&page_size=1")
eid = first_id(atoken, "/employments?page=1&page_size=1")
scid = first_id(atoken, "/scores?page=1&page_size=1")
print(f"\n运行时取样 ID:student={sid} class={cid} teacher={tid} advisor={aid} employment={eid} score={scid}")
missing = [n for n, v in [("student", sid), ("class", cid), ("teacher", tid), ("advisor", aid), ("employment", eid), ("score", scid)] if v is None]
if missing:
print(f"数据库缺少样本数据:{missing} —— 请先跑 app/scripts/seed_data.py 再对账。")
return 2
# ---------- 读接口清单(按真实路由表)----------
read_targets: list[tuple[str, str, str]] = [
("学生列表", "GET", "/students?page=1&page_size=5"),
("学生下拉", "GET", "/students/meta/options"),
("学生详情", "GET", f"/students/{sid}"),
("学生成绩明细", "GET", f"/students/{sid}/scores"),
("成绩列表", "GET", "/scores?page=1&page_size=5"),
("成绩口径", "GET", "/scores/meta"),
("某生成绩汇总", "GET", f"/scores/student/{sid}"),
("红线名单", "GET", "/scores/warnings"),
("班级列表", "GET", "/classes?page=1&page_size=5"),
("班级下拉", "GET", "/classes/meta/options"),
("班级详情", "GET", f"/classes/{cid}"),
("老师列表", "GET", "/teachers?page=1&page_size=5"),
("老师详情", "GET", f"/teachers/{tid}"),
("顾问列表", "GET", "/advisors?page=1&page_size=5"),
("顾问详情", "GET", f"/advisors/{aid}"),
("就业列表", "GET", "/employments?page=1&page_size=5"),
("某生就业", "GET", f"/employments/student/{sid}"),
("某班就业", "GET", f"/employments/class/{cid}"),
("总览", "GET", "/statistics/overview"),
("班级概览", "GET", "/statistics/classes/overview"),
("年龄分布", "GET", "/statistics/students/age-distribution"),
("按年龄筛", "GET", "/statistics/students/by-age?op=gt&value=20"),
("班级均分", "GET", "/statistics/scores/class-average"),
("班级均分排序", "GET", "/statistics/scores/class-average?order=desc"),
("全科达标", "GET", "/statistics/scores/all-above"),
("多次不及格", "GET", "/statistics/scores/failures?min_times=2"),
("成绩波动", "GET", "/statistics/scores/volatility?top=5"),
("就业时长", "GET", "/statistics/employment/durations"),
("班级均时长", "GET", "/statistics/employment/class-avg-duration"),
("薪资 TopN", "GET", "/statistics/employment/top-salary?top=5"),
("就业漏斗", "GET", "/statistics/employment/funnel"),
("规则字段", "GET", "/advanced/meta"),
("规则示例", "GET", "/advanced/examples"),
("健康检查", "GET", "/api/health"),
("[兼容]某生就业", "GET", f"/employment/students/{sid}"),
("[兼容]某班就业", "GET", f"/employment/class/{cid}"),
]
# POST 但语义是「读」的接口,必须与上表同等对待(viewer 要能用)
read_post: list[tuple[str, str, dict]] = [
("高级查询", "/advanced/query", {"model": "student", "rules": [], "page": 1, "page_size": 5}),
("高级聚合", "/advanced/aggregate", {"model": "student", "group_by": ["class_id"], "metrics": [{"func": "count", "alias": "人数"}]}),
]
write_targets: list[tuple[str, str, str, dict | None]] = [
("新增学生", "POST", "/students", {"name": "权限探针", "gender": 1}),
("改学生", "PUT", f"/students/{sid}", {"name": "权限探针"}),
("删学生", "DELETE", f"/students/{sid}", None),
("恢复学生", "POST", f"/students/{sid}/restore", None),
("批量删学生", "POST", "/students/batch/delete", {"ids": [sid]}),
("导入学生", "POST", "/students/import", None),
("录成绩", "POST", "/scores", {"stu_id": sid, "exam_seq": 99, "score": 60}),
("批量录成绩", "POST", "/scores/batch", {"items": [{"stu_id": sid, "exam_seq": 99, "score": 60}]}),
("改成绩", "PUT", f"/scores/{scid}", {"score": 61}),
("删成绩", "DELETE", f"/scores/{scid}", None),
("清空成绩", "POST", f"/scores/student/{sid}/clear", None),
("新增班级", "POST", "/classes", {"name": "权限探针班"}),
("改班级", "PUT", f"/classes/{cid}", {"name": "权限探针班"}),
("删班级", "DELETE", f"/classes/{cid}", None),
("新增老师", "POST", "/teachers", {"name": "权限探针师"}),
("改老师", "PUT", f"/teachers/{tid}", {"name": "权限探针师"}),
("删老师", "DELETE", f"/teachers/{tid}", None),
("新增顾问", "POST", "/advisors", {"name": "权限探针顾"}),
("改顾问", "PUT", f"/advisors/{aid}", {"name": "权限探针顾"}),
("删顾问", "DELETE", f"/advisors/{aid}", None),
("登记就业", "POST", "/employments", {"stu_id": sid, "open_date": "2024-01-01"}),
("改就业", "PUT", f"/employments/{eid}", {"city": "深圳"}),
("删就业", "DELETE", f"/employments/{eid}", None),
("[兼容]加就业", "POST", f"/employment/students/{sid}", {"city": "深圳"}),
("新增账号", "POST", "/auth/accounts", {"username": "permprobe", "password": "permprobe1", "real_name": "权限探针"}),
]
# ---------- 1. viewer 读 ----------
print(f"\n--- 1. viewer 读权限({len(read_targets)} 个 GET + {len(read_post)} 个读语义 POST,应全部 code=0)---")
v_read_ok: dict[str, bool] = {}
for name, method, path in read_targets:
status, body = call(method, path, vtoken)
good = status == 200 and body.get("code") == 0
v_read_ok[name] = good
check(good, f"viewer 读 {name}", f"→ HTTP {status} {str(body)[:130]}")
for name, path, payload in read_post:
status, body = call("POST", path, vtoken, payload)
good = status == 200 and body.get("code") == 0
v_read_ok[name] = good
check(good, f"viewer 读 {name}", f"→ HTTP {status} {str(body)[:150]}")
# ---------- 2. admin 读(逐项一致性)----------
print(f"\n--- 2. admin 读权限(与 viewer 逐项一致)---")
for name, method, path in read_targets:
status, body = call(method, path, atoken)
good = status == 200 and body.get("code") == 0
check(good, f"admin 读 {name}", f"→ HTTP {status} {str(body)[:130]}")
check(good == v_read_ok[name], f"admin/viewer 一致 · {name}", f"→ admin={good} viewer={v_read_ok[name]}")
for name, path, payload in read_post:
status, body = call("POST", path, atoken, payload)
good = status == 200 and body.get("code") == 0
check(good, f"admin 读 {name}", f"→ HTTP {status} {str(body)[:150]}")
check(good == v_read_ok[name], f"admin/viewer 一致 · {name}", f"→ admin={good} viewer={v_read_ok[name]}")
# ---------- 2b. 二进制下载接口(xlsx 模板)----------
print("\n--- 2b. 导入模板下载(xlsx 二进制,避开 JSON 解析)---")
for who, tok in [("viewer", vtoken), ("admin", atoken)]:
status, raw, ctype = call_raw("GET", "/students/import/template", tok)
is_xlsx = raw[:2] == b"PK" # xlsx 本质是 zip,魔数 PK
check(status == 200 and is_xlsx, f"{who} 能下载导入模板(xlsx)", f"→ HTTP {status} type={ctype} len={len(raw)}")
# ---------- 3. viewer 写(必须 403)----------
print(f"\n--- 3. viewer 写权限({len(write_targets)} 个写接口,必须 403)---")
for name, method, path, payload in write_targets:
status, body = call(method, path, vtoken, payload)
code = body.get("code")
is403 = status == 403 or code == 403
check(is403, f"viewer 写 {name} → 403", f"→ HTTP {status} code={code} {str(body)[:110]}")
# ---------- 4. admin 写(抽样放行 + 回滚)----------
print("\n--- 4. admin 写权限(抽样放行,跑完回滚)---")
status, body = call("POST", "/advisors", atoken, {"name": PROBE_NAME})
made = body.get("code") == 0
check(made, "admin 能新增顾问", f"→ HTTP {status} {str(body)[:150]}")
if made:
probe_id = body["data"]["id"]
status, body = call("DELETE", f"/advisors/{probe_id}", atoken)
check(body.get("code") == 0, "admin 能删除刚建的顾问(逻辑删除)", f"→ {str(body)[:150]}")
# ---------- 5. 管理员专属 ----------
print("\n--- 5. 管理员专属接口边界 ---")
status, body = call("GET", "/auth/accounts", vtoken)
check(body.get("code") == 403, "viewer 读账号列表 → 403", f"→ HTTP {status} {str(body)[:120]}")
status, body = call("GET", "/auth/accounts", atoken)
check(body.get("code") == 0, "admin 读账号列表 → 放行", f"→ HTTP {status} {str(body)[:120]}")
# 未登录也不该漏
print("\n--- 6. 无 token 访问 ---")
for name, method, path in [("学生列表", "GET", "/students?page=1&page_size=1"), ("总览", "GET", "/statistics/overview")]:
status, body = call(method, path, "")
check(body.get("code") == 401, f"无 token 读 {name} → 401", f"→ HTTP {status} {str(body)[:120]}")
print("\n--- 7. 收尾清理 ---")
purge_probe()
print("\n" + "=" * 74)
total = passed + len(failed)
print(f"结果:{passed}/{total} 通过")
if failed:
print("失败项:")
for f in failed:
print(" -", f)
print("=" * 74)
return 0 if not failed else 1
if __name__ == "__main__":
sys.exit(main())