| 1 | package 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 | |
| 16 | import ( |
| 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. |
| 78 | var ( |
| 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. |
| 95 | var 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. |
| 287 | var 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. |
| 298 | func 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. |
| 332 | func 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). |
| 459 | func 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. |
| 566 | var 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. |
| 635 | func 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. |
| 688 | type muxDoor struct { |
| 689 | pattern string |
| 690 | handler ast.Expr |
| 691 | } |
| 692 | |
| 693 | // cliSource is what that single pass yields. |
| 694 | type 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. |
| 704 | func 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 | |
| 745 | func (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. |
| 802 | func 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. |
| 808 | func 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. |
| 833 | func 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. |
| 891 | func 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. |
| 905 | func 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. |
| 925 | func 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. |
| 953 | func 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. |
| 986 | func 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). |
| 1013 | func 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. |
| 1064 | func 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. |
| 1084 | func 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. |
| 1121 | func 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. |
| 1155 | func 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. |
| 1181 | func 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. |
| 1218 | func 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 | |