From 3f79159fbd692380859af78d8fd035455d4491e3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 3 Oct 2026 19:27:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth,gateway):=20=E6=8E=A5=E7=BA=BF=20view?= =?UTF-8?q?er=20=E5=8F=AA=E8=AF=BB=E9=97=A8=20=E2=80=94=2012=20=E6=9D=A1?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=20GET=20=E5=AF=B9=20viewer=20=E5=BC=80?= =?UTF-8?q?=E6=94=BE=EF=BC=8C=E6=95=8F=E6=84=9F=E6=A1=A3=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=20owner/admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: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(应做能力降级) --- Docs/api-client.md | 28 ++- internal/auth/auth.go | 9 +- internal/services/gateway/gateway_test.go | 6 +- .../services/gateway/notifications_test.go | 8 +- .../gateway/observability_logs_test.go | 13 +- internal/services/gateway/rbac_viewer_test.go | 237 ++++++++++++++++++ internal/services/gateway/routes.go | 80 +++--- 7 files changed, 341 insertions(+), 40 deletions(-) create mode 100644 internal/services/gateway/rbac_viewer_test.go diff --git a/Docs/api-client.md b/Docs/api-client.md index bd80df1..1d1988c 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -11,7 +11,7 @@ ## 0. 基础约定 - **Base URL**: `http://:8080`(网关唯一对外端口;生产一般经反代/HTTPS)。 - **版本**: REST 全部挂在 `/api/v1/{resource}`;未来破坏性变更升 v2。 -- **认证**: `Authorization: Bearer `(RBAC:`owner`/`admin`/`viewer`);管理端另支持 `X-API-Key`。 +- **认证**: `Authorization: Bearer `(RBAC:`owner`/`admin`/`viewer`,权限矩阵见 §2.0);⚠️ **`X-API-Key` 目前尚未接线**(`internal/auth/apikey.go` 能签发/吊销带角色的 key,但**没有任何鉴权路径接受它**——当前只有 Bearer JWT 生效;接线属后续批次,接线前请勿依赖该头)。 - **响应统一**: `{"data": ...}` / `{"error": {"code": , "message": }}`;状态码 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。 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 967b016..6e3f28e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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" ) diff --git a/internal/services/gateway/gateway_test.go b/internal/services/gateway/gateway_test.go index e2858e1..4bd062d 100644 --- a/internal/services/gateway/gateway_test.go +++ b/internal/services/gateway/gateway_test.go @@ -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}, } diff --git a/internal/services/gateway/notifications_test.go b/internal/services/gateway/notifications_test.go index 775a85b..c03a12a 100644 --- a/internal/services/gateway/notifications_test.go +++ b/internal/services/gateway/notifications_test.go @@ -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) } } diff --git a/internal/services/gateway/observability_logs_test.go b/internal/services/gateway/observability_logs_test.go index 9e30089..deabd2c 100644 --- a/internal/services/gateway/observability_logs_test.go +++ b/internal/services/gateway/observability_logs_test.go @@ -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) } } diff --git a/internal/services/gateway/rbac_viewer_test.go b/internal/services/gateway/rbac_viewer_test.go new file mode 100644 index 0000000..acd263d --- /dev/null +++ b/internal/services/gateway/rbac_viewer_test.go @@ -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) + }) + } +} diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index a09847c..992e684 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -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)))