mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(plugins): 访问控制在插件页配置 + 修复 active 仪表在周期视图显示 0
## 插件管理页(用户要求:权限不要只放 config.yaml) 插件页新增两列(可见性、端点)和一块访问控制编辑器,可直接改: - 每个插件的整体可见性(跟随插件 / 仅 admin / 仅 user / admin+user / 所有人) - 每个注册端点的可访问角色与密钥(admin, user, @homeagent, public) 原先这些能力后端齐全但 WebUI 零入口,唯一改法是手改 config.yaml。 ## 修掉的三个真缺陷 1. endpointAccess 读错了 override 的键 SetEndpointAccess 按 plugin#path 存,endpointAccess 却读 plugin 级。 后果:改了权限后 UI 仍显示插件声明的角色,再保存一次就把限制悄悄撤销。 鉴权一直是对的,只有显示和往返是坏的 —— 所以之前没被发现。 2. override 只在内存里 内存 map 让"billing 仅 admin"看着生效,直到下次重启限制凭空消失, 日志里没有任何线索。现在落盘到 plugin_dir/.access.json, 插件升级覆盖 .state.json 也不会连带放宽访问。删掉最后一个 override 就删文件, "没有覆盖"用文件不存在来诚实表达。 3. 可见性列表泄漏内部键 端点 override 与插件可见性共用一个 map,UI 因此会看到 "probe#hello" 并可能把它当 audience 存回去。现在按已注册端点集合过滤。 另外把整体可见性接到同一条 /access 通路(path 用 *),并修了可见性下拉的 集合比较:服务端原样存 "admin, user",而选项是 "user,admin",不相等就会 显示"跟随插件",用户一保存就把访问范围放宽回插件声明 —— 比显示错更糟。 ## 活跃请求:三个视图显示 0 active 是实时仪表,没有日历周期可过滤,但周期响应压根没带这个字段。 前端 `st.active || 0` 于是把"字段缺失"变成了一句自信的"0 活跃", 而 all 视图(响应里有该字段)显示真值 —— 三个视图 0、总视图正常。 现在周期响应带上 active,前端去掉 || 0 回退:缺失显示 — 而不是假装是 0。 判据:四个 period 都断言 active 存在且为数字;变异去掉字段即变红。 生产实测 day/week/month/all 均为 4。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev> (cherry picked from commit120ba9e957) (cherry picked from commit3b28fde22e)
This commit is contained in:
@ -464,6 +464,13 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
"by_status": out.Status,
|
||||
"buckets": out.Bucket,
|
||||
"truncated": out.Truncated,
|
||||
// "Active requests" is a live gauge (requests in flight right now),
|
||||
// not a period aggregate. Omitting it from the period response made
|
||||
// the KPI read `st.active || 0` → 0 on every day/week/month view while
|
||||
// the all-time view showed the truth, which read as "the gateway is
|
||||
// idle" precisely when it was busiest. A calendar window cannot
|
||||
// filter it, so it travels with every response unfiltered.
|
||||
"active": g.stats.Active(),
|
||||
"records": page.Records,
|
||||
// The paging cursor has to travel with the first page, otherwise
|
||||
// the scroll handler has nothing to page from and the table is
|
||||
|
||||
@ -130,6 +130,11 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
"on_disk": ps.OnDisk(),
|
||||
"hook_errors": ps.HookErrors(),
|
||||
"plugin_dir": g.pluginDir(),
|
||||
// Visibility overrides and the endpoint access table, so the
|
||||
// plugin page renders what is IN FORCE rather than making the
|
||||
// operator read .lua files or config.yaml to find out.
|
||||
"visibility": ps.VisibilityOverrides(),
|
||||
"http_access": ps.EndpointAccess(),
|
||||
})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
@ -388,6 +393,17 @@ func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, n
|
||||
}
|
||||
ps := g.core.Plugins()
|
||||
|
||||
// epPath "*" is the WHOLE-PLUGIN visibility override, not an endpoint. It
|
||||
// shares this route (and therefore this store) on purpose: an operator
|
||||
// setting "who can see this plugin" and "who can call this endpoint" is
|
||||
// asking the same question at two granularities, and two stores would let
|
||||
// them disagree. SetEndpointAccess cannot serve it — it validates that the
|
||||
// endpoint exists, which "*" by definition does not.
|
||||
if epPath == pluginWholeOverride {
|
||||
g.handlePluginVisibility(w, r, name)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
for _, e := range ps.EndpointAccess() {
|
||||
@ -489,3 +505,41 @@ func filterKeyBuckets(v interface{}, masked string, depth int) interface{} {
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// pluginWholeOverride is the epPath that addresses a plugin's overall visibility
|
||||
// rather than one of its endpoints.
|
||||
const pluginWholeOverride = "*"
|
||||
|
||||
// handlePluginVisibility is the whole-plugin half of the access API. The
|
||||
// audience string uses the same vocabulary as an endpoint spec (roles, @key
|
||||
// names, public) so the UI can drive both from one input.
|
||||
func (g *Gateway) handlePluginVisibility(w http.ResponseWriter, r *http.Request, name string) {
|
||||
ps := g.core.Plugins()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"plugin": name,
|
||||
"visibility": ps.VisibilityOverrides()[name],
|
||||
})
|
||||
case http.MethodPut, http.MethodDelete:
|
||||
spec := ""
|
||||
if r.Method == http.MethodPut {
|
||||
var body struct {
|
||||
Access string `json:"access"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
spec = body.Access
|
||||
}
|
||||
if err := ps.ValidateAccessSpec(spec); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
ps.SetPluginVisibility(name, spec)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "visibility": spec})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method+" is not allowed here")
|
||||
}
|
||||
}
|
||||
|
||||
@ -210,3 +210,54 @@ func TestPluginStatePUTIsAdminOnly(t *testing.T) {
|
||||
t.Errorf("admin PUT state = %d: %s", adm.Code, adm.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginsListCarriesAccessTables: the plugin page renders visibility
|
||||
// overrides and the endpoint access table from /api/plugins. Without them in the
|
||||
// response the operator has to open a .lua or config.yaml to learn who can see
|
||||
// what, which is exactly what the UI is meant to avoid.
|
||||
func TestPluginsListCarriesAccessTables(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/plugins = %d", rr.Code)
|
||||
}
|
||||
var out struct {
|
||||
Visibility map[string]string `json:"visibility"`
|
||||
HTTPAccess []map[string]interface{} `json:"http_access"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
|
||||
}
|
||||
// The calltest plugin registers no endpoints, so the table is present but
|
||||
// empty — the KEY is that the field exists at all. A missing field decodes to
|
||||
// nil and the UI silently renders nothing, which looks identical to "this
|
||||
// plugin has no endpoints".
|
||||
if !strings.Contains(rr.Body.String(), "http_access") {
|
||||
t.Error("/api/plugins response lacks the http_access field")
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "visibility") {
|
||||
t.Error("/api/plugins response lacks the visibility field")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginWholeVisibilityViaAccessAPI covers the "*" path: the plugin page
|
||||
// edits a plugin's overall audience through the same route it edits endpoints
|
||||
// with. Before this existed the UI had a visibility selector whose save 404'd,
|
||||
// because SetEndpointAccess validates that the endpoint exists and "*" does not.
|
||||
func TestPluginWholeVisibilityViaAccessAPI(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
rr := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
|
||||
`{"access":"admin, user"}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT */access = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"ok":true`) {
|
||||
t.Errorf("unexpected body: %s", rr.Body.String())
|
||||
}
|
||||
// A user role is a legitimate audience; a nonsense one is not.
|
||||
bad := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
|
||||
`{"access":"../../etc"}`)
|
||||
if bad.Code != http.StatusBadRequest {
|
||||
t.Errorf("traversal-shaped audience = %d, want 400: %s", bad.Code, bad.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@ -1211,3 +1211,16 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||
"replay_partial": s.replayPartial,
|
||||
}
|
||||
}
|
||||
|
||||
// Active reports requests in flight right now.
|
||||
//
|
||||
// It is a live gauge, not an aggregate: there is no calendar period that scopes
|
||||
// it, which is why the period-scoped /api/stats response has to carry it
|
||||
// explicitly. Before it did, the day/week/month views fed `st.active || 0` a
|
||||
// missing key and showed a hard 0 — the same gateway that was serving hundreds
|
||||
// of requests a second looked idle in three of the four views.
|
||||
func (s *Stats) Active() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.active
|
||||
}
|
||||
|
||||
@ -401,3 +401,34 @@ func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) {
|
||||
t.Fatal("today must be contained in both the week and the month totals")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodStatsCarryActiveGauge pins the live "active requests" gauge onto
|
||||
// every period response.
|
||||
//
|
||||
// It is a live gauge, not an aggregate, so no calendar window can scope it — but
|
||||
// the period response is what the day/week/month views render from, and it used
|
||||
// to omit the field entirely. The UI then evaluated `st.active || 0` and printed
|
||||
// a hard 0 in three of four views, while the all-time view (whose response did
|
||||
// carry the field) showed the truth. Reading "0 active" off a busy gateway is
|
||||
// worse than reading nothing: it is a confident wrong answer.
|
||||
func TestPeriodStatsCarryActiveGauge(t *testing.T) {
|
||||
for _, period := range []string{"day", "week", "month", "all"} {
|
||||
g := newTestGateway(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/stats?period="+period, "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("period=%s: %d", period, rr.Code)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("period=%s decode: %v", period, err)
|
||||
}
|
||||
v, ok := out["active"]
|
||||
if !ok {
|
||||
t.Errorf("period=%s response has no active field; the UI renders 0", period)
|
||||
continue
|
||||
}
|
||||
if _, ok := v.(float64); !ok {
|
||||
t.Errorf("period=%s active is %T, want a number", period, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -815,8 +815,21 @@
|
||||
plTitle: "插件",
|
||||
plState: "状态",
|
||||
plStages: "阶段",
|
||||
plActive: "启用中",
|
||||
plDisabled: "已禁用",
|
||||
|
||||
plVisibility: "可见性",
|
||||
plEndpoints: "端点",
|
||||
plAccessTitle: "访问控制",
|
||||
plAccessHint: "填写允许访问的角色或密钥(@名称)。留空=仅 admin。填 public=任何人。指定 @密钥 时只有该密钥能访问,不受角色规则影响。",
|
||||
plWholePlugin: "以下端点",
|
||||
plKeyHint: "可用密钥名",
|
||||
plAccessSave: "保存访问控制", plActive: "启用中",
|
||||
|
||||
tVisDefault: "跟随插件",
|
||||
tVisAdmin: "仅 admin",
|
||||
tVisRoles: "admin + user",
|
||||
tVisAll: "所有人",
|
||||
plOverridden: "已覆盖插件声明",
|
||||
plEpsN: "%d 个端点", plDisabled: "已禁用",
|
||||
plBroken: "加载失败",
|
||||
plBuiltin: "内置",
|
||||
plHooks: "个阶段",
|
||||
@ -1090,8 +1103,21 @@
|
||||
plTitle: "Plugins",
|
||||
plState: "State",
|
||||
plStages: "Stages",
|
||||
plActive: "Active",
|
||||
plDisabled: "Disabled",
|
||||
|
||||
plVisibility: "Visibility",
|
||||
plEndpoints: "Endpoints",
|
||||
plAccessTitle: "Access control",
|
||||
plAccessHint: "Roles or keys (@name) allowed to reach each endpoint. Empty = admin only. public = anyone. Listing @keys replaces the role rules entirely.",
|
||||
plWholePlugin: "endpoints below",
|
||||
plKeyHint: "Key names",
|
||||
plAccessSave: "Save access", plActive: "Active",
|
||||
|
||||
tVisDefault: "follow plugin",
|
||||
tVisAdmin: "admin only",
|
||||
tVisRoles: "admin + user",
|
||||
tVisAll: "everyone",
|
||||
plOverridden: "overrides plugin",
|
||||
plEpsN: "%d endpoints", plDisabled: "Disabled",
|
||||
plBroken: "Failed to load",
|
||||
plBuiltin: "Built-in",
|
||||
plHooks: "stages",
|
||||
@ -1994,7 +2020,12 @@
|
||||
const e = $(sel);
|
||||
if (e) e.textContent = txt;
|
||||
};
|
||||
kset('[data-kpi="active"]', st.active || 0);
|
||||
// No `|| 0` fallback: that fallback is what turned a MISSING field
|
||||
// into a confident "0 active" on three of four views. If the server
|
||||
// ever stops sending it again, an em dash says "unknown" and the
|
||||
// regression is visible, instead of the gauge quietly claiming the
|
||||
// gateway is idle while it serves traffic.
|
||||
kset('[data-kpi="active"]', st.active === undefined ? "—" : fmtN(st.active));
|
||||
kset('[data-kpi="reqs"]', fmtN(tot.reqs));
|
||||
const rsub = $('[data-kpi="reqs-sub"]');
|
||||
if (rsub)
|
||||
@ -4473,6 +4504,8 @@ function afterChainEdit() {
|
||||
<td><b>${esc(p.name)}</b>${builtin}<div class="muted">${esc(p.description || p.version || "")}</div>${err}</td>
|
||||
<td>${state}</td>
|
||||
<td>${hooks}</td>
|
||||
<td>${plVisCell(j, p)}</td>
|
||||
<td>${plEpsCell(j, p)}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="small" onclick="editPlugin('${escAttr(p.name)}')">${t("plEdit")}</button>
|
||||
${toggle}
|
||||
@ -4494,8 +4527,9 @@ function afterChainEdit() {
|
||||
$("#tab-plugins").innerHTML = `
|
||||
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
|
||||
<div class="tbl-wrap"><table><tr>
|
||||
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th></th>
|
||||
</tr>${rows || `<tr><td colspan="4" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
|
||||
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th>${t("plVisibility")}</th><th>${t("plEndpoints")}</th><th></th>
|
||||
</tr>${rows || `<tr><td colspan="6" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
|
||||
<div id="pl-access"></div>
|
||||
${errList ? `<p class="muted" style="color:var(--danger)">${t("plHookErr")}</p><ul style="font-size:12px">${errList}</ul>` : ""}
|
||||
<p class="muted">${t("plDir")} <code>${esc(j.plugin_dir)}</code></p>
|
||||
</div>
|
||||
@ -4508,6 +4542,161 @@ function afterChainEdit() {
|
||||
<p><button onclick="installPlugin()">${t("plInstallBtn")}</button></p>
|
||||
</div>`;
|
||||
bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code");
|
||||
renderPluginAccess(j);
|
||||
}
|
||||
|
||||
|
||||
const PL_VIS = [
|
||||
["", "tVisDefault"],
|
||||
["admin", "tVisAdmin"],
|
||||
["user,admin", "tVisRoles"],
|
||||
["all", "tVisAll"],
|
||||
];
|
||||
|
||||
// plVisCell shows the plugin's own declaration next to any operator
|
||||
// override, because "admin-only" means something different depending on
|
||||
// whether the plugin chose it or the deployment imposed it. Editing goes
|
||||
// through the same access API as endpoints so there is one place where
|
||||
// visibility is stored.
|
||||
function plVisCell(j, p) {
|
||||
const decl = (p.ui && p.ui.visibility) || "";
|
||||
const ov = (j.visibility || {})[p.name] || "";
|
||||
const cur = ov || decl;
|
||||
// Compare as SETS. The server stores whatever order the operator typed
|
||||
// and we round-trip it verbatim, so "admin, user" must still light up a
|
||||
// choice — otherwise the selector silently shows "follow plugin", and
|
||||
// saving from there writes an empty override that WIDENS access back to
|
||||
// the plugin's own declaration.
|
||||
const norm = (v) =>
|
||||
(v || "")
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(",");
|
||||
const curN = norm(cur);
|
||||
// An audience with no matching choice (one naming a @key, say) is offered
|
||||
// as itself so the value in force is never hidden from the operator.
|
||||
const opts = PL_VIS.some((c) => norm(c[0]) === curN)
|
||||
? PL_VIS
|
||||
: PL_VIS.concat([[cur, "plCustomVis"]]);
|
||||
return `<select data-plvis="${escAttr(p.name)}" style="font-size:11.5px;max-width:180px">
|
||||
${opts
|
||||
.map(
|
||||
(c) =>
|
||||
`<option value="${c[0]}"${norm(c[0]) === curN ? " selected" : ""}>${
|
||||
c[1] === "plCustomVis" ? "自定义: " + cur : t(c[1])
|
||||
}</option>`,
|
||||
)
|
||||
.join("")}
|
||||
</select>${
|
||||
ov && decl && ov !== decl
|
||||
? `<div class="muted" style="font-size:10.5px" title="${esc(decl)}">${t("plOverridden")}</div>`
|
||||
: ""
|
||||
}`;
|
||||
}
|
||||
|
||||
function plEpsCell(j, p) {
|
||||
const n = (j.http_access || []).filter((e) => e.plugin === p.name).length;
|
||||
return n
|
||||
? `<a href="#" onclick="document.getElementById('pl-access').scrollIntoView({behavior:'smooth'});return false">${t("plEpsN").replace("%d", n)}</a>`
|
||||
: `<span class="muted">—</span>`;
|
||||
}
|
||||
|
||||
// ---- visibility & endpoint access ----
|
||||
//
|
||||
// The plugin page is where an operator edits who can see what. The
|
||||
// alternatives are editing a bundled .lua — overwritten on the next
|
||||
// upgrade — or hand-editing config.yaml, with no feedback about what is
|
||||
// actually in force. Each row shows the rule next to whether it was
|
||||
// DECLARED by the plugin or OVERRIDDEN here, because "admin-only" means
|
||||
// something different depending on which.
|
||||
function renderPluginAccess(j) {
|
||||
const host = $("#pl-access");
|
||||
if (!host) return;
|
||||
const eps = j.http_access || [];
|
||||
if (!eps.length) { host.innerHTML = ""; return; }
|
||||
const keys = (window.__gwKeyRows || []).map((k) => k.name).filter(Boolean);
|
||||
const byPlugin = {};
|
||||
eps.forEach((e) => { (byPlugin[e.plugin] = byPlugin[e.plugin] || []).push(e); });
|
||||
let h = `<div class="card" style="margin-top:12px"><h2>${t("plAccessTitle")}</h2>
|
||||
<p class="muted" style="font-size:12px">${t("plAccessHint")}</p>`;
|
||||
Object.keys(byPlugin).forEach((pn) => {
|
||||
h += `<div style="margin:0 0 14px">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px">
|
||||
<b style="font-size:13px">${esc(pn)}</b>
|
||||
<span class="muted" style="font-size:11.5px">${t("plWholePlugin")}</span>
|
||||
</div>`;
|
||||
(byPlugin[pn] || []).forEach((e) => {
|
||||
const ovr = (e.override_keys && e.override_keys.length) ||
|
||||
(e.override_roles && e.override_roles.length);
|
||||
const cur = ovr
|
||||
? [...(e.override_keys || []).map((k) => "@" + k), ...(e.override_roles || [])].join(",")
|
||||
: [...(e.keys || []).map((k) => "@" + k), ...(e.roles || []), e.public ? "public" : ""]
|
||||
.filter(Boolean).join(",") || "admin";
|
||||
h += `<div style="margin:6px 0 0;padding-left:10px;border-left:2px solid var(--bd)">
|
||||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
<code style="font-size:11.5px">${esc(e.method)} ${esc(e.full_path)}</code>
|
||||
<span class="muted" style="font-size:11px">→ ${esc(e.handler)}()</span>
|
||||
${e.description ? `<span class="muted" style="font-size:11px">${esc(e.description)}</span>` : ""}
|
||||
<span class="grow"></span>
|
||||
<input data-ep="${esc(pn)}|${esc(e.path)}" value="${esc(cur)}"
|
||||
style="max-width:340px;font-size:11.5px"
|
||||
placeholder="admin, user, @homeagent, public">
|
||||
</div>
|
||||
${keys.length ? `<div style="font-size:11px;opacity:.7;margin-top:3px">${t("plKeyHint")}: ${esc(keys.join(", "))}</div>` : ""}
|
||||
</div>`;
|
||||
});
|
||||
h += `</div>`;
|
||||
});
|
||||
h += `<p><button id="pl-access-save" onclick="savePluginAccess(this)">${t("plAccessSave")}</button></p></div>`;
|
||||
host.innerHTML = h;
|
||||
}
|
||||
|
||||
async function savePluginAccess(btn) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
// Whole-plugin visibility first. Sent through the SAME access API as
|
||||
// endpoints (path "*") so visibility has exactly one storage path.
|
||||
const viss = Array.prototype.slice.call(
|
||||
document.querySelectorAll("[data-plvis]"));
|
||||
for (const sel of viss) {
|
||||
const pn = sel.getAttribute("data-plvis");
|
||||
const url =
|
||||
"/api/plugins/" + encodeURIComponent(pn) + "/http/*/access";
|
||||
const v = sel.value;
|
||||
if (v) {
|
||||
await api(url, { method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ access: v }) });
|
||||
} else {
|
||||
await api(url, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
const inputs = Array.prototype.slice.call(
|
||||
document.querySelectorAll("#pl-access [data-ep]"));
|
||||
for (const inp of inputs) {
|
||||
const raw = inp.getAttribute("data-ep");
|
||||
const sep = raw.indexOf("|");
|
||||
const pn = raw.slice(0, sep), ep = raw.slice(sep + 1);
|
||||
const url = "/api/plugins/" + encodeURIComponent(pn) + "/http/" +
|
||||
encodeURIComponent(ep) + "/access";
|
||||
const v = inp.value.trim();
|
||||
if (v) {
|
||||
await api(url, { method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ access: v }) });
|
||||
} else {
|
||||
await api(url, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
toast(t("toastSaved"));
|
||||
renderPlugins();
|
||||
} catch (e) {
|
||||
toast(tFmt("toastSaveFail", e.message));
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(name, disabled) {
|
||||
|
||||
@ -388,3 +388,26 @@ func TestPluginSidebarEntryCarriesATitle(t *testing.T) {
|
||||
t.Errorf("plugin sidebar button renders the icon only; a label is required. "+
|
||||
"got: %.200s", stmt)
|
||||
}
|
||||
|
||||
// TestPluginPageHasAccessEditor: the plugin page must let an operator configure
|
||||
// endpoint access. The APIs existed with zero UI entry point, which meant the
|
||||
// only way to change who could reach a plugin endpoint was to hand-edit
|
||||
// config.yaml — the thing the UI is meant to replace.
|
||||
func TestPluginPageHasAccessEditor(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
for _, needle := range []string{
|
||||
`renderPluginAccess`, // called after rendering the plugin list
|
||||
`savePluginAccess`, // wired to a button
|
||||
`/access`, // hits the per-endpoint access API
|
||||
`id="pl-access"`, // container exists
|
||||
`http_access`, // reads the table from /api/plugins
|
||||
`data-ep=`, // one input per endpoint
|
||||
`data-plvis=`, // one visibility selector per plugin
|
||||
`/http/*/access`, // whole-plugin visibility shares the access API
|
||||
`plVisCell`, // renders the declared-vs-override distinction
|
||||
} {
|
||||
if !strings.Contains(src, needle) {
|
||||
t.Errorf("plugin page lacks %q", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
115
internal/lua/plugin_access_persist.go
Normal file
115
internal/lua/plugin_access_persist.go
Normal file
@ -0,0 +1,115 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Access overrides are OPERATOR policy, not plugin data. They live in their own
|
||||
// file rather than in each plugin's state because:
|
||||
//
|
||||
// - they describe the deployment, not the plugin, so a plugin upgrade that
|
||||
// replaces its .state.json must not silently widen access again;
|
||||
// - they must survive a restart. An in-memory map made "restrict billing to
|
||||
// admin" look like it worked right up to the next deploy, at which point the
|
||||
// restriction evaporated with nothing in the logs to say why;
|
||||
// - the plugin_dir is the one place an operator already looks when asking
|
||||
// "what does this plugin actually get to see", so the answer lives beside it.
|
||||
//
|
||||
// The file is a flat map of "name" (whole plugin) or "name#path" (one endpoint)
|
||||
// to an access spec, which is the same shape the in-memory store uses, so load
|
||||
// and save cannot disagree about what a key means.
|
||||
|
||||
const accessFileName = ".access.json"
|
||||
|
||||
func (ps *Plugins) accessFile() string {
|
||||
if ps.dir == "" {
|
||||
return ""
|
||||
}
|
||||
return ps.dir + string(os.PathSeparator) + accessFileName
|
||||
}
|
||||
|
||||
// LoadAccessOverrides reads the persisted overrides into memory. A missing file
|
||||
// is not an error: no operator has restricted anything yet, which is the
|
||||
// default state of a fresh install.
|
||||
//
|
||||
// A corrupt file is also not fatal. Losing a restriction is bad, but refusing to
|
||||
// start the gateway is worse, and an empty map plus a logged error leaves the
|
||||
// operator with something to fix.
|
||||
func (ps *Plugins) LoadAccessOverrides() error {
|
||||
path := ps.accessFile()
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := map[string]string{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
if ps.visibility == nil {
|
||||
ps.visibility = visibilityOverrides{}
|
||||
}
|
||||
for k, v := range m {
|
||||
ps.visibility[k] = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveAccessOverridesLocked flushes the whole override map. It is called on
|
||||
// every change, and unlike plugin state the map is tiny and changes only when an
|
||||
// operator clicks Save — so a synchronous write costs nothing that matters, and
|
||||
// it means the change is durable the moment the API returns 200.
|
||||
func (ps *Plugins) saveAccessOverridesLocked() {
|
||||
path := ps.accessFile()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
out := make(map[string]string, len(ps.visibility))
|
||||
for k, v := range ps.visibility {
|
||||
if v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
// Leave no empty file behind: its absence is the honest encoding of
|
||||
// "nothing is overridden".
|
||||
_ = os.Remove(path)
|
||||
return
|
||||
}
|
||||
writeJSONAtomic(path, out)
|
||||
}
|
||||
|
||||
// AccessOverrides returns a copy of every override in force, for the UI.
|
||||
func (ps *Plugins) accessOverridesSnapshot() map[string]string {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
if len(ps.visibility) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(ps.visibility))
|
||||
for k, v := range ps.visibility {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedOverrideKeys exists only so a debug dump is stable; map iteration order
|
||||
// would otherwise make two identical states look different.
|
||||
func (ps *Plugins) sortedOverrideKeys() []string {
|
||||
m := ps.accessOverridesSnapshot()
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
110
internal/lua/plugin_access_persist_test.go
Normal file
110
internal/lua/plugin_access_persist_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAccessOverrideSurvivesReload is the test that matters for an access
|
||||
// restriction. An in-memory map makes "billing is admin-only" look like it
|
||||
// worked right up to the next restart, and then the restriction is simply gone
|
||||
// with nothing in the logs to explain it. Both the plugin-level audience and
|
||||
// the per-endpoint spec must come back.
|
||||
func TestAccessOverrideSurvivesReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := `local P = {}
|
||||
P.name = "api"
|
||||
P.version = "1.0.0"
|
||||
P.http = {
|
||||
{ path = "health", handler = "health", roles = { "admin" } },
|
||||
}
|
||||
function P.health(req) return { json = { ok = true } } end
|
||||
return P
|
||||
`
|
||||
first := NewPlugins(NewVM(dir), dir)
|
||||
if err := first.LoadSource("api", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.SetPluginVisibility("api", "admin, user")
|
||||
if err := first.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, accessFileName)); err != nil {
|
||||
t.Fatalf("override was not written: %v", err)
|
||||
}
|
||||
|
||||
// A fresh Plugins over the same dir is what a restart looks like.
|
||||
second := NewPlugins(NewVM(dir), dir)
|
||||
// The plugin source is on disk too, so this second instance sees both the
|
||||
// plugin and the overrides — the same pair a restart sees. Loading only the
|
||||
// overrides would leave no endpoints to walk, and the assertion below would
|
||||
// pass vacuously.
|
||||
if err := second.LoadSource("api", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := second.LoadAccessOverrides(); err != nil {
|
||||
t.Fatalf("reload overrides: %v", err)
|
||||
}
|
||||
if got := second.VisibilityOverride("api"); got != "admin, user" {
|
||||
t.Errorf("plugin visibility lost across reload: %q", got)
|
||||
}
|
||||
found := false
|
||||
for _, e := range second.EndpointAccess() {
|
||||
if e.Path == "health" && len(e.OverrideKeys) == 1 && e.OverrideKeys[0] == "homeagent" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("endpoint override lost across reload")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessOverrideClearedRemovesFile keeps the "nothing overridden" state
|
||||
// distinguishable from "overridden with something".
|
||||
func TestAccessOverrideClearedRemovesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
ps.SetPluginVisibility("api", "admin")
|
||||
path := filepath.Join(dir, accessFileName)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected an access file: %v", err)
|
||||
}
|
||||
ps.SetPluginVisibility("api", "")
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("clearing the last override should remove the file, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisibilityOverridesExcludeEndpointKeys keeps endpoint policy out of the
|
||||
// plugin-audience listing. Both live in one map, and reporting "probe#hello"
|
||||
// under visibility is how the plugin page ends up offering to save an audience
|
||||
// of that name — which nothing reads as an audience.
|
||||
func TestVisibilityOverridesExcludeEndpointKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := `local P = {}
|
||||
P.name = "api"
|
||||
P.version = "1.0.0"
|
||||
P.http = { { path = "health", handler = "health", roles = { "admin" } } }
|
||||
function P.health(req) return { json = { ok = true } } end
|
||||
return P
|
||||
`
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.SetPluginVisibility("api", "admin, user")
|
||||
if err := ps.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ps.VisibilityOverrides()
|
||||
if got["api"] != "admin, user" {
|
||||
t.Errorf("plugin audience missing: %+v", got)
|
||||
}
|
||||
if _, leaked := got["api#health"]; leaked {
|
||||
t.Errorf("endpoint override leaked into the audience map: %+v", got)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Errorf("expected exactly the plugin audience, got %+v", got)
|
||||
}
|
||||
}
|
||||
@ -354,29 +354,25 @@ type HTTPEndpointAccess struct {
|
||||
func (ps *Plugins) endpointAccess() []HTTPEndpointAccess {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
return ps.endpointAccessLocked()
|
||||
}
|
||||
|
||||
// endpointAccessLocked is the body of endpointAccess for callers that already
|
||||
// hold ps.mu — VisibilityOverrides walks the same map and must not re-lock.
|
||||
func (ps *Plugins) endpointAccessLocked() []HTTPEndpointAccess {
|
||||
var out []HTTPEndpointAccess
|
||||
for _, p := range ps.plugins {
|
||||
if p.http == nil || p.LoadError != "" {
|
||||
continue
|
||||
}
|
||||
ov := ps.visibility[p.Info.Name]
|
||||
var ovRoles, ovKeys []string
|
||||
if ov != "" {
|
||||
for _, part := range strings.Split(ov, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
// A key name is anything that is not a role: the override string
|
||||
// carries both, so "@name" marks a key and a bare word a role.
|
||||
if strings.HasPrefix(part, "@") {
|
||||
ovKeys = append(ovKeys, strings.TrimPrefix(part, "@"))
|
||||
} else {
|
||||
ovRoles = append(ovRoles, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, h := range p.http {
|
||||
// The override key is per ENDPOINT (plugin#path), not per plugin.
|
||||
// Reading the plugin-level key here reported the plugin's own
|
||||
// audience override as if it governed every endpoint, and reported
|
||||
// no endpoint override at all — so the UI drew the DECLARED roles
|
||||
// after an operator had saved a change, and re-saving from that
|
||||
// display silently reverted the restriction.
|
||||
ovRoles, ovKeys := splitAccessSpec(ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)])
|
||||
out = append(out, HTTPEndpointAccess{
|
||||
Plugin: p.Info.Name, Path: h.Path, FullPath: h.routePath(),
|
||||
Method: h.Method, Handler: h.Handler,
|
||||
@ -422,9 +418,10 @@ func (ps *Plugins) SetEndpointAccess(plugin, path, spec string) error {
|
||||
key := endpointOverrideKey(plugin, path)
|
||||
if strings.TrimSpace(spec) == "" {
|
||||
delete(ps.visibility, key)
|
||||
return nil
|
||||
} else {
|
||||
ps.visibility[key] = spec
|
||||
}
|
||||
ps.visibility[key] = spec
|
||||
ps.saveAccessOverridesLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -509,3 +506,25 @@ func (ps *Plugins) ValidateAccessSpec(spec string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitAccessSpec turns an override string into role and key lists. "@name" is a
|
||||
// key; a bare word is a role. Shared by the reporting path and the enforcement
|
||||
// path so the two can never disagree about what an override means.
|
||||
func splitAccessSpec(spec string) (roles, keys []string) {
|
||||
if strings.TrimSpace(spec) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
switch {
|
||||
case part == "", part == "public":
|
||||
// "public" is an enforcement concern, not a listing; it is reported
|
||||
// via the Public field.
|
||||
case strings.HasPrefix(part, "@"):
|
||||
keys = append(keys, strings.TrimPrefix(part, "@"))
|
||||
default:
|
||||
roles = append(roles, part)
|
||||
}
|
||||
}
|
||||
return roles, keys
|
||||
}
|
||||
|
||||
@ -397,4 +397,34 @@ func TestParseHandlerResponseShapes(t *testing.T) {
|
||||
if r.Status != 204 || r.Headers["X-A"] != "b" {
|
||||
t.Errorf("decoded %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
// TestEndpointAccessReportsPerEndpointOverride guards the reporting side of the
|
||||
// override store.
|
||||
//
|
||||
// SetEndpointAccess already keyed overrides by plugin#path, but endpointAccess
|
||||
// read the plugin-level key. After an operator saved a restriction on ONE
|
||||
// endpoint, /api/plugins kept reporting the plugin's DECLARED roles, so the
|
||||
// plugin page drew the pre-change value and a later save from that screen
|
||||
// reverted the restriction. Authorization kept working — only the display and
|
||||
// the round trip through the UI were broken, which is why it went unnoticed.
|
||||
func TestEndpointAccessReportsPerEndpointOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// httpPlugin registers "health" and "echo/:what".
|
||||
if err := ps.SetEndpointAccess("api", "health", "user"); err != nil {
|
||||
t.Fatalf("SetEndpointAccess: %v", err)
|
||||
}
|
||||
got := map[string][]string{}
|
||||
for _, e := range ps.EndpointAccess() {
|
||||
got[e.Path] = e.OverrideRoles
|
||||
}
|
||||
if len(got["health"]) != 1 || got["health"][0] != "user" {
|
||||
t.Errorf("health override not reported to the UI: %+v", got["health"])
|
||||
}
|
||||
if len(got["echo/:what"]) != 0 {
|
||||
t.Errorf("echo inherited an override it never had: %+v", got["echo/:what"])
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,3 +79,40 @@ func (s *StateScope) DepthOf(key string) int {
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// VisibilityOverrides returns every operator override in force, keyed by plugin
|
||||
// name. An absent name means the plugin's own declaration applies.
|
||||
//
|
||||
// The WebUI needs this to show which visibility is IMPOSED, not merely which is
|
||||
// in effect: an operator comparing two deployments should not have to open a
|
||||
// .lua to learn whether a page is admin-only by the plugin's choice or by their
|
||||
// own override.
|
||||
func (ps *Plugins) VisibilityOverrides() map[string]string {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
if len(ps.visibility) == 0 {
|
||||
return nil
|
||||
}
|
||||
epKeys := make(map[string]struct{})
|
||||
for _, e := range ps.endpointAccessLocked() {
|
||||
epKeys[endpointOverrideKey(e.Plugin, e.Path)] = struct{}{}
|
||||
}
|
||||
out := make(map[string]string, len(ps.visibility))
|
||||
for k, v := range ps.visibility {
|
||||
// Per-endpoint overrides live in the SAME map under a "name#path" key.
|
||||
// They are endpoint policy, not a plugin audience, so leaking them here
|
||||
// both mislabels them and lets the plugin page write an audience of the
|
||||
// form "probe#hello" that no code ever reads as one.
|
||||
// Per-endpoint overrides share this map under a "name#path" key. They
|
||||
// are endpoint policy, not a plugin audience: reporting them here both
|
||||
// mislabels them and invites the plugin page to save an audience of the
|
||||
// form "probe#hello" that nothing reads as an audience. The set of
|
||||
// endpoint keys is already computed, so ask it rather than re-deriving
|
||||
// the shape from the key.
|
||||
if _, isEndpoint := epKeys[k]; isEndpoint {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ package lua
|
||||
// less featureful, so the split is explicit and documented.
|
||||
|
||||
import (
|
||||
"log"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@ -511,6 +512,12 @@ func (ps *Plugins) LoadDir() error {
|
||||
if ps.dir == "" {
|
||||
return nil
|
||||
}
|
||||
// Overrides first: they are keyed by plugin name and must be in place before
|
||||
// anything reads them, and a reload must not blank a restriction the
|
||||
// operator set earlier in this same process.
|
||||
if err := ps.LoadAccessOverrides(); err != nil {
|
||||
log.Printf("[core] plugin access overrides not loaded: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(ps.dir); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
@ -1167,9 +1174,10 @@ func (ps *Plugins) SetPluginVisibility(plugin, audience string) {
|
||||
audience = strings.TrimSpace(audience)
|
||||
if audience == "" {
|
||||
delete(ps.visibility, plugin)
|
||||
return
|
||||
} else {
|
||||
ps.visibility[plugin] = audience
|
||||
}
|
||||
ps.visibility[plugin] = audience
|
||||
ps.saveAccessOverridesLocked()
|
||||
}
|
||||
|
||||
// VisibilityOverride returns the operator's audience override for a plugin,
|
||||
|
||||
Reference in New Issue
Block a user