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(应做能力降级)
This commit is contained in:
1 parent
2c100fe3d9
commit
3f79159fbd
7 files changed
+341
-40
No files matched your search
@@ -291,7 +291,11 @@ func TestRBACForbiddenAndUnauthorized(t *testing.T) {
|
||||
{name: "chat no token", url: "/api/v1/chat", token: "", want: http.StatusUnauthorized},
|
||||
{name: "chat viewer", url: "/api/v1/chat", token: viewer, want: http.StatusForbidden},
|
||||
{name: "admin no token", url: "/api/v1/admin/plugins", token: "", want: http.StatusUnauthorized},
|
||||
{name: "admin viewer", url: "/api/v1/admin/plugins", token: viewer, want: http.StatusForbidden},
|
||||
// A viewer reads the monitoring surface (/admin/plugins) but not the
|
||||
// restricted reads (/admin/memories) — gateway/rbac_viewer_test.go
|
||||
// enumerates both sets route by route.
|
||||
{name: "admin plugin viewer ok", url: "/api/v1/admin/plugins", token: viewer, want: http.StatusOK},
|
||||
{name: "admin memories viewer forbidden", url: "/api/v1/admin/memories", token: viewer, want: http.StatusForbidden},
|
||||
{name: "admin owner ok", url: "/api/v1/admin/plugins", token: owner, want: http.StatusOK},
|
||||
{name: "sessions viewer ok", url: "/api/v1/sessions", token: viewer, want: http.StatusOK},
|
||||
}
|
||||
|
||||
@@ -257,8 +257,8 @@ func TestAdminListNotifications_NoSourcesNoFabrication(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAdminListNotifications_Errors proves the endpoint fails loudly: an unknown
|
||||
// family is a 400, a failing source is a 500 (never a silently shortened feed),
|
||||
// and a viewer is rejected by RBAC.
|
||||
// family is a 400 and a failing source is a 500 (never a silently shortened
|
||||
// feed). A viewer reads the feed (read-only monitoring tier).
|
||||
func TestAdminListNotifications_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
audit, bt := notificationFeedFixture()
|
||||
@@ -278,8 +278,8 @@ func TestAdminListNotifications_Errors(t *testing.T) {
|
||||
}
|
||||
|
||||
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
|
||||
if status, _, _ := listNotifications(t, ts, viewer, ""); status != http.StatusForbidden {
|
||||
t.Fatalf("viewer status = %d, want 403", status)
|
||||
if status, _, _ := listNotifications(t, ts, viewer, ""); status != http.StatusOK {
|
||||
t.Fatalf("viewer status = %d, want 200", status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -201,14 +201,21 @@ func TestAdminObservabilityLogs_EmptyBufferIs200Empty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminObservabilityLogs_ViewerForbidden(t *testing.T) {
|
||||
// TestAdminObservabilityLogs_ViewerReadsTail locks the viewer gate: the log tail
|
||||
// is part of the read-only monitoring surface the viewer role exists for, so a
|
||||
// viewer reads it exactly like an owner (the payload is log records, not
|
||||
// credentials).
|
||||
func TestAdminObservabilityLogs_ViewerReadsTail(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
srv.SetLogBuffer(logger.NewRingBuffer(4))
|
||||
tok := signAny(t, jwt, "acct", auth.RoleViewer)
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("viewer status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), `"data":[]`) {
|
||||
t.Fatalf("body = %s, want an empty data list", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
Reference in new issue
Block a user