package console import ( "encoding/json" "errors" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/svcrole" ) // mintBody is the create door's request body, spelled once so a test that // varies one field says which field it is varying. func mintBody(name, role, app string) map[string]string { return map[string]string{"name": name, "role": role, "app": app} } // jsonStrings pulls a []string out of a raw JSON response body. The doors // list is the only array this file reads, and reading it through the raw // bytes (rather than a decoded map handed down from postJSON) keeps every // assertion in this file working from what actually went over the wire. func jsonStrings(t *testing.T, body, key string) []string { t.Helper() var out map[string]any if err := json.Unmarshal([]byte(body), &out); err != nil { t.Fatalf("decode %s: %v (body %s)", key, err, body) } raw, ok := out[key].([]any) if !ok { t.Fatalf("%s is not an array in %s", key, body) } got := make([]string, 0, len(raw)) for _, v := range raw { s, ok := v.(string) if !ok { t.Fatalf("%s carries a non-string element in %s", key, body) } got = append(got, s) } return got } // jsonField pulls one string field out of a raw JSON response body. func jsonField(t *testing.T, body, key string) string { t.Helper() var out map[string]any if err := json.Unmarshal([]byte(body), &out); err != nil { t.Fatalf("decode %s: %v (body %s)", key, err, body) } s, _ := out[key].(string) return s } // jsonBool pulls one boolean field out of a raw JSON response body. func jsonBool(t *testing.T, body, key string) bool { t.Helper() var out map[string]any if err := json.Unmarshal([]byte(body), &out); err != nil { t.Fatalf("decode %s: %v (body %s)", key, err, body) } v, ok := out[key].(bool) if !ok { t.Fatalf("%s is not a boolean in %s", key, body) } return v } // TestServiceMintIsOwnerOnly — minting is an authority grant that outlives // its minter, so it needs the owner gate, not the ordinary member write // door. The two refusals differ deliberately: a member who already proved // they hold the account gets 403 (there is nothing left to conceal, and a // 404 would send them hunting for a typo in a sqid they typed correctly), // while a non-member gets the family's byte-identical 404, because a // distinct answer would confirm the account exists. func TestServiceMintIsOwnerOnly(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-mint-owner") member := cliToken(t, fx, "alice@keymail.dev", "code-svc-mint-member") stranger := cliToken(t, fx, "eve@keymail.dev", "code-svc-mint-stranger") u := fx.ts.URL + "/api/cli/services/" + fx.sqid if code, body := postRaw(t, u, mintBody("hub", "publish", ""), owner); code != http.StatusCreated { t.Errorf("owner mint = %d %s; want 201", code, body) } if code, body := postRaw(t, u, mintBody("other", "publish", ""), member); code != http.StatusForbidden { t.Errorf("member mint = %d %s; want 403", code, body) } strangerCode, strangerBody := postRaw(t, u, mintBody("x", "publish", ""), stranger) if strangerCode != http.StatusNotFound { t.Errorf("non-member mint = %d %s; want 404", strangerCode, strangerBody) } // The same bytes an account that does not exist at all answers with. unknownCode, unknownBody := postRaw(t, fx.ts.URL+"/api/cli/services/zzzz", mintBody("x", "publish", ""), owner) if unknownCode != http.StatusNotFound || unknownBody != strangerBody { t.Errorf("unknown-account mint = %d %q, non-member = %d %q — the 404 is not byte-identical", unknownCode, unknownBody, strangerCode, strangerBody) } } // TestServiceListRotateRevokeAreOwnerOnly: the owner gate belongs on all // four doors, not only on the one that hands out a secret. A member who // could revoke could take a production path down; a member who could list // learns the whole credential inventory. func TestServiceListRotateRevokeAreOwnerOnly(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) member := cliToken(t, fx, "alice@keymail.dev", "code-svc-doors-member") stranger := cliToken(t, fx, "eve@keymail.dev", "code-svc-doors-stranger") base := fx.ts.URL + "/api/cli/services/" + fx.sqid if code, body := getRaw(t, base, member); code != http.StatusForbidden { t.Errorf("member list = %d %s; want 403", code, body) } if code, body := getRaw(t, base, stranger); code != http.StatusNotFound { t.Errorf("non-member list = %d %s; want 404", code, body) } for _, verb := range []string{"rotate", "revoke"} { if code, body := postRaw(t, base+"/hub/"+verb, map[string]string{}, member); code != http.StatusForbidden { t.Errorf("member %s = %d %s; want 403", verb, code, body) } if code, body := postRaw(t, base+"/hub/"+verb, map[string]string{}, stranger); code != http.StatusNotFound { t.Errorf("non-member %s = %d %s; want 404", verb, code, body) } } // And the member's refusal must be the same at every door — a 403 that // varied by verb would tell a member which of the four they are nearest // to reaching. _, one := postRaw(t, base+"/hub/rotate", map[string]string{}, member) _, two := postRaw(t, base+"/hub/revoke", map[string]string{}, member) if one != two { t.Errorf("member refusal varies by door:\nrotate: %s\nrevoke: %s", one, two) } } // TestAServiceCannotMintAService is the escalation cut, tested from the wire // rather than from the role table: a publish credential and an ADMIN one must // both meet the 403 the role gate gives a zero Set. func TestAServiceCannotMintAService(t *testing.T) { fx := newConsoleFixture(t) for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Admin} { tok := serviceToken(t, fx.st, OpsAccountID, "svc-"+string(role), "", role) for _, path := range []string{ "/api/cli/services/" + fx.sqid, "/api/cli/services/" + fx.sqid + "/other/rotate", "/api/cli/services/" + fx.sqid + "/other/revoke", "/api/cli/apps/" + fx.sqid + "/kass/instance-token", } { code, _, body := rawReq(t, http.MethodPost, fx.ts.URL+path, tok, `{}`) if code != http.StatusForbidden { t.Errorf("role %q at POST %s = %d %s; want 403", role, path, code, body) } } // The listing is a door too: an inventory of every credential in // the account is exactly what a compromised one must not enumerate. if code, body := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, tok); code != http.StatusForbidden { t.Errorf("role %q at GET services = %d %s; want 403", role, code, body) } } } // TestMintReturnsTheSecretExactlyOnce — only the hash is stored, so there is // no second copy to hand out. The listing must never carry a secret. func TestMintReturnsTheSecretExactlyOnce(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-once") u := fx.ts.URL + "/api/cli/services/" + fx.sqid code, body := postRaw(t, u, mintBody("hub", "publish", ""), owner) if code != http.StatusCreated { t.Fatalf("mint = %d %s; want 201", code, body) } secret := jsonField(t, body, "token") if secret == "" { t.Fatal("mint returned no token") } // The secret works, which is what makes its absence from the listing // meaningful rather than merely a string not appearing. if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", secret); status != http.StatusOK { t.Fatalf("minted credential at GET apps = %d %s; want 200", status, out) } _, list := getRaw(t, u, owner) if strings.Contains(list, secret) { t.Error("the listing carries the secret") } // The listing DOES carry the sha256, as the listing id (C36). That is // not the secret and cannot be turned back into it: only the preimage // authenticates. It is here because it is the handle revoke accepts, and // the only handle a nameless legacy row has. if !strings.Contains(list, hashToken(secret)) { t.Errorf("the listing does not carry the credential's listing id: %s", list) } } // TestMintRefusesAnUnknownRole keeps the catalogue closed at the door, not // just in the type. func TestMintRefusesAnUnknownRole(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-role") u := fx.ts.URL + "/api/cli/services/" + fx.sqid for _, bad := range []string{"", "root", "owner", "Publish", "*"} { if code, body := postRaw(t, u, mintBody("hub", bad, ""), owner); code != http.StatusBadRequest { t.Errorf("role %q = %d %s; want 400", bad, code, body) } } } // TestMintRefusesAnUnusableName: the name is the handle rotate and revoke // address the credential by and the string an operator types on a command // line, so it takes the same grammar a fleet label does. A name with a path // separator or a leading hyphen in it is refused here rather than becoming a // row nobody can address cleanly later. func TestMintRefusesAnUnusableName(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-name") u := fx.ts.URL + "/api/cli/services/" + fx.sqid for _, bad := range []string{"", " ", "Hub", "a/b", "a b", "a.b", strings.Repeat("a", 33)} { if code, body := postRaw(t, u, mintBody(bad, "publish", ""), owner); code != http.StatusBadRequest { t.Errorf("name %q = %d %s; want 400", bad, code, body) } } // A name is trimmed before it is judged, the same as a fleet's. if code, body := postRaw(t, u, mintBody(" hub ", "publish", ""), owner); code != http.StatusCreated { t.Errorf("padded name = %d %s; want 201", code, body) } } // TestMintRefusesAnAppThatIsNotThere: an app-scoped credential minted // against a name that is not in the account is authority pointed at // nothing — and if that app is later created by someone else in the // account, authority pointed at something its minter never chose. func TestMintRefusesAnAppThatIsNotThere(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-app") u := fx.ts.URL + "/api/cli/services/" + fx.sqid if code, body := postRaw(t, u, mintBody("router", "instance", "no-such-app"), owner); code != http.StatusNotFound { t.Errorf("mint against a missing app = %d %s; want 404", code, body) } if code, body := postRaw(t, u, mintBody("router", "instance", "kass"), owner); code != http.StatusCreated { t.Errorf("mint against a real app = %d %s; want 201", code, body) } } // TestMintRefusesADuplicateName: within an account a name is the handle, so // two live credentials cannot share one. The 409 must come from the // sentinel, not from a string match on a driver error, which is what tells // it apart from a store that is simply broken. func TestMintRefusesADuplicateName(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-dup") u := fx.ts.URL + "/api/cli/services/" + fx.sqid if code, body := postRaw(t, u, mintBody("hub", "publish", ""), owner); code != http.StatusCreated { t.Fatalf("first mint = %d %s; want 201", code, body) } if code, body := postRaw(t, u, mintBody("hub", "operate", ""), owner); code != http.StatusConflict { t.Errorf("duplicate mint = %d %s; want 409", code, body) } } // TestCreateAnswersWithTheDoorsItGranted — an operator must be able to see // what they gave away without reading a spec (AGENTS.md's no-infra rule // applied to comprehension, not just access). The list is derived from the // same map the gate reads, so it cannot drift from what the gate allows. func TestCreateAnswersWithTheDoorsItGranted(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-doors") _, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, mintBody("hub", "publish", ""), owner) doors := jsonStrings(t, body, "doors") if len(doors) == 0 { t.Fatal("create returned no door list") } for _, d := range doors { if strings.Contains(d, "instance-token") || strings.Contains(d, "/fleets/") || strings.Contains(d, "/api/cli/services/") { t.Errorf("door list names a closed door: %s", d) } } // It is the gate's own answer, not a hand-written list: every pattern it // names must be a door this server actually admits `publish` to, and // every such door must be named. want := map[string]bool{} for pattern, set := range fx.srv.cliRoles { if set.Permits(svcrole.Publish) { want[pattern] = true } } if len(doors) != len(want) { t.Errorf("door list has %d entries, the gate admits publish to %d", len(doors), len(want)) } for _, d := range doors { if !want[d] { t.Errorf("door list names %s, which the gate does not admit publish to", d) } } if !sortedStrings(doors) { t.Errorf("door list is not sorted: %v", doors) } } // sortedStrings reports whether a slice is in ascending order — the door // list is sorted so two mints of the same role answer identically, rather // than in whatever order a map range happened to produce. func sortedStrings(s []string) bool { for i := 1; i < len(s); i++ { if s[i-1] > s[i] { return false } } return true } // TestLegacyAppTokensAppearInTheAccountListing — absorbing them must not hide // them. A credential nobody can see is one nobody rotates. func TestLegacyAppTokensAppearInTheAccountListing(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-legacy") if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()); err != nil { t.Fatalf("NewAppToken: %v", err) } code, body := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, owner) if code != http.StatusOK { t.Fatalf("list = %d %s; want 200", code, body) } if !strings.Contains(body, `"instance"`) || !strings.Contains(body, "titogo") { t.Errorf("legacy app token missing from the account listing: %s", body) } // The label is the ONLY thing telling two nameless credentials bound to // the same app apart, so an owner deciding which to revoke needs it // (C39) — and the listing id is the only handle either of them has. if !strings.Contains(body, `"router"`) { t.Errorf("legacy app token listed without its label: %s", body) } rows, err := fx.st.ServicePrincipals(OpsAccountID) if err != nil || len(rows) != 1 { t.Fatalf("ServicePrincipals = %d rows, %v", len(rows), err) } if !strings.Contains(body, rows[0].ID) { t.Errorf("legacy app token listed without its listing id: %s", body) } } // TestServiceListRendersTheRotationFacts: the listing exists so an owner can // decide what to rotate and what to kill, which takes the scope, the age of // the grant, the age of the current secret, when it was last used and who // minted it. Asserted as a shape rather than as bytes so the wire may gain // fields without this failing. func TestServiceListRendersTheRotationFacts(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-list-shape") tok := serviceToken(t, fx.st, OpsAccountID, "hub", "kass", svcrole.Operate) code, body := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, owner) if code != http.StatusOK { t.Fatalf("list = %d %s; want 200", code, body) } var out struct { Services []struct { ID string `json:"id"` Name string `json:"name"` Label string `json:"label"` Role string `json:"role"` App string `json:"app"` Created string `json:"created"` Rotated string `json:"rotated"` LastUsed string `json:"last_used"` MintedBy string `json:"minted_by"` } `json:"services"` } if err := json.Unmarshal([]byte(body), &out); err != nil { t.Fatalf("decode listing: %v (%s)", err, body) } if len(out.Services) != 1 { t.Fatalf("listing = %d rows, want 1: %s", len(out.Services), body) } got := out.Services[0] if got.Name != "hub" || got.Role != "operate" || got.App != "kass" { t.Errorf("row = %+v; want hub/operate/kass", got) } // The listing id is the credential's sha256 — the handle revoke takes, // and the only one a nameless row will ever have (C36). if got.ID != hashToken(tok) { t.Errorf("id = %q; want the credential's sha256 %q", got.ID, hashToken(tok)) } if got.Label != "" { t.Errorf("label = %q; a named credential has a name to be known by", got.Label) } if got.Created == "" || got.Rotated == "" { t.Errorf("row = %+v; created and rotated must both be rendered", got) } if got.MintedBy != "paul@example.test" { t.Errorf("minted_by = %q; want the minter serviceToken recorded", got.MintedBy) } // Never used yet, and an empty string says so rather than the epoch // reading as a real timestamp from 1970. if got.LastUsed != "" { t.Errorf("last_used = %q; want empty for a credential never used", got.LastUsed) } } // TestRotateReplacesTheSecret: rotation is not revocation of the identity — // same name, same role, same scope — but the OLD secret must stop working // the moment the new one is handed out, or a rotation done because a secret // leaked would have changed nothing. func TestRotateReplacesTheSecret(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-rotate") old := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", old); status != http.StatusOK { t.Fatalf("the credential does not work before rotation: %d %s", status, body) } code, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid+"/hub/rotate", map[string]string{}, owner) if code != http.StatusOK { t.Fatalf("rotate = %d %s; want 200", code, body) } fresh := jsonField(t, body, "token") if fresh == "" || fresh == old { t.Fatalf("rotate returned %q; want a secret that is not the old one", fresh) } if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", old); status != http.StatusUnauthorized { t.Errorf("the OLD secret still works after rotation: %d %s", status, out) } if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", fresh); status != http.StatusOK { t.Errorf("the fresh secret does not work: %d %s", status, out) } // The identity survives: one row, same name and role. _, list := getRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid, owner) if !strings.Contains(list, `"hub"`) || !strings.Contains(list, `"publish"`) { t.Errorf("after rotation the listing = %s; want the same name and role", list) } } // TestRotateAnUnknownNameIs404: within an account the caller already owns, an // unknown name is an ordinary not-found — not a cross-tenant probe, exactly // as findFleetBox reasons about a bad box label inside an owned fleet. func TestRotateAnUnknownNameIs404(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-rotate-404") if code, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid+"/nope/rotate", map[string]string{}, owner); code != http.StatusNotFound { t.Errorf("rotate an unknown name = %d %s; want 404", code, body) } // A legacy app token has no name, so an empty-ish name must not reach // it: rotating "the nameless row" would hand out one secret while // invalidating several credentials. if _, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()); err != nil { t.Fatal(err) } if code, body := postRaw(t, fx.ts.URL+"/api/cli/services/"+fx.sqid+"/%20/rotate", map[string]string{}, owner); code != http.StatusNotFound { t.Errorf("rotate a blank name = %d %s; want 404", code, body) } } // TestRevokeIsIdempotent: revocation must answer the same 200 whether it // landed the first time, the second time, or against a name that never // existed. The person reaching for it is racing a compromise and must not // have to work out whether their first attempt took. func TestRevokeIsIdempotent(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke") tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) base := fx.ts.URL + "/api/cli/services/" + fx.sqid first, firstBody := postRaw(t, base+"/hub/revoke", map[string]string{}, owner) if first != http.StatusOK { t.Fatalf("revoke = %d %s; want 200", first, firstBody) } if jsonField(t, firstBody, "result") != "revoked" || !jsonBool(t, firstBody, "matched") { t.Errorf("revoking a live credential = %s; want result revoked and matched true", firstBody) } if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusUnauthorized { t.Errorf("a revoked credential still works: %d %s", status, out) } second, secondBody := postRaw(t, base+"/hub/revoke", map[string]string{}, owner) unknown, unknownBody := postRaw(t, base+"/never-existed/revoke", map[string]string{}, owner) // A name that could never have been minted is the same answer again — // refused before it can reach the audit trail, and indistinguishable // from every other revoke from outside. junk, junkBody := postRaw(t, base+"/A%20Name%20Nobody%20Minted/revoke", map[string]string{}, owner) if second != http.StatusOK || unknown != http.StatusOK || junk != http.StatusOK { t.Errorf("re-revoke = %d, unknown revoke = %d, unmintable revoke = %d; want 200 from all three", second, unknown, junk) } // Idempotence is the STATUS and the result: every one of these leaves // the caller with the same fact, that nothing of that handle is live. for what, body := range map[string]string{"second": secondBody, "unknown": unknownBody, "junk": junkBody} { if jsonField(t, body, "result") != "revoked" { t.Errorf("%s revoke = %s; want result revoked", what, body) } // ...and `matched` is the non-authoritative half that says which it // was, so a typo'd name is not a false all-clear (C41). if jsonBool(t, body, "matched") { t.Errorf("%s revoke reports matched true, but nothing was live: %s", what, body) } } // And the row leaves the listing rather than lingering as a tombstone // somebody has to interpret. _, list := getRaw(t, base, owner) if strings.Contains(list, `"hub"`) { t.Errorf("a revoked credential is still listed: %s", list) } } // TestServiceDoorsAuditAgainstTheApp: every mutation writes an audit row // naming the human who did it — the credential outlives the session that // created it, and "who granted this" is the first question asked of a // credential nobody recognises. An APP-SCOPED credential's rows are filed // against that app, because the app's activity feed is the one place its // owner looks; filing them under the credential's name would hide authority // handed out over someone's app from that app's own page (C38). func TestServiceDoorsAuditAgainstTheApp(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-audit") base := fx.ts.URL + "/api/cli/services/" + fx.sqid if code, body := postRaw(t, base, mintBody("hub", "publish", "kass"), owner); code != http.StatusCreated { t.Fatalf("mint = %d %s", code, body) } if code, body := postRaw(t, base+"/hub/rotate", map[string]string{}, owner); code != http.StatusOK { t.Fatalf("rotate = %d %s", code, body) } if code, body := postRaw(t, base+"/hub/revoke", map[string]string{}, owner); code != http.StatusOK { t.Fatalf("revoke = %d %s", code, body) } events, err := fx.st.Events(OpsAccountID, "kass", 50) if err != nil { t.Fatal(err) } want := map[string]bool{"service-create": false, "service-rotate": false, "service-revoke": false} for _, e := range events { if _, ok := want[e.Action]; !ok { continue } want[e.Action] = true if e.Address != "paul@keymail.dev" { t.Errorf("%s audited as %q; want the human who pressed it", e.Action, e.Address) } if !strings.Contains(e.Detail, "hub") { t.Errorf("%s detail = %q; it must name the credential", e.Action, e.Detail) } } for action, seen := range want { if !seen { t.Errorf("no %s audit row in the app's activity", action) } } // And nowhere else: a row filed under the credential's name would be // invisible on the page that matters. stray, err := fx.st.Events(OpsAccountID, "hub", 50) if err != nil { t.Fatal(err) } if len(stray) != 0 { t.Errorf("app-scoped credential filed %d rows under its own name: %+v", len(stray), stray) } } // TestAccountScopedCredentialAuditsUnderItsName: with no app to file // against, the subject IS the credential, so it keeps the shape fleets uses. // It exercises all three verbs and checks the actor and the detail, the same // as its app-scoped sibling — rotate especially, because // serviceAuditKey(name, "") on the rotate path is pinned nowhere else. func TestAccountScopedCredentialAuditsUnderItsName(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-audit-acct") base := fx.ts.URL + "/api/cli/services/" + fx.sqid if code, body := postRaw(t, base, mintBody("root", "admin", ""), owner); code != http.StatusCreated { t.Fatalf("mint = %d %s", code, body) } if code, body := postRaw(t, base+"/root/rotate", map[string]string{}, owner); code != http.StatusOK { t.Fatalf("rotate = %d %s", code, body) } if code, body := postRaw(t, base+"/root/revoke", map[string]string{}, owner); code != http.StatusOK { t.Fatalf("revoke = %d %s", code, body) } events, err := fx.st.Events(OpsAccountID, "root", 50) if err != nil { t.Fatal(err) } want := map[string]bool{"service-create": false, "service-rotate": false, "service-revoke": false} for _, e := range events { if _, ok := want[e.Action]; !ok { continue } want[e.Action] = true if e.Address != "paul@keymail.dev" { t.Errorf("%s audited as %q; want the human who pressed it", e.Action, e.Address) } if !strings.Contains(e.Detail, "root") { t.Errorf("%s detail = %q; it must name the credential", e.Action, e.Detail) } } for action, seen := range want { if !seen { t.Errorf("no %s audit row under the credential's name", action) } } } // TestRevokeAcceptsAnUppercaseListingID (C40) is the one that fires when it // matters most. hashToken writes lowercase, but an owner racing a compromise // as often has the id from PowerShell's Get-FileHash, certutil or a pasted // audit extract — all uppercase. Case-strict, that paste matched neither arm // and the door answered exactly like a successful revoke, leaving a live // admin credential behind a false all-clear. func TestRevokeAcceptsAnUppercaseListingID(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-upper") tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Admin) base := fx.ts.URL + "/api/cli/services/" + fx.sqid upper := strings.ToUpper(hashToken(tok)) code, body := postRaw(t, base+"/"+upper+"/revoke", map[string]string{}, owner) if code != http.StatusOK { t.Fatalf("revoke by uppercase id = %d %s; want 200", code, body) } if !jsonBool(t, body, "matched") { t.Errorf("revoke by uppercase id = %s; want matched true — the credential was live", body) } if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusUnauthorized { t.Errorf("the credential still works after an uppercase-id revoke: %d %s", status, out) } } // TestRevokeDoesNotFoldACredentialName is the other half of C40, and the // reason the folding lives in the id arm alone: a 64-character segment // cannot be a name, so folding there can misroute nothing — but folding a // NAME would make the segment HUB revoke the credential hub, which is a // different credential from the one the caller typed, killed silently. func TestRevokeDoesNotFoldACredentialName(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-case") tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) base := fx.ts.URL + "/api/cli/services/" + fx.sqid code, body := postRaw(t, base+"/HUB/revoke", map[string]string{}, owner) if code != http.StatusOK { t.Fatalf("revoke HUB = %d %s; want 200", code, body) } if jsonBool(t, body, "matched") { t.Errorf("revoke HUB reports matched true — it must not reach hub: %s", body) } if status, out := getRaw(t, fx.ts.URL+"/api/cli/apps", tok); status != http.StatusOK { t.Errorf("hub was revoked by a request naming HUB: %d %s", status, out) } } // TestUnusableRevokeHandleIsAudited (C42): an attempted revoke that could // not have worked is exactly the event an operator goes looking for // afterwards. The early return used to leave no row at all, which // contradicted the principle stated at the other audit call in the same // handler. Filed under a subject that cannot be an app. func TestUnusableRevokeHandleIsAudited(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-audit") base := fx.ts.URL + "/api/cli/services/" + fx.sqid if code, body := postRaw(t, base+"/Not%20A%20Handle/revoke", map[string]string{}, owner); code != http.StatusOK { t.Fatalf("revoke with an unusable handle = %d %s; want 200", code, body) } events, err := fx.st.Events(OpsAccountID, serviceAuditSubject, 50) if err != nil { t.Fatal(err) } if len(events) != 1 { t.Fatalf("audit rows under %q = %d, want 1: %+v", serviceAuditSubject, len(events), events) } e := events[0] if e.Action != "service-revoke" || e.Address != "paul@keymail.dev" { t.Errorf("row = %+v; want a service-revoke by the human who pressed it", e) } if !strings.Contains(e.Detail, "neither a credential name nor a listing id") { t.Errorf("detail = %q; it must say the handle was unusable", e.Detail) } // The handle itself is unbounded caller-controlled text and must not be // echoed into the trail — the same reason it never reaches the store. if strings.Contains(e.Detail, "Not A Handle") { t.Errorf("detail echoes the caller's handle: %q", e.Detail) } } // TestRevokeAddressesARowByItsListingID (C36): a legacy kind='app' row // carries no name — every one of them carries the empty string — so the // listing id is the only handle it will ever have. An owner who can see a // credential in the listing must be able to kill it from the same door. func TestRevokeAddressesARowByItsListingID(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-id") legacy, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router", time.Now()) if err != nil { t.Fatal(err) } named := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) base := fx.ts.URL + "/api/cli/services/" + fx.sqid // The id comes off the listing, which is the whole point of carrying it // there: no caller has to compute a hash to use this door. _, list := getRaw(t, base, owner) var out struct { Services []struct { ID string `json:"id"` Name string `json:"name"` App string `json:"app"` } `json:"services"` } if err := json.Unmarshal([]byte(list), &out); err != nil { t.Fatalf("decode listing: %v (%s)", err, list) } ids := map[string]string{} for _, r := range out.Services { key := r.Name if key == "" { key = r.App } ids[key] = r.ID } if ids["titogo"] == "" || ids["hub"] == "" { t.Fatalf("listing did not carry both ids: %s", list) } if code, body := postRaw(t, base+"/"+ids["titogo"]+"/revoke", map[string]string{}, owner); code != http.StatusOK { t.Fatalf("revoke a legacy row by id = %d %s; want 200", code, body) } if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", legacy); status != http.StatusUnauthorized { t.Errorf("the legacy credential still works after a revoke by id: %d %s", status, body) } // A NAMED row is addressable both ways, so the ergonomic handle and the // universal one cannot disagree about what they reach. if code, body := postRaw(t, base+"/"+ids["hub"]+"/revoke", map[string]string{}, owner); code != http.StatusOK { t.Fatalf("revoke a named row by id = %d %s; want 200", code, body) } if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", named); status != http.StatusUnauthorized { t.Errorf("the named credential still works after a revoke by id: %d %s", status, body) } if _, after := getRaw(t, base, owner); strings.Contains(after, "titogo") || strings.Contains(after, `"hub"`) { t.Errorf("revoked credentials still listed: %s", after) } // The revoke-by-id answer is the same bytes as every other revoke. _, byID := postRaw(t, base+"/"+ids["hub"]+"/revoke", map[string]string{}, owner) _, byName := postRaw(t, base+"/hub/revoke", map[string]string{}, owner) if byID != byName { t.Errorf("revoke answers differ by handle:\nid: %s\nname: %s", byID, byName) } } // TestRevokeIgnoresAnIdFromAnotherAccount: the listing id is a handle, not // an authority. Both store calls are account-scoped, so an id belonging to // somebody else's account matches nothing here — and, being idempotent, says // nothing about whether it exists. func TestRevokeIgnoresAnIdFromAnotherAccount(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-revoke-foreign") if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", time.Now()); err != nil { t.Fatal(err) } theirs := serviceToken(t, fx.st, 2, "theirs", "", svcrole.Publish) rows, err := fx.st.ServicePrincipals(2) if err != nil || len(rows) != 1 { t.Fatalf("ServicePrincipals(2) = %d rows, %v", len(rows), err) } base := fx.ts.URL + "/api/cli/services/" + fx.sqid mine, mineBody := postRaw(t, base+"/never-existed/revoke", map[string]string{}, owner) foreign, foreignBody := postRaw(t, base+"/"+rows[0].ID+"/revoke", map[string]string{}, owner) if mine != http.StatusOK || foreign != http.StatusOK { t.Fatalf("unknown = %d, foreign id = %d; want 200 from both", mine, foreign) } if foreignBody != mineBody { t.Errorf("a foreign id is distinguishable from an unknown name:\nunknown: %s\nforeign: %s", mineBody, foreignBody) } // And it says so: a foreign id must never report a match, or the // non-authoritative signal would be telling the caller they killed // something in an account they do not hold. if jsonBool(t, foreignBody, "matched") { t.Errorf("a foreign id reports matched true: %s", foreignBody) } if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps", theirs); status != http.StatusOK { t.Errorf("the other account's credential was revoked through this door: %d %s", status, body) } } // TestDoorsForReadsTheGate: doorsFor is derived from cliRoles, so the answer // an operator is given is the answer the gate will give. A closed door // appears for no role, and admin sees strictly more than publish. func TestDoorsForReadsTheGate(t *testing.T) { s := newConsoleFixture(t).srv publish := s.doorsFor(svcrole.Publish) admin := s.doorsFor(svcrole.Admin) if len(publish) == 0 || len(admin) == 0 { t.Fatalf("doorsFor: publish=%d admin=%d, want both non-empty", len(publish), len(admin)) } if len(admin) <= len(publish) { t.Errorf("admin reaches %d doors, publish %d; admin is the union of the catalogue", len(admin), len(publish)) } inAdmin := map[string]bool{} for _, d := range admin { inAdmin[d] = true } for _, d := range publish { if !inAdmin[d] { t.Errorf("%s is open to publish but not to admin", d) } } for _, closed := range closedDoors { for _, r := range svcrole.All() { for _, d := range s.doorsFor(r) { if d == closed { t.Errorf("doorsFor(%q) names the closed door %s", r, closed) } } } } } // A Service credential must name one app. // // apiAccountAppBy binds a principal to its app only when p.App != "", so an // account-scoped credential reaches every app in the account. That is // tolerable for a role a person mints for their own tooling; it is not for one // an APP holds at runtime to spend the deployment's CPU on its own data. // // The refusal lives in NewServicePrincipal rather than a handler because there // are two mint doors — this one and the browser form, which explicitly offers // an account-wide credential — and a handler check would leave one open. This // test drives the store directly for that reason. func TestServiceCredentialCannotBeAccountScoped(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() _, err := fx.st.NewServicePrincipal(OpsAccountID, "svc-wide", "", svcrole.Service, "op@keymail.dev", now) if !errors.Is(err, ErrAccountScopedService) { t.Fatalf("account-scoped Service credential = %v, want ErrAccountScopedService", err) } // Bound to an app, it mints. if _, err := fx.st.NewServicePrincipal(OpsAccountID, "svc-app", "kass", svcrole.Service, "op@keymail.dev", now); err != nil { t.Fatalf("app-scoped Service credential: %v", err) } // Every other role still mints account-wide — this refusal is about // Service alone, not a new rule for the whole vocabulary. for _, r := range []svcrole.Role{svcrole.Publish, svcrole.Instance, svcrole.Operate, svcrole.Admin} { if _, err := fx.st.NewServicePrincipal(OpsAccountID, "svc-"+string(r), "", r, "op@keymail.dev", now); err != nil { t.Fatalf("account-scoped %s credential: %v", r, err) } } }