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
3import (
4 "encoding/json"
5 "errors"
6 "net/http"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/svcrole"
12)
13
14// mintBody is the create door's request body, spelled once so a test that
15// varies one field says which field it is varying.
16func mintBody(name, role, app string) map[string]string {
17 return map[string]string{"name": name, "role": role, "app": app}
18}
19
20// jsonStrings pulls a []string out of a raw JSON response body. The doors
21// list is the only array this file reads, and reading it through the raw
22// bytes (rather than a decoded map handed down from postJSON) keeps every
23// assertion in this file working from what actually went over the wire.
24func jsonStrings(t *testing.T, body, key string) []string {
25 t.Helper()
26 var out map[string]any
27 if err := json.Unmarshal([]byte(body), &out); err != nil {
28 t.Fatalf("decode %s: %v (body %s)", key, err, body)
29 }
30 raw, ok := out[key].([]any)
31 if !ok {
32 t.Fatalf("%s is not an array in %s", key, body)
33 }
34 got := make([]string, 0, len(raw))
35 for _, v := range raw {
36 s, ok := v.(string)
37 if !ok {
38 t.Fatalf("%s carries a non-string element in %s", key, body)
39 }
40 got = append(got, s)
41 }
42 return got
43}
44
45// jsonField pulls one string field out of a raw JSON response body.
46func jsonField(t *testing.T, body, key string) string {
47 t.Helper()
48 var out map[string]any
49 if err := json.Unmarshal([]byte(body), &out); err != nil {
50 t.Fatalf("decode %s: %v (body %s)", key, err, body)
51 }
52 s, _ := out[key].(string)
53 return s
54}
55
56// jsonBool pulls one boolean field out of a raw JSON response body.
57func jsonBool(t *testing.T, body, key string) bool {
58 t.Helper()
59 var out map[string]any
60 if err := json.Unmarshal([]byte(body), &out); err != nil {
61 t.Fatalf("decode %s: %v (body %s)", key, err, body)
62 }
63 v, ok := out[key].(bool)
64 if !ok {
65 t.Fatalf("%s is not a boolean in %s", key, body)
66 }
67 return v
68}
69
70// TestServiceMintIsOwnerOnly — minting is an authority grant that outlives
71// its minter, so it needs the owner gate, not the ordinary member write
72// door. The two refusals differ deliberately: a member who already proved
73// they hold the account gets 403 (there is nothing left to conceal, and a
74// 404 would send them hunting for a typo in a sqid they typed correctly),
75// while a non-member gets the family's byte-identical 404, because a
76// distinct answer would confirm the account exists.
77func TestServiceMintIsOwnerOnly(t *testing.T) {
78 fx := newConsoleFixture(t)
79 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
80 t.Fatal(err)
81 }
82 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-mint-owner")
83 member := cliToken(t, fx, "alice@keymail.dev", "code-svc-mint-member")
84 stranger := cliToken(t, fx, "eve@keymail.dev", "code-svc-mint-stranger")
85
86 u := fx.ts.URL + "/api/cli/services/" + fx.sqid
87 if code, body := postRaw(t, u, mintBody("hub", "publish", ""), owner); code != http.StatusCreated {
88 t.Errorf("owner mint = %d %s; want 201", code, body)
89 }
90 if code, body := postRaw(t, u, mintBody("other", "publish", ""), member); code != http.StatusForbidden {
91 t.Errorf("member mint = %d %s; want 403", code, body)
92 }
93 strangerCode, strangerBody := postRaw(t, u, mintBody("x", "publish", ""), stranger)
94 if strangerCode != http.StatusNotFound {
95 t.Errorf("non-member mint = %d %s; want 404", strangerCode, strangerBody)
96 }
97 // The same bytes an account that does not exist at all answers with.
98 unknownCode, unknownBody := postRaw(t, fx.ts.URL+"/api/cli/services/zzzz", mintBody("x", "publish", ""), owner)
99 if unknownCode != http.StatusNotFound || unknownBody != strangerBody {
100 t.Errorf("unknown-account mint = %d %q, non-member = %d %q — the 404 is not byte-identical",
101 unknownCode, unknownBody, strangerCode, strangerBody)
102 }
103}
104
105// TestServiceListRotateRevokeAreOwnerOnly: the owner gate belongs on all
106// four doors, not only on the one that hands out a secret. A member who
107// could revoke could take a production path down; a member who could list
108// learns the whole credential inventory.
109func TestServiceListRotateRevokeAreOwnerOnly(t *testing.T) {
110 fx := newConsoleFixture(t)
111 if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
112 t.Fatal(err)
113 }
114 serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
115 member := cliToken(t, fx, "alice@keymail.dev", "code-svc-doors-member")
116 stranger := cliToken(t, fx, "eve@keymail.dev", "code-svc-doors-stranger")
117
118 base := fx.ts.URL + "/api/cli/services/" + fx.sqid
119 if code, body := getRaw(t, base, member); code != http.StatusForbidden {
120 t.Errorf("member list = %d %s; want 403", code, body)
121 }
122 if code, body := getRaw(t, base, stranger); code != http.StatusNotFound {
123 t.Errorf("non-member list = %d %s; want 404", code, body)
124 }
125 for _, verb := range []string{"rotate", "revoke"} {
126 if code, body := postRaw(t, base+"/hub/"+verb, map[string]string{}, member); code != http.StatusForbidden {
127 t.Errorf("member %s = %d %s; want 403", verb, code, body)
128 }
129 if code, body := postRaw(t, base+"/hub/"+verb, map[string]string{}, stranger); code != http.StatusNotFound {
130 t.Errorf("non-member %s = %d %s; want 404", verb, code, body)
131 }
132 }
133 // And the member's refusal must be the same at every door — a 403 that
134 // varied by verb would tell a member which of the four they are nearest
135 // to reaching.
136 _, one := postRaw(t, base+"/hub/rotate", map[string]string{}, member)
137 _, two := postRaw(t, base+"/hub/revoke", map[string]string{}, member)
138 if one != two {
139 t.Errorf("member refusal varies by door:\nrotate: %s\nrevoke: %s", one, two)
140 }
141}
142
143// TestAServiceCannotMintAService is the escalation cut, tested from the wire
144// rather than from the role table: a publish credential and an ADMIN one must
145// both meet the 403 the role gate gives a zero Set.
146func TestAServiceCannotMintAService(t *testing.T) {
147 fx := newConsoleFixture(t)
148 for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Admin} {
149 tok := serviceToken(t, fx.st, OpsAccountID, "svc-"+string(role), "", role)
150 for _, path := range []string{
151 "/api/cli/services/" + fx.sqid,
152 "/api/cli/services/" + fx.sqid + "/other/rotate",
153 "/api/cli/services/" + fx.sqid + "/other/revoke",
154 "/api/cli/apps/" + fx.sqid + "/kass/instance-token",
155 } {
156 code, _, body := rawReq(t, http.MethodPost, fx.ts.URL+path, tok, `{}`)
157 if code != http.StatusForbidden {
158 t.Errorf("role %q at POST %s = %d %s; want 403", role, path, code, body)
159 }
160 }
161 // The listing is a door too: an inventory of every credential in
162 // the account is exactly what a compromised one must not enumerate.
163 if code, body := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, tok); code != http.StatusForbidden {
164 t.Errorf("role %q at GET services = %d %s; want 403", role, code, body)
165 }
166 }
167}
168
169// TestMintReturnsTheSecretExactlyOnce — only the hash is stored, so there is
170// no second copy to hand out. The listing must never carry a secret.
171func TestMintReturnsTheSecretExactlyOnce(t *testing.T) {
172 fx := newConsoleFixture(t)
173 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-once")
174 u := fx.ts.URL + "/api/cli/services/" + fx.sqid
175 code, body := postRaw(t, u, mintBody("hub", "publish", ""), owner)
176 if code != http.StatusCreated {
177 t.Fatalf("mint = %d %s; want 201", code, body)
178 }
179 secret := jsonField(t, body, "token")
180 if secret == "" {
181 t.Fatal("mint returned no token")
182 }
183 // The secret works, which is what makes its absence from the listing
184 // meaningful rather than merely a string not appearing.
185 if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", secret); status != http.StatusOK {
186 t.Fatalf("minted credential at GET apps = %d %s; want 200", status, out)
187 }
188 _, list := getRaw(t, u, owner)
189 if strings.Contains(list, secret) {
190 t.Error("the listing carries the secret")
191 }
192 // The listing DOES carry the sha256, as the listing id (C36). That is
193 // not the secret and cannot be turned back into it: only the preimage
194 // authenticates. It is here because it is the handle revoke accepts, and
195 // the only handle a nameless legacy row has.
196 if !strings.Contains(list, hashToken(secret)) {
197 t.Errorf("the listing does not carry the credential's listing id: %s", list)
198 }
199}
200
201// TestMintRefusesAnUnknownRole keeps the catalogue closed at the door, not
202// just in the type.
203func TestMintRefusesAnUnknownRole(t *testing.T) {
204 fx := newConsoleFixture(t)
205 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-role")
206 u := fx.ts.URL + "/api/cli/services/" + fx.sqid
207 for _, bad := range []string{"", "root", "owner", "Publish", "*"} {
208 if code, body := postRaw(t, u, mintBody("hub", bad, ""), owner); code != http.StatusBadRequest {
209 t.Errorf("role %q = %d %s; want 400", bad, code, body)
210 }
211 }
212}
213
214// TestMintRefusesAnUnusableName: the name is the handle rotate and revoke
215// address the credential by and the string an operator types on a command
216// line, so it takes the same grammar a fleet label does. A name with a path
217// separator or a leading hyphen in it is refused here rather than becoming a
218// row nobody can address cleanly later.
219func TestMintRefusesAnUnusableName(t *testing.T) {
220 fx := newConsoleFixture(t)
221 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-name")
222 u := fx.ts.URL + "/api/cli/services/" + fx.sqid
223 for _, bad := range []string{"", " ", "Hub", "a/b", "a b", "a.b", strings.Repeat("a", 33)} {
224 if code, body := postRaw(t, u, mintBody(bad, "publish", ""), owner); code != http.StatusBadRequest {
225 t.Errorf("name %q = %d %s; want 400", bad, code, body)
226 }
227 }
228 // A name is trimmed before it is judged, the same as a fleet's.
229 if code, body := postRaw(t, u, mintBody(" hub ", "publish", ""), owner); code != http.StatusCreated {
230 t.Errorf("padded name = %d %s; want 201", code, body)
231 }
232}
233
234// TestMintRefusesAnAppThatIsNotThere: an app-scoped credential minted
235// against a name that is not in the account is authority pointed at
236// nothing — and if that app is later created by someone else in the
237// account, authority pointed at something its minter never chose.
238func TestMintRefusesAnAppThatIsNotThere(t *testing.T) {
239 fx := newConsoleFixture(t)
240 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-app")
241 u := fx.ts.URL + "/api/cli/services/" + fx.sqid
242 if code, body := postRaw(t, u, mintBody("router", "instance", "no-such-app"), owner); code != http.StatusNotFound {
243 t.Errorf("mint against a missing app = %d %s; want 404", code, body)
244 }
245 if code, body := postRaw(t, u, mintBody("router", "instance", "kass"), owner); code != http.StatusCreated {
246 t.Errorf("mint against a real app = %d %s; want 201", code, body)
247 }
248}
249
250// TestMintRefusesADuplicateName: within an account a name is the handle, so
251// two live credentials cannot share one. The 409 must come from the
252// sentinel, not from a string match on a driver error, which is what tells
253// it apart from a store that is simply broken.
254func TestMintRefusesADuplicateName(t *testing.T) {
255 fx := newConsoleFixture(t)
256 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-dup")
257 u := fx.ts.URL + "/api/cli/services/" + fx.sqid
258 if code, body := postRaw(t, u, mintBody("hub", "publish", ""), owner); code != http.StatusCreated {
259 t.Fatalf("first mint = %d %s; want 201", code, body)
260 }
261 if code, body := postRaw(t, u, mintBody("hub", "operate", ""), owner); code != http.StatusConflict {
262 t.Errorf("duplicate mint = %d %s; want 409", code, body)
263 }
264}
265
266// TestCreateAnswersWithTheDoorsItGranted — an operator must be able to see
267// what they gave away without reading a spec (AGENTS.md's no-infra rule
268// applied to comprehension, not just access). The list is derived from the
269// same map the gate reads, so it cannot drift from what the gate allows.
270func TestCreateAnswersWithTheDoorsItGranted(t *testing.T) {
271 fx := newConsoleFixture(t)
272 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-doors")
273 _, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, mintBody("hub", "publish", ""), owner)
274 doors := jsonStrings(t, body, "doors")
275 if len(doors) == 0 {
276 t.Fatal("create returned no door list")
277 }
278 for _, d := range doors {
279 if strings.Contains(d, "instance-token") || strings.Contains(d, "/fleets/") || strings.Contains(d, "/api/cli/services/") {
280 t.Errorf("door list names a closed door: %s", d)
281 }
282 }
283 // It is the gate's own answer, not a hand-written list: every pattern it
284 // names must be a door this server actually admits `publish` to, and
285 // every such door must be named.
286 want := map[string]bool{}
287 for pattern, set := range fx.srv.cliRoles {
288 if set.Permits(svcrole.Publish) {
289 want[pattern] = true
290 }
291 }
292 if len(doors) != len(want) {
293 t.Errorf("door list has %d entries, the gate admits publish to %d", len(doors), len(want))
294 }
295 for _, d := range doors {
296 if !want[d] {
297 t.Errorf("door list names %s, which the gate does not admit publish to", d)
298 }
299 }
300 if !sortedStrings(doors) {
301 t.Errorf("door list is not sorted: %v", doors)
302 }
303}
304
305// sortedStrings reports whether a slice is in ascending order — the door
306// list is sorted so two mints of the same role answer identically, rather
307// than in whatever order a map range happened to produce.
308func sortedStrings(s []string) bool {
309 for i := 1; i < len(s); i++ {
310 if s[i-1] > s[i] {
311 return false
312 }
313 }
314 return true
315}
316
317// TestLegacyAppTokensAppearInTheAccountListing — absorbing them must not hide
318// them. A credential nobody can see is one nobody rotates.
319func TestLegacyAppTokensAppearInTheAccountListing(t *testing.T) {
320 fx := newConsoleFixture(t)
321 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-legacy")
322 if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()); err != nil {
323 t.Fatalf("NewAppToken: %v", err)
324 }
325 code, body := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, owner)
326 if code != http.StatusOK {
327 t.Fatalf("list = %d %s; want 200", code, body)
328 }
329 if !strings.Contains(body, `"instance"`) || !strings.Contains(body, "titogo") {
330 t.Errorf("legacy app token missing from the account listing: %s", body)
331 }
332 // The label is the ONLY thing telling two nameless credentials bound to
333 // the same app apart, so an owner deciding which to revoke needs it
334 // (C39) — and the listing id is the only handle either of them has.
335 if !strings.Contains(body, `"router"`) {
336 t.Errorf("legacy app token listed without its label: %s", body)
337 }
338 rows, err := fx.st.ServicePrincipals(OpsAccountID)
339 if err != nil || len(rows) != 1 {
340 t.Fatalf("ServicePrincipals = %d rows, %v", len(rows), err)
341 }
342 if !strings.Contains(body, rows[0].ID) {
343 t.Errorf("legacy app token listed without its listing id: %s", body)
344 }
345}
346
347// TestServiceListRendersTheRotationFacts: the listing exists so an owner can
348// decide what to rotate and what to kill, which takes the scope, the age of
349// the grant, the age of the current secret, when it was last used and who
350// minted it. Asserted as a shape rather than as bytes so the wire may gain
351// fields without this failing.
352func TestServiceListRendersTheRotationFacts(t *testing.T) {
353 fx := newConsoleFixture(t)
354 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-list-shape")
355 tok := serviceToken(t, fx.st, OpsAccountID, "hub", "kass", svcrole.Operate)
356 code, body := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, owner)
357 if code != http.StatusOK {
358 t.Fatalf("list = %d %s; want 200", code, body)
359 }
360 var out struct {
361 Services []struct {
362 ID string `json:"id"`
363 Name string `json:"name"`
364 Label string `json:"label"`
365 Role string `json:"role"`
366 App string `json:"app"`
367 Created string `json:"created"`
368 Rotated string `json:"rotated"`
369 LastUsed string `json:"last_used"`
370 MintedBy string `json:"minted_by"`
371 } `json:"services"`
372 }
373 if err := json.Unmarshal([]byte(body), &out); err != nil {
374 t.Fatalf("decode listing: %v (%s)", err, body)
375 }
376 if len(out.Services) != 1 {
377 t.Fatalf("listing = %d rows, want 1: %s", len(out.Services), body)
378 }
379 got := out.Services[0]
380 if got.Name != "hub" || got.Role != "operate" || got.App != "kass" {
381 t.Errorf("row = %+v; want hub/operate/kass", got)
382 }
383 // The listing id is the credential's sha256 — the handle revoke takes,
384 // and the only one a nameless row will ever have (C36).
385 if got.ID != hashToken(tok) {
386 t.Errorf("id = %q; want the credential's sha256 %q", got.ID, hashToken(tok))
387 }
388 if got.Label != "" {
389 t.Errorf("label = %q; a named credential has a name to be known by", got.Label)
390 }
391 if got.Created == "" || got.Rotated == "" {
392 t.Errorf("row = %+v; created and rotated must both be rendered", got)
393 }
394 if got.MintedBy != "paul@example.test" {
395 t.Errorf("minted_by = %q; want the minter serviceToken recorded", got.MintedBy)
396 }
397 // Never used yet, and an empty string says so rather than the epoch
398 // reading as a real timestamp from 1970.
399 if got.LastUsed != "" {
400 t.Errorf("last_used = %q; want empty for a credential never used", got.LastUsed)
401 }
402}
403
404// TestRotateReplacesTheSecret: rotation is not revocation of the identity —
405// same name, same role, same scope — but the OLD secret must stop working
406// the moment the new one is handed out, or a rotation done because a secret
407// leaked would have changed nothing.
408func TestRotateReplacesTheSecret(t *testing.T) {
409 fx := newConsoleFixture(t)
410 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-rotate")
411 old := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
412 if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", old); status != http.StatusOK {
413 t.Fatalf("the credential does not work before rotation: %d %s", status, body)
414 }
415 code, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid+"/hub/rotate", map[string]string{}, owner)
416 if code != http.StatusOK {
417 t.Fatalf("rotate = %d %s; want 200", code, body)
418 }
419 fresh := jsonField(t, body, "token")
420 if fresh == "" || fresh == old {
421 t.Fatalf("rotate returned %q; want a secret that is not the old one", fresh)
422 }
423 if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", old); status != http.StatusUnauthorized {
424 t.Errorf("the OLD secret still works after rotation: %d %s", status, out)
425 }
426 if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", fresh); status != http.StatusOK {
427 t.Errorf("the fresh secret does not work: %d %s", status, out)
428 }
429 // The identity survives: one row, same name and role.
430 _, list := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, owner)
431 if !strings.Contains(list, `"hub"`) || !strings.Contains(list, `"publish"`) {
432 t.Errorf("after rotation the listing = %s; want the same name and role", list)
433 }
434}
435
436// TestRotateAnUnknownNameIs404: within an account the caller already owns, an
437// unknown name is an ordinary not-found — not a cross-tenant probe, exactly
438// as findFleetBox reasons about a bad box label inside an owned fleet.
439func TestRotateAnUnknownNameIs404(t *testing.T) {
440 fx := newConsoleFixture(t)
441 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-rotate-404")
442 if code, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid+"/nope/rotate",
443 map[string]string{}, owner); code != http.StatusNotFound {
444 t.Errorf("rotate an unknown name = %d %s; want 404", code, body)
445 }
446 // A legacy app token has no name, so an empty-ish name must not reach
447 // it: rotating "the nameless row" would hand out one secret while
448 // invalidating several credentials.
449 if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()); err != nil {
450 t.Fatal(err)
451 }
452 if code, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid+"/%20/rotate",
453 map[string]string{}, owner); code != http.StatusNotFound {
454 t.Errorf("rotate a blank name = %d %s; want 404", code, body)
455 }
456}
457
458// TestRevokeIsIdempotent: revocation must answer the same 200 whether it
459// landed the first time, the second time, or against a name that never
460// existed. The person reaching for it is racing a compromise and must not
461// have to work out whether their first attempt took.
462func TestRevokeIsIdempotent(t *testing.T) {
463 fx := newConsoleFixture(t)
464 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke")
465 tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
466 base := fx.ts.URL + "/api/cli/services/" + fx.sqid
467
468 first, firstBody := postRaw(t, base+"/hub/revoke", map[string]string{}, owner)
469 if first != http.StatusOK {
470 t.Fatalf("revoke = %d %s; want 200", first, firstBody)
471 }
472 if jsonField(t, firstBody, "result") != "revoked" || !jsonBool(t, firstBody, "matched") {
473 t.Errorf("revoking a live credential = %s; want result revoked and matched true", firstBody)
474 }
475 if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusUnauthorized {
476 t.Errorf("a revoked credential still works: %d %s", status, out)
477 }
478 second, secondBody := postRaw(t, base+"/hub/revoke", map[string]string{}, owner)
479 unknown, unknownBody := postRaw(t, base+"/never-existed/revoke", map[string]string{}, owner)
480 // A name that could never have been minted is the same answer again —
481 // refused before it can reach the audit trail, and indistinguishable
482 // from every other revoke from outside.
483 junk, junkBody := postRaw(t, base+"/A%20Name%20Nobody%20Minted/revoke", map[string]string{}, owner)
484 if second != http.StatusOK || unknown != http.StatusOK || junk != http.StatusOK {
485 t.Errorf("re-revoke = %d, unknown revoke = %d, unmintable revoke = %d; want 200 from all three",
486 second, unknown, junk)
487 }
488 // Idempotence is the STATUS and the result: every one of these leaves
489 // the caller with the same fact, that nothing of that handle is live.
490 for what, body := range map[string]string{"second": secondBody, "unknown": unknownBody, "junk": junkBody} {
491 if jsonField(t, body, "result") != "revoked" {
492 t.Errorf("%s revoke = %s; want result revoked", what, body)
493 }
494 // ...and `matched` is the non-authoritative half that says which it
495 // was, so a typo'd name is not a false all-clear (C41).
496 if jsonBool(t, body, "matched") {
497 t.Errorf("%s revoke reports matched true, but nothing was live: %s", what, body)
498 }
499 }
500 // And the row leaves the listing rather than lingering as a tombstone
501 // somebody has to interpret.
502 _, list := getRaw(t, base, owner)
503 if strings.Contains(list, `"hub"`) {
504 t.Errorf("a revoked credential is still listed: %s", list)
505 }
506}
507
508// TestServiceDoorsAuditAgainstTheApp: every mutation writes an audit row
509// naming the human who did it — the credential outlives the session that
510// created it, and "who granted this" is the first question asked of a
511// credential nobody recognises. An APP-SCOPED credential's rows are filed
512// against that app, because the app's activity feed is the one place its
513// owner looks; filing them under the credential's name would hide authority
514// handed out over someone's app from that app's own page (C38).
515func TestServiceDoorsAuditAgainstTheApp(t *testing.T) {
516 fx := newConsoleFixture(t)
517 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-audit")
518 base := fx.ts.URL + "/api/cli/services/" + fx.sqid
519 if code, body := postRaw(t, base, mintBody("hub", "publish", "kass"), owner); code != http.StatusCreated {
520 t.Fatalf("mint = %d %s", code, body)
521 }
522 if code, body := postRaw(t, base+"/hub/rotate", map[string]string{}, owner); code != http.StatusOK {
523 t.Fatalf("rotate = %d %s", code, body)
524 }
525 if code, body := postRaw(t, base+"/hub/revoke", map[string]string{}, owner); code != http.StatusOK {
526 t.Fatalf("revoke = %d %s", code, body)
527 }
528 events, err := fx.st.Events(OpsAccountID, "kass", 50)
529 if err != nil {
530 t.Fatal(err)
531 }
532 want := map[string]bool{"service-create": false, "service-rotate": false, "service-revoke": false}
533 for _, e := range events {
534 if _, ok := want[e.Action]; !ok {
535 continue
536 }
537 want[e.Action] = true
538 if e.Address != "paul@keymail.dev" {
539 t.Errorf("%s audited as %q; want the human who pressed it", e.Action, e.Address)
540 }
541 if !strings.Contains(e.Detail, "hub") {
542 t.Errorf("%s detail = %q; it must name the credential", e.Action, e.Detail)
543 }
544 }
545 for action, seen := range want {
546 if !seen {
547 t.Errorf("no %s audit row in the app's activity", action)
548 }
549 }
550 // And nowhere else: a row filed under the credential's name would be
551 // invisible on the page that matters.
552 stray, err := fx.st.Events(OpsAccountID, "hub", 50)
553 if err != nil {
554 t.Fatal(err)
555 }
556 if len(stray) != 0 {
557 t.Errorf("app-scoped credential filed %d rows under its own name: %+v", len(stray), stray)
558 }
559}
560
561// TestAccountScopedCredentialAuditsUnderItsName: with no app to file
562// against, the subject IS the credential, so it keeps the shape fleets uses.
563// It exercises all three verbs and checks the actor and the detail, the same
564// as its app-scoped sibling — rotate especially, because
565// serviceAuditKey(name, "") on the rotate path is pinned nowhere else.
566func TestAccountScopedCredentialAuditsUnderItsName(t *testing.T) {
567 fx := newConsoleFixture(t)
568 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-audit-acct")
569 base := fx.ts.URL + "/api/cli/services/" + fx.sqid
570 if code, body := postRaw(t, base, mintBody("root", "admin", ""), owner); code != http.StatusCreated {
571 t.Fatalf("mint = %d %s", code, body)
572 }
573 if code, body := postRaw(t, base+"/root/rotate", map[string]string{}, owner); code != http.StatusOK {
574 t.Fatalf("rotate = %d %s", code, body)
575 }
576 if code, body := postRaw(t, base+"/root/revoke", map[string]string{}, owner); code != http.StatusOK {
577 t.Fatalf("revoke = %d %s", code, body)
578 }
579 events, err := fx.st.Events(OpsAccountID, "root", 50)
580 if err != nil {
581 t.Fatal(err)
582 }
583 want := map[string]bool{"service-create": false, "service-rotate": false, "service-revoke": false}
584 for _, e := range events {
585 if _, ok := want[e.Action]; !ok {
586 continue
587 }
588 want[e.Action] = true
589 if e.Address != "paul@keymail.dev" {
590 t.Errorf("%s audited as %q; want the human who pressed it", e.Action, e.Address)
591 }
592 if !strings.Contains(e.Detail, "root") {
593 t.Errorf("%s detail = %q; it must name the credential", e.Action, e.Detail)
594 }
595 }
596 for action, seen := range want {
597 if !seen {
598 t.Errorf("no %s audit row under the credential's name", action)
599 }
600 }
601}
602
603// TestRevokeAcceptsAnUppercaseListingID (C40) is the one that fires when it
604// matters most. hashToken writes lowercase, but an owner racing a compromise
605// as often has the id from PowerShell's Get-FileHash, certutil or a pasted
606// audit extract — all uppercase. Case-strict, that paste matched neither arm
607// and the door answered exactly like a successful revoke, leaving a live
608// admin credential behind a false all-clear.
609func TestRevokeAcceptsAnUppercaseListingID(t *testing.T) {
610 fx := newConsoleFixture(t)
611 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-upper")
612 tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Admin)
613 base := fx.ts.URL + "/api/cli/services/" + fx.sqid
614
615 upper := strings.ToUpper(hashToken(tok))
616 code, body := postRaw(t, base+"/"+upper+"/revoke", map[string]string{}, owner)
617 if code != http.StatusOK {
618 t.Fatalf("revoke by uppercase id = %d %s; want 200", code, body)
619 }
620 if !jsonBool(t, body, "matched") {
621 t.Errorf("revoke by uppercase id = %s; want matched true — the credential was live", body)
622 }
623 if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusUnauthorized {
624 t.Errorf("the credential still works after an uppercase-id revoke: %d %s", status, out)
625 }
626}
627
628// TestRevokeDoesNotFoldACredentialName is the other half of C40, and the
629// reason the folding lives in the id arm alone: a 64-character segment
630// cannot be a name, so folding there can misroute nothing — but folding a
631// NAME would make the segment HUB revoke the credential hub, which is a
632// different credential from the one the caller typed, killed silently.
633func TestRevokeDoesNotFoldACredentialName(t *testing.T) {
634 fx := newConsoleFixture(t)
635 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-case")
636 tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
637 base := fx.ts.URL + "/api/cli/services/" + fx.sqid
638
639 code, body := postRaw(t, base+"/HUB/revoke", map[string]string{}, owner)
640 if code != http.StatusOK {
641 t.Fatalf("revoke HUB = %d %s; want 200", code, body)
642 }
643 if jsonBool(t, body, "matched") {
644 t.Errorf("revoke HUB reports matched true — it must not reach hub: %s", body)
645 }
646 if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusOK {
647 t.Errorf("hub was revoked by a request naming HUB: %d %s", status, out)
648 }
649}
650
651// TestUnusableRevokeHandleIsAudited (C42): an attempted revoke that could
652// not have worked is exactly the event an operator goes looking for
653// afterwards. The early return used to leave no row at all, which
654// contradicted the principle stated at the other audit call in the same
655// handler. Filed under a subject that cannot be an app.
656func TestUnusableRevokeHandleIsAudited(t *testing.T) {
657 fx := newConsoleFixture(t)
658 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-audit")
659 base := fx.ts.URL + "/api/cli/services/" + fx.sqid
660 if code, body := postRaw(t, base+"/Not%20A%20Handle/revoke", map[string]string{}, owner); code != http.StatusOK {
661 t.Fatalf("revoke with an unusable handle = %d %s; want 200", code, body)
662 }
663 events, err := fx.st.Events(OpsAccountID, serviceAuditSubject, 50)
664 if err != nil {
665 t.Fatal(err)
666 }
667 if len(events) != 1 {
668 t.Fatalf("audit rows under %q = %d, want 1: %+v", serviceAuditSubject, len(events), events)
669 }
670 e := events[0]
671 if e.Action != "service-revoke" || e.Address != "paul@keymail.dev" {
672 t.Errorf("row = %+v; want a service-revoke by the human who pressed it", e)
673 }
674 if !strings.Contains(e.Detail, "neither a credential name nor a listing id") {
675 t.Errorf("detail = %q; it must say the handle was unusable", e.Detail)
676 }
677 // The handle itself is unbounded caller-controlled text and must not be
678 // echoed into the trail — the same reason it never reaches the store.
679 if strings.Contains(e.Detail, "Not A Handle") {
680 t.Errorf("detail echoes the caller's handle: %q", e.Detail)
681 }
682}
683
684// TestRevokeAddressesARowByItsListingID (C36): a legacy kind='app' row
685// carries no name — every one of them carries the empty string — so the
686// listing id is the only handle it will ever have. An owner who can see a
687// credential in the listing must be able to kill it from the same door.
688func TestRevokeAddressesARowByItsListingID(t *testing.T) {
689 fx := newConsoleFixture(t)
690 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-id")
691 legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now())
692 if err != nil {
693 t.Fatal(err)
694 }
695 named := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
696 base := fx.ts.URL + "/api/cli/services/" + fx.sqid
697
698 // The id comes off the listing, which is the whole point of carrying it
699 // there: no caller has to compute a hash to use this door.
700 _, list := getRaw(t, base, owner)
701 var out struct {
702 Services []struct {
703 ID string `json:"id"`
704 Name string `json:"name"`
705 App string `json:"app"`
706 } `json:"services"`
707 }
708 if err := json.Unmarshal([]byte(list), &out); err != nil {
709 t.Fatalf("decode listing: %v (%s)", err, list)
710 }
711 ids := map[string]string{}
712 for _, r := range out.Services {
713 key := r.Name
714 if key == "" {
715 key = r.App
716 }
717 ids[key] = r.ID
718 }
719 if ids["titogo"] == "" || ids["hub"] == "" {
720 t.Fatalf("listing did not carry both ids: %s", list)
721 }
722
723 if code, body := postRaw(t, base+"/"+ids["titogo"]+"/revoke", map[string]string{}, owner); code != http.StatusOK {
724 t.Fatalf("revoke a legacy row by id = %d %s; want 200", code, body)
725 }
726 if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", legacy); status != http.StatusUnauthorized {
727 t.Errorf("the legacy credential still works after a revoke by id: %d %s", status, body)
728 }
729 // A NAMED row is addressable both ways, so the ergonomic handle and the
730 // universal one cannot disagree about what they reach.
731 if code, body := postRaw(t, base+"/"+ids["hub"]+"/revoke", map[string]string{}, owner); code != http.StatusOK {
732 t.Fatalf("revoke a named row by id = %d %s; want 200", code, body)
733 }
734 if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", named); status != http.StatusUnauthorized {
735 t.Errorf("the named credential still works after a revoke by id: %d %s", status, body)
736 }
737 if _, after := getRaw(t, base, owner); strings.Contains(after, "titogo") || strings.Contains(after, `"hub"`) {
738 t.Errorf("revoked credentials still listed: %s", after)
739 }
740 // The revoke-by-id answer is the same bytes as every other revoke.
741 _, byID := postRaw(t, base+"/"+ids["hub"]+"/revoke", map[string]string{}, owner)
742 _, byName := postRaw(t, base+"/hub/revoke", map[string]string{}, owner)
743 if byID != byName {
744 t.Errorf("revoke answers differ by handle:\nid: %s\nname: %s", byID, byName)
745 }
746}
747
748// TestRevokeIgnoresAnIdFromAnotherAccount: the listing id is a handle, not
749// an authority. Both store calls are account-scoped, so an id belonging to
750// somebody else's account matches nothing here — and, being idempotent, says
751// nothing about whether it exists.
752func TestRevokeIgnoresAnIdFromAnotherAccount(t *testing.T) {
753 fx := newConsoleFixture(t)
754 owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-foreign")
755 if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", time.Now()); err != nil {
756 t.Fatal(err)
757 }
758 theirs := serviceToken(t, fx.st, 2, "theirs", "", svcrole.Publish)
759 rows, err := fx.st.ServicePrincipals(2)
760 if err != nil || len(rows) != 1 {
761 t.Fatalf("ServicePrincipals(2) = %d rows, %v", len(rows), err)
762 }
763
764 base := fx.ts.URL + "/api/cli/services/" + fx.sqid
765 mine, mineBody := postRaw(t, base+"/never-existed/revoke", map[string]string{}, owner)
766 foreign, foreignBody := postRaw(t, base+"/"+rows[0].ID+"/revoke", map[string]string{}, owner)
767 if mine != http.StatusOK || foreign != http.StatusOK {
768 t.Fatalf("unknown = %d, foreign id = %d; want 200 from both", mine, foreign)
769 }
770 if foreignBody != mineBody {
771 t.Errorf("a foreign id is distinguishable from an unknown name:\nunknown: %s\nforeign: %s",
772 mineBody, foreignBody)
773 }
774 // And it says so: a foreign id must never report a match, or the
775 // non-authoritative signal would be telling the caller they killed
776 // something in an account they do not hold.
777 if jsonBool(t, foreignBody, "matched") {
778 t.Errorf("a foreign id reports matched true: %s", foreignBody)
779 }
780 if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", theirs); status != http.StatusOK {
781 t.Errorf("the other account's credential was revoked through this door: %d %s", status, body)
782 }
783}
784
785// TestDoorsForReadsTheGate: doorsFor is derived from cliRoles, so the answer
786// an operator is given is the answer the gate will give. A closed door
787// appears for no role, and admin sees strictly more than publish.
788func TestDoorsForReadsTheGate(t *testing.T) {
789 s := newConsoleFixture(t).srv
790 publish := s.doorsFor(svcrole.Publish)
791 admin := s.doorsFor(svcrole.Admin)
792 if len(publish) == 0 || len(admin) == 0 {
793 t.Fatalf("doorsFor: publish=%d admin=%d, want both non-empty", len(publish), len(admin))
794 }
795 if len(admin) <= len(publish) {
796 t.Errorf("admin reaches %d doors, publish %d; admin is the union of the catalogue", len(admin), len(publish))
797 }
798 inAdmin := map[string]bool{}
799 for _, d := range admin {
800 inAdmin[d] = true
801 }
802 for _, d := range publish {
803 if !inAdmin[d] {
804 t.Errorf("%s is open to publish but not to admin", d)
805 }
806 }
807 for _, closed := range closedDoors {
808 for _, r := range svcrole.All() {
809 for _, d := range s.doorsFor(r) {
810 if d == closed {
811 t.Errorf("doorsFor(%q) names the closed door %s", r, closed)
812 }
813 }
814 }
815 }
816}
817
818// A Service credential must name one app.
819//
820// apiAccountAppBy binds a principal to its app only when p.App != "", so an
821// account-scoped credential reaches every app in the account. That is
822// tolerable for a role a person mints for their own tooling; it is not for one
823// an APP holds at runtime to spend the deployment's CPU on its own data.
824//
825// The refusal lives in NewServicePrincipal rather than a handler because there
826// are two mint doors — this one and the browser form, which explicitly offers
827// an account-wide credential — and a handler check would leave one open. This
828// test drives the store directly for that reason.
829func TestServiceCredentialCannotBeAccountScoped(t *testing.T) {
830 fx := newConsoleFixture(t)
831 now := time.Now()
832
833 _, err := fx.st.NewServicePrincipal(OpsAccountID, "svc-wide", "", svcrole.Service, "op@keymail.dev", now)
834 if !errors.Is(err, ErrAccountScopedService) {
835 t.Fatalf("account-scoped Service credential = %v, want ErrAccountScopedService", err)
836 }
837
838 // Bound to an app, it mints.
839 if _, err := fx.st.NewServicePrincipal(OpsAccountID, "svc-app", "kass", svcrole.Service, "op@keymail.dev", now); err != nil {
840 t.Fatalf("app-scoped Service credential: %v", err)
841 }
842
843 // Every other role still mints account-wide — this refusal is about
844 // Service alone, not a new rule for the whole vocabulary.
845 for _, r := range []svcrole.Role{svcrole.Publish, svcrole.Instance, svcrole.Operate, svcrole.Admin} {
846 if _, err := fx.st.NewServicePrincipal(OpsAccountID, "svc-"+string(r), "", r, "op@keymail.dev", now); err != nil {
847 t.Fatalf("account-scoped %s credential: %v", r, err)
848 }
849 }
850}
851