背景: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(应做能力降级)
238 lines
11 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|