carlos / platform Public

console: scope resolves from the principal, not from the minter's memberships

Task 4 of the service-principals branch. apiAccount stopped resolving its
caller through cliUser and reads the principal cliRoute already put on the
context, so the scope step of the gate is principal-aware and the double
token lookup every /api/cli/ request was doing is gone.

A service's scope is its BINDING, matched as a sqid only: AccountsFor is
never consulted for one, which is issue #220's second consequence closed —
the reach is fixed at mint and does not grow when the human who minted the
credential joins another account next month. An app-scoped credential
reaches exactly one app, checked before the existence lookup so "not your
app" and "no such app" are the same 404, and a binding that names nothing
is never read as a binding that names everything.

apiInstancePrincipal is deleted. Its rules — the sqid-only match, the exact
app binding, the app:<sqid>/<app> audit actor that keeps a machine's rows
from reading as the minter's — are now in the shared path where every family
gets them, and its reasoning moved with them rather than being dropped.
GET /api/cli/apps answers a service with exactly its own scope (spec §3a-i)
with no change to the wire shape the CLI parses.

One thing the collapse would have granted by accident and does not: enabling
instances writes the manifest that bounds what an app may ever be brought up
as, and that stayed a human act only because apiAccountApp could not see an
app token. apiPerson states it deliberately at that door and at the mint
door, so the inner of the two walls survives the resolver becoming
principal-aware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
Paul Campbell 0f1907e3c2e52995d09ee45583205ebb3a89f4c4 parent 475c650
5 files changed, +648 −120
  • internal/console/apicli.go +213 −43
  • internal/console/apiinstances.go +60 −66
  • internal/console/authstore.go +9 −0
  • internal/console/svcroutes_test.go +16 −11
  • internal/console/svcscope_test.go +350 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 85fc31e1b..b915270a0 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -30,24 +30,13 @@ import (
"github.com/carlosframework/platform/internal/version"
)
-// apiAccountApp resolves and authorizes one CLI API request. The account
-// segment accepts a sqid or a name, resolved against the caller's OWN
-// memberships — resolution and membership proof are deliberately the same
-// lookup, so an unknown account and someone else's account are the same 404.
-//
-// Resolution is SYMMETRIC (matchAccounts, resolve.go): the segment matches
-// as a sqid or as a name — case-insensitively — with no precedence between
-// the two spaces, exactly as the browser's /a/{seg} resolves it. A sqid
-// match no longer wins outright over a name match, because precedence is how
-// a reference silently changes meaning the day a colliding account appears:
-// the caller who typed a name would start acting on someone else's tenant
-// without the wording of their command changing at all. Two candidates are
-// refused (409) rather than guessed at.
-//
-// `?exact=sqid` is the transport for the CLI's -account-id flag: it matches
-// the segment as a sqid ONLY, so the reference a person reaches for to
-// settle an ambiguity can never itself be ambiguous. It never widens access
-// — a non-member's probe meets the same 404 either way.
+// apiAccountApp resolves and authorizes one CLI API request, for either kind
+// of caller. For a PERSON the account segment accepts a sqid or a name,
+// resolved against the caller's OWN memberships — resolution and membership
+// proof are deliberately the same lookup, so an unknown account and someone
+// else's account are the same 404. For a SERVICE it is the credential's own
+// binding, matched as a sqid only. apiPrincipalAccount holds both rules and
+// the reasoning behind them; apiAccountAppBy adds the app half.
func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
return s.apiAccountAppBy(w, r, s.store.HasAppIn)
}
@@ -63,15 +52,25 @@ func (s *Server) apiTrashedApp(w http.ResponseWriter, r *http.Request) (string,
return s.apiAccountAppBy(w, r, s.store.HasTrashedAppIn)
}
-// apiAccountAppBy is the shared body of the two resolvers above: apiAccount
-// resolves the account half, then `has` — the last gate and the only
-// variable — says whether THIS door's subject exists.
+// apiAccountAppBy is the shared body of the two resolvers above: the account
+// half resolves first, then `has` — the last gate and the only variable —
+// says whether THIS door's subject exists.
func (s *Server) apiAccountAppBy(w http.ResponseWriter, r *http.Request, has func(int64, string) (bool, error)) (string, *Account, string, bool) {
- addr, acct, ok := s.apiAccount(w, r)
+ p, acct, ok := s.apiPrincipalAccount(w, r)
if !ok {
return "", nil, "", false
}
app := r.PathValue("app")
+ // An app-scoped service reaches exactly one app. Checked BEFORE the
+ // existence lookup, so that "not your app" and "no such app" stay the
+ // same 404 rather than differing by which branch produced it — and an
+ // empty binding is never a wildcard, which apiPrincipalAccount has
+ // already refused outright for the one credential shape that can carry
+ // one (see serviceBinding).
+ if p.Kind == PrincipalService && p.App != "" && p.App != app {
+ http.NotFound(w, r)
+ return "", nil, "", false
+ }
found, err := has(acct.ID, app)
if err != nil {
log.Printf("console: api has-app %s/%s: %v", acct.Sqid, app, err)
@@ -80,24 +79,118 @@ func (s *Server) apiAccountAppBy(w http.ResponseWriter, r *http.Request, has fun
http.NotFound(w, r)
return "", nil, "", false
}
- return addr, acct, app, true
+ return auditActor(p, acct), acct, app, true
}
-// apiAccount is the account half of apiAccountAppBy: bearer → address, then
-// the {account} segment resolved against the caller's OWN memberships.
-// Split out for the one route that has no app to check — create, where the
-// app is what's being made — so its unknown-account and someone-else's-
-// account refusals stay byte-identical with every other route's.
-func (s *Server) apiAccount(w http.ResponseWriter, r *http.Request) (string, *Account, bool) {
- addr, ok := s.cliUser(w, r)
+// auditActor renders a principal for the audit trail, for the log lines that
+// name a caller, and for the rate-limit key an address used to serve as. A
+// service is deliberately NOT a bare address: a row written by a machine
+// must never read as a row written by the human who minted its token.
+func auditActor(p Principal, acct *Account) string {
+ if p.Kind == PrincipalUser {
+ return p.Address
+ }
+ if p.Name == "" {
+ // A legacy kind='app' row has no name; keep the string the audit
+ // trail has always written for it, so old rows and new rows about
+ // the same credential still read as the same actor.
+ return "app:" + acct.Sqid + "/" + p.App
+ }
+ return "service:" + acct.Sqid + "/" + p.Name
+}
+
+// serviceBinding resolves the account a SERVICE principal is bound to. ok is
+// false for a binding this console cannot honour, and every one of those is
+// the family's 404 at the doors and an empty answer at the one door that
+// enumerates (handleAPIApps) — never a widening.
+//
+// Two of the three refusals guard the same failure mode, which is worth
+// naming because it is the one a collapse of two resolvers into one could
+// introduce: a credential bound to NOTHING must never read as a credential
+// bound to EVERYTHING. An unset account id is one shape of it. The other is
+// a legacy kind='app' row whose app column is empty — for that row the app
+// IS the whole scope, so an empty one is not an account-scoped credential,
+// it is a broken one. apiInstancePrincipal refused exactly this row and so
+// does this. A NAMED service principal, by contrast, may legitimately carry
+// an empty app: account-scoped is a scope its minter chose.
+//
+// The third is an account deleted out from under a live binding, which is a
+// 404 rather than an error for the same reason every other missing account
+// is.
+func (s *Server) serviceBinding(p Principal) (*Account, bool) {
+ if p.AccountID == 0 || (p.Name == "" && p.App == "") {
+ return nil, false
+ }
+ acct, err := s.store.AccountByID(p.AccountID)
+ if err != nil {
+ log.Printf("console: api principal account %d: %v", p.AccountID, err)
+ return nil, false
+ }
+ if acct == nil {
+ return nil, false
+ }
+ return acct, true
+}
+
+// apiPrincipalAccount is the account half of apiAccountAppBy, in its typed
+// form: the principal cliRoute already resolved, and the account the
+// {account} segment names for THAT principal. Callers that need only the
+// audit actor use apiAccount below.
+//
+// A SERVICE's scope is a BINDING, not a membership set, so the segment is
+// matched as a sqid ONLY. It holds no memberships, so there is no set within
+// which to resolve a name; resolving globally and then comparing would be a
+// name-collision oracle for a credential that never needed names. The
+// binding is exact or it is a 404. This is apiInstancePrincipal's rule,
+// moved here rather than deleted, because this is now the only place it is
+// written down.
+//
+// Nothing in the service branch consults AccountsFor. That is the whole
+// point of issue #220: the reach is a property of the credential, fixed at
+// mint, and the memberships of the human who minted it are irrelevant —
+// including the ones that human gains next month.
+//
+// A PERSON's path below is unchanged. Resolution is SYMMETRIC
+// (matchAccounts, resolve.go): the segment matches as a sqid or as a name —
+// case-insensitively — with no precedence between the two spaces, exactly as
+// the browser's /a/{seg} resolves it. A sqid match no longer wins outright
+// over a name match, because precedence is how a reference silently changes
+// meaning the day a colliding account appears: the caller who typed a name
+// would start acting on someone else's tenant without the wording of their
+// command changing at all. Two candidates are refused (409) rather than
+// guessed at.
+//
+// `?exact=sqid` is the transport for the CLI's -account-id flag: it matches
+// the segment as a sqid ONLY, so the reference a person reaches for to
+// settle an ambiguity can never itself be ambiguous. It never widens access
+// — a non-member's probe meets the same 404 either way.
+func (s *Server) apiPrincipalAccount(w http.ResponseWriter, r *http.Request) (Principal, *Account, bool) {
+ p, ok := principalFrom(r.Context())
if !ok {
- return "", nil, false
+ // Only reachable from a door that skipped cliRoute, which
+ // TestEveryCLIDoorNamesItsRoles says cannot happen under
+ // /api/cli/. Refuse rather than fall back to a second resolver: a
+ // silent fallback here would be the one path on which the role gate
+ // does not run.
+ http.NotFound(w, r)
+ return Principal{}, nil, false
}
seg := r.PathValue("account")
+
+ if p.Kind == PrincipalService {
+ acct, ok := s.serviceBinding(p)
+ if !ok || seg != acct.Sqid {
+ http.NotFound(w, r)
+ return Principal{}, nil, false
+ }
+ return p, acct, true
+ }
+
+ addr := p.Address
accts, err := s.store.AccountsFor(addr)
if err != nil {
jsonError(w, http.StatusInternalServerError, "could not load accounts")
- return "", nil, false
+ return Principal{}, nil, false
}
var matches []*Account
if r.URL.Query().Get("exact") == "sqid" {
@@ -126,13 +219,51 @@ func (s *Server) apiAccount(w http.ResponseWriter, r *http.Request) (string, *Ac
}
jsonError(w, http.StatusConflict,
fmt.Sprintf("account %q is ambiguous — pass -account-id with one of: %s", seg, strings.Join(alts, ", ")))
- return "", nil, false
+ return Principal{}, nil, false
}
if acct == nil {
http.NotFound(w, r)
+ return Principal{}, nil, false
+ }
+ return p, acct, true
+}
+
+// apiAccount is apiPrincipalAccount for the callers that need only the audit
+// actor and the account — every existing one, which is why the three-value
+// signature stays exactly as it was. The first return is WHO TO AUDIT AS
+// (auditActor), which for a service is deliberately not a bare address.
+func (s *Server) apiAccount(w http.ResponseWriter, r *http.Request) (string, *Account, bool) {
+ p, acct, ok := s.apiPrincipalAccount(w, r)
+ if !ok {
return "", nil, false
}
- return addr, acct, true
+ return auditActor(p, acct), acct, true
+}
+
+// apiPerson refuses any caller that is not a PERSON, with the byte-identical
+// 401 cliUser has always given — before any account segment is resolved, so
+// the answer stays a constant function of the credential rather than telling
+// a service whether the path it named is its own.
+//
+// It is the inner of two walls at the doors whose subject is a human act:
+// minting a credential (a credential may never mint a credential) and
+// enabling instances (the manifest is standing policy about what may be
+// brought into existence). cliRoute is the outer wall at the mint doors,
+// which are registered with the zero Set and so are closed to every role.
+// The inner wall used to be a side effect of those handlers resolving their
+// caller through cliUser; folding that resolution into the shared
+// principal-aware path removed it, so it is stated here deliberately instead
+// of being inherited by accident.
+//
+// It reads the principal cliRoute already resolved, so it costs no second
+// lookup. Pinned by TestAPIInstanceEnableRefusals and TestEnableStaysAHumanAct.
+func (s *Server) apiPerson(w http.ResponseWriter, r *http.Request) bool {
+ p, ok := principalFrom(r.Context())
+ if !ok || p.Kind != PrincipalUser {
+ s.cliNotSignedIn(w)
+ return false
+ }
+ return true
}
// handleAPICreateApp answers `carlos apps create`: the CLI door onto the
@@ -240,20 +371,47 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
}
}
-// handleAPIApps answers `carlos apps`: every account the token's holder
-// belongs to, and the app names each one has claimed. Scoped to the caller's
-// OWN memberships — there is no "list every account" verb here, on the same
+// handleAPIApps answers `carlos apps`: every account the caller can see, and
+// the app names each one has claimed. Scoped to the caller's OWN
+// memberships — there is no "list every account" verb here, on the same
// principle apiAccountApp enforces one call at a time.
+//
+// This is the ONE door with no {account} segment, and therefore the one door
+// whose ANSWER is scoped rather than whose ACCESS is gated (spec §3a-i). A
+// service principal has no membership set to enumerate: it answers with
+// EXACTLY its own scope — the one account it is bound to, or the single app
+// if it is app-scoped — and never enumerates anything else. Readable by
+// every role, because a credential seeing the thing it is already bound to
+// discloses nothing it does not already hold.
+//
+// The `role` field is the MEMBERSHIP role (owner/member) that AccountsFor's
+// join populates. A service holds no membership, so its account row carries
+// the empty string, which is the right answer and not a gap to fill: v1 adds
+// no field here, because the CLI in the field parses this response and the
+// wire shape stays byte-compatible.
func (s *Server) handleAPIApps(w http.ResponseWriter, r *http.Request) {
- addr, ok := s.cliUser(w, r)
+ p, ok := principalFrom(r.Context())
if !ok {
+ http.NotFound(w, r)
return
}
- accts, err := s.store.AccountsFor(addr)
- if err != nil {
- log.Printf("console: api apps for %s: %v", addr, err)
- jsonError(w, http.StatusInternalServerError, "could not load accounts")
- return
+ var accts []Account
+ if p.Kind == PrincipalService {
+ // A binding this console cannot honour enumerates NOTHING. It is not
+ // an error — the credential's scope is simply empty — and above all
+ // it is not a fall-through to every account, which is the failure
+ // mode serviceBinding exists to refuse.
+ if acct, ok := s.serviceBinding(p); ok {
+ accts = []Account{*acct}
+ }
+ } else {
+ var err error
+ accts, err = s.store.AccountsFor(p.Address)
+ if err != nil {
+ log.Printf("console: api apps for %s: %v", p.Address, err)
+ jsonError(w, http.StatusInternalServerError, "could not load accounts")
+ return
+ }
}
out := make([]map[string]any, 0, len(accts))
for _, a := range accts {
@@ -263,6 +421,18 @@ func (s *Server) handleAPIApps(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusInternalServerError, "could not load apps")
return
}
+ if p.Kind == PrincipalService && p.App != "" {
+ // App-scoped: exactly its own app, and only if that app is still
+ // claimed. Filtering the real list rather than answering with
+ // the binding keeps a deleted app out of the answer.
+ var mine []string
+ for _, app := range apps {
+ if app == p.App {
+ mine = append(mine, app)
+ }
+ }
+ apps = mine
+ }
if apps == nil {
apps = []string{}
}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index ab7202f7e..4aa5a5ed7 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -5,15 +5,22 @@
// reconcile pass (internal/provision) is what turns it into a route, on its
// own box only.
//
-// This is the ONE console family a non-human principal may speak to, which
-// is what makes the gate below the security boundary of the feature. Two
-// shapes of bearer reach it: a person's token, resolved through exactly the
-// same membership-proving lookup every other CLI verb uses (apiAccountApp),
-// or an APP token, which is bound to one account and one app at mint time
-// and must match the path it is used on EXACTLY. Every refusal before that
-// proof is the family's byte-identical 404 — an unknown account, someone
-// else's account, an unclaimed app and an app token probing a foreign path
-// are all one answer, so probing teaches nothing.
+// This was the FIRST console family a non-human principal could speak to,
+// and for a while it carried its own resolver (apiInstancePrincipal) to do
+// it. That resolver is gone: service principals are a platform-wide idea now
+// (svcprincipal.go, spec 2026-08-18), and its rules — the sqid-only match,
+// the exact app binding, the `app:<sqid>/<app>` audit actor — live in the
+// shared path (apiAccountApp) where every family gets them. What is left
+// here is the same gate with the instance BUDGET bound to it.
+//
+// Two shapes of bearer reach it: a person's token, resolved through exactly
+// the same membership-proving lookup every other CLI verb uses, or a service
+// credential bound to one account and — for the legacy app tokens this
+// family minted — one app, which must match the path it is used on EXACTLY.
+// Every refusal before that proof is the family's byte-identical 404 — an
+// unknown account, someone else's account, an unclaimed app and a service
+// credential probing a foreign path are all one answer, so probing teaches
+// nothing.
//
// Spec: docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
// §7 (the verbs), §8 (the credential), §10 (the DNS seam), §11 (security).
@@ -48,61 +55,20 @@ const instanceStoreTimeout = 15 * time.Second
// serving a prefix.
const instanceListMax = 500
-// apiInstancePrincipal authorizes one instance-verb request and returns the
-// principal to audit it under.
-//
-// The app token is tried FIRST, and deliberately: it is one indexed lookup,
-// a user token comes back !ok from it (CLIAppToken answers only kind='app'),
-// and trying it first means an app token never touches apiAccountApp's
-// membership machinery — which it has no memberships to satisfy anyway.
-//
-// An app token's path check is SQID-ONLY. apiAccountApp resolves the
-// account segment as a sqid or a name against the caller's own memberships;
-// an app token holds no memberships, so there is no set to resolve a name
-// within, and inventing one (resolve globally, then compare) would be a
-// name-collision oracle for a credential that never needed names. The
-// binding is exact or it is a 404.
-//
-// The returned principal is the audit actor: the user's address, or
-// "app:<sqid>/<app>" — deliberately not a bare address, so a row written by
-// an app is never read as a row written by the human who minted its token.
-func (s *Server) apiInstancePrincipal(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
- if acctID, boundApp, ok := s.store.CLIAppToken(bearer(r), time.Now()); ok {
- acct, err := s.store.AccountByID(acctID)
- if err != nil {
- log.Printf("console: api instances account %d: %v", acctID, err)
- }
- // Every one of these is the same 404. A binding whose account has
- // since been deleted (acct nil), a binding with no app in it, and a
- // path that simply names something else all refuse identically —
- // and nothing here treats an absent account id or an empty app as
- // a wildcard, which is the failure mode a "bound to nothing means
- // bound to everything" bug would take.
- if err != nil || acct == nil || boundApp == "" ||
- r.PathValue("account") != acct.Sqid || r.PathValue("app") != boundApp {
- http.NotFound(w, r)
- return "", nil, "", false
- }
- return "app:" + acct.Sqid + "/" + boundApp, acct, boundApp, true
- }
- // Not an app token: the ordinary CLI gate, which 401s an unknown bearer
- // and 404s an account or app this identity cannot see — including an
- // app token presented here, since CLITokenAddress refuses those too.
- addr, acct, app, ok := s.apiAccountApp(w, r)
- if !ok {
- return "", nil, "", false
- }
- return addr, acct, app, true
-}
-
// instanceGate is the preamble every instance verb shares, in the order the
// family fixed (handleAPIPromote): principal FIRST, budget second, then the
// deployment's own readiness. The order is load-bearing — a rate limit
// answered before the principal is proven would be a 429 where a 404
// belongs, and "this path is rate limited" is itself the answer that an app
// exists here.
+//
+// The principal proof is apiAccountApp's, which is to say the general one:
+// this family no longer resolves its own callers. The string it returns is
+// the audit actor either way — an address for a person, app:<sqid>/<app> for
+// a legacy app token exactly as before, service:<sqid>/<name> for a named
+// service credential.
func (s *Server) instanceGate(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
- principal, acct, app, ok := s.apiInstancePrincipal(w, r)
+ principal, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
return "", nil, "", false
}
@@ -867,12 +833,23 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request)
}
// handleAPIInstanceToken mints the app-held credential (spec §8). Gated by
-// apiAccountApp DIRECTLY, not by the instance gate: minting is a human act,
-// and an app token cannot mint another — it meets cliUser's 401 here, the
-// same one a fabricated token meets, because CLITokenAddress does not
-// answer for app tokens at all. Budgeted on the address-keyed write
-// limiter for the same reason: the principal a mint belongs to is a person.
+// apiPerson and then apiAccountApp DIRECTLY, not by the instance gate:
+// minting is a human act, and a credential may never mint a credential —
+// including itself. A service principal meets the 401 a fabricated token
+// meets, the same one it met when this door's only resolver was cliUser.
+// Budgeted on the address-keyed write limiter for the same reason: the
+// principal a mint belongs to is a person.
+//
+// That 401 is the INNER of two walls. The outer is cliRoute: this door is
+// registered with the zero Set, so no role — admin included — reaches it at
+// all (TestNoRoleReachesAMintOrFleetDoor, TestAdminIsRefusedAtAClosedDoor).
+// Both are kept, because the inner one used to be a side effect of resolving
+// through cliUser and would otherwise have vanished the day that resolution
+// became principal-aware.
func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request) {
+ if !s.apiPerson(w, r) {
+ return
+ }
addr, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
return
@@ -931,11 +908,28 @@ const (
// under any other domain is refused with a 409, never overwritten: this door
// can hand an account keys only to hostnames that were already hers.
//
-// Gated by apiAccountApp DIRECTLY, not the instance gate: enabling is
-// policy, a human act — an app token meets cliUser's 401 here, exactly as it
-// does at the token mint. Budgeted on the address-keyed write limiter for
-// the same reason.
+// Gated by apiPerson and then apiAccountApp DIRECTLY, not the instance gate:
+// enabling is policy, a human act — a service credential meets the 401 here
+// that it meets at the token mint, and for the same reason. The manifest
+// this door writes is standing policy about what may ever be BROUGHT INTO
+// existence for the app; a credential the app itself holds must not be able
+// to rewrite the bounds it runs inside. Budgeted on the address-keyed write
+// limiter for the same reason.
+//
+// The role table DOES list this door under `instance` (spec §5, and
+// TestInstanceRoleMatchesTodaysAppTokenReach pins it), so the role gate
+// passes an app token through to here. That is not a contradiction: role is
+// the outer wall and person-only is the inner one, and this handler is the
+// only place the inner one can be stated, because it is a fact about the
+// SUBJECT of the door rather than about the caller's role. Before service
+// principals it was a side effect of resolving through cliUser; making the
+// resolution principal-aware turned it into something that has to be said
+// out loud. Pinned by TestAPIInstanceEnableRefusals and
+// TestEnableStaysAHumanAct.
func (s *Server) handleAPIInstanceEnable(w http.ResponseWriter, r *http.Request) {
+ if !s.apiPerson(w, r) {
+ return
+ }
addr, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
return
diff --git a/internal/console/authstore.go b/internal/console/authstore.go
index 0ac7da442..95fd49444 100644
--- a/internal/console/authstore.go
+++ b/internal/console/authstore.go
@@ -493,6 +493,15 @@ VALUES (?, ?, ?, '', ?, 0, ?, ?, ?)`,
// ok is false for unknown, revoked and expired tokens — and for every USER
// token, which is the containment property read from this side: the app gate
// must not accept a person's credential just because it is valid.
+//
+// NO PRODUCTION CALLER since apiInstancePrincipal collapsed into the shared
+// principal path (spec 2026-08-18 §5): CLIPrincipal reads a kind='app' row
+// now, and reads it as a service principal holding role `instance`. This is
+// kept as the direct pin on the LEGACY read path — authstore_test.go
+// exercises expiry, the user-token refusal and the after-reopen read against
+// this function without an HTTP server in the way — so that the row shape
+// those tests describe stays described even as the gate above it moves.
+// Delete it when that coverage moves to CLIPrincipal, not before.
func (s *Store) CLIAppToken(rawToken string, now time.Time) (int64, string, bool) {
if rawToken == "" {
return 0, "", false
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 0812a6d38..289e65332 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -128,9 +128,10 @@ func TestNoRoleReachesAMintOrFleetDoor(t *testing.T) {
//
// 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.
+// else, so every role may ask it. Listing it in `want` below would make that
+// map lie about what LEGACY reach is — an app token could not reach this
+// door before service principals existed, so it is new access granted
+// deliberately, not compatibility being preserved.
var exemptFromLegacyPin = map[string]bool{
"GET /api/cli/apps": true,
}
@@ -489,17 +490,21 @@ func TestRoleRefusalIsConstantAcrossAccounts(t *testing.T) {
}
}
-// 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.
+// TestServiceRoleReachesTheDoorItNames: a credential whose role is on the
+// door gets the ANSWER, not merely a non-403. Both halves of the gate have
+// to work for that — the role gate here, and the scope resolution that finds
+// the credential's own account and app without a membership in sight — so
+// this is the one test that says the two compose into a working caller
+// rather than into two refusals that happen to agree.
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)
+ status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/config", tok)
+ if status != http.StatusOK {
+ t.Fatalf("operate credential on the config door = %d %s, want 200", status, body)
+ }
+ if !strings.Contains(body, `"bundle"`) {
+ t.Errorf("config body = %s, want the door's own answer", body)
}
}
diff --git a/internal/console/svcscope_test.go b/internal/console/svcscope_test.go
new file mode 100644
index 000000000..30c0bd423
--- /dev/null
+++ b/internal/console/svcscope_test.go
@@ -0,0 +1,350 @@
+package console
+
+// The SCOPE half of the gate (spec §3b), the step after the role gate:
+// bearer -> principal (401) -> role (403) -> SCOPE (404) -> budget (429) ->
+// handler.
+//
+// A person's scope is their membership set, resolved per request. A service
+// principal's scope is a BINDING fixed at mint, and the properties below are
+// what "fixed at mint" has to mean in practice: the minter's memberships are
+// never consulted, the account segment matches as a sqid only, an app binding
+// is exact, an empty binding is never a wildcard, and the audit trail can
+// tell the machine from the human who minted its token.
+
+import (
+ "encoding/json"
+ "net/http"
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// theirAccount is the second, foreign account these tests probe at: a real
+// account the SERVICE is not bound to, whose owner is the very address that
+// minted the service credential. That last part is the sharp edge — if
+// anything in the scope path reads a service's `address` column as a
+// membership set, this account is what it would reach.
+func theirAccount(t *testing.T, fx *consoleFixture) *Account {
+ t.Helper()
+ now := time.Now()
+ if err := fx.st.CreateAccount(42, "cdef", "widgets", serviceMinter, now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(42, serviceMinter, RoleOwner, now); err != nil {
+ t.Fatal(err)
+ }
+ acct, err := fx.st.AccountByID(42)
+ if err != nil || acct == nil {
+ t.Fatalf("AccountByID(42) = %v, %v", acct, err)
+ }
+ return acct
+}
+
+// serviceMinter is the address svcfixture_test.go's serviceToken records as
+// the minter of every service credential it mints.
+const serviceMinter = "paul@example.test"
+
+// apiApps reads GET /api/cli/apps as the flat set of "<sqid>/<app>" pairs it
+// discloses, which is the only thing these tests care about — one door, one
+// answer, and whether that answer names anything outside the caller's scope.
+func apiApps(t *testing.T, fx *consoleFixture, bearer string) []string {
+ t.Helper()
+ status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", bearer)
+ if status != http.StatusOK {
+ t.Fatalf("GET /api/cli/apps = %d %s, want 200", status, body)
+ }
+ var out struct {
+ Accounts []struct {
+ Sqid string `json:"sqid"`
+ Apps []string `json:"apps"`
+ } `json:"accounts"`
+ }
+ if err := json.Unmarshal([]byte(body), &out); err != nil {
+ t.Fatalf("apps body %s: %v", body, err)
+ }
+ var pairs []string
+ for _, a := range out.Accounts {
+ for _, app := range a.Apps {
+ pairs = append(pairs, a.Sqid+"/"+app)
+ }
+ }
+ sort.Strings(pairs)
+ return pairs
+}
+
+func sameStrings(got, want []string) bool {
+ if len(got) != len(want) {
+ return false
+ }
+ sort.Strings(want)
+ for i := range got {
+ if got[i] != want[i] {
+ return false
+ }
+ }
+ return true
+}
+
+// TestServiceReachesOnlyItsOwnAccount is consequence 2 of issue #220 closed:
+// the reach is a property of the credential, fixed at mint, not of a person's
+// membership set evaluated per request. The foreign account here is OWNED by
+// the address that minted the credential, so a resolver that fell back to
+// AccountsFor would answer 200 for it.
+func TestServiceReachesOnlyItsOwnAccount(t *testing.T) {
+ fx := newConsoleFixture(t)
+ theirs := theirAccount(t, fx)
+ if _, err := fx.st.CreateAppIn(theirs.ID, "kass", serviceMinter, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+
+ if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/channels", tok); status != http.StatusOK {
+ t.Errorf("own account = %d %s; want 200", status, body)
+ }
+ if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+theirs.Sqid+"/kass/channels", tok); status != http.StatusNotFound {
+ t.Errorf("the minter's OTHER account = %d %s; want 404", status, body)
+ }
+}
+
+// TestServiceScopeIsSqidOnly keeps apiInstancePrincipal's existing rule. A
+// service holds no memberships, so there is no set within which to resolve a
+// NAME; resolving globally and then comparing would be a name-collision
+// oracle for a credential that never needed names.
+//
+// The person's answer for the same segment is asserted alongside it, because
+// the rule is a DIFFERENCE between the two kinds of caller rather than a
+// property of the segment: symmetric name resolution is exactly what a
+// person still gets.
+func TestServiceScopeIsSqidOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+
+ if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/channels", tok); status != http.StatusOK {
+ t.Errorf("service by sqid = %d %s; want 200", status, body)
+ }
+ // The ops account's NAME is "ops" (store.go's backfill).
+ if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/ops/kass/channels", tok); status != http.StatusNotFound {
+ t.Errorf("service by name = %d %s; want 404 (sqid-only)", status, body)
+ }
+ person := cliToken(t, fx, "paul@keymail.dev", "code-scope-sqid-only")
+ if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/ops/kass/channels", person); status != http.StatusOK {
+ t.Errorf("person by name = %d %s; want 200 — a person's resolution is still symmetric", status, body)
+ }
+}
+
+// TestAppScopedServiceCannotLeaveItsApp — the app binding is exact or it is a
+// 404, and the refusal is byte-identical with the one an app that does not
+// exist gets, so "not your app" and "no such app" cannot be told apart.
+func TestAppScopedServiceCannotLeaveItsApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ for _, app := range []string{"one", "two"} {
+ if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ tok := serviceToken(t, fx.st, OpsAccountID, "onlyone", "one", svcrole.Publish)
+ want := canonicalNotFound(t)
+
+ if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/one/channels", tok); status != http.StatusOK {
+ t.Errorf("bound app = %d %s; want 200", status, body)
+ }
+ status, h, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/two/channels", tok, "")
+ assertNotFound(t, "app-scoped service at a sibling app", want, status, h, body)
+ status, h, body = rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/ghost/channels", tok, "")
+ assertNotFound(t, "app-scoped service at an app that does not exist", want, status, h, body)
+}
+
+// TestAppsListForAServiceIsItsOwnScope — spec §3a-i. GET /api/cli/apps has no
+// {account} segment: for a person it is every app in every account they
+// belong to, and a service has no such set. It answers with exactly its own
+// scope and enumerates nothing else.
+func TestAppsListForAServiceIsItsOwnScope(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ theirs := theirAccount(t, fx)
+ for _, app := range []string{"one", "two"} {
+ if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := fx.st.CreateAppIn(theirs.ID, "secret", serviceMinter, now); err != nil {
+ t.Fatal(err)
+ }
+
+ acctTok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+ if got := apiApps(t, fx, acctTok); !sameStrings(got, []string{fx.sqid + "/kass", fx.sqid + "/one", fx.sqid + "/two"}) {
+ t.Errorf("account-scoped service sees %v; want its own account's apps only", got)
+ }
+ appTok := serviceToken(t, fx.st, OpsAccountID, "just-one", "one", svcrole.Publish)
+ if got := apiApps(t, fx, appTok); !sameStrings(got, []string{fx.sqid + "/one"}) {
+ t.Errorf("app-scoped service sees %v; want just its own app", got)
+ }
+ // A legacy kind='app' credential is a service principal too, and reads
+ // the same way here — its scope is its one app.
+ legacy, err := fx.st.NewAppToken(OpsAccountID, "two", serviceMinter, "router", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := apiApps(t, fx, legacy); !sameStrings(got, []string{fx.sqid + "/two"}) {
+ t.Errorf("legacy app token sees %v; want just its own app", got)
+ }
+}
+
+// TestAppsListForAServiceCarriesNoMembershipRole: the `role` field on this
+// response is the MEMBERSHIP role (owner/member), populated by AccountsFor's
+// join. A service holds no membership, so it is empty — the right answer,
+// and the wire shape stays byte-compatible for the CLI that parses it rather
+// than growing a field in v1.
+func TestAppsListForAServiceCarriesNoMembershipRole(t *testing.T) {
+ fx := newConsoleFixture(t)
+ tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+ status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", tok)
+ if status != http.StatusOK {
+ t.Fatalf("apps = %d %s, want 200", status, body)
+ }
+ var out struct {
+ Accounts []map[string]any `json:"accounts"`
+ }
+ if err := json.Unmarshal([]byte(body), &out); err != nil {
+ t.Fatal(err)
+ }
+ if len(out.Accounts) != 1 {
+ t.Fatalf("accounts = %v, want exactly the bound account", out.Accounts)
+ }
+ a := out.Accounts[0]
+ for _, k := range []string{"sqid", "name", "role", "apps"} {
+ if _, ok := a[k]; !ok {
+ t.Errorf("account row %v is missing %q — the wire shape must not change", a, k)
+ }
+ }
+ if a["role"] != "" {
+ t.Errorf("role = %v, want \"\" — a service holds no membership role", a["role"])
+ }
+ if a["sqid"] != fx.sqid {
+ t.Errorf("sqid = %v, want the bound account %q", a["sqid"], fx.sqid)
+ }
+}
+
+// TestServiceAuditActorIsNotABareAddress is property 4: a row written by a
+// machine must never read as a row written by the human who minted its
+// token. A NAMED credential audits as service:<sqid>/<name>; a legacy
+// nameless one keeps writing app:<sqid>/<app> exactly as it always has, so
+// old and new rows about one credential still read as the same actor
+// (pinned from the legacy side by TestAPIInstanceAppTokenContainment).
+func TestServiceAuditActorIsNotABareAddress(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-scope-audit-actor")
+ enableInstances(t, fx, "titogo", titoManifest())
+ tok := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Instance)
+
+ status, _, body := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), tok,
+ `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("service create = %d %s, want 201", status, body)
+ }
+ var rec provision.Record
+ if err := json.Unmarshal([]byte(body), &rec); err != nil {
+ t.Fatalf("create body %s: %v", body, err)
+ }
+ if rec.CreatedBy != "service:bab/hub" {
+ t.Errorf("created_by = %q, want service:bab/hub — never the minter's bare address", rec.CreatedBy)
+ }
+}
+
+// TestLegacyBindingWithNoAppIsNotAWildcard: apiInstancePrincipal refused a
+// kind='app' row whose app column was empty, on the reasoning that a
+// credential bound to nothing must never read as bound to everything. That
+// row is now resolved by the shared path, and the refusal has to survive the
+// move — it is the exact failure mode the collapse could have introduced.
+func TestLegacyBindingWithNoAppIsNotAWildcard(t *testing.T) {
+ fx := newConsoleFixture(t)
+ raw, err := fx.st.NewAppToken(OpsAccountID, "", serviceMinter, "bound to nothing", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := canonicalNotFound(t)
+ status, h, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/domains", raw, "")
+ assertNotFound(t, "legacy binding with no app", want, status, h, body)
+ // And it enumerates nothing either.
+ if got := apiApps(t, fx, raw); len(got) != 0 {
+ t.Errorf("apps for a credential bound to nothing = %v; want nothing", got)
+ }
+}
+
+// TestServiceBoundToAVanishedAccountReachesNothing: the binding names an
+// account id, and an account can be deleted out from under it. Every answer
+// is the family's 404, never a resolver that treats a missing account as
+// "no constraint".
+func TestServiceBoundToAVanishedAccountReachesNothing(t *testing.T) {
+ fx := newConsoleFixture(t)
+ theirs := theirAccount(t, fx)
+ tok := serviceToken(t, fx.st, theirs.ID, "orphan", "", svcrole.Publish)
+ if err := fx.st.DeleteAccount(theirs.ID, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ want := canonicalNotFound(t)
+ for _, seg := range []string{theirs.Sqid, fx.sqid} {
+ status, h, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+seg+"/kass/channels", tok, "")
+ assertNotFound(t, "orphaned credential at "+seg, want, status, h, body)
+ }
+ if got := apiApps(t, fx, tok); len(got) != 0 {
+ t.Errorf("apps for an orphaned credential = %v; want nothing", got)
+ }
+}
+
+// TestEnableStaysAHumanAct: enabling instances is policy — it writes the
+// manifest that says which domain, pool, regions and channel an app may ever
+// be brought up on. The role gate lets an `instance` credential as far as the
+// door (the legacy pin, TestInstanceRoleMatchesTodaysAppTokenReach), and the
+// handler's own person check is what refuses it, with the same 401 a
+// fabricated token meets. Collapsing apiInstancePrincipal removed the wall
+// that used to do this by accident; this pins the deliberate one.
+func TestEnableStaysAHumanAct(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-scope-enable-human")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ named := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Instance)
+ legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", serviceMinter, "router", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range []struct{ what, tok string }{
+ {"named service credential", named},
+ {"legacy app token", legacy},
+ } {
+ if status, _, body := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), c.tok, `{}`); status != http.StatusUnauthorized {
+ t.Errorf("%s enabling instances = %d %s, want 401", c.what, status, body)
+ }
+ }
+}
+
+// TestServiceBindingDoesNotOutliveTheApp pins a deliberate NARROWING of the
+// collapse, and it is the one behaviour change a legacy app token can see.
+//
+// apiInstancePrincipal proved the binding and stopped there: it never asked
+// whether the app was still claimed, so an app token went on writing records
+// for an app that had been trashed. The shared path asks — the same
+// HasAppIn a person's request has always been held to — so the credential
+// now stops where the app does. That is the right answer (a trashed app is
+// not a thing to provision into) and it is a change, so it gets a test
+// rather than a discovery.
+func TestServiceBindingDoesNotOutliveTheApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "doomed", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ tok := serviceToken(t, fx.st, OpsAccountID, "hub", "doomed", svcrole.Instance)
+ u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/doomed/domains"
+ if status, body := getRaw(t, u, tok); status != http.StatusOK {
+ t.Fatalf("bound app while claimed = %d %s, want 200", status, body)
+ }
+ if err := fx.st.TrashAppRow(OpsAccountID, "doomed", now); err != nil {
+ t.Fatal(err)
+ }
+ want := canonicalNotFound(t)
+ status, h, body := rawReq(t, http.MethodGet, u, tok, "")
+ assertNotFound(t, "bound app after it was trashed", want, status, h, body)
+}