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 pane on the account settings page (issue #220,
2// spec §6): the browser half of apiservices.go's API family and services.go's
3// CLI verbs.
4//
5// WHY THERE IS A BROWSER HALF AT ALL. Revocation is the emergency control,
6// and an emergency control that needs a terminal is not one. The person who
7// has just learned a credential leaked has a phone and a browser; making
8// them find a machine with `carlos` on it, logged in to the right console,
9// is the failure mode this pane exists to remove. That is also why revoke —
10// alone of the three — is not behind a passkey (see below).
11//
12// GATE ORDER, the one this repo pins, on all three POSTs:
13//
14// session (s.account) — signed out goes to the front page, a non-member
15// gets the byte-identical http.NotFound an unrouted
16// URL gets. Account existence is not probeable.
17// CSRF — constant-time, BEFORE any pending action can be
18// parked by a cross-site post.
19// owner 404 — a member probing an owner-only action gets a
20// stranger's answer.
21// cheap refusals — a bad name, an unknown role, an app that is not
22// here. No gesture could change any of those answers,
23// so asking for one would waste it (handleStoreDestroy
24// refuses its no-backend case in the same place).
25// requireFreshAuth — LAST of the auth gates, so no redirect into Keymail
26// ever happens for someone who should have got a 404.
27//
28// STEP-UP IS ASYMMETRIC HERE, AND DELIBERATELY SO.
29//
30// create and rotate take requireFreshAuth: each one hands the operator a
31// WORKING production credential, which is at least as consequential as the
32// app deletion that already steps up, and a stolen session that can mint an
33// admin credential has escalated itself past the session's own lifetime.
34//
35// revoke does NOT. It only REMOVES authority: the worst a stolen session can
36// do with it is turn a service off, which is loud, recoverable in one mint,
37// and nothing like a silent grant. Against that sits the whole reason this
38// pane exists — someone racing a compromise must not be held at a passkey
39// round trip, which fails closed (stepUpUnavailable) exactly when the
40// identity provider is having the bad day that tends to accompany one.
41//
42// This is STRICTER than the nearest precedent and the divergence is
43// deliberate rather than an oversight: ownerFleet (fleets.go) steps up
44// nothing at all, including add-box, which also mints a bearer token. The
45// judgment here is that minting a credential whose reach is decided once and
46// outlives its minter deserves the gesture; the next reader should see a
47// choice, not an inconsistency.
48//
49// The API doors take no step-up at all and that stays true — requireFreshAuth
50// is session and passkey machinery, and a bearer door has no session to
51// re-prove.
52//
53// WHAT THE ONCE-ONLY RULE COSTS. create and rotate answer 200 with the page
54// rather than redirecting to it (POST/redirect/GET), because a redirect
55// would have to carry the plaintext somewhere the next request could read it
56// — a flash cookie, or server-side storage of a live secret — and both are
57// worse than the thing they would fix. The cost is that a browser RELOAD
58// re-posts. On create that is refused by the name's uniqueness
59// (ErrServiceNameTaken); on ROTATE it rotates again, which retires the
60// secret the operator has just copied. The same trade
61// handleFleetRotateBoxPage already makes. Do not "fix" it with a flash.
62//
63// The re-post hazard is REAL ONLY WHEN STEP-UP WAS ALREADY FRESH. When the
64// passkey was demanded, what the address bar holds is the step-up CALLBACK,
65// not this form, and reloading that re-runs a pending action TakePendingAction
66// has already burned — so it neither mints nor rotates, it reports that the
67// action expired. The page over-warns on that path, which is the safe
68// direction, and says which path the reader is on.
69//
70// The trade is ACCEPTED, and the honest half of accepting it is that the
71// PAGE SAYS SO, next to the secret and differently for each verb (C47,
72// account_settings.html). The browser's own "confirm form resubmission"
73// prompt is not a guard anyone reads, and an operator who has just pasted a
74// credential into a config must not discover this by losing it. A single-use
75// form nonce would remove the hazard and is real machinery on a page used a
76// handful of times a year; the sentence is the proportionate answer. If that
77// line is ever edited away, this trade stops being one.
78package console
79
80import (
81 "crypto/subtle"
82 "errors"
83 "fmt"
84 "log"
85 "net/http"
86 "net/url"
87 "strings"
88 "time"
89
90 "github.com/carlosframework/platform/internal/registry"
91 "github.com/carlosframework/platform/internal/runs"
92 "github.com/carlosframework/platform/internal/svcrole"
93)
94
95// ServiceSecretView is the once-only block: a freshly minted or freshly
96// rotated plaintext, rendered DIRECTLY in the response to the POST that
97// produced it.
98//
99// Never a flash and never a redirect. handleFleetRotateBoxPage established
100// the shape and the reason is sharper here: a flash is a cookie, so
101// flashing a credential would write a live bearer secret into the browser's
102// cookie jar and hand it back on the next page view. Rendered in place, it
103// appears exactly once, the same discipline printServiceCreated (services.go)
104// holds at the terminal.
105type ServiceSecretView struct {
106 Name string
107 Role string
108 Scope string // "the whole account" or "app <name>"
109 // Rungs is a deploy credential's rungs, "canary, edge"; empty for every
110 // other role.
111 Rungs string
112 // Commands is a run credential's commands, "import classic, import
113 // sync"; empty for every other role.
114 Commands string
115 Token string
116 // Doors is what the credential may actually reach, read from the SAME
117 // role table the gate consults (doorsFor) — the create response's
118 // `doors`, in a browser. Empty on rotate, which grants nothing new.
119 Doors []string
120 // Rotated switches the block's copy between "minted" and "rotated". The
121 // rotation warning is not decoration: ROTATION IS NOT REVOCATION, and an
122 // operator who reaches for rotate when they meant revoke has left a
123 // compromised credential's work in flight.
124 Rotated bool
125 // App is the app the credential is bound to, "" for account-scoped, so
126 // the deliver-it line can name it rather than printing a placeholder.
127 App string
128 // ScopeKnown says Role and Scope are FACTS rather than defaults.
129 //
130 // Create fills them from the form it just validated, so they are always
131 // known there. Rotate reads them back with findServiceCredential, which
132 // is best-effort by design — and a failed read leaves App "", which
133 // serviceScopeWords renders as "the whole account". That would be a LIE
134 // about an app-scoped credential, on the screen an operator is using to
135 // check what they just re-armed. False beats absent here, so the flag
136 // exists and the template withholds both fields rather than guessing.
137 ScopeKnown bool
138}
139
140// DeliverCommand is the ONE command the pane tells an operator to run to get
141// the secret into a running app — the same `carlos secrets set` line
142// deliverLine (services.go) prints, and for the same reason: `carlos env set`
143// would write the credential as UNSEALED plaintext config. Never the token
144// itself, only the literal placeholder, so the secret appears once on its own
145// line and not a second time inside a copy-paste command.
146func (v ServiceSecretView) DeliverCommand() string {
147 app := "<app>"
148 if v.App != "" {
149 app = v.App
150 }
151 return "carlos secrets set --app " + app + " CARLOS_SERVICE_TOKEN=<secret>"
152}
153
154// accountSettingsURL is where all three POSTs send a browser on an ordinary
155// (non-secret) outcome.
156func accountSettingsURL(sqid string) string {
157 return "/a/" + url.PathEscape(sqid) + "/settings"
158}
159
160// servicePane fills the owner-only half of the account settings page.
161//
162// Called ONLY for an owner. A member sees the page without this section
163// rather than with an empty one: an empty section reads as "this account has
164// no service credentials", which is a different and possibly false
165// statement. That is why the pane needs no new template condition — it goes
166// inside the {{if .IsOwner}} block that already exists — and why the fields
167// below are left zero for everyone else.
168//
169// Every read is best-effort in the sense that it degrades to a stated
170// warning rather than to a silent lie. A failed listing must not render as
171// "no credentials"; it says so, because an owner who concludes an account
172// holds nothing is an owner who stops looking.
173func (s *Server) servicePane(p *page, acct *Account) {
174 rows, err := s.store.ServicePrincipals(acct.ID)
175 if err != nil {
176 log.Printf("console: service pane %s: %v", acct.Sqid, err)
177 p.ServicesUnknown = true
178 if p.FlashErr == "" {
179 p.FlashErr = "Could not read this account's service credentials."
180 }
181 } else {
182 p.Services = rows
183 }
184 for _, role := range svcrole.All() {
185 p.ServiceRoles = append(p.ServiceRoles, string(role))
186 }
187 p.ServiceRungs = deployRungs
188 // The mint form's app choices. A select rather than a text box because
189 // the door refuses an app this account does not hold anyway, and a typo
190 // that costs a round trip through the passkey is a bad trade. A failed
191 // read costs the app options, never the form: an account-wide credential
192 // is still mintable.
193 if apps, err := s.store.AppsIn(acct.ID); err != nil {
194 log.Printf("console: service pane apps %s: %v", acct.Sqid, err)
195 } else {
196 p.ServiceApps = apps
197 }
198}
199
200// serviceSettingsPage rebuilds the whole account settings page for a
201// response that is NOT a redirect — the two secret-bearing outcomes.
202//
203// AppCount's failure is logged and swallowed here where handleAccountSettings
204// answers 500, and the difference is deliberate: this response carries a
205// credential that exists nowhere else, so failing the render would destroy a
206// secret the database has already accepted. HasApps only decides whether the
207// delete form draws; handleDeleteAccount re-checks the count itself before
208// deleting anything (ownership.go), so a wrong guess here costs a form that
209// refuses, not a deletion.
210func (s *Server) serviceSettingsPage(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account) page {
211 p := s.accountPage(w, r, addr, raw, acct, RoleOwner)
212 p.Title = "Account settings"
213 p.ActiveNav = "settings"
214 if n, err := s.store.AppCount(acct.ID); err != nil {
215 log.Printf("console: app count %s: %v", acct.Sqid, err)
216 } else {
217 p.HasApps = n > 0
218 }
219 s.servicePane(&p, acct)
220 return p
221}
222
223// renderServiceSecret writes the once-only response.
224//
225// Cache-Control: no-store for the same reason writeJSON sets it on the API's
226// own token bodies (authcli.go) — and it is MORE load-bearing here, not
227// less: an HTML page gets a second life a JSON response never gets, in the
228// bfcache and in session restore.
229func (s *Server) renderServiceSecret(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, secret *ServiceSecretView) {
230 p := s.serviceSettingsPage(w, r, addr, raw, acct)
231 p.ServiceSecret = secret
232 w.Header().Set("Cache-Control", "no-store")
233 s.render(w, http.StatusOK, "account_settings", p)
234}
235
236// serviceOwner is the shared front of all three POSTs: session and account
237// (byte-identical 404), then CSRF, then the owner's own 404. Exactly
238// handleAppDomainAttach's order, which is exactly handleAppDelete's minus
239// the parts that do not apply.
240//
241// It returns before any step-up so each handler can put its own cheap
242// refusals between this and requireFreshAuth.
243func (s *Server) serviceOwner(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, ok bool) {
244 addr, raw, acct, role, ok := s.account(w, r)
245 if !ok {
246 return "", "", nil, false
247 }
248 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
249 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
250 return "", "", nil, false
251 }
252 if role != RoleOwner {
253 http.NotFound(w, r)
254 return "", "", nil, false
255 }
256 return addr, raw, acct, true
257}
258
259// handleServiceCreatePage is the pane's mint button: the same
260// NewServicePrincipal write handleAPIServiceCreate makes, with the fresh
261// plaintext shown directly in this response and nowhere else.
262//
263// Gate order: session → CSRF → owner 404 → name/role/app refusals →
264// requireFreshAuth LAST. See this file's doc comment for why create is
265// stepped up at all.
266func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request) {
267 addr, raw, acct, ok := s.serviceOwner(w, r)
268 if !ok {
269 return
270 }
271 back := func(kind, msg string) {
272 s.setFlash(w, kind, msg)
273 http.Redirect(w, r, accountSettingsURL(acct.Sqid), http.StatusSeeOther)
274 }
275 // registry.ValidTunnelName, the same grammar handleAPIServiceCreate
276 // applies — this is a handle rotate and revoke address the credential by
277 // for the rest of its life, at a terminal as well as here.
278 name := strings.TrimSpace(r.FormValue("name"))
279 if !registry.ValidTunnelName(name) {
280 back("err", "That is not a usable credential name — 1-32 characters of a-z, 0-9 and hyphen. Nothing was minted.")
281 return
282 }
283 role, roleOK := svcrole.Parse(strings.TrimSpace(r.FormValue("role")))
284 if !roleOK {
285 back("err", "Choose a role for the credential — one of: "+strings.Join(roleWords(), ", ")+". Nothing was minted.")
286 return
287 }
288 // An app-scoped credential must name an app this account actually holds.
289 // Otherwise the mint succeeds against nothing, and the day somebody
290 // creates an app of that name the credential silently acquires authority
291 // over an app its minter never chose.
292 app := strings.TrimSpace(r.FormValue("app"))
293 // Said HERE as well as in NewServicePrincipal, and the duplication is
294 // deliberate rather than the thing §3.4 warns about. The store keeps the
295 // AUTHORITY — one place, so neither mint door can forget it — and this is
296 // a validation, refusing before the passkey trip the way every other
297 // unusable field on this form does. A form that sent an owner through a
298 // step-up to be told "could not mint, try again" would send them round the
299 // same impossible loop; the arm below still catches it if this drifts.
300 if role == svcrole.Service && app == "" {
301 back("err", "A service credential must name one app. It is held by an app at runtime, "+
302 "and an account-wide one would reach every app in this account. Nothing was minted.")
303 return
304 }
305 // The rung refusals, same reasoning: the store stays the authority and
306 // these spare the owner a passkey trip for an impossible request.
307 canon, rerr := canonicalRungs(r.Form["rungs"])
308 var ue *UnknownRungError
309 switch {
310 case errors.Is(rerr, ErrStableNotDelegable):
311 back("err", "Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted.")
312 return
313 case errors.As(rerr, &ue):
314 back("err", fmt.Sprintf("“%s” is not a rung. Choose canary, edge or beta. Nothing was minted.", ue.Rung))
315 return
316 case role == svcrole.Deploy && app == "":
317 back("err", "A deploy credential must name one app. Nothing was minted.")
318 return
319 case role == svcrole.Deploy && canon == "":
320 back("err", "A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted.")
321 return
322 case role != svcrole.Deploy && canon != "":
323 back("err", "Only a deploy credential takes rungs. Nothing was minted.")
324 return
325 }
326 // The run refusals, same reasoning again. The field is the CLI's
327 // --commands text: prefixes separated by commas, words by spaces.
328 commands := runs.ParseCommandList(r.FormValue("commands"))
329 var cerr error
330 switch {
331 case role == svcrole.Run && app == "":
332 cerr = ErrAccountScopedRun
333 case role == svcrole.Run && len(commands) == 0:
334 cerr = ErrRunNeedsCommands
335 case role == svcrole.Run:
336 cerr = runs.CheckCommandPrefixes(commands)
337 case len(commands) != 0:
338 cerr = ErrCommandsNeedRun
339 }
340 if msg, ok := runMintRefusal(cerr); ok {
341 back("err", runMintSentence(msg))
342 return
343 }
344 if app != "" {
345 found, err := s.store.HasAppIn(acct.ID, app)
346 if err != nil {
347 log.Printf("console: service create %s/%s: has-app %s: %v", acct.Sqid, name, app, err)
348 back("err", "Could not check that app — nothing was minted. Try again.")
349 return
350 }
351 if !found {
352 back("err", "There is no app called “"+app+"” in this account. Nothing was minted.")
353 return
354 }
355 }
356 if !s.requireFreshAuth(w, r, addr, raw) {
357 return
358 }
359 secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, r.Form["rungs"], commands, addr, time.Now())
360 if err != nil {
361 // The sentinel, not a string match: a name collision is the owner's
362 // to fix and a busy database is not.
363 if errors.Is(err, ErrServiceNameTaken) {
364 back("err", "This account already has a live credential called “"+name+"”. Nothing was minted.")
365 return
366 }
367 // The form is the door that OFFERS an account-wide credential, so it
368 // is the one most likely to meet this refusal — and the one where
369 // "could not mint, try again" is worst: the owner would retry the
370 // same impossible request. Same words as the API door, so the two
371 // cannot describe the same rule differently.
372 if errors.Is(err, ErrAccountScopedService) {
373 back("err", "A service credential must name one app. It is held by an app at runtime, "+
374 "and an account-wide one would reach every app in this account. Nothing was minted.")
375 return
376 }
377 // The store is the authority; these arms say the same words as the
378 // pre-checks above if one of them ever drifts.
379 var ue *UnknownRungError
380 switch {
381 case errors.Is(err, ErrAccountScopedDeploy):
382 back("err", "A deploy credential must name one app. Nothing was minted.")
383 return
384 case errors.Is(err, ErrDeployNeedsRungs):
385 back("err", "A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted.")
386 return
387 case errors.Is(err, ErrStableNotDelegable):
388 back("err", "Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted.")
389 return
390 case errors.As(err, &ue):
391 back("err", fmt.Sprintf("“%s” is not a rung. Choose canary, edge or beta. Nothing was minted.", ue.Rung))
392 return
393 case errors.Is(err, ErrRungsNeedDeploy):
394 back("err", "Only a deploy credential takes rungs. Nothing was minted.")
395 return
396 }
397 if msg, ok := runMintRefusal(err); ok {
398 back("err", runMintSentence(msg))
399 return
400 }
401 log.Printf("console: service create %s/%s: %v", acct.Sqid, name, err)
402 back("err", "Could not mint the credential — try again.")
403 return
404 }
405 // serviceScopeWords, not a second inline if: the pane, the audit row and
406 // the rotate response all have to say the scope the same way, and the
407 // API's own create door already speaks these exact words.
408 scope := serviceScopeWords(app)
409 // serviceAuditKey, so an app-scoped credential's grant lands in THAT
410 // app's activity feed — the one place its owner looks (C38).
411 detail := "minted service credential " + name + " with role " + string(role) + " over " + scope + " (console)"
412 switch role {
413 case svcrole.Deploy:
414 detail = fmt.Sprintf("minted service credential %s with role deploy over app %s, rungs %s (%s)", name, app, canon, "console")
415 case svcrole.Run:
416 detail = fmt.Sprintf("minted service credential %s with role run over app %s, commands %s (%s)", name, app, runs.FormatCommandList(commands), "console")
417 }
418 s.audit(acct, serviceAuditKey(name, app), addr, "service-create", detail)
419 s.renderServiceSecret(w, r, addr, raw, acct, &ServiceSecretView{
420 Name: name, Role: string(role), Scope: scope, App: app,
421 Rungs: strings.ReplaceAll(canon, ",", ", "),
422 Commands: runs.FormatCommandList(commands),
423 Token: secret, Doors: s.doorsFor(role), ScopeKnown: true,
424 })
425}
426
427// runMintSentence turns a runMintRefusal into the form's sentence: capital
428// first, and the same "Nothing was minted." every refusal here ends with.
429func runMintSentence(msg string) string {
430 return strings.ToUpper(msg[:1]) + msg[1:] + ". Nothing was minted."
431}
432
433// handleServiceRotatePage is the pane's rotate button: a fresh secret for a
434// credential that already exists.
435//
436// ROTATION KEEPS THE IDENTITY — same row, same name, same role, same scope,
437// same audit history and same rate-limit budget. What it does immediately is
438// stop the OLD secret resolving. It is NAME-only, exactly as
439// handleAPIServiceRotate is: a legacy kind='app' row has no name and no
440// rotate path at all, so the pane draws no rotate button beside one.
441//
442// Gate order: session → CSRF → owner 404 → name refusal → requireFreshAuth
443// LAST.
444func (s *Server) handleServiceRotatePage(w http.ResponseWriter, r *http.Request) {
445 addr, raw, acct, ok := s.serviceOwner(w, r)
446 if !ok {
447 return
448 }
449 back := func(kind, msg string) {
450 s.setFlash(w, kind, msg)
451 http.Redirect(w, r, accountSettingsURL(acct.Sqid), http.StatusSeeOther)
452 }
453 name := strings.TrimSpace(r.FormValue("name"))
454 if !registry.ValidTunnelName(name) {
455 back("err", "That is not a credential this account can rotate. Nothing was rotated.")
456 return
457 }
458 if !s.requireFreshAuth(w, r, addr, raw) {
459 return
460 }
461 var secret string
462 err := s.serviceCredentialWrite(r.Context(), acct, serviceHandle{name: name}, "sql.RotateServicePrincipal", func(st *Store) error {
463 var err error
464 secret, err = st.RotateServicePrincipal(acct.ID, name, time.Now())
465 return err
466 })
467 if err != nil {
468 if errors.Is(err, ErrNoServicePrincipal) {
469 back("err", "There is no live credential called “"+name+"” in this account. Nothing was rotated.")
470 return
471 }
472 log.Printf("console: service rotate %s/%s: %v", acct.Sqid, name, err)
473 back("err", "Could not rotate the credential — try again.")
474 return
475 }
476 // Read back for the audit's filing only (serviceAuditKey), the same
477 // best-effort lookup handleAPIServiceRotate makes: a failed read files
478 // the row under the credential's own name and costs nothing else.
479 row, found := s.findServiceCredential(acct.ID, serviceHandle{name: name})
480 s.audit(acct, serviceAuditKey(name, row.App), addr, "service-rotate",
481 "rotated the secret of service credential "+name+" (console)")
482 // found carries through to ScopeKnown (Minor 4): an operator re-arming a
483 // credential is shown WHAT they re-armed, and a read-back that failed
484 // says so rather than defaulting to "the whole account" — which would be
485 // wrong about exactly the credentials it matters most to be right about.
486 s.renderServiceSecret(w, r, addr, raw, acct, &ServiceSecretView{
487 Name: name, Role: row.Role, Scope: serviceScopeWords(row.App), App: row.App,
488 Token: secret, Rotated: true, ScopeKnown: found,
489 })
490}
491
492// handleServiceRevokePage is the pane's revoke button, and the reason the
493// pane exists: the credential stops resolving, at once and for good, without
494// a terminal and without a passkey round trip.
495//
496// IT TAKES A NAME OR A LISTING ID, read through serviceHandleOf — the API
497// door's own grammar, so the two cannot drift. A legacy kind='app' row has
498// no name, so its listing id is the only handle it will ever have; without
499// that arm an owner could SEE such a credential here and have no way to kill
500// it.
501//
502// REVOCATION IS IDEMPOTENT, AND THE PAGE SAYS WHICH IT WAS. An unknown
503// handle, an already-revoked credential and a live one all leave the same
504// state behind — no credential of that handle is live — so none of them is
505// an error. But a page that says "Revoked" when a stale handle matched
506// nothing is a false all-clear at the one door used in anger, and the likely
507// miss is a typo rather than a race. The two outcomes therefore get visibly
508// different words, the same distinction `carlos services revoke` draws
509// between "revoked" and "nothing to revoke".
510//
511// THERE IS DELIBERATELY NO TYPED CONFIRMATION HERE (C48), though this repo
512// has the idiom and uses it for app and account deletion. Every gate in front
513// of the emergency control is friction at exactly the wrong moment, and a
514// confirm while the passkey has been REMOVED would be incoherent besides: it
515// would say the act is grave enough to interrupt but not grave enough to
516// re-authenticate. The cost is real — a mis-click kills a live credential —
517// and it is the most recoverable destructive act on this surface: mint a
518// replacement and redistribute. App deletion, which the typed confirmation
519// guards, is not recoverable that way, which is why the idiom belongs there
520// and not here. The audit row names who pressed it.
521//
522// Gate order: session → CSRF → owner 404 → act. No requireFreshAuth — see
523// this file's doc comment.
524func (s *Server) handleServiceRevokePage(w http.ResponseWriter, r *http.Request) {
525 // The session token is deliberately dropped: revoke takes no step-up, so
526 // there is nothing here that needs it. serviceOwner has already used it
527 // for the CSRF comparison.
528 addr, _, acct, ok := s.serviceOwner(w, r)
529 if !ok {
530 return
531 }
532 back := func(kind, msg string) {
533 s.setFlash(w, kind, msg)
534 http.Redirect(w, r, accountSettingsURL(acct.Sqid), http.StatusSeeOther)
535 }
536 h := serviceHandleOf(strings.TrimSpace(r.FormValue("handle")))
537 if !h.addresses() {
538 // A segment that is neither a name nor a listing id matches no row
539 // this console ever wrote. Audited all the same, under a subject that
540 // is not an app (serviceAuditSubject): an attempted revoke that could
541 // not have worked is exactly the event an operator goes looking for
542 // afterwards. The handle itself is not echoed into the trail — it is
543 // unbounded caller-controlled text by definition.
544 s.audit(acct, serviceAuditSubject, addr, "service-revoke",
545 "revoke attempted with a handle that is neither a credential name nor a listing id (console)")
546 back("err", "Nothing live matched that — nothing to revoke.")
547 return
548 }
549 // Read before the write, for the audit's filing only: a revoked row
550 // leaves the listing, so the app it was scoped to is unknowable
551 // afterwards. The authoritative write below is keyed on what the CALLER
552 // named, never on what this found, so a failed read costs an audit row's
553 // filing and never a revoke.
554 row, found := s.findServiceCredential(acct.ID, h)
555
556 err := s.serviceCredentialWrite(r.Context(), acct, h, "sql.RevokeServicePrincipal", func(st *Store) error {
557 if h.name != "" {
558 return st.RevokeServicePrincipal(acct.ID, h.name, time.Now())
559 }
560 // The universal handle, account-scoped, and the only one that reaches
561 // a nameless row. Safe by construction: a person's cli_tokens row
562 // carries account_id = 0, so an account-scoped revoke can never reach
563 // a human's terminal token (RevokeAccountToken).
564 return st.RevokeAccountToken(acct.ID, h.id, time.Now())
565 })
566 if err != nil && !errors.Is(err, ErrNoServicePrincipal) {
567 log.Printf("console: service revoke %s: %v", acct.Sqid, err)
568 back("err", "Could not revoke the credential — it may still be live. Try again.")
569 return
570 }
571 subject, what := serviceWhat(h, row, found)
572 detail := "revoked " + what + " (console)"
573 if err != nil {
574 detail = "revoked " + what + " — nothing live matched (console)"
575 }
576 s.audit(acct, serviceAuditKey(subject, row.App), addr, "service-revoke", detail)
577 if err != nil {
578 // Deliberately without the word "revoked" in it: a miss that reads as
579 // a kill is the exact failure this branch exists to prevent.
580 back("err", "Nothing live matched “"+serviceHandleWords(h)+"” — nothing to revoke (already gone, or never existed).")
581 return
582 }
583 back("ok", "Revoked "+what+" — its next call fails, whatever secret it holds.")
584}
585
586// serviceHandleWords is the handle as the flash says it back. It is the
587// PARSED handle, never the raw form value, so nothing unbounded and
588// caller-controlled reaches the page — the same reason the audit row above
589// does not echo it.
590func serviceHandleWords(h serviceHandle) string {
591 if h.name != "" {
592 return h.name
593 }
594 return h.id
595}
596
597// serviceScopeWords renders a credential's scope in the words the pane and
598// the audit trail both use.
599func serviceScopeWords(app string) string {
600 if app == "" {
601 return "the whole account"
602 }
603 return "app " + app
604}
605
606// roleWords is svcrole's catalogue as strings, read from the package rather
607// than hand-listed, the same discipline doorsFor applies to the door list.
608func roleWords() []string {
609 roles := svcrole.All()
610 out := make([]string, len(roles))
611 for i, r := range roles {
612 out[i] = string(r)
613 }
614 return out
615}
616