feat(auth,gateway): 接线 viewer 只读门 — 12 条监控 GET 对 viewer 开放,敏感档保持 owner/admin
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

背景:auth.go 注释写着 RoleViewer 是只读监控档,但实现从未接线 —— 管理侧 24 个 GET 全是 owner/admin,viewer 全 403。本批把它接上(手机端「全功能管理」的前置之一)。
- routes.go:新增第四道门 viewerAuth = Authorize(owner, admin, viewer);registerAdminRoutes 传入。**只**把 12 条只读监控 GET 从 writeAuth 换过来:personas / sessions / plugins / plugins-installed / channels / iot / automations / rag / observability{metrics,trace,logs} / notifications;其余 12 条读与 32 条写**一行不动**
- 保持 owner/admin(viewer 403):memories / llm / users / backthink / audit / sessions-export / channels-messages ×2;owner-only(35 条)含凭据获取(login-qr/login-status)、channels view/friends、全部写操作
- 三处「编码旧策略」的既有测试按**新权限矩阵**改写(不是放宽):gateway_test 的 /admin/plugins viewer 403→200 且补一条 memories 403;observability_logs 的 ViewerForbidden→ViewerReadsTail;notifications 的 viewer 403→200
- 新增 rbac_viewer_test.go:5 个顶层 / 185 个子测试(12 条×3 角色 200、9+35 条 viewer 403、admin 在读档非 401/403 且在 owner-only 403、56 条匿名 401、写路由 viewer 403);含 3 次变异验证(含**反向变异**:把 memories 误放开为 viewerAuth 会 FAIL)
- auth.go 的 RoleViewer 注释改为**实际**可见面;Docs/api-client 新增 §2.0 管理侧权限矩阵 + 变更记录 v0.40;顺带修 §0 一处不实陈述(原写「管理端另支持 X-API-Key」→ 标注尚未接线)
- 验收:**隔离 worktree(HEAD + 本片)** go build/vet/test 全绿 + linux/amd64 与 darwin/arm64 交叉编译 + gofmt 干净(主工作树此时被另一代理的在途改动占用,见下条提交)
- 已知遗留(未改前端,按范围只记录):管理面板未按角色隐藏分区 → viewer 打开 memories/users/llm/审计等页会吃 403(应做能力降级)
This commit is contained in:
AskaEth committed 2026-10-03 19:27:25 +08:00
1 parent 2c100fe3d9
commit 3f79159fbd
7 files changed
+341 -40

No files matched your search

+51 -29
View File
@@ -18,15 +18,19 @@ func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// The RBAC middlewares are built once per role set and shared by every route
// that requires it (owner>admin>viewer, DevDocs/02 §九).
// that requires it (owner>admin>viewer, DevDocs/02 §九). viewerAuth is "every
// authenticated role": it names the read-only monitoring surface the viewer
// role exists for (module inventories, runtime metrics/traces, the log tail
// and the notification feed) — see registerAdminRoutes.
anyAuth := s.auth.Authorize()
ownerAuth := s.auth.Authorize(auth.RoleOwner)
writeAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)
viewerAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer)
s.registerPublicRoutes(mux)
s.registerUserRoutes(mux, anyAuth, ownerAuth, writeAuth)
s.registerHistoryRoutes(mux, writeAuth)
s.registerAdminRoutes(mux, ownerAuth, writeAuth)
s.registerAdminRoutes(mux, ownerAuth, writeAuth, viewerAuth)
s.mountWebhooks(mux)
return s.withMiddleware(mux)
@@ -102,44 +106,62 @@ func (s *Server) registerHistoryRoutes(mux *http.ServeMux, writeAuth func(http.H
mux.HandleFunc("/ws/chat", s.handleWS)
}
// registerAdminRoutes mounts the admin API: reads require owner or admin, the
// audited policy write requires owner, and every other write is reserved for the
// owner (RBAC red line). Each write handler persists to the real backing store or
// answers honestly with a clear "M10 / not-wired" error rather than fabricating a
// success.
// registerAdminRoutes mounts the admin API. Three read tiers plus two write
// tiers, mirroring the role hierarchy (RBAC red line):
//
// - viewerAuth (owner/admin/viewer): the read-only monitoring surface — module
// inventories, runtime metrics/traces, the backend log tail and the
// notification feed. Every payload here is a non-sensitive projection with no
// credential, no other account's data and no message body, which is what
// makes the viewer role a usable dashboard/logs/monitoring account.
// - writeAuth reads (owner/admin): everything a viewer must not see — memories,
// the LLM provider stack, accounts/API keys, the backthink monitor, the audit
// trail, channel message logs/transcripts, full session export.
// - writeAuth writes (owner/admin): the audited knowledge search.
// - ownerAuth (owner): the audited policy write, every credential-acquiring
// channel endpoint (login-qr/login-status) and every other mutation.
//
// Each write handler persists to the real backing store or answers honestly with
// a clear "M10 / not-wired" error rather than fabricating a success.
func (s *Server) registerAdminRoutes(
mux *http.ServeMux,
ownerAuth func(http.Handler) http.Handler,
writeAuth func(http.Handler) http.Handler,
viewerAuth func(http.Handler) http.Handler,
) {
// Admin reads: owner/admin.
mux.Handle("GET /api/v1/admin/personas", writeAuth(http.HandlerFunc(s.adminListPersonas)))
mux.Handle("GET /api/v1/admin/sessions", writeAuth(http.HandlerFunc(s.adminListSessions)))
mux.Handle("GET /api/v1/admin/memories", writeAuth(http.HandlerFunc(s.adminListMemories)))
mux.Handle("GET /api/v1/admin/plugins", writeAuth(http.HandlerFunc(s.adminListPlugins)))
mux.Handle("GET /api/v1/admin/plugins/installed", writeAuth(http.HandlerFunc(s.adminListInstalledPlugins)))
mux.Handle("GET /api/v1/admin/channels", writeAuth(http.HandlerFunc(s.adminListChannels)))
mux.Handle("GET /api/v1/admin/channels/messages", writeAuth(http.HandlerFunc(s.handleChannelMessages)))
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", writeAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
mux.Handle("GET /api/v1/admin/iot", writeAuth(http.HandlerFunc(s.adminListIoT)))
mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm)))
mux.Handle("GET /api/v1/admin/automations", writeAuth(http.HandlerFunc(s.adminListAutomations)))
mux.Handle("GET /api/v1/admin/users", writeAuth(http.HandlerFunc(s.adminListUsers)))
mux.Handle("GET /api/v1/admin/backthink", writeAuth(http.HandlerFunc(s.adminListBackthink)))
mux.Handle("GET /api/v1/admin/rag", writeAuth(http.HandlerFunc(s.adminListRag)))
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
mux.Handle("GET /api/v1/admin/observability/metrics", writeAuth(http.HandlerFunc(s.adminObservabilityMetrics)))
mux.Handle("GET /api/v1/admin/observability/trace", writeAuth(http.HandlerFunc(s.adminObservabilityTrace)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
// Admin reads open to viewer: the read-only monitoring/inventory surface.
mux.Handle("GET /api/v1/admin/personas", viewerAuth(http.HandlerFunc(s.adminListPersonas)))
mux.Handle("GET /api/v1/admin/sessions", viewerAuth(http.HandlerFunc(s.adminListSessions)))
mux.Handle("GET /api/v1/admin/plugins", viewerAuth(http.HandlerFunc(s.adminListPlugins)))
mux.Handle("GET /api/v1/admin/plugins/installed", viewerAuth(http.HandlerFunc(s.adminListInstalledPlugins)))
mux.Handle("GET /api/v1/admin/channels", viewerAuth(http.HandlerFunc(s.adminListChannels)))
mux.Handle("GET /api/v1/admin/iot", viewerAuth(http.HandlerFunc(s.adminListIoT)))
mux.Handle("GET /api/v1/admin/automations", viewerAuth(http.HandlerFunc(s.adminListAutomations)))
// Knowledge-base document list: names/descriptions plus chunk/embedding
// status. The `?query=` search rides along here and stays read-only.
mux.Handle("GET /api/v1/admin/rag", viewerAuth(http.HandlerFunc(s.adminListRag)))
mux.Handle("GET /api/v1/admin/observability/metrics", viewerAuth(http.HandlerFunc(s.adminObservabilityMetrics)))
mux.Handle("GET /api/v1/admin/observability/trace", viewerAuth(http.HandlerFunc(s.adminObservabilityTrace)))
// Backend log tail: the logger's own bounded ring buffer, injected by the
// composition root (501 until then). Newest first, `?level=`/`?limit=` filtered.
mux.Handle("GET /api/v1/admin/observability/logs", writeAuth(http.HandlerFunc(s.adminObservabilityLogs)))
mux.Handle("GET /api/v1/admin/observability/logs", viewerAuth(http.HandlerFunc(s.adminObservabilityLogs)))
// Unified notification feed (design/10 §3 #16): a read-only projection of the
// audit trail + backthink pushes, so the panel's notification page needs no
// new persisted model.
mux.Handle("GET /api/v1/admin/notifications", writeAuth(http.HandlerFunc(s.adminListNotifications)))
mux.Handle("POST /api/v1/admin/plugins/policy", ownerAuth(http.HandlerFunc(s.adminSetPolicy)))
mux.Handle("GET /api/v1/admin/notifications", viewerAuth(http.HandlerFunc(s.adminListNotifications)))
// Admin reads reserved for owner/admin: private content, other accounts'
// data and the audit trail. Not open to viewer.
mux.Handle("GET /api/v1/admin/memories", writeAuth(http.HandlerFunc(s.adminListMemories)))
mux.Handle("GET /api/v1/admin/channels/messages", writeAuth(http.HandlerFunc(s.handleChannelMessages)))
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", writeAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm)))
mux.Handle("GET /api/v1/admin/users", writeAuth(http.HandlerFunc(s.adminListUsers)))
mux.Handle("GET /api/v1/admin/backthink", writeAuth(http.HandlerFunc(s.adminListBackthink)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
mux.Handle("POST /api/v1/admin/plugins/policy", ownerAuth(http.HandlerFunc(s.adminSetPolicy)))
// Admin writes: owner-only, audited.
mux.Handle("POST /api/v1/admin/personas", ownerAuth(http.HandlerFunc(s.adminCreatePersona)))