Files
ModelRouter/internal/lua/plugin_access.go
JianFeeeee 360a2a8644 refactor(plugins): 统一授权词汇 —— AccessRule
## 问题

同一个问题「谁能看到 / 能调用什么」有两套规则、相反的默认值,并排住在两个文件里:

    audienceAllows(want, role):   "" / "all" → 全开      页面可见性
    HTTPHandler.allows(role,key): ""         → 仅 admin   注册端点

两个默认值各自都对:页面没人看得见就等于没渲染,页面不该被未声明的 audience
藏起来;而端点「忘了声明」绝不能等于对公网敞开。

危害不在默认值本身,在于**同一个问题有两套词汇**。运维配「谁能看 billing」和
「谁能调 /balance」得先学会两套规则,才能回答同一个问题。

## 改动

新增 internal/lua/plugin_access.go,把两套规则收敛成一个 AccessRule,
并把**仍然不同的那一点——默认值——写成数据而不是留成隐含**:

- AllowsCapability:空 = 拒绝(端点、函数)
- AllowsVisibility:空 = 继承(页面、元素)

语法也统一成一种,`ParseAccessRule` / `Rule` 双向:

    "admin" / "admin, user" / "@alice, @bob" / "user, @alice" / "public"

`@name` 是密钥、其余是角色,一��字符串表达得完规则编辑器要提供的全部选项。

## 抽取时差点弄丢一个真实语义

`"all"` 是既有插件文件和**已保存的运维 override** 里的写法。统一后的解析器
不认识它,于是一个已发布的「全开」授权静默变成了关闭 —— 已部署的
billing、quota 可见性和所有存量 override 都会受影响。

是既有的 TestAudienceAllows 抓到的,它当场变红。这正是抽取的实质收益:
两个函数并排住着的时候,没人会想到去动那个共用字符串。

`TestAllIsAnAliasForPublic` 把它写成新类型的性质,而不是留给碰巧跑到的那
个套件去守着。

## 顺手加的东西我又撤了

accessRuleFor 第一版把 roles 里写成 `@alice` 的项重分类成密钥,理由是
「这大概是个自然的笔误」。那是在重构里偷带行为变更:会把写了莫名其妙内容的
端点的可达范围悄悄放宽。已撤,注释里写明为什么这个笔误值得修是另一件事、
需要它自己的理由和判据。

## 判据

七条性质,每条一个默认/边界:capability 默认关、visibility 默认继承、
keys 替换而非追加、keys 空时未认证调用者不匹配、admin 恒通、
"all" 别名、Rule() 往返稳定、角色与密钥名大小写/空格无关。

变异自证四次,全部变红:keys 变 OR、capability 默认全开、visibility 默认
拒绝、丢掉 "all" 别名。

头两次跑变异时报「没抓到」,是注入脚本自己写坏了(NameError / 替换串不
匹配)—— 变异没生效不等于判据无力,重写后确认四条都能抓到。

## 一条被记录下来的意外

`TestRoleAndKeyUnionAdmitsBoth` 最初断言「同时列出 role 和 key 时两者都放行」。
debug 跑出来是相反的:keys 非空就整体替换掉 role,carol 被拒。

这个意外正是不把它放在注释里的理由。判据现在断言实际语义(替换),与既有
HTTPHandler.allows 一致,因此没有任何已部署行为改变;但它把「这是我们的选择」
写成了可执行的一句话,而不是默默沿用旧代码碰巧做的事。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 20:37:09 +08:00

181 lines
6.5 KiB
Go

package lua
import "strings"
// One vocabulary for "who may see / read / call this".
//
// Before this there were two rules with OPPOSITE defaults, living side by side:
//
// - audienceAllows(want, role): "" or "all" → everybody
// Used by UI visibility and per-page/per-element audiences.
// - HTTPHandler.allows(role, key): "" → admin ONLY
// Used by registered endpoints, where "I forgot to declare" must not mean
// "open to the internet".
//
// Both defaults are right for their own surface, and having them in one file
// with one name is how a reader ends up assuming the other one's rule applies.
// An operator configuring "who can see billing" and "who can call /balance" had
// to learn two vocabularies to answer the same question.
//
// The distinction that remains is not the RULE, it is the DEFAULT, so it is
// stated as data rather than left implicit: an empty grant is "deny" for
// capabilities and "inherit" for visibility, and every call site names which it
// wants.
// AccessRule is a parsed grant: a set of roles, an optional set of named gateway
// keys, and an explicit public flag.
//
// Keys REPLACE roles rather than adding to them. That is not an arbitrary
// choice: "allow these two keys" read as "these two keys AND everyone in the
// user role" silently widens access to every user key, which is the opposite of
// what someone tightening a rule meant. `keys = { "@alice" }` therefore means
// exactly alice.
type AccessRule struct {
// Roles admit a gateway role: "admin", "user", …
Roles []string
// Keys admit a named gateway key, by CONFIG NAME (never the secret itself, so
// a rule file contains no credential and survives key rotation).
Keys []string
// Public admits anyone, including an unauthenticated caller.
Public bool
}
// AccessAll is the grant that admits everyone.
func AccessAll() AccessRule { return AccessRule{Public: true} }
// ParseAccessRule reads the one grant syntax the plugin API accepts:
//
// "admin" roles
// "admin, user" roles
// "@alice, @bob" named keys, replacing the role rule
// "user, @alice" the named keys plus the roles (union)
// "public" anyone
// "all" anyone (the older spelling of public)
// "" empty — meaning depends on the caller
//
// An "@name" token is a key, anything else is a role. That is unambiguous to
// type and to parse, and it means one string can express everything the rules
// editor needs to offer.
//
// "all" is kept as an alias for "public" because it is what existing plugin
// files and saved operator overrides already contain. Dropping it would have
// silently turned a published grant into a closed one.
func ParseAccessRule(s string) AccessRule {
var r AccessRule
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.EqualFold(part, "public") || strings.EqualFold(part, "all") {
r.Public = true
continue
}
if strings.HasPrefix(part, "@") {
if k := strings.TrimSpace(strings.TrimPrefix(part, "@")); k != "" {
r.Keys = append(r.Keys, k)
}
continue
}
r.Roles = append(r.Roles, part)
}
return r
}
// Rule renders the grant back to the syntax ParseAccessRule accepts. The admin
// page uses it to fill its input, so a value that round-trips is a value the
// operator can edit without translating.
func (r AccessRule) Rule() string {
var parts []string
for _, k := range r.Keys {
parts = append(parts, "@"+k)
}
for _, role := range r.Roles {
parts = append(parts, role)
}
if r.Public {
parts = append(parts, "public")
}
return strings.Join(parts, ", ")
}
// Empty reports whether the grant names nobody.
func (r AccessRule) Empty() bool {
return len(r.Roles) == 0 && len(r.Keys) == 0 && !r.Public
}
// AllowsCapability reports whether a CAPABILITY (an endpoint, a callable
// function) is reachable. Empty means DENY: a plugin that forgets to declare
// gets a 403 naming the field to set, never an open door.
//
// Admin is always admitted. An operator has to be able to inspect what a plugin
// exposes even while locked out of it, or a typo in a key list would leave
// nobody able to diagnose the endpoint they were trying to tighten.
func (r AccessRule) AllowsCapability(role, keyName string) bool {
if role == "admin" {
return true
}
if len(r.Keys) > 0 {
// A non-empty key list REPLACES the role rule (see the type comment).
// An unauthenticated caller has no key name, so a key-scoped capability
// is unreachable for them — even when the same rule also lists roles,
// because a caller with no key is also not the kind of caller a role
// rule was written for.
if keyName == "" {
return false
}
for _, k := range r.Keys {
if strings.EqualFold(k, keyName) {
return true
}
}
// The key list replaced the role rule, so a miss is a deny even though
// roles may also be listed.
return false
}
if r.Public {
return true
}
for _, want := range r.Roles {
if strings.EqualFold(want, role) {
return true
}
}
return false
}
// AllowsVisibility reports whether a VIEW (a page, a UI element) is rendered.
// Empty means INHERIT: visibility is additive by nature — a page nobody can see
// is simply not rendered — so an undeclared audience must not hide a page the
// plugin clearly intends to publish.
func (r AccessRule) AllowsVisibility(role string) bool {
if r.Empty() || r.Public {
return true
}
if role == "admin" && len(r.Keys) > 0 {
// A key-scoped view still has to be inspectable by an admin, for the
// same reason capabilities do.
return true
}
for _, want := range r.Roles {
if strings.EqualFold(want, role) {
return true
}
}
return false
}
// accessRuleFor folds the separate declaration fields of an endpoint into one
// grant, so the rules below live in one place instead of beside each field.
//
// It is a plain fold, deliberately. An earlier version also reclassified a role
// that happened to start with "@" into a key, on the theory that "roles =
// { '@alice' }" is a natural mistake. That is a behaviour change smuggled into a
// refactor: it would have quietly widened who reaches an endpoint whose author
// wrote something nonsensical. If that mistake is worth fixing, it is a change
// with its own justification and its own test, not a side effect of unifying
// two functions into one.
func accessRuleFor(roles, keys []string, public bool) AccessRule {
return AccessRule{Roles: roles, Keys: keys, Public: public}
}