carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
14import (
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.
32func 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.
50const 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.
55func 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.
84func 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.
105func 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.
130func 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.
150func 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.
174func 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.
211func 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).
247func 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.
271func 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".
290func 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).
312func 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.
348func 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.
395func 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.
425func 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