| 1 | package console |
| 2 | |
| 3 | // The SCOPE half of the gate (spec §3b), the step after the role gate: |
| 4 | // bearer -> principal (401) -> role (403) -> SCOPE (404) -> budget (429) -> |
| 5 | // handler. |
| 6 | // |
| 7 | // A person's scope is their membership set, resolved per request. A service |
| 8 | // principal's scope is a BINDING fixed at mint, and the properties below are |
| 9 | // what "fixed at mint" has to mean in practice: the minter's memberships are |
| 10 | // never consulted, the account segment matches as a sqid only, an app binding |
| 11 | // is exact, an empty binding is never a wildcard, and the audit trail can |
| 12 | // tell the machine from the human who minted its token. |
| 13 | |
| 14 | import ( |
| 15 | "context" |
| 16 | "encoding/json" |
| 17 | "net/http" |
| 18 | "net/http/httptest" |
| 19 | "sort" |
| 20 | "testing" |
| 21 | "time" |
| 22 | |
| 23 | "github.com/carlosframework/platform/internal/provision" |
| 24 | "github.com/carlosframework/platform/internal/svcrole" |
| 25 | ) |
| 26 | |
| 27 | // theirAccount is the second, foreign account these tests probe at: a real |
| 28 | // account the SERVICE is not bound to, whose owner is the very address that |
| 29 | // minted the service credential. That last part is the sharp edge — if |
| 30 | // anything in the scope path reads a service's `address` column as a |
| 31 | // membership set, this account is what it would reach. |
| 32 | func theirAccount(t *testing.T, fx *consoleFixture) *Account { |
| 33 | t.Helper() |
| 34 | now := time.Now() |
| 35 | if err := fx.st.CreateAccount(42, "cdef", "widgets", serviceMinter, now); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | if err := fx.st.AddMember(42, serviceMinter, RoleOwner, now); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | acct, err := fx.st.AccountByID(42) |
| 42 | if err != nil || acct == nil { |
| 43 | t.Fatalf("AccountByID(42) = %v, %v", acct, err) |
| 44 | } |
| 45 | return acct |
| 46 | } |
| 47 | |
| 48 | // serviceMinter is the address svcfixture_test.go's serviceToken records as |
| 49 | // the minter of every service credential it mints. |
| 50 | const serviceMinter = "paul@example.test" |
| 51 | |
| 52 | // apiApps reads GET /api/cli/apps as the flat set of "<sqid>/<app>" pairs it |
| 53 | // discloses, which is the only thing these tests care about — one door, one |
| 54 | // answer, and whether that answer names anything outside the caller's scope. |
| 55 | func apiApps(t *testing.T, fx *consoleFixture, bearer string) []string { |
| 56 | t.Helper() |
| 57 | status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", bearer) |
| 58 | if status != http.StatusOK { |
| 59 | t.Fatalf("GET /api/cli/apps = %d %s, want 200", status, body) |
| 60 | } |
| 61 | var out struct { |
| 62 | Accounts []struct { |
| 63 | Sqid string `json:"sqid"` |
| 64 | Apps []string `json:"apps"` |
| 65 | } `json:"accounts"` |
| 66 | } |
| 67 | if err := json.Unmarshal([]byte(body), &out); err != nil { |
| 68 | t.Fatalf("apps body %s: %v", body, err) |
| 69 | } |
| 70 | var pairs []string |
| 71 | for _, a := range out.Accounts { |
| 72 | for _, app := range a.Apps { |
| 73 | pairs = append(pairs, a.Sqid+"/"+app) |
| 74 | } |
| 75 | } |
| 76 | sort.Strings(pairs) |
| 77 | return pairs |
| 78 | } |
| 79 | |
| 80 | // sameStrings compares two sets regardless of order. It sorts COPIES of both |
| 81 | // sides: sorting only `want` happened to work while every caller fed it |
| 82 | // apiApps's pre-sorted output, and would have quietly compared an unsorted |
| 83 | // `got` the first time someone passed a raw list. |
| 84 | func sameStrings(got, want []string) bool { |
| 85 | if len(got) != len(want) { |
| 86 | return false |
| 87 | } |
| 88 | a := append([]string(nil), got...) |
| 89 | b := append([]string(nil), want...) |
| 90 | sort.Strings(a) |
| 91 | sort.Strings(b) |
| 92 | for i := range a { |
| 93 | if a[i] != b[i] { |
| 94 | return false |
| 95 | } |
| 96 | } |
| 97 | return true |
| 98 | } |
| 99 | |
| 100 | // TestServiceReachesOnlyItsOwnAccount is consequence 2 of issue #220 closed: |
| 101 | // the reach is a property of the credential, fixed at mint, not of a person's |
| 102 | // membership set evaluated per request. The foreign account here is OWNED by |
| 103 | // the address that minted the credential, so a resolver that fell back to |
| 104 | // AccountsFor would answer 200 for it. |
| 105 | func TestServiceReachesOnlyItsOwnAccount(t *testing.T) { |
| 106 | fx := newConsoleFixture(t) |
| 107 | theirs := theirAccount(t, fx) |
| 108 | if _, err := fx.st.CreateAppIn(theirs.ID, "kass", serviceMinter, time.Now()); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| 112 | |
| 113 | if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/channels", tok); status != http.StatusOK { |
| 114 | t.Errorf("own account = %d %s; want 200", status, body) |
| 115 | } |
| 116 | if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+theirs.Sqid+"/kass/channels", tok); status != http.StatusNotFound { |
| 117 | t.Errorf("the minter's OTHER account = %d %s; want 404", status, body) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // TestServiceScopeIsSqidOnly keeps apiInstancePrincipal's existing rule. A |
| 122 | // service holds no memberships, so there is no set within which to resolve a |
| 123 | // NAME; resolving globally and then comparing would be a name-collision |
| 124 | // oracle for a credential that never needed names. |
| 125 | // |
| 126 | // The person's answer for the same segment is asserted alongside it, because |
| 127 | // the rule is a DIFFERENCE between the two kinds of caller rather than a |
| 128 | // property of the segment: symmetric name resolution is exactly what a |
| 129 | // person still gets. |
| 130 | func TestServiceScopeIsSqidOnly(t *testing.T) { |
| 131 | fx := newConsoleFixture(t) |
| 132 | tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| 133 | |
| 134 | if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/channels", tok); status != http.StatusOK { |
| 135 | t.Errorf("service by sqid = %d %s; want 200", status, body) |
| 136 | } |
| 137 | // The ops account's NAME is "ops" (store.go's backfill). |
| 138 | if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/ops/kass/channels", tok); status != http.StatusNotFound { |
| 139 | t.Errorf("service by name = %d %s; want 404 (sqid-only)", status, body) |
| 140 | } |
| 141 | person := cliToken(t, fx, "paul@keymail.dev", "code-scope-sqid-only") |
| 142 | if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/ops/kass/channels", person); status != http.StatusOK { |
| 143 | t.Errorf("person by name = %d %s; want 200 — a person's resolution is still symmetric", status, body) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | // TestAppScopedServiceCannotLeaveItsApp — the app binding is exact or it is a |
| 148 | // 404, and the refusal is byte-identical with the one an app that does not |
| 149 | // exist gets, so "not your app" and "no such app" cannot be told apart. |
| 150 | func TestAppScopedServiceCannotLeaveItsApp(t *testing.T) { |
| 151 | fx := newConsoleFixture(t) |
| 152 | now := time.Now() |
| 153 | for _, app := range []string{"one", "two"} { |
| 154 | if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", now); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | } |
| 158 | tok := serviceToken(t, fx.st, OpsAccountID, "onlyone", "one", svcrole.Publish) |
| 159 | want := canonicalNotFound(t) |
| 160 | |
| 161 | if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/one/channels", tok); status != http.StatusOK { |
| 162 | t.Errorf("bound app = %d %s; want 200", status, body) |
| 163 | } |
| 164 | status, h, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/two/channels", tok, "") |
| 165 | assertNotFound(t, "app-scoped service at a sibling app", want, status, h, body) |
| 166 | status, h, body = rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/ghost/channels", tok, "") |
| 167 | assertNotFound(t, "app-scoped service at an app that does not exist", want, status, h, body) |
| 168 | } |
| 169 | |
| 170 | // TestAppsListForAServiceIsItsOwnScope — spec §3a-i. GET /api/cli/apps has no |
| 171 | // {account} segment: for a person it is every app in every account they |
| 172 | // belong to, and a service has no such set. It answers with exactly its own |
| 173 | // scope and enumerates nothing else. |
| 174 | func TestAppsListForAServiceIsItsOwnScope(t *testing.T) { |
| 175 | fx := newConsoleFixture(t) |
| 176 | now := time.Now() |
| 177 | theirs := theirAccount(t, fx) |
| 178 | for _, app := range []string{"one", "two"} { |
| 179 | if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", now); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | } |
| 183 | if _, err := fx.st.CreateAppIn(theirs.ID, "secret", serviceMinter, now); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | |
| 187 | acctTok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| 188 | if got := apiApps(t, fx, acctTok); !sameStrings(got, []string{fx.sqid + "/kass", fx.sqid + "/one", fx.sqid + "/two"}) { |
| 189 | t.Errorf("account-scoped service sees %v; want its own account's apps only", got) |
| 190 | } |
| 191 | appTok := serviceToken(t, fx.st, OpsAccountID, "just-one", "one", svcrole.Publish) |
| 192 | if got := apiApps(t, fx, appTok); !sameStrings(got, []string{fx.sqid + "/one"}) { |
| 193 | t.Errorf("app-scoped service sees %v; want just its own app", got) |
| 194 | } |
| 195 | // A legacy kind='app' credential is a service principal too, and reads |
| 196 | // the same way here — its scope is its one app. |
| 197 | legacy, err := fx.st.NewAppToken(OpsAccountID, "two", serviceMinter, "router", now) |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | if got := apiApps(t, fx, legacy); !sameStrings(got, []string{fx.sqid + "/two"}) { |
| 202 | t.Errorf("legacy app token sees %v; want just its own app", got) |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // TestAppsListForAServiceCarriesNoMembershipRole: the `role` field on this |
| 207 | // response is the MEMBERSHIP role (owner/member), populated by AccountsFor's |
| 208 | // join. A service holds no membership, so it is empty — the right answer, |
| 209 | // and the wire shape stays byte-compatible for the CLI that parses it rather |
| 210 | // than growing a field in v1. |
| 211 | func TestAppsListForAServiceCarriesNoMembershipRole(t *testing.T) { |
| 212 | fx := newConsoleFixture(t) |
| 213 | tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| 214 | status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", tok) |
| 215 | if status != http.StatusOK { |
| 216 | t.Fatalf("apps = %d %s, want 200", status, body) |
| 217 | } |
| 218 | var out struct { |
| 219 | Accounts []map[string]any `json:"accounts"` |
| 220 | } |
| 221 | if err := json.Unmarshal([]byte(body), &out); err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | if len(out.Accounts) != 1 { |
| 225 | t.Fatalf("accounts = %v, want exactly the bound account", out.Accounts) |
| 226 | } |
| 227 | a := out.Accounts[0] |
| 228 | for _, k := range []string{"sqid", "name", "role", "apps"} { |
| 229 | if _, ok := a[k]; !ok { |
| 230 | t.Errorf("account row %v is missing %q — the wire shape must not change", a, k) |
| 231 | } |
| 232 | } |
| 233 | if a["role"] != "" { |
| 234 | t.Errorf("role = %v, want \"\" — a service holds no membership role", a["role"]) |
| 235 | } |
| 236 | if a["sqid"] != fx.sqid { |
| 237 | t.Errorf("sqid = %v, want the bound account %q", a["sqid"], fx.sqid) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // TestServiceAuditActorIsNotABareAddress is property 4: a row written by a |
| 242 | // machine must never read as a row written by the human who minted its |
| 243 | // token. A NAMED credential audits as service:<sqid>/<name>; a legacy |
| 244 | // nameless one keeps writing app:<sqid>/<app> exactly as it always has, so |
| 245 | // old and new rows about one credential still read as the same actor |
| 246 | // (pinned from the legacy side by TestAPIInstanceAppTokenContainment). |
| 247 | func TestServiceAuditActorIsNotABareAddress(t *testing.T) { |
| 248 | fx, _ := instanceFixture(t, "code-scope-audit-actor") |
| 249 | enableInstances(t, fx, "titogo", titoManifest()) |
| 250 | tok := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Instance) |
| 251 | |
| 252 | status, _, body := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), tok, |
| 253 | `{"host":"acme.go.tito.io","region":"ie"}`) |
| 254 | if status != http.StatusCreated { |
| 255 | t.Fatalf("service create = %d %s, want 201", status, body) |
| 256 | } |
| 257 | var rec provision.Record |
| 258 | if err := json.Unmarshal([]byte(body), &rec); err != nil { |
| 259 | t.Fatalf("create body %s: %v", body, err) |
| 260 | } |
| 261 | if rec.CreatedBy != "service:bab/hub" { |
| 262 | t.Errorf("created_by = %q, want service:bab/hub — never the minter's bare address", rec.CreatedBy) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | // TestLegacyBindingWithNoAppIsNotAWildcard: apiInstancePrincipal refused a |
| 267 | // kind='app' row whose app column was empty, on the reasoning that a |
| 268 | // credential bound to nothing must never read as bound to everything. That |
| 269 | // row is now resolved by the shared path, and the refusal has to survive the |
| 270 | // move — it is the exact failure mode the collapse could have introduced. |
| 271 | func TestLegacyBindingWithNoAppIsNotAWildcard(t *testing.T) { |
| 272 | fx := newConsoleFixture(t) |
| 273 | raw, err := fx.st.NewAppToken(OpsAccountID, "", serviceMinter, "bound to nothing", time.Now()) |
| 274 | if err != nil { |
| 275 | t.Fatal(err) |
| 276 | } |
| 277 | want := canonicalNotFound(t) |
| 278 | status, h, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/domains", raw, "") |
| 279 | assertNotFound(t, "legacy binding with no app", want, status, h, body) |
| 280 | // And it enumerates nothing either. |
| 281 | if got := apiApps(t, fx, raw); len(got) != 0 { |
| 282 | t.Errorf("apps for a credential bound to nothing = %v; want nothing", got) |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | // TestServiceBoundToAVanishedAccountReachesNothing: the binding names an |
| 287 | // account id, and an account can be deleted out from under it. Every answer |
| 288 | // is the family's 404, never a resolver that treats a missing account as |
| 289 | // "no constraint". |
| 290 | func TestServiceBoundToAVanishedAccountReachesNothing(t *testing.T) { |
| 291 | fx := newConsoleFixture(t) |
| 292 | theirs := theirAccount(t, fx) |
| 293 | tok := serviceToken(t, fx.st, theirs.ID, "orphan", "", svcrole.Publish) |
| 294 | if err := fx.st.DeleteAccount(theirs.ID, time.Now()); err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | want := canonicalNotFound(t) |
| 298 | for _, seg := range []string{theirs.Sqid, fx.sqid} { |
| 299 | status, h, body := rawReq(t, http.MethodGet, fx.ts.URL+"/api/cli/apps/"+seg+"/kass/channels", tok, "") |
| 300 | assertNotFound(t, "orphaned credential at "+seg, want, status, h, body) |
| 301 | } |
| 302 | if got := apiApps(t, fx, tok); len(got) != 0 { |
| 303 | t.Errorf("apps for an orphaned credential = %v; want nothing", got) |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | // TestEnableStaysAHumanAct: enabling instances is policy — it writes the |
| 308 | // manifest that says which domain, pool, regions and channel an app may ever |
| 309 | // be brought up on — so the door is registered with the zero Set and answers |
| 310 | // every service credential, of either kind, with the closed-door 403. This |
| 311 | // is the OUTER and load-bearing wall (ruling C25). |
| 312 | func TestEnableStaysAHumanAct(t *testing.T) { |
| 313 | fx, _ := instanceFixture(t, "code-scope-enable-human") |
| 314 | fx.srv.cfg.AppsDomain = "oncarlos.test" |
| 315 | named := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Instance) |
| 316 | legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", serviceMinter, "router", time.Now()) |
| 317 | if err != nil { |
| 318 | t.Fatal(err) |
| 319 | } |
| 320 | for _, c := range []struct{ what, tok string }{ |
| 321 | {"named service credential", named}, |
| 322 | {"legacy app token", legacy}, |
| 323 | } { |
| 324 | status, _, body := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), c.tok, `{}`) |
| 325 | if status != http.StatusForbidden { |
| 326 | t.Errorf("%s enabling instances = %d %s, want 403", c.what, status, body) |
| 327 | } |
| 328 | } |
| 329 | // A member still enables, which is the point of the door. |
| 330 | member := cliToken(t, fx, "alice@keymail.dev", "code-scope-enable-member") |
| 331 | if status, _, body := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`); status != http.StatusOK { |
| 332 | t.Errorf("member enabling instances = %d %s, want 200", status, body) |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | // TestApiPersonIsTheInnerWall tests apiPerson DIRECTLY, because the two doors |
| 337 | // that use it are both registered with the zero Set and so can never deliver |
| 338 | // a service principal to it through the mux. |
| 339 | // |
| 340 | // That is exactly why it is worth testing rather than deleting. The |
| 341 | // registration is the load-bearing wall; this is the second one, and it |
| 342 | // exists because a handler whose semantics are person-only should not depend |
| 343 | // solely on a line in another file staying right. Before this branch the |
| 344 | // refusal was implicit — apiAccountApp resolved through cliUser, which |
| 345 | // answers for nobody but a person — and collapsing that resolution into the |
| 346 | // principal-aware path destroyed the invariant. apiPerson restores it as |
| 347 | // something stated rather than inherited. |
| 348 | func TestApiPersonIsTheInnerWall(t *testing.T) { |
| 349 | fx := newConsoleFixture(t) |
| 350 | for _, c := range []struct { |
| 351 | what string |
| 352 | p Principal |
| 353 | want bool |
| 354 | }{ |
| 355 | {"a person", Principal{Kind: PrincipalUser, Address: "paul@keymail.dev"}, true}, |
| 356 | {"a named service credential", |
| 357 | Principal{Kind: PrincipalService, Address: serviceMinter, AccountID: OpsAccountID, |
| 358 | Name: "hub", Role: svcrole.Admin}, false}, |
| 359 | {"a legacy app token", |
| 360 | Principal{Kind: PrincipalService, Address: serviceMinter, AccountID: OpsAccountID, |
| 361 | App: "kass", Role: svcrole.Instance}, false}, |
| 362 | } { |
| 363 | r := httptest.NewRequest(http.MethodPut, "/api/cli/apps/bab/kass/instances-manifest", nil) |
| 364 | r = r.WithContext(context.WithValue(r.Context(), cliRouteKey{}, c.p)) |
| 365 | rec := httptest.NewRecorder() |
| 366 | if got := fx.srv.apiPerson(rec, r); got != c.want { |
| 367 | t.Errorf("apiPerson(%s) = %v, want %v", c.what, got, c.want) |
| 368 | } |
| 369 | if c.want { |
| 370 | continue |
| 371 | } |
| 372 | if rec.Code != http.StatusUnauthorized { |
| 373 | t.Errorf("apiPerson(%s) wrote %d, want the 401 cliUser gives", c.what, rec.Code) |
| 374 | } |
| 375 | } |
| 376 | // A request that never went through cliRoute carries no principal, and |
| 377 | // must not read as a zero-valued user principal with no address. |
| 378 | rec := httptest.NewRecorder() |
| 379 | if fx.srv.apiPerson(rec, httptest.NewRequest(http.MethodPut, "/x", nil)) { |
| 380 | t.Error("apiPerson passed a request with no principal on it") |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | // TestServiceBindingDoesNotOutliveTheApp pins a deliberate NARROWING of the |
| 385 | // collapse, and it is the one behaviour change a legacy app token can see. |
| 386 | // |
| 387 | // apiInstancePrincipal proved the binding and stopped there: it never asked |
| 388 | // whether the app was still claimed, so an app token went on writing records |
| 389 | // for an app that had been trashed. The shared path asks — the same |
| 390 | // HasAppIn a person's request has always been held to — so the credential |
| 391 | // now stops where the app does. That is the right answer (a trashed app is |
| 392 | // not a thing to provision into) and it is a change, so it gets a test |
| 393 | // rather than a discovery. It is a NARROWING and it is meant: do not "fix" |
| 394 | // it back by dropping the HasAppIn call for service principals. |
| 395 | func TestServiceBindingDoesNotOutliveTheApp(t *testing.T) { |
| 396 | fx := newConsoleFixture(t) |
| 397 | now := time.Now() |
| 398 | if _, err := fx.st.CreateAppIn(OpsAccountID, "doomed", "paul@keymail.dev", now); err != nil { |
| 399 | t.Fatal(err) |
| 400 | } |
| 401 | tok := serviceToken(t, fx.st, OpsAccountID, "hub", "doomed", svcrole.Instance) |
| 402 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/doomed/domains" |
| 403 | if status, body := getRaw(t, u, tok); status != http.StatusOK { |
| 404 | t.Fatalf("bound app while claimed = %d %s, want 200", status, body) |
| 405 | } |
| 406 | if err := fx.st.TrashAppRow(OpsAccountID, "doomed", now); err != nil { |
| 407 | t.Fatal(err) |
| 408 | } |
| 409 | want := canonicalNotFound(t) |
| 410 | status, h, body := rawReq(t, http.MethodGet, u, tok, "") |
| 411 | assertNotFound(t, "bound app after it was trashed", want, status, h, body) |
| 412 | } |
| 413 | |
| 414 | // TestAppScopedServiceCannotClaimANewApp is C29. The app-binding check lives |
| 415 | // in apiAccountAppBy and fires on the {app} segment — and POST |
| 416 | // /api/cli/apps/{account}/new has none, while being Allow(publish). Without |
| 417 | // a guard at the door itself, a credential minted for ONE app and handed to |
| 418 | // that app's CI could claim any other name in the account: squatting, quota |
| 419 | // and billing surface, and a row the operator never authorised. Pre-branch |
| 420 | // this was impossible, because no app token reached the door at all. |
| 421 | // |
| 422 | // The refusal is the byte-identical 404 every other binding mismatch gives — |
| 423 | // it is a scope refusal, not a role refusal, and the door stays Allow(publish) |
| 424 | // because an ACCOUNT-scoped credential is exactly what it is for. |
| 425 | func TestAppScopedServiceCannotClaimANewApp(t *testing.T) { |
| 426 | fx := newConsoleFixture(t) |
| 427 | if _, err := fx.st.CreateAppIn(OpsAccountID, "one", "paul@keymail.dev", time.Now()); err != nil { |
| 428 | t.Fatal(err) |
| 429 | } |
| 430 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/new" |
| 431 | want := canonicalNotFound(t) |
| 432 | |
| 433 | appTok := serviceToken(t, fx.st, OpsAccountID, "just-one", "one", svcrole.Publish) |
| 434 | status, h, body := rawReq(t, http.MethodPost, u, appTok, `{"name":"two"}`) |
| 435 | assertNotFound(t, "app-scoped credential claiming a new app", want, status, h, body) |
| 436 | |
| 437 | // And it really did not create anything. |
| 438 | apps, err := fx.st.AppsIn(OpsAccountID) |
| 439 | if err != nil { |
| 440 | t.Fatal(err) |
| 441 | } |
| 442 | for _, a := range apps { |
| 443 | if a == "two" { |
| 444 | t.Fatal("an app-scoped credential claimed a second app") |
| 445 | } |
| 446 | } |
| 447 | // A legacy app token never even reaches the guard: it holds role |
| 448 | // `instance` and this door needs `publish`, so role-before-scope refuses |
| 449 | // it out at cliRoute with the constant 403. Asserted here so the two |
| 450 | // walls are not confused for one — if the role table ever put `instance` |
| 451 | // on this door, THIS is the assertion that would move, and the guard |
| 452 | // above is what would then be doing the work. |
| 453 | legacy, err := fx.st.NewAppToken(OpsAccountID, "one", serviceMinter, "router", time.Now()) |
| 454 | if err != nil { |
| 455 | t.Fatal(err) |
| 456 | } |
| 457 | if status, _, body := rawReq(t, http.MethodPost, u, legacy, `{"name":"three"}`); status != http.StatusForbidden { |
| 458 | t.Errorf("legacy app token claiming a new app = %d %s, want the role gate's 403", status, body) |
| 459 | } |
| 460 | |
| 461 | // The ACCOUNT-scoped credential this door is designed for still works: |
| 462 | // the guard is a binding check, not a narrowing of the role. |
| 463 | hub := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| 464 | if status, _, body := rawReq(t, http.MethodPost, u, hub, `{"name":"four"}`); status != http.StatusOK { |
| 465 | t.Fatalf("account-scoped credential creating an app = %d %s, want 200", status, body) |
| 466 | } |
| 467 | if has, err := fx.st.HasAppIn(OpsAccountID, "four"); err != nil || !has { |
| 468 | t.Fatalf("HasAppIn(four) = %v %v, want the hub's app claimed", has, err) |
| 469 | } |
| 470 | } |
| 471 | |