// 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" "strconv" ) // 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" // Deploy is a deploy pipeline's credential: ship, and promote or roll // back only the rungs the credential was minted with. It is always bound // to one app, and it never reaches app lifecycle, retention or domains. // The rungs live on the credential, not here: this package knows roles, // not channels (spec 2026-10-05-deploy-credential-design.md §5). Deploy Role = "deploy" // 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" // Service is an app calling a platform SERVICE at runtime — work the // platform performs for the app, on the app's own data, that costs the // deployment resources. Scanning an object is the first; PDF rendering is // the next. // // It is deliberately NOT Instance. That role is defined by observation of // its call sites rather than by its name, and a token minted to read logs // must not silently gain the ability to spend the deployment's CPU. // TestInstanceRoleMatchesTodaysAppTokenReach pins Instance to exactly the // doors it reaches today, and this role exists so that pin can hold. Service Role = "service" // Run is a credential that starts `carlos run` runs: one-off commands on // the platform's own hosts. It is deliberately outside the union Admin // stands for. Allow refuses it (it panics), because every Allow set // carries Admin; a door admits it only by being registered through // Exactly (spec 2026-10-24-service-runs-design.md § 1). Run Role = "run" // Admin is the union of every role except Run, plus ledgers, store and // place. It is NOT "everything harmless": store grant mints a // path-scoped IAM user, which is a real handover. Run is reachable only // through Exactly, so an Admin principal cannot start a run by being // Admin. Admin Role = "admin" ) // catalogue is the whole vocabulary, in the order All returns it. var catalogue = []Role{Publish, Deploy, Instance, Operate, Service, Run, 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. // // Allow called with no arguments returns an empty, closed Set — a legitimate // form distinct from a typo. Calling Allow with an unparseable role panics. // A dropped role would be a door silently narrowed: Allow(Publish, Role("oprate")) // would return a plausible {admin, publish} with the typo gone and nothing to // catch it. Registration runs once at server start, in NewServer and CI, so a // panic is the cheapest place for a typo to surface. Reaching this at all requires // an explicit Role("...") conversion — a mistyped CONSTANT is a compile error. 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 { // Panic, not skip. A dropped role is a door silently narrowed: // Allow(Publish, Role("oprate")) would return a perfectly // valid-looking {admin, publish} with the typo gone and nothing // to catch it. Registration runs once, in NewServer, so this // fires at server start and in CI rather than as a 403 someone // debugs in production weeks later. Reaching it at all takes an // explicit Role("...") conversion — a mistyped CONSTANT is a // compile error, which is why this costs nothing in practice. panic("svcrole: Allow called with an unknown role " + strconv.Quote(string(r))) } if r == Run { // Every Allow set carries Admin, so Allow(Run) would let an // administrator through a run door: the one thing Run exists // to prevent. Fail loud at registration, like a typo. panic("svcrole: Allow called with the run role, which Admin must not reach; use Exactly(Run)") } s.roles[r] = true } s.roles[Admin] = true return s } // Exactly builds the Set for a door that admits ONLY the roles listed. Unlike // Allow it does not add Admin: this is how a role outside Admin's union (Run) // is granted to a door, and how a door says "no administrator either". // // Like Allow, it panics on an unparseable role, for the same reason: a // dropped role would be a door silently narrowed. Called with no arguments it // returns the empty, closed Set. func Exactly(roles ...Role) Set { if len(roles) == 0 { return Set{} } s := Set{roles: make(map[Role]bool, len(roles))} for _, r := range roles { if _, ok := Parse(string(r)); !ok { panic("svcrole: Exactly called with an unknown role " + strconv.Quote(string(r))) } s.roles[r] = 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 }