carlos / platform Public

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
Paul Campbell 233f7ad56f0407b487e14293b0aaa816a7054a16 parent b164683
4 files changed, +705 −65
  • internal/console/apiinstances_test.go +19 −10
  • internal/console/authcli.go +160 −55
  • internal/console/server.go +10 −0
  • internal/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)
+ }
+}