// The service-credential pane on the account settings page (issue #220, // spec §6): the browser half of apiservices.go's API family and services.go's // CLI verbs. // // WHY THERE IS A BROWSER HALF AT ALL. Revocation is the emergency control, // and an emergency control that needs a terminal is not one. The person who // has just learned a credential leaked has a phone and a browser; making // them find a machine with `carlos` on it, logged in to the right console, // is the failure mode this pane exists to remove. That is also why revoke — // alone of the three — is not behind a passkey (see below). // // GATE ORDER, the one this repo pins, on all three POSTs: // // session (s.account) — signed out goes to the front page, a non-member // gets the byte-identical http.NotFound an unrouted // URL gets. Account existence is not probeable. // CSRF — constant-time, BEFORE any pending action can be // parked by a cross-site post. // owner 404 — a member probing an owner-only action gets a // stranger's answer. // cheap refusals — a bad name, an unknown role, an app that is not // here. No gesture could change any of those answers, // so asking for one would waste it (handleStoreDestroy // refuses its no-backend case in the same place). // requireFreshAuth — LAST of the auth gates, so no redirect into Keymail // ever happens for someone who should have got a 404. // // STEP-UP IS ASYMMETRIC HERE, AND DELIBERATELY SO. // // create and rotate take requireFreshAuth: each one hands the operator a // WORKING production credential, which is at least as consequential as the // app deletion that already steps up, and a stolen session that can mint an // admin credential has escalated itself past the session's own lifetime. // // revoke does NOT. It only REMOVES authority: the worst a stolen session can // do with it is turn a service off, which is loud, recoverable in one mint, // and nothing like a silent grant. Against that sits the whole reason this // pane exists — someone racing a compromise must not be held at a passkey // round trip, which fails closed (stepUpUnavailable) exactly when the // identity provider is having the bad day that tends to accompany one. // // This is STRICTER than the nearest precedent and the divergence is // deliberate rather than an oversight: ownerFleet (fleets.go) steps up // nothing at all, including add-box, which also mints a bearer token. The // judgment here is that minting a credential whose reach is decided once and // outlives its minter deserves the gesture; the next reader should see a // choice, not an inconsistency. // // The API doors take no step-up at all and that stays true — requireFreshAuth // is session and passkey machinery, and a bearer door has no session to // re-prove. // // WHAT THE ONCE-ONLY RULE COSTS. create and rotate answer 200 with the page // rather than redirecting to it (POST/redirect/GET), because a redirect // would have to carry the plaintext somewhere the next request could read it // — a flash cookie, or server-side storage of a live secret — and both are // worse than the thing they would fix. The cost is that a browser RELOAD // re-posts. On create that is refused by the name's uniqueness // (ErrServiceNameTaken); on ROTATE it rotates again, which retires the // secret the operator has just copied. The same trade // handleFleetRotateBoxPage already makes. Do not "fix" it with a flash. // // The re-post hazard is REAL ONLY WHEN STEP-UP WAS ALREADY FRESH. When the // passkey was demanded, what the address bar holds is the step-up CALLBACK, // not this form, and reloading that re-runs a pending action TakePendingAction // has already burned — so it neither mints nor rotates, it reports that the // action expired. The page over-warns on that path, which is the safe // direction, and says which path the reader is on. // // The trade is ACCEPTED, and the honest half of accepting it is that the // PAGE SAYS SO, next to the secret and differently for each verb (C47, // account_settings.html). The browser's own "confirm form resubmission" // prompt is not a guard anyone reads, and an operator who has just pasted a // credential into a config must not discover this by losing it. A single-use // form nonce would remove the hazard and is real machinery on a page used a // handful of times a year; the sentence is the proportionate answer. If that // line is ever edited away, this trade stops being one. package console import ( "crypto/subtle" "errors" "fmt" "log" "net/http" "net/url" "strings" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/runs" "github.com/carlosframework/platform/internal/svcrole" ) // ServiceSecretView is the once-only block: a freshly minted or freshly // rotated plaintext, rendered DIRECTLY in the response to the POST that // produced it. // // Never a flash and never a redirect. handleFleetRotateBoxPage established // the shape and the reason is sharper here: a flash is a cookie, so // flashing a credential would write a live bearer secret into the browser's // cookie jar and hand it back on the next page view. Rendered in place, it // appears exactly once, the same discipline printServiceCreated (services.go) // holds at the terminal. type ServiceSecretView struct { Name string Role string Scope string // "the whole account" or "app " // Rungs is a deploy credential's rungs, "canary, edge"; empty for every // other role. Rungs string // Commands is a run credential's commands, "import classic, import // sync"; empty for every other role. Commands string Token string // Doors is what the credential may actually reach, read from the SAME // role table the gate consults (doorsFor) — the create response's // `doors`, in a browser. Empty on rotate, which grants nothing new. Doors []string // Rotated switches the block's copy between "minted" and "rotated". The // rotation warning is not decoration: ROTATION IS NOT REVOCATION, and an // operator who reaches for rotate when they meant revoke has left a // compromised credential's work in flight. Rotated bool // App is the app the credential is bound to, "" for account-scoped, so // the deliver-it line can name it rather than printing a placeholder. App string // ScopeKnown says Role and Scope are FACTS rather than defaults. // // Create fills them from the form it just validated, so they are always // known there. Rotate reads them back with findServiceCredential, which // is best-effort by design — and a failed read leaves App "", which // serviceScopeWords renders as "the whole account". That would be a LIE // about an app-scoped credential, on the screen an operator is using to // check what they just re-armed. False beats absent here, so the flag // exists and the template withholds both fields rather than guessing. ScopeKnown bool } // DeliverCommand is the ONE command the pane tells an operator to run to get // the secret into a running app — the same `carlos secrets set` line // deliverLine (services.go) prints, and for the same reason: `carlos env set` // would write the credential as UNSEALED plaintext config. Never the token // itself, only the literal placeholder, so the secret appears once on its own // line and not a second time inside a copy-paste command. func (v ServiceSecretView) DeliverCommand() string { app := "" if v.App != "" { app = v.App } return "carlos secrets set --app " + app + " CARLOS_SERVICE_TOKEN=" } // accountSettingsURL is where all three POSTs send a browser on an ordinary // (non-secret) outcome. func accountSettingsURL(sqid string) string { return "/a/" + url.PathEscape(sqid) + "/settings" } // servicePane fills the owner-only half of the account settings page. // // Called ONLY for an owner. A member sees the page without this section // rather than with an empty one: an empty section reads as "this account has // no service credentials", which is a different and possibly false // statement. That is why the pane needs no new template condition — it goes // inside the {{if .IsOwner}} block that already exists — and why the fields // below are left zero for everyone else. // // Every read is best-effort in the sense that it degrades to a stated // warning rather than to a silent lie. A failed listing must not render as // "no credentials"; it says so, because an owner who concludes an account // holds nothing is an owner who stops looking. func (s *Server) servicePane(p *page, acct *Account) { rows, err := s.store.ServicePrincipals(acct.ID) if err != nil { log.Printf("console: service pane %s: %v", acct.Sqid, err) p.ServicesUnknown = true if p.FlashErr == "" { p.FlashErr = "Could not read this account's service credentials." } } else { p.Services = rows } for _, role := range svcrole.All() { p.ServiceRoles = append(p.ServiceRoles, string(role)) } p.ServiceRungs = deployRungs // The mint form's app choices. A select rather than a text box because // the door refuses an app this account does not hold anyway, and a typo // that costs a round trip through the passkey is a bad trade. A failed // read costs the app options, never the form: an account-wide credential // is still mintable. if apps, err := s.store.AppsIn(acct.ID); err != nil { log.Printf("console: service pane apps %s: %v", acct.Sqid, err) } else { p.ServiceApps = apps } } // serviceSettingsPage rebuilds the whole account settings page for a // response that is NOT a redirect — the two secret-bearing outcomes. // // AppCount's failure is logged and swallowed here where handleAccountSettings // answers 500, and the difference is deliberate: this response carries a // credential that exists nowhere else, so failing the render would destroy a // secret the database has already accepted. HasApps only decides whether the // delete form draws; handleDeleteAccount re-checks the count itself before // deleting anything (ownership.go), so a wrong guess here costs a form that // refuses, not a deletion. func (s *Server) serviceSettingsPage(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account) page { p := s.accountPage(w, r, addr, raw, acct, RoleOwner) p.Title = "Account settings" p.ActiveNav = "settings" if n, err := s.store.AppCount(acct.ID); err != nil { log.Printf("console: app count %s: %v", acct.Sqid, err) } else { p.HasApps = n > 0 } s.servicePane(&p, acct) return p } // renderServiceSecret writes the once-only response. // // Cache-Control: no-store for the same reason writeJSON sets it on the API's // own token bodies (authcli.go) — and it is MORE load-bearing here, not // less: an HTML page gets a second life a JSON response never gets, in the // bfcache and in session restore. func (s *Server) renderServiceSecret(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, secret *ServiceSecretView) { p := s.serviceSettingsPage(w, r, addr, raw, acct) p.ServiceSecret = secret w.Header().Set("Cache-Control", "no-store") s.render(w, http.StatusOK, "account_settings", p) } // serviceOwner is the shared front of all three POSTs: session and account // (byte-identical 404), then CSRF, then the owner's own 404. Exactly // handleAppDomainAttach's order, which is exactly handleAppDelete's minus // the parts that do not apply. // // It returns before any step-up so each handler can put its own cheap // refusals between this and requireFreshAuth. func (s *Server) serviceOwner(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, ok bool) { addr, raw, acct, role, ok := s.account(w, r) if !ok { return "", "", nil, false } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return "", "", nil, false } if role != RoleOwner { http.NotFound(w, r) return "", "", nil, false } return addr, raw, acct, true } // handleServiceCreatePage is the pane's mint button: the same // NewServicePrincipal write handleAPIServiceCreate makes, with the fresh // plaintext shown directly in this response and nowhere else. // // Gate order: session → CSRF → owner 404 → name/role/app refusals → // requireFreshAuth LAST. See this file's doc comment for why create is // stepped up at all. func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request) { addr, raw, acct, ok := s.serviceOwner(w, r) if !ok { return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, accountSettingsURL(acct.Sqid), http.StatusSeeOther) } // registry.ValidTunnelName, the same grammar handleAPIServiceCreate // applies — this is a handle rotate and revoke address the credential by // for the rest of its life, at a terminal as well as here. name := strings.TrimSpace(r.FormValue("name")) if !registry.ValidTunnelName(name) { back("err", "That is not a usable credential name — 1-32 characters of a-z, 0-9 and hyphen. Nothing was minted.") return } role, roleOK := svcrole.Parse(strings.TrimSpace(r.FormValue("role"))) if !roleOK { back("err", "Choose a role for the credential — one of: "+strings.Join(roleWords(), ", ")+". Nothing was minted.") return } // An app-scoped credential must name an app this account actually holds. // Otherwise the mint succeeds against nothing, and the day somebody // creates an app of that name the credential silently acquires authority // over an app its minter never chose. app := strings.TrimSpace(r.FormValue("app")) // Said HERE as well as in NewServicePrincipal, and the duplication is // deliberate rather than the thing §3.4 warns about. The store keeps the // AUTHORITY — one place, so neither mint door can forget it — and this is // a validation, refusing before the passkey trip the way every other // unusable field on this form does. A form that sent an owner through a // step-up to be told "could not mint, try again" would send them round the // same impossible loop; the arm below still catches it if this drifts. if role == svcrole.Service && app == "" { back("err", "A service credential must name one app. It is held by an app at runtime, "+ "and an account-wide one would reach every app in this account. Nothing was minted.") return } // The rung refusals, same reasoning: the store stays the authority and // these spare the owner a passkey trip for an impossible request. canon, rerr := canonicalRungs(r.Form["rungs"]) var ue *UnknownRungError switch { case errors.Is(rerr, ErrStableNotDelegable): back("err", "Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted.") return case errors.As(rerr, &ue): back("err", fmt.Sprintf("“%s” is not a rung. Choose canary, edge or beta. Nothing was minted.", ue.Rung)) return case role == svcrole.Deploy && app == "": back("err", "A deploy credential must name one app. Nothing was minted.") return case role == svcrole.Deploy && canon == "": back("err", "A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted.") return case role != svcrole.Deploy && canon != "": back("err", "Only a deploy credential takes rungs. Nothing was minted.") return } // The run refusals, same reasoning again. The field is the CLI's // --commands text: prefixes separated by commas, words by spaces. commands := runs.ParseCommandList(r.FormValue("commands")) var cerr error switch { case role == svcrole.Run && app == "": cerr = ErrAccountScopedRun case role == svcrole.Run && len(commands) == 0: cerr = ErrRunNeedsCommands case role == svcrole.Run: cerr = runs.CheckCommandPrefixes(commands) case len(commands) != 0: cerr = ErrCommandsNeedRun } if msg, ok := runMintRefusal(cerr); ok { back("err", runMintSentence(msg)) return } if app != "" { found, err := s.store.HasAppIn(acct.ID, app) if err != nil { log.Printf("console: service create %s/%s: has-app %s: %v", acct.Sqid, name, app, err) back("err", "Could not check that app — nothing was minted. Try again.") return } if !found { back("err", "There is no app called “"+app+"” in this account. Nothing was minted.") return } } if !s.requireFreshAuth(w, r, addr, raw) { return } secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, r.Form["rungs"], commands, addr, time.Now()) if err != nil { // The sentinel, not a string match: a name collision is the owner's // to fix and a busy database is not. if errors.Is(err, ErrServiceNameTaken) { back("err", "This account already has a live credential called “"+name+"”. Nothing was minted.") return } // The form is the door that OFFERS an account-wide credential, so it // is the one most likely to meet this refusal — and the one where // "could not mint, try again" is worst: the owner would retry the // same impossible request. Same words as the API door, so the two // cannot describe the same rule differently. if errors.Is(err, ErrAccountScopedService) { back("err", "A service credential must name one app. It is held by an app at runtime, "+ "and an account-wide one would reach every app in this account. Nothing was minted.") return } // The store is the authority; these arms say the same words as the // pre-checks above if one of them ever drifts. var ue *UnknownRungError switch { case errors.Is(err, ErrAccountScopedDeploy): back("err", "A deploy credential must name one app. Nothing was minted.") return case errors.Is(err, ErrDeployNeedsRungs): back("err", "A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted.") return case errors.Is(err, ErrStableNotDelegable): back("err", "Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted.") return case errors.As(err, &ue): back("err", fmt.Sprintf("“%s” is not a rung. Choose canary, edge or beta. Nothing was minted.", ue.Rung)) return case errors.Is(err, ErrRungsNeedDeploy): back("err", "Only a deploy credential takes rungs. Nothing was minted.") return } if msg, ok := runMintRefusal(err); ok { back("err", runMintSentence(msg)) return } log.Printf("console: service create %s/%s: %v", acct.Sqid, name, err) back("err", "Could not mint the credential — try again.") return } // serviceScopeWords, not a second inline if: the pane, the audit row and // the rotate response all have to say the scope the same way, and the // API's own create door already speaks these exact words. scope := serviceScopeWords(app) // serviceAuditKey, so an app-scoped credential's grant lands in THAT // app's activity feed — the one place its owner looks (C38). detail := "minted service credential " + name + " with role " + string(role) + " over " + scope + " (console)" switch role { case svcrole.Deploy: detail = fmt.Sprintf("minted service credential %s with role deploy over app %s, rungs %s (%s)", name, app, canon, "console") case svcrole.Run: detail = fmt.Sprintf("minted service credential %s with role run over app %s, commands %s (%s)", name, app, runs.FormatCommandList(commands), "console") } s.audit(acct, serviceAuditKey(name, app), addr, "service-create", detail) s.renderServiceSecret(w, r, addr, raw, acct, &ServiceSecretView{ Name: name, Role: string(role), Scope: scope, App: app, Rungs: strings.ReplaceAll(canon, ",", ", "), Commands: runs.FormatCommandList(commands), Token: secret, Doors: s.doorsFor(role), ScopeKnown: true, }) } // runMintSentence turns a runMintRefusal into the form's sentence: capital // first, and the same "Nothing was minted." every refusal here ends with. func runMintSentence(msg string) string { return strings.ToUpper(msg[:1]) + msg[1:] + ". Nothing was minted." } // handleServiceRotatePage is the pane's rotate button: a fresh secret for a // credential that already exists. // // ROTATION KEEPS THE IDENTITY — same row, same name, same role, same scope, // same audit history and same rate-limit budget. What it does immediately is // stop the OLD secret resolving. It is NAME-only, exactly as // handleAPIServiceRotate is: a legacy kind='app' row has no name and no // rotate path at all, so the pane draws no rotate button beside one. // // Gate order: session → CSRF → owner 404 → name refusal → requireFreshAuth // LAST. func (s *Server) handleServiceRotatePage(w http.ResponseWriter, r *http.Request) { addr, raw, acct, ok := s.serviceOwner(w, r) if !ok { return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, accountSettingsURL(acct.Sqid), http.StatusSeeOther) } name := strings.TrimSpace(r.FormValue("name")) if !registry.ValidTunnelName(name) { back("err", "That is not a credential this account can rotate. Nothing was rotated.") return } if !s.requireFreshAuth(w, r, addr, raw) { return } var secret string err := s.serviceCredentialWrite(r.Context(), acct, serviceHandle{name: name}, "sql.RotateServicePrincipal", func(st *Store) error { var err error secret, err = st.RotateServicePrincipal(acct.ID, name, time.Now()) return err }) if err != nil { if errors.Is(err, ErrNoServicePrincipal) { back("err", "There is no live credential called “"+name+"” in this account. Nothing was rotated.") return } log.Printf("console: service rotate %s/%s: %v", acct.Sqid, name, err) back("err", "Could not rotate the credential — try again.") return } // Read back for the audit's filing only (serviceAuditKey), the same // best-effort lookup handleAPIServiceRotate makes: a failed read files // the row under the credential's own name and costs nothing else. row, found := s.findServiceCredential(acct.ID, serviceHandle{name: name}) s.audit(acct, serviceAuditKey(name, row.App), addr, "service-rotate", "rotated the secret of service credential "+name+" (console)") // found carries through to ScopeKnown (Minor 4): an operator re-arming a // credential is shown WHAT they re-armed, and a read-back that failed // says so rather than defaulting to "the whole account" — which would be // wrong about exactly the credentials it matters most to be right about. s.renderServiceSecret(w, r, addr, raw, acct, &ServiceSecretView{ Name: name, Role: row.Role, Scope: serviceScopeWords(row.App), App: row.App, Token: secret, Rotated: true, ScopeKnown: found, }) } // handleServiceRevokePage is the pane's revoke button, and the reason the // pane exists: the credential stops resolving, at once and for good, without // a terminal and without a passkey round trip. // // IT TAKES A NAME OR A LISTING ID, read through serviceHandleOf — the API // door's own grammar, so the two cannot drift. A legacy kind='app' row has // no name, so its listing id is the only handle it will ever have; without // that arm an owner could SEE such a credential here and have no way to kill // it. // // REVOCATION IS IDEMPOTENT, AND THE PAGE SAYS WHICH IT WAS. An unknown // handle, an already-revoked credential and a live one all leave the same // state behind — no credential of that handle is live — so none of them is // an error. But a page that says "Revoked" when a stale handle matched // nothing is a false all-clear at the one door used in anger, and the likely // miss is a typo rather than a race. The two outcomes therefore get visibly // different words, the same distinction `carlos services revoke` draws // between "revoked" and "nothing to revoke". // // THERE IS DELIBERATELY NO TYPED CONFIRMATION HERE (C48), though this repo // has the idiom and uses it for app and account deletion. Every gate in front // of the emergency control is friction at exactly the wrong moment, and a // confirm while the passkey has been REMOVED would be incoherent besides: it // would say the act is grave enough to interrupt but not grave enough to // re-authenticate. The cost is real — a mis-click kills a live credential — // and it is the most recoverable destructive act on this surface: mint a // replacement and redistribute. App deletion, which the typed confirmation // guards, is not recoverable that way, which is why the idiom belongs there // and not here. The audit row names who pressed it. // // Gate order: session → CSRF → owner 404 → act. No requireFreshAuth — see // this file's doc comment. func (s *Server) handleServiceRevokePage(w http.ResponseWriter, r *http.Request) { // The session token is deliberately dropped: revoke takes no step-up, so // there is nothing here that needs it. serviceOwner has already used it // for the CSRF comparison. addr, _, acct, ok := s.serviceOwner(w, r) if !ok { return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, accountSettingsURL(acct.Sqid), http.StatusSeeOther) } h := serviceHandleOf(strings.TrimSpace(r.FormValue("handle"))) if !h.addresses() { // A segment that is neither a name nor a listing id matches no row // this console ever wrote. Audited all the same, under a subject that // is not an app (serviceAuditSubject): an attempted revoke that could // not have worked is exactly the event an operator goes looking for // afterwards. The handle itself is not echoed into the trail — it is // unbounded caller-controlled text by definition. s.audit(acct, serviceAuditSubject, addr, "service-revoke", "revoke attempted with a handle that is neither a credential name nor a listing id (console)") back("err", "Nothing live matched that — nothing to revoke.") return } // Read before the write, for the audit's filing only: a revoked row // leaves the listing, so the app it was scoped to is unknowable // afterwards. The authoritative write below is keyed on what the CALLER // named, never on what this found, so a failed read costs an audit row's // filing and never a revoke. row, found := s.findServiceCredential(acct.ID, h) err := s.serviceCredentialWrite(r.Context(), acct, h, "sql.RevokeServicePrincipal", func(st *Store) error { if h.name != "" { return st.RevokeServicePrincipal(acct.ID, h.name, time.Now()) } // The universal handle, account-scoped, and the only one that reaches // a nameless row. Safe by construction: a person's cli_tokens row // carries account_id = 0, so an account-scoped revoke can never reach // a human's terminal token (RevokeAccountToken). return st.RevokeAccountToken(acct.ID, h.id, time.Now()) }) if err != nil && !errors.Is(err, ErrNoServicePrincipal) { log.Printf("console: service revoke %s: %v", acct.Sqid, err) back("err", "Could not revoke the credential — it may still be live. Try again.") return } subject, what := serviceWhat(h, row, found) detail := "revoked " + what + " (console)" if err != nil { detail = "revoked " + what + " — nothing live matched (console)" } s.audit(acct, serviceAuditKey(subject, row.App), addr, "service-revoke", detail) if err != nil { // Deliberately without the word "revoked" in it: a miss that reads as // a kill is the exact failure this branch exists to prevent. back("err", "Nothing live matched “"+serviceHandleWords(h)+"” — nothing to revoke (already gone, or never existed).") return } back("ok", "Revoked "+what+" — its next call fails, whatever secret it holds.") } // serviceHandleWords is the handle as the flash says it back. It is the // PARSED handle, never the raw form value, so nothing unbounded and // caller-controlled reaches the page — the same reason the audit row above // does not echo it. func serviceHandleWords(h serviceHandle) string { if h.name != "" { return h.name } return h.id } // serviceScopeWords renders a credential's scope in the words the pane and // the audit trail both use. func serviceScopeWords(app string) string { if app == "" { return "the whole account" } return "app " + app } // roleWords is svcrole's catalogue as strings, read from the package rather // than hand-listed, the same discipline doorsFor applies to the door list. func roleWords() []string { roles := svcrole.All() out := make([]string, len(roles)) for i, r := range roles { out[i] = string(r) } return out }