Files
Cyrene_v2/internal/services/gateway/admin_panels.go
T
AskaEth 2d21aa553e 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 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
2026-10-06 09:03:57 +08:00

313 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}