Files
2026-09-21 19:03:31 +08:00

299 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""只读账号 / 管理员的权限边界对账。
做三件事:
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())