| new file mode 100644 |
| index 000000000..ca0da7c81 |
| --- /dev/null |
| +++ b/internal/svcrole/svcrole.go |
| @@ -0,0 +1,122 @@ |
| +// Package svcrole is the closed role vocabulary for service principals: what |
| +// a credential held by a SERVER may ask the console API for. |
| +// |
| +// WHY NOT internal/caps. caps is the app-manifest vocabulary — systemd |
| +// directives, privileged ports, database paths, the host-role grant an |
| +// operator types on a box. It says what a UNIT may do ON A MACHINE. This says |
| +// what a CALLER may do AT A DOOR. Fusing them would couple a manifest format |
| +// to a permission model and give each a reason to change that has nothing to |
| +// do with the other, so they share a spirit and no types. |
| +// |
| +// This package deliberately knows NOTHING about routes. The door-to-role |
| +// binding lives at registration (internal/console's cliRoute), so there is |
| +// exactly one place a door's authority is written down, and no second table |
| +// here to drift from it. |
| +// |
| +// Spec: docs/superpowers/specs/2026-08-18-service-principals-design.md §3. |
| +package svcrole |
| + |
| +import "sort" |
| + |
| +// Role is one name from the closed catalogue. A person's cli_tokens row |
| +// carries the empty string, which is deliberately NOT a Role: authority for a |
| +// person is their membership set, evaluated per request, and no role name |
| +// must ever resolve to it. |
| +type Role string |
| + |
| +const ( |
| + // Publish is the deploy loop: claim an app, ship, promote, roll back, |
| + // manage its hostnames, retire it. |
| + Publish Role = "publish" |
| + // Instance is the LEGACY app-token authority, defined by observation of |
| + // today's call sites rather than by its name — it includes domains and |
| + // logs. See the console's role table and spec §5. |
| + Instance Role = "instance" |
| + // Operate is runtime work with no release authority: restart, config, |
| + // logs. |
| + Operate Role = "operate" |
| + // Admin is the union of the three above plus ledgers, store and place. |
| + // It is NOT "everything harmless": store grant mints a path-scoped IAM |
| + // user, which is a real handover. |
| + Admin Role = "admin" |
| +) |
| + |
| +// catalogue is the whole vocabulary, in the order All returns it. |
| +var catalogue = []Role{Publish, Instance, Operate, Admin} |
| + |
| +// All returns the catalogue. The slice is copied so a caller cannot edit the |
| +// vocabulary by writing through it. |
| +func All() []Role { |
| + out := make([]Role, len(catalogue)) |
| + copy(out, catalogue) |
| + return out |
| +} |
| + |
| +// Parse resolves a stored role name. ok is false for anything outside the |
| +// catalogue, INCLUDING the empty string a person's row carries — a caller |
| +// that treats "unparseable" as "no authority" is then correct by default. |
| +func Parse(s string) (Role, bool) { |
| + for _, r := range catalogue { |
| + if string(r) == s { |
| + return r, true |
| + } |
| + } |
| + return "", false |
| +} |
| + |
| +// Set is the roles that reach one door. The zero value permits nothing, |
| +// which is the whole point: a door whose roles were never named refuses |
| +// every service principal rather than accidentally admitting one. |
| +type Set struct { |
| + roles map[Role]bool |
| +} |
| + |
| +// Allow builds the Set for a door. Admin is never listed by a caller that |
| +// means "publish and above": it is added here, once, from the definition |
| +// that Admin is the union — so ~50 registrations cannot disagree with the |
| +// catalogue about what Admin is. A door reachable ONLY by an administrator |
| +// says Allow(Admin) explicitly and gets exactly that. |
| +func Allow(roles ...Role) Set { |
| + if len(roles) == 0 { |
| + return Set{} |
| + } |
| + s := Set{roles: make(map[Role]bool, len(roles)+1)} |
| + for _, r := range roles { |
| + if _, ok := Parse(string(r)); !ok { |
| + // A typo'd constant is a programming error, and the safe |
| + // direction is to drop it: the door ends up MORE closed, not |
| + // silently open to a name nothing can hold anyway. |
| + continue |
| + } |
| + s.roles[r] = true |
| + } |
| + if len(s.roles) == 0 { |
| + return Set{} |
| + } |
| + s.roles[Admin] = true |
| + return s |
| +} |
| + |
| +// Permits reports whether a principal holding r may pass this door. |
| +func (s Set) Permits(r Role) bool { |
| + if s.roles == nil { |
| + return false |
| + } |
| + return s.roles[r] |
| +} |
| + |
| +// Empty reports whether this Set permits nothing at all — what the |
| +// registration-coverage test asserts no door has. |
| +func (s Set) Empty() bool { return len(s.roles) == 0 } |
| + |
| +// Roles lists the Set's members, sorted, for test assertions and for the |
| +// door list the services API hands back so an operator can see what they |
| +// just granted. |
| +func (s Set) Roles() []Role { |
| + out := make([]Role, 0, len(s.roles)) |
| + for r := range s.roles { |
| + out = append(out, r) |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) |
| + return out |
| +} |