| index a9a179a9e..dc3e09be3 100644 |
| --- a/internal/console/apiservices.go |
| +++ b/internal/console/apiservices.go |
| @@ -25,6 +25,7 @@ import ( |
| "errors" |
| "log" |
| "net/http" |
| + "regexp" |
| "sort" |
| "strings" |
| "time" |
| @@ -34,13 +35,29 @@ import ( |
| ) |
| |
| // serviceView is the wire shape of one listed credential — deliberately NOT |
| -// CLIToken, which carries the token hash in its ID field. The listing must |
| -// never echo a secret and must never echo the stored hash either: the hash |
| -// is not a secret, but it is the handle a revoke-by-id door addresses rows |
| -// with, and an API that hands it out invites a caller to build one. |
| +// CLIToken, so that a field added to the store's row never becomes wire by |
| +// accident. It carries no secret and it never will: only the sha256 of a |
| +// credential is stored, so there is nothing here to leak even in principle. |
| +// |
| +// It DOES carry the listing id, which is that sha256 (C36). The hash is not |
| +// a credential — only its preimage authenticates, so knowing it grants |
| +// nothing — and it is this codebase's established addressing handle: the |
| +// personal settings page has always posted it as `id` to |
| +// handleCLITokenRevoke. It earns its place here because a legacy kind='app' |
| +// row has NO name, and the id is the only handle it has; without it the |
| +// listing would show an owner a credential they could not then revoke |
| +// through this API. |
| type serviceView struct { |
| + // ID is the listing handle revoke accepts in place of a name. See above. |
| + ID string `json:"id"` |
| Name string `json:"name"` |
| Role string `json:"role"` |
| + // Label is the free text NewAppToken records for a legacy row ("router" |
| + // on titogo's). It is the only thing distinguishing two nameless |
| + // credentials bound to the same app, so an owner deciding which to |
| + // revoke needs it. Empty for a named service credential, which has a |
| + // name to be known by instead. |
| + Label string `json:"label"` |
| // App is the scope: the empty string means account-scoped, and it is |
| // rendered rather than omitted so "bound to nothing" and "bound to an |
| // app whose name did not survive the wire" cannot be confused. |
| @@ -122,6 +139,16 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| if !ok { |
| return |
| } |
| + // The ordinary per-minute WRITE budget, deliberately not the scarce |
| + // 10-per-day create budget handleAPIFleetNew and handleAPICreateApp |
| + // spend. That daily budget exists for namespaces whose spam is |
| + // PERMANENT: a claimed app name can never be reused, and a fleet name is |
| + // globally unique forever, so each wasted attempt costs something nobody |
| + // can get back. A service credential is none of that — per-account, |
| + // revocable, and its name is free again the moment it is revoked (the |
| + // unique index is scoped to live rows). The door is owner-gated on top, |
| + // which is a far tighter bound than app-create has. Do not "fix" this |
| + // toward fleets. |
| if !s.allowWrite(p, time.Now()) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| @@ -193,7 +220,7 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| if app != "" { |
| scope = "app " + app |
| } |
| - s.audit(acct, name, actor, "service-create", |
| + s.audit(acct, serviceAuditKey(name, app), actor, "service-create", |
| "minted service credential "+name+" with role "+string(role)+" over "+scope+" (cli)") |
| writeJSON(w, http.StatusCreated, map[string]any{ |
| "name": name, |
| @@ -225,7 +252,9 @@ func (s *Server) handleAPIServiceList(w http.ResponseWriter, r *http.Request) { |
| out := make([]serviceView, 0, len(rows)) |
| for _, t := range rows { |
| out = append(out, serviceView{ |
| + ID: t.ID, |
| Name: t.Name, |
| + Label: t.Label, |
| Role: t.Role, |
| App: t.App, |
| Created: serviceStamp(t.CreatedAt), |
| @@ -250,23 +279,90 @@ func serviceStamp(t time.Time) string { |
| return t.UTC().Format(time.RFC3339) |
| } |
| |
| -// mintedName is the {name} path segment, refused unless it is a name that |
| -// could have been minted (handleAPIServiceCreate's grammar, applied to the |
| -// same column from the other side). |
| +// listingIDRe is the shape of a listing id: the lowercase hex sha256 |
| +// hashToken writes, exactly 64 characters. |
| +var listingIDRe = regexp.MustCompile(`^[0-9a-f]{64}$`) |
| + |
| +// serviceHandle is the {name} path segment resolved to how it addresses a |
| +// row: a NAME (create's grammar) or a listing ID (C36). |
| // |
| -// It costs nothing in behaviour — an unmintable name can never match a live |
| -// row, so the store would answer "no such credential" anyway — and it buys |
| -// one thing: an arbitrary path segment, of arbitrary length, never reaches |
| -// the audit trail's app column on the revoke path, which audits even when |
| -// nothing matched. Callers turn a false into whatever their door's |
| -// no-such-credential answer already is, so this narrows no answer and |
| -// widens none. |
| -func mintedName(r *http.Request) (string, bool) { |
| +// The two grammars are disjoint — a name is at most 32 characters of |
| +// [a-z0-9-] and a listing id is exactly 64 hex characters — so a segment can |
| +// never be read as both, and no flag or query parameter is needed to say |
| +// which one the caller meant. A segment that is neither addresses nothing: |
| +// no row this console ever wrote could match it. |
| +// |
| +// Refusing an unrecognisable segment here rather than passing it to the |
| +// store also keeps an arbitrary, arbitrarily long path segment out of the |
| +// audit trail's app column on the revoke path, which audits even when |
| +// nothing matched. It narrows no answer: every caller turns an unaddressable |
| +// segment into the same answer its door already gives for a handle that |
| +// matches nothing. |
| +type serviceHandle struct { |
| + name string // "" unless the segment is a credential name |
| + id string // "" unless the segment is a listing id |
| +} |
| + |
| +func (h serviceHandle) addresses() bool { return h.name != "" || h.id != "" } |
| + |
| +func serviceHandleFrom(r *http.Request) serviceHandle { |
| // No TrimSpace: create stores the trimmed name, so the stored value |
| - // always satisfies this exactly, and a padded segment would not match a |
| - // row in any case. |
| - name := r.PathValue("name") |
| - return name, registry.ValidTunnelName(name) |
| + // always satisfies ValidTunnelName exactly, and a padded segment would |
| + // not match a row in any case. |
| + seg := r.PathValue("name") |
| + switch { |
| + case registry.ValidTunnelName(seg): |
| + return serviceHandle{name: seg} |
| + case listingIDRe.MatchString(seg): |
| + return serviceHandle{id: seg} |
| + } |
| + return serviceHandle{} |
| +} |
| + |
| +// serviceAuditKey is the app column an audit row about one credential is |
| +// filed under: the app the credential is scoped to when it has one, and the |
| +// credential's own name otherwise. |
| +// |
| +// An app-scoped credential's grant, rotation and revocation belong in THAT |
| +// APP's activity feed, because that feed is the one place the app's owner |
| +// looks. Filing them under the credential's name instead — the shape fleets |
| +// uses, where the subject really is the thing named — would leave the owner |
| +// of an app with no sight of the authority handed out over it (C38). An |
| +// account-scoped credential has no app to file under and keeps the fleets |
| +// shape. |
| +func serviceAuditKey(name, app string) string { |
| + if app != "" { |
| + return app |
| + } |
| + return name |
| +} |
| + |
| +// findServiceCredential looks one of this account's LIVE credentials up by |
| +// whichever handle addressed it. |
| +// |
| +// It exists for the audit trail alone. The store methods that do the work |
| +// answer only "did anything match", and serviceAuditKey needs the app a |
| +// credential is scoped to — which, on the revoke path, has to be read BEFORE |
| +// the write, since a revoked row leaves the listing. |
| +// |
| +// Best-effort by design: a read failure is reported as not-found, and every |
| +// caller treats not-found as "file it under the credential's own name", |
| +// never as "there was nothing to revoke". That is why the authoritative |
| +// write in each caller is keyed on what the CALLER named rather than on |
| +// anything found here — a database hiccup must not turn an emergency revoke |
| +// into a silent success. |
| +func (s *Server) findServiceCredential(accountID int64, h serviceHandle) (CLIToken, bool) { |
| + rows, err := s.store.ServicePrincipals(accountID) |
| + if err != nil { |
| + log.Printf("console: service lookup in %d: %v", accountID, err) |
| + return CLIToken{}, false |
| + } |
| + for _, t := range rows { |
| + if (h.name != "" && t.Name == h.name) || (h.id != "" && t.ID == h.id) { |
| + return t, true |
| + } |
| + } |
| + return CLIToken{}, false |
| } |
| |
| // handleAPIServiceRotate answers `carlos services rotate`: a fresh secret |
| @@ -286,11 +382,18 @@ func (s *Server) handleAPIServiceRotate(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| - name, ok := mintedName(r) |
| - if !ok { |
| + // Rotate is NAME-only, where revoke also takes a listing id. A row a |
| + // listing id addresses is either nameless — a legacy kind='app' row, |
| + // which has no rotate path at all (RotateServicePrincipal refuses an |
| + // empty name by design, so that one call cannot rotate several rows at |
| + // once) — or it has a name, which is the handle to use. So an |
| + // id-shaped segment here is the ordinary not-found, not a second way in. |
| + h := serviceHandleFrom(r) |
| + if h.name == "" { |
| http.NotFound(w, r) |
| return |
| } |
| + name := h.name |
| secret, err := s.store.RotateServicePrincipal(acct.ID, name, time.Now()) |
| if err != nil { |
| // Within an account the caller already owns, an unknown name is an |
| @@ -305,8 +408,13 @@ func (s *Server) handleAPIServiceRotate(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusInternalServerError, "could not rotate the credential — try again") |
| return |
| } |
| + // The row survives a rotation unchanged but for its secret, so this |
| + // reads back the app it is scoped to and files the audit row there |
| + // (serviceAuditKey). Best-effort: a failed read files under the name. |
| + row, _ := s.findServiceCredential(acct.ID, h) |
| actor := auditActor(p, acct) |
| - s.audit(acct, name, actor, "service-rotate", "rotated the secret of service credential "+name+" (cli)") |
| + s.audit(acct, serviceAuditKey(name, row.App), actor, "service-rotate", |
| + "rotated the secret of service credential "+name+" (cli)") |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "name": name, |
| "account": acct.Sqid, |
| @@ -317,16 +425,27 @@ func (s *Server) handleAPIServiceRotate(w http.ResponseWriter, r *http.Request) |
| // handleAPIServiceRevoke answers `carlos services revoke`: the credential |
| // stops resolving, at once and for good. |
| // |
| +// IT TAKES A NAME OR A LISTING ID (C36). A named service credential is |
| +// addressed the ergonomic way, by the name its owner chose. A legacy |
| +// kind='app' row has no name — nothing does, every one of them carries the |
| +// empty string — so the listing id is the only handle it will ever have, and |
| +// without it an owner could see such a credential in the listing and have no |
| +// way to kill it from here. serviceHandle tells the two apart by shape. |
| +// |
| // REVOCATION IS IDEMPOTENT, and that is a deliberate refusal to be precise. |
| -// An unknown name, an already-revoked one and a live one all answer the same |
| -// 200, because the person reaching for this door is usually racing a |
| -// compromise and must not have to work out whether their first attempt |
| -// landed. The one thing they need to know afterwards — that the credential |
| -// is dead — is true in every one of those cases. |
| +// An unknown name, an unknown id, an already-revoked credential and a live |
| +// one all answer the same 200, because the person reaching for this door is |
| +// usually racing a compromise and must not have to work out whether their |
| +// first attempt landed. The one thing they need to know afterwards — that |
| +// the credential is dead — is true in every one of those cases. |
| // |
| // It costs nothing in disclosure: the caller is the account's owner, so |
| // "does a credential of this name exist here" is not a fact being kept from |
| -// them. It is available from the listing one door away. |
| +// them. It is available from the listing one door away. An id belonging to |
| +// ANOTHER account is a different matter, and it is refused by construction: |
| +// both store calls below are scoped to acct.ID, so a foreign id matches |
| +// nothing and gets the same 200 as a fabricated one, having changed |
| +// nothing. |
| func (s *Server) handleAPIServiceRevoke(w http.ResponseWriter, r *http.Request) { |
| p, acct, ok := s.apiOwnerAccount(w, r) |
| if !ok { |
| @@ -336,27 +455,62 @@ func (s *Server) handleAPIServiceRevoke(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| - name, ok := mintedName(r) |
| - if !ok { |
| - // The same answer an unknown name gets, because that is what it is. |
| + h := serviceHandleFrom(r) |
| + if !h.addresses() { |
| + // A segment that is neither a name nor a listing id matches no row |
| + // this console ever wrote, so it gets the answer an unknown handle |
| + // gets — which is the same answer every other case gets. |
| writeJSON(w, http.StatusOK, map[string]any{"result": "revoked"}) |
| return |
| } |
| - err := s.store.RevokeServicePrincipal(acct.ID, name, time.Now()) |
| + // Read before the write, for the audit 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 own |
| + // cli_tokens row carries account_id = 0, so an account-scoped |
| + // revoke cannot 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/%s: %v", acct.Sqid, name, err) |
| + log.Printf("console: service revoke %s: %v", acct.Sqid, err) |
| jsonError(w, http.StatusInternalServerError, "could not revoke the credential — try again") |
| return |
| } |
| // Audited even when nothing matched: "someone tried to revoke X" is |
| // worth having in the trail of an account whose credentials are being |
| // argued about, and the detail says which it was. |
| + subject, what := h.name, "service credential "+h.name |
| + if subject == "" { |
| + subject, what = h.id, "credential "+h.id |
| + } |
| + if found { |
| + if row.Name != "" { |
| + subject, what = row.Name, "service credential "+row.Name |
| + } else { |
| + // A nameless legacy row is described by what it IS — its label |
| + // and its app — because its id says nothing to whoever reads |
| + // the trail a year later. |
| + subject, what = row.App, "app credential for "+row.App |
| + if row.Label != "" { |
| + what = "app credential " + row.Label + " for " + row.App |
| + } |
| + } |
| + } |
| actor := auditActor(p, acct) |
| - detail := "revoked service credential " + name + " (cli)" |
| + detail := "revoked " + what + " (cli)" |
| if err != nil { |
| - detail = "revoked service credential " + name + " — nothing live of that name (cli)" |
| + detail = "revoked " + what + " — nothing live matched (cli)" |
| } |
| - s.audit(acct, name, actor, "service-revoke", detail) |
| + s.audit(acct, serviceAuditKey(subject, row.App), actor, "service-revoke", detail) |
| // The body carries no name and nothing else request-derived: it is the |
| // same bytes for a live credential, an already-revoked one and a name |
| // that never existed, which is what makes the idempotence above visible |