carlos / platform Public

console: revoke by listing id, audit against the app, keep the label

Controller rulings C36-C39 on the mint doors.

C36 — the listing carries `id` (the token hash) and revoke accepts a name OR
an id. My premise that the hash could not go on the wire was wrong: /settings
has always posted it as the `id` field to handleCLITokenRevoke, and it is not
a credential — only its preimage authenticates. It earns its place because a
legacy kind='app' row carries no name, so the id is the only handle it has;
without it an owner could see a credential in the listing and have no way to
kill it. The two grammars are disjoint (<=32 chars of [a-z0-9-] vs exactly 64
hex), so no flag is needed to say which was meant. Both store calls are
account-scoped, so a foreign id changes nothing and, being idempotent, says
nothing. Rotate stays name-only: an id addresses either a nameless row, which
has no rotate path, or a named one, which has a better handle.

C38 — an app-scoped credential's create/rotate/revoke rows are filed against
its APP, because that app's activity feed is the one place its owner looks.
Account-scoped credentials keep the fleets shape and file under their name.

C39 — the listing carries the legacy row's label, the only thing telling two
nameless credentials on one app apart.

C37 — allowWrite stays, with the reasoning written at the call: the scarce
daily budget is for namespaces whose spam is permanent, and a service name is
per-account, revocable and reusable after revoke.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
Paul Campbell pushed by paul@keymail.dev d276ef26b2529213d74997600a6be159844e98f5 parent eaa057f
2 files changed, +360 −50
  • internal/console/apiservices.go +191 −37
  • internal/console/apiservices_test.go +169 −13
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go
index a9a179a9e..dc3e09be3 100644
--- a/internal/console/apiservices.go
+++ b/internal/console/apiservices.go
@@ -25,6 +25,7 @@ import (
"errors"
"log"
"net/http"
+ "regexp"
"sort"
"strings"
"time"
@@ -34,13 +35,29 @@ import (
)
// serviceView is the wire shape of one listed credential — deliberately NOT
-// CLIToken, which carries the token hash in its ID field. The listing must
-// never echo a secret and must never echo the stored hash either: the hash
-// is not a secret, but it is the handle a revoke-by-id door addresses rows
-// with, and an API that hands it out invites a caller to build one.
+// CLIToken, so that a field added to the store's row never becomes wire by
+// accident. It carries no secret and it never will: only the sha256 of a
+// credential is stored, so there is nothing here to leak even in principle.
+//
+// It DOES carry the listing id, which is that sha256 (C36). The hash is not
+// a credential — only its preimage authenticates, so knowing it grants
+// nothing — and it is this codebase's established addressing handle: the
+// personal settings page has always posted it as `id` to
+// handleCLITokenRevoke. It earns its place here because a legacy kind='app'
+// row has NO name, and the id is the only handle it has; without it the
+// listing would show an owner a credential they could not then revoke
+// through this API.
type serviceView struct {
+ // ID is the listing handle revoke accepts in place of a name. See above.
+ ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
+ // Label is the free text NewAppToken records for a legacy row ("router"
+ // on titogo's). It is the only thing distinguishing two nameless
+ // credentials bound to the same app, so an owner deciding which to
+ // revoke needs it. Empty for a named service credential, which has a
+ // name to be known by instead.
+ Label string `json:"label"`
// App is the scope: the empty string means account-scoped, and it is
// rendered rather than omitted so "bound to nothing" and "bound to an
// app whose name did not survive the wire" cannot be confused.
@@ -122,6 +139,16 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
+ // The ordinary per-minute WRITE budget, deliberately not the scarce
+ // 10-per-day create budget handleAPIFleetNew and handleAPICreateApp
+ // spend. That daily budget exists for namespaces whose spam is
+ // PERMANENT: a claimed app name can never be reused, and a fleet name is
+ // globally unique forever, so each wasted attempt costs something nobody
+ // can get back. A service credential is none of that — per-account,
+ // revocable, and its name is free again the moment it is revoked (the
+ // unique index is scoped to live rows). The door is owner-gated on top,
+ // which is a far tighter bound than app-create has. Do not "fix" this
+ // toward fleets.
if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
@@ -193,7 +220,7 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request)
if app != "" {
scope = "app " + app
}
- s.audit(acct, name, actor, "service-create",
+ s.audit(acct, serviceAuditKey(name, app), actor, "service-create",
"minted service credential "+name+" with role "+string(role)+" over "+scope+" (cli)")
writeJSON(w, http.StatusCreated, map[string]any{
"name": name,
@@ -225,7 +252,9 @@ func (s *Server) handleAPIServiceList(w http.ResponseWriter, r *http.Request) {
out := make([]serviceView, 0, len(rows))
for _, t := range rows {
out = append(out, serviceView{
+ ID: t.ID,
Name: t.Name,
+ Label: t.Label,
Role: t.Role,
App: t.App,
Created: serviceStamp(t.CreatedAt),
@@ -250,23 +279,90 @@ func serviceStamp(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}
-// mintedName is the {name} path segment, refused unless it is a name that
-// could have been minted (handleAPIServiceCreate's grammar, applied to the
-// same column from the other side).
+// listingIDRe is the shape of a listing id: the lowercase hex sha256
+// hashToken writes, exactly 64 characters.
+var listingIDRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
+
+// serviceHandle is the {name} path segment resolved to how it addresses a
+// row: a NAME (create's grammar) or a listing ID (C36).
//
-// It costs nothing in behaviour — an unmintable name can never match a live
-// row, so the store would answer "no such credential" anyway — and it buys
-// one thing: an arbitrary path segment, of arbitrary length, never reaches
-// the audit trail's app column on the revoke path, which audits even when
-// nothing matched. Callers turn a false into whatever their door's
-// no-such-credential answer already is, so this narrows no answer and
-// widens none.
-func mintedName(r *http.Request) (string, bool) {
+// The two grammars are disjoint — a name is at most 32 characters of
+// [a-z0-9-] and a listing id is exactly 64 hex characters — so a segment can
+// never be read as both, and no flag or query parameter is needed to say
+// which one the caller meant. A segment that is neither addresses nothing:
+// no row this console ever wrote could match it.
+//
+// Refusing an unrecognisable segment here rather than passing it to the
+// store also keeps an arbitrary, arbitrarily long path segment out of the
+// audit trail's app column on the revoke path, which audits even when
+// nothing matched. It narrows no answer: every caller turns an unaddressable
+// segment into the same answer its door already gives for a handle that
+// matches nothing.
+type serviceHandle struct {
+ name string // "" unless the segment is a credential name
+ id string // "" unless the segment is a listing id
+}
+
+func (h serviceHandle) addresses() bool { return h.name != "" || h.id != "" }
+
+func serviceHandleFrom(r *http.Request) serviceHandle {
// No TrimSpace: create stores the trimmed name, so the stored value
- // always satisfies this exactly, and a padded segment would not match a
- // row in any case.
- name := r.PathValue("name")
- return name, registry.ValidTunnelName(name)
+ // always satisfies ValidTunnelName exactly, and a padded segment would
+ // not match a row in any case.
+ seg := r.PathValue("name")
+ switch {
+ case registry.ValidTunnelName(seg):
+ return serviceHandle{name: seg}
+ case listingIDRe.MatchString(seg):
+ return serviceHandle{id: seg}
+ }
+ return serviceHandle{}
+}
+
+// serviceAuditKey is the app column an audit row about one credential is
+// filed under: the app the credential is scoped to when it has one, and the
+// credential's own name otherwise.
+//
+// An app-scoped credential's grant, rotation and revocation belong in THAT
+// APP's activity feed, because that feed is the one place the app's owner
+// looks. Filing them under the credential's name instead — the shape fleets
+// uses, where the subject really is the thing named — would leave the owner
+// of an app with no sight of the authority handed out over it (C38). An
+// account-scoped credential has no app to file under and keeps the fleets
+// shape.
+func serviceAuditKey(name, app string) string {
+ if app != "" {
+ return app
+ }
+ return name
+}
+
+// findServiceCredential looks one of this account's LIVE credentials up by
+// whichever handle addressed it.
+//
+// It exists for the audit trail alone. The store methods that do the work
+// answer only "did anything match", and serviceAuditKey needs the app a
+// credential is scoped to — which, on the revoke path, has to be read BEFORE
+// the write, since a revoked row leaves the listing.
+//
+// Best-effort by design: a read failure is reported as not-found, and every
+// caller treats not-found as "file it under the credential's own name",
+// never as "there was nothing to revoke". That is why the authoritative
+// write in each caller is keyed on what the CALLER named rather than on
+// anything found here — a database hiccup must not turn an emergency revoke
+// into a silent success.
+func (s *Server) findServiceCredential(accountID int64, h serviceHandle) (CLIToken, bool) {
+ rows, err := s.store.ServicePrincipals(accountID)
+ if err != nil {
+ log.Printf("console: service lookup in %d: %v", accountID, err)
+ return CLIToken{}, false
+ }
+ for _, t := range rows {
+ if (h.name != "" && t.Name == h.name) || (h.id != "" && t.ID == h.id) {
+ return t, true
+ }
+ }
+ return CLIToken{}, false
}
// handleAPIServiceRotate answers `carlos services rotate`: a fresh secret
@@ -286,11 +382,18 @@ func (s *Server) handleAPIServiceRotate(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
- name, ok := mintedName(r)
- if !ok {
+ // Rotate is NAME-only, where revoke also takes a listing id. A row a
+ // listing id addresses is either nameless — a legacy kind='app' row,
+ // which has no rotate path at all (RotateServicePrincipal refuses an
+ // empty name by design, so that one call cannot rotate several rows at
+ // once) — or it has a name, which is the handle to use. So an
+ // id-shaped segment here is the ordinary not-found, not a second way in.
+ h := serviceHandleFrom(r)
+ if h.name == "" {
http.NotFound(w, r)
return
}
+ name := h.name
secret, err := s.store.RotateServicePrincipal(acct.ID, name, time.Now())
if err != nil {
// Within an account the caller already owns, an unknown name is an
@@ -305,8 +408,13 @@ func (s *Server) handleAPIServiceRotate(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusInternalServerError, "could not rotate the credential — try again")
return
}
+ // The row survives a rotation unchanged but for its secret, so this
+ // reads back the app it is scoped to and files the audit row there
+ // (serviceAuditKey). Best-effort: a failed read files under the name.
+ row, _ := s.findServiceCredential(acct.ID, h)
actor := auditActor(p, acct)
- s.audit(acct, name, actor, "service-rotate", "rotated the secret of service credential "+name+" (cli)")
+ s.audit(acct, serviceAuditKey(name, row.App), actor, "service-rotate",
+ "rotated the secret of service credential "+name+" (cli)")
writeJSON(w, http.StatusOK, map[string]any{
"name": name,
"account": acct.Sqid,
@@ -317,16 +425,27 @@ func (s *Server) handleAPIServiceRotate(w http.ResponseWriter, r *http.Request)
// handleAPIServiceRevoke answers `carlos services revoke`: the credential
// stops resolving, at once and for good.
//
+// IT TAKES A NAME OR A LISTING ID (C36). A named service credential is
+// addressed the ergonomic way, by the name its owner chose. A legacy
+// kind='app' row has no name — nothing does, every one of them carries the
+// empty string — so the listing id is the only handle it will ever have, and
+// without it an owner could see such a credential in the listing and have no
+// way to kill it from here. serviceHandle tells the two apart by shape.
+//
// REVOCATION IS IDEMPOTENT, and that is a deliberate refusal to be precise.
-// An unknown name, an already-revoked one and a live one all answer the same
-// 200, because the person reaching for this door is usually racing a
-// compromise and must not have to work out whether their first attempt
-// landed. The one thing they need to know afterwards — that the credential
-// is dead — is true in every one of those cases.
+// An unknown name, an unknown id, an already-revoked credential and a live
+// one all answer the same 200, because the person reaching for this door is
+// usually racing a compromise and must not have to work out whether their
+// first attempt landed. The one thing they need to know afterwards — that
+// the credential is dead — is true in every one of those cases.
//
// It costs nothing in disclosure: the caller is the account's owner, so
// "does a credential of this name exist here" is not a fact being kept from
-// them. It is available from the listing one door away.
+// them. It is available from the listing one door away. An id belonging to
+// ANOTHER account is a different matter, and it is refused by construction:
+// both store calls below are scoped to acct.ID, so a foreign id matches
+// nothing and gets the same 200 as a fabricated one, having changed
+// nothing.
func (s *Server) handleAPIServiceRevoke(w http.ResponseWriter, r *http.Request) {
p, acct, ok := s.apiOwnerAccount(w, r)
if !ok {
@@ -336,27 +455,62 @@ func (s *Server) handleAPIServiceRevoke(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
- name, ok := mintedName(r)
- if !ok {
- // The same answer an unknown name gets, because that is what it is.
+ h := serviceHandleFrom(r)
+ if !h.addresses() {
+ // A segment that is neither a name nor a listing id matches no row
+ // this console ever wrote, so it gets the answer an unknown handle
+ // gets — which is the same answer every other case gets.
writeJSON(w, http.StatusOK, map[string]any{"result": "revoked"})
return
}
- err := s.store.RevokeServicePrincipal(acct.ID, name, time.Now())
+ // Read before the write, for the audit only: a revoked row leaves the
+ // listing, so the app it was scoped to is unknowable afterwards. The
+ // authoritative write below is keyed on what the CALLER named, never on
+ // what this found, so a failed read costs an audit row's filing and
+ // never a revoke.
+ row, found := s.findServiceCredential(acct.ID, h)
+
+ var err error
+ if h.name != "" {
+ err = s.store.RevokeServicePrincipal(acct.ID, h.name, time.Now())
+ } else {
+ // The universal handle: account-scoped, and the only one that
+ // reaches a nameless row. Safe by construction — a person's own
+ // cli_tokens row carries account_id = 0, so an account-scoped
+ // revoke cannot reach a human's terminal token (RevokeAccountToken).
+ err = s.store.RevokeAccountToken(acct.ID, h.id, time.Now())
+ }
if err != nil && !errors.Is(err, ErrNoServicePrincipal) {
- log.Printf("console: service revoke %s/%s: %v", acct.Sqid, name, err)
+ log.Printf("console: service revoke %s: %v", acct.Sqid, err)
jsonError(w, http.StatusInternalServerError, "could not revoke the credential — try again")
return
}
// Audited even when nothing matched: "someone tried to revoke X" is
// worth having in the trail of an account whose credentials are being
// argued about, and the detail says which it was.
+ subject, what := h.name, "service credential "+h.name
+ if subject == "" {
+ subject, what = h.id, "credential "+h.id
+ }
+ if found {
+ if row.Name != "" {
+ subject, what = row.Name, "service credential "+row.Name
+ } else {
+ // A nameless legacy row is described by what it IS — its label
+ // and its app — because its id says nothing to whoever reads
+ // the trail a year later.
+ subject, what = row.App, "app credential for "+row.App
+ if row.Label != "" {
+ what = "app credential " + row.Label + " for " + row.App
+ }
+ }
+ }
actor := auditActor(p, acct)
- detail := "revoked service credential " + name + " (cli)"
+ detail := "revoked " + what + " (cli)"
if err != nil {
- detail = "revoked service credential " + name + " — nothing live of that name (cli)"
+ detail = "revoked " + what + " — nothing live matched (cli)"
}
- s.audit(acct, name, actor, "service-revoke", detail)
+ s.audit(acct, serviceAuditKey(subject, row.App), actor, "service-revoke", detail)
// The body carries no name and nothing else request-derived: it is the
// same bytes for a live credential, an already-revoked one and a name
// that never existed, which is what makes the idempotence above visible
diff --git a/internal/console/apiservices_test.go b/internal/console/apiservices_test.go
index 57b3ddf89..d9c4a5b61 100644
--- a/internal/console/apiservices_test.go
+++ b/internal/console/apiservices_test.go
@@ -174,8 +174,12 @@ func TestMintReturnsTheSecretExactlyOnce(t *testing.T) {
if strings.Contains(list, secret) {
t.Error("the listing carries the secret")
}
- if strings.Contains(list, hashToken(secret)) {
- t.Error("the listing carries the stored hash")
+ // 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)
}
}
@@ -310,6 +314,19 @@ func TestLegacyAppTokensAppearInTheAccountListing(t *testing.T) {
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
@@ -320,14 +337,16 @@ func TestLegacyAppTokensAppearInTheAccountListing(t *testing.T) {
func TestServiceListRendersTheRotationFacts(t *testing.T) {
fx := newConsoleFixture(t)
owner := cliToken(t, fx, "paul@keymail.dev", "code-svc-list-shape")
- serviceToken(t, fx.st, OpsAccountID, "hub", "kass", svcrole.Operate)
+ 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"`
@@ -346,6 +365,14 @@ func TestServiceListRendersTheRotationFacts(t *testing.T) {
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)
}
@@ -452,11 +479,14 @@ func TestRevokeIsIdempotent(t *testing.T) {
}
}
-// TestServiceDoorsAudit: every one of the three mutations writes an audit
-// row naming the human who did it, because the credential outlives the
-// session that created it and "who granted this" is the first question asked
-// of a credential nobody recognises.
-func TestServiceDoorsAudit(t *testing.T) {
+// 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
@@ -469,10 +499,7 @@ func TestServiceDoorsAudit(t *testing.T) {
if code, body := postRaw(t, base+"/hub/revoke", map[string]string{}, owner); code != http.StatusOK {
t.Fatalf("revoke = %d %s", code, body)
}
- // Keyed on the credential's own name, the way a fleet's rows are keyed
- // on the fleet's — the subject of the action, not the app it happens to
- // be scoped to.
- events, err := fx.st.Events(OpsAccountID, "hub", 50)
+ events, err := fx.st.Events(OpsAccountID, "kass", 50)
if err != nil {
t.Fatal(err)
}
@@ -491,8 +518,137 @@ func TestServiceDoorsAudit(t *testing.T) {
}
for action, seen := range want {
if !seen {
- t.Errorf("no %s audit row", action)
+ 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.
+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/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)
+ }
+ seen := map[string]bool{}
+ for _, e := range events {
+ seen[e.Action] = true
+ }
+ if !seen["service-create"] || !seen["service-revoke"] {
+ t.Errorf("account-scoped credential's rows not filed under its name: %+v", events)
+ }
+}
+
+// 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)
+ }
+ 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)
}
}