Files
Cyrene_v2/internal/services/gateway/rbac_viewer_test.go
T
AskaEth 3f79159fbd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(auth,gateway): 接线 viewer 只读门 — 12 条监控 GET 对 viewer 开放,敏感档保持 owner/admin
背景: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(应做能力降级)
2026-10-03 19:27:25 +08:00

238 lines
11 KiB
Go

package gateway
import (
"net/http"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// rbacRoute is one admin route plus the method needed to reach it. The name is
// the subtest label, so a failure names the route without reading the path.
type rbacRoute struct {
name string
method string
path string
}
// viewerReadRoutes is the read-only monitoring tier: the exact admin route set
// wired to viewerAuth in gateway/routes.go (owner/admin/viewer). It must stay in
// lockstep with registerAdminRoutes — adding a route to viewerAuth without
// adding it here leaves it untested, and moving one out of viewerAuth makes the
// matching subtest fail (see the batch's mutation-verification records).
var viewerReadRoutes = []rbacRoute{
{"personas", http.MethodGet, "/api/v1/admin/personas"},
{"sessions", http.MethodGet, "/api/v1/admin/sessions"},
{"plugins", http.MethodGet, "/api/v1/admin/plugins"},
{"plugins/installed", http.MethodGet, "/api/v1/admin/plugins/installed"},
{"channels", http.MethodGet, "/api/v1/admin/channels"},
{"iot", http.MethodGet, "/api/v1/admin/iot"},
{"automations", http.MethodGet, "/api/v1/admin/automations"},
{"rag", http.MethodGet, "/api/v1/admin/rag"},
{"observability/metrics", http.MethodGet, "/api/v1/admin/observability/metrics"},
{"observability/trace", http.MethodGet, "/api/v1/admin/observability/trace"},
{"observability/logs", http.MethodGet, "/api/v1/admin/observability/logs"},
{"notifications", http.MethodGet, "/api/v1/admin/notifications"},
}
// ownerAdminReadRoutes is the admin read tier that stays owner/admin: private
// content, other accounts' data and the audit trail. A viewer must get 403; an
// admin must pass the gate (the handler's own status is out of scope here, so
// only "not 401/403" is asserted for admin).
var ownerAdminReadRoutes = []rbacRoute{
{"memories", http.MethodGet, "/api/v1/admin/memories"},
{"channels/messages", http.MethodGet, "/api/v1/admin/channels/messages"},
{"channels/{platform}/messages", http.MethodGet, "/api/v1/admin/channels/kook/messages"},
{"llm", http.MethodGet, "/api/v1/admin/llm"},
{"users", http.MethodGet, "/api/v1/admin/users"},
{"backthink", http.MethodGet, "/api/v1/admin/backthink"},
{"observability/audit", http.MethodGet, "/api/v1/admin/observability/audit"},
{"sessions/{id}/export", http.MethodGet, "/api/v1/admin/sessions/web/export"},
{"rag/query", http.MethodPost, "/api/v1/admin/rag/query"},
}
// ownerOnlyRoutes is every route reserved for the owner, including the
// credential-acquiring channel endpoints (login-qr/login-status) and the
// state-mutating writes. An admin must be rejected too, so the list doubles as
// the owner>admin boundary test.
var ownerOnlyRoutes = []rbacRoute{
{"channels/{platform}/login-qr", http.MethodGet, "/api/v1/admin/channels/weixin-ilink/login-qr"},
{"channels/{platform}/login-status", http.MethodGet, "/api/v1/admin/channels/weixin-ilink/login-status"},
{"channels/{platform}/view", http.MethodGet, "/api/v1/admin/channels/kook/view"},
{"channels/{platform}/friends", http.MethodGet, "/api/v1/admin/channels/kook/friends"},
{"channels/{platform}/friend/handle", http.MethodPost, "/api/v1/admin/channels/kook/friend/handle"},
{"channels/{platform}/message/test", http.MethodPost, "/api/v1/admin/channels/kook/message/test"},
{"channels/{platform}/config PUT", http.MethodPut, "/api/v1/admin/channels/kook/config"},
{"channels/{platform}/config DELETE", http.MethodDelete, "/api/v1/admin/channels/kook/config"},
{"channels/open", http.MethodPost, "/api/v1/admin/channels/open"},
{"channels/close", http.MethodPost, "/api/v1/admin/channels/close"},
{"channels/test", http.MethodPost, "/api/v1/admin/channels/test"},
{"plugins/policy", http.MethodPost, "/api/v1/admin/plugins/policy"},
{"plugins/install", http.MethodPost, "/api/v1/admin/plugins/install"},
{"plugins/uninstall", http.MethodPost, "/api/v1/admin/plugins/uninstall"},
{"personas POST", http.MethodPost, "/api/v1/admin/personas"},
{"personas/{id} PUT", http.MethodPut, "/api/v1/admin/personas/cyrene"},
{"users POST", http.MethodPost, "/api/v1/admin/users"},
{"users/apikey", http.MethodPost, "/api/v1/admin/users/apikey"},
{"users/apikey/{id} DELETE", http.MethodDelete, "/api/v1/admin/users/apikey/abc123"},
{"iot/{id}/control", http.MethodPost, "/api/v1/admin/iot/dev1/control"},
{"iot/scenarios", http.MethodPost, "/api/v1/admin/iot/scenarios"},
{"iot/rules", http.MethodPost, "/api/v1/admin/iot/rules"},
{"automations POST", http.MethodPost, "/api/v1/admin/automations"},
{"automations/{id}/run", http.MethodPost, "/api/v1/admin/automations/a1/run"},
{"automations/{id} DELETE", http.MethodDelete, "/api/v1/admin/automations/a1"},
{"llm POST", http.MethodPost, "/api/v1/admin/llm"},
{"llm/test", http.MethodPost, "/api/v1/admin/llm/test"},
{"memories POST", http.MethodPost, "/api/v1/admin/memories"},
{"memories/search", http.MethodPost, "/api/v1/admin/memories/search"},
{"rag POST", http.MethodPost, "/api/v1/admin/rag"},
{"rag/delete", http.MethodPost, "/api/v1/admin/rag/delete"},
{"system/revoke-keys", http.MethodPost, "/api/v1/admin/system/revoke-keys"},
{"system/rotate-jwt", http.MethodPost, "/api/v1/admin/system/rotate-jwt"},
{"system/backup", http.MethodPost, "/api/v1/admin/system/backup"},
{"system/restore", http.MethodPost, "/api/v1/admin/system/restore"},
}
// newRBACEnv builds the gateway with a real JWT service, a capability registry
// and an injected log ring buffer, so every viewer-readable route can answer
// 200 (the log tail would otherwise be an honest 501).
func newRBACEnv(t *testing.T) *testEnv {
t.Helper()
env := newEnv(t, nil, true)
env.srv.SetLogBuffer(logger.NewRingBuffer(8))
return env
}
// rbacBody returns a request body valid enough that the RBAC gate — not a
// decoder error — decides the status. A failed decode still answers 4xx, which
// never turns a 403 into a false pass, but a valid body keeps the assertions
// honest about what each route expects.
func rbacBody(rt rbacRoute) any {
switch rt.name {
case "rag/query":
return map[string]any{"query": "cyrene"}
default:
return nil
}
}
// TestAdminRBAC_ViewerReadsMonitoringSurface locks the viewer tier: each of the
// twelve read-only monitoring routes answers 200 for viewer, admin and owner
// alike (owner>admin>viewer all satisfy viewerAuth).
func TestAdminRBAC_ViewerReadsMonitoringSurface(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
for _, role := range []auth.Role{auth.RoleViewer, auth.RoleAdmin, auth.RoleOwner} {
role := role
t.Run(string(role), func(t *testing.T) {
tok := env.token(t, role)
for _, rt := range viewerReadRoutes {
rt := rt
t.Run(rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s as %s: status = %d, want 200 (body=%s)", rt.path, role, resp.StatusCode, body)
}
})
}
})
}
}
// TestAdminRBAC_AnonymousIsUnauthorized proves every admin route (both tiers)
// fails closed with 401 — not 403 and never a silent success — when no bearer
// token is presented.
func TestAdminRBAC_AnonymousIsUnauthorized(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
routes := make([]rbacRoute, 0, len(viewerReadRoutes)+len(ownerAdminReadRoutes)+len(ownerOnlyRoutes))
routes = append(routes, viewerReadRoutes...)
routes = append(routes, ownerAdminReadRoutes...)
routes = append(routes, ownerOnlyRoutes...)
for _, rt := range routes {
rt := rt
t.Run(rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("%s anonymous: status = %d, want 401 (body=%s)", rt.path, resp.StatusCode, body)
}
wantErrorShape(t, body)
})
}
}
// TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes locks the exclusion list: a
// viewer is 403 on every admin read that carries private content, other
// accounts' data or audit records, and on every owner-only route (writes plus
// the credential-acquiring channel endpoints).
func TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
viewer := env.token(t, auth.RoleViewer)
routes := make([]rbacRoute, 0, len(ownerAdminReadRoutes)+len(ownerOnlyRoutes))
routes = append(routes, ownerAdminReadRoutes...)
routes = append(routes, ownerOnlyRoutes...)
for _, rt := range routes {
rt := rt
t.Run(rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer %s: status = %d, want 403 (body=%s)", rt.path, resp.StatusCode, body)
}
wantErrorShape(t, body)
})
}
}
// TestAdminRBAC_AdminPassesReadGateButNotOwnerGate separates the two upper
// tiers: an admin passes the gate on the owner/admin read routes (its handler
// may still answer 404/501 — only the gate's verdict is asserted) but is 403 on
// every owner-only route, proving admin never leaks into owner.
func TestAdminRBAC_AdminPassesReadGateButNotOwnerGate(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
admin := env.token(t, auth.RoleAdmin)
for _, rt := range ownerAdminReadRoutes {
rt := rt
t.Run("read/"+rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), admin)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
t.Fatalf("admin %s: status = %d, want the gate to admit admin (body=%s)", rt.path, resp.StatusCode, body)
}
})
}
for _, rt := range ownerOnlyRoutes {
rt := rt
t.Run("owner-only/"+rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), admin)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("admin %s: status = %d, want 403 (body=%s)", rt.path, resp.StatusCode, body)
}
wantErrorShape(t, body)
})
}
}
// TestAdminRBAC_ViewerForbiddenOnWrites pins the two write routes named in the
// wiring brief so a future gate change cannot quietly hand a viewer a mutation.
func TestAdminRBAC_ViewerForbiddenOnWrites(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
viewer := env.token(t, auth.RoleViewer)
for _, rt := range []rbacRoute{
{"POST /admin/personas", http.MethodPost, "/api/v1/admin/personas"},
{"DELETE /admin/users/apikey/{id}", http.MethodDelete, "/api/v1/admin/users/apikey/abc123"},
} {
rt := rt
t.Run(rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, nil, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer %s: status = %d, want 403 (body=%s)", rt.path, resp.StatusCode, body)
}
wantErrorShape(t, body)
})
}
}