carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The service-credential API (issue #220, spec §6): mint, list, rotate and
2// revoke the principal that is not an address.
3//
4// This is the highest-authority surface the console has. Everything a
5// credential can ever do is decided here, once, at mint — the reach is a
6// property of the credential rather than of whoever holds it next month —
7// so the doors are modelled on fleets.go: the same account resolution, the
8// same mint-once discipline (the plaintext appears in the create and rotate
9// responses and nowhere else, because only its hash is stored), the same
10// byte-identical refusals.
11//
12// Two rules distinguish this file from every other /api/cli/ family:
13//
14// - Every door registers with the ZERO role set, so no service credential
15// reaches any of them — not even an admin one, and not even to rotate
16// itself. That is the escalation cut spec §3 turns on, and it is
17// enforced in cliRoute, before these handlers run. Nothing here
18// re-checks it: one place per rule.
19// - Every door is OWNER-gated (apiOwnerAccount). Minting is an authority
20// grant that outlives its minter and is not the ordinary member write it
21// resembles.
22package console
23
24import (
25 "errors"
26 "fmt"
27 "log"
28 "net/http"
29 "regexp"
30 "sort"
31 "strings"
32 "time"
33
34 "github.com/carlosframework/platform/internal/registry"
35 "github.com/carlosframework/platform/internal/runs"
36 "github.com/carlosframework/platform/internal/svcrole"
37)
38
39// serviceView is the wire shape of one listed credential — deliberately NOT
40// CLIToken, so that a field added to the store's row never becomes wire by
41// accident. It carries no secret and it never will: only the sha256 of a
42// credential is stored, so there is nothing here to leak even in principle.
43//
44// It DOES carry the listing id, which is that sha256 (C36). The hash is not
45// a credential — only its preimage authenticates, so knowing it grants
46// nothing — and it is this codebase's established addressing handle: the
47// personal settings page has always posted it as `id` to
48// handleCLITokenRevoke. It earns its place here because a legacy kind='app'
49// row has NO name, and the id is the only handle it has; without it the
50// listing would show an owner a credential they could not then revoke
51// through this API.
52type serviceView struct {
53 // ID is the listing handle revoke accepts in place of a name. See above.
54 ID string `json:"id"`
55 Name string `json:"name"`
56 Role string `json:"role"`
57 // Label is the free text NewAppToken records for a legacy row ("router"
58 // on titogo's). It is the only thing distinguishing two nameless
59 // credentials bound to the same app, so an owner deciding which to
60 // revoke needs it. Empty for a named service credential, which has a
61 // name to be known by instead.
62 Label string `json:"label"`
63 // App is the scope: the empty string means account-scoped, and it is
64 // rendered rather than omitted so "bound to nothing" and "bound to an
65 // app whose name did not survive the wire" cannot be confused.
66 App string `json:"app"`
67 // Rungs is a deploy credential's ladder bound, canonical order. An
68 // empty array, never null, for every other row.
69 Rungs []string `json:"rungs"`
70 // Commands is a run credential's granted command prefixes, each a list
71 // of words, in the order minted. An empty array, never null, for every
72 // other row.
73 Commands [][]string `json:"commands"`
74 // CommandsUnreadable is true for a run row whose stored command list
75 // cannot be read, which CLIPrincipal refuses at sign-in. Omitted
76 // otherwise. Commands is then [].
77 CommandsUnreadable bool `json:"commands_unreadable,omitempty"`
78 // Created is when the credential was GRANTED; Rotated is when its
79 // current secret began. The two answer different questions — how long
80 // has this existed, and how old is the secret in use right now — and a
81 // rotation policy needs the second without destroying the first
82 // (svcprincipal.go, RotateServicePrincipal).
83 Created string `json:"created"`
84 Rotated string `json:"rotated"`
85 LastUsed string `json:"last_used"`
86 MintedBy string `json:"minted_by"`
87 // There is deliberately no `revoked` field, though the brief's field
88 // list names one. ServicePrincipals filters revoked_at = 0 (a revoked
89 // row is a tombstone, not history — see RevokeServicePrincipal), so
90 // every row reaching here is live and the field would be a constant
91 // false. Absent as derived, not forgotten.
92}
93
94// apiOwnerAccount is apiPrincipalAccount plus the owner check, for the doors
95// whose subject is a credential rather than an app.
96//
97// The two refusals are deliberately different and the difference is the
98// point. A caller who does not hold this account at all gets the family's
99// byte-identical 404 from apiPrincipalAccount — unchanged, and the reason it
100// must stay byte-identical is that a distinct answer here would confirm the
101// account exists. A caller who DOES hold it but is only a member gets a 403:
102// they have already proved membership, so there is nothing left to conceal,
103// and a 404 would send an ordinary member hunting for a typo in a sqid they
104// typed correctly.
105//
106// A service principal never arrives here: every door in this file registers
107// with the zero role Set, so the role gate refuses it before the handler
108// runs (spec §3). This gate does not need to re-check that, and deliberately
109// does not — one place per rule. It fails closed anyway if that ever
110// changed: AccountByID, which the service branch resolves through, does not
111// populate Role, so a service's account row could not equal RoleOwner.
112//
113// Account.Role is populated by AccountsFor's join, so the check is one
114// comparison rather than a second query.
115func (s *Server) apiOwnerAccount(w http.ResponseWriter, r *http.Request) (Principal, *Account, bool) {
116 return s.apiOwnerAccountFor(w, r, "manage service credentials")
117}
118
119// apiOwnerAccountFor is the shared owner gate for account-level CLI doors.
120// purpose completes the refusal in the vocabulary of the door the caller
121// actually used; the membership-hiding 404 remains owned by
122// apiPrincipalAccount and is byte-identical across all callers.
123func (s *Server) apiOwnerAccountFor(w http.ResponseWriter, r *http.Request, purpose string) (Principal, *Account, bool) {
124 p, acct, ok := s.apiPrincipalAccount(w, r)
125 if !ok {
126 return Principal{}, nil, false
127 }
128 if acct.Role != RoleOwner {
129 jsonError(w, http.StatusForbidden, "only an account owner can "+purpose)
130 return Principal{}, nil, false
131 }
132 return p, acct, true
133}
134
135// doorsFor lists the /api/cli/ patterns a role reaches, sorted.
136//
137// It reads s.cliRoles — the SAME map the gate consults on every request — so
138// the answer create hands back cannot drift from what the credential will
139// actually be admitted to. A hand-written list in a help string would be
140// right on the day it was written and wrong on the day a door moved.
141//
142// This exists because of AGENTS.md's no-infra rule read as a rule about
143// comprehension rather than only about access: an operator must be able to
144// see what they granted without reading a spec.
145func (s *Server) doorsFor(role svcrole.Role) []string {
146 out := make([]string, 0, len(s.cliRoles))
147 for pattern, set := range s.cliRoles {
148 if set.Permits(role) {
149 out = append(out, pattern)
150 }
151 }
152 sort.Strings(out)
153 return out
154}
155
156// handleAPIServiceCreate answers `carlos services create`: mints a
157// credential bound to this account and, optionally, to one app inside it.
158//
159// The plaintext is in this response and nowhere else. Only its sha256 hex
160// reaches the database, so there is no second copy for a later door to hand
161// out and nothing to recover if it is lost — the answer to a lost secret is
162// rotate, which is one command.
163func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) {
164 p, acct, ok := s.apiOwnerAccount(w, r)
165 if !ok {
166 return
167 }
168 // The ordinary per-minute WRITE budget, deliberately not the scarce
169 // 10-per-day create budget handleAPIFleetNew and handleAPICreateApp
170 // spend. That daily budget exists for namespaces whose spam is
171 // PERMANENT: a claimed app name can never be reused, and a fleet name is
172 // globally unique forever, so each wasted attempt costs something nobody
173 // can get back. A service credential is none of that — per-account,
174 // revocable, and its name is free again the moment it is revoked (the
175 // unique index is scoped to live rows). The door is owner-gated on top,
176 // which is a far tighter bound than app-create has. Do not "fix" this
177 // toward fleets.
178 if !s.allowWrite(p, time.Now()) {
179 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
180 return
181 }
182 var req struct {
183 Name string `json:"name"`
184 Role string `json:"role"`
185 App string `json:"app"`
186 // Rungs is the deploy credential's ladder bound: the rungs it may
187 // move. Required for role deploy, refused for every other role.
188 Rungs []string `json:"rungs"`
189 // Commands is the run credential's granted command prefixes, each
190 // a list of argv words. Required for role run, refused for every
191 // other role.
192 Commands [][]string `json:"commands"`
193 }
194 if !readJSON(w, r, &req) {
195 return
196 }
197 // ValidTunnelName, the grammar a fleet label takes, and for the same
198 // reason: this is a handle an operator types on a command line, and it
199 // is what rotate and revoke address the credential by for the rest of
200 // its life. A name carrying a path separator or a space would reach the
201 // column verbatim with nothing downstream able to fix it.
202 name := strings.TrimSpace(req.Name)
203 if !registry.ValidTunnelName(name) {
204 jsonError(w, http.StatusBadRequest,
205 "invalid credential name — 1-32 characters of a-z, 0-9 and hyphen")
206 return
207 }
208 role, ok := svcrole.Parse(strings.TrimSpace(req.Role))
209 if !ok {
210 roles := svcrole.All()
211 names := make([]string, len(roles))
212 for i, r := range roles {
213 names[i] = string(r)
214 }
215 jsonError(w, http.StatusBadRequest,
216 "unknown role — one of: "+strings.Join(names, ", "))
217 return
218 }
219 // An app-scoped credential must name an app that is actually in this
220 // account. Otherwise the mint succeeds against nothing, and the day
221 // somebody creates an app of that name the credential silently acquires
222 // authority over an app its minter never chose. The 404 is the family's:
223 // the caller already holds this account, so it says only that the app is
224 // not here.
225 app := strings.TrimSpace(req.App)
226 if app != "" {
227 found, err := s.store.HasAppIn(acct.ID, app)
228 if err != nil {
229 log.Printf("console: service create %s/%s: has-app %s: %v", acct.Sqid, name, app, err)
230 jsonError(w, http.StatusInternalServerError, "could not check the app — try again")
231 return
232 }
233 if !found {
234 http.NotFound(w, r)
235 return
236 }
237 }
238 actor := auditActor(p, acct)
239 secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, req.Rungs, req.Commands, actor, time.Now())
240 if err != nil {
241 // The sentinel, not a string match: a name collision is the
242 // caller's to fix and a busy database is not, and the two must not
243 // answer alike.
244 if errors.Is(err, ErrServiceNameTaken) {
245 jsonError(w, http.StatusConflict, "a credential named "+name+" already exists in this account")
246 return
247 }
248 if errors.Is(err, ErrAccountScopedService) {
249 jsonError(w, http.StatusBadRequest,
250 "a service-role credential must name one app (--app): it is held by an app at runtime, "+
251 "and an account-wide one would reach every app in the account")
252 return
253 }
254 var ue *UnknownRungError
255 switch {
256 case errors.Is(err, ErrAccountScopedDeploy):
257 jsonError(w, http.StatusBadRequest, "a deploy credential must name one app (--app)")
258 return
259 case errors.Is(err, ErrDeployNeedsRungs):
260 jsonError(w, http.StatusBadRequest, "a deploy credential must name its rungs (--rungs): canary, edge or beta")
261 return
262 case errors.Is(err, ErrStableNotDelegable):
263 jsonError(w, http.StatusBadRequest, "stable cannot be delegated: moving it needs a passkey in the console")
264 return
265 case errors.As(err, &ue):
266 jsonError(w, http.StatusBadRequest, fmt.Sprintf("unknown rung %q: one of canary, edge, beta", ue.Rung))
267 return
268 case errors.Is(err, ErrRungsNeedDeploy):
269 jsonError(w, http.StatusBadRequest, "only a deploy credential takes rungs")
270 return
271 }
272 if msg, ok := runMintRefusal(err); ok {
273 jsonError(w, http.StatusBadRequest, msg+runMintFlagHint(err))
274 return
275 }
276 log.Printf("console: service create %s/%s: %v", acct.Sqid, name, err)
277 jsonError(w, http.StatusInternalServerError, "could not mint the credential — try again")
278 return
279 }
280 scope := "the whole account"
281 if app != "" {
282 scope = "app " + app
283 }
284 canon, _ := canonicalRungs(req.Rungs) // already validated by the mint
285 rungs := []string{}
286 if canon != "" {
287 rungs = strings.Split(canon, ",")
288 }
289 commands := [][]string{}
290 if role == svcrole.Run {
291 commands = req.Commands // already validated by the mint
292 }
293 detail := "minted service credential " + name + " with role " + string(role) + " over " + scope + " (cli)"
294 switch role {
295 case svcrole.Deploy:
296 detail = fmt.Sprintf("minted service credential %s with role deploy over app %s, rungs %s (%s)", name, app, canon, "cli")
297 case svcrole.Run:
298 detail = fmt.Sprintf("minted service credential %s with role run over app %s, commands %s (%s)", name, app, runs.FormatCommandList(commands), "cli")
299 }
300 s.audit(acct, serviceAuditKey(name, app), actor, "service-create", detail)
301 writeJSON(w, http.StatusCreated, map[string]any{
302 "name": name,
303 "role": string(role),
304 "app": app,
305 "rungs": rungs,
306 "commands": commands,
307 "account": acct.Sqid,
308 "token": secret,
309 "doors": s.doorsFor(role),
310 })
311}
312
313// runMintRefusal is the words for a run credential's mint refusals, shared
314// by both mint doors so they cannot describe one rule two ways. ok is false
315// for an error that is not one of them. The API door adds the flag to fix
316// (runMintFlagHint); the console form says it as a sentence.
317func runMintRefusal(err error) (msg string, ok bool) {
318 switch {
319 case errors.Is(err, ErrAccountScopedRun):
320 return "a run credential must name one app", true
321 case errors.Is(err, ErrRunNeedsCommands), errors.Is(err, runs.ErrNoCommands):
322 return `a run credential must name the commands it may run, for example "import classic"`, true
323 case errors.Is(err, ErrCommandsNeedRun):
324 return "only a run credential takes commands", true
325 case errors.Is(err, runs.ErrTooManyCommands):
326 return fmt.Sprintf("a run credential takes at most %d command prefixes", runs.MaxCommandPrefixes), true
327 case errors.Is(err, runs.ErrEmptyCommand):
328 return "a command prefix has no words", true
329 case errors.Is(err, runs.ErrEmptyCommandWord):
330 return "a command prefix holds an empty word", true
331 case errors.Is(err, runs.ErrCommandWordTooLong):
332 return fmt.Sprintf("a command word is over %d bytes", runs.MaxCommandWordBytes), true
333 case errors.Is(err, runs.ErrCommandWordChars):
334 return "a command word holds a space, a comma or a character that cannot be printed", true
335 }
336 return "", false
337}
338
339// runMintFlagHint names the CLI flag that fixes a run mint refusal, the way
340// the deploy refusals name --app and --rungs.
341func runMintFlagHint(err error) string {
342 switch {
343 case errors.Is(err, ErrAccountScopedRun):
344 return " (--app)"
345 case errors.Is(err, ErrCommandsNeedRun):
346 return ""
347 }
348 return " (--commands)"
349}
350
351// handleAPIServiceList answers `carlos services list`: every live credential
352// bound to this account, service-named and legacy-nameless alike, so that
353// absorbing the old kind='app' rows into this model does not make them
354// invisible. A credential nobody can see is one nobody rotates.
355//
356// Never the hash and never a secret — see serviceView.
357func (s *Server) handleAPIServiceList(w http.ResponseWriter, r *http.Request) {
358 _, acct, ok := s.apiOwnerAccount(w, r)
359 if !ok {
360 return
361 }
362 rows, err := s.store.ServicePrincipals(acct.ID)
363 if err != nil {
364 log.Printf("console: service list %s: %v", acct.Sqid, err)
365 jsonError(w, http.StatusInternalServerError, "could not list credentials")
366 return
367 }
368 out := make([]serviceView, 0, len(rows))
369 for _, t := range rows {
370 rungs := []string{}
371 if t.Rungs != "" {
372 rungs = strings.Split(t.Rungs, ",")
373 }
374 commands := t.CommandPrefixes()
375 if commands == nil {
376 commands = [][]string{}
377 }
378 out = append(out, serviceView{
379 ID: t.ID,
380 Name: t.Name,
381 Label: t.Label,
382 Role: t.Role,
383 App: t.App,
384 Rungs: rungs,
385 Commands: commands,
386 // Set only for a run row whose stored list cannot be read:
387 // CLIPrincipal refuses it, and an empty list would read as
388 // a credential that may run nothing rather than one that
389 // cannot sign in at all.
390 CommandsUnreadable: t.CommandsUnreadable(),
391 Created: serviceStamp(t.CreatedAt),
392 Rotated: serviceStamp(t.RotatedAt),
393 LastUsed: serviceStamp(t.LastUsed),
394 MintedBy: t.Address,
395 })
396 }
397 writeJSON(w, http.StatusOK, map[string]any{"services": out})
398}
399
400// serviceStamp renders a timestamp for the listing, with the ZERO time rendered as
401// an empty string rather than as 1 Jan 1970. A credential that has never
402// been used, and a legacy row minted before rotated_at existed, must not
403// read as fifty-six years stale on the page an owner uses to decide what to
404// rotate — that is the same failure CLIToken.NeverExpires exists to prevent
405// at the other end of the row.
406func serviceStamp(t time.Time) string {
407 if t.IsZero() || t.Unix() == 0 {
408 return ""
409 }
410 return t.UTC().Format(time.RFC3339)
411}
412
413// listingIDRe is the shape of a listing id: the hex sha256 hashToken writes,
414// exactly 64 characters. It accepts UPPERCASE hex as well as lower, and the
415// id arm below folds what it matches to lower.
416//
417// That is not politeness, it is the difference between a credential being
418// dead and an operator believing it is. hashToken writes lowercase, but the
419// person reaching for this door is racing a compromise and is as likely to
420// have the id from PowerShell's Get-FileHash, certutil, or a pasted audit
421// extract — all of which emit uppercase — as from the listing. Case-strict,
422// that paste matches neither arm, and the answer to an unaddressable handle
423// is a 200 indistinguishable from a successful revoke. The operator walks
424// away from a live admin credential believing they killed it.
425//
426// Folding is provably safe HERE and nowhere else. A 64-character segment can
427// never be a credential name (ValidTunnelName caps a name at 32), so nothing
428// this arm folds could have been meant as a name. The NAME arm stays
429// byte-exact for exactly the opposite reason: folding there would make the
430// segment HUB revoke the credential hub, which is a different credential
431// than the one the caller typed, and silently.
432var listingIDRe = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
433
434// serviceHandle is the {name} path segment resolved to how it addresses a
435// row: a NAME (create's grammar) or a listing ID (C36).
436//
437// The two grammars are disjoint — a name is at most 32 characters of
438// [a-z0-9-] and a listing id is exactly 64 hex characters — so a segment can
439// never be read as both, and no flag or query parameter is needed to say
440// which one the caller meant. That disjointness is also what makes the id
441// arm safe to case-fold while the name arm is not (see listingIDRe). A
442// segment that is neither addresses nothing: no row this console ever wrote
443// could match it.
444//
445// Refusing an unrecognisable segment here rather than passing it to the
446// store also keeps an arbitrary, arbitrarily long path segment out of the
447// audit trail's app column on the revoke path, which audits even when
448// nothing matched. It narrows no answer: every caller turns an unaddressable
449// segment into the same answer its door already gives for a handle that
450// matches nothing.
451type serviceHandle struct {
452 name string // "" unless the segment is a credential name
453 id string // "" unless the segment is a listing id
454}
455
456func (h serviceHandle) addresses() bool { return h.name != "" || h.id != "" }
457
458func serviceHandleFrom(r *http.Request) serviceHandle {
459 // No TrimSpace: create stores the trimmed name, so the stored value
460 // always satisfies ValidTunnelName exactly, and a padded segment would
461 // not match a row in any case.
462 return serviceHandleOf(r.PathValue("name"))
463}
464
465// serviceHandleOf is serviceHandleFrom's grammar, taken as a plain string so
466// the console PAGE's revoke form (svcpage.go) reads a handle exactly the way
467// the API door reads a path segment. One grammar, one place: a browser form
468// that decided for itself what a name looked like would drift from the door
469// its own buttons are meant to mirror.
470func serviceHandleOf(seg string) serviceHandle {
471 switch {
472 case registry.ValidTunnelName(seg):
473 return serviceHandle{name: seg}
474 case listingIDRe.MatchString(seg):
475 // Folded to the case hashToken writes. See listingIDRe: safe on this
476 // arm alone, because a 64-character segment cannot be a name.
477 return serviceHandle{id: strings.ToLower(seg)}
478 }
479 return serviceHandle{}
480}
481
482// serviceAuditKey is the app column an audit row about one credential is
483// filed under: the app the credential is scoped to when it has one, and the
484// credential's own name otherwise.
485//
486// An app-scoped credential's grant, rotation and revocation belong in THAT
487// APP's activity feed, because that feed is the one place the app's owner
488// looks. Filing them under the credential's name instead — the shape fleets
489// uses, where the subject really is the thing named — would leave the owner
490// of an app with no sight of the authority handed out over it (C38). An
491// account-scoped credential has no app to file under and keeps the fleets
492// shape.
493func serviceAuditKey(name, app string) string {
494 if app != "" {
495 return app
496 }
497 return name
498}
499
500// serviceWhat names the credential a revoke acted on: the SUBJECT an audit
501// row is filed under and the WORDS both doors use to describe what happened.
502//
503// Shared by handleAPIServiceRevoke and handleServiceRevokePage (svcpage.go),
504// which is the whole point of it being a function — the two doors write into
505// the same activity feed about the same event, and a phrasing improvement
506// made in one of them would otherwise leave the account's trail disagreeing
507// with itself depending on whether an operator used the browser or the CLI.
508// Same reason serviceHandleOf is shared rather than re-derived.
509//
510// A nameless legacy row is described by what it IS — its label and its app —
511// because its id says nothing to whoever reads the trail a year later. The
512// handle is the fallback for a row that could not be read back: not-found is
513// never "there was nothing to revoke", only "we could not name it".
514func serviceWhat(h serviceHandle, row CLIToken, found bool) (subject, what string) {
515 subject, what = h.name, "service credential "+h.name
516 if subject == "" {
517 subject, what = h.id, "credential "+h.id
518 }
519 if !found {
520 return subject, what
521 }
522 if row.Name != "" {
523 return row.Name, "service credential " + row.Name
524 }
525 what = "app credential for " + row.App
526 if row.Label != "" {
527 what = "app credential " + row.Label + " for " + row.App
528 }
529 return row.App, what
530}
531
532// serviceAuditSubject is the app column an audit row is filed under when
533// there is no credential to name — an attempted revoke whose handle
534// addresses nothing (C42).
535//
536// The parentheses and the space are load-bearing: an app name is lowercase
537// letters, digits and dashes starting with a letter (handleAPICreateApp), so
538// this string cannot collide with a real app's activity feed.
539const serviceAuditSubject = "(service credentials)"
540
541// findServiceCredential looks one of this account's LIVE credentials up by
542// whichever handle addressed it.
543//
544// It exists for the audit trail alone. The store methods that do the work
545// answer only "did anything match", and serviceAuditKey needs the app a
546// credential is scoped to — which, on the revoke path, has to be read BEFORE
547// the write, since a revoked row leaves the listing.
548//
549// Best-effort by design: a read failure is reported as not-found, and every
550// caller treats not-found as "file it under the credential's own name",
551// never as "there was nothing to revoke". That is why the authoritative
552// write in each caller is keyed on what the CALLER named rather than on
553// anything found here — a database hiccup must not turn an emergency revoke
554// into a silent success.
555func (s *Server) findServiceCredential(accountID int64, h serviceHandle) (CLIToken, bool) {
556 rows, err := s.store.ServicePrincipals(accountID)
557 if err != nil {
558 log.Printf("console: service lookup in %d: %v", accountID, err)
559 return CLIToken{}, false
560 }
561 for _, t := range rows {
562 if (h.name != "" && t.Name == h.name) || (h.id != "" && t.ID == h.id) {
563 return t, true
564 }
565 }
566 return CLIToken{}, false
567}
568
569// handleAPIServiceRotate answers `carlos services rotate`: a fresh secret
570// for a credential that already exists, its hash replacing the stored one.
571//
572// ROTATION KEEPS THE IDENTITY. Same row, same name, same role, same scope,
573// same audit history and same rate-limit budget (principalKey keys a service
574// on its name for exactly this reason) — only the secret moves. What it does
575// do, immediately, is stop the OLD secret working: CLIPrincipal resolves by
576// hash, and the old hash is gone from the table the moment this write lands.
577func (s *Server) handleAPIServiceRotate(w http.ResponseWriter, r *http.Request) {
578 p, acct, ok := s.apiOwnerAccount(w, r)
579 if !ok {
580 return
581 }
582 if !s.allowWrite(p, time.Now()) {
583 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
584 return
585 }
586 // Rotate is NAME-only, where revoke also takes a listing id. A row a
587 // listing id addresses is either nameless — a legacy kind='app' row,
588 // which has no rotate path at all (RotateServicePrincipal refuses an
589 // empty name by design, so that one call cannot rotate several rows at
590 // once) — or it has a name, which is the handle to use. So an
591 // id-shaped segment here is the ordinary not-found, not a second way in.
592 h := serviceHandleFrom(r)
593 if h.name == "" {
594 http.NotFound(w, r)
595 return
596 }
597 name := h.name
598 var secret string
599 err := s.serviceCredentialWrite(r.Context(), acct, h, "sql.RotateServicePrincipal", func(st *Store) error {
600 var err error
601 secret, err = st.RotateServicePrincipal(acct.ID, name, time.Now())
602 return err
603 })
604 if err != nil {
605 // Within an account the caller already owns, an unknown name is an
606 // ordinary not-found rather than a cross-tenant probe — the same
607 // reasoning findFleetBox gives for a bad box label inside an owned
608 // fleet. The sentinel is what separates it from a store failure.
609 if errors.Is(err, ErrNoServicePrincipal) {
610 http.NotFound(w, r)
611 return
612 }
613 log.Printf("console: service rotate %s/%s: %v", acct.Sqid, name, err)
614 jsonError(w, http.StatusInternalServerError, "could not rotate the credential — try again")
615 return
616 }
617 // The row survives a rotation unchanged but for its secret, so this
618 // reads back the app it is scoped to and files the audit row there
619 // (serviceAuditKey). Best-effort: a failed read files under the name.
620 row, _ := s.findServiceCredential(acct.ID, h)
621 actor := auditActor(p, acct)
622 s.audit(acct, serviceAuditKey(name, row.App), actor, "service-rotate",
623 "rotated the secret of service credential "+name+" (cli)")
624 writeJSON(w, http.StatusOK, map[string]any{
625 "name": name,
626 "account": acct.Sqid,
627 "token": secret,
628 })
629}
630
631// handleAPIServiceRevoke answers `carlos services revoke`: the credential
632// stops resolving, at once and for good.
633//
634// IT TAKES A NAME OR A LISTING ID (C36). A named service credential is
635// addressed the ergonomic way, by the name its owner chose. A legacy
636// kind='app' row has no name — nothing does, every one of them carries the
637// empty string — so the listing id is the only handle it will ever have, and
638// without it an owner could see such a credential in the listing and have no
639// way to kill it from here. serviceHandle tells the two apart by shape.
640//
641// REVOCATION IS IDEMPOTENT. An unknown name, an unknown id, an
642// already-revoked credential and a live one all answer 200
643// "result":"revoked", because the person reaching for this door is usually
644// racing a compromise and must not have to work out whether their first
645// attempt landed. What they need afterwards — that no credential of that
646// handle is live — is true in every one of those cases.
647//
648// AND IT SAYS WHICH IT WAS, in a non-authoritative "matched" boolean (C41).
649// Idempotence demands only that a SECOND call on the same handle answer like
650// the first, and "matched": false on a second revoke is both true and
651// useful. The likely miss here is not a stolen id but a typo — `revoke hubb`
652// — and an opaque 200 turns that into a false all-clear at the one door used
653// in anger. It costs nothing in disclosure either: the caller is the
654// account's owner, so "is there a live credential of this handle" is not a
655// fact being kept from them; it is in the listing one door away.
656//
657// An id belonging to ANOTHER account is a different matter, and it is
658// refused by construction: both store calls below are scoped to acct.ID, so
659// a foreign id matches nothing, reports "matched": false, and has changed
660// nothing.
661func (s *Server) handleAPIServiceRevoke(w http.ResponseWriter, r *http.Request) {
662 p, acct, ok := s.apiOwnerAccount(w, r)
663 if !ok {
664 return
665 }
666 if !s.allowWrite(p, time.Now()) {
667 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
668 return
669 }
670 actor := auditActor(p, acct)
671 h := serviceHandleFrom(r)
672 if !h.addresses() {
673 // A segment that is neither a name nor a listing id matches no row
674 // this console ever wrote, so it reports the same "nothing matched"
675 // an unknown handle does.
676 //
677 // It is AUDITED, under a subject that is not an app (C42). An
678 // attempted revoke that could not have worked is exactly the event
679 // an operator goes looking for afterwards — "I definitely revoked
680 // that" — and the early return used to leave no trace of it at all,
681 // which contradicted the principle stated at the audit call below.
682 //
683 // The handle itself is not echoed into the row. By definition it is
684 // not something this console wrote, and it is an unbounded
685 // caller-controlled path segment; keeping it out of the trail is the
686 // same reason serviceHandle refuses to pass it to the store. Who,
687 // when, and "the handle was unusable" is what the operator needs.
688 s.audit(acct, serviceAuditSubject, actor, "service-revoke",
689 "revoke attempted with a handle that is neither a credential name nor a listing id (cli)")
690 writeJSON(w, http.StatusOK, map[string]any{"result": "revoked", "matched": false})
691 return
692 }
693 // Read before the write, for the audit only: a revoked row leaves the
694 // listing, so the app it was scoped to is unknowable afterwards. The
695 // authoritative write below is keyed on what the CALLER named, never on
696 // what this found, so a failed read costs an audit row's filing and
697 // never a revoke.
698 row, found := s.findServiceCredential(acct.ID, h)
699
700 err := s.serviceCredentialWrite(r.Context(), acct, h, "sql.RevokeServicePrincipal", func(st *Store) error {
701 if h.name != "" {
702 return st.RevokeServicePrincipal(acct.ID, h.name, time.Now())
703 }
704 // The universal handle: account-scoped, and the only one that
705 // reaches a nameless row. Safe by construction — a person's own
706 // cli_tokens row carries account_id = 0, so an account-scoped
707 // revoke cannot reach a human's terminal token (RevokeAccountToken).
708 return st.RevokeAccountToken(acct.ID, h.id, time.Now())
709 })
710 if err != nil && !errors.Is(err, ErrNoServicePrincipal) {
711 log.Printf("console: service revoke %s: %v", acct.Sqid, err)
712 jsonError(w, http.StatusInternalServerError, "could not revoke the credential — try again")
713 return
714 }
715 // Audited even when nothing matched: "someone tried to revoke X" is
716 // worth having in the trail of an account whose credentials are being
717 // argued about, and the detail says which it was.
718 subject, what := serviceWhat(h, row, found)
719 detail := "revoked " + what + " (cli)"
720 if err != nil {
721 detail = "revoked " + what + " — nothing live matched (cli)"
722 }
723 s.audit(acct, serviceAuditKey(subject, row.App), actor, "service-revoke", detail)
724 // The body carries no name and nothing else request-derived, so two
725 // revokes of the same handle are the same bytes — which is what makes
726 // the idempotence above visible from the outside rather than merely true
727 // inside. `matched` is the one thing that varies, and it varies with
728 // what HAPPENED rather than with what was asked: false on the second
729 // call for the same credential the first call killed.
730 writeJSON(w, http.StatusOK, map[string]any{"result": "revoked", "matched": err == nil})
731}
732