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