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 commit 120ba9e957)
This commit is contained in:
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