diff --git a/internal/lua/plugin_access.go b/internal/lua/plugin_access.go new file mode 100644 index 0000000..6fa6efc --- /dev/null +++ b/internal/lua/plugin_access.go @@ -0,0 +1,180 @@ +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} +} diff --git a/internal/lua/plugin_access_test.go b/internal/lua/plugin_access_test.go new file mode 100644 index 0000000..96655f5 --- /dev/null +++ b/internal/lua/plugin_access_test.go @@ -0,0 +1,152 @@ +package lua + +import ( + "strings" + "testing" +) + +// The two functions this file replaces had OPPOSITE defaults: +// +// audienceAllows: "" / "all" → everybody +// HTTPHandler.allows: "" → admin ONLY +// +// Both are correct for their surface. The hazard is that they are two +// vocabularies for the same question, so a reader — or an operator — assumes +// the rule that is more familiar applies. AccessRule keeps one rule and states +// the default as data at each call site. + +// TestCapabilityDefaultIsClosed: an endpoint that declares nobody must not be +// reachable. This is the property that makes "I forgot" fail safe. +func TestCapabilityDefaultIsClosed(t *testing.T) { + for _, tc := range []struct { + name string + rule string + }{ + {"empty", ""}, + {"nil-ish", " "}, + } { + if ParseAccessRule(tc.rule).AllowsCapability("user", "alice") { + t.Errorf("%s: an undeclared capability is reachable by a user", tc.name) + } + if !ParseAccessRule(tc.rule).AllowsCapability("admin", "alice") { + t.Errorf("%s: admin locked out of a capability they must be able to inspect", tc.name) + } + } +} + +// TestVisibilityDefaultIsInherits: the opposite default, on purpose. A page +// nobody can see is not rendered; an undeclared audience must not hide a page +// the author clearly meant to publish. +func TestVisibilityDefaultIsInherits(t *testing.T) { + if !ParseAccessRule("").AllowsVisibility("user") { + t.Error("an undeclared audience hides a page") + } + if ParseAccessRule("admin").AllowsVisibility("user") { + t.Error("an admin-only page was shown to a user") + } +} + +// TestNamedKeysReplaceRolesNotAdd: the single most consequential rule, and the +// one an "OR" implementation gets wrong. "allow these two keys" must not also +// admit every user key. +func TestNamedKeysReplaceRolesNotAdd(t *testing.T) { + r := ParseAccessRule("@alice, @bob") + if !r.AllowsCapability("user", "alice") { + t.Error("a named key was not admitted") + } + if r.AllowsCapability("user", "carol") { + t.Error("a key list leaked to every other user — the rule must REPLACE, not extend") + } + if r.AllowsCapability("user", "") { + t.Error("an unauthenticated caller — no key name at all — matched a named key") + } + if !r.AllowsCapability("admin", "") { + t.Error("admin locked out; a typo in a key list would leave nobody able to diagnose it") + } +} + +// TestRoleAndKeyUnionAdmitsNeitherSibling: what a rule naming BOTH a role and a +// key actually does. +// +// Written first as "both are admitted", which is the intuitive reading of a +// union — and the debug run showed the opposite: a non-empty key list REPLACES +// the role rule outright, so carol is denied even though "user" is listed. +// +// That surprise is exactly why the rule is named, commented and now tested. The +// question is whether the behaviour is the one we want; asserting it here +// documents the choice rather than quietly preserving whatever the old code +// happened to do. The behaviour matches the pre-existing HTTPHandler.allows, so +// nothing deployed changes. +func TestRoleAndKeyUnionAdmitsNeitherSibling(t *testing.T) { + r := ParseAccessRule("user, @alice") + if !r.AllowsCapability("user", "alice") { + t.Error("the named key was not admitted") + } + if r.AllowsCapability("user", "carol") { + t.Error("a listed role was honoured despite a non-empty key list; " + + "the key list must replace it, or a listed role silently widens " + + "access to every user key") + } + if r.AllowsCapability("user", "mallory") { + t.Error("an unrelated user was admitted") + } +} + +// TestAllIsAnAliasForPublic guards a real regression this refactor nearly +// shipped. "all" is what existing plugin files and already-saved operator +// overrides contain; when the unified parser did not know it, a published grant +// silently became a closed one. The pre-existing TestAudienceAllows is what +// caught it — this test states it as a property of the new type so the +// compatibility requirement is not left to whichever suite happens to run. +func TestAllIsAnAliasForPublic(t *testing.T) { + r := ParseAccessRule("all") + if !r.Public { + t.Fatal(`"all" did not parse as public`) + } + if !r.AllowsCapability("user", "carol") { + t.Error(`"all" closed a grant that was published as open`) + } + // It must also render back to the form the editor understands, or the next + // save of an untouched field would rewrite it. + if !strings.Contains(strings.ToLower(r.Rule()), "public") { + t.Errorf("Rule() = %q, want it to render as public", r.Rule()) + } +} + +// TestRuleRoundTrips: the admin page fills its input from Rule(), so a grant +// that does not round-trip is one an operator cannot edit without translating. +func TestRuleRoundTrips(t *testing.T) { + for _, in := range []string{ + "admin", + "admin, user", + "@alice", + "@alice, @bob", + "user, @alice", + "public", + "all", + } { + once := ParseAccessRule(in) + twice := ParseAccessRule(once.Rule()) + if once.Rule() != twice.Rule() { + t.Errorf("%q -> %q -> %q, not stable", in, once.Rule(), twice.Rule()) + } + if len(twice.Roles) != len(once.Roles) || len(twice.Keys) != len(once.Keys) { + t.Errorf("%q lost data across a round trip: %+v -> %+v", in, once, twice) + } + } +} + +// TestRoleMatchingIsCaseAndSpaceInsensitive: grants arrive typed by humans into +// a text input, and "Admin" not matching "admin" would lock an admin out of +// their own plugin. +func TestRoleMatchingIsCaseAndSpaceInsensitive(t *testing.T) { + if !ParseAccessRule(" Admin , User ").AllowsCapability("admin", "x") { + t.Error("casing and spacing defeated a role match") + } + if ParseAccessRule("Admin").AllowsCapability("user", "x") { + t.Error("a user was admitted by a case variant") + } + if !ParseAccessRule(" @Alice ").AllowsCapability("user", "alice") { + t.Error("casing defeated a named-key match") + } +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 4548955..eee9423 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -1134,18 +1134,9 @@ func (ps *Plugins) HookErrors() map[string]map[string]interface{} { // "*" is deliberately NOT a synonym for "all": an audience that looks // restrictive in a config file while meaning everything is exactly the kind of // setting that fails silently. +// audienceAllows answers a VISIBILITY question, so an empty grant inherits. func audienceAllows(want, role string) bool { - want = strings.ToLower(strings.TrimSpace(want)) - if want == "" || want == "all" { - return true - } - role = strings.ToLower(strings.TrimSpace(role)) - for _, part := range strings.Split(want, ",") { - if strings.TrimSpace(part) == role { - return true - } - } - return false + return ParseAccessRule(want).AllowsVisibility(role) } // visibilityOverrides holds the operator's per-plugin audience overrides. @@ -2091,40 +2082,11 @@ func (h HTTPHandler) routePath() string { // // keyName is the caller key's configured name ("" when it has none), not the // secret. +// allows answers a CAPABILITY question, so an empty grant denies. See +// AccessRule.AllowsCapability for why the default is closed and why a non-empty +// key list replaces the role rule instead of adding to it. func (h HTTPHandler) allows(role, keyName string) bool { - // Admin is always admitted. An operator must 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. - if role == "admin" { - return true - } - // Named keys REPLACE the role rule rather than adding to it. - if len(h.Keys) > 0 { - if keyName == "" { - return false - } - for _, k := range h.Keys { - if strings.EqualFold(strings.TrimSpace(k), keyName) { - return true - } - } - return false - } - if h.Public { - return true - } - if len(h.Roles) == 0 { - // No declared roles and no public opt-out: treat as admin-only. A plugin - // author who forgets this gets a 403 rather than an open endpoint, and - // the error says which field to set. - return role == "admin" - } - for _, r := range h.Roles { - if strings.EqualFold(strings.TrimSpace(r), role) { - return true - } - } - return false + return accessRuleFor(h.Roles, h.Keys, h.Public).AllowsCapability(role, keyName) } func (ps *Plugins) StateVisibleTo(name, role string) bool {