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>
This commit is contained in:
JianFeeeee
2026-10-04 11:49:47 +08:00
parent c17a851ec1
commit 120ba9e957
13 changed files with 716 additions and 29 deletions

View 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
}

View 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)
}
}

View File

@ -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
}

View File

@ -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"])
}
}

View File

@ -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
}

View File

@ -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,