mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 问题
同一个问题「谁能看到 / 能调用什么」有两套规则、相反的默认值,并排住在两个文件里:
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>
181 lines
6.5 KiB
Go
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}
|
|
}
|