feat(plugins): 插件管理器接线 —— 面板接入点快照 + 动作分发(PLG-2)

- GET /api/v1/admin/panels:按调用者角色过滤后的接入点快照(PLG-1 的 catalog 挂进插件管理器)
- POST /api/v1/admin/plugins/{id}/actions/{action}:**只接受该插件声明过的动作名**
  (未声明 ⇒ 404);写动作 writeAuth + 落审计;插件只给动作名不给 URL ⇒ 不给插件开任意请求面
- 生命周期:install/uninstall/enable/disable 变化后**重算面板集合**并经进程内 bus 推 panels_changed
  (前端可热更新;不新开端口)

契约 v0.78 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:57 +08:00
1 parent c907a34510
commit 2d21aa553e
9 files changed
+1990 -6

No files matched your search

+312
View File
@@ -0,0 +1,312 @@
package gateway
// 本文件是 design/09-插件生态 §5.2.1 的「唯一契约面」在网关侧的落点(切片 PLG-2):
//
// - GET /api/v1/admin/panels —— 按调用者角色过滤后的接入点快照
// - POST /api/v1/admin/plugins/{id}/actions/{action} —— 已声明动作的分发(鉴权 + 审计)
//
// 两条路都**只**经插件管理器的面板中枢(s.plugins.Panels())取事实,网关自己不读
// plugins.json、不猜插件的存储形状(§5.2.1 面板侧红线②)。中枢未接时两条路都回
// 501 panels_unavailable —— 绝不回一份空面板列表把「没接线」伪装成「没有页面」。
//
// 动作面是**名字**面,不是 URL 面:插件声明的只有动作名,宿主不接受任何来自声明的
// 请求地址(§5.4.4 不给插件开 SSRF 面)。
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// panelActionBodyLimit 限制动作请求体大小。面板动作的载荷是表单值/行标识这类小对象,
// 1 MiB 已极宽裕;不设限会让一个坏客户端把整个请求体读进内存。
const panelActionBodyLimit = 1 << 20
// errPanelActionBodyTooLarge 是本地哨兵,用来把「体太大」与「不是合法 JSON」分开
// 映射到同一条 400 的不同文案。
var errPanelActionBodyTooLarge = errors.New("plugin action body too large")
// AdminPanelItem 是下发给面板的一条导航条目(§5.2.1 的 nav.item + page.root)。
//
// Sections 直接复用 SDK 的声明类型:那份形状(JSON tag 与 §5.2 示例逐字同形)就是
// 插件的公开声明面,网关不另立一份 DTO 去翻译它 —— 两套形状必然分叉。
type AdminPanelItem struct {
// PluginID 是声明该面板的插件 id(目录命名空间)。
PluginID string `json:"pluginId"`
// ID 是面板 id。
ID string `json:"id"`
// Title 是页面标题(同时是侧边栏条目文案)。
Title string `json:"title"`
// Description 是页面描述(page.root 消费;缺省省略)。
Description string `json:"description,omitempty"`
// Icon 是宿主内置图标名(缺省省略 = 前端用默认图标)。
Icon string `json:"icon,omitempty"`
// Order 是组内排序(小在前)。
Order int `json:"order"`
// Route 是面板路由的**相对片段** `plugin/<pluginId>/<panelId>`,不含管理面板
// 前缀(ADMIN_PATH 可变,前缀只有前端与入口注入页知道)—— 前端拼成
// `<prefix>/plugin/<pluginId>/<panelId>`,与 design/09 §5.7 C 同构。
Route string `json:"route"`
// Sections 是该页面的区块声明(已按调用者角色裁剪:viewer 只留只读区块)。
Sections []sdk.Section `json:"sections"`
}
// AdminPanelGroup 是侧边栏折叠分组(§5.2.1 的 nav.group)。
type AdminPanelGroup struct {
// ID 是分组 id(本期闭集只有 `plugins`)。
ID string `json:"id"`
// Title 是分组的本地化显示名(宿主文案,见 panelGroupTitle)。
Title string `json:"title"`
// Order 是分组排序(取组内最小 order)。
Order int `json:"order"`
// Items 是分组内的条目。
Items []AdminPanelItem `json:"items"`
}
// AdminPanelsPayload 是 GET /api/v1/admin/panels 的 data 形状。
type AdminPanelsPayload struct {
// Role 是本次快照按哪个角色过滤的(回显让前端能确认自己拿到的裁剪档)。
Role string `json:"role"`
// Groups 是分组化后的面板集合。
Groups []AdminPanelGroup `json:"groups"`
// PluginIDs 是**声明过面板**的插件 id(已排序)。它不等于已安装插件:前端拿它
// 与 GET /admin/plugins/installed 对照,把「已装、未声明任何页面」的插件如实
// 显示成「未声明管理页面」,而不是藏掉(§5.2.1 面板侧红线③)。
PluginIDs []string `json:"pluginIds"`
}
// adminListPanels 返回按调用者角色过滤后的面板接入点快照(§5.2.1 唯一契约面之一)。
//
// 它是**读**端点,任何已认证角色都能调(路由门是 viewerAuth)—— 对 viewer 而言它
// 回的就是被角色过滤裁掉写面后的只读投影(§5.4.1),因此不存在越权读。
func (s *Server) adminListPanels(w http.ResponseWriter, r *http.Request) {
role, ok := callerRole(r)
if !ok {
writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "unauthorized", "缺少调用者身份"))
return
}
hub := s.panelHub()
if hub == nil {
writeError(w, panelsUnavailable())
return
}
groups, err := hub.Groups(role)
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrForbidden, "unknown_role", "%v", err))
return
}
out := AdminPanelsPayload{
Role: string(role),
Groups: make([]AdminPanelGroup, 0, len(groups)),
PluginIDs: hub.PluginIDs(),
}
if out.PluginIDs == nil {
out.PluginIDs = []string{}
}
for _, g := range groups {
out.Groups = append(out.Groups, adminPanelGroupOf(g))
}
writeJSON(w, http.StatusOK, out)
}
// adminPanelGroupOf 把目录分组映射为其线形状。
func adminPanelGroupOf(g paneldecl.Group) AdminPanelGroup {
items := make([]AdminPanelItem, 0, len(g.Panels))
for _, e := range g.Panels {
items = append(items, AdminPanelItem{
PluginID: e.PluginID,
ID: e.Panel.ID,
Title: e.Panel.Title,
Description: e.Panel.Description,
Icon: e.Panel.Icon,
Order: e.Panel.Order,
Route: panelRoute(e.PluginID, e.Panel.ID),
Sections: e.Panel.Sections,
})
}
return AdminPanelGroup{
ID: string(g.ID),
Title: panelGroupTitle(g.ID),
Order: g.Panels[0].Panel.Order,
Items: items,
}
}
// adminPluginAction 分发一个插件动作(§5.2.1 动作分发 + 鉴权 + 审计)。
//
// 顺序是刻意的**先证后行**:
// 1. 未声明 ⇒ 404(插件只给动作名 ⇒ 未声明的名字根本不存在,不是「没权限」);
// 2. 写动作 ⇒ 必须 owner/admin(读来源动作对 viewer 开放,§5.4.1);
// 3. 停止用插件 ⇒ 409;
// 4. 未接线执行器 ⇒ 501(诚实作答,绝不假装执行成功)。
//
// 每一步都落审计:谁在什么时候想动哪个插件的哪个动作、结果如何。
func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) {
pluginID := strings.TrimSpace(r.PathValue("id"))
action := strings.TrimSpace(r.PathValue("action"))
hub := s.panelHub()
if hub == nil {
s.auditPanelAction(r, pluginID, action, "unavailable")
writeError(w, panelsUnavailable())
return
}
kind, declared := hub.Action(pluginID, action)
if !declared {
s.auditPanelAction(r, pluginID, action, "not_declared")
writeError(w, &apperr.AppError{
Code: "action_not_declared",
Message: fmt.Sprintf("插件 %q 没有声明动作 %q(插件只给动作名,不给 URL)", pluginID, action),
HTTPCode: http.StatusNotFound,
})
return
}
if kind == pluginmgr.ActionWrite && !callerHasWrite(r) {
s.auditPanelAction(r, pluginID, action, "denied")
writeError(w, apperr.Wrap(apperr.ErrForbidden, "forbidden", "写动作需要 owner/admin(§5.4.1)"))
return
}
payload, derr := decodeActionPayload(r)
if derr != nil {
s.auditPanelAction(r, pluginID, action, "bad_request")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", derr))
return
}
res, err := hub.Dispatch(r.Context(), pluginmgr.ActionRequest{
PluginID: pluginID,
Action: action,
Payload: payload,
})
if err != nil {
status, code, msg := actionFailure(err)
s.auditPanelAction(r, pluginID, action, auditResultOf(err))
writeError(w, &apperr.AppError{Code: code, Message: msg, Cause: err, HTTPCode: status})
return
}
s.auditPanelAction(r, pluginID, action, "ok")
writeJSON(w, http.StatusOK, map[string]any{
"pluginId": pluginID,
"action": action,
"kind": string(kind),
"result": res,
})
}
// actionFailure 把分发失败映射为 HTTP 状态 + 结构化错误码 + 文案
// (「错误码与文案分离」,§5.4.6:前端据此本地化,不靠解析字符串)。
func actionFailure(err error) (status int, code, message string) {
switch {
case errors.Is(err, pluginmgr.ErrActionNotDeclared):
return http.StatusNotFound, "action_not_declared", "该动作未在插件声明里出现过"
case errors.Is(err, pluginmgr.ErrPluginDisabled):
return http.StatusConflict, "plugin_disabled", "插件已停用,其动作不再分发"
case errors.Is(err, pluginmgr.ErrActionUnavailable):
return http.StatusNotImplemented, "action_not_implemented", "动作已声明,但宿主尚未接线执行器"
default:
return http.StatusBadGateway, "action_failed", "插件动作执行失败,详细原因见后端日志"
}
}
// auditResultOf 把分发失败归成审计链里的一档结果。
func auditResultOf(err error) string {
switch {
case errors.Is(err, pluginmgr.ErrActionNotDeclared):
return "not_declared"
case errors.Is(err, pluginmgr.ErrPluginDisabled):
return "disabled"
case errors.Is(err, pluginmgr.ErrActionUnavailable):
return "unimplemented"
default:
return "error"
}
}
// auditPanelAction 把一次插件动作的结局写进审计链(who / what / when / result)。
// 附加字段只有插件 id 与动作名 —— 请求体可能有凭据,绝不进审计。
func (s *Server) auditPanelAction(r *http.Request, pluginID, action, result string) {
who := "anonymous"
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
who = claims.Sub + ":" + string(claims.Role)
}
s.audit(r.Context(), who, "plugin.action", result, "pluginId", pluginID, "action", action)
}
// decodeActionPayload 解码可选的动作请求体:空体 ⇒ nil(多数读来源动作不需要参数),
// 非法 JSON 或超限 ⇒ 显式错误。绝不把坏体静默当空体继续跑。
func decodeActionPayload(r *http.Request) (any, error) {
if r.Body == nil {
return nil, nil
}
raw, err := io.ReadAll(io.LimitReader(r.Body, panelActionBodyLimit+1))
if err != nil {
return nil, fmt.Errorf("读取请求体失败: %w", err)
}
if len(raw) > panelActionBodyLimit {
return nil, errPanelActionBodyTooLarge
}
if strings.TrimSpace(string(raw)) == "" {
return nil, nil
}
var payload any
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, fmt.Errorf("请求体不是合法 JSON: %w", err)
}
return payload, nil
}
// panelHub 返回已接线的面板中枢(未接线时 nil)。网关只经插件管理器取它,
// 因此「面板中枢」与「插件生命周期」天然共享一份事实。
func (s *Server) panelHub() *pluginmgr.Panels {
if s.plugins == nil {
return nil
}
return s.plugins.Panels()
}
// callerRole 从请求上下文取已验证的调用者角色(路由门已保证存在;缺失即 fail-closed)。
func callerRole(r *http.Request) (auth.Role, bool) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok || claims == nil || !claims.Role.Valid() {
return "", false
}
return claims.Role, true
}
// callerHasWrite 报告调用者是否具备写权(owner/admin,沿用 design/11 的 writeAuth 口径)。
func callerHasWrite(r *http.Request) bool {
role, ok := callerRole(r)
return ok && auth.HasRole(role, auth.RoleAdmin)
}
// panelsUnavailable 是「面板中枢未接线」的诚实 501:不接线的部署拿到的是
// 明确的错误码,而不是一份看起来正常但空的面板列表。
func panelsUnavailable() error {
return &apperr.AppError{
Code: "panels_unavailable",
Message: "插件面板中枢未接线(未注入 panel hub)",
HTTPCode: http.StatusNotImplemented,
}
}
// panelRoute 给出前端路由的相对片段(不含 ADMIN_PATH 前缀,理由见 AdminPanelItem.Route)。
func panelRoute(pluginID, panelID string) string {
return "plugin/" + pluginID + "/" + panelID
}
// panelGroupTitle 给出分组的本地化显示名。§5.2.1 的 nav.group 消费 title/order,
// 但分组本身**没有声明**(`group` 只是分组 id)⇒ 分组名由宿主文案提供;
// PLG-1 报告 §2 不一致④已登记这个归属待裁决,改口径只改这一个函数。
func panelGroupTitle(id sdk.GroupID) string {
if id == sdk.GroupPlugins {
return "插件"
}
return string(id)
}
@@ -0,0 +1,354 @@
package gateway
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件锁定 design/09 §5.2.1 的两条「唯一契约面」(切片 PLG-2):
// GET /api/v1/admin/panels 与 POST /api/v1/admin/plugins/{id}/actions/{action}。
//
// 断言重点不是「能返回 200」,而是三条红线:
// ① 快照按**调用者角色**过滤(viewer 拿不到写面);
// ② 动作**只接受已声明的名字**(未声明 ⇒ 404),写动作要 owner/admin;
// ③ 每个动作结算都落审计;未接线时诚实 501,绝不假装成功。
// panelTestEnv 是一个接线了「插件管理器 + 面板中枢」的网关。
type panelTestEnv struct {
ts *httptest.Server
srv *Server
hub *pluginmgr.Panels
jwt *auth.JWTService
}
func newPanelTestEnv(t *testing.T) *panelTestEnv {
t.Helper()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
caps := capreg.New()
mgr := pluginmgr.NewManager(caps, filepath.Join(t.TempDir(), "plugins.json"), nil)
hub, err := pluginmgr.NewPanels(nil)
if err != nil {
t.Fatalf("NewPanels: %v", err)
}
mgr.AttachPanels(hub)
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
srv, err := New(cfg, Deps{Auth: jwt, Bus: bus.New(), Registry: caps, Plugins: mgr})
if err != nil {
t.Fatalf("new server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(func() {
ts.Close()
mgr.Close(context.Background())
})
env := &panelTestEnv{ts: ts, srv: srv, hub: hub, jwt: jwt}
env.declare(t, "web-search")
return env
}
// declare 给 id 登记一份 form(写面)+ table(读面 + 行内写动作)的声明。
func (e *panelTestEnv) declare(t *testing.T, pluginID string) {
t.Helper()
if _, err := e.hub.Declare(pluginID, []sdk.Panel{gatewayTestPanel()}); err != nil {
t.Fatalf("Declare(%s): %v", pluginID, err)
}
}
// token 签发一个指定角色的访问令牌。
func (e *panelTestEnv) token(t *testing.T, role auth.Role) string {
t.Helper()
tok, err := e.jwt.Sign("acct-"+string(role), role, time.Hour)
if err != nil {
t.Fatalf("sign %s: %v", role, err)
}
return tok
}
// auditRecords 返回审计链里的记录(最新在前)。
func (e *panelTestEnv) auditRecords(t *testing.T) []AuditRecord {
t.Helper()
recs, err := e.srv.obs.audit.List(context.Background(), 50)
if err != nil {
t.Fatalf("list audit: %v", err)
}
return recs
}
// gatewayTestPanel 是 §5.2 示例形状的声明(form + table)。
func gatewayTestPanel() sdk.Panel {
enabled := true
return sdk.Panel{
ID: "providers",
Title: "搜索提供商",
Order: 20,
Icon: "search",
When: &sdk.When{Enabled: &enabled},
Sections: []sdk.Section{
{
Type: sdk.SectionForm,
ID: "add",
Title: "添加提供商",
Fields: []sdk.Field{
{Key: "apiKey", Label: "API Key", Type: sdk.FieldSecret, Required: true},
},
Submit: &sdk.Submit{Action: "plugin.saveProvider"},
},
{
Type: sdk.SectionTable,
ID: "list",
Title: "已配置的提供商",
Source: &sdk.Source{Action: "plugin.listProviders"},
Columns: []sdk.Column{
{Key: "provider", Label: "提供商"},
},
RowActions: []sdk.RowAction{
{ID: "test", Label: "测试连通", Action: "plugin.testProvider"},
},
},
},
}
}
// TestAdminPanelsSnapshotIsRoleFiltered 锁定:同一份声明对 owner 与 viewer 下发的
// 快照不同 —— owner 有 form(写面),viewer 只剩 table 且行内动作被摘掉。
func TestAdminPanelsSnapshotIsRoleFiltered(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
owner := panelsPayloadOf(t, env, auth.RoleOwner)
if owner.Role != "owner" || len(owner.Groups) != 1 {
t.Fatalf("owner 快照 = %+v", owner)
}
g := owner.Groups[0]
if g.ID != "plugins" || g.Title != "插件" || g.Order != 20 {
t.Fatalf("分组 = %+v,期望 plugins/插件/order=20", g)
}
if len(g.Items) != 1 {
t.Fatalf("条目 = %+v,期望 1 条", g.Items)
}
item := g.Items[0]
if item.PluginID != "web-search" || item.ID != "providers" || item.Route != "plugin/web-search/providers" {
t.Fatalf("条目 = %+v", item)
}
if len(item.Sections) != 2 {
t.Fatalf("owner 的区块 = %d 个,期望 form+table 两个", len(item.Sections))
}
if len(owner.PluginIDs) != 1 || owner.PluginIDs[0] != "web-search" {
t.Fatalf("pluginIds = %v(红线③靠它与已安装列表对照)", owner.PluginIDs)
}
viewer := panelsPayloadOf(t, env, auth.RoleViewer)
if viewer.Role != "viewer" || len(viewer.Groups) != 1 {
t.Fatalf("viewer 快照 = %+v", viewer)
}
vs := viewer.Groups[0].Items[0].Sections
if len(vs) != 1 || vs[0].Type != sdk.SectionTable {
t.Fatalf("viewer 的区块 = %+v,期望只剩 table(form 是写面)", vs)
}
if len(vs[0].RowActions) != 0 {
t.Fatalf("viewer 的行内动作应当被摘空,实际 %+v", vs[0].RowActions)
}
}
// panelsPayloadOf 以给定角色拉一次面板快照并解码。
func panelsPayloadOf(t *testing.T, env *panelTestEnv, role auth.Role) AdminPanelsPayload {
t.Helper()
resp, raw := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/panels", nil, env.token(t, role))
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET panels (%s) status = %d, body = %s", role, resp.StatusCode, raw)
}
var out struct {
Data AdminPanelsPayload `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode panels: %v (%s)", err, raw)
}
return out.Data
}
// TestAdminPanelsUnwiredAnswers501 锁定未接线口径:没有面板中枢时诚实回 501,
// 绝不回一份空列表把「没接线」伪装成「没有插件的页面」。
func TestAdminPanelsUnwiredAnswers501(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
_ = srv
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/panels", nil, tok)
if resp.StatusCode != http.StatusNotImplemented || !strings.Contains(string(raw), "panels_unavailable") {
t.Fatalf("GET panels status = %d, body = %s,期望 501 panels_unavailable", resp.StatusCode, raw)
}
resp, raw = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/plugins/calc/actions/plugin.listProviders", nil, tok)
if resp.StatusCode != http.StatusNotImplemented || !strings.Contains(string(raw), "panels_unavailable") {
t.Fatalf("POST action status = %d, body = %s,期望 501 panels_unavailable", resp.StatusCode, raw)
}
}
// TestAdminPluginActionUndeclaredIs404 锁定 §5.2.1 的**动作白名单**:未在声明里
// 出现过的动作名一律 404(插件只给名字,宿主没有可执行的东西)。
func TestAdminPluginActionUndeclaredIs404(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
tok := env.token(t, auth.RoleOwner)
resp, raw := doReq(t, http.MethodPost,
env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.nukeEverything", nil, tok)
if resp.StatusCode != http.StatusNotFound || !strings.Contains(string(raw), "action_not_declared") {
t.Fatalf("status = %d, body = %s,期望 404 action_not_declared", resp.StatusCode, raw)
}
// 没有任何声明的插件同样 404(没有声明就没有名字空间,更谈不上动作)。
resp, raw = doReq(t, http.MethodPost,
env.ts.URL+"/api/v1/admin/plugins/ghost/actions/plugin.listProviders", nil, tok)
if resp.StatusCode != http.StatusNotFound || !strings.Contains(string(raw), "action_not_declared") {
t.Fatalf("未声明插件的动作 status = %d, body = %s,期望 404", resp.StatusCode, raw)
}
}
// TestAdminPluginActionWriteRequiresOwnerOrAdmin 锁定写动作门(§5.4.1):
// viewer 触发 form 的 submit ⇒ 403 且落审计;owner 且已接线执行器 ⇒ 200 且结果透传。
func TestAdminPluginActionWriteRequiresOwnerOrAdmin(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
env.hub.SetExecutor(func(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
return map[string]any{"saved": req.Action, "payload": req.Payload}, nil
})
url := env.ts.URL + "/api/v1/admin/plugins/web-search/actions/plugin.saveProvider"
resp, raw := doReq(t, http.MethodPost, url, map[string]any{"apiKey": "sk-x"}, env.token(t, auth.RoleViewer))
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer 写动作 status = %d, body = %s,期望 403", resp.StatusCode, raw)
}
if !hasAudit(env.auditRecords(t), "plugin.action", "denied") {
t.Fatalf("被拒的写动作必须落审计(denied)")
}
resp, raw = doReq(t, http.MethodPost, url, map[string]any{"apiKey": "sk-x"}, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("owner 写动作 status = %d, body = %s,期望 200", resp.StatusCode, raw)
}
var out struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
if out.Data["action"] != "plugin.saveProvider" || out.Data["kind"] != "write" {
t.Fatalf("动作响应 = %+v", out.Data)
}
if !hasAudit(env.auditRecords(t), "plugin.action", "ok") {
t.Fatalf("成功的写动作必须落审计(ok)")
}
}
// TestAdminPluginActionReadSourceIsOpenToViewer 锁定读来源动作对 viewer 开放
// (§5.4.1「只读事实面对 viewer 可见」):table 的 source 动作不被写门挡住。
func TestAdminPluginActionReadSourceIsOpenToViewer(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
env.hub.SetExecutor(func(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
return []map[string]any{{"provider": "searxng"}}, nil
})
resp, raw := doReq(t, http.MethodPost,
env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.listProviders", nil, env.token(t, auth.RoleViewer))
if resp.StatusCode != http.StatusOK {
t.Fatalf("viewer 读来源动作 status = %d, body = %s,期望 200", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), "searxng") {
t.Fatalf("读来源动作的结果应当透传:%s", raw)
}
}
// TestAdminPluginActionHonest501WithoutExecutor 锁定「声明了但没接线」的诚实作答:
// 501 action_not_implemented,绝不回一份假的成功。
func TestAdminPluginActionHonest501WithoutExecutor(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
resp, raw := doReq(t, http.MethodPost,
env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.testProvider", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusNotImplemented || !strings.Contains(string(raw), "action_not_implemented") {
t.Fatalf("status = %d, body = %s,期望 501 action_not_implemented", resp.StatusCode, raw)
}
if !hasAudit(env.auditRecords(t), "plugin.action", "unimplemented") {
t.Fatalf("未接线的动作结算必须落审计(unimplemented)")
}
}
// TestAdminPluginActionDisabledIs409 锁定停用插件不再分发动作(声明还在,入口没了)。
func TestAdminPluginActionDisabledIs409(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
env.hub.SetEnabled("web-search", false)
resp, raw := doReq(t, http.MethodPost,
env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.listProviders", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusConflict || !strings.Contains(string(raw), "plugin_disabled") {
t.Fatalf("status = %d, body = %s,期望 409 plugin_disabled", resp.StatusCode, raw)
}
}
// TestAdminPluginActionRejectsMalformedBody 锁定请求体两条硬边界:
// 非法 JSON ⇒ 400(不当空体继续跑);超限 ⇒ 400(不把整个体读进内存)。
func TestAdminPluginActionRejectsMalformedBody(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
env.hub.SetExecutor(func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
return "should-not-run", nil
})
url := env.ts.URL + "/api/v1/admin/plugins/web-search/actions/plugin.saveProvider"
// 手工构造请求:doReq 会把入参 JSON 序列化,无法产生畸形 JSON 体。
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader("{not json"))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+env.token(t, auth.RoleOwner))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
raw := readBody(t, resp)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("非法 JSON status = %d, body = %s,期望 400", resp.StatusCode, raw)
}
big := strings.Repeat("a", panelActionBodyLimit+16)
resp, raw = doReq(t, http.MethodPost, url, map[string]any{"blob": big}, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("超限请求体 status = %d, body = %s,期望 400", resp.StatusCode, raw)
}
}
// readBody 读完并关闭响应体(供手工构造的请求复用)。
func readBody(t *testing.T, resp *http.Response) []byte {
t.Helper()
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return b
}
// hasAudit 报告审计链里是否存在指定的 (what, result) 记录。
func hasAudit(recs []AuditRecord, what, result string) bool {
for _, r := range recs {
if r.What == what && r.Result == result {
return true
}
}
return false
}
+10
View File
@@ -162,6 +162,16 @@ func (s *Server) registerAdminRoutes(
// 任何新的持久化模型。
mux.Handle("GET /api/v1/admin/notifications", viewerAuth(http.HandlerFunc(s.adminListNotifications)))
// 插件管理面板的**唯一契约面**(design/09 §5.2.1;切片 PLG-2):
// - GET /admin/panels 是**按调用者角色过滤后**的接入点快照,走 viewerAuth ——
// viewer 拿到的是已被裁掉写面的只读投影(§5.4.1),因此这条读门不越权;
// - 动作端点走 viewerAuth,是因为 table/log 的 **source 读来源**动作对 viewer
// 可见;**写**动作(form 的 submit、table 的 rowActions)由 handler 内再按
// owner|admin 收紧(callerHasWrite,见 admin_panels.go),与「写动作一律
// writeAuth」逐字对齐 —— 路由中间件只能有一道门,写面的门因此落在 handler 里。
mux.Handle("GET /api/v1/admin/panels", viewerAuth(http.HandlerFunc(s.adminListPanels)))
mux.Handle("POST /api/v1/admin/plugins/{id}/actions/{action}", viewerAuth(http.HandlerFunc(s.adminPluginAction)))
// 保留给 owner/admin 的管理读:私有内容、其他账号的
// 数据与审计链。不对 viewer 开放。
mux.Handle("GET /api/v1/admin/memories", writeAuth(http.HandlerFunc(s.adminListMemories)))