// The service-credential API (issue #220, spec §6): mint, list, rotate and // revoke the principal that is not an address. // // This is the highest-authority surface the console has. Everything a // credential can ever do is decided here, once, at mint — the reach is a // property of the credential rather than of whoever holds it next month — // so the doors are modelled on fleets.go: the same account resolution, the // same mint-once discipline (the plaintext appears in the create and rotate // responses and nowhere else, because only its hash is stored), the same // byte-identical refusals. // // Two rules distinguish this file from every other /api/cli/ family: // // - Every door registers with the ZERO role set, so no service credential // reaches any of them — not even an admin one, and not even to rotate // itself. That is the escalation cut spec §3 turns on, and it is // enforced in cliRoute, before these handlers run. Nothing here // re-checks it: one place per rule. // - Every door is OWNER-gated (apiOwnerAccount). Minting is an authority // grant that outlives its minter and is not the ordinary member write it // resembles. package console import ( "errors" "fmt" "log" "net/http" "regexp" "sort" "strings" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/runs" "github.com/carlosframework/platform/internal/svcrole" ) // serviceView is the wire shape of one listed credential — deliberately NOT // 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. App string `json:"app"` // Rungs is a deploy credential's ladder bound, canonical order. An // empty array, never null, for every other row. Rungs []string `json:"rungs"` // Commands is a run credential's granted command prefixes, each a list // of words, in the order minted. An empty array, never null, for every // other row. Commands [][]string `json:"commands"` // CommandsUnreadable is true for a run row whose stored command list // cannot be read, which CLIPrincipal refuses at sign-in. Omitted // otherwise. Commands is then []. CommandsUnreadable bool `json:"commands_unreadable,omitempty"` // Created is when the credential was GRANTED; Rotated is when its // current secret began. The two answer different questions — how long // has this existed, and how old is the secret in use right now — and a // rotation policy needs the second without destroying the first // (svcprincipal.go, RotateServicePrincipal). Created string `json:"created"` Rotated string `json:"rotated"` LastUsed string `json:"last_used"` MintedBy string `json:"minted_by"` // There is deliberately no `revoked` field, though the brief's field // list names one. ServicePrincipals filters revoked_at = 0 (a revoked // row is a tombstone, not history — see RevokeServicePrincipal), so // every row reaching here is live and the field would be a constant // false. Absent as derived, not forgotten. } // apiOwnerAccount is apiPrincipalAccount plus the owner check, for the doors // whose subject is a credential rather than an app. // // The two refusals are deliberately different and the difference is the // point. A caller who does not hold this account at all gets the family's // byte-identical 404 from apiPrincipalAccount — unchanged, and the reason it // must stay byte-identical is that a distinct answer here would confirm the // account exists. A caller who DOES hold it but is only a member gets a 403: // they have already proved membership, so there is nothing left to conceal, // and a 404 would send an ordinary member hunting for a typo in a sqid they // typed correctly. // // A service principal never arrives here: every door in this file registers // with the zero role Set, so the role gate refuses it before the handler // runs (spec §3). This gate does not need to re-check that, and deliberately // does not — one place per rule. It fails closed anyway if that ever // changed: AccountByID, which the service branch resolves through, does not // populate Role, so a service's account row could not equal RoleOwner. // // Account.Role is populated by AccountsFor's join, so the check is one // comparison rather than a second query. func (s *Server) apiOwnerAccount(w http.ResponseWriter, r *http.Request) (Principal, *Account, bool) { return s.apiOwnerAccountFor(w, r, "manage service credentials") } // apiOwnerAccountFor is the shared owner gate for account-level CLI doors. // purpose completes the refusal in the vocabulary of the door the caller // actually used; the membership-hiding 404 remains owned by // apiPrincipalAccount and is byte-identical across all callers. func (s *Server) apiOwnerAccountFor(w http.ResponseWriter, r *http.Request, purpose string) (Principal, *Account, bool) { p, acct, ok := s.apiPrincipalAccount(w, r) if !ok { return Principal{}, nil, false } if acct.Role != RoleOwner { jsonError(w, http.StatusForbidden, "only an account owner can "+purpose) return Principal{}, nil, false } return p, acct, true } // doorsFor lists the /api/cli/ patterns a role reaches, sorted. // // It reads s.cliRoles — the SAME map the gate consults on every request — so // the answer create hands back cannot drift from what the credential will // actually be admitted to. A hand-written list in a help string would be // right on the day it was written and wrong on the day a door moved. // // This exists because of AGENTS.md's no-infra rule read as a rule about // comprehension rather than only about access: an operator must be able to // see what they granted without reading a spec. func (s *Server) doorsFor(role svcrole.Role) []string { out := make([]string, 0, len(s.cliRoles)) for pattern, set := range s.cliRoles { if set.Permits(role) { out = append(out, pattern) } } sort.Strings(out) return out } // handleAPIServiceCreate answers `carlos services create`: mints a // credential bound to this account and, optionally, to one app inside it. // // The plaintext is in this response and nowhere else. Only its sha256 hex // reaches the database, so there is no second copy for a later door to hand // out and nothing to recover if it is lost — the answer to a lost secret is // rotate, which is one command. func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) { p, acct, ok := s.apiOwnerAccount(w, r) 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 } var req struct { Name string `json:"name"` Role string `json:"role"` App string `json:"app"` // Rungs is the deploy credential's ladder bound: the rungs it may // move. Required for role deploy, refused for every other role. Rungs []string `json:"rungs"` // Commands is the run credential's granted command prefixes, each // a list of argv words. Required for role run, refused for every // other role. Commands [][]string `json:"commands"` } if !readJSON(w, r, &req) { return } // ValidTunnelName, the grammar a fleet label takes, and for the same // reason: this is a handle an operator types on a command line, and it // is what rotate and revoke address the credential by for the rest of // its life. A name carrying a path separator or a space would reach the // column verbatim with nothing downstream able to fix it. name := strings.TrimSpace(req.Name) if !registry.ValidTunnelName(name) { jsonError(w, http.StatusBadRequest, "invalid credential name — 1-32 characters of a-z, 0-9 and hyphen") return } role, ok := svcrole.Parse(strings.TrimSpace(req.Role)) if !ok { roles := svcrole.All() names := make([]string, len(roles)) for i, r := range roles { names[i] = string(r) } jsonError(w, http.StatusBadRequest, "unknown role — one of: "+strings.Join(names, ", ")) return } // An app-scoped credential must name an app that is actually in this // account. Otherwise the mint succeeds against nothing, and the day // somebody creates an app of that name the credential silently acquires // authority over an app its minter never chose. The 404 is the family's: // the caller already holds this account, so it says only that the app is // not here. app := strings.TrimSpace(req.App) if app != "" { found, err := s.store.HasAppIn(acct.ID, app) if err != nil { log.Printf("console: service create %s/%s: has-app %s: %v", acct.Sqid, name, app, err) jsonError(w, http.StatusInternalServerError, "could not check the app — try again") return } if !found { http.NotFound(w, r) return } } actor := auditActor(p, acct) secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, req.Rungs, req.Commands, actor, time.Now()) if err != nil { // The sentinel, not a string match: a name collision is the // caller's to fix and a busy database is not, and the two must not // answer alike. if errors.Is(err, ErrServiceNameTaken) { jsonError(w, http.StatusConflict, "a credential named "+name+" already exists in this account") return } if errors.Is(err, ErrAccountScopedService) { jsonError(w, http.StatusBadRequest, "a service-role credential must name one app (--app): it is held by an app at runtime, "+ "and an account-wide one would reach every app in the account") return } var ue *UnknownRungError switch { case errors.Is(err, ErrAccountScopedDeploy): jsonError(w, http.StatusBadRequest, "a deploy credential must name one app (--app)") return case errors.Is(err, ErrDeployNeedsRungs): jsonError(w, http.StatusBadRequest, "a deploy credential must name its rungs (--rungs): canary, edge or beta") return case errors.Is(err, ErrStableNotDelegable): jsonError(w, http.StatusBadRequest, "stable cannot be delegated: moving it needs a passkey in the console") return case errors.As(err, &ue): jsonError(w, http.StatusBadRequest, fmt.Sprintf("unknown rung %q: one of canary, edge, beta", ue.Rung)) return case errors.Is(err, ErrRungsNeedDeploy): jsonError(w, http.StatusBadRequest, "only a deploy credential takes rungs") return } if msg, ok := runMintRefusal(err); ok { jsonError(w, http.StatusBadRequest, msg+runMintFlagHint(err)) return } log.Printf("console: service create %s/%s: %v", acct.Sqid, name, err) jsonError(w, http.StatusInternalServerError, "could not mint the credential — try again") return } scope := "the whole account" if app != "" { scope = "app " + app } canon, _ := canonicalRungs(req.Rungs) // already validated by the mint rungs := []string{} if canon != "" { rungs = strings.Split(canon, ",") } commands := [][]string{} if role == svcrole.Run { commands = req.Commands // already validated by the mint } detail := "minted service credential " + name + " with role " + string(role) + " over " + scope + " (cli)" switch role { case svcrole.Deploy: detail = fmt.Sprintf("minted service credential %s with role deploy over app %s, rungs %s (%s)", name, app, canon, "cli") case svcrole.Run: detail = fmt.Sprintf("minted service credential %s with role run over app %s, commands %s (%s)", name, app, runs.FormatCommandList(commands), "cli") } s.audit(acct, serviceAuditKey(name, app), actor, "service-create", detail) writeJSON(w, http.StatusCreated, map[string]any{ "name": name, "role": string(role), "app": app, "rungs": rungs, "commands": commands, "account": acct.Sqid, "token": secret, "doors": s.doorsFor(role), }) } // runMintRefusal is the words for a run credential's mint refusals, shared // by both mint doors so they cannot describe one rule two ways. ok is false // for an error that is not one of them. The API door adds the flag to fix // (runMintFlagHint); the console form says it as a sentence. func runMintRefusal(err error) (msg string, ok bool) { switch { case errors.Is(err, ErrAccountScopedRun): return "a run credential must name one app", true case errors.Is(err, ErrRunNeedsCommands), errors.Is(err, runs.ErrNoCommands): return `a run credential must name the commands it may run, for example "import classic"`, true case errors.Is(err, ErrCommandsNeedRun): return "only a run credential takes commands", true case errors.Is(err, runs.ErrTooManyCommands): return fmt.Sprintf("a run credential takes at most %d command prefixes", runs.MaxCommandPrefixes), true case errors.Is(err, runs.ErrEmptyCommand): return "a command prefix has no words", true case errors.Is(err, runs.ErrEmptyCommandWord): return "a command prefix holds an empty word", true case errors.Is(err, runs.ErrCommandWordTooLong): return fmt.Sprintf("a command word is over %d bytes", runs.MaxCommandWordBytes), true case errors.Is(err, runs.ErrCommandWordChars): return "a command word holds a space, a comma or a character that cannot be printed", true } return "", false } // runMintFlagHint names the CLI flag that fixes a run mint refusal, the way // the deploy refusals name --app and --rungs. func runMintFlagHint(err error) string { switch { case errors.Is(err, ErrAccountScopedRun): return " (--app)" case errors.Is(err, ErrCommandsNeedRun): return "" } return " (--commands)" } // handleAPIServiceList answers `carlos services list`: every live credential // bound to this account, service-named and legacy-nameless alike, so that // absorbing the old kind='app' rows into this model does not make them // invisible. A credential nobody can see is one nobody rotates. // // Never the hash and never a secret — see serviceView. func (s *Server) handleAPIServiceList(w http.ResponseWriter, r *http.Request) { _, acct, ok := s.apiOwnerAccount(w, r) if !ok { return } rows, err := s.store.ServicePrincipals(acct.ID) if err != nil { log.Printf("console: service list %s: %v", acct.Sqid, err) jsonError(w, http.StatusInternalServerError, "could not list credentials") return } out := make([]serviceView, 0, len(rows)) for _, t := range rows { rungs := []string{} if t.Rungs != "" { rungs = strings.Split(t.Rungs, ",") } commands := t.CommandPrefixes() if commands == nil { commands = [][]string{} } out = append(out, serviceView{ ID: t.ID, Name: t.Name, Label: t.Label, Role: t.Role, App: t.App, Rungs: rungs, Commands: commands, // Set only for a run row whose stored list cannot be read: // CLIPrincipal refuses it, and an empty list would read as // a credential that may run nothing rather than one that // cannot sign in at all. CommandsUnreadable: t.CommandsUnreadable(), Created: serviceStamp(t.CreatedAt), Rotated: serviceStamp(t.RotatedAt), LastUsed: serviceStamp(t.LastUsed), MintedBy: t.Address, }) } writeJSON(w, http.StatusOK, map[string]any{"services": out}) } // serviceStamp renders a timestamp for the listing, with the ZERO time rendered as // an empty string rather than as 1 Jan 1970. A credential that has never // been used, and a legacy row minted before rotated_at existed, must not // read as fifty-six years stale on the page an owner uses to decide what to // rotate — that is the same failure CLIToken.NeverExpires exists to prevent // at the other end of the row. func serviceStamp(t time.Time) string { if t.IsZero() || t.Unix() == 0 { return "" } return t.UTC().Format(time.RFC3339) } // listingIDRe is the shape of a listing id: the hex sha256 hashToken writes, // exactly 64 characters. It accepts UPPERCASE hex as well as lower, and the // id arm below folds what it matches to lower. // // That is not politeness, it is the difference between a credential being // dead and an operator believing it is. hashToken writes lowercase, but the // person reaching for this door is racing a compromise and is as likely to // have the id from PowerShell's Get-FileHash, certutil, or a pasted audit // extract — all of which emit uppercase — as from the listing. Case-strict, // that paste matches neither arm, and the answer to an unaddressable handle // is a 200 indistinguishable from a successful revoke. The operator walks // away from a live admin credential believing they killed it. // // Folding is provably safe HERE and nowhere else. A 64-character segment can // never be a credential name (ValidTunnelName caps a name at 32), so nothing // this arm folds could have been meant as a name. The NAME arm stays // byte-exact for exactly the opposite reason: folding there would make the // segment HUB revoke the credential hub, which is a different credential // than the one the caller typed, and silently. var listingIDRe = regexp.MustCompile(`^[0-9a-fA-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). // // 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. That disjointness is also what makes the id // arm safe to case-fold while the name arm is not (see listingIDRe). 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 ValidTunnelName exactly, and a padded segment would // not match a row in any case. return serviceHandleOf(r.PathValue("name")) } // serviceHandleOf is serviceHandleFrom's grammar, taken as a plain string so // the console PAGE's revoke form (svcpage.go) reads a handle exactly the way // the API door reads a path segment. One grammar, one place: a browser form // that decided for itself what a name looked like would drift from the door // its own buttons are meant to mirror. func serviceHandleOf(seg string) serviceHandle { switch { case registry.ValidTunnelName(seg): return serviceHandle{name: seg} case listingIDRe.MatchString(seg): // Folded to the case hashToken writes. See listingIDRe: safe on this // arm alone, because a 64-character segment cannot be a name. return serviceHandle{id: strings.ToLower(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 } // serviceWhat names the credential a revoke acted on: the SUBJECT an audit // row is filed under and the WORDS both doors use to describe what happened. // // Shared by handleAPIServiceRevoke and handleServiceRevokePage (svcpage.go), // which is the whole point of it being a function — the two doors write into // the same activity feed about the same event, and a phrasing improvement // made in one of them would otherwise leave the account's trail disagreeing // with itself depending on whether an operator used the browser or the CLI. // Same reason serviceHandleOf is shared rather than re-derived. // // 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. The // handle is the fallback for a row that could not be read back: not-found is // never "there was nothing to revoke", only "we could not name it". func serviceWhat(h serviceHandle, row CLIToken, found bool) (subject, what string) { subject, what = h.name, "service credential "+h.name if subject == "" { subject, what = h.id, "credential "+h.id } if !found { return subject, what } if row.Name != "" { return row.Name, "service credential " + row.Name } what = "app credential for " + row.App if row.Label != "" { what = "app credential " + row.Label + " for " + row.App } return row.App, what } // serviceAuditSubject is the app column an audit row is filed under when // there is no credential to name — an attempted revoke whose handle // addresses nothing (C42). // // The parentheses and the space are load-bearing: an app name is lowercase // letters, digits and dashes starting with a letter (handleAPICreateApp), so // this string cannot collide with a real app's activity feed. const serviceAuditSubject = "(service credentials)" // 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 // for a credential that already exists, its hash replacing the stored one. // // ROTATION KEEPS THE IDENTITY. Same row, same name, same role, same scope, // same audit history and same rate-limit budget (principalKey keys a service // on its name for exactly this reason) — only the secret moves. What it does // do, immediately, is stop the OLD secret working: CLIPrincipal resolves by // hash, and the old hash is gone from the table the moment this write lands. func (s *Server) handleAPIServiceRotate(w http.ResponseWriter, r *http.Request) { p, acct, ok := s.apiOwnerAccount(w, r) if !ok { return } if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } // 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 var secret string err := s.serviceCredentialWrite(r.Context(), acct, h, "sql.RotateServicePrincipal", func(st *Store) error { var err error secret, err = st.RotateServicePrincipal(acct.ID, name, time.Now()) return err }) if err != nil { // Within an account the caller already owns, an unknown name is an // ordinary not-found rather than a cross-tenant probe — the same // reasoning findFleetBox gives for a bad box label inside an owned // fleet. The sentinel is what separates it from a store failure. if errors.Is(err, ErrNoServicePrincipal) { http.NotFound(w, r) return } log.Printf("console: service rotate %s/%s: %v", acct.Sqid, name, err) 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, 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, "token": secret, }) } // 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. An unknown name, an unknown id, an // already-revoked credential and a live one all answer 200 // "result":"revoked", because the person reaching for this door is usually // racing a compromise and must not have to work out whether their first // attempt landed. What they need afterwards — that no credential of that // handle is live — is true in every one of those cases. // // AND IT SAYS WHICH IT WAS, in a non-authoritative "matched" boolean (C41). // Idempotence demands only that a SECOND call on the same handle answer like // the first, and "matched": false on a second revoke is both true and // useful. The likely miss here is not a stolen id but a typo — `revoke hubb` // — and an opaque 200 turns that into a false all-clear at the one door used // in anger. It costs nothing in disclosure either: the caller is the // account's owner, so "is there a live credential of this handle" is not a // fact being kept from them; it is in 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, reports "matched": false, and has changed // nothing. func (s *Server) handleAPIServiceRevoke(w http.ResponseWriter, r *http.Request) { p, acct, ok := s.apiOwnerAccount(w, r) if !ok { return } if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } actor := auditActor(p, acct) 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 reports the same "nothing matched" // an unknown handle does. // // It is AUDITED, under a subject that is not an app (C42). An // attempted revoke that could not have worked is exactly the event // an operator goes looking for afterwards — "I definitely revoked // that" — and the early return used to leave no trace of it at all, // which contradicted the principle stated at the audit call below. // // The handle itself is not echoed into the row. By definition it is // not something this console wrote, and it is an unbounded // caller-controlled path segment; keeping it out of the trail is the // same reason serviceHandle refuses to pass it to the store. Who, // when, and "the handle was unusable" is what the operator needs. s.audit(acct, serviceAuditSubject, actor, "service-revoke", "revoke attempted with a handle that is neither a credential name nor a listing id (cli)") writeJSON(w, http.StatusOK, map[string]any{"result": "revoked", "matched": false}) return } // 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) err := s.serviceCredentialWrite(r.Context(), acct, h, "sql.RevokeServicePrincipal", func(st *Store) error { if h.name != "" { return st.RevokeServicePrincipal(acct.ID, h.name, time.Now()) } // 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). return st.RevokeAccountToken(acct.ID, h.id, time.Now()) }) if err != nil && !errors.Is(err, ErrNoServicePrincipal) { 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 := serviceWhat(h, row, found) detail := "revoked " + what + " (cli)" if err != nil { detail = "revoked " + what + " — nothing live matched (cli)" } s.audit(acct, serviceAuditKey(subject, row.App), actor, "service-revoke", detail) // The body carries no name and nothing else request-derived, so two // revokes of the same handle are the same bytes — which is what makes // the idempotence above visible from the outside rather than merely true // inside. `matched` is the one thing that varies, and it varies with // what HAPPENED rather than with what was asked: false on the second // call for the same credential the first call killed. writeJSON(w, http.StatusOK, map[string]any{"result": "revoked", "matched": err == nil}) }