carlos / platform Public

console: service credentials on the account, revocable without a terminal

The browser half of the service-principal work (issue #220, spec §6): the
account settings page grows an owner-only pane that lists, mints, rotates
and revokes the credentials a server holds, and /settings keeps showing the
ones the viewer minted, read-only, pointing at the account that manages
them.

Revocation is the emergency control, so it must work without a terminal.
That premise sets the step-up split: create and rotate take
requireFreshAuth, because each hands out a working production credential;
revoke does NOT, because it only removes authority and must not sit behind
a passkey round trip that fails closed on exactly the bad day that
accompanies a compromise. This is stricter than ownerFleet, which steps up
nothing including add-box; the divergence is recorded as a choice in
svcpage.go's doc comment.

Gate order on all three POSTs is the pinned one: session (byte-identical
404) -> CSRF -> owner 404 -> cheap refusals -> requireFreshAuth LAST.

The fresh plaintext is rendered directly in the response that minted it,
never redirected to and never flashed — a flash is a cookie, and a live
bearer secret does not belong in a cookie jar. Cache-Control: no-store on
those two responses.

Revoke addresses a credential by name or by listing id through the API
door's own grammar (serviceHandleOf, factored out of serviceHandleFrom), so
a nameless legacy kind='app' row is killable from the page that lists it,
and it distinguishes "revoked" from "nothing live matched" the way the CLI
does — a page that says revoked when a stale handle matched nothing is a
false all-clear at the one door used in anger.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
Paul Campbell pushed by paul@keymail.dev 7b800a1248b4affa63e5391e0438e55066ba5512 parent c556430
8 files changed, +1208 −6
  • internal/console/apiservices.go +9 −1
  • internal/console/authcli.go +44 −1
  • internal/console/authstore.go +20 −0
  • internal/console/server.go +34 −0
  • internal/console/svcpage.go +484 −0
  • internal/console/svcpage_test.go +516 −0
  • internal/console/templates/account_settings.html +89 −0
  • internal/console/templates/settings.html +12 −4
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go
index 4508c215d..d906851d3 100644
--- a/internal/console/apiservices.go
+++ b/internal/console/apiservices.go
@@ -333,7 +333,15 @@ func serviceHandleFrom(r *http.Request) serviceHandle {
// No TrimSpace: create stores the trimmed name, so the stored value
// always satisfies ValidTunnelName exactly, and a padded segment would
// not match a row in any case.
- seg := r.PathValue("name")
+ return serviceHandleOf(r.PathValue("name"))
+}
+
+// serviceHandleOf is serviceHandleFrom's grammar, taken as a plain string so
+// the console PAGE's revoke form (svcpage.go) reads a handle exactly the way
+// the API door reads a path segment. One grammar, one place: a browser form
+// that decided for itself what a name looked like would drift from the door
+// its own buttons are meant to mirror.
+func serviceHandleOf(seg string) serviceHandle {
switch {
case registry.ValidTunnelName(seg):
return serviceHandle{name: seg}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 36fa8e30a..88a269434 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -871,6 +871,14 @@ type cliPage struct {
Request *CLIAuthRequest
Tokens []CLIToken
Days int
+ // ServiceAccounts maps a service credential's account id to that
+ // account's sqid, so the read-only mirror can say WHERE a row is managed
+ // and link there. Built here rather than hung on CLIToken because the
+ // sqid is page chrome, not part of the stored row — and a row whose
+ // account this console cannot resolve is simply absent from the map,
+ // which the template renders as "managed on its own account" rather than
+ // as a broken link.
+ ServiceAccounts map[int64]string
}
func (s *Server) renderCLI(w http.ResponseWriter, status int, name string, p cliPage) {
@@ -1005,10 +1013,44 @@ func (s *Server) handleCLIApprove(w http.ResponseWriter, r *http.Request) {
}
}
+// serviceAccountSqids resolves the accounts the viewer's SERVICE credentials
+// are bound to, for the read-only mirror's "lives on account <sqid> — manage
+// there" link.
+//
+// Service rows only: an app token keeps its own Revoke button on this page
+// (it always has had one, and its minter is not necessarily an account
+// owner), so it needs no pointer elsewhere. A lookup that fails is left out
+// of the map rather than filled with a guess — a link to /a//settings is
+// worse than plain words.
+func (s *Server) serviceAccountSqids(toks []CLIToken) map[int64]string {
+ out := map[int64]string{}
+ for _, t := range toks {
+ if !t.IsService() || t.AccountID == 0 {
+ continue
+ }
+ if _, done := out[t.AccountID]; done {
+ continue
+ }
+ acct, err := s.store.AccountByID(t.AccountID)
+ if err != nil || acct == nil {
+ log.Printf("console: settings mirror: account %d: %v", t.AccountID, err)
+ continue
+ }
+ out[t.AccountID] = acct.Sqid
+ }
+ return out
+}
+
// handleCLITokens lists the signed-in user's own CLI credentials. Per USER,
// not per account: a token is an identity, and it carries whatever accounts
// that identity belongs to — so the person who can kill it is the person it
// speaks as.
+//
+// SERVICE credentials the viewer minted stay listed here, READ-ONLY. They are
+// managed on their account's own pane (svcpage.go), where the owner gate and
+// the audit trail live — but absorbing them into that listing must not make
+// them vanish from a page people already know, so the row stays and points
+// there instead of carrying a button.
func (s *Server) handleCLITokens(w http.ResponseWriter, r *http.Request) {
addr, raw, ok := s.session(r)
if !ok {
@@ -1022,7 +1064,8 @@ func (s *Server) handleCLITokens(w http.ResponseWriter, r *http.Request) {
return
}
p := cliPage{page: page{Title: "Your settings", Address: addr, CSRF: raw},
- Tokens: toks, Console: s.cfg.Origin, Days: int(CLITokenTTL / (24 * time.Hour))}
+ Tokens: toks, Console: s.cfg.Origin, Days: int(CLITokenTTL / (24 * time.Hour)),
+ ServiceAccounts: s.serviceAccountSqids(toks)}
p.Flash, p.FlashErr = s.takeFlash(w, r)
s.renderCLI(w, http.StatusOK, "settings", p)
}
diff --git a/internal/console/authstore.go b/internal/console/authstore.go
index 3c204fa54..8098415a1 100644
--- a/internal/console/authstore.go
+++ b/internal/console/authstore.go
@@ -141,6 +141,26 @@ func (t CLIToken) IsApp() bool { return t.Kind == cliKindApp }
// reason IsApp exists: a template cannot see cliKindService.
func (t CLIToken) IsService() bool { return t.Kind == cliKindService }
+// Handle is how this credential is ADDRESSED by the account pane's rotate
+// and revoke forms and by `carlos services revoke`: the NAME its owner chose
+// when it has one, and its listing id otherwise.
+//
+// A legacy kind='app' row has no name — every one of them carries the empty
+// string — so the id is the only handle it will ever have, and an owner who
+// could see such a row without being able to address it could not kill it.
+// The two grammars are disjoint by length (a name is at most 32 characters,
+// a listing id is exactly 64 hex), which is what lets serviceHandleOf read
+// either from one field without a second one saying which it is.
+//
+// It lives here rather than in the template so the addressing rule is
+// written down once, next to IsApp and IsService and for the same reason.
+func (t CLIToken) Handle() string {
+ if t.Name != "" {
+ return t.Name
+ }
+ return t.ID
+}
+
// migrateCLIAuth creates the CLI-identity tables. Additive and idempotent,
// called from Store.migrate.
func (s *Store) migrateCLIAuth() error {
diff --git a/internal/console/server.go b/internal/console/server.go
index d178238e5..fd581946d 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -774,6 +774,13 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /a/{sqid}/members/remove", s.handleRemoveMember)
s.mux.HandleFunc("POST /a/{sqid}/members/transfer", s.handleTransferOwnership)
s.mux.HandleFunc("POST /a/{sqid}/delete", s.handleDeleteAccount)
+ // The service-credential pane's writes (svcpage.go, issue #220):
+ // owner-only, and create/rotate behind passkey step-up besides. Revoke
+ // deliberately is NOT — it is the emergency control, and it must not sit
+ // behind a round trip that fails closed. See svcpage.go's doc comment.
+ s.mux.HandleFunc("POST /a/{sqid}/services/new", s.handleServiceCreatePage)
+ s.mux.HandleFunc("POST /a/{sqid}/services/rotate", s.handleServiceRotatePage)
+ s.mux.HandleFunc("POST /a/{sqid}/services/revoke", s.handleServiceRevokePage)
s.mux.HandleFunc("GET /a/{sqid}/trash", s.handleTrash)
s.mux.HandleFunc("POST /a/{sqid}/trash/restore", s.handleRestoreApp)
s.mux.HandleFunc("POST /a/{sqid}/trash/delete", s.handlePurgeApp)
@@ -1039,6 +1046,27 @@ type page struct {
// never re-derivable from a later view of this same page.
RotatedLabel string
RotatedToken string
+
+ // The service-credential pane on /a/{sqid}/settings (svcpage.go, issue
+ // #220). Filled ONLY for an owner, which is why the pane needs no new
+ // template condition — it sits inside the {{if .IsOwner}} block that
+ // already exists. A member therefore sees the page WITHOUT the section
+ // rather than with an empty one: an empty section reads as "this account
+ // has no service credentials", a different and possibly false statement.
+ //
+ // Services carries no secret and never can: only a credential's sha256 is
+ // stored, so there is nothing in a listed row to leak. ServicesUnknown
+ // says the listing READ FAILED, so the pane can say so instead of
+ // rendering an empty table as "none" — nil is otherwise both answers at
+ // once.
+ Services []CLIToken
+ ServicesUnknown bool
+ ServiceRoles []string // the mint form's role choices, from svcrole.All
+ ServiceApps []string // the mint form's scope choices: this account's apps
+ // ServiceSecret is the once-only block, set ONLY on the response to the
+ // create or rotate POST that produced it — never redirected to, never
+ // flashed (a flash is a cookie, and a credential does not belong in one).
+ ServiceSecret *ServiceSecretView
}
// IsOwner is the template's one permission question.
@@ -1955,6 +1983,12 @@ func (s *Server) handleAccountSettings(w http.ResponseWriter, r *http.Request) {
return
}
p.HasApps = n > 0
+ // The service-credential pane (svcpage.go), owners only — see page's
+ // Services field for why a member gets no section rather than an empty
+ // one.
+ if role == RoleOwner {
+ s.servicePane(&p, acct)
+ }
s.render(w, http.StatusOK, "account_settings", p)
}
diff --git a/internal/console/svcpage.go b/internal/console/svcpage.go
new file mode 100644
index 000000000..75e4f3544
--- /dev/null
+++ b/internal/console/svcpage.go
@@ -0,0 +1,484 @@
+// 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 browser's own "confirm form
+// resubmission" prompt is the guard, and it is the same trade
+// handleFleetRotateBoxPage already makes. Do not "fix" it with a flash.
+package console
+
+import (
+ "crypto/subtle"
+ "errors"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "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 <name>"
+ 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
+}
+
+// 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 := "<app>"
+ if v.App != "" {
+ app = v.App
+ }
+ return "carlos secrets set -app " + app + " CARLOS_SERVICE_TOKEN=<secret>"
+}
+
+// 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))
+ }
+ // 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"))
+ 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.NewServicePrincipal(acct.ID, name, app, role, 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
+ }
+ 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).
+ s.audit(acct, serviceAuditKey(name, app), addr, "service-create",
+ "minted service credential "+name+" with role "+string(role)+" over "+scope+" (console)")
+ s.renderServiceSecret(w, r, addr, raw, acct, &ServiceSecretView{
+ Name: name, Role: string(role), Scope: scope, App: app,
+ Token: secret, Doors: s.doorsFor(role),
+ })
+}
+
+// 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
+ }
+ secret, err := s.store.RotateServicePrincipal(acct.ID, name, time.Now())
+ 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, _ := 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)")
+ s.renderServiceSecret(w, r, addr, raw, acct, &ServiceSecretView{
+ Name: name, Role: row.Role, Scope: serviceScopeWords(row.App), App: row.App,
+ Token: secret, Rotated: true,
+ })
+}
+
+// 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".
+//
+// 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)
+
+ var err error
+ if h.name != "" {
+ err = s.store.RevokeServicePrincipal(acct.ID, h.name, time.Now())
+ } else {
+ // 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).
+ err = s.store.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.")
+}
+
+// serviceWhat names the credential a revoke acted on, for the audit trail and
+// for the flash. A nameless legacy row is described by what it IS — its label
+// and its app — because its id says nothing to whoever reads either a year
+// later. Shared shape with handleAPIServiceRevoke's own naming.
+func serviceWhat(h serviceHandle, row CLIToken, found bool) (subject, what string) {
+ subject, what = h.name, "service credential "+h.name
+ if subject == "" {
+ subject, what = h.id, "credential "+h.id
+ }
+ if !found {
+ return subject, what
+ }
+ if row.Name != "" {
+ return row.Name, "service credential " + row.Name
+ }
+ what = "app credential for " + row.App
+ if row.Label != "" {
+ what = "app credential " + row.Label + " for " + row.App
+ }
+ return row.App, what
+}
+
+// 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
+}
diff --git a/internal/console/svcpage_test.go b/internal/console/svcpage_test.go
new file mode 100644
index 000000000..068d89012
--- /dev/null
+++ b/internal/console/svcpage_test.go
@@ -0,0 +1,516 @@
+package console
+
+// The service-credential pane on the account settings page (server.go's
+// handleAccountSettings plus the three POSTs beside it) — the browser half
+// of what the API family (apiservices.go) and the CLI (services.go) already
+// do.
+//
+// The property the whole file turns on: REVOCATION MUST WORK WITHOUT A
+// TERMINAL. Someone racing a compromise at 3am has a browser and a phone,
+// and the pane is the only door they can reach. So revoke is tested end to
+// end — a credential that worked against a real API door stops working
+// after the button — and it is tested to be reachable in one step, with no
+// passkey round trip in front of it.
+//
+// The other property is the one this repo pins everywhere: the ordered gate.
+// session → CSRF → owner (byte-identical 404) → requireFreshAuth LAST. A
+// non-member must never get a redirect that confirms an account exists.
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// paneDoor is one of the pane's three POSTs, with a form that would
+// otherwise be accepted — every gate assertion below has to be about the
+// gate, not about a request that would have been refused anyway.
+type paneDoor struct {
+ name string
+ path string
+ form url.Values
+}
+
+func paneDoors() []paneDoor {
+ return []paneDoor{
+ {"create", "/services/new", url.Values{"name": {"hub"}, "role": {"publish"}, "app": {""}}},
+ {"rotate", "/services/rotate", url.Values{"name": {"hub"}}},
+ {"revoke", "/services/revoke", url.Values{"handle": {"hub"}}},
+ }
+}
+
+// withCSRF copies a door's form and stamps this browser's own token on it.
+func withCSRF(form url.Values, csrf string) url.Values {
+ out := url.Values{}
+ for k, v := range form {
+ out[k] = v
+ }
+ out.Set("csrf", csrf)
+ return out
+}
+
+// signInAs signs a fresh browser in as address, and puts the fake identity
+// provider back to the fixture's owner afterwards so a later sign-in is not
+// silently somebody else.
+func signInAs(t *testing.T, fx *consoleFixture, address, code string) (*http.Client, string) {
+ t.Helper()
+ fx.setUser(address)
+ defer fx.setUser("paul@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, code)
+ return c, sessionToken(t, fx, c)
+}
+
+// TestServicePaneIsOwnerGatedInOrder — the ordered gate this repo pins:
+// session → CSRF → owner (byte-identical 404) → requireFreshAuth LAST. A
+// non-member must never get a redirect that confirms an account exists.
+//
+// The step-up half is deliberately asymmetric and that asymmetry is the
+// point: create and rotate hand out a working production credential and are
+// stepped up, revoke only REMOVES authority and is not. See the handlers'
+// own doc comments.
+func TestServicePaneIsOwnerGatedInOrder(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+
+ owner := signInPaul(t, fx)
+ ownerCSRF := sessionToken(t, fx, owner)
+ member, memberCSRF := signInAs(t, fx, "kim@keymail.dev", "code-svcpane-member")
+ stranger, strangerCSRF := signInAs(t, fx, "eve@keymail.dev", "code-svcpane-stranger")
+
+ // The bytes an unrouted URL answers with. Every refusal below that is
+ // meant to conceal the account has to be exactly these.
+ _, _, unrouted := get(t, owner, fx.ts.URL+"/no/such/path")
+
+ for _, d := range paneDoors() {
+ u := fx.u(d.path)
+
+ // SIGNED OUT: the front page, and nothing about this account.
+ if status, loc, _ := postForm(t, newBrowser(t), u, d.form); status != http.StatusFound || loc != "/" {
+ t.Errorf("%s signed out = %d -> %q, want 302 -> /", d.name, status, loc)
+ }
+
+ // NON-MEMBER and MEMBER: the byte-identical 404, and never a
+ // redirect — a hop into Keymail would confirm the account exists to
+ // one and confirm the action exists to the other.
+ for _, who := range []struct {
+ label string
+ c *http.Client
+ csrf string
+ }{{"non-member", stranger, strangerCSRF}, {"member", member, memberCSRF}} {
+ status, loc, body := postForm(t, who.c, u, withCSRF(d.form, who.csrf))
+ if status != http.StatusNotFound || body != unrouted {
+ t.Errorf("%s as %s = %d %q, want the byte-identical 404 %q",
+ d.name, who.label, status, body, unrouted)
+ }
+ if loc != "" {
+ t.Errorf("%s as %s redirected to %q — a refusal that conceals must not hop anywhere",
+ d.name, who.label, loc)
+ }
+ }
+
+ // OWNER, BAD CSRF: refused before any pending action can be parked,
+ // so no passkey trip and nothing stashed.
+ status, loc, body := postForm(t, owner, u, withCSRF(d.form, "wrong"))
+ if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked") {
+ t.Errorf("%s with a bad CSRF token = %d, want 403 refused:\n%s", d.name, status, body)
+ }
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Errorf("%s with a bad CSRF token hopped into Keymail (%q) — CSRF must come first", d.name, loc)
+ }
+ }
+ var stashed int
+ fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed)
+ if stashed != 0 {
+ t.Errorf("%d pending actions parked by refused requests, want 0", stashed)
+ }
+
+ // OWNER, GOOD CSRF. create and rotate demand the passkey; revoke does
+ // not, because the emergency control must not sit behind a round trip.
+ for _, d := range paneDoors()[:2] {
+ status, loc, body := postForm(t, owner, fx.u(d.path), withCSRF(d.form, ownerCSRF))
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Errorf("%s as the owner = %d -> %q, want a redirect into Keymail:\n%s", d.name, status, loc, body)
+ }
+ }
+ status, loc, body := postForm(t, owner, fx.u("/services/revoke"),
+ withCSRF(url.Values{"handle": {"hub"}}, ownerCSRF))
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("revoke demanded a passkey (-> %q) — the emergency control must be one step", loc)
+ }
+ if status != http.StatusSeeOther || loc != fx.path("/settings") {
+ t.Fatalf("revoke as the owner = %d -> %q, want 303 back to the settings page:\n%s", status, loc, body)
+ }
+}
+
+// TestRevokeFromThePageKillsTheCredential — end to end, which is the only
+// way this claim is worth anything: a credential that WORKED against a real
+// API door stops working the moment the button is pressed, and leaves the
+// listing with it.
+func TestRevokeFromThePageKillsTheCredential(t *testing.T) {
+ fx := newConsoleFixture(t)
+ tok := serviceToken(t, fx.st, OpsAccountID, "shipbot", "kass", svcrole.Operate)
+ door := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/config"
+ if status, body := getRaw(t, door, tok); status != http.StatusOK {
+ t.Fatalf("the credential does not work before the button: %d %s", status, body)
+ }
+
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ if _, _, body := get(t, c, fx.u("/settings")); !strings.Contains(body, "shipbot") {
+ t.Fatalf("the pane does not list the credential it is about to revoke:\n%s", body)
+ }
+ status, loc, body := postForm(t, c, fx.u("/services/revoke"),
+ url.Values{"csrf": {csrf}, "handle": {"shipbot"}})
+ if status != http.StatusSeeOther || loc != fx.path("/settings") {
+ t.Fatalf("revoke = %d -> %q:\n%s", status, loc, body)
+ }
+ if status, body := getRaw(t, door, tok); status != http.StatusUnauthorized {
+ t.Fatalf("the credential still works after the button: %d %s", status, body)
+ }
+ // Two views, because the first one carries the flash — which NAMES the
+ // credential it killed, exactly as it should. The listing assertion has
+ // to be made against a view with no flash on it, or it would be reading
+ // the confirmation and calling it a row.
+ if _, _, flashed := get(t, c, fx.u("/settings")); !strings.Contains(flashed, "Revoked") {
+ t.Fatalf("the page does not say the revoke landed:\n%s", flashed)
+ }
+ _, _, page := get(t, c, fx.u("/settings"))
+ if strings.Contains(page, "shipbot") {
+ t.Fatalf("a revoked credential is still listed:\n%s", page)
+ }
+}
+
+// TestRevokeSaysWhenNothingMatched — the same distinction the CLI draws
+// between "revoked" and "nothing to revoke". A page that says "revoked"
+// when a stale handle matched nothing is a false all-clear at the one door
+// used in anger.
+func TestRevokeSaysWhenNothingMatched(t *testing.T) {
+ fx := newConsoleFixture(t)
+ serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ // A typo. Idempotent — it is not an error — but it must not read as a kill.
+ postForm(t, c, fx.u("/services/revoke"), url.Values{"csrf": {csrf}, "handle": {"hubb"}})
+ _, _, body := get(t, c, fx.u("/settings"))
+ if strings.Contains(body, "Revoked") {
+ t.Fatalf("a handle that matched nothing reported a revoke:\n%s", body)
+ }
+ if !strings.Contains(body, "Nothing live") {
+ t.Fatalf("the page does not say the handle matched nothing:\n%s", body)
+ }
+ // And the real credential is untouched by the miss.
+ if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 1 {
+ t.Fatalf("ServicePrincipals = %d rows %v after a missed revoke, want the credential still live", len(rows), err)
+ }
+
+ // The second revoke of a name the first one killed says the same thing.
+ postForm(t, c, fx.u("/services/revoke"), url.Values{"csrf": {csrf}, "handle": {"hub"}})
+ if _, _, first := get(t, c, fx.u("/settings")); !strings.Contains(first, "Revoked") {
+ t.Fatalf("the first revoke did not report a kill:\n%s", first)
+ }
+ postForm(t, c, fx.u("/services/revoke"), url.Values{"csrf": {csrf}, "handle": {"hub"}})
+ _, _, second := get(t, c, fx.u("/settings"))
+ if strings.Contains(second, "Revoked") {
+ t.Fatalf("the second revoke of the same handle reported a kill:\n%s", second)
+ }
+}
+
+// TestPaneRevokesALegacyAppTokenByItsListingID — a legacy kind='app' row has
+// no name, so the listing id is the only handle it will ever have. Without
+// this an owner could SEE such a credential on the pane and have no way to
+// kill it from there.
+func TestPaneRevokesALegacyAppTokenByItsListingID(t *testing.T) {
+ fx := newConsoleFixture(t)
+ tok, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ rows, err := fx.st.ServicePrincipals(OpsAccountID)
+ if err != nil || len(rows) != 1 {
+ t.Fatalf("ServicePrincipals = %d rows %v, want the legacy row", len(rows), err)
+ }
+ id := rows[0].ID
+
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ _, _, page := get(t, c, fx.u("/settings"))
+ // The label is the only distinguisher a nameless row has beyond its app.
+ if !strings.Contains(page, "router") {
+ t.Fatalf("the pane does not show the legacy row's label:\n%s", page)
+ }
+ if !strings.Contains(page, id) {
+ t.Fatalf("the pane offers no handle for the nameless row:\n%s", page)
+ }
+
+ if status, _, body := postForm(t, c, fx.u("/services/revoke"),
+ url.Values{"csrf": {csrf}, "handle": {id}}); status != http.StatusSeeOther {
+ t.Fatalf("revoke by listing id = %d:\n%s", status, body)
+ }
+ if _, ok := fx.st.CLIPrincipal(tok, time.Now()); ok {
+ t.Fatal("the legacy app token still resolves after being revoked from the pane")
+ }
+}
+
+// TestThePageNeverRendersASecret — the listing is built from stored rows,
+// and only a credential's sha256 is stored, so there is nothing in it to
+// leak even in principle. This asserts that where it can be seen: the
+// plaintext of a live credential is not on the page that lists it.
+func TestThePageNeverRendersASecret(t *testing.T) {
+ fx := newConsoleFixture(t)
+ a := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+ b := serviceToken(t, fx.st, OpsAccountID, "shipbot", "kass", svcrole.Operate)
+ legacy, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/settings"))
+ if status != http.StatusOK {
+ t.Fatalf("GET the account settings page = %d", status)
+ }
+ if !strings.Contains(body, "hub") || !strings.Contains(body, "shipbot") {
+ t.Fatalf("the pane does not list the credentials it is meant to:\n%s", body)
+ }
+ for _, secret := range []string{a, b, legacy} {
+ if strings.Contains(body, secret) {
+ t.Fatalf("the account settings page rendered a live credential's plaintext")
+ }
+ }
+}
+
+// TestTheFreshSecretAppearsOnceAndOnlyInItsOwnResponse — the once-only
+// discipline handleFleetRotateBoxPage established: the plaintext is rendered
+// DIRECTLY in the response to the POST that minted it, never redirected to
+// and never put in a flash cookie, which would write a live credential into
+// the browser's cookie jar and hand it out again on the next page view.
+func TestTheFreshSecretAppearsOnceAndOnlyInItsOwnResponse(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postGated(t, fx, c, fx.u("/services/new"),
+ url.Values{"csrf": {csrf}, "name": {"hub"}, "role": {"publish"}, "app": {""}})
+ if status != http.StatusOK {
+ t.Fatalf("create = %d:\n%s", status, body)
+ }
+ secret := paneSecret(t, body)
+ if _, ok := fx.st.CLIPrincipal(secret, time.Now()); !ok {
+ t.Fatal("the secret the page showed does not resolve — it is not the credential's own")
+ }
+ if strings.Count(body, secret) != 1 {
+ t.Fatalf("the fresh secret appears %d times in its own response, want exactly 1", strings.Count(body, secret))
+ }
+ if hdr := paneCacheControl(t, c, fx, csrf); !strings.Contains(hdr, "no-store") {
+ t.Fatalf("Cache-Control on a response carrying a credential = %q, want no-store", hdr)
+ }
+ // Never in the cookie jar: a flash cookie is storage the browser keeps.
+ for _, ck := range c.Jar.Cookies(mustURL(t, fx.ts.URL)) {
+ if strings.Contains(ck.Value, secret) {
+ t.Fatalf("cookie %q carries the fresh secret", ck.Name)
+ }
+ }
+ // And gone from the very next view of the same page.
+ if _, _, again := get(t, c, fx.u("/settings")); strings.Contains(again, secret) {
+ t.Fatalf("the secret survived into a later view of the page:\n%s", again)
+ }
+
+ // Rotate shows the NEW secret once, and the old one stops resolving.
+ status, _, body = postGated(t, fx, c, fx.u("/services/rotate"),
+ url.Values{"csrf": {csrf}, "name": {"hub"}})
+ if status != http.StatusOK {
+ t.Fatalf("rotate = %d:\n%s", status, body)
+ }
+ fresh := paneSecret(t, body)
+ if fresh == secret {
+ t.Fatal("rotate showed the same secret again")
+ }
+ if _, ok := fx.st.CLIPrincipal(secret, time.Now()); ok {
+ t.Fatal("the pre-rotation secret still resolves")
+ }
+ if _, ok := fx.st.CLIPrincipal(fresh, time.Now()); !ok {
+ t.Fatal("the rotated secret does not resolve")
+ }
+}
+
+// paneSecret pulls the plaintext out of the once-only block. The page prints
+// it on its own CARLOS_SERVICE_TOKEN= line, the same shape the CLI prints.
+func paneSecret(t *testing.T, body string) string {
+ t.Helper()
+ const marker = "CARLOS_SERVICE_TOKEN="
+ i := strings.Index(body, marker)
+ if i < 0 {
+ t.Fatalf("no secret in the response:\n%s", body)
+ }
+ rest := body[i+len(marker):]
+ j := strings.IndexAny(rest, "<\n\r ")
+ if j < 0 {
+ t.Fatalf("the secret is not delimited:\n%s", body)
+ }
+ secret := strings.TrimSpace(rest[:j])
+ if secret == "" {
+ t.Fatalf("empty secret in:\n%s", body)
+ }
+ return secret
+}
+
+// paneCacheControl mints one more credential purely to read the response
+// header off it — the header is the property under test, and the body is
+// already covered above.
+func paneCacheControl(t *testing.T, c *http.Client, fx *consoleFixture, csrf string) string {
+ t.Helper()
+ res, err := c.PostForm(fx.u("/services/new"),
+ url.Values{"csrf": {csrf}, "name": {"second"}, "role": {"operate"}, "app": {""}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ return res.Header.Get("Cache-Control")
+}
+
+func mustURL(t *testing.T, raw string) *url.URL {
+ t.Helper()
+ u, err := url.Parse(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return u
+}
+
+// TestTheServicePaneIsOwnerOnlyOnThePage — a MEMBER sees the account
+// settings 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.
+func TestTheServicePaneIsOwnerOnlyOnThePage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+
+ owner := signInPaul(t, fx)
+ if _, _, body := get(t, owner, fx.u("/settings")); !strings.Contains(body, "Service credentials") ||
+ !strings.Contains(body, "hub") {
+ t.Fatalf("the owner's settings page has no service pane:\n%s", body)
+ }
+ member, _ := signInAs(t, fx, "kim@keymail.dev", "code-svcpane-owneronly")
+ status, _, body := get(t, member, fx.u("/settings"))
+ if status != http.StatusOK {
+ t.Fatalf("member GET settings = %d", status)
+ }
+ if strings.Contains(body, "Service credentials") || strings.Contains(body, "hub") {
+ t.Fatalf("a member's settings page rendered the service pane:\n%s", body)
+ }
+}
+
+// TestPersonalSettingsMirrorsServiceCredentialsReadOnly — absorbing a
+// credential into the account listing must not make it VANISH from a page
+// people already know. It stays on /settings, read-only, pointing at the
+// account pane that manages it.
+func TestPersonalSettingsMirrorsServiceCredentialsReadOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.NewServicePrincipal(OpsAccountID, "hub", "", svcrole.Publish,
+ "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.ts.URL+"/settings")
+ if status != http.StatusOK {
+ t.Fatalf("GET /settings = %d", status)
+ }
+ if !strings.Contains(body, "hub") {
+ t.Fatalf("the credential vanished from the page its minter knows:\n%s", body)
+ }
+ if !strings.Contains(body, "/a/"+fx.sqid+"/settings") || !strings.Contains(body, "manage there") {
+ t.Fatalf("/settings does not point at the account pane that manages it:\n%s", body)
+ }
+ if strings.Contains(body, `action="/auth/cli/tokens/revoke"`) {
+ t.Fatalf("/settings offers a revoke button for a service credential — the mirror is read-only:\n%s", body)
+ }
+}
+
+// TestPersonalSettingsStillRevokesALegacyAppToken is the other half of the
+// mirror rule, and the reason the read-only treatment keys on IsService and
+// not on "anything that is not a person": an app token has always been
+// revocable from this page, and its minter may not be an account owner.
+func TestPersonalSettingsStillRevokesALegacyAppToken(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.NewAppToken(OpsAccountID, "kass", "paul@keymail.dev", "router", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.ts.URL+"/settings")
+ if !strings.Contains(body, `action="/auth/cli/tokens/revoke"`) {
+ t.Fatalf("/settings no longer offers to revoke an app token:\n%s", body)
+ }
+}
+
+// TestPaneMintRefusalsCostNoPasskey — a bad name or an unknown role is the
+// caller's slip, and no gesture could change the answer, so it is refused
+// BEFORE the passkey. Same reasoning handleStoreDestroy's "no backend"
+// refusal carries.
+func TestPaneMintRefusalsCostNoPasskey(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ for _, bad := range []struct {
+ why string
+ form url.Values
+ }{
+ {"an unusable name", url.Values{"csrf": {csrf}, "name": {"Hub Two"}, "role": {"publish"}}},
+ {"an unknown role", url.Values{"csrf": {csrf}, "name": {"hub"}, "role": {"root"}}},
+ {"an app that is not here", url.Values{"csrf": {csrf}, "name": {"hub"}, "role": {"publish"}, "app": {"ghost"}}},
+ } {
+ status, loc, body := postForm(t, c, fx.u("/services/new"), bad.form)
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Errorf("create with %s cost a passkey trip (-> %q)", bad.why, loc)
+ }
+ if status != http.StatusSeeOther || loc != fx.path("/settings") {
+ t.Errorf("create with %s = %d -> %q, want 303 back to the page:\n%s", bad.why, status, loc, body)
+ }
+ }
+ if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 0 {
+ t.Fatalf("ServicePrincipals = %d rows %v after three refused mints, want none", len(rows), err)
+ }
+}
+
+// TestPaneMintIsAudited — the pane's writes belong in the same trail the API
+// and the CLI write to, filed the same way (serviceAuditKey: an app-scoped
+// credential's grant belongs in THAT APP's feed).
+func TestPaneMintIsAudited(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ if status, _, body := postGated(t, fx, c, fx.u("/services/new"),
+ url.Values{"csrf": {csrf}, "name": {"shipbot"}, "role": {"operate"}, "app": {"kass"}}); status != http.StatusOK {
+ t.Fatalf("create = %d:\n%s", status, body)
+ }
+ events, err := fx.st.Events(OpsAccountID, "kass", 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var found bool
+ for _, e := range events {
+ if e.Action == "service-create" && strings.Contains(e.Detail, "shipbot") {
+ found = true
+ if e.Address != "paul@keymail.dev" {
+ t.Errorf("audit actor = %q, want the member who pressed the button", e.Address)
+ }
+ if !strings.Contains(e.Detail, "console") {
+ t.Errorf("audit detail %q does not say which door it came through", e.Detail)
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("no service-create event on the app the credential was scoped to: %+v", events)
+ }
+}
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html
index aaf4431f7..8f322c474 100644
--- a/internal/console/templates/account_settings.html
+++ b/internal/console/templates/account_settings.html
@@ -14,6 +14,95 @@
</table>
</div>
{{if .IsOwner}}
+<h2>Service credentials</h2>
+<p class="small">Credentials a SERVER holds — a CI runner, a router, a job — not a person. Each one
+ is bound to this account, carries exactly one role, and never expires: revoking is the control,
+ and it works from here.</p>
+{{with .ServiceSecret}}
+<div class="banner">
+ {{if .Rotated}}
+ <p><strong>New secret for {{.Name}}:</strong></p>
+ {{else}}
+ <p><strong>Minted {{.Name}} — role {{.Role}}, over {{.Scope}}:</strong></p>
+ {{end}}
+ <p><code>CARLOS_SERVICE_TOKEN={{.Token}}</code></p>
+ <p class="small">Shown once. Only its hash is stored — a lost secret means rotate, not another
+ look at this page.</p>
+ <p class="small">Deliver it: <code>{{.DeliverCommand}}</code></p>
+ {{if .Rotated}}
+ <p class="small">ROTATION IS NOT REVOCATION: this refuses the credential's NEXT call with the
+ OLD secret, but does not stop work already in flight — a credential that may be COMPROMISED
+ needs Revoke, not another rotate.</p>
+ {{end}}
+ {{if .Doors}}
+ <p class="small">{{if .App}}It may (bound to app {{.App}} ONLY, despite the wildcards below):{{else}}It may:{{end}}</p>
+ <ul class="small">
+ {{range .Doors}}<li><code>{{.}}</code></li>{{end}}
+ </ul>
+ {{end}}
+</div>
+{{end}}
+{{if .ServicesUnknown}}
+<p class="small">This account's credentials could not be read just now, so none are listed. That is
+ not the same as there being none — reload before concluding anything.</p>
+{{else if .Services}}
+<div class="tablewrap">
+<table>
+ <tr>
+ <th>Name</th><th>Role</th><th>Scope</th><th>Label</th>
+ <th>Secret since</th><th>Last used</th><th>Minted by</th><th></th>
+ </tr>
+ {{range .Services}}
+ <tr>
+ <td>{{if .Name}}{{.Name}}{{else}}<span class="small">(unnamed)</span>{{end}}</td>
+ <td>{{.Role}}</td>
+ <td>{{if .App}}app {{.App}}{{else}}account-wide{{end}}</td>
+ <td>{{emptyOrDash .Label}}</td>
+ <td>{{if .RotatedAt.IsZero}}—{{else}}{{fmtTime .RotatedAt}}{{end}}</td>
+ <td>{{if .LastUsed.IsZero}}—{{else}}{{fmtTime .LastUsed}}{{end}}</td>
+ <td>{{emptyOrDash .Address}}</td>
+ <td>
+ {{if .Name}}
+ <form method="post" action="{{$.Base}}/services/rotate">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="name" value="{{.Name}}">
+ <button class="linkish" type="submit">Rotate</button>
+ </form>
+ {{end}}
+ <form method="post" action="{{$.Base}}/services/revoke">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="handle" value="{{.Handle}}">
+ <button class="linkish danger" type="submit">Revoke</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+</table>
+</div>
+<p class="small">Revoking is immediate and needs nothing but this page: the credential's next call
+ fails, whatever secret it holds. Rotating replaces the secret and keeps everything else — the
+ name, the role, the scope, the history — so a credential that may be compromised needs Revoke,
+ not Rotate. A row with no name is an older app token; its listing id is its handle, and its
+ label is what it was minted as.</p>
+{{else}}
+<p class="small">No service credentials in this account.</p>
+{{end}}
+<h3>Mint a credential</h3>
+<form class="signin" method="post" action="{{.Base}}/services/new">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="name" placeholder="name, e.g. ci-runner">
+ <select name="role">
+ {{range .ServiceRoles}}<option value="{{.}}">{{.}}</option>{{end}}
+ </select>
+ <select name="app">
+ <option value="">the whole account</option>
+ {{range .ServiceApps}}<option value="{{.}}">app {{.}}</option>{{end}}
+ </select>
+ <button type="submit">Mint credential</button>
+</form>
+<p class="small">The secret is shown once, on the next screen, and is never recoverable from here.
+ Minting and rotating both ask for your passkey; revoking does not, so it stays one step in an
+ emergency.</p>
<h2>Delete this account</h2>
{{if .HasApps}}
<p class="small">This account has apps, so it can't be deleted. Delete or move the apps first;
diff --git a/internal/console/templates/settings.html b/internal/console/templates/settings.html
index c5f0f038b..c94c76843 100644
--- a/internal/console/templates/settings.html
+++ b/internal/console/templates/settings.html
@@ -14,17 +14,23 @@
<tr><th>Terminal</th><th>From</th><th>Signed in</th><th>Last used</th><th>Expires</th><th></th></tr>
{{range .Tokens}}
<tr>
- <td>{{emptyOrDash .Label}}{{if .IsApp}} <span class="small">(app token{{if .App}} · {{.App}}{{end}})</span>{{end}}</td>
+ <td>{{if .IsService}}{{.Name}} <span class="small">(service credential{{if .App}} · {{.App}}{{end}})</span>{{else}}{{emptyOrDash .Label}}{{if .IsApp}} <span class="small">(app token{{if .App}} · {{.App}}{{end}})</span>{{end}}{{end}}</td>
<td>{{emptyOrDash .RemoteAddr}}</td>
<td>{{fmtTime .CreatedAt}}</td>
<td>{{if .LastUsed.IsZero}}—{{else}}{{fmtTime .LastUsed}}{{end}}</td>
<td>{{if .NeverExpires}}Never{{else}}{{fmtTime .ExpiresAt}}{{end}}</td>
<td>
+ {{if .IsService}}
+ {{$sqid := index $.ServiceAccounts .AccountID}}
+ {{if $sqid}}<span class="small">lives on account <a href="/a/{{$sqid}}/settings">{{$sqid}}</a> — manage there</span>
+ {{else}}<span class="small">managed on its own account</span>{{end}}
+ {{else}}
<form method="post" action="/auth/cli/tokens/revoke">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}">
<button class="linkish danger" type="submit">Revoke</button>
</form>
+ {{end}}
</td>
</tr>
{{end}}
@@ -33,9 +39,11 @@
<p class="small">Revoking is immediate: that terminal's next command fails and
it has to log in again. Terminal tokens expire on their own after {{.Days}}
days; an app token never expires — it is a credential you minted for an app,
-and revoking it here is how it is rotated. The name and the address are what
-the machine asking told us — if a row here is not a terminal you recognise,
-revoke it.</p>
+and revoking it here is how it is rotated. A SERVICE credential is listed here
+because you minted it, but it belongs to its account and is rotated and
+revoked from that account's settings page, not this one. The name and the
+address are what the machine asking told us — if a row here is not a terminal
+you recognise, revoke it.</p>
{{else}}
<p>No terminals are signed in as you.</p>
<pre><code>carlos auth login -console {{.Console}}</code></pre>