carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The structural role gate (spec §3b). Every /api/cli/ door names the roles
4// that may reach it AT REGISTRATION, and a service principal that lacks the
5// role is refused before the handler runs.
6//
7// The order is: bearer -> principal (401) -> role (403) -> scope (404) ->
8// budget (429) -> handler. Role BEFORE scope is deliberate. A role refusal
9// reads neither the account segment nor the database, so it is a constant
10// function of the door and the caller's own role — identical whether the
11// named account exists, belongs to someone else, or belongs to the caller.
12// There is nothing in it to leak. Checking scope first would instead make the
13// pair of answers a membership oracle: 404 for an account you do not hold,
14// 403 for one you do.
15
16import (
17 "context"
18 "go/ast"
19 "go/parser"
20 "go/token"
21 "net/http"
22 "os"
23 "path/filepath"
24 "reflect"
25 "sort"
26 "strconv"
27 "strings"
28 "testing"
29 "time"
30
31 "github.com/carlosframework/platform/internal/svcrole"
32)
33
34// mintDoors, fleetDoors and policyDoors are the three families no role may
35// ever reach. Listed as substrings of the registered pattern, because that is
36// the shape the table is keyed on. /api/cli/services/ was a standing guard
37// before those doors existed; it is a live assertion now that they do, and it
38// covers all four of them (create, list, rotate, revoke) with one entry.
39//
40// These lists are the INTENT-level pin, and they exist because closedDoors
41// below is only bookkeeping. closedDoors says "these twelve are registered
42// with the zero Set"; a commit that reopened a door and edited that list in
43// the same breath would stay green. These say WHICH DOORS MAY NEVER OPEN,
44// whatever the registration currently says, so reopening one takes an edit
45// that has to argue with the words next to it.
46//
47// policyDoors is instances-manifest: enabling writes the manifest that bounds
48// which domain, pool, regions and channel an app may ever be brought up on.
49// A credential the app itself holds must not rewrite the bounds it runs
50// inside, which is a fact about the door's subject and not about any role.
51// /{app}/updates is the same manifest's update policy (settings spec §3.2),
52// including which settings an instance may change itself: the bound on the
53// instance credential, which that credential must never widen.
54//
55// The email family adds one entry to each list, and both are facts about the
56// door's subject rather than about any role:
57//
58// /email/credentials (create and revoke, one substring) is a MINT door. It
59// is the only door in the whole API that hands a plaintext sending secret
60// back to its caller — store's equivalent seals its secret into config and
61// the console keeps no key to open it. That reply is a standing authority to
62// send as the account's verified domain, it outlives the request, and no
63// on-platform app needs it (enable delivers credentials as env instead). So
64// it belongs beside instance-token and the services family: the caller it
65// exists for is a person.
66//
67// /email/pause is a POLICY door. It is the abuse brake, and the same request
68// raises the tenant's daily cap — the bound the tenant sends inside. A
69// credential must no more raise its own sending cap than rewrite the
70// manifest it runs under.
71//
72// systemDoors is the `carlos system` family (apisystem.go), and it is the
73// widest subject of the four: the PLATFORM's own binary on every box in the
74// deployment. A credential reaching one of these could point the whole fleet
75// at a binary of its choosing, which is not a wider version of any workload's
76// authority — it is the end of the boundary the roles are drawn inside. One
77// substring covers all eight doors, and no role may ever reach any of them.
78var (
79 mintDoors = []string{"/instance-token", "/door-keys", "/api/cli/services/", "/email/credentials"}
80 fleetDoors = []string{"/api/cli/fleets/"}
81 policyDoors = []string{"/instances-manifest", "/{app}/updates", "/{app}/routes", "/email/pause", "/build-probe", "/{app}/swap"}
82 systemDoors = []string{"/api/cli/system/"}
83)
84
85// closedDoors is the exact set of /api/cli/ patterns registered with the zero
86// Set — the Set that permits nothing, including admin.
87//
88// The list is spelled out rather than derived, and asserted as an EQUALITY,
89// because the two ways of getting this wrong pull in opposite directions. A
90// door that forgot to name its roles must fail (it would be an accidental
91// zero Set, unreachable by every credential, and silently so). A door that
92// must be shut but is not must fail too (it would be an escalation path).
93// Only an equality catches both; "no empty Sets" catches neither, and "these
94// are empty" catches only the first.
95var closedDoors = []string{
96 // Deployment-wide scanning policy. There is no {account} to scope a
97 // service credential to, and enabling it starts reading tenant bytes
98 // across every store on the deployment — an owner's decision, not a
99 // credential's.
100 "GET /api/cli/system/antivirus",
101 "POST /api/cli/system/antivirus",
102 "POST /api/cli/system/antivirus/rotate-webhook-key",
103 // Platform GeoIP (geoip spec §4.2): deployment-wide, operator-only, a
104 // person's decision. Publishing replaces what every edge serves.
105 "PUT /api/cli/system/geoip",
106 "GET /api/cli/system/geoip",
107 "DELETE /api/cli/system/geoip",
108 // The seeds floor (seeded-instances spec §3.3): deployment-wide and
109 // operator-only. Publishing arms a floor every box enforces.
110 "POST /api/cli/system/seeds-floor",
111 // Legacy route assignments (seeded-instances spec §6.4): operator-only.
112 // An assignment lets an import take over routes filed under the
113 // operator account.
114 "POST /api/cli/system/legacy-routes",
115 // A credential may never mint a credential, including itself. This is
116 // the escalation cut of spec §3.
117 "POST /api/cli/apps/{account}/{app}/instance-token",
118 // Door keys (#175 P7) are credentials too: minting one is the same cut,
119 // and listing and revoking them are closed with it, as the services
120 // family's are.
121 "GET /api/cli/apps/{account}/{app}/door-keys",
122 "POST /api/cli/apps/{account}/{app}/door-keys",
123 "POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke",
124 // Enabling instances writes the MANIFEST — standing policy about which
125 // domain, pool, regions and channel the app may ever be brought up on.
126 // A credential the app itself holds must not rewrite the bounds it runs
127 // inside, and none ever has: handleAPIInstanceEnable gates on
128 // apiAccountApp, which could not see a non-person at all. Closing it at
129 // the registration says so where the role table can be read.
130 "GET /api/cli/apps/{account}/{app}/routes",
131 "PUT /api/cli/apps/{account}/{app}/instances-manifest",
132 // The app's instance update policy (settings spec §3.2) is written onto
133 // that same manifest: which settings an instance may change itself, and
134 // the defaults new and existing instances follow. Shut for the manifest
135 // door's reason, and the instance-bound credential (#155 step 6) does not
136 // open it either: an instance must not widen its own control.
137 "PUT /api/cli/apps/{account}/{app}/updates",
138 // The fleet-wide live tenancy read (discussion #11). Shut to every
139 // credential for a reason that is about the door's SUBJECT rather than
140 // any role: the answer names every account's instances on every box.
141 // There is no app-scoped reading of it — narrowing it to the caller's
142 // own account would be a different door answering a different question —
143 // so no service role can be the right holder, whatever roles exist
144 // later. The operator check itself is in the handler, because cliRoute's
145 // Set only narrows service principals and a person always passes it.
146 "GET /api/cli/status/instances",
147 // carlos status --layout (plan 4, task 5): the same fleet-wide,
148 // subject-shaped cut as the door above — which layout every instance on
149 // every box is on is an operator's view, not any one app's, so no
150 // service role is the right holder here either.
151 "GET /api/cli/status/layout",
152 // Enrolling a BOX into a fleet, and rotating or detaching one, is a
153 // human act with a machine's credentials as its output. Same cut.
154 "POST /api/cli/fleets/{account}/new",
155 "GET /api/cli/fleets/{account}",
156 "POST /api/cli/fleets/{account}/{fleet}/boxes",
157 "POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate",
158 "POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach",
159 "POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags",
160 // Minting, rotating and revoking a SERVICE credential is the same cut
161 // seen from the other side: these are the doors that hand out authority,
162 // so a credential must never reach them — not even the admin one, and
163 // not even to rotate itself. Owner-gated inside, for a person.
164 "POST /api/cli/services/{account}",
165 "GET /api/cli/services/{account}",
166 "POST /api/cli/services/{account}/{name}/rotate",
167 "POST /api/cli/services/{account}/{name}/revoke",
168 // Minting a STANDALONE SMTP credential is the same cut again, and the
169 // sharpest case of it: this door's 200 body carries the password in
170 // plaintext, because a standalone credential is displayed once and
171 // nothing else ever will (spec §6b). A service credential that could
172 // call it would convert "may deploy this app" into "may send as this
173 // account's domain" — with no delivery step, no config seal, and no
174 // expiry. Revoke is closed with it for the services family's symmetry:
175 // nothing a service could have minted here exists for it to clean up.
176 "POST /api/cli/apps/{account}/{app}/email/credentials",
177 "DELETE /api/cli/apps/{account}/{app}/email/credentials/{name}",
178 // The email abuse brake. It already resolves an ADDRESS against
179 // CARLOS_MAIL_OPERATORS, which no service principal can hold, so any
180 // role named here would be a registration that lies about who gets in.
181 // It also raises the daily cap in the same request, which is an
182 // authority grant rather than a runtime toggle.
183 "POST /api/cli/apps/{account}/{app}/email/pause",
184 // Applying for and deciding an account-level storage entitlement are
185 // person-only acts. An admin service credential belongs to an app and may
186 // neither speak for the account owner nor grant its account permission to
187 // mint deployment-owned cloud estate. The queue is closed with its writes
188 // so machine credentials cannot enumerate applicants and their notes.
189 "POST /api/cli/accounts/{account}/store/apply",
190 "POST /api/cli/accounts/{account}/store/approve",
191 "POST /api/cli/accounts/{account}/store/decline",
192 "GET /api/cli/store/approvals",
193 // An account's plan, packs and sponsored limits are a fleet operator's
194 // decision (plans-caps-packs spec §4, §8.7); no machine credential reads
195 // or sets them.
196 "GET /api/cli/accounts/{account}/plan",
197 "POST /api/cli/accounts/{account}/plan",
198 // Moving a route between channels decides what that route SERVES, which
199 // is release authority wearing a config-shaped hat: a credential the app
200 // itself holds must not be able to walk its own route onto a channel
201 // whose rules it never met. Shut for the same reason the manifest door
202 // is — an app may not rewrite the bounds it runs inside.
203 "POST /api/cli/apps/{account}/{app}/instances/{host}/channel",
204 // Instance update settings (settings spec §3.3): a member's preference
205 // about which authorized build an instance runs. svcrole.Instance is
206 // app-wide, so it would let one instance's code pin a sibling; the
207 // instance-bound credential that may reach these is #155 step 6.
208 "GET /api/cli/apps/{account}/{app}/instances/{host}/updates",
209 "PUT /api/cli/apps/{account}/{app}/instances/{host}/updates",
210 // "Update now" (settings plan 3 Task 7) asks root to move this instance:
211 // the same app-wide reason, and fast_track is an account owner's alone.
212 "POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install",
213 // The `carlos db` replica door (apidb.go) serves the whole customer
214 // database, file by file. No credential the app holds needs it: the app
215 // reads its own database live, and CI has no business pulling
216 // production data. Shut to every role, admin included (spec 2026-09-02
217 // §6, "Who may open it").
218 "GET /api/cli/apps/{account}/{app}/db/{host}/ltx/{level}",
219 "GET /api/cli/apps/{account}/{app}/db/{host}/ltx/{level}/{span}",
220 // The forwarded `carlos db` read (db region forward spec §9.1) runs the
221 // same door's gate for an instance in another region and relays its
222 // rows: the same cut.
223 "POST /api/cli/apps/{account}/{app}/db/{host}/reads",
224 // The six `carlos run` doors are not here: since the service runs spec
225 // (2026-10-24 §1, amending run spec §2 ruling 1) they take the run role
226 // and no other, Admin included, through svcrole.Exactly. runDoors below
227 // pins that.
228 // The build probe is what `carlos deploy`'s watch TRUSTS in place of the
229 // X-Carlos-Version header — the header being the thing that reports a
230 // release before any process has restarted. A credential that could
231 // repoint the probe could therefore make its own deploys report success
232 // over a process that never cycled, which is the precise failure the
233 // probe exists to prevent. Setting it is a person's one-time act.
234 "POST /api/cli/apps/{account}/{app}/build-probe",
235 // The swap policy (zero-downtime spec §4.1, §11): whether a release runs
236 // beside the previous build on the instance's database. "Changed only by
237 // app members": a credential the app holds must not choose what its own
238 // migrations run beside.
239 "PUT /api/cli/apps/{account}/{app}/swap",
240 // The sidecar manifest, shut for the instances manifest's reason and one
241 // of its own. It is standing policy — which domain, pool, regions and
242 // channel a sidecar may ever come up on — and it also carries `args`,
243 // the argv the platform execs after the release binary. A credential the
244 // app itself holds must not be able to rewrite what its own boxes will
245 // exec on its behalf. The five sidecar doors beside it stay open to the
246 // instance role, which is the point of the family: an app process mints
247 // a bot per member, inside bounds a person set.
248 "PUT /api/cli/apps/{account}/{app}/sidecars-manifest",
249 // `carlos system` (apisystem.go): what the whole deployment RUNS. The
250 // same cut as the mint and fleet doors, taken over the substrate every
251 // tenant sits inside rather than over one account's credentials, so it
252 // is shut to every role including admin. The owner check is in the
253 // handlers, because a person passes cliRoute's Set regardless.
254 "GET /api/cli/system/status",
255 "POST /api/cli/system/update",
256 "GET /api/cli/system/jobs/{id}",
257 "PUT /api/cli/system/upload",
258 "POST /api/cli/system/rollback",
259 // Cancelling an unpublished executable activation (claim plan 9, I-4)
260 // frees a channel for the next promote or rollback: the same subject,
261 // the same cut.
262 "POST /api/cli/system/activation/cancel",
263 // The layout door (plan 5): the fleet's instance layout is the
264 // substrate every tenant sits inside, the same cut as the five above.
265 "GET /api/cli/system/layout",
266 "POST /api/cli/system/layout",
267 // Pointer digests (#253, spec §4.6): every account's emission marker,
268 // and the report of channels still without digests. Deployment-wide and
269 // root-signed, the same cut as the update doors.
270 "GET /api/cli/system/pointer-digests",
271 "POST /api/cli/system/pointer-digests",
272 // `carlos channels resign`: the console signs digests a person has
273 // confirmed. A credential never confirms on a person's behalf.
274 "POST /api/cli/apps/{account}/{app}/channels/resign",
275 // `carlos releases authorize`: the console signs a promotion record for
276 // the digest a person confirmed. The GET is closed with it, as the
277 // pointer-digests read is: a credential has no plan to confirm.
278 "GET /api/cli/apps/{account}/{app}/releases/{version}/authorize",
279 "POST /api/cli/apps/{account}/{app}/releases/{version}/authorize",
280}
281
282// runDoors are the six `carlos run` doors (service runs spec §1, and § 3's
283// key lookup). They are
284// the one family registered with svcrole.Exactly: the run role reaches them
285// and NO other role does, Admin included, so a credential that may provision
286// instances cannot run commands on them unless it was minted for that.
287var runDoors = []string{
288 "POST /api/cli/apps/{account}/{app}/instances/{host}/runs",
289 "GET /api/cli/apps/{account}/{app}/instances/{host}/runs",
290 "GET /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}",
291 "POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/cancel",
292 "POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/expire",
293 "POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/ack",
294}
295
296// TestRunDoorsTakeOnlyTheRunRole is the route-role table's row for the run
297// doors, and the only doors the run role reaches.
298func TestRunDoorsTakeOnlyTheRunRole(t *testing.T) {
299 s := newConsoleFixture(t).srv
300 isRunDoor := map[string]bool{}
301 for _, pattern := range runDoors {
302 isRunDoor[pattern] = true
303 set, ok := s.cliRoles[pattern]
304 if !ok {
305 t.Errorf("%s is not registered", pattern)
306 continue
307 }
308 if got := set.Roles(); len(got) != 1 || got[0] != svcrole.Run {
309 t.Errorf("%s takes roles %v, want exactly [run]", pattern, got)
310 }
311 for _, r := range svcrole.All() {
312 if r != svcrole.Run && set.Permits(r) {
313 t.Errorf("%s is reachable by role %q; only run may reach a run door", pattern, r)
314 }
315 }
316 if set.Permits(svcrole.Admin) {
317 t.Errorf("%s admits Admin", pattern)
318 }
319 }
320 for pattern, set := range s.cliRoles {
321 if set.Permits(svcrole.Run) && !isRunDoor[pattern] {
322 t.Errorf("%s is reachable by the run role, which reaches the run doors only", pattern)
323 }
324 }
325}
326
327// TestEveryCLIDoorNamesItsRoles is the default-deny guarantee for the
328// /api/cli/ half. A door registered with plain mux.HandleFunc would not
329// appear in cliRoles at all; a door registered with an accidental empty Set
330// is unreachable by every service principal. Both are failures worth catching
331// at build time rather than in production.
332func TestEveryCLIDoorNamesItsRoles(t *testing.T) {
333 // Doors registered only behind a switch. Each is in the source (the
334 // default-deny scan sees it) and may be absent from cliRoles only while
335 // its switch is off. The host claim's are closed doors (the zero Set);
336 // CARLOS_MOVES_PHASE1's resolve keeps the Instance role so a service
337 // token meets the family's 404 rather than the role gate's 403.
338 hostClaimDoors := []string{
339 "POST /api/cli/ops/hosts/{host}/resolve/inspect",
340 "POST /api/cli/ops/hosts/{host}/resolve",
341 }
342 // Box and group roles (region HA spec 2 §7): closed doors (operator
343 // only), registered only with host claims and CARLOS_REGION_HA both on.
344 regionHADoors := []string{
345 "POST /api/cli/system/boxes/{box}/role",
346 "POST /api/cli/system/groups/{region}/role",
347 }
348 movesDoors := []string{
349 "POST /api/cli/apps/{account}/{app}/instances/{host}/move/resolve",
350 }
351 registered := scanCLISource(t).gated
352 for _, tc := range []struct {
353 name string
354 hostClaim bool
355 moves bool
356 regionHA bool
357 }{
358 {"flag-off", false, false, false},
359 {"flag-on", true, false, false},
360 {"moves-on", false, true, false},
361 {"flag-on-moves-on", true, true, false},
362 {"region-ha-on", false, false, true},
363 {"flag-on-region-ha-on", true, false, true},
364 } {
365 t.Run(tc.name, func(t *testing.T) {
366 st := openTestStore(t)
367 if tc.hostClaim {
368 var err error
369 st, err = OpenGated(filepath.Join(t.TempDir(), "gated.db"))
370 if err != nil {
371 t.Fatal(err)
372 }
373 t.Cleanup(func() { st.Close() })
374 }
375 s, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", HostClaim: tc.hostClaim, MovesEnabled: tc.moves, RegionHA: tc.regionHA}, st)
376 if err != nil {
377 t.Fatal(err)
378 }
379 var empty []string
380 for pattern, set := range s.cliRoles {
381 if set.Empty() {
382 empty = append(empty, pattern)
383 }
384 }
385 wantClosed := append([]string(nil), closedDoors...)
386 roles := tc.hostClaim && tc.regionHA
387 if tc.hostClaim {
388 wantClosed = append(wantClosed, hostClaimDoors...)
389 }
390 if roles {
391 wantClosed = append(wantClosed, regionHADoors...)
392 }
393 sort.Strings(empty)
394 sort.Strings(wantClosed)
395 if !reflect.DeepEqual(empty, wantClosed) {
396 t.Errorf("doors registered with an empty role set: got %q, want %q", empty, wantClosed)
397 }
398
399 // Only these named routes may be absent with their switch off.
400 // All source registrations remain subject to the default-deny
401 // scan.
402 wantRegistered := make(map[string]bool)
403 for _, pattern := range registered {
404 wantRegistered[pattern] = true
405 }
406 for _, pattern := range hostClaimDoors {
407 if !wantRegistered[pattern] {
408 t.Errorf("host claim route missing from source: %s", pattern)
409 }
410 if !tc.hostClaim {
411 delete(wantRegistered, pattern)
412 }
413 set, present := s.cliRoles[pattern]
414 if present != tc.hostClaim || (present && !set.Empty()) {
415 t.Errorf("%s: present=%v empty=%v, want present=%v and no service roles", pattern, present, set.Empty(), tc.hostClaim)
416 }
417 }
418 for _, pattern := range regionHADoors {
419 if !wantRegistered[pattern] {
420 t.Errorf("region HA route missing from source: %s", pattern)
421 }
422 if !roles {
423 delete(wantRegistered, pattern)
424 }
425 set, present := s.cliRoles[pattern]
426 if present != roles || (present && !set.Empty()) {
427 t.Errorf("%s: present=%v empty=%v, want present=%v and no service roles", pattern, present, set.Empty(), roles)
428 }
429 }
430 for _, pattern := range movesDoors {
431 if !wantRegistered[pattern] {
432 t.Errorf("moves route missing from source: %s", pattern)
433 }
434 if !tc.moves {
435 delete(wantRegistered, pattern)
436 }
437 set, present := s.cliRoles[pattern]
438 if present != tc.moves || (present && set.Empty()) {
439 t.Errorf("%s: present=%v empty=%v, want present=%v and a role set", pattern, present, set.Empty(), tc.moves)
440 }
441 }
442 for pattern := range wantRegistered {
443 if _, ok := s.cliRoles[pattern]; !ok {
444 t.Errorf("%s: the source registers it, the server did not record it", pattern)
445 }
446 }
447 if len(s.cliRoles) != len(wantRegistered) {
448 t.Errorf("cliRoles holds %d doors, want %d for this flag state", len(s.cliRoles), len(wantRegistered))
449 }
450 })
451 }
452}
453
454// TestNoRoleReachesAMintFleetOrPolicyDoor is the escalation cut: a credential
455// must never be able to mint or rotate a credential — including itself —
456// must never enrol a box, must never rewrite the policy that bounds what its
457// own app may be brought up as, and must never move the platform binary the
458// whole deployment runs (systemDoors).
459func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) {
460 s := newConsoleFixture(t).srv
461 var families []string
462 families = append(families, mintDoors...)
463 families = append(families, fleetDoors...)
464 families = append(families, policyDoors...)
465 families = append(families, systemDoors...)
466 for pattern, set := range s.cliRoles {
467 for _, closed := range families {
468 if !strings.Contains(pattern, closed) {
469 continue
470 }
471 for _, r := range svcrole.All() {
472 if set.Permits(r) {
473 t.Errorf("%s is reachable by role %q; mint, fleet and policy doors must be closed to every role", pattern, r)
474 }
475 }
476 }
477 }
478}
479
480// exemptFromLegacyPin names doors that permit `instance` DELIBERATELY as new
481// access rather than as legacy reach, and so must not be pinned by
482// TestInstanceRoleMatchesTodaysAppTokenReach.
483//
484// GET /api/cli/apps is the only one. It is scope-scoped rather than
485// role-gated (spec §3a-i): its answer is the caller's OWN binding, nothing
486// else, so every role may ask it. Listing it in `want` below would make that
487// map lie about what LEGACY reach is — an app token could not reach this
488// door before service principals existed, so it is new access granted
489// deliberately, not compatibility being preserved.
490//
491// GET .../domains/{host} is the second, and for the same reason. It is the
492// per-host read (#299 §2e) and it did not exist before service principals,
493// so no app token has ever reached it and listing it in `want` would make
494// that map lie about what LEGACY reach is. The instance role reaches it
495// DELIBERATELY: an app's router refreshes one hostname's managed state with
496// its CARLOS_INSTANCE_TOKEN, which is the entire reason the door was added.
497// It discloses strictly less than the list door beside it — one hostname the
498// caller already owns — so it cannot need a role that list does not have.
499//
500// GET .../features is the third (#347), on the same footing: it did not
501// exist before service principals, and the instance role reaches it
502// DELIBERATELY because the app IS the consumer — its router reads its own
503// switches with its CARLOS_INSTANCE_TOKEN. The same map already rides on
504// the domains list answer that role has always reached, so this discloses
505// nothing the list does not. The WRITE beside it is admin-only, and
506// TestAPIFeaturesRoles pins that an instance credential never reaches it.
507// The three move doors (#331) are the fourth, fifth and sixth, together:
508// they did not exist before service principals, so listing them in `want`
509// would make the legacy map lie. The instance role reaches them
510// DELIBERATELY — the tenant's ROUTER drives a move with the app-held
511// CARLOS_INSTANCE_TOKEN (the residence design's stated caller), and a
512// credential that may already CREATE and DELETE instances (both in `want`)
513// is not gaining authority by relocating one. TestAPIInstanceMoveVerbs
514// pins what the doors themselves refuse.
515//
516// PUT .../domains/{host}/paths is the seventh, and on the same footing: path
517// routing did not exist before service principals, so listing it in `want`
518// would make the legacy map lie. The instance role reaches it DELIBERATELY,
519// to match the READ of the same state (GET .../domains/{host}, exempted
520// above) — an app's router that may read its own published prefix map with
521// its CARLOS_INSTANCE_TOKEN is the writer this door was built for, and a
522// credential that may already attach and detach that hostname is not gaining
523// authority by saying how its traffic splits. The door is owner-gated on
524// account and app and refuses a control-plane app outright, so an instance
525// token can only ever publish its OWN app's map.
526// TestAPIDomainPathsReachesPublishAndInstance pins the contract itself.
527//
528// The five sidecar doors stand on exactly that
529// footing: sidecar members did not exist before service principals, so
530// listing them in `want` would make the legacy map lie. The instance role
531// reaches them DELIBERATELY, and this is the requirement the design was
532// written for — Eleven's app process creates a bot per enrolled member
533// holding no AWS credentials and no box access, with the
534// CARLOS_INSTANCE_TOKEN it already has. A credential that may already
535// CREATE and DELETE instances (both in `want`) is not gaining authority by
536// creating a member that nothing can dial; if anything it is narrower, since
537// a sidecar has no route, no hostname and no inbound path at all. The
538// MANIFEST door is the one held back (svcrole.Set{}, see closedDoors): it
539// carries the argv the platform execs, which is not a thing an app's own
540// credential may rewrite.
541//
542// GET .../errors is the eighth (spec 2026-09-07 §3.6). It did not exist
543// before service principals, so listing it in `want` would make the legacy
544// map lie. The instance role reaches it DELIBERATELY, to match the logs door
545// pinned in `want` below: the errors window is the same records read from the
546// other end, and a credential that may read EVERY line of its own app's logs
547// gains nothing by reading the error-level ones grouped. The telemetry PUT
548// registered beside it is Operate-only and no instance credential reaches it
549// — TestAPITelemetryPutSetsLogLevel pins that refusal.
550// GET .../analytics is the ninth (spec 2026-09-07 §3.6), and it stands on
551// the errors door's footing exactly. It did not exist before service
552// principals, so listing it in `want` would make the legacy map lie. The
553// instance role reaches it DELIBERATELY, for the reason the logs door in
554// `want` is reached: the counts are the app's own traffic, aggregated and
555// stripped of addresses, and a credential that may read every line of its
556// own app's logs is not learning anything new from a tally of its own page
557// views. The telemetry PUT that decides whether those counts are collected
558// at all is Operate-only and no instance credential reaches it —
559// TestAPITelemetryPutSetsAnalytics pins that half.
560//
561// GET .../availability is the tenth (spec 2026-09-07 §11), and it stands on
562// the errors door's footing exactly: it is the other half of the same
563// records, split off 2026-09-24. Before the split an instance credential
564// read these very rows through GET .../errors, so granting it here widens
565// nothing — refusing it would be the change.
566var exemptFromLegacyPin = map[string]bool{
567 "GET /api/cli/apps": true,
568 "GET /api/cli/apps/{account}/{app}/errors": true,
569 "GET /api/cli/apps/{account}/{app}/availability": true,
570 "GET /api/cli/apps/{account}/{app}/analytics": true,
571 "GET /api/cli/apps/{account}/{app}/domains/{host}": true,
572 "PUT /api/cli/apps/{account}/{app}/domains/{host}/paths": true,
573 "GET /api/cli/apps/{account}/{app}/features": true,
574 "GET /api/cli/apps/{account}/{app}/regions": true,
575 // The region guess (nearest-region spec §9.4) stands on GET .../regions'
576 // footing: the same deployment topology, filtered by the app's own
577 // policy, for a hint the caller supplies. It did not exist before
578 // service principals, and titogo's per-instance picker is its consumer.
579 "POST /api/cli/apps/{account}/{app}/regions/guess": true,
580 "GET /api/cli/apps/{account}/{app}/instances/{host}/move": true,
581 "POST /api/cli/apps/{account}/{app}/instances/{host}/move": true,
582 "POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut": true,
583 "DELETE /api/cli/apps/{account}/{app}/instances/{host}/move": true,
584 // The offline operator move's doors: each is operator-only behind
585 // moveOperatorGate (a service token meets the family's 404), and
586 // refuses with MV-O20 on a console with host claims or Phase 1 on.
587 "GET /api/cli/apps/{account}/{app}/instances/{host}/move/offline": true,
588 "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline": true,
589 "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/stage": true,
590 "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/seal": true,
591 "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/flip": true,
592 "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/open": true,
593 "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/finish": true,
594 "POST /api/cli/apps/{account}/{app}/instances/{host}/move/offline/abort": true,
595 "GET /api/cli/apps/{account}/{app}/sidecars": true,
596 "POST /api/cli/apps/{account}/{app}/sidecars": true,
597 "GET /api/cli/apps/{account}/{app}/sidecars/{name}": true,
598 "DELETE /api/cli/apps/{account}/{app}/sidecars/{name}": true,
599 "POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake": true,
600 // The platform's GeoIP lookup (geoip spec §11.1) is new access on the
601 // analytics read's footing: it did not exist before service principals,
602 // and the app itself is the consumer (a batch job or a webhook handler
603 // placing an address with its CARLOS_INSTANCE_TOKEN). It discloses
604 // nothing about the account; only what the deployment's database says
605 // about an address the caller chose.
606 "GET /api/cli/apps/{account}/{app}/geo": true,
607 // The import door (seeded-instances spec §6.1) is new access on the
608 // create door's footing: every import call takes the instance role, as
609 // the create that begins the import does. It did not exist before
610 // service principals.
611 "PUT /api/cli/apps/{account}/{app}/instances/{host}/import": true,
612 "POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped": true,
613 "POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit": true,
614 "POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort": true,
615}
616
617// TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec
618// §5. kind='app' tokens today reach the instance verbs AND domains AND logs —
619// titogo's router re-attaches every hostname on every reload pass with its
620// CARLOS_INSTANCE_TOKEN. A tidy `instance` role meaning "the instance verbs"
621// would 403 that router on the next console deploy.
622//
623// PUT .../instances-manifest IS ABSENT ON PURPOSE, and its absence is a
624// correction rather than an oversight. The first draft of this list was built
625// from the route registrations filed under "instances" instead of from each
626// handler's actual gate, and it wrongly included the enable door.
627// handleAPIInstanceEnable has always gated on apiAccountApp, which refused a
628// non-person at cliUser with a 401 — no app token has ever reached it. So it
629// is not legacy reach, it is a door that is closed (svcrole.Set{}, see
630// closedDoors) and always was. Do not add it back without first making an app
631// token reach it on a pre-service-principals build.
632//
633// If this test fails because a door moved, that is the point: decide
634// deliberately, then update BOTH this list and the role table.
635func TestInstanceRoleMatchesTodaysAppTokenReach(t *testing.T) {
636 want := map[string]bool{
637 "GET /api/cli/apps/{account}/{app}/instances": true,
638 "POST /api/cli/apps/{account}/{app}/instances": true,
639 "GET /api/cli/apps/{account}/{app}/instances/{host}": true,
640 "PATCH /api/cli/apps/{account}/{app}/instances/{host}": true,
641 "DELETE /api/cli/apps/{account}/{app}/instances/{host}": true,
642 "GET /api/cli/apps/{account}/{app}/domains": true,
643 "POST /api/cli/apps/{account}/{app}/domains": true,
644 "DELETE /api/cli/apps/{account}/{app}/domains/{host}": true,
645 "GET /api/cli/apps/{account}/{app}/logs": true,
646 }
647 s := newConsoleFixture(t).srv
648 got := map[string]bool{}
649 for pattern, set := range s.cliRoles {
650 if set.Permits(svcrole.Instance) && !exemptFromLegacyPin[pattern] {
651 got[pattern] = true
652 }
653 }
654 for p := range want {
655 if !got[p] {
656 t.Errorf("%s: role instance does not reach it, but an app token does today", p)
657 }
658 }
659 for p := range got {
660 if !want[p] {
661 t.Errorf("%s: role instance reaches it, but an app token does not today — widening the legacy role", p)
662 }
663 }
664}
665
666// ---- reading the package's own source ---------------------------------------
667//
668// http.ServeMux cannot be enumerated: there is no way to ask a live server
669// which patterns it holds, so a test that wants to prove EVERY door is gated
670// has to read the source that registers them. That is an unusual shape for a
671// test and it earns its place — it is the only thing that catches a door
672// added tomorrow with plain mux.HandleFunc, which is the exact way
673// default-deny would be lost.
674//
675// It is one go/ast pass over every non-test file in the package, not a scan
676// of one file's lines. Both parts of that matter. Package-wide, because the
677// services API arrives in a file of its own with a registration function of
678// its own, and those are the doors that MINT credentials: a scanner that read
679// only authcli.go would report 47 gated doors out of 47 and be silent about
680// them. And AST rather than strings, because a registration split across two
681// lines escapes a substring match, and because a line scanner keying on
682// "s.mux.HandleFunc(" fails the build for any COMMENT that happens to quote
683// it — a standing tax on the wording of the most heavily commented function
684// in the file.
685
686// muxDoor is one pattern registered straight onto the mux, with the handler
687// expression it was given.
688type muxDoor struct {
689 pattern string
690 handler ast.Expr
691}
692
693// cliSource is what that single pass yields.
694type cliSource struct {
695 fset *token.FileSet
696 funcs map[string]*ast.FuncDecl // by bare name
697 gated []string // patterns registered through cliRoute
698 plain []muxDoor // patterns registered outside /api/cli/
699}
700
701// scanCLISource parses the package and classifies every route registration in
702// it. Anything it cannot classify is an error rather than a silent skip: a
703// registration this test cannot read is a registration it cannot vouch for.
704func scanCLISource(t *testing.T) *cliSource {
705 t.Helper()
706 fset := token.NewFileSet()
707 pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool {
708 return !strings.HasSuffix(fi.Name(), "_test.go")
709 }, 0)
710 if err != nil {
711 t.Fatalf("parse package: %v", err)
712 }
713 pkg, ok := pkgs["console"]
714 if !ok {
715 t.Fatal("package console not found by the parser")
716 }
717 src := &cliSource{fset: fset, funcs: map[string]*ast.FuncDecl{}}
718 for _, f := range pkg.Files {
719 for _, d := range f.Decls {
720 if fd, ok := d.(*ast.FuncDecl); ok && fd.Name != nil && fd.Body != nil {
721 src.funcs[fd.Name.Name] = fd
722 }
723 }
724 }
725 for _, f := range pkg.Files {
726 for _, d := range f.Decls {
727 fd, ok := d.(*ast.FuncDecl)
728 if !ok || fd.Body == nil {
729 continue
730 }
731 // cliRoute is the gate itself. Its own mux.HandleFunc call is
732 // the one registration in the package whose pattern is a
733 // variable rather than a literal, and it is the mechanism these
734 // tests are asserting ABOUT — reading it as a door would be
735 // reading the lock as a way in. It is the only exemption.
736 if fd.Name != nil && (fd.Name.Name == "cliRoute" || fd.Name.Name == "registerRoute") {
737 continue
738 }
739 src.inspect(t, fd.Body)
740 }
741 }
742 return src
743}
744
745func (src *cliSource) inspect(t *testing.T, body *ast.BlockStmt) {
746 t.Helper()
747 ast.Inspect(body, func(n ast.Node) bool {
748 call, ok := n.(*ast.CallExpr)
749 if !ok {
750 return true
751 }
752 sel, ok := call.Fun.(*ast.SelectorExpr)
753 if !ok {
754 return true
755 }
756 switch sel.Sel.Name {
757 case "cliRoute":
758 if len(call.Args) != 3 {
759 t.Errorf("cliRoute at %s takes %d arguments; this test expects (pattern, allow, handler)",
760 src.fset.Position(call.Pos()), len(call.Args))
761 return true
762 }
763 pattern, ok := stringLit(call.Args[0])
764 if !ok {
765 t.Errorf("cliRoute at %s: pattern is not a string literal, so this test cannot classify it",
766 src.fset.Position(call.Pos()))
767 return true
768 }
769 src.gated = append(src.gated, pattern)
770 if !strings.Contains(pattern, "/api/cli/") {
771 t.Errorf("%s: registered through cliRoute but is not under /api/cli/", pattern)
772 }
773 case "Handle", "HandleFunc", "registerRoute":
774 pattern, isLit := stringLit(call.Args[0])
775 // The bypass guard, and the reason this pass is package-wide: an
776 // /api/cli/ door registered anywhere but cliRoute names no roles
777 // and is therefore gated by nothing at all.
778 if isLit && strings.Contains(pattern, "/api/cli/") {
779 t.Errorf("%s at %s: registered with %s, bypassing the role gate",
780 pattern, src.fset.Position(call.Pos()), sel.Sel.Name)
781 return true
782 }
783 if sel.Sel.Name != "registerRoute" && !isMuxRegistration(sel) {
784 return true
785 }
786 if !isLit {
787 t.Errorf("mux.%s at %s: pattern is not a string literal, so this test cannot classify it",
788 sel.Sel.Name, src.fset.Position(call.Pos()))
789 return true
790 }
791 if len(call.Args) == 2 {
792 src.plain = append(src.plain, muxDoor{pattern: pattern, handler: call.Args[1]})
793 }
794 }
795 return true
796 })
797}
798
799// isMuxRegistration reports whether a selector is a call on something's `mux`
800// field — s.mux.HandleFunc and friends — as opposed to any other method that
801// happens to share the name.
802func isMuxRegistration(sel *ast.SelectorExpr) bool {
803 recv, ok := sel.X.(*ast.SelectorExpr)
804 return ok && recv.Sel.Name == "mux"
805}
806
807// stringLit unwraps an argument that is a plain quoted string.
808func stringLit(e ast.Expr) (string, bool) {
809 lit, ok := e.(*ast.BasicLit)
810 if !ok || lit.Kind != token.STRING {
811 return "", false
812 }
813 s, err := strconv.Unquote(lit.Value)
814 if err != nil {
815 return "", false
816 }
817 return s, true
818}
819
820// TestAuthCLIFamilyResolvesOnlyPersons is the OTHER half of default-deny.
821// The /auth/cli/ handlers — whoami, revoke, tokens/revoke, accounts (which
822// creates an ACCOUNT) — sit outside cliRoute and need no role bindings,
823// because every one of them that reads a bearer at all gates on cliUser,
824// which answers only for a person's token. That is currently true by
825// construction; this makes it true by test, so the day someone reaches for
826// CLIPrincipal over there the build says so.
827//
828// It asserts over EVERY door the package registers outside /api/cli/, not
829// just the CLI family, because the property is the same one everywhere: a
830// handler the role gate does not stand in front of must not be resolving a
831// service credential. A browser page passes it trivially, which is the
832// correct answer for a browser page.
833func TestAuthCLIFamilyResolvesOnlyPersons(t *testing.T) {
834 src := scanCLISource(t)
835 for _, d := range src.plain {
836 switch h := d.handler.(type) {
837 case *ast.SelectorExpr:
838 assertResolvesOnlyPersons(t, src, d.pattern, src.funcs[h.Sel.Name])
839 case *ast.Ident:
840 assertResolvesOnlyPersons(t, src, d.pattern, src.funcs[h.Name])
841 case *ast.FuncLit:
842 assertBodyResolvesOnlyPersons(t, src, d.pattern, h.Body)
843 default:
844 t.Errorf("%s: handler is neither a named function nor a literal; this test cannot see inside it", d.pattern)
845 }
846 }
847
848 // A concrete presence check rather than a count: the CLI identity family
849 // is the part of this surface the assertion exists for, so name it. If
850 // the walker breaks, or one of these moves under cliRoute without the
851 // move being intended, this says so — where a minimum-length guard would
852 // have gone on passing.
853 seen := map[string]bool{}
854 for _, d := range src.plain {
855 seen[d.pattern] = true
856 }
857 for _, pattern := range []string{
858 "POST /auth/cli/start",
859 "POST /auth/cli/poll",
860 "GET /auth/cli",
861 "POST /auth/cli/approve",
862 "GET /settings",
863 "GET /auth/cli/tokens",
864 "POST /auth/cli/tokens/revoke",
865 "GET /auth/cli/whoami",
866 "POST /auth/cli/revoke",
867 "POST /auth/cli/accounts",
868 } {
869 if !seen[pattern] {
870 t.Errorf("%s: not found as a plain mux registration; either it moved or the walker is broken", pattern)
871 }
872 }
873
874 // The positive half: the three doors of the family that DO read a
875 // bearer must read it through cliUser. Without this the test above
876 // would still pass if a handler stopped authenticating altogether.
877 for _, name := range []string{"handleCLIWhoami", "handleCLIRevoke", "handleCLINewAccount"} {
878 fd, ok := src.funcs[name]
879 if !ok {
880 t.Errorf("%s: not found in the package", name)
881 continue
882 }
883 if !callsSelector(fd.Body, "cliUser") {
884 t.Errorf("%s: does not go through cliUser; a bearer-reading /auth/cli/ door must", name)
885 }
886 }
887}
888
889// assertResolvesOnlyPersons is assertBodyResolvesOnlyPersons with the lookup
890// of a named handler folded in.
891func assertResolvesOnlyPersons(t *testing.T, src *cliSource, what string, fd *ast.FuncDecl) {
892 t.Helper()
893 if fd == nil {
894 t.Errorf("%s: handler function not found in the package", what)
895 return
896 }
897 assertBodyResolvesOnlyPersons(t, src, what, fd.Body)
898}
899
900// assertBodyResolvesOnlyPersons fails if a handler outside /api/cli/ resolves
901// a bearer by any route other than cliUser. CLIPrincipal answers for a
902// SERVICE credential and CLITokenAddress is the raw resolver cliUser wraps;
903// a handler over here calling either one has stopped being person-only, and
904// that is precisely the drift this catches.
905func assertBodyResolvesOnlyPersons(t *testing.T, src *cliSource, what string, body *ast.BlockStmt) {
906 t.Helper()
907 if body == nil {
908 return
909 }
910 ast.Inspect(body, func(n ast.Node) bool {
911 sel, ok := n.(*ast.SelectorExpr)
912 if !ok {
913 return true
914 }
915 switch sel.Sel.Name {
916 case "CLIPrincipal", "CLITokenAddress", "principalFrom":
917 t.Errorf("%s: resolves a bearer through %s at %s — the /auth/cli/ family must go through cliUser, which answers only for a person",
918 what, sel.Sel.Name, src.fset.Position(sel.Pos()))
919 }
920 return true
921 })
922}
923
924// callsSelector reports whether body calls something named name.
925func callsSelector(body *ast.BlockStmt, name string) bool {
926 found := false
927 ast.Inspect(body, func(n ast.Node) bool {
928 call, ok := n.(*ast.CallExpr)
929 if !ok {
930 return true
931 }
932 switch fn := call.Fun.(type) {
933 case *ast.SelectorExpr:
934 if fn.Sel.Name == name {
935 found = true
936 }
937 case *ast.Ident:
938 if fn.Name == name {
939 found = true
940 }
941 }
942 return true
943 })
944 return found
945}
946
947// TestRoleRefusalIsConstantAcrossAccounts is the reason role is checked
948// before scope. The same credential at the same door gets the SAME BYTES
949// whether the account segment names its own account, an account that belongs
950// to someone else, or an account that does not exist at all. Were scope
951// checked first, the 404/403 pair would answer "are you a member of this?"
952// for any account an attacker cared to name.
953func TestRoleRefusalIsConstantAcrossAccounts(t *testing.T) {
954 fx := newConsoleFixture(t)
955 now := time.Now()
956 if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
957 t.Fatal(err)
958 }
959 // A publish credential at an OPERATE door.
960 tok := serviceToken(t, fx.st, OpsAccountID, "shipper", "kass", svcrole.Publish)
961
962 var bodies []string
963 for _, acct := range []string{fx.sqid, "cdef", "zzzz"} {
964 status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+acct+"/kass/config", tok)
965 if status != http.StatusForbidden {
966 t.Fatalf("publish credential on the config door of %q = %d %s, want 403", acct, status, body)
967 }
968 bodies = append(bodies, body)
969 }
970 for i := 1; i < len(bodies); i++ {
971 if bodies[i] != bodies[0] {
972 t.Errorf("role refusal differs by account:\n own: %s\nother: %s", bodies[0], bodies[i])
973 }
974 }
975 if !strings.Contains(bodies[0], "publish") || !strings.Contains(bodies[0], "operate") {
976 t.Errorf("403 body = %s; it should name the role held and the role the door needs", bodies[0])
977 }
978}
979
980// TestServiceRoleReachesTheDoorItNames: a credential whose role is on the
981// door gets the ANSWER, not merely a non-403. Both halves of the gate have
982// to work for that — the role gate here, and the scope resolution that finds
983// the credential's own account and app without a membership in sight — so
984// this is the one test that says the two compose into a working caller
985// rather than into two refusals that happen to agree.
986func TestServiceRoleReachesTheDoorItNames(t *testing.T) {
987 fx := newConsoleFixture(t)
988 tok := serviceToken(t, fx.st, OpsAccountID, "runner", "kass", svcrole.Operate)
989 status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/config", tok)
990 if status != http.StatusOK {
991 t.Fatalf("operate credential on the config door = %d %s, want 200", status, body)
992 }
993 if !strings.Contains(body, `"bundle"`) {
994 t.Errorf("config body = %s, want the door's own answer", body)
995 }
996}
997
998// TestEveryClosedDoorRefusesAServiceCredential walks the closedDoors list
999// itself and dials every pattern on it with a real service credential over
1000// HTTP. Driven off the list rather than off a hand-written set of URLs, so a
1001// ninth closed door added later is covered the moment it is registered — and
1002// so the list cannot become a claim nobody checks.
1003//
1004// This is what turns closedDoors from bookkeeping into a property. On its own
1005// the list only says "these are registered with the zero Set"; a door that
1006// was reopened while the list was edited to match would still be green
1007// against it. Here the answer comes from the running server.
1008//
1009// The credential holds ADMIN, the role most likely to slip through, and it is
1010// account-scoped so no app binding can be doing the refusing instead of the
1011// role gate. Every answer must be the closed-door 403, whose body is the same
1012// bytes at every door (TestClosedDoorRefusalIsItsOwnMessage).
1013func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
1014 fx := newConsoleFixture(t)
1015 tok := serviceToken(t, fx.st, OpsAccountID, "root", "", svcrole.Admin)
1016 seen := 0
1017 for _, pattern := range closedDoors {
1018 method, path, found := strings.Cut(pattern, " ")
1019 if !found {
1020 t.Fatalf("closedDoors entry %q is not \"METHOD /path\"", pattern)
1021 }
1022 // Fill the wildcards with things that EXIST where they can, so a 403
1023 // is the role gate refusing and not a 404 arriving first. Order is
1024 // role before scope, so this is belt and braces.
1025 u := strings.NewReplacer(
1026 "{account}", fx.sqid,
1027 "{app}", "kass",
1028 "{fleet}", "edge",
1029 "{label}", "box1",
1030 "{name}", "hub",
1031 "{host}", "web.go.tito.io",
1032 "{id}", "deadbeefdeadbeef",
1033 "{level}", "0",
1034 "{span}", "1-1",
1035 "{version}", "v1",
1036 ).Replace(path)
1037 if strings.Contains(u, "{") {
1038 t.Fatalf("closedDoors entry %q has a wildcard this test does not fill", pattern)
1039 }
1040 body := ""
1041 if method == http.MethodPost || method == http.MethodPut {
1042 body = `{}`
1043 }
1044 status, _, out := rawReq(t, method, fx.ts.URL+u, tok, body)
1045 if status != http.StatusForbidden {
1046 t.Errorf("%s = %d %s, want the closed-door 403", pattern, status, out)
1047 }
1048 if !strings.Contains(out, "closed to service credentials") {
1049 t.Errorf("%s body = %s, want the closed-door refusal", pattern, out)
1050 }
1051 seen++
1052 }
1053 if seen != len(closedDoors) || seen != 59 {
1054 t.Errorf("walked %d closed doors, want all %d (and 59 today: instance-token, three door-key doors, the routes door, instances-manifest, the app update policy, sidecars-manifest, the route-channel move, three instance update settings doors, two db replica doors, the forwarded db read, six fleets, four services, two email credentials, email pause, the build probe, the swap policy, the live tenancy read, the layout read, the activation-cancel door, nine system doors, three deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, four store-entitlement doors, and two account plan doors)",
1055 seen, len(closedDoors))
1056 }
1057}
1058
1059// TestAdminIsRefusedAtAClosedDoor proves the escalation cut end to end, not
1060// just in the table. Admin is the union of every role, so it is the one
1061// credential most likely to slip through a door that named no roles — and
1062// the zero Set has to refuse it too, or "forgot to name a role" would read
1063// as "admin-only" rather than as the closure it is.
1064func TestAdminIsRefusedAtAClosedDoor(t *testing.T) {
1065 fx := newConsoleFixture(t)
1066 tok := serviceToken(t, fx.st, OpsAccountID, "root", "", svcrole.Admin)
1067 if status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/instance-token",
1068 map[string]string{}, tok); status != http.StatusForbidden {
1069 t.Fatalf("admin credential minting a token = %d %s, want 403", status, body)
1070 }
1071 if status, body := postRaw(t, fx.ts.URL+"/api/cli/fleets/"+fx.sqid+"/new",
1072 map[string]string{"name": "edge"}, tok); status != http.StatusForbidden {
1073 t.Fatalf("admin credential enrolling a fleet = %d %s, want 403", status, body)
1074 }
1075}
1076
1077// TestClosedDoorRefusalIsItsOwnMessage: a door no role reaches must not say
1078// "this door needs one of []". That renders from the general refusal when
1079// Roles() is empty, and it misleads — it implies some role exists that would
1080// work, and sends whoever reads it looking for the role that opens the mint
1081// door. The closed-door answer is its own sentence, and it is CONSTANT: the
1082// same bytes for every role and every account segment, so it discloses
1083// neither what the caller holds nor whether the account it named is real.
1084func TestClosedDoorRefusalIsItsOwnMessage(t *testing.T) {
1085 fx := newConsoleFixture(t)
1086 now := time.Now()
1087 if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
1088 t.Fatal(err)
1089 }
1090 admin := serviceToken(t, fx.st, OpsAccountID, "root", "", svcrole.Admin)
1091 shipper := serviceToken(t, fx.st, OpsAccountID, "shipper", "kass", svcrole.Publish)
1092
1093 var bodies []string
1094 for _, c := range []struct{ tok, acct string }{
1095 {admin, fx.sqid}, {shipper, fx.sqid}, {admin, "cdef"}, {shipper, "zzzz"},
1096 } {
1097 status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/"+c.acct+"/kass/instance-token",
1098 map[string]string{}, c.tok)
1099 if status != http.StatusForbidden {
1100 t.Fatalf("closed door = %d %s, want 403", status, body)
1101 }
1102 bodies = append(bodies, body)
1103 }
1104 for i := 1; i < len(bodies); i++ {
1105 if bodies[i] != bodies[0] {
1106 t.Errorf("closed-door refusal varies with the caller or the account:\n %s\n %s", bodies[0], bodies[i])
1107 }
1108 }
1109 if strings.Contains(bodies[0], "[]") || strings.Contains(bodies[0], "needs one of") {
1110 t.Errorf("closed-door refusal = %s; it must not offer a role list, there is no role that works", bodies[0])
1111 }
1112 if !strings.Contains(bodies[0], "closed to service credentials") {
1113 t.Errorf("closed-door refusal = %s; it should say the door is closed to service credentials", bodies[0])
1114 }
1115}
1116
1117// TestPersonNeverMeetsTheRoleGate: a person's authority is their membership
1118// set, evaluated per request by the handler. The role gate is for service
1119// principals only and must be invisible to a human at a terminal — including
1120// at the doors only `admin` reaches.
1121func TestPersonNeverMeetsTheRoleGate(t *testing.T) {
1122 fx := newConsoleFixture(t)
1123 tok := cliToken(t, fx, "paul@keymail.dev", "code-role-gate-person")
1124 for _, u := range []string{
1125 fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/ledgers",
1126 fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/config",
1127 fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/store",
1128 // The zero-Set doors are the ones that matter here. They refuse
1129 // EVERY role, so a gate that mistook a person for a service
1130 // principal would lock every human out of `carlos instances token`
1131 // and `carlos fleets` — the loudest possible regression, and the
1132 // one a test named for the person's exemption should be the place
1133 // to find covered.
1134 fx.ts.URL + "/api/cli/fleets/" + fx.sqid,
1135 } {
1136 if status, body := getRaw(t, u, tok); status == http.StatusForbidden {
1137 t.Errorf("person at %s = 403 %s; the role gate must not see a person at all", u, body)
1138 }
1139 }
1140 for _, u := range []string{
1141 fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/instance-token",
1142 fx.ts.URL + "/api/cli/fleets/" + fx.sqid + "/new",
1143 } {
1144 if status, body := postRaw(t, u, map[string]string{"name": "edge"}, tok); status == http.StatusForbidden {
1145 t.Errorf("person at %s = 403 %s; a closed door is closed to SERVICE credentials, not to people", u, body)
1146 }
1147 }
1148}
1149
1150// TestCLIRouteUnauthorizedMatchesCLIUser: the gate's 401 and cliUser's 401
1151// are the SAME BYTES. If they differed, "this bearer is a service credential
1152// at a person's door" would be distinguishable from "this bearer does not
1153// exist" — which is exactly the property the service-principal model exists
1154// to hold.
1155func TestCLIRouteUnauthorizedMatchesCLIUser(t *testing.T) {
1156 fx := newConsoleFixture(t)
1157 // /auth/cli/whoami is cliUser's own door; /api/cli/apps is cliRoute's.
1158 userStatus, userBody := getRaw(t, fx.ts.URL+"/auth/cli/whoami", "not-a-token")
1159 routeStatus, routeBody := getRaw(t, fx.ts.URL+"/api/cli/apps", "not-a-token")
1160 if userStatus != http.StatusUnauthorized || routeStatus != http.StatusUnauthorized {
1161 t.Fatalf("whoami = %d, apps = %d; want 401 from both", userStatus, routeStatus)
1162 }
1163 if userBody != routeBody {
1164 t.Errorf("401 bodies differ:\ncliUser: %s\ncliRoute: %s", userBody, routeBody)
1165 }
1166 // A revoked service credential is the same answer as a fabricated one.
1167 tok := serviceToken(t, fx.st, OpsAccountID, "gone", "kass", svcrole.Publish)
1168 if err := fx.st.RevokeServicePrincipal(OpsAccountID, "gone", time.Now()); err != nil {
1169 t.Fatal(err)
1170 }
1171 if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusUnauthorized || body != routeBody {
1172 t.Errorf("revoked service credential = %d %s, want the fabricated-token 401 %s", status, body, routeBody)
1173 }
1174}
1175
1176// TestPrincipalReachesTheHandler: cliRoute resolves the bearer once and hands
1177// the result down the request context, so the handler's own resolution (Task
1178// 4's collapse of apiInstancePrincipal) needs no second database read. The
1179// probe door is registered on this fixture's own server, which is why it does
1180// not disturb the coverage tests above.
1181func TestPrincipalReachesTheHandler(t *testing.T) {
1182 fx := newConsoleFixture(t)
1183 got := make(chan Principal, 1)
1184 fx.srv.cliRoute("GET /api/cli/probe", svcrole.Allow(svcrole.Publish),
1185 func(w http.ResponseWriter, r *http.Request) {
1186 p, ok := principalFrom(r.Context())
1187 if !ok {
1188 t.Error("principalFrom: no principal on a cliRoute request")
1189 }
1190 got <- p
1191 w.WriteHeader(http.StatusNoContent)
1192 })
1193
1194 tok := serviceToken(t, fx.st, OpsAccountID, "shipper", "kass", svcrole.Publish)
1195 if status, body := getRaw(t, fx.ts.URL+"/api/cli/probe", tok); status != http.StatusNoContent {
1196 t.Fatalf("probe = %d %s, want 204", status, body)
1197 }
1198 p := <-got
1199 if p.Kind != PrincipalService || p.Role != svcrole.Publish || p.Name != "shipper" ||
1200 p.App != "kass" || p.AccountID != OpsAccountID {
1201 t.Errorf("principal = %+v, want the shipper service principal", p)
1202 }
1203
1204 person := cliToken(t, fx, "paul@keymail.dev", "code-role-gate-probe")
1205 if status, body := getRaw(t, fx.ts.URL+"/api/cli/probe", person); status != http.StatusNoContent {
1206 t.Fatalf("probe as a person = %d %s, want 204", status, body)
1207 }
1208 p = <-got
1209 if p.Kind != PrincipalUser || p.Address != "paul@keymail.dev" {
1210 t.Errorf("principal = %+v, want paul as a person", p)
1211 }
1212}
1213
1214// TestPrincipalFromEmptyContextIsNotAPrincipal: nothing outside this package
1215// can forge a principal onto a request, and a request that never went through
1216// cliRoute reports no principal rather than a zero-valued one that would read
1217// as a user principal with no address.
1218func TestPrincipalFromEmptyContextIsNotAPrincipal(t *testing.T) {
1219 if p, ok := principalFrom(context.Background()); ok {
1220 t.Errorf("principalFrom(background) = %+v, true; want ok=false", p)
1221 }
1222}
1223