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 ( "context" "encoding/json" "net/http" "net/http/httptest" "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 "/" 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 } // sameStrings compares two sets regardless of order. It sorts COPIES of both // sides: sorting only `want` happened to work while every caller fed it // apiApps's pre-sorted output, and would have quietly compared an unsorted // `got` the first time someone passed a raw list. func sameStrings(got, want []string) bool { if len(got) != len(want) { return false } a := append([]string(nil), got...) b := append([]string(nil), want...) sort.Strings(a) sort.Strings(b) for i := range a { if a[i] != b[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:/; a legacy // nameless one keeps writing 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 — so the door is registered with the zero Set and answers // every service credential, of either kind, with the closed-door 403. This // is the OUTER and load-bearing wall (ruling C25). 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}, } { status, _, body := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), c.tok, `{}`) if status != http.StatusForbidden { t.Errorf("%s enabling instances = %d %s, want 403", c.what, status, body) } } // A member still enables, which is the point of the door. member := cliToken(t, fx, "alice@keymail.dev", "code-scope-enable-member") if status, _, body := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`); status != http.StatusOK { t.Errorf("member enabling instances = %d %s, want 200", status, body) } } // TestApiPersonIsTheInnerWall tests apiPerson DIRECTLY, because the two doors // that use it are both registered with the zero Set and so can never deliver // a service principal to it through the mux. // // That is exactly why it is worth testing rather than deleting. The // registration is the load-bearing wall; this is the second one, and it // exists because a handler whose semantics are person-only should not depend // solely on a line in another file staying right. Before this branch the // refusal was implicit — apiAccountApp resolved through cliUser, which // answers for nobody but a person — and collapsing that resolution into the // principal-aware path destroyed the invariant. apiPerson restores it as // something stated rather than inherited. func TestApiPersonIsTheInnerWall(t *testing.T) { fx := newConsoleFixture(t) for _, c := range []struct { what string p Principal want bool }{ {"a person", Principal{Kind: PrincipalUser, Address: "paul@keymail.dev"}, true}, {"a named service credential", Principal{Kind: PrincipalService, Address: serviceMinter, AccountID: OpsAccountID, Name: "hub", Role: svcrole.Admin}, false}, {"a legacy app token", Principal{Kind: PrincipalService, Address: serviceMinter, AccountID: OpsAccountID, App: "kass", Role: svcrole.Instance}, false}, } { r := httptest.NewRequest(http.MethodPut, "/api/cli/apps/bab/kass/instances-manifest", nil) r = r.WithContext(context.WithValue(r.Context(), cliRouteKey{}, c.p)) rec := httptest.NewRecorder() if got := fx.srv.apiPerson(rec, r); got != c.want { t.Errorf("apiPerson(%s) = %v, want %v", c.what, got, c.want) } if c.want { continue } if rec.Code != http.StatusUnauthorized { t.Errorf("apiPerson(%s) wrote %d, want the 401 cliUser gives", c.what, rec.Code) } } // A request that never went through cliRoute carries no principal, and // must not read as a zero-valued user principal with no address. rec := httptest.NewRecorder() if fx.srv.apiPerson(rec, httptest.NewRequest(http.MethodPut, "/x", nil)) { t.Error("apiPerson passed a request with no principal on it") } } // 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. It is a NARROWING and it is meant: do not "fix" // it back by dropping the HasAppIn call for service principals. 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) } // TestAppScopedServiceCannotClaimANewApp is C29. The app-binding check lives // in apiAccountAppBy and fires on the {app} segment — and POST // /api/cli/apps/{account}/new has none, while being Allow(publish). Without // a guard at the door itself, a credential minted for ONE app and handed to // that app's CI could claim any other name in the account: squatting, quota // and billing surface, and a row the operator never authorised. Pre-branch // this was impossible, because no app token reached the door at all. // // The refusal is the byte-identical 404 every other binding mismatch gives — // it is a scope refusal, not a role refusal, and the door stays Allow(publish) // because an ACCOUNT-scoped credential is exactly what it is for. func TestAppScopedServiceCannotClaimANewApp(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "one", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/new" want := canonicalNotFound(t) appTok := serviceToken(t, fx.st, OpsAccountID, "just-one", "one", svcrole.Publish) status, h, body := rawReq(t, http.MethodPost, u, appTok, `{"name":"two"}`) assertNotFound(t, "app-scoped credential claiming a new app", want, status, h, body) // And it really did not create anything. apps, err := fx.st.AppsIn(OpsAccountID) if err != nil { t.Fatal(err) } for _, a := range apps { if a == "two" { t.Fatal("an app-scoped credential claimed a second app") } } // A legacy app token never even reaches the guard: it holds role // `instance` and this door needs `publish`, so role-before-scope refuses // it out at cliRoute with the constant 403. Asserted here so the two // walls are not confused for one — if the role table ever put `instance` // on this door, THIS is the assertion that would move, and the guard // above is what would then be doing the work. legacy, err := fx.st.NewAppToken(OpsAccountID, "one", serviceMinter, "router", time.Now()) if err != nil { t.Fatal(err) } if status, _, body := rawReq(t, http.MethodPost, u, legacy, `{"name":"three"}`); status != http.StatusForbidden { t.Errorf("legacy app token claiming a new app = %d %s, want the role gate's 403", status, body) } // The ACCOUNT-scoped credential this door is designed for still works: // the guard is a binding check, not a narrowing of the role. hub := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) if status, _, body := rawReq(t, http.MethodPost, u, hub, `{"name":"four"}`); status != http.StatusOK { t.Fatalf("account-scoped credential creating an app = %d %s, want 200", status, body) } if has, err := fx.st.HasAppIn(OpsAccountID, "four"); err != nil || !has { t.Fatalf("HasAppIn(four) = %v %v, want the hub's app claimed", has, err) } }