carlos / platform Public

console: close the role gate's coverage hole, and three review fixes

Important 1 — the bypass guard read one file; the hole was on Task 6's path.
registeredCLIPatterns scanned authcli.go's lines and the AST test walked only
registerCLIRoutes, so the services API — arriving in its own file with its own
registration function, holding the doors that MINT credentials — would have
been invisible to both. cliRoles would have compared 47 to 47, and
TestNoRoleReachesAMintOrFleetDoor's /api/cli/services/ entry would have passed
vacuously over a map that never held them. Every structural test green, mint
doors ungated.

Replaced by scanCLISource: one go/ast pass over every non-test file in the
package, collecting cliRoute patterns and flagging any Handle/HandleFunc whose
literal pattern is under /api/cli/, wherever it lives. cliRoute itself is the
single exemption — its own registration is the mechanism under test, and its
pattern is a variable by construction.

Both directions proven against a temporary file standing in for Task 6's
apiservices.go: registered with mux.HandleFunc it fails as a bypass;
registered with cliRoute but not wired into NewServer it fails on the
source-vs-server count.

The line scanner is retired, not wrapped. It was fragile both ways: a
registration split across lines escaped strings.Contains, and any comment
quoting "s.mux.HandleFunc(" hard-failed the build — a standing tax on the
wording of the most heavily commented function in the file. The len(out) < 40
floor goes with it; the source-vs-server equality already fails a walker that
found nothing. The auth-family half now asserts over every non-/api/cli/ door
the package registers, not just the CLI ten, with a named-pattern presence
check in place of a magic count.

Minor 3 — a closed door gets its own refusal. "this door needs one of []"
implied some role would work and sent the reader looking for it. Now: "this
door is closed to service credentials — no role reaches it; it needs a
person". Constant across roles and account segments, asserted as such.

Minor 4 — TestPersonNeverMeetsTheRoleGate now covers the zero-Set doors
(instance-token, fleets list and new), which are the ones that would lock
every human out if the person exemption were wrong.

C24 — CLIPrincipal read expires_at = 0 as "never" for every kind, while
CLITokenAddress reads a person's 0 as the epoch. No divergence today, since
NewCLIToken always stamps now+CLITokenTTL — but Task 4 folds cliUser into this
resolver, and at that moment its reading becomes the person's expiry rule. A
kind that never expires is a decision each minter makes, not one to inherit by
accident. 0 is now never only for non-user kinds, with a test that inserts a
kind='' row carrying 0 and asserts both resolvers refuse it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
Paul Campbell 475c650694c21fc25265e122d5b19c608f6972f7 parent 233f7ad
4 files changed, +319 −88
  • internal/console/authcli.go +11 −0
  • internal/console/svcprincipal.go +21 −3
  • internal/console/svcprincipal_test.go +49 −0
  • internal/console/svcroutes_test.go +238 −85
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 9a62cb2ab..2b8094c99 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -249,6 +249,17 @@ func (s *Server) cliRoute(pattern string, allow svcrole.Set, h http.HandlerFunc)
return
}
if p.Kind == PrincipalService && !allow.Permits(p.Role) {
+ if allow.Empty() {
+ // A closed door has no role that would work, so naming the
+ // caller's role and an empty list ("needs one of []") would
+ // read as a bug and send whoever hit it looking for the role
+ // that opens it. There is none: this is the escalation cut.
+ // Constant for every role and every account segment, which
+ // keeps it as leak-free as the refusal below.
+ jsonError(w, http.StatusForbidden,
+ "this door is closed to service credentials — no role reaches it; it needs a person")
+ return
+ }
jsonError(w, http.StatusForbidden, fmt.Sprintf(
"this credential holds role %q; this door needs one of %v", p.Role, allow.Roles()))
return
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go
index 39b25983a..330aeae02 100644
--- a/internal/console/svcprincipal.go
+++ b/internal/console/svcprincipal.go
@@ -87,9 +87,27 @@ FROM cli_tokens WHERE token_hash = ?`, hash).
if revoked != 0 {
return Principal{}, false
}
- // 0 is "never" (app and service tokens); any other value is still
- // honoured.
- if !(expires == 0 || now.Unix() <= expires) {
+ // Expiry, and it is NOT one rule for every kind.
+ //
+ // For an app or service row, expires_at = 0 means never: both minters
+ // write it deliberately (NewAppToken and NewServicePrincipal both
+ // document the trade — a production path that dies on day 91 is worse
+ // than a long-lived credential whose rotation is one command, and
+ // revocation is the control).
+ //
+ // For a PERSON's row, 0 means the Unix epoch, which is to say expired,
+ // exactly as CLITokenAddress has always read it. NewCLIToken always
+ // stamps now+CLITokenTTL so no such row exists today — but this
+ // function is on its way to becoming the ONLY resolver, cliUser
+ // included, and at that moment its reading of 0 becomes the person's
+ // expiry rule. A kind that never expires is a decision each minter
+ // makes; inheriting it by accident would turn any user row that ever
+ // carried 0 into an immortal credential.
+ if kind == cliKindUser {
+ if now.Unix() > expires {
+ return Principal{}, false
+ }
+ } else if !(expires == 0 || now.Unix() <= expires) {
return Principal{}, false
}
var p Principal
diff --git a/internal/console/svcprincipal_test.go b/internal/console/svcprincipal_test.go
index a61f47105..2ab0ebc13 100644
--- a/internal/console/svcprincipal_test.go
+++ b/internal/console/svcprincipal_test.go
@@ -662,3 +662,52 @@ func TestRevokedServicePrincipalAbsentFromCLITokens(t *testing.T) {
}
}
}
+
+// TestCLIPrincipalUserRowWithZeroExpiryIsExpired: expires_at = 0 means
+// "never" for an app or service row and "the epoch" — expired — for a
+// person's. That asymmetry is deliberate and it is the ONE place the two
+// resolvers could have drifted apart: CLITokenAddress has always read a
+// person's 0 as expired, and CLIPrincipal is on its way to replacing it.
+// NewCLIToken always stamps now+CLITokenTTL, so no such row exists today;
+// this is the guard against the day one does, because inheriting the
+// service kinds' "never" would make it an immortal credential.
+func TestCLIPrincipalUserRowWithZeroExpiryIsExpired(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ insert := func(raw string, expires int64) {
+ t.Helper()
+ if _, err := s.db.Exec(`INSERT INTO cli_tokens
+(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app)
+VALUES (?, 'paul@example.test', 'terminal', '', ?, ?, ?, 0, '')`,
+ hashToken(raw), now.Unix(), expires, cliKindUser); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // The control: an ordinary, unexpired person's row resolves. Without
+ // this the assertion below could pass on a malformed insert.
+ live, err := randomToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ insert(live, now.Add(time.Hour).Unix())
+ p, ok := s.CLIPrincipal(live, now)
+ if !ok || p.Kind != PrincipalUser || p.Address != "paul@example.test" {
+ t.Fatalf("CLIPrincipal(live user row) = %+v,%v; want paul as a person", p, ok)
+ }
+
+ // The guard: the same row with expires_at = 0.
+ immortal, err := randomToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ insert(immortal, 0)
+ if p, ok := s.CLIPrincipal(immortal, now); ok {
+ t.Errorf("CLIPrincipal(user row, expires_at=0) = %+v, ok; want refused — 0 is never for a service, the epoch for a person", p)
+ }
+ // And the two resolvers agree, which is the property that has to survive
+ // cliUser being folded into this one.
+ if _, ok := s.CLITokenAddress(immortal, now); ok {
+ t.Error("CLITokenAddress(user row, expires_at=0) = ok; the two resolvers disagree")
+ }
+}
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 306d77990..0812a6d38 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -87,10 +87,14 @@ func TestEveryCLIDoorNamesItsRoles(t *testing.T) {
empty, wantClosed)
}
- registered := registeredCLIPatterns(t)
+ // scanCLISource fails on its own for a door registered outside
+ // cliRoute; this pairs what it DID find against what the running server
+ // recorded. The equality is what makes a walker that silently found
+ // nothing fail too — no magic minimum needed.
+ registered := scanCLISource(t).gated
for _, pattern := range registered {
if _, ok := s.cliRoles[pattern]; !ok {
- t.Errorf("%s: registered without going through cliRoute", pattern)
+ t.Errorf("%s: the source registers it, the server did not record it", pattern)
}
}
if len(s.cliRoles) != len(registered) {
@@ -171,53 +175,46 @@ func TestInstanceRoleMatchesTodaysAppTokenReach(t *testing.T) {
}
}
-// 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
+// ---- reading the package's own source ---------------------------------------
+//
+// http.ServeMux cannot be enumerated: there is no way to ask a live server
+// which patterns it holds, so a test that wants to prove EVERY door is gated
+// has to read the source that registers them. That is an unusual shape for a
+// test and it earns its place — it is the only thing that catches a door
+// added tomorrow with plain mux.HandleFunc, which is the exact way
+// default-deny would be lost.
+//
+// It is one go/ast pass over every non-test file in the package, not a scan
+// of one file's lines. Both parts of that matter. Package-wide, because the
+// services API arrives in a file of its own with a registration function of
+// its own, and those are the doors that MINT credentials: a scanner that read
+// only authcli.go would report 47 gated doors out of 47 and be silent about
+// them. And AST rather than strings, because a registration split across two
+// lines escapes a substring match, and because a line scanner keying on
+// "s.mux.HandleFunc(" fails the build for any COMMENT that happens to quote
+// it — a standing tax on the wording of the most heavily commented function
+// in the file.
+
+// muxDoor is one pattern registered straight onto the mux, with the handler
+// expression it was given.
+type muxDoor struct {
+ pattern string
+ handler ast.Expr
}
-// 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) {
+// cliSource is what that single pass yields.
+type cliSource struct {
+ fset *token.FileSet
+ funcs map[string]*ast.FuncDecl // by bare name
+ gated []string // patterns registered through cliRoute
+ plain []muxDoor // patterns registered outside /api/cli/
+}
+
+// scanCLISource parses the package and classifies every route registration in
+// it. Anything it cannot classify is an error rather than a silent skip: a
+// registration this test cannot read is a registration it cannot vouch for.
+func scanCLISource(t *testing.T) *cliSource {
+ t.Helper()
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
@@ -229,67 +226,168 @@ func TestAuthCLIFamilyResolvesOnlyPersons(t *testing.T) {
if !ok {
t.Fatal("package console not found by the parser")
}
- funcs := map[string]*ast.FuncDecl{}
+ src := &cliSource{fset: fset, 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
+ src.funcs[fd.Name.Name] = fd
}
}
}
- reg, ok := funcs["registerCLIRoutes"]
- if !ok {
- t.Fatal("registerCLIRoutes not found; this test is reading the wrong package")
+ for _, f := range pkg.Files {
+ for _, d := range f.Decls {
+ fd, ok := d.(*ast.FuncDecl)
+ if !ok || fd.Body == nil {
+ continue
+ }
+ // cliRoute is the gate itself. Its own mux.HandleFunc call is
+ // the one registration in the package whose pattern is a
+ // variable rather than a literal, and it is the mechanism these
+ // tests are asserting ABOUT — reading it as a door would be
+ // reading the lock as a way in. It is the only exemption.
+ if fd.Name != nil && fd.Name.Name == "cliRoute" {
+ continue
+ }
+ src.inspect(t, fd.Body)
+ }
}
+ return src
+}
- var named []string
- ast.Inspect(reg.Body, func(n ast.Node) bool {
+func (src *cliSource) inspect(t *testing.T, body *ast.BlockStmt) {
+ t.Helper()
+ ast.Inspect(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)
+ if !ok {
return true
}
- if strings.Contains(pattern, "/api/cli/") {
- t.Errorf("%s: registered with mux.HandleFunc, bypassing the role gate", pattern)
- return true
+ switch sel.Sel.Name {
+ case "cliRoute":
+ if len(call.Args) != 3 {
+ t.Errorf("cliRoute at %s takes %d arguments; this test expects (pattern, allow, handler)",
+ src.fset.Position(call.Pos()), len(call.Args))
+ return true
+ }
+ pattern, ok := stringLit(call.Args[0])
+ if !ok {
+ t.Errorf("cliRoute at %s: pattern is not a string literal, so this test cannot classify it",
+ src.fset.Position(call.Pos()))
+ return true
+ }
+ src.gated = append(src.gated, pattern)
+ if !strings.Contains(pattern, "/api/cli/") {
+ t.Errorf("%s: registered through cliRoute but is not under /api/cli/", pattern)
+ }
+ case "Handle", "HandleFunc":
+ pattern, isLit := stringLit(call.Args[0])
+ // The bypass guard, and the reason this pass is package-wide: an
+ // /api/cli/ door registered anywhere but cliRoute names no roles
+ // and is therefore gated by nothing at all.
+ if isLit && strings.Contains(pattern, "/api/cli/") {
+ t.Errorf("%s at %s: registered with %s, bypassing the role gate",
+ pattern, src.fset.Position(call.Pos()), sel.Sel.Name)
+ return true
+ }
+ if !isMuxRegistration(sel) {
+ return true
+ }
+ if !isLit {
+ t.Errorf("mux.%s at %s: pattern is not a string literal, so this test cannot classify it",
+ sel.Sel.Name, src.fset.Position(call.Pos()))
+ return true
+ }
+ if len(call.Args) == 2 {
+ src.plain = append(src.plain, muxDoor{pattern: pattern, handler: call.Args[1]})
+ }
}
- switch h := call.Args[1].(type) {
+ return true
+ })
+}
+
+// isMuxRegistration reports whether a selector is a call on something's `mux`
+// field — s.mux.HandleFunc and friends — as opposed to any other method that
+// happens to share the name.
+func isMuxRegistration(sel *ast.SelectorExpr) bool {
+ recv, ok := sel.X.(*ast.SelectorExpr)
+ return ok && recv.Sel.Name == "mux"
+}
+
+// stringLit unwraps an argument that is a plain quoted string.
+func stringLit(e ast.Expr) (string, bool) {
+ lit, ok := e.(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ return "", false
+ }
+ s, err := strconv.Unquote(lit.Value)
+ if err != nil {
+ return "", false
+ }
+ return s, true
+}
+
+// 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.
+//
+// It asserts over EVERY door the package registers outside /api/cli/, not
+// just the CLI family, because the property is the same one everywhere: a
+// handler the role gate does not stand in front of must not be resolving a
+// service credential. A browser page passes it trivially, which is the
+// correct answer for a browser page.
+func TestAuthCLIFamilyResolvesOnlyPersons(t *testing.T) {
+ src := scanCLISource(t)
+ for _, d := range src.plain {
+ switch h := d.handler.(type) {
case *ast.SelectorExpr:
- named = append(named, h.Sel.Name)
- assertResolvesOnlyPersons(t, fset, pattern, funcs[h.Sel.Name])
+ assertResolvesOnlyPersons(t, src, d.pattern, src.funcs[h.Sel.Name])
case *ast.Ident:
- named = append(named, h.Name)
- assertResolvesOnlyPersons(t, fset, pattern, funcs[h.Name])
+ assertResolvesOnlyPersons(t, src, d.pattern, src.funcs[h.Name])
case *ast.FuncLit:
- assertBodyResolvesOnlyPersons(t, fset, pattern, h.Body)
+ assertBodyResolvesOnlyPersons(t, src, d.pattern, h.Body)
default:
- t.Errorf("%s: handler is neither a named function nor a literal; this test cannot see inside it", pattern)
+ t.Errorf("%s: handler is neither a named function nor a literal; this test cannot see inside it", d.pattern)
+ }
+ }
+
+ // A concrete presence check rather than a count: the CLI identity family
+ // is the part of this surface the assertion exists for, so name it. If
+ // the walker breaks, or one of these moves under cliRoute without the
+ // move being intended, this says so — where a minimum-length guard would
+ // have gone on passing.
+ seen := map[string]bool{}
+ for _, d := range src.plain {
+ seen[d.pattern] = true
+ }
+ for _, pattern := range []string{
+ "POST /auth/cli/start",
+ "POST /auth/cli/poll",
+ "GET /auth/cli",
+ "POST /auth/cli/approve",
+ "GET /settings",
+ "GET /auth/cli/tokens",
+ "POST /auth/cli/tokens/revoke",
+ "GET /auth/cli/whoami",
+ "POST /auth/cli/revoke",
+ "POST /auth/cli/accounts",
+ } {
+ if !seen[pattern] {
+ t.Errorf("%s: not found as a plain mux registration; either it moved or the walker is broken", 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]
+ fd, ok := src.funcs[name]
if !ok {
t.Errorf("%s: not found in the package", name)
continue
@@ -302,13 +400,13 @@ func TestAuthCLIFamilyResolvesOnlyPersons(t *testing.T) {
// 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) {
+func assertResolvesOnlyPersons(t *testing.T, src *cliSource, 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(t, src, what, fd.Body)
}
// assertBodyResolvesOnlyPersons fails if a handler outside /api/cli/ resolves
@@ -316,7 +414,7 @@ func assertResolvesOnlyPersons(t *testing.T, fset *token.FileSet, what string, f
// 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) {
+func assertBodyResolvesOnlyPersons(t *testing.T, src *cliSource, what string, body *ast.BlockStmt) {
t.Helper()
if body == nil {
return
@@ -329,7 +427,7 @@ func assertBodyResolvesOnlyPersons(t *testing.T, fset *token.FileSet, what strin
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()))
+ what, sel.Sel.Name, src.fset.Position(sel.Pos()))
}
return true
})
@@ -423,6 +521,46 @@ func TestAdminIsRefusedAtAClosedDoor(t *testing.T) {
}
}
+// TestClosedDoorRefusalIsItsOwnMessage: a door no role reaches must not say
+// "this door needs one of []". That renders from the general refusal when
+// Roles() is empty, and it misleads — it implies some role exists that would
+// work, and sends whoever reads it looking for the role that opens the mint
+// door. The closed-door answer is its own sentence, and it is CONSTANT: the
+// same bytes for every role and every account segment, so it discloses
+// neither what the caller holds nor whether the account it named is real.
+func TestClosedDoorRefusalIsItsOwnMessage(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)
+ }
+ admin := serviceToken(t, fx.st, OpsAccountID, "root", "", svcrole.Admin)
+ shipper := serviceToken(t, fx.st, OpsAccountID, "shipper", "kass", svcrole.Publish)
+
+ var bodies []string
+ for _, c := range []struct{ tok, acct string }{
+ {admin, fx.sqid}, {shipper, fx.sqid}, {admin, "cdef"}, {shipper, "zzzz"},
+ } {
+ status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/"+c.acct+"/kass/instance-token",
+ map[string]string{}, c.tok)
+ if status != http.StatusForbidden {
+ t.Fatalf("closed door = %d %s, want 403", status, body)
+ }
+ bodies = append(bodies, body)
+ }
+ for i := 1; i < len(bodies); i++ {
+ if bodies[i] != bodies[0] {
+ t.Errorf("closed-door refusal varies with the caller or the account:\n %s\n %s", bodies[0], bodies[i])
+ }
+ }
+ if strings.Contains(bodies[0], "[]") || strings.Contains(bodies[0], "needs one of") {
+ t.Errorf("closed-door refusal = %s; it must not offer a role list, there is no role that works", bodies[0])
+ }
+ if !strings.Contains(bodies[0], "closed to service credentials") {
+ t.Errorf("closed-door refusal = %s; it should say the door is closed to service credentials", bodies[0])
+ }
+}
+
// 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
@@ -434,11 +572,26 @@ func TestPersonNeverMeetsTheRoleGate(t *testing.T) {
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",
+ // The zero-Set doors are the ones that matter here. They refuse
+ // EVERY role, so a gate that mistook a person for a service
+ // principal would lock every human out of `carlos instances token`
+ // and `carlos fleets` — the loudest possible regression, and the
+ // one a test named for the person's exemption should be the place
+ // to find covered.
+ fx.ts.URL + "/api/cli/fleets/" + fx.sqid,
} {
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)
}
}
+ for _, u := range []string{
+ fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/instance-token",
+ fx.ts.URL + "/api/cli/fleets/" + fx.sqid + "/new",
+ } {
+ if status, body := postRaw(t, u, map[string]string{"name": "edge"}, tok); status == http.StatusForbidden {
+ t.Errorf("person at %s = 403 %s; a closed door is closed to SERVICE credentials, not to people", u, body)
+ }
+ }
}
// TestCLIRouteUnauthorizedMatchesCLIUser: the gate's 401 and cliUser's 401