- 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 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
313 lines
12 KiB
Go
313 lines
12 KiB
Go
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)
|
||
}
|