carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
17package svcrole
18
19import (
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.
28type Role string
29
30const (
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.
73var 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.
77func 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.
86func 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.
98type 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.
115func 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.
151func 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.
166func (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.
175func (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.
180func (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