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

+26 -2
View File
@@ -11,7 +11,7 @@
## 0. 基础约定
- **Base URL**: `http://<host>:8080`(网关唯一对外端口;生产一般经反代/HTTPS)。
- **版本**: REST 全部挂在 `/api/v1/{resource}`;未来破坏性变更升 v2。
- **认证**: `Authorization: Bearer <JWT>`(RBAC:`owner`/`admin`/`viewer`);管理端另支持 `X-API-Key`。
- **认证**: `Authorization: Bearer <JWT>`(RBAC:`owner`/`admin`/`viewer`,权限矩阵见 §2.0);⚠️ **`X-API-Key` 目前尚未接线**(`internal/auth/apikey.go` 能签发/吊销带角色的 key,但**没有任何鉴权路径接受它**——当前只有 Bearer JWT 生效;接线属后续批次,接线前请勿依赖该头)。
- **响应统一**: `{"data": ...}` / `{"error": {"code": <string>, "message": <string>}}`;状态码 200/201/204/400/401/403/404/429/500。
- **错误码**:业务错误带 `code` 字符串(如 `unauthorized` / `not_found`),便于客户端分支。
@@ -114,7 +114,30 @@
## 2. REST 端点(管理,`/api/v1/admin/*`)
> 仅供管理面板与授权客户端;RBAC:`owner`/`admin`/`viewer`(viewer 只读、写 403);**写操作全程审计**。
> 仅供管理面板与授权客户端;RBAC:`owner`/`admin`/`viewer`;**写操作全程审计**。角色分档见下方「管理侧权限矩阵」(v0.40 起 `viewer` 真正可用:只读监控档,其余一律 `403`)。
### 2.0 管理侧权限矩阵(v0.40 起,与 `internal/services/gateway/routes.go` 逐条一致)
- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 {error:{code:"unauthorized"}}`(fail-closed,全部 56 条管理路由无一例外)。
- **角色分档**(`✅` 放行,`❌` 回 `403 forbidden`;owner>admin>viewer 递增):
| 端点组 | owner | admin | viewer |
|------|:---:|:---:|:---:|
| **只读监控档**(12 条 GET,见下①) | ✅ | ✅ | ✅ |
| **owner/admin 只读档**(9 条,见下②) | ✅ | ✅ | ❌ |
| **owner-only 写/凭据档**(35 条,见下③) | ✅ | ❌ | ❌ |
① **`viewer` 可读的 12 条只读监控档**(`viewerAuth` = owner/admin/viewer)——模块清单 + 运行时指标/日志/通知,全部是**非敏感投影**(无凭据、无他人账号数据、无消息正文):
`GET /admin/personas`、`GET /admin/sessions`、`GET /admin/plugins`、`GET /admin/plugins/installed`、`GET /admin/channels`、`GET /admin/iot`、`GET /admin/automations`、`GET /admin/rag`、`GET /admin/observability/metrics`、`GET /admin/observability/trace`、`GET /admin/observability/logs`、`GET /admin/notifications`。
② **仅 `owner`/`admin` 的 9 条**(`viewer` → `403`;理由:含**他人数据 / 隐私正文 / 完整导出 / 审计流水 / 全局凭据面**):
`GET /admin/memories`(私人记忆正文)、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`(完整 IM 消息流/转录)、`GET /admin/llm`(provider/模型配置面)、`GET /admin/users`(账号与 API key 列表)、`GET /admin/backthink`(后台思考记录)、`GET /admin/observability/audit`(审计流水)、`GET /admin/sessions/{id}/export`(会话导出)、`POST /admin/rag/query`(知识库检索,语义上是读,但按「全局知识面」归此档)。
③ **仅 `owner` 的 35 条**(`admin` 与 `viewer` 均 `403`)——全部写操作,外加**凭据获取路径**(`login-qr`/`login-status`,`view`/`friends`,渠道连接/测试/配置):
`GET /admin/channels/{platform}/login-qr`、`login-status`、`view`、`friends`;`POST /admin/channels/{platform}/friend/handle`、`message/test`;`PUT|DELETE /admin/channels/{platform}/config`;`POST /admin/channels/open|close|test`;`POST /admin/plugins/policy|install|uninstall`;`POST /admin/personas`、`PUT /admin/personas/{id}`;`POST /admin/users`、`POST /admin/users/apikey`、`DELETE /admin/users/apikey/{id}`;`POST /admin/iot/{id}/control`、`POST /admin/iot/scenarios`、`POST /admin/iot/rules`;`POST /admin/automations`、`POST /admin/automations/{id}/run`、`DELETE /admin/automations/{id}`;`POST /admin/llm`、`POST /admin/llm/test`;`POST /admin/memories`、`POST /admin/memories/search`;`POST /admin/rag`、`POST /admin/rag/delete`;`POST /admin/system/revoke-keys|rotate-jwt|backup|restore`。
> **用户侧同级只读**(非 admin 前缀,同样任意已认证角色可读):`GET /api/v1/sessions`、`GET /api/v1/personas`、`GET /api/v1/meta/enums`(§1 行 4/9/15)。
> **已知遗留**:前端管理面板**尚未**按角色隐藏/禁用分区——`viewer` 打开②③档页面会拿到 `403`,客户端需按本矩阵自行降级(后端契约已定,前端适配后置)。
| # | 端点 | 方法 | 状态 | 说明 |
|---|------|------|:---:|------|
@@ -329,6 +352,7 @@
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
## 5. 变更记录
- `v0.40`(**`viewer` 角色真正接线:只读监控档**,对客户端**收紧/放宽若干 admin 读的鉴权**,无端点/字段形状变更):此前 `viewer` 只是常量(`internal/auth` 的 `RoleViewer` 注释写着「只读 dashboard/logs/monitoring」)而**从未接线**——管理侧 24 条 GET 全走 owner/admin,`viewer` 一条都读不到(**全 403**)。本版新增第四道门 `viewerAuth = owner+admin+viewer`,并把 **12 条只读监控 GET** 从 owner/admin 放宽到 owner/admin/**viewer**:`GET /admin/personas`、`/admin/sessions`、`/admin/plugins`、`/admin/plugins/installed`、`/admin/channels`、`/admin/iot`、`/admin/automations`、`/admin/rag`、`/admin/observability/metrics`、`/admin/observability/trace`、`/admin/observability/logs`、`/admin/notifications`。**明确不放给 viewer**(含凭据获取路径、他人数据、完整消息/导出明文、审计流水,或 `admin` 也不给):`GET /admin/memories`、`/admin/llm`、`/admin/users`、`/admin/backthink`、`/admin/observability/audit`、`/admin/channels/messages`、`/admin/channels/{platform}/messages`、`/admin/sessions/{id}/export`、`POST /admin/rag/query`(这 9 条 owner/admin)以及其余 **35 条 owner-only**(全部写操作 + `login-qr`/`login-status`/`view`/`friends`/渠道连接与配置/`plugins/*`/`users/*`/`system/*`)。**匿名**:任意 admin 路由仍 `401`(fail-closed)。**向后兼容**:`owner`/`admin` 语义不变(放行集合是超集),仅 `viewer` 的可见面从「全 403」变为 12 条只读监控;不依赖 `viewer` 的客户端行为不变。完整分档见 §2.0「管理侧权限矩阵」。
- `v0.39`(**微信 iLink 会话被拒可见化 + 不再假 OK**,对客户端**新增两个可选字段、一处错误码**,向后兼容):① **`GET /api/v1/admin/channels` 每平台行新增可选 `needsRescan`(bool,`omitempty`)+ `lastError`(string,`omitempty`)**——仅当适配器**真的**要求重新授权时出现(今天只有微信 iLink 返回 `errcode=-14` 这一种),取值来自适配器新增的**可选能力接口** `RescanRequired() (bool, string)`,网关**经类型断言**读取(主 `PlatformAdapter` 契约**不变**;未实现该能力的平台字段**缺省不出现**)。用途:把「需要重新扫码」与「主人自己关掉的未连接」区分开——后者仅 `connected:false`。② **`POST /admin/channels/open`** 对「iLink 已拒绝该会话」回 **`409 session_rejected`**(message 明确要求用「扫码登录」重新绑定),**不再**吞掉错误回 200;`409 needs_login`(尚无 token,v0.8 起)不变。③ **适配器行为**:iLink `errcode=-14` 进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` 返回被拒错误(**不再**对被拒会话报成功);`HealthCheck` 区分「会话被拒」与「未连接」;并以 **`ERROR`** 级日志写明「本次会话已被 iLink 拒绝,需要重新扫码绑定」(只带 `bot_id` 等非敏感标识,**token 永不入日志**)。被拒态在读循环与发送两条路径**都会**置位,并仅在**扫码登录拿到新 token** 或**配置换了新 token**(保存 + 热重载)时清除。④ 扫码登录侧:被拒期间 `login-status` 报 **`failed` + 被拒原因**(**不再**假报 `confirmed`,否则面板会把已作废凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in`,否则重新扫码这条路被堵死);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端以「已绑定」应答、**不**发新凭据)。**向后兼容**:只新增可选字段与一处更诚实的错误码;不读新字段、不处理 409 的客户端行为不变(此时面板显示「未连接」,仍可用「扫码登录」恢复)。**语义澄清(本次仅措辞,不改契约形状、不另起版本号——v0.39 尚未被任何客户端采用)**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用),`errcode=-14` 表示 **iLink 侧拒绝/作废了该会话凭据**,**不是时间性过期**。详见 §2 行 6 与「微信 iLink『会话被拒』契约」。
- `v0.38`(**B4 客户端共享枚举权威表 + 未知值回落约定**,对客户端**新增一个只读 REST 端点**):新增 **`GET /api/v1/meta/enums`**(**任意已认证角色可读**,与 `GET /sessions` 同级;无 token `401`)→ `200 {data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`:① **`platforms`**(6)= `web`/`kook`/`qq-obv11`/`weixin-ilink`/`telegram`/`webhook`——即各内置适配器 `Name()` + Web 主会话来源 `web`;② **`moods`**(5)= `calm`/`happy`/`excited`/`sad`/`angry`,与 persona 情绪状态机内置状态一致(该状态机**可扩展**,故非封闭集合);③ **`modes`**(9)= `chat`/`code`/`home`/`work`/`docs`/`voice`/`vision`/`agents`/`settings`(design/07 §1);④ **`unknown`** 恒 `"display-raw"` = **未知值回落约定机器可读**:客户端遇不在表中的取值**原样显示原始字符串**(**不丢弃、不报错、不占位符**),**服务端新增取值视为非破坏性变更**(forward-compatible)。**单一事实来源在 Go 侧**(`internal/contract/enums`),并有**反漂移测试**断言适配器 `Name()` / 情绪内置状态 / 前端九模式与三张表一致,漏登记即 CI FAIL。Web 侧镜像 `frontend/web/src/lib/enums.ts`(`labelFor` 未知值返回原值),顶栏/侧栏/消息气泡的「心情」标签已改用它渲染。**向后兼容**:仅新增端点,既有契约形状不变。详见 §1 行 15 与 §1.1。
- `v0.37`(**D4 未读状态 / 已读游标**,对客户端**新增一个语义字段、一个 REST 端点、一个下行事件**):① `chat_sessions` 新增 **`read_at INTEGER NOT NULL DEFAULT 0`**(迁移 **0010**,down 可逆;**毫秒 Unix 时间戳**,与 `chat_messages.created_at` **同单位**)。② **语义**:**会话级单一已读游标 + 单 owner 账号**——同一账号所有端共用一个游标,**读一处 = 全端已读**(不做 per-device/per-user 游标,与「同一账号所有浏览器都属于同一 owner」一致);`createdAt <= readAt` 的消息算已读,未读数 = `createdAt > readAt` 的条数。③ **`GET /api/v1/sessions` 每项新增恒返回的 `unreadCount`**(整数,0 = 无未读):与 `lastMessage` **同一条 SQL 派生**(关联子查询 `COUNT(*) WHERE session_id = s.id AND created_at > s.read_at`,走已有 `idx_chat_msgs_session_created`,**无 N+1、不新增索引**)。④ **新增 `POST /api/v1/sessions/{id}/read`**(owner/admin,与 `PATCH /sessions/{id}` 同鉴权):body `{before?}`(可选毫秒;缺省= 现在)→ `200 {data:{sessionId,readAt}}`;`before` 为 0/负数/非数字/body 非对象 → `400`,未知会话(含隔离 `sess-xxx`)→ `404`,未注入 HistoryStore → `501`。**游标只前进不后退**(`read_at = MAX(read_at, before)`),乱序/并发/重试请求幂等安全。⑤ **新增下行事件 `session_read`**:`{type:"session_read", sessionId, data:{readAt}}`,全连接广播 + 客户端按 `sessionId` 过滤,用于同账号其它端清红点;失败路径不广播(§3.9)。⑥ **升级回填**:0010 把既有会话游标回填为「该会话现有最新消息时间」,**升级当天不误报未读**(既有历史全部视为已读)。⑦ `HistoryStore`(网关消费侧契约)新增 `MarkSessionRead(ctx, sessionID, before)`;`SessionMeta` 新增 `UnreadCount`。**向后兼容**:仅新增字段/端点/事件,既有契约形状不变;不消费 `unreadCount`/`session_read` 的客户端行为不变。详见 §1 行 4/8a 与 §3.9。
+8 -1
View File
@@ -23,7 +23,14 @@ const (
RoleOwner Role = "owner"
// RoleAdmin manages modules but no sensitive security items.
RoleAdmin Role = "admin"
// RoleViewer is read-only (dashboard/logs/monitoring).
// RoleViewer is read-only: the dashboard/logs/monitoring surface. It reads
// the admin module inventories (personas/sessions/plugins/channels/IoT/
// automations/knowledge docs), the runtime metrics/traces, the backend log
// tail and the notification feed, plus the authenticated user reads
// (GET /sessions, /personas, /meta/enums). Everything else is 403 — notably
// memories, accounts/API keys, the LLM provider stack, audit records, channel
// message logs, session export and every write (see
// gateway/registerAdminRoutes for the wired route table).
RoleViewer Role = "viewer"
)
+5 -1
View File
@@ -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)
})
}
}
+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)))