| 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. |
| 78 | package console |
| 79 | |
| 80 | import ( |
| 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. |
| 105 | type 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. |
| 146 | func (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. |
| 156 | func 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. |
| 173 | func (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. |
| 210 | func (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. |
| 229 | func (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. |
| 243 | func (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. |
| 266 | func (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. |
| 429 | func 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. |
| 444 | func (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. |
| 524 | func (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. |
| 590 | func 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. |
| 599 | func 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. |
| 608 | func 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 | |