Skip to content

Commit fe3a858

Browse files
committed
fix(rbac): operator 凭证写 UI 与全量统计打通,并回收审计流水
代码整改(review §code): - /api/auth/session、/api/credentials 透出 is_operator,前端凭证页写 UI 改以 is_operator 为门槛(此前只认 is_admin,operator 被误降为只读) - admin_stats._scope 让 operator 与 admin 同权限读全量统计(与 README 「全部统计」对齐),viewer 仍只看自己 - RetentionTask 接入 AuditRepository.prune:审计流水此前从不回收(无界增长), 现按同一保留期裁剪(README/TECHNICAL 承诺的 90 天) - build_runner/main 贯通 audit 依赖 文档更正(review §spec): - TECHNICAL 成长中心「8 类」→「7 类」 - PROPOSAL viewer 角色行去掉「审计查看」(审计为 admin-only) - PROPOSAL/TECHNICAL 角色表补全 operator 全量统计与 is_operator 口径 测试:后端 +3(operator 标志/统计 scope/审计裁剪),前端 +2(operator 写 UI/ 统计用户列);ruff + pytest 100% + tsc + vitest + build 全绿
1 parent 8b7d777 commit fe3a858

19 files changed

Lines changed: 160 additions & 47 deletions

‎PROPOSAL.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,9 +318,9 @@ v1 只接 OpenAI 出口,但上游 SSE 解析到「中立事件」这一步独
318318

319319
| 角色 | 能做什么 | 判定依赖 | 典型用途 |
320320
|---|---|---|---|
321-
| `admin` | 用户管理、运行时配置、凭证全部写操作 | `require_admin` | 维护者 |
322-
| `operator` | 凭证写操作(导入 / 删除 / 启停 / pin / 切换账号 / 签到 / 成长) | `require_operator` | 日常运维 |
323-
| `viewer` | 只读(含统计与审计查看) | 无(登录即可) | 观察者 |
321+
| `admin` | 用户管理、运行时配置、凭证写操作、全量统计、审计日志 | `require_admin` | 维护者 |
322+
| `operator` | 凭证写操作(导入 / 删除 / 启停 / pin / 切换账号 / 签到 / 成长)、全量统计 | `require_operator` | 日常运维 |
323+
| `viewer` | 只读(看仪表盘、凭证列表、自己的统计、Playground;审计为 admin-only) | 无(登录即可) | 观察者 |
324324

325325
**鉴权链**(每个管理面请求):签名 Cookie(HMAC,payload 带用户名与 `session_epoch`)→ 用户仍存在且启用 → epoch 与 DB 一致 → 角色现读 DB。四者缺一即 401;**角色不进 Cookie**,所以降级立即生效,不必等 Cookie 过期。
326326

‎TECHNICAL.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ response.completed | response.incomplete
595595

596596
**三层引导(`auth/bootstrap.py`)**:① 导入 `users.txt`;② 把 `ADMIN_USERNAMES` 里点名的用户提权为 `admin`(这个 env 只剩**引导期**语义——DB 里已有角色后就不再是权威来源,否则「管理台降级某人 + env 还写着他」会互相打架);③ **仍无活跃 admin 则启动失败**,消息给出两条恢复路径(补 `users.txt` 重启 / 跑 `scripts/create_user.py`)。防锁死的最后一层在这里收口。
597597

598-
**角色**:`admin` / `operator` / `viewer`(`rbac.py`)。`require_admin` 管用户管理与配置(`admin_auth` / `admin_settings` / `admin_users` / `admin_audit`),`require_operator` 管凭证写操作(`admin_credentials` 的全部分支)。`Principal` 保留 `is_admin` 位置不动(既有 32 处 `Depends(principal_from_request)` 与前端 `is_admin` 字段零改动),`role` 追加为第三字段,`is_operator` 是派生 property(`is_admin or role == "operator"`)——升级时只动判定,不动调用面。
598+
**角色**:`admin` / `operator` / `viewer`(`rbac.py`)。`require_admin` 管用户管理与配置(`admin_auth` / `admin_settings` / `admin_users` / `admin_audit`),`require_operator` 管凭证写操作(`admin_credentials` 的全部分支)。`Principal` 保留 `is_admin` 位置不动(既有 32 处 `Depends(principal_from_request)` 与前端 `is_admin` 字段零改动),`role` 追加为第三字段,`is_operator` 是派生 property(`is_admin or role == "operator"`)——升级时只动判定,不动调用面。**统计口径**同样以 `is_operator` 为界:`admin_stats._scope` 让 admin 与 operator 传 `username` 生效(默认 `None` = 全量),viewer 恒回落到本人——凭证运维者需要看自己维护渠道的整体健康,与 README 角色表的「全部统计」一致。登录 / 会话响应的 `is_operator` 是同一 property 的透出,供前端决定凭证页写 UI 与统计页用户列可见性(此前前端只认 `is_admin`,operator 被误降为只读)。
599599

600600
**会话吊销不建会话表**:`users.session_epoch` 进签名 Cookie 的 `ep` 声明(`build_session_token`/`verify_session_token` 返回 `(username, epoch)`)。改密、降级、禁用/启用、硬删一律 `session_epoch + 1`,`principal_from_request` 每请求比一次,旧 Cookie 当场失效。**角色不进 Cookie,每请求现读 DB**——把角色塞进已签名的 Cookie 会让降级延迟到 Cookie 过期才生效,与「降级必须立即可信」直接冲突。向后兼容:老 Cookie 无 `ep` 按 0 处理(不是拒绝),显式给了非法值(非整数)才拒绝。
601601

@@ -844,9 +844,9 @@ class Scheduler:
844844
| token 到期(token_expiry.py) | —(读路径,非任务) | 从显式 `expires_at` 或 JWT `exp` 派生到期时间,写 `credentials.token_expires_at`(§3.9) |
845845
| token 预刷新(refresh.py) | 每 60 分钟 | 到期前 `REFRESH_SKEW_HOURS`(默认 24h)窗口内轮换 refresh token;到期时间同上(CB 实测无显式字段) |
846846
| 每日签到(checkin.py) | 每 10 分钟(全天) | 成功即封账该凭证当日(`日期:scope`,进程内内存态);失败持续重试 |
847-
| 成长中心(growth.py) | 每 `GROWTH_INTERVAL_MINUTES`(默认 60,下限 5) | 仅 CodeBuddy:8 类领取;结果落 `growth_events` + 回写 `credentials.growth_last_result` |
847+
| 成长中心(growth.py) | 每 `GROWTH_INTERVAL_MINUTES`(默认 60,下限 5) | 仅 CodeBuddy:7 类领取;结果落 `growth_events` + 回写 `credentials.growth_last_result` |
848848
| 活跃上报(activity.py,默认关闭) | 每 10 分钟醒一次,仅 `ACTIVITY_REPORT_HOUR`(默认 10 点,北京时间)窗口内执行 | 仅 CodeBuddy:补发一条 `chat_request_send` 续连登;按「endpoint + userId」隔离、当日封账;成功落一行 `growth_events` |
849-
| 明细清理(retention.py) | 每 5 分钟 | `usage_events` 全量重算小时汇总(幂等 upsert,与 record 的增量双写对账)+ 90 天前明细清理;同期限回收 `credit_events`(§3.10) |
849+
| 明细清理(retention.py) | 每 5 分钟 | `usage_events` 全量重算小时汇总(幂等 upsert,与 record 的增量双写对账)+ 90 天前明细清理;同期限回收 `credit_events`(§3.10)与 `audit_events`(§3.13) |
850850
| 模型目录刷新(`api/models.py::list_models`,注入式) | 每 `MODEL_CATALOG_MINUTES`(默认 30,下限 5) | 兜底重拉各渠道模型表(走同一条 `list_models`:TTL 门禁 + 逐渠道 publish + 落盘快照)。周期 30 分钟是跟着 zen 免费模型判活缓存(`MODELS_CACHE_TTL_SECONDS`,30 分钟)对齐——更密不会让 zen 多探一次,只是白打其余渠道的 `/models` |
851851

852852
**模型目录刷新为什么是「注入协程」而不是一个 tasks 模块**:`tasks/` 不 import `api/`(反向依赖会把 HTTP 层拖进后台任务),所以 `TaskRunner` 接的是 `model_catalog: Callable[[], Awaitable[object]] | None`,由 `main.lifespan` 闭包注入;`None`(老调用方 / 测试)时不装配这条循环,管理台也不展示这张卡片——与 growth / activity 的处理一致。

‎src/api/admin_auth.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
ACTION_LOGIN_SUCCESS,
1717
ACTION_USER_PASSWORD_CHANGE,
1818
)
19-
from ..auth.rbac import ROLE_ADMIN, UnauthorizedError, require_admin
19+
from ..auth.rbac import ROLE_ADMIN, ROLE_OPERATOR, UnauthorizedError, require_admin
2020
from ..auth.session import create_session_token
2121
from ..auth.users import create_password_hash, verify_password
2222
from ..compat.openai.request import InvalidRequest
@@ -82,6 +82,7 @@ async def login(request: Request, payload: dict,
8282
must_change = services.users.must_change_password(username)
8383
response = JSONResponse({"username": username,
8484
"is_admin": role == ROLE_ADMIN,
85+
"is_operator": role in (ROLE_ADMIN, ROLE_OPERATOR),
8586
"role": role,
8687
"must_change_password": must_change})
8788
issue_session(response, services, username, role=role,
@@ -98,6 +99,7 @@ async def logout(_csrf: None = Depends(csrf_protected)):
9899
@router.get("/api/auth/session")
99100
async def session_info(principal=Depends(principal_from_request)):
100101
return {"username": principal.username, "is_admin": principal.is_admin,
102+
"is_operator": principal.is_operator,
101103
"role": principal.role,
102104
"must_change_password": services.users.must_change_password(
103105
principal.username)}

‎src/api/admin_credentials.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ async def list_credentials(principal=Depends(principal_from_request)):
8181
"token_expiry_warning_seconds":
8282
services.settings.token_expiry_warning_seconds,
8383
"viewer": principal.username,
84-
"is_admin": principal.is_admin}
84+
"is_admin": principal.is_admin,
85+
# 凭证写门槛(admin 或 operator):前端据此决定是否显示
86+
# 导入/启停/删除等写操作,与后端 require_operator 同一口径。
87+
"is_operator": principal.is_operator}
8588

8689
@router.post("/api/credentials")
8790
async def import_credential(payload: dict,

‎src/api/admin_stats.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@
99

1010

1111
def _scope(principal: Principal, username: str | None) -> str | None:
12-
"""admin 传 username 可查全局/指定人;普通用户永远只能看自己。"""
13-
return username if principal.is_admin else principal.username
12+
"""admin/operator 传 username 可查全局/指定人;viewer 永远只能看自己。
13+
14+
operator 是「凭证运维」角色,按 PROPOSAL §4.7 与 README 的角色表应能看
15+
全量统计(否则看不到自己维护的渠道整体健康);viewer 才是严格自负。
16+
"""
17+
return username if principal.is_operator else principal.username
1418

1519

1620
def create_router(services: Services) -> APIRouter:

‎src/main.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ async def _refresh_model_catalog() -> dict[str, int]:
355355
runner = build_runner(credentials, registry, app_.state.stats_collector, runtime,
356356
growth_events=app_.state.growth_events,
357357
credit_events=credit_events,
358+
audit=audit,
358359
model_catalog=_refresh_model_catalog)
359360
app_.state.task_runner = runner
360361
await runner.start()

‎src/tasks/retention.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@ class RetentionTask:
1414
1515
`credit_events` 非 None 时同样按保留期清理积分流水:它每轮探测都可能
1616
落一行,不回收会长期增长(读侧有 LIMIT,但表本身会一直变大)。
17+
18+
`audit` 非 None 时按同一保留期清理审计流水:登录/写操作每笔一行,
19+
不回收同样会无界增长(README「审计」承诺保留期与请求明细一致)。
1720
"""
1821

1922
def __init__(self, collector: StatsCollector, *, retention_days: int = 90,
20-
credentials=None, credit_events=None) -> None:
23+
credentials=None, credit_events=None, audit=None) -> None:
2124
self._collector = collector
2225
self.retention_days = retention_days
2326
self._credentials = credentials
2427
self._credit_events = credit_events
28+
self._audit = audit
2529

2630
def run_once(self) -> dict[str, int]:
2731
rolled = self._collector.rollup_hourly()
@@ -32,6 +36,10 @@ def run_once(self) -> dict[str, int]:
3236
purged_credit_events = (
3337
self._credit_events.prune(keep_days=self.retention_days)
3438
if self._credit_events is not None else 0)
39+
purged_audit = (
40+
self._audit.prune(keep_days=self.retention_days)
41+
if self._audit is not None else 0)
3542
return {"rolled_up": rolled, "purged": purged,
3643
"expired_coolings": expired_coolings,
37-
"purged_credit_events": purged_credit_events}
44+
"purged_credit_events": purged_credit_events,
45+
"purged_audit": purged_audit}

‎src/tasks/runner.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def _as_report(result: object) -> dict[str, Any]:
276276

277277

278278
def build_runner(credentials, providers: dict, stats_collector, config,
279-
growth_events=None, credit_events=None,
279+
growth_events=None, credit_events=None, audit=None,
280280
model_catalog: Callable[[], Awaitable[object]] | None = None,
281281
status: TaskStatusStore | None = None) -> TaskRunner:
282282
"""按配置装配后台任务(Pacer 由两个 provider 共享)。
@@ -287,6 +287,9 @@ def build_runner(credentials, providers: dict, stats_collector, config,
287287
credit_events 同理:为 None 时保留流水清理不启用(表仍会随探测增长,
288288
但不影响功能;生产路径总是传入)。
289289
290+
audit 同理:为 None 时不清理审计流水(表仍会随登录/写操作增长;
291+
生产路径总是传入)。
292+
290293
model_catalog 同理:None 时不装配模型目录刷新循环(老调用方与测试保持
291294
原行为);生产路径传入「跑一轮 list_models」的协程。
292295
@@ -315,7 +318,7 @@ def build_runner(credentials, providers: dict, stats_collector, config,
315318
refresh=RefreshTask(credentials, providers, skew_seconds=config.refresh_skew_hours * 3600,
316319
now=lambda: int(time.time()), pacer=pacer),
317320
retention=RetentionTask(stats_collector, credentials=credentials,
318-
credit_events=credit_events),
321+
credit_events=credit_events, audit=audit),
319322
quota_probe_minutes=lambda: config.quota_probe_minutes,
320323
growth_interval_minutes=lambda: config.growth_interval_minutes,
321324
model_catalog=model_catalog,

‎tests/test_auth_bootstrap.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -435,8 +435,8 @@ def test_login_response_exposes_role_and_flag(tmp_path, monkeypatch):
435435
with TestClient(app) as client:
436436
body = client.post("/api/auth/login",
437437
json={"username": "op", "password": "oppw"}).json()
438-
assert body == {"username": "op", "is_admin": False, "role": ROLE_OPERATOR,
439-
"must_change_password": True}
438+
assert body == {"username": "op", "is_admin": False, "is_operator": True,
439+
"role": ROLE_OPERATOR, "must_change_password": True}
440440
db.close()
441441

442442

‎tests/test_m15_operations.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2546,6 +2546,7 @@ def test_login_success_sets_httponly_cookie(tmp_path):
25462546
json={"username": "root", "password": "rootpw"})
25472547
assert response.status_code == 200
25482548
assert response.json() == {"username": "root", "is_admin": True,
2549+
"is_operator": True,
25492550
"role": "admin", "must_change_password": False}
25502551
cookie = response.headers["set-cookie"]
25512552
assert "httponly" in cookie.lower() and "samesite=lax" in cookie.lower()
@@ -2649,6 +2650,7 @@ def test_credentials_endpoint_exposes_admin_flag(tmp_path):
26492650
client.post("/api/auth/login", json={"username": "guest", "password": "guestpw"})
26502651
body = client.get("/api/credentials").json()
26512652
assert body["viewer"] == "guest" and body["is_admin"] is False
2653+
assert body["is_operator"] is False
26522654

26532655

26542656
def test_credentials_endpoint_exposes_expiring_credits(tmp_path):
@@ -3963,6 +3965,23 @@ def test_retention_task_purges_expired_model_cooldowns(repo):
39633965
assert RetentionTask(collector).run_once()["expired_coolings"] == 0
39643966

39653967

3968+
def test_retention_task_prunes_audit_events(repo):
3969+
"""留存任务按保留期回收审计流水;未注入时计数为 0(README「审计保留 90 天」)。"""
3970+
from src.db.repo import AuditRepository
3971+
3972+
_credentials, db = repo
3973+
audit = AuditRepository(db)
3974+
# 两条都远在过去;retention_days=0 → cutoff=当前时刻,两条都 < cutoff 即被删
3975+
audit.record(actor="root", action="login.success", now=100)
3976+
audit.record(actor="root", action="login.failure", now=200)
3977+
collector = StatsCollector(db)
3978+
report = RetentionTask(collector, retention_days=0, audit=audit).run_once()
3979+
assert report["purged_audit"] == 2
3980+
assert audit.query() == []
3981+
# 不传 audit(旧调用方)时不报错,计数为 0
3982+
assert RetentionTask(collector).run_once()["purged_audit"] == 0
3983+
3984+
39663985
def test_list_all_exposes_model_cooldowns(tmp_path):
39673986
"""管理台列表带出生效中的模型冷却(过期行不下发)。"""
39683987
from src.engine.scheduler import ModelCooldown

0 commit comments

Comments
 (0)