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
8 files changed,
+1208
−6
internal/console/apiservices.go+9 −1internal/console/authcli.go+44 −1internal/console/authstore.go+20 −0internal/console/server.go+34 −0internal/console/svcpage.go+484 −0internal/console/svcpage_test.go+516 −0internal/console/templates/account_settings.html+89 −0internal/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> |