| 1 | // Package svcrole is the closed role vocabulary for service principals: what |
| 2 | // a credential held by a SERVER may ask the console API for. |
| 3 | // |
| 4 | // WHY NOT internal/caps. caps is the app-manifest vocabulary — systemd |
| 5 | // directives, privileged ports, database paths, the host-role grant an |
| 6 | // operator types on a box. It says what a UNIT may do ON A MACHINE. This says |
| 7 | // what a CALLER may do AT A DOOR. Fusing them would couple a manifest format |
| 8 | // to a permission model and give each a reason to change that has nothing to |
| 9 | // do with the other, so they share a spirit and no types. |
| 10 | // |
| 11 | // This package deliberately knows NOTHING about routes. The door-to-role |
| 12 | // binding lives at registration (internal/console's cliRoute), so there is |
| 13 | // exactly one place a door's authority is written down, and no second table |
| 14 | // here to drift from it. |
| 15 | // |
| 16 | // Spec: docs/superpowers/specs/2026-08-18-service-principals-design.md §3. |
| 17 | package svcrole |
| 18 | |
| 19 | import ( |
| 20 | "sort" |
| 21 | "strconv" |
| 22 | ) |
| 23 | |
| 24 | // Role is one name from the closed catalogue. A person's cli_tokens row |
| 25 | // carries the empty string, which is deliberately NOT a Role: authority for a |
| 26 | // person is their membership set, evaluated per request, and no role name |
| 27 | // must ever resolve to it. |
| 28 | type Role string |
| 29 | |
| 30 | const ( |
| 31 | // Publish is the deploy loop: claim an app, ship, promote, roll back, |
| 32 | // manage its hostnames, retire it. |
| 33 | Publish Role = "publish" |
| 34 | // Deploy is a deploy pipeline's credential: ship, and promote or roll |
| 35 | // back only the rungs the credential was minted with. It is always bound |
| 36 | // to one app, and it never reaches app lifecycle, retention or domains. |
| 37 | // The rungs live on the credential, not here: this package knows roles, |
| 38 | // not channels (spec 2026-10-05-deploy-credential-design.md §5). |
| 39 | Deploy Role = "deploy" |
| 40 | // Instance is the LEGACY app-token authority, defined by observation of |
| 41 | // today's call sites rather than by its name — it includes domains and |
| 42 | // logs. See the console's role table and spec §5. |
| 43 | Instance Role = "instance" |
| 44 | // Operate is runtime work with no release authority: restart, config, |
| 45 | // logs. |
| 46 | Operate Role = "operate" |
| 47 | // Service is an app calling a platform SERVICE at runtime — work the |
| 48 | // platform performs for the app, on the app's own data, that costs the |
| 49 | // deployment resources. Scanning an object is the first; PDF rendering is |
| 50 | // the next. |
| 51 | // |
| 52 | // It is deliberately NOT Instance. That role is defined by observation of |
| 53 | // its call sites rather than by its name, and a token minted to read logs |
| 54 | // must not silently gain the ability to spend the deployment's CPU. |
| 55 | // TestInstanceRoleMatchesTodaysAppTokenReach pins Instance to exactly the |
| 56 | // doors it reaches today, and this role exists so that pin can hold. |
| 57 | Service Role = "service" |
| 58 | // Run is a credential that starts `carlos run` runs: one-off commands on |
| 59 | // the platform's own hosts. It is deliberately outside the union Admin |
| 60 | // stands for. Allow refuses it (it panics), because every Allow set |
| 61 | // carries Admin; a door admits it only by being registered through |
| 62 | // Exactly (spec 2026-10-24-service-runs-design.md § 1). |
| 63 | Run Role = "run" |
| 64 | // Admin is the union of every role except Run, plus ledgers, store and |
| 65 | // place. It is NOT "everything harmless": store grant mints a |
| 66 | // path-scoped IAM user, which is a real handover. Run is reachable only |
| 67 | // through Exactly, so an Admin principal cannot start a run by being |
| 68 | // Admin. |
| 69 | Admin Role = "admin" |
| 70 | ) |
| 71 | |
| 72 | // catalogue is the whole vocabulary, in the order All returns it. |
| 73 | var catalogue = []Role{Publish, Deploy, Instance, Operate, Service, Run, Admin} |
| 74 | |
| 75 | // All returns the catalogue. The slice is copied so a caller cannot edit the |
| 76 | // vocabulary by writing through it. |
| 77 | func All() []Role { |
| 78 | out := make([]Role, len(catalogue)) |
| 79 | copy(out, catalogue) |
| 80 | return out |
| 81 | } |
| 82 | |
| 83 | // Parse resolves a stored role name. ok is false for anything outside the |
| 84 | // catalogue, INCLUDING the empty string a person's row carries — a caller |
| 85 | // that treats "unparseable" as "no authority" is then correct by default. |
| 86 | func Parse(s string) (Role, bool) { |
| 87 | for _, r := range catalogue { |
| 88 | if string(r) == s { |
| 89 | return r, true |
| 90 | } |
| 91 | } |
| 92 | return "", false |
| 93 | } |
| 94 | |
| 95 | // Set is the roles that reach one door. The zero value permits nothing, |
| 96 | // which is the whole point: a door whose roles were never named refuses |
| 97 | // every service principal rather than accidentally admitting one. |
| 98 | type Set struct { |
| 99 | roles map[Role]bool |
| 100 | } |
| 101 | |
| 102 | // Allow builds the Set for a door. Admin is never listed by a caller that |
| 103 | // means "publish and above": it is added here, once, from the definition |
| 104 | // that Admin is the union — so ~50 registrations cannot disagree with the |
| 105 | // catalogue about what Admin is. A door reachable ONLY by an administrator |
| 106 | // says Allow(Admin) explicitly and gets exactly that. |
| 107 | // |
| 108 | // Allow called with no arguments returns an empty, closed Set — a legitimate |
| 109 | // form distinct from a typo. Calling Allow with an unparseable role panics. |
| 110 | // A dropped role would be a door silently narrowed: Allow(Publish, Role("oprate")) |
| 111 | // would return a plausible {admin, publish} with the typo gone and nothing to |
| 112 | // catch it. Registration runs once at server start, in NewServer and CI, so a |
| 113 | // panic is the cheapest place for a typo to surface. Reaching this at all requires |
| 114 | // an explicit Role("...") conversion — a mistyped CONSTANT is a compile error. |
| 115 | func Allow(roles ...Role) Set { |
| 116 | if len(roles) == 0 { |
| 117 | return Set{} |
| 118 | } |
| 119 | s := Set{roles: make(map[Role]bool, len(roles)+1)} |
| 120 | for _, r := range roles { |
| 121 | if _, ok := Parse(string(r)); !ok { |
| 122 | // Panic, not skip. A dropped role is a door silently narrowed: |
| 123 | // Allow(Publish, Role("oprate")) would return a perfectly |
| 124 | // valid-looking {admin, publish} with the typo gone and nothing |
| 125 | // to catch it. Registration runs once, in NewServer, so this |
| 126 | // fires at server start and in CI rather than as a 403 someone |
| 127 | // debugs in production weeks later. Reaching it at all takes an |
| 128 | // explicit Role("...") conversion — a mistyped CONSTANT is a |
| 129 | // compile error, which is why this costs nothing in practice. |
| 130 | panic("svcrole: Allow called with an unknown role " + strconv.Quote(string(r))) |
| 131 | } |
| 132 | if r == Run { |
| 133 | // Every Allow set carries Admin, so Allow(Run) would let an |
| 134 | // administrator through a run door: the one thing Run exists |
| 135 | // to prevent. Fail loud at registration, like a typo. |
| 136 | panic("svcrole: Allow called with the run role, which Admin must not reach; use Exactly(Run)") |
| 137 | } |
| 138 | s.roles[r] = true |
| 139 | } |
| 140 | s.roles[Admin] = true |
| 141 | return s |
| 142 | } |
| 143 | |
| 144 | // Exactly builds the Set for a door that admits ONLY the roles listed. Unlike |
| 145 | // Allow it does not add Admin: this is how a role outside Admin's union (Run) |
| 146 | // is granted to a door, and how a door says "no administrator either". |
| 147 | // |
| 148 | // Like Allow, it panics on an unparseable role, for the same reason: a |
| 149 | // dropped role would be a door silently narrowed. Called with no arguments it |
| 150 | // returns the empty, closed Set. |
| 151 | func Exactly(roles ...Role) Set { |
| 152 | if len(roles) == 0 { |
| 153 | return Set{} |
| 154 | } |
| 155 | s := Set{roles: make(map[Role]bool, len(roles))} |
| 156 | for _, r := range roles { |
| 157 | if _, ok := Parse(string(r)); !ok { |
| 158 | panic("svcrole: Exactly called with an unknown role " + strconv.Quote(string(r))) |
| 159 | } |
| 160 | s.roles[r] = true |
| 161 | } |
| 162 | return s |
| 163 | } |
| 164 | |
| 165 | // Permits reports whether a principal holding r may pass this door. |
| 166 | func (s Set) Permits(r Role) bool { |
| 167 | if s.roles == nil { |
| 168 | return false |
| 169 | } |
| 170 | return s.roles[r] |
| 171 | } |
| 172 | |
| 173 | // Empty reports whether this Set permits nothing at all — what the |
| 174 | // registration-coverage test asserts no door has. |
| 175 | func (s Set) Empty() bool { return len(s.roles) == 0 } |
| 176 | |
| 177 | // Roles lists the Set's members, sorted, for test assertions and for the |
| 178 | // door list the services API hands back so an operator can see what they |
| 179 | // just granted. |
| 180 | func (s Set) Roles() []Role { |
| 181 | out := make([]Role, 0, len(s.roles)) |
| 182 | for r := range s.roles { |
| 183 | out = append(out, r) |
| 184 | } |
| 185 | sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) |
| 186 | return out |
| 187 | } |
| 188 | |