console: the role gate is structural — a door that names no roles is closed
Every /api/cli/ door now registers through cliRoute, which names the roles
that may reach it. cliRoute resolves the bearer once, refuses a service
principal whose role is not on the door, and passes the principal down the
request context for the handler.
The gate order is bearer -> principal (401) -> role (403) -> scope (404) ->
budget (429) -> handler. Role BEFORE scope: a role refusal reads neither the
account segment nor the database, so it is a constant function of the door
and the caller's own role and leaks nothing. Scope-first would have made the
404/403 pair a membership oracle. Role-first is also the only order that can
be enforced out in the wrapper rather than asked of ~50 handlers.
Seven doors carry the zero Set, which permits nothing including admin: the
instance-token mint and the six fleets verbs. A credential may never mint a
credential, and enrolling a box stays a human act. The list is asserted as an
EQUALITY so an accidental empty Set fails too.
The /auth/cli/ family and GET /settings stay on plain mux.HandleFunc: they
are the other half of default-deny, gating on cliUser, which answers only for
a person. An AST test now holds them to it.
One existing assertion is re-recorded rather than preserved.
TestAPIInstanceAppTokenContainment asserted 401 for an app token at promote
and at the mint door, on the reasoning that the old resolver could not see a
non-person at all. Under the gate an app token is a service principal holding
role instance, so both are 403 — the answer the spec's §8 risk register
already names ("the role gate ships closed, so any mis-bound door is a 403
for a live caller"). The containment itself is unchanged and still asserted;
only the status moved, and the 403 is byte-identical across account segments.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
4 files changed,
+705
−65
internal/console/apiinstances_test.go+19 −10internal/console/authcli.go+160 −55internal/console/server.go+10 −0internal/console/svcroutes_test.go+516 −0
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go| index 633e25472..c6454f39d 100644 |
| --- a/internal/console/apiinstances_test.go |
| +++ b/internal/console/apiinstances_test.go |
| @@ -225,19 +225,28 @@ func TestAPIInstanceAppTokenContainment(t *testing.T) { |
| t.Fatal(err) |
| } |
| - // Promote: an app token is unknown to CLITokenAddress, so it meets the |
| - // same 401 a fabricated token does — never a 403 or a 404 that would |
| - // tell it the door exists for someone. |
| + // Promote: the containment is unchanged, the ANSWER moved from 401 to |
| + // 403 when the role gate landed (service-principals spec §3b, §8). An |
| + // app token is a service principal holding role `instance`, and promote |
| + // names `publish` — so cliRoute refuses it out at the wrapper, before |
| + // the handler and before the account segment is read. 403 discloses |
| + // nothing the holder does not already know: its own role, and the roles |
| + // this door wants. It is the same constant answer for every account |
| + // segment, which is what keeps it from being a membership oracle |
| + // (TestRoleRefusalIsConstantAcrossAccounts). |
| if status, _, out := rawReq(t, http.MethodPost, |
| fx.ts.URL+"/api/cli/apps/bab/titogo/promote", appTok, |
| - `{"version":"1.0.0","channel":"canary/a"}`); status != http.StatusUnauthorized { |
| - t.Fatalf("app token on promote = %d %s, want 401", status, out) |
| - } |
| - // Minting is a human act: the mint door is apiAccountApp's, and |
| - // apiAccountApp can never see an app token at all. |
| + `{"version":"1.0.0","channel":"canary/a"}`); status != http.StatusForbidden { |
| + t.Fatalf("app token on promote = %d %s, want 403", status, out) |
| + } |
| + // Minting is a human act: the mint door is registered with the zero |
| + // Set, so no role reaches it and admin is no escape hatch either |
| + // (TestNoRoleReachesAMintOrFleetDoor). Behind it, apiAccountApp still |
| + // cannot see an app token at all — the gate is the outer of two walls, |
| + // not a replacement for the inner one. |
| if status, _, out := rawReq(t, http.MethodPost, |
| - fx.ts.URL+"/api/cli/apps/bab/titogo/instance-token", appTok, `{}`); status != http.StatusUnauthorized { |
| - t.Fatalf("app token minting a token = %d %s, want 401", status, out) |
| + fx.ts.URL+"/api/cli/apps/bab/titogo/instance-token", appTok, `{}`); status != http.StatusForbidden { |
| + t.Fatalf("app token minting a token = %d %s, want 403", status, out) |
| } |
| // Its own app's verbs work. |
| status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), appTok, |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 78b7de62c..9a62cb2ab 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -36,6 +36,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| // Rate limits. Deliberately generous for humans and tight for scripts: the |
| @@ -199,7 +200,73 @@ func newCLILimits() *cliLimits { |
| } |
| } |
| +// cliRouteKey is the context key cliRoute stashes the resolved principal |
| +// under. Unexported, and of an unexported type, so nothing outside this |
| +// package can forge one onto a request. |
| +type cliRouteKey struct{} |
| + |
| +// principalFrom reads the principal cliRoute resolved. ok is false only for a |
| +// request that did not come through cliRoute — which, for anything under |
| +// /api/cli/, TestEveryCLIDoorNamesItsRoles says cannot happen. |
| +func principalFrom(ctx context.Context) (Principal, bool) { |
| + p, ok := ctx.Value(cliRouteKey{}).(Principal) |
| + return p, ok |
| +} |
| + |
| +// cliRoute registers one CLI API door and is the ONLY way to register one. |
| +// It resolves the bearer once, applies the role gate, and passes the |
| +// principal down on the request context so the handler's own resolver does |
| +// not repeat the lookup. |
| +// |
| +// THE ROLE GATE RUNS BEFORE THE HANDLER, AND THEREFORE BEFORE THE SCOPE |
| +// CHECK — spec §3b. A role refusal reads neither the account segment nor the |
| +// database: it is a constant function of the door and the caller's own role, |
| +// identical whether the named account exists, belongs to someone else, or |
| +// belongs to the caller. There is nothing in it to leak. Checking scope first |
| +// would instead make the pair of answers a membership oracle — 404 for an |
| +// account you do not hold, 403 for one you do. Role-first is also the only |
| +// order that can be enforced structurally, out here, rather than asked of |
| +// every handler. |
| +// |
| +// A PERSON never meets the gate. Their authority is their membership set, |
| +// evaluated per request by the handler, and no role name resolves to it. |
| +// |
| +// allow is never optional and the zero Set permits nothing — not even admin — |
| +// so a door added without naming its roles is unreachable by every service |
| +// principal rather than open to all of them. |
| +func (s *Server) cliRoute(pattern string, allow svcrole.Set, h http.HandlerFunc) { |
| + if s.cliRoles == nil { |
| + s.cliRoles = map[string]svcrole.Set{} |
| + } |
| + s.cliRoles[pattern] = allow |
| + s.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { |
| + p, ok := s.store.CLIPrincipal(bearer(r), time.Now()) |
| + if !ok { |
| + // The same 401, with the same body, cliUser has always given: |
| + // an expired token, a revoked one and a fabricated one are not |
| + // distinguishable from outside. |
| + s.cliNotSignedIn(w) |
| + return |
| + } |
| + if p.Kind == PrincipalService && !allow.Permits(p.Role) { |
| + jsonError(w, http.StatusForbidden, fmt.Sprintf( |
| + "this credential holds role %q; this door needs one of %v", p.Role, allow.Roles())) |
| + return |
| + } |
| + h(w, r.WithContext(context.WithValue(r.Context(), cliRouteKey{}, p))) |
| + }) |
| +} |
| + |
| // registerCLIRoutes wires the CLI-identity surface. Called from NewServer. |
| +// |
| +// The /auth/cli/ family and GET /settings below stay on plain |
| +// mux.HandleFunc, deliberately: they are the OTHER half of default-deny. |
| +// Every one of them that reads a bearer at all gates on cliUser, which |
| +// answers only for a person, so a service credential meets there exactly the |
| +// 401 a fabricated token meets. Pinned by TestAuthCLIFamilyResolvesOnlyPersons. |
| +// |
| +// Every /api/cli/ door goes through cliRoute and names its roles. Pinned by |
| +// TestEveryCLIDoorNamesItsRoles. |
| func (s *Server) registerCLIRoutes() { |
| s.mux.HandleFunc("POST /auth/cli/start", s.handleCLIStart) |
| s.mux.HandleFunc("POST /auth/cli/poll", s.handleCLIPoll) |
| @@ -216,50 +283,50 @@ func (s *Server) registerCLIRoutes() { |
| // The CLI's read-only API (apicli.go) — deliberately a different prefix |
| // (/api/cli/) from the identity routes above (/auth/cli/), which must not |
| // move now that CLIs depend on them. |
| - s.mux.HandleFunc("GET /api/cli/apps", s.handleAPIApps) |
| + s.cliRoute("GET /api/cli/apps", svcrole.Allow(svcrole.Publish, svcrole.Instance, svcrole.Operate), s.handleAPIApps) |
| // The literal `new` mirrors the browser door (/a/{sqid}/apps/new); no |
| // bare POST .../{app} route exists to collide with, and the mux prefers |
| // the literal if one ever does. |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/new", s.handleAPICreateApp) |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/channels", s.handleAPIChannels) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", s.handleAPIPromote) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/rollback", s.handleAPIRollback) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", s.handleAPIShip) |
| + s.cliRoute("POST /api/cli/apps/{account}/new", svcrole.Allow(svcrole.Publish), s.handleAPICreateApp) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/channels", svcrole.Allow(svcrole.Publish), s.handleAPIChannels) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/promote", svcrole.Allow(svcrole.Publish), s.handleAPIPromote) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/rollback", svcrole.Allow(svcrole.Publish), s.handleAPIRollback) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish), s.handleAPIShip) |
| // `carlos restart` (issue #113): a member's "cycle this app's processes" |
| // with no version and no config to push — the same write-door class as |
| // promote and rollback, and the only one whose subject is a running |
| // process rather than a pointer. |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/restart", s.handleAPIRestart) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/restart", svcrole.Allow(svcrole.Operate), s.handleAPIRestart) |
| // `carlos releases retention` (issue #136): a member's ambient-prune |
| // policy — read, set, clear, preview. Same identity gates as every |
| // door here; the safety list in release.PruneEligible is what keeps a |
| // policy from ever deleting anything promoted, tagged, or recent. |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/retention", s.handleAPIRetentionGet) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/retention", s.handleAPIRetentionSet) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/retention", svcrole.Allow(svcrole.Publish), s.handleAPIRetentionGet) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/retention", svcrole.Allow(svcrole.Publish), s.handleAPIRetentionSet) |
| // The version-target, tags and releases-list API (apiversion.go, |
| // release versioning spec 2026-08-08). GET .../releases coexists with |
| // the POST above on the SAME path — a distinct method route, not a |
| // collision — for `carlos releases` to read what `carlos ship` writes. |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/version-target", s.handleAPIVersionTargetGet) |
| - s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/version-target", s.handleAPIVersionTargetSet) |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/tags", s.handleAPITagsList) |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/tags/{version}", s.handleAPITagGet) |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/releases", s.handleAPIReleasesList) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish), s.handleAPIVersionTargetGet) |
| + s.cliRoute("PUT /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish), s.handleAPIVersionTargetSet) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/tags", svcrole.Allow(svcrole.Publish), s.handleAPITagsList) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/tags/{version}", svcrole.Allow(svcrole.Publish), s.handleAPITagGet) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish), s.handleAPIReleasesList) |
| // Trash and restore (apicli.go). Delete resolves through the LIVE-apps |
| // door and restore through the trashed-apps one, so each verb answers |
| // only for an app in the state it can act on — and both give a stranger |
| // the same 404. |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/delete", s.handleAPITrashApp) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/restore", s.handleAPIRestoreApp) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/delete", svcrole.Allow(svcrole.Publish), s.handleAPITrashApp) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/restore", svcrole.Allow(svcrole.Publish), s.handleAPIRestoreApp) |
| // The ledger API (apiledger.go) — the only write path to ledgers/ from |
| // outside a box. The literal `publish` segment is kept adjacent to the |
| // {ledger} wildcards for readability; the mux prefers it regardless of |
| // registration order. |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/ledgers", s.handleAPILedgerList) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/publish", s.handleAPILedgerPublish) |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head", s.handleAPILedgerHead) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", s.handleAPILedgerAppend) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", s.handleAPILedgerBlob) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/ledgers", svcrole.Allow(svcrole.Admin), s.handleAPILedgerList) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/publish", svcrole.Allow(svcrole.Admin), s.handleAPILedgerPublish) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head", svcrole.Allow(svcrole.Admin), s.handleAPILedgerHead) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append", svcrole.Allow(svcrole.Admin), s.handleAPILedgerAppend) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/blobs", svcrole.Allow(svcrole.Admin), s.handleAPILedgerBlob) |
| // The instance provisioning API (apiinstances.go) — the only door onto |
| // instances/ and domains/ from outside a box, and the only family an |
| // APP's own token may speak to. `instance-token` is a literal segment beside the |
| @@ -270,54 +337,81 @@ func (s *Server) registerCLIRoutes() { |
| // `instances-manifest` is the member-facing half of `carlos instances |
| // enable` (issue #66): a literal sibling of instances/, never under it, |
| // so a host named "instances-manifest" could still never reach it. |
| - s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/instances-manifest", s.handleAPIInstanceEnable) |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances", s.handleAPIInstanceList) |
| + s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances-manifest", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceEnable) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceList) |
| // The logs read (`carlos logs`, apilogs.go) — same gate, same 404 |
| // discipline, and an app token may read its own app's logs. |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/logs", s.handleAPILogs) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/instances", s.handleAPIInstanceCreate) |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceGet) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/logs", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPILogs) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceCreate) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceGet) |
| // PATCH is the multi-upstream repoint (issue #152): the two fields of a |
| // record — its addr set and its probe — that change while the instance |
| // keeps serving. Same {host} wildcard, same gate, same byte-identical |
| // 404 as its GET and DELETE siblings. |
| - s.mux.HandleFunc("PATCH /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceUpdate) |
| - s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceDelete) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/instance-token", s.handleAPIInstanceToken) |
| + s.cliRoute("PATCH /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceUpdate) |
| + s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceDelete) |
| + // The zero Set, deliberately: a credential may never mint a credential, |
| + // including itself. This is the escalation cut (spec §3). It takes no |
| + // authority away — handleAPIInstanceToken gates on apiAccountApp, which |
| + // has never been able to see a non-person at all — it moves the refusal |
| + // out to the wrapper, where it is structural, and changes the ANSWER a |
| + // credential gets here from 401 to 403. Pinned by |
| + // TestNoRoleReachesAMintOrFleetDoor and TestAdminIsRefusedAtAClosedDoor. |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instance-token", svcrole.Set{}, s.handleAPIInstanceToken) |
| // The extra-hostname API (apidomains.go, issue #151) — the only door onto |
| // domains/ from outside a box, sharing the instance family's gate exactly |
| // (instanceGate), so titogo's router attaches its customers' hostnames |
| // with the CARLOS_INSTANCE_TOKEN it already holds. |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/domains", s.handleAPIDomainList) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains", s.handleAPIDomainAttach) |
| - s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/domains/{host}", s.handleAPIDomainDetach) |
| + // |
| + // Domains carries `Instance` for COMPATIBILITY, not because it belongs |
| + // there: that router re-attaches every hostname on every reload pass with |
| + // its CARLOS_INSTANCE_TOKEN, which is a kind='app' row and so holds role |
| + // instance. Dropping it here 403s that router on the next console deploy. |
| + // Same for logs, above. Pinned by |
| + // TestInstanceRoleMatchesTodaysAppTokenReach; spec §5. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainList) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainAttach) |
| + s.cliRoute("DELETE /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainDetach) |
| // The config API (apiconfig.go) — `carlos env` and `carlos secrets` |
| // through the same gate as ship/promote, so deploy access no longer |
| // means bucket credentials (issue #43). Secrets arrive already sealed; |
| // the console relays an opaque envelope and holds no key to open it. |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/config", s.handleAPIConfigGet) |
| - s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/config", s.handleAPIConfigPut) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/config/sync", s.handleAPIConfigSync) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/config", svcrole.Allow(svcrole.Operate), s.handleAPIConfigGet) |
| + s.cliRoute("PUT /api/cli/apps/{account}/{app}/config", svcrole.Allow(svcrole.Operate), s.handleAPIConfigPut) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/config/sync", svcrole.Allow(svcrole.Operate), s.handleAPIConfigSync) |
| // The routes read (apiroutes.go, issue #64) — the member-only door onto |
| // this box's registry rows for one app, so a route's environment |
| - // binding can be read back without a box shell. Deliberately NOT |
| - // instanceGate: see that file's package comment for why an app token |
| - // must not reach it. |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/routes", s.handleAPIRoutes) |
| + // binding can be read back without a box shell. |
| + // |
| + // The ZERO Set, and the reasoning is that file's own: "An app token is |
| + // a machine credential minted for one instance to register itself; this |
| + // answer describes every route of the app, with upstream addresses, |
| + // owning units and environment bindings, which is an operator's view |
| + // rather than a workload's." A service credential is the same kind of |
| + // caller an app token is, so the door that refuses one refuses all of |
| + // them. It joins policyDoors for that reason — person-only by intent, |
| + // not merely by bookkeeping. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/routes", svcrole.Set{}, s.handleAPIRoutes) |
| // The customer fleets API (fleets.go) — account-owned remote-box groups |
| // and where an app's routes/instances place onto them. `new` is a |
| // literal beside the {fleet} wildcard for the same reason apps/new is: |
| // the mux prefers a literal regardless of registration order, so there |
| // is no ambiguity with a fleet that happened to be named "new". |
| - s.mux.HandleFunc("POST /api/cli/fleets/{account}/new", s.handleAPIFleetNew) |
| - s.mux.HandleFunc("GET /api/cli/fleets/{account}", s.handleAPIFleetList) |
| - s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes", s.handleAPIFleetAddBox) |
| - s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", s.handleAPIFleetRotateBox) |
| - s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", s.handleAPIFleetDetachBox) |
| - s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", s.handleAPIFleetTagBox) |
| + // |
| + // Every one of them is the zero Set, deliberately: enrolling a box into a |
| + // fleet, and rotating or detaching one, hands out a MACHINE's credentials |
| + // and changes where a whole account's apps can be placed. That is a human |
| + // act, and no service role reaches it — the same escalation cut as the |
| + // mint door above. Pinned by TestNoRoleReachesAMintOrFleetDoor. |
| + s.cliRoute("POST /api/cli/fleets/{account}/new", svcrole.Set{}, s.handleAPIFleetNew) |
| + s.cliRoute("GET /api/cli/fleets/{account}", svcrole.Set{}, s.handleAPIFleetList) |
| + s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes", svcrole.Set{}, s.handleAPIFleetAddBox) |
| + s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", svcrole.Set{}, s.handleAPIFleetRotateBox) |
| + s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", svcrole.Set{}, s.handleAPIFleetDetachBox) |
| + s.cliRoute("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", svcrole.Set{}, s.handleAPIFleetTagBox) |
| // Placement lives under the apps family, not fleets: its subject is an |
| // APP, and a target fleet name is only ever the request body's data. |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/place", s.handleAPIPlace) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/place", svcrole.Allow(svcrole.Admin), s.handleAPIPlace) |
| // The object-storage API (apistore.go, issue #55) — declare a store, read |
| // what became of it, and drive the walks that make it real. Beside the |
| // config routes on purpose: a store's credentials are DELIVERED as config, |
| @@ -328,11 +422,11 @@ func (s *Server) registerCLIRoutes() { |
| // and finish are MEMBER verbs (spec §8) — they cycle a credential inside an |
| // estate a grant already paid for. All three are literal segments under |
| // .../store, never {wildcards}, so no app or host name can reach them. |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/store", s.handleAPIStoreCreate) |
| - s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/store", s.handleAPIStoreGet) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/store/grant", s.handleAPIStoreGrant) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/store/rotate", s.handleAPIStoreRotate) |
| - s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/store/rotate/finish", s.handleAPIStoreRotateFinish) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreCreate) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGet) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/store/grant", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGrant) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotate) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate/finish", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotateFinish) |
| } |
| // clientIP is who the rate limiter counts against. |
| @@ -443,13 +537,24 @@ func bearer(r *http.Request) string { |
| return strings.TrimSpace(h[7:]) |
| } |
| -// cliUser authenticates a CLI request. Every failure is the same 401 with the |
| -// same body: an expired token, a revoked one and a fabricated one are not |
| -// distinguishable from outside. |
| +// cliNotSignedIn is the ONE 401 the CLI surface gives, written once so the |
| +// two authenticators cannot drift. cliUser answers it for a person's door and |
| +// cliRoute for an API door, and the bodies must be byte-identical: a |
| +// differently-phrased 401 over here would make "this bearer is a service |
| +// credential at a person's door" distinguishable from "this bearer does not |
| +// exist", which is the whole property the person-only doors hold. |
| +// Pinned by TestCLIRouteUnauthorizedMatchesCLIUser. |
| +func (s *Server) cliNotSignedIn(w http.ResponseWriter) { |
| + jsonError(w, http.StatusUnauthorized, "not signed in — run: carlos auth login -console "+s.cfg.Origin) |
| +} |
| + |
| +// cliUser authenticates a CLI request AS A PERSON. Every failure is the same |
| +// 401 with the same body: an expired token, a revoked one, a fabricated one |
| +// and a service credential are not distinguishable from outside. |
| func (s *Server) cliUser(w http.ResponseWriter, r *http.Request) (string, bool) { |
| addr, ok := s.store.CLITokenAddress(bearer(r), time.Now()) |
| if !ok { |
| - jsonError(w, http.StatusUnauthorized, "not signed in — run: carlos auth login -console "+s.cfg.Origin) |
| + s.cliNotSignedIn(w) |
| return "", false |
| } |
| return addr, true |
diff --git a/internal/console/server.go b/internal/console/server.go| index f42207a8a..d178238e5 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -35,6 +35,7 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| "github.com/carlosframework/platform/internal/usage" |
| ) |
| @@ -530,6 +531,15 @@ type Server struct { |
| instances *instanceFetcher |
| secure bool // Secure cookies iff the origin is https |
| cli *cliLimits // rate limits on the CLI-identity endpoints (authcli.go) |
| + // cliRoles is pattern -> allowed roles, recorded by cliRoute as it |
| + // registers each /api/cli/ door (authcli.go). Written once, during |
| + // registerCLIRoutes, before the server serves anything, and only read |
| + // afterwards. It exists for two readers: the coverage tests, which |
| + // assert that every door names its roles and that the closed ones stay |
| + // closed, and the services API, which hands an operator the door list |
| + // for the role they just granted so they can see what they gave away |
| + // without reading a spec. |
| + cliRoles map[string]svcrole.Set |
| // redirects is cfg.RedirectHosts, normalized (lowercase, no port) into a |
| // lookup set — ServeHTTP checks it before the mux sees the request. |
| redirects map[string]bool |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| new file mode 100644 |
| index 000000000..306d77990 |
| --- /dev/null |
| +++ b/internal/console/svcroutes_test.go |
| @@ -0,0 +1,516 @@ |
| +package console |
| + |
| +// The structural role gate (spec §3b). Every /api/cli/ door names the roles |
| +// that may reach it AT REGISTRATION, and a service principal that lacks the |
| +// role is refused before the handler runs. |
| +// |
| +// The order is: bearer -> principal (401) -> role (403) -> scope (404) -> |
| +// budget (429) -> handler. Role BEFORE scope is deliberate. A role refusal |
| +// reads neither the account segment nor the database, so it is a constant |
| +// function of the door and the caller's own role — identical whether the |
| +// named account exists, belongs to someone else, or belongs to the caller. |
| +// There is nothing in it to leak. Checking scope first would instead make the |
| +// pair of answers a membership oracle: 404 for an account you do not hold, |
| +// 403 for one you do. |
| + |
| +import ( |
| + "context" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "net/http" |
| + "os" |
| + "reflect" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// mintDoors and fleetDoors are the two families no role may ever reach. |
| +// Listed as substrings of the registered pattern, because that is the shape |
| +// the table is keyed on. /api/cli/services/ has no doors yet — the services |
| +// API is a later task — so that entry is a standing guard rather than a live |
| +// assertion: the day those doors appear, they appear closed or this fails. |
| +var ( |
| + mintDoors = []string{"/instance-token", "/api/cli/services/"} |
| + fleetDoors = []string{"/api/cli/fleets/"} |
| +) |
| + |
| +// closedDoors is the exact set of /api/cli/ patterns registered with the zero |
| +// Set — the Set that permits nothing, including admin. |
| +// |
| +// The list is spelled out rather than derived, and asserted as an EQUALITY, |
| +// because the two ways of getting this wrong pull in opposite directions. A |
| +// door that forgot to name its roles must fail (it would be an accidental |
| +// zero Set, unreachable by every credential, and silently so). A door that |
| +// must be shut but is not must fail too (it would be an escalation path). |
| +// Only an equality catches both; "no empty Sets" catches neither, and "these |
| +// are empty" catches only the first. |
| +var closedDoors = []string{ |
| + // A credential may never mint a credential, including itself. This is |
| + // the escalation cut of spec §3. |
| + "POST /api/cli/apps/{account}/{app}/instance-token", |
| + // Enrolling a BOX into a fleet, and rotating or detaching one, is a |
| + // human act with a machine's credentials as its output. Same cut. |
| + "POST /api/cli/fleets/{account}/new", |
| + "GET /api/cli/fleets/{account}", |
| + "POST /api/cli/fleets/{account}/{fleet}/boxes", |
| + "POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", |
| + "POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", |
| + "POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", |
| +} |
| + |
| +// TestEveryCLIDoorNamesItsRoles is the default-deny guarantee for the |
| +// /api/cli/ half. A door registered with plain mux.HandleFunc would not |
| +// appear in cliRoles at all; a door registered with an accidental empty Set |
| +// is unreachable by every service principal. Both are failures worth catching |
| +// at build time rather than in production. |
| +func TestEveryCLIDoorNamesItsRoles(t *testing.T) { |
| + s := newConsoleFixture(t).srv |
| + |
| + var empty []string |
| + for pattern, set := range s.cliRoles { |
| + if set.Empty() { |
| + empty = append(empty, pattern) |
| + } |
| + } |
| + sort.Strings(empty) |
| + wantClosed := append([]string(nil), closedDoors...) |
| + sort.Strings(wantClosed) |
| + if !reflect.DeepEqual(empty, wantClosed) { |
| + t.Errorf("doors registered with an empty role set:\n got %q\nwant %q\n"+ |
| + "An unexpected empty Set is a door nobody can reach; a missing one is a door that should be shut and is not.", |
| + empty, wantClosed) |
| + } |
| + |
| + registered := registeredCLIPatterns(t) |
| + for _, pattern := range registered { |
| + if _, ok := s.cliRoles[pattern]; !ok { |
| + t.Errorf("%s: registered without going through cliRoute", pattern) |
| + } |
| + } |
| + if len(s.cliRoles) != len(registered) { |
| + t.Errorf("cliRoles holds %d doors, the source registers %d — the two must be the same set", |
| + len(s.cliRoles), len(registered)) |
| + } |
| +} |
| + |
| +// TestNoRoleReachesAMintOrFleetDoor is the escalation cut: a credential must |
| +// never be able to mint or rotate a credential — including itself — and must |
| +// never enrol a box. |
| +func TestNoRoleReachesAMintOrFleetDoor(t *testing.T) { |
| + s := newConsoleFixture(t).srv |
| + for pattern, set := range s.cliRoles { |
| + for _, closed := range append(append([]string{}, mintDoors...), fleetDoors...) { |
| + if !strings.Contains(pattern, closed) { |
| + continue |
| + } |
| + for _, r := range svcrole.All() { |
| + if set.Permits(r) { |
| + t.Errorf("%s is reachable by role %q; mint and fleet doors must be closed to every role", pattern, r) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// exemptFromLegacyPin names doors that permit `instance` DELIBERATELY as new |
| +// access rather than as legacy reach, and so must not be pinned by |
| +// TestInstanceRoleMatchesTodaysAppTokenReach. |
| +// |
| +// GET /api/cli/apps is the only one. It is scope-scoped rather than |
| +// role-gated (spec §3a-i): its answer is the caller's OWN binding, nothing |
| +// else, so every role may ask it. An app token cannot reach it today — the |
| +// handler goes through cliUser and answers 401 — so listing it in `want` |
| +// below would make that map lie about what legacy reach is. |
| +var exemptFromLegacyPin = map[string]bool{ |
| + "GET /api/cli/apps": true, |
| +} |
| + |
| +// TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec |
| +// §5. kind='app' tokens today reach the instance verbs AND domains AND logs — |
| +// titogo's router re-attaches every hostname on every reload pass with its |
| +// CARLOS_INSTANCE_TOKEN. A tidy `instance` role meaning "the instance verbs" |
| +// would 403 that router on the next console deploy. |
| +// |
| +// If this test fails because a door moved, that is the point: decide |
| +// deliberately, then update BOTH this list and the role table. |
| +func TestInstanceRoleMatchesTodaysAppTokenReach(t *testing.T) { |
| + want := map[string]bool{ |
| + "PUT /api/cli/apps/{account}/{app}/instances-manifest": true, |
| + "GET /api/cli/apps/{account}/{app}/instances": true, |
| + "POST /api/cli/apps/{account}/{app}/instances": true, |
| + "GET /api/cli/apps/{account}/{app}/instances/{host}": true, |
| + "PATCH /api/cli/apps/{account}/{app}/instances/{host}": true, |
| + "DELETE /api/cli/apps/{account}/{app}/instances/{host}": true, |
| + "GET /api/cli/apps/{account}/{app}/domains": true, |
| + "POST /api/cli/apps/{account}/{app}/domains": true, |
| + "DELETE /api/cli/apps/{account}/{app}/domains/{host}": true, |
| + "GET /api/cli/apps/{account}/{app}/logs": true, |
| + } |
| + s := newConsoleFixture(t).srv |
| + got := map[string]bool{} |
| + for pattern, set := range s.cliRoles { |
| + if set.Permits(svcrole.Instance) && !exemptFromLegacyPin[pattern] { |
| + got[pattern] = true |
| + } |
| + } |
| + for p := range want { |
| + if !got[p] { |
| + t.Errorf("%s: role instance does not reach it, but an app token does today", p) |
| + } |
| + } |
| + for p := range got { |
| + if !want[p] { |
| + t.Errorf("%s: role instance reaches it, but an app token does not today — widening the legacy role", p) |
| + } |
| + } |
| +} |
| + |
| +// registeredCLIPatterns reads the package's own source rather than the mux, |
| +// because http.ServeMux cannot be enumerated. The check it enables is worth |
| +// the unusual shape: it catches a door added tomorrow with plain |
| +// mux.HandleFunc, which is the exact way default-deny would be lost. |
| +func registeredCLIPatterns(t *testing.T) []string { |
| + t.Helper() |
| + src, err := os.ReadFile("authcli.go") |
| + if err != nil { |
| + t.Fatalf("read authcli.go: %v", err) |
| + } |
| + var out []string |
| + for _, line := range strings.Split(string(src), "\n") { |
| + if !strings.Contains(line, "/api/cli/") { |
| + continue |
| + } |
| + if strings.Contains(line, "s.mux.HandleFunc(") { |
| + t.Errorf("door registered with mux.HandleFunc, bypassing the role gate:\n %s", strings.TrimSpace(line)) |
| + continue |
| + } |
| + if !strings.Contains(line, "s.cliRoute(") { |
| + continue |
| + } |
| + // The pattern is the first quoted string on the line. |
| + i := strings.Index(line, `"`) |
| + if i < 0 { |
| + continue |
| + } |
| + j := strings.Index(line[i+1:], `"`) |
| + if j < 0 { |
| + continue |
| + } |
| + out = append(out, line[i+1:i+1+j]) |
| + } |
| + if len(out) < 40 { |
| + t.Fatalf("found only %d cliRoute patterns; the parser is broken, not the code", len(out)) |
| + } |
| + return out |
| +} |
| + |
| +// TestAuthCLIFamilyResolvesOnlyPersons is the OTHER half of default-deny. |
| +// The /auth/cli/ handlers — whoami, revoke, tokens/revoke, accounts (which |
| +// creates an ACCOUNT) — sit outside cliRoute and need no role bindings, |
| +// because every one of them that reads a bearer at all gates on cliUser, |
| +// which answers only for a person's token. That is currently true by |
| +// construction; this makes it true by test, so the day someone reaches for |
| +// CLIPrincipal over there the build says so. |
| +func TestAuthCLIFamilyResolvesOnlyPersons(t *testing.T) { |
| + fset := token.NewFileSet() |
| + pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool { |
| + return !strings.HasSuffix(fi.Name(), "_test.go") |
| + }, 0) |
| + if err != nil { |
| + t.Fatalf("parse package: %v", err) |
| + } |
| + pkg, ok := pkgs["console"] |
| + if !ok { |
| + t.Fatal("package console not found by the parser") |
| + } |
| + funcs := map[string]*ast.FuncDecl{} |
| + for _, f := range pkg.Files { |
| + for _, d := range f.Decls { |
| + if fd, ok := d.(*ast.FuncDecl); ok && fd.Name != nil && fd.Body != nil { |
| + funcs[fd.Name.Name] = fd |
| + } |
| + } |
| + } |
| + reg, ok := funcs["registerCLIRoutes"] |
| + if !ok { |
| + t.Fatal("registerCLIRoutes not found; this test is reading the wrong package") |
| + } |
| + |
| + var named []string |
| + ast.Inspect(reg.Body, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + sel, ok := call.Fun.(*ast.SelectorExpr) |
| + if !ok || sel.Sel.Name != "HandleFunc" || len(call.Args) != 2 { |
| + return true |
| + } |
| + lit, ok := call.Args[0].(*ast.BasicLit) |
| + if !ok || lit.Kind != token.STRING { |
| + t.Errorf("mux.HandleFunc at %s: pattern is not a string literal, so this test cannot classify it", |
| + fset.Position(call.Pos())) |
| + return true |
| + } |
| + pattern, err := strconv.Unquote(lit.Value) |
| + if err != nil { |
| + t.Errorf("mux.HandleFunc at %s: unquote %s: %v", fset.Position(call.Pos()), lit.Value, err) |
| + return true |
| + } |
| + if strings.Contains(pattern, "/api/cli/") { |
| + t.Errorf("%s: registered with mux.HandleFunc, bypassing the role gate", pattern) |
| + return true |
| + } |
| + switch h := call.Args[1].(type) { |
| + case *ast.SelectorExpr: |
| + named = append(named, h.Sel.Name) |
| + assertResolvesOnlyPersons(t, fset, pattern, funcs[h.Sel.Name]) |
| + case *ast.Ident: |
| + named = append(named, h.Name) |
| + assertResolvesOnlyPersons(t, fset, pattern, funcs[h.Name]) |
| + case *ast.FuncLit: |
| + assertBodyResolvesOnlyPersons(t, fset, pattern, h.Body) |
| + default: |
| + t.Errorf("%s: handler is neither a named function nor a literal; this test cannot see inside it", pattern) |
| + } |
| + return true |
| + }) |
| + if len(named) < 8 { |
| + t.Fatalf("found only %d named non-/api/cli/ handlers; the walker is broken, not the code", len(named)) |
| + } |
| + |
| + // The positive half: the three doors of the family that DO read a |
| + // bearer must read it through cliUser. Without this the test above |
| + // would still pass if a handler stopped authenticating altogether. |
| + for _, name := range []string{"handleCLIWhoami", "handleCLIRevoke", "handleCLINewAccount"} { |
| + fd, ok := funcs[name] |
| + if !ok { |
| + t.Errorf("%s: not found in the package", name) |
| + continue |
| + } |
| + if !callsSelector(fd.Body, "cliUser") { |
| + t.Errorf("%s: does not go through cliUser; a bearer-reading /auth/cli/ door must", name) |
| + } |
| + } |
| +} |
| + |
| +// assertResolvesOnlyPersons is assertBodyResolvesOnlyPersons with the lookup |
| +// of a named handler folded in. |
| +func assertResolvesOnlyPersons(t *testing.T, fset *token.FileSet, what string, fd *ast.FuncDecl) { |
| + t.Helper() |
| + if fd == nil { |
| + t.Errorf("%s: handler function not found in the package", what) |
| + return |
| + } |
| + assertBodyResolvesOnlyPersons(t, fset, what, fd.Body) |
| +} |
| + |
| +// assertBodyResolvesOnlyPersons fails if a handler outside /api/cli/ resolves |
| +// a bearer by any route other than cliUser. CLIPrincipal answers for a |
| +// SERVICE credential and CLITokenAddress is the raw resolver cliUser wraps; |
| +// a handler over here calling either one has stopped being person-only, and |
| +// that is precisely the drift this catches. |
| +func assertBodyResolvesOnlyPersons(t *testing.T, fset *token.FileSet, what string, body *ast.BlockStmt) { |
| + t.Helper() |
| + if body == nil { |
| + return |
| + } |
| + ast.Inspect(body, func(n ast.Node) bool { |
| + sel, ok := n.(*ast.SelectorExpr) |
| + if !ok { |
| + return true |
| + } |
| + switch sel.Sel.Name { |
| + case "CLIPrincipal", "CLITokenAddress", "principalFrom": |
| + t.Errorf("%s: resolves a bearer through %s at %s — the /auth/cli/ family must go through cliUser, which answers only for a person", |
| + what, sel.Sel.Name, fset.Position(sel.Pos())) |
| + } |
| + return true |
| + }) |
| +} |
| + |
| +// callsSelector reports whether body calls something named name. |
| +func callsSelector(body *ast.BlockStmt, name string) bool { |
| + found := false |
| + ast.Inspect(body, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + switch fn := call.Fun.(type) { |
| + case *ast.SelectorExpr: |
| + if fn.Sel.Name == name { |
| + found = true |
| + } |
| + case *ast.Ident: |
| + if fn.Name == name { |
| + found = true |
| + } |
| + } |
| + return true |
| + }) |
| + return found |
| +} |
| + |
| +// TestRoleRefusalIsConstantAcrossAccounts is the reason role is checked |
| +// before scope. The same credential at the same door gets the SAME BYTES |
| +// whether the account segment names its own account, an account that belongs |
| +// to someone else, or an account that does not exist at all. Were scope |
| +// checked first, the 404/403 pair would answer "are you a member of this?" |
| +// for any account an attacker cared to name. |
| +func TestRoleRefusalIsConstantAcrossAccounts(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A publish credential at an OPERATE door. |
| + tok := serviceToken(t, fx.st, OpsAccountID, "shipper", "kass", svcrole.Publish) |
| + |
| + var bodies []string |
| + for _, acct := range []string{fx.sqid, "cdef", "zzzz"} { |
| + status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+acct+"/kass/config", tok) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("publish credential on the config door of %q = %d %s, want 403", acct, status, body) |
| + } |
| + bodies = append(bodies, body) |
| + } |
| + for i := 1; i < len(bodies); i++ { |
| + if bodies[i] != bodies[0] { |
| + t.Errorf("role refusal differs by account:\n own: %s\nother: %s", bodies[0], bodies[i]) |
| + } |
| + } |
| + if !strings.Contains(bodies[0], "publish") || !strings.Contains(bodies[0], "operate") { |
| + t.Errorf("403 body = %s; it should name the role held and the role the door needs", bodies[0]) |
| + } |
| +} |
| + |
| +// TestServiceRoleReachesTheDoorItNames: the gate lets a credential through |
| +// when its role is on the door. It is asserted as "not 403" rather than as a |
| +// success code on purpose — at this task the handlers still resolve their |
| +// caller through cliUser, so a service principal that passes the gate then |
| +// meets the handler's own 401. Wiring the principal into the handlers is the |
| +// next task; the gate's job here is only to not refuse. |
| +func TestServiceRoleReachesTheDoorItNames(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + tok := serviceToken(t, fx.st, OpsAccountID, "runner", "kass", svcrole.Operate) |
| + if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/config", tok); status == http.StatusForbidden { |
| + t.Fatalf("operate credential on the config door = 403 %s, want the gate to pass it", body) |
| + } |
| +} |
| + |
| +// TestAdminIsRefusedAtAClosedDoor proves the escalation cut end to end, not |
| +// just in the table. Admin is the union of every role, so it is the one |
| +// credential most likely to slip through a door that named no roles — and |
| +// the zero Set has to refuse it too, or "forgot to name a role" would read |
| +// as "admin-only" rather than as the closure it is. |
| +func TestAdminIsRefusedAtAClosedDoor(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + tok := serviceToken(t, fx.st, OpsAccountID, "root", "", svcrole.Admin) |
| + if status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/instance-token", |
| + map[string]string{}, tok); status != http.StatusForbidden { |
| + t.Fatalf("admin credential minting a token = %d %s, want 403", status, body) |
| + } |
| + if status, body := postRaw(t, fx.ts.URL+"/api/cli/fleets/"+fx.sqid+"/new", |
| + map[string]string{"name": "edge"}, tok); status != http.StatusForbidden { |
| + t.Fatalf("admin credential enrolling a fleet = %d %s, want 403", status, body) |
| + } |
| +} |
| + |
| +// TestPersonNeverMeetsTheRoleGate: a person's authority is their membership |
| +// set, evaluated per request by the handler. The role gate is for service |
| +// principals only and must be invisible to a human at a terminal — including |
| +// at the doors only `admin` reaches. |
| +func TestPersonNeverMeetsTheRoleGate(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + tok := cliToken(t, fx, "paul@keymail.dev", "code-role-gate-person") |
| + for _, u := range []string{ |
| + fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/ledgers", |
| + fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/config", |
| + fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/store", |
| + } { |
| + if status, body := getRaw(t, u, tok); status == http.StatusForbidden { |
| + t.Errorf("person at %s = 403 %s; the role gate must not see a person at all", u, body) |
| + } |
| + } |
| +} |
| + |
| +// TestCLIRouteUnauthorizedMatchesCLIUser: the gate's 401 and cliUser's 401 |
| +// are the SAME BYTES. If they differed, "this bearer is a service credential |
| +// at a person's door" would be distinguishable from "this bearer does not |
| +// exist" — which is exactly the property the service-principal model exists |
| +// to hold. |
| +func TestCLIRouteUnauthorizedMatchesCLIUser(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + // /auth/cli/whoami is cliUser's own door; /api/cli/apps is cliRoute's. |
| + userStatus, userBody := getRaw(t, fx.ts.URL+"/auth/cli/whoami", "not-a-token") |
| + routeStatus, routeBody := getRaw(t, fx.ts.URL+"/api/cli/apps", "not-a-token") |
| + if userStatus != http.StatusUnauthorized || routeStatus != http.StatusUnauthorized { |
| + t.Fatalf("whoami = %d, apps = %d; want 401 from both", userStatus, routeStatus) |
| + } |
| + if userBody != routeBody { |
| + t.Errorf("401 bodies differ:\ncliUser: %s\ncliRoute: %s", userBody, routeBody) |
| + } |
| + // A revoked service credential is the same answer as a fabricated one. |
| + tok := serviceToken(t, fx.st, OpsAccountID, "gone", "kass", svcrole.Publish) |
| + if err := fx.st.RevokeServicePrincipal(OpsAccountID, "gone", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusUnauthorized || body != routeBody { |
| + t.Errorf("revoked service credential = %d %s, want the fabricated-token 401 %s", status, body, routeBody) |
| + } |
| +} |
| + |
| +// TestPrincipalReachesTheHandler: cliRoute resolves the bearer once and hands |
| +// the result down the request context, so the handler's own resolution (Task |
| +// 4's collapse of apiInstancePrincipal) needs no second database read. The |
| +// probe door is registered on this fixture's own server, which is why it does |
| +// not disturb the coverage tests above. |
| +func TestPrincipalReachesTheHandler(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + got := make(chan Principal, 1) |
| + fx.srv.cliRoute("GET /api/cli/probe", svcrole.Allow(svcrole.Publish), |
| + func(w http.ResponseWriter, r *http.Request) { |
| + p, ok := principalFrom(r.Context()) |
| + if !ok { |
| + t.Error("principalFrom: no principal on a cliRoute request") |
| + } |
| + got <- p |
| + w.WriteHeader(http.StatusNoContent) |
| + }) |
| + |
| + tok := serviceToken(t, fx.st, OpsAccountID, "shipper", "kass", svcrole.Publish) |
| + if status, body := getRaw(t, fx.ts.URL+"/api/cli/probe", tok); status != http.StatusNoContent { |
| + t.Fatalf("probe = %d %s, want 204", status, body) |
| + } |
| + p := <-got |
| + if p.Kind != PrincipalService || p.Role != svcrole.Publish || p.Name != "shipper" || |
| + p.App != "kass" || p.AccountID != OpsAccountID { |
| + t.Errorf("principal = %+v, want the shipper service principal", p) |
| + } |
| + |
| + person := cliToken(t, fx, "paul@keymail.dev", "code-role-gate-probe") |
| + if status, body := getRaw(t, fx.ts.URL+"/api/cli/probe", person); status != http.StatusNoContent { |
| + t.Fatalf("probe as a person = %d %s, want 204", status, body) |
| + } |
| + p = <-got |
| + if p.Kind != PrincipalUser || p.Address != "paul@keymail.dev" { |
| + t.Errorf("principal = %+v, want paul as a person", p) |
| + } |
| +} |
| + |
| +// TestPrincipalFromEmptyContextIsNotAPrincipal: nothing outside this package |
| +// can forge a principal onto a request, and a request that never went through |
| +// cliRoute reports no principal rather than a zero-valued one that would read |
| +// as a user principal with no address. |
| +func TestPrincipalFromEmptyContextIsNotAPrincipal(t *testing.T) { |
| + if p, ok := principalFrom(context.Background()); ok { |
| + t.Errorf("principalFrom(background) = %+v, true; want ok=false", p) |
| + } |
| +} |