| 1 | // The service-credential API (issue #220, spec §6): mint, list, rotate and |
| 2 | // revoke the principal that is not an address. |
| 3 | // |
| 4 | // This is the highest-authority surface the console has. Everything a |
| 5 | // credential can ever do is decided here, once, at mint — the reach is a |
| 6 | // property of the credential rather than of whoever holds it next month — |
| 7 | // so the doors are modelled on fleets.go: the same account resolution, the |
| 8 | // same mint-once discipline (the plaintext appears in the create and rotate |
| 9 | // responses and nowhere else, because only its hash is stored), the same |
| 10 | // byte-identical refusals. |
| 11 | // |
| 12 | // Two rules distinguish this file from every other /api/cli/ family: |
| 13 | // |
| 14 | // - Every door registers with the ZERO role set, so no service credential |
| 15 | // reaches any of them — not even an admin one, and not even to rotate |
| 16 | // itself. That is the escalation cut spec §3 turns on, and it is |
| 17 | // enforced in cliRoute, before these handlers run. Nothing here |
| 18 | // re-checks it: one place per rule. |
| 19 | // - Every door is OWNER-gated (apiOwnerAccount). Minting is an authority |
| 20 | // grant that outlives its minter and is not the ordinary member write it |
| 21 | // resembles. |
| 22 | package console |
| 23 | |
| 24 | import ( |
| 25 | "errors" |
| 26 | "fmt" |
| 27 | "log" |
| 28 | "net/http" |
| 29 | "regexp" |
| 30 | "sort" |
| 31 | "strings" |
| 32 | "time" |
| 33 | |
| 34 | "github.com/carlosframework/platform/internal/registry" |
| 35 | "github.com/carlosframework/platform/internal/runs" |
| 36 | "github.com/carlosframework/platform/internal/svcrole" |
| 37 | ) |
| 38 | |
| 39 | // serviceView is the wire shape of one listed credential — deliberately NOT |
| 40 | // CLIToken, so that a field added to the store's row never becomes wire by |
| 41 | // accident. It carries no secret and it never will: only the sha256 of a |
| 42 | // credential is stored, so there is nothing here to leak even in principle. |
| 43 | // |
| 44 | // It DOES carry the listing id, which is that sha256 (C36). The hash is not |
| 45 | // a credential — only its preimage authenticates, so knowing it grants |
| 46 | // nothing — and it is this codebase's established addressing handle: the |
| 47 | // personal settings page has always posted it as `id` to |
| 48 | // handleCLITokenRevoke. It earns its place here because a legacy kind='app' |
| 49 | // row has NO name, and the id is the only handle it has; without it the |
| 50 | // listing would show an owner a credential they could not then revoke |
| 51 | // through this API. |
| 52 | type serviceView struct { |
| 53 | // ID is the listing handle revoke accepts in place of a name. See above. |
| 54 | ID string `json:"id"` |
| 55 | Name string `json:"name"` |
| 56 | Role string `json:"role"` |
| 57 | // Label is the free text NewAppToken records for a legacy row ("router" |
| 58 | // on titogo's). It is the only thing distinguishing two nameless |
| 59 | // credentials bound to the same app, so an owner deciding which to |
| 60 | // revoke needs it. Empty for a named service credential, which has a |
| 61 | // name to be known by instead. |
| 62 | Label string `json:"label"` |
| 63 | // App is the scope: the empty string means account-scoped, and it is |
| 64 | // rendered rather than omitted so "bound to nothing" and "bound to an |
| 65 | // app whose name did not survive the wire" cannot be confused. |
| 66 | App string `json:"app"` |
| 67 | // Rungs is a deploy credential's ladder bound, canonical order. An |
| 68 | // empty array, never null, for every other row. |
| 69 | Rungs []string `json:"rungs"` |
| 70 | // Commands is a run credential's granted command prefixes, each a list |
| 71 | // of words, in the order minted. An empty array, never null, for every |
| 72 | // other row. |
| 73 | Commands [][]string `json:"commands"` |
| 74 | // CommandsUnreadable is true for a run row whose stored command list |
| 75 | // cannot be read, which CLIPrincipal refuses at sign-in. Omitted |
| 76 | // otherwise. Commands is then []. |
| 77 | CommandsUnreadable bool `json:"commands_unreadable,omitempty"` |
| 78 | // Created is when the credential was GRANTED; Rotated is when its |
| 79 | // current secret began. The two answer different questions — how long |
| 80 | // has this existed, and how old is the secret in use right now — and a |
| 81 | // rotation policy needs the second without destroying the first |
| 82 | // (svcprincipal.go, RotateServicePrincipal). |
| 83 | Created string `json:"created"` |
| 84 | Rotated string `json:"rotated"` |
| 85 | LastUsed string `json:"last_used"` |
| 86 | MintedBy string `json:"minted_by"` |
| 87 | // There is deliberately no `revoked` field, though the brief's field |
| 88 | // list names one. ServicePrincipals filters revoked_at = 0 (a revoked |
| 89 | // row is a tombstone, not history — see RevokeServicePrincipal), so |
| 90 | // every row reaching here is live and the field would be a constant |
| 91 | // false. Absent as derived, not forgotten. |
| 92 | } |
| 93 | |
| 94 | // apiOwnerAccount is apiPrincipalAccount plus the owner check, for the doors |
| 95 | // whose subject is a credential rather than an app. |
| 96 | // |
| 97 | // The two refusals are deliberately different and the difference is the |
| 98 | // point. A caller who does not hold this account at all gets the family's |
| 99 | // byte-identical 404 from apiPrincipalAccount — unchanged, and the reason it |
| 100 | // must stay byte-identical is that a distinct answer here would confirm the |
| 101 | // account exists. A caller who DOES hold it but is only a member gets a 403: |
| 102 | // they have already proved membership, so there is nothing left to conceal, |
| 103 | // and a 404 would send an ordinary member hunting for a typo in a sqid they |
| 104 | // typed correctly. |
| 105 | // |
| 106 | // A service principal never arrives here: every door in this file registers |
| 107 | // with the zero role Set, so the role gate refuses it before the handler |
| 108 | // runs (spec §3). This gate does not need to re-check that, and deliberately |
| 109 | // does not — one place per rule. It fails closed anyway if that ever |
| 110 | // changed: AccountByID, which the service branch resolves through, does not |
| 111 | // populate Role, so a service's account row could not equal RoleOwner. |
| 112 | // |
| 113 | // Account.Role is populated by AccountsFor's join, so the check is one |
| 114 | // comparison rather than a second query. |
| 115 | func (s *Server) apiOwnerAccount(w http.ResponseWriter, r *http.Request) (Principal, *Account, bool) { |
| 116 | return s.apiOwnerAccountFor(w, r, "manage service credentials") |
| 117 | } |
| 118 | |
| 119 | // apiOwnerAccountFor is the shared owner gate for account-level CLI doors. |
| 120 | // purpose completes the refusal in the vocabulary of the door the caller |
| 121 | // actually used; the membership-hiding 404 remains owned by |
| 122 | // apiPrincipalAccount and is byte-identical across all callers. |
| 123 | func (s *Server) apiOwnerAccountFor(w http.ResponseWriter, r *http.Request, purpose string) (Principal, *Account, bool) { |
| 124 | p, acct, ok := s.apiPrincipalAccount(w, r) |
| 125 | if !ok { |
| 126 | return Principal{}, nil, false |
| 127 | } |
| 128 | if acct.Role != RoleOwner { |
| 129 | jsonError(w, http.StatusForbidden, "only an account owner can "+purpose) |
| 130 | return Principal{}, nil, false |
| 131 | } |
| 132 | return p, acct, true |
| 133 | } |
| 134 | |
| 135 | // doorsFor lists the /api/cli/ patterns a role reaches, sorted. |
| 136 | // |
| 137 | // It reads s.cliRoles — the SAME map the gate consults on every request — so |
| 138 | // the answer create hands back cannot drift from what the credential will |
| 139 | // actually be admitted to. A hand-written list in a help string would be |
| 140 | // right on the day it was written and wrong on the day a door moved. |
| 141 | // |
| 142 | // This exists because of AGENTS.md's no-infra rule read as a rule about |
| 143 | // comprehension rather than only about access: an operator must be able to |
| 144 | // see what they granted without reading a spec. |
| 145 | func (s *Server) doorsFor(role svcrole.Role) []string { |
| 146 | out := make([]string, 0, len(s.cliRoles)) |
| 147 | for pattern, set := range s.cliRoles { |
| 148 | if set.Permits(role) { |
| 149 | out = append(out, pattern) |
| 150 | } |
| 151 | } |
| 152 | sort.Strings(out) |
| 153 | return out |
| 154 | } |
| 155 | |
| 156 | // handleAPIServiceCreate answers `carlos services create`: mints a |
| 157 | // credential bound to this account and, optionally, to one app inside it. |
| 158 | // |
| 159 | // The plaintext is in this response and nowhere else. Only its sha256 hex |
| 160 | // reaches the database, so there is no second copy for a later door to hand |
| 161 | // out and nothing to recover if it is lost — the answer to a lost secret is |
| 162 | // rotate, which is one command. |
| 163 | func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) { |
| 164 | p, acct, ok := s.apiOwnerAccount(w, r) |
| 165 | if !ok { |
| 166 | return |
| 167 | } |
| 168 | // The ordinary per-minute WRITE budget, deliberately not the scarce |
| 169 | // 10-per-day create budget handleAPIFleetNew and handleAPICreateApp |
| 170 | // spend. That daily budget exists for namespaces whose spam is |
| 171 | // PERMANENT: a claimed app name can never be reused, and a fleet name is |
| 172 | // globally unique forever, so each wasted attempt costs something nobody |
| 173 | // can get back. A service credential is none of that — per-account, |
| 174 | // revocable, and its name is free again the moment it is revoked (the |
| 175 | // unique index is scoped to live rows). The door is owner-gated on top, |
| 176 | // which is a far tighter bound than app-create has. Do not "fix" this |
| 177 | // toward fleets. |
| 178 | if !s.allowWrite(p, time.Now()) { |
| 179 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 180 | return |
| 181 | } |
| 182 | var req struct { |
| 183 | Name string `json:"name"` |
| 184 | Role string `json:"role"` |
| 185 | App string `json:"app"` |
| 186 | // Rungs is the deploy credential's ladder bound: the rungs it may |
| 187 | // move. Required for role deploy, refused for every other role. |
| 188 | Rungs []string `json:"rungs"` |
| 189 | // Commands is the run credential's granted command prefixes, each |
| 190 | // a list of argv words. Required for role run, refused for every |
| 191 | // other role. |
| 192 | Commands [][]string `json:"commands"` |
| 193 | } |
| 194 | if !readJSON(w, r, &req) { |
| 195 | return |
| 196 | } |
| 197 | // ValidTunnelName, the grammar a fleet label takes, and for the same |
| 198 | // reason: this is a handle an operator types on a command line, and it |
| 199 | // is what rotate and revoke address the credential by for the rest of |
| 200 | // its life. A name carrying a path separator or a space would reach the |
| 201 | // column verbatim with nothing downstream able to fix it. |
| 202 | name := strings.TrimSpace(req.Name) |
| 203 | if !registry.ValidTunnelName(name) { |
| 204 | jsonError(w, http.StatusBadRequest, |
| 205 | "invalid credential name — 1-32 characters of a-z, 0-9 and hyphen") |
| 206 | return |
| 207 | } |
| 208 | role, ok := svcrole.Parse(strings.TrimSpace(req.Role)) |
| 209 | if !ok { |
| 210 | roles := svcrole.All() |
| 211 | names := make([]string, len(roles)) |
| 212 | for i, r := range roles { |
| 213 | names[i] = string(r) |
| 214 | } |
| 215 | jsonError(w, http.StatusBadRequest, |
| 216 | "unknown role — one of: "+strings.Join(names, ", ")) |
| 217 | return |
| 218 | } |
| 219 | // An app-scoped credential must name an app that is actually in this |
| 220 | // account. Otherwise the mint succeeds against nothing, and the day |
| 221 | // somebody creates an app of that name the credential silently acquires |
| 222 | // authority over an app its minter never chose. The 404 is the family's: |
| 223 | // the caller already holds this account, so it says only that the app is |
| 224 | // not here. |
| 225 | app := strings.TrimSpace(req.App) |
| 226 | if app != "" { |
| 227 | found, err := s.store.HasAppIn(acct.ID, app) |
| 228 | if err != nil { |
| 229 | log.Printf("console: service create %s/%s: has-app %s: %v", acct.Sqid, name, app, err) |
| 230 | jsonError(w, http.StatusInternalServerError, "could not check the app — try again") |
| 231 | return |
| 232 | } |
| 233 | if !found { |
| 234 | http.NotFound(w, r) |
| 235 | return |
| 236 | } |
| 237 | } |
| 238 | actor := auditActor(p, acct) |
| 239 | secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, req.Rungs, req.Commands, actor, time.Now()) |
| 240 | if err != nil { |
| 241 | // The sentinel, not a string match: a name collision is the |
| 242 | // caller's to fix and a busy database is not, and the two must not |
| 243 | // answer alike. |
| 244 | if errors.Is(err, ErrServiceNameTaken) { |
| 245 | jsonError(w, http.StatusConflict, "a credential named "+name+" already exists in this account") |
| 246 | return |
| 247 | } |
| 248 | if errors.Is(err, ErrAccountScopedService) { |
| 249 | jsonError(w, http.StatusBadRequest, |
| 250 | "a service-role credential must name one app (--app): it is held by an app at runtime, "+ |
| 251 | "and an account-wide one would reach every app in the account") |
| 252 | return |
| 253 | } |
| 254 | var ue *UnknownRungError |
| 255 | switch { |
| 256 | case errors.Is(err, ErrAccountScopedDeploy): |
| 257 | jsonError(w, http.StatusBadRequest, "a deploy credential must name one app (--app)") |
| 258 | return |
| 259 | case errors.Is(err, ErrDeployNeedsRungs): |
| 260 | jsonError(w, http.StatusBadRequest, "a deploy credential must name its rungs (--rungs): canary, edge or beta") |
| 261 | return |
| 262 | case errors.Is(err, ErrStableNotDelegable): |
| 263 | jsonError(w, http.StatusBadRequest, "stable cannot be delegated: moving it needs a passkey in the console") |
| 264 | return |
| 265 | case errors.As(err, &ue): |
| 266 | jsonError(w, http.StatusBadRequest, fmt.Sprintf("unknown rung %q: one of canary, edge, beta", ue.Rung)) |
| 267 | return |
| 268 | case errors.Is(err, ErrRungsNeedDeploy): |
| 269 | jsonError(w, http.StatusBadRequest, "only a deploy credential takes rungs") |
| 270 | return |
| 271 | } |
| 272 | if msg, ok := runMintRefusal(err); ok { |
| 273 | jsonError(w, http.StatusBadRequest, msg+runMintFlagHint(err)) |
| 274 | return |
| 275 | } |
| 276 | log.Printf("console: service create %s/%s: %v", acct.Sqid, name, err) |
| 277 | jsonError(w, http.StatusInternalServerError, "could not mint the credential — try again") |
| 278 | return |
| 279 | } |
| 280 | scope := "the whole account" |
| 281 | if app != "" { |
| 282 | scope = "app " + app |
| 283 | } |
| 284 | canon, _ := canonicalRungs(req.Rungs) // already validated by the mint |
| 285 | rungs := []string{} |
| 286 | if canon != "" { |
| 287 | rungs = strings.Split(canon, ",") |
| 288 | } |
| 289 | commands := [][]string{} |
| 290 | if role == svcrole.Run { |
| 291 | commands = req.Commands // already validated by the mint |
| 292 | } |
| 293 | detail := "minted service credential " + name + " with role " + string(role) + " over " + scope + " (cli)" |
| 294 | switch role { |
| 295 | case svcrole.Deploy: |
| 296 | detail = fmt.Sprintf("minted service credential %s with role deploy over app %s, rungs %s (%s)", name, app, canon, "cli") |
| 297 | case svcrole.Run: |
| 298 | detail = fmt.Sprintf("minted service credential %s with role run over app %s, commands %s (%s)", name, app, runs.FormatCommandList(commands), "cli") |
| 299 | } |
| 300 | s.audit(acct, serviceAuditKey(name, app), actor, "service-create", detail) |
| 301 | writeJSON(w, http.StatusCreated, map[string]any{ |
| 302 | "name": name, |
| 303 | "role": string(role), |
| 304 | "app": app, |
| 305 | "rungs": rungs, |
| 306 | "commands": commands, |
| 307 | "account": acct.Sqid, |
| 308 | "token": secret, |
| 309 | "doors": s.doorsFor(role), |
| 310 | }) |
| 311 | } |
| 312 | |
| 313 | // runMintRefusal is the words for a run credential's mint refusals, shared |
| 314 | // by both mint doors so they cannot describe one rule two ways. ok is false |
| 315 | // for an error that is not one of them. The API door adds the flag to fix |
| 316 | // (runMintFlagHint); the console form says it as a sentence. |
| 317 | func runMintRefusal(err error) (msg string, ok bool) { |
| 318 | switch { |
| 319 | case errors.Is(err, ErrAccountScopedRun): |
| 320 | return "a run credential must name one app", true |
| 321 | case errors.Is(err, ErrRunNeedsCommands), errors.Is(err, runs.ErrNoCommands): |
| 322 | return `a run credential must name the commands it may run, for example "import classic"`, true |
| 323 | case errors.Is(err, ErrCommandsNeedRun): |
| 324 | return "only a run credential takes commands", true |
| 325 | case errors.Is(err, runs.ErrTooManyCommands): |
| 326 | return fmt.Sprintf("a run credential takes at most %d command prefixes", runs.MaxCommandPrefixes), true |
| 327 | case errors.Is(err, runs.ErrEmptyCommand): |
| 328 | return "a command prefix has no words", true |
| 329 | case errors.Is(err, runs.ErrEmptyCommandWord): |
| 330 | return "a command prefix holds an empty word", true |
| 331 | case errors.Is(err, runs.ErrCommandWordTooLong): |
| 332 | return fmt.Sprintf("a command word is over %d bytes", runs.MaxCommandWordBytes), true |
| 333 | case errors.Is(err, runs.ErrCommandWordChars): |
| 334 | return "a command word holds a space, a comma or a character that cannot be printed", true |
| 335 | } |
| 336 | return "", false |
| 337 | } |
| 338 | |
| 339 | // runMintFlagHint names the CLI flag that fixes a run mint refusal, the way |
| 340 | // the deploy refusals name --app and --rungs. |
| 341 | func runMintFlagHint(err error) string { |
| 342 | switch { |
| 343 | case errors.Is(err, ErrAccountScopedRun): |
| 344 | return " (--app)" |
| 345 | case errors.Is(err, ErrCommandsNeedRun): |
| 346 | return "" |
| 347 | } |
| 348 | return " (--commands)" |
| 349 | } |
| 350 | |
| 351 | // handleAPIServiceList answers `carlos services list`: every live credential |
| 352 | // bound to this account, service-named and legacy-nameless alike, so that |
| 353 | // absorbing the old kind='app' rows into this model does not make them |
| 354 | // invisible. A credential nobody can see is one nobody rotates. |
| 355 | // |
| 356 | // Never the hash and never a secret — see serviceView. |
| 357 | func (s *Server) handleAPIServiceList(w http.ResponseWriter, r *http.Request) { |
| 358 | _, acct, ok := s.apiOwnerAccount(w, r) |
| 359 | if !ok { |
| 360 | return |
| 361 | } |
| 362 | rows, err := s.store.ServicePrincipals(acct.ID) |
| 363 | if err != nil { |
| 364 | log.Printf("console: service list %s: %v", acct.Sqid, err) |
| 365 | jsonError(w, http.StatusInternalServerError, "could not list credentials") |
| 366 | return |
| 367 | } |
| 368 | out := make([]serviceView, 0, len(rows)) |
| 369 | for _, t := range rows { |
| 370 | rungs := []string{} |
| 371 | if t.Rungs != "" { |
| 372 | rungs = strings.Split(t.Rungs, ",") |
| 373 | } |
| 374 | commands := t.CommandPrefixes() |
| 375 | if commands == nil { |
| 376 | commands = [][]string{} |
| 377 | } |
| 378 | out = append(out, serviceView{ |
| 379 | ID: t.ID, |
| 380 | Name: t.Name, |
| 381 | Label: t.Label, |
| 382 | Role: t.Role, |
| 383 | App: t.App, |
| 384 | Rungs: rungs, |
| 385 | Commands: commands, |
| 386 | // Set only for a run row whose stored list cannot be read: |
| 387 | // CLIPrincipal refuses it, and an empty list would read as |
| 388 | // a credential that may run nothing rather than one that |
| 389 | // cannot sign in at all. |
| 390 | CommandsUnreadable: t.CommandsUnreadable(), |
| 391 | Created: serviceStamp(t.CreatedAt), |
| 392 | Rotated: serviceStamp(t.RotatedAt), |
| 393 | LastUsed: serviceStamp(t.LastUsed), |
| 394 | MintedBy: t.Address, |
| 395 | }) |
| 396 | } |
| 397 | writeJSON(w, http.StatusOK, map[string]any{"services": out}) |
| 398 | } |
| 399 | |
| 400 | // serviceStamp renders a timestamp for the listing, with the ZERO time rendered as |
| 401 | // an empty string rather than as 1 Jan 1970. A credential that has never |
| 402 | // been used, and a legacy row minted before rotated_at existed, must not |
| 403 | // read as fifty-six years stale on the page an owner uses to decide what to |
| 404 | // rotate — that is the same failure CLIToken.NeverExpires exists to prevent |
| 405 | // at the other end of the row. |
| 406 | func serviceStamp(t time.Time) string { |
| 407 | if t.IsZero() || t.Unix() == 0 { |
| 408 | return "" |
| 409 | } |
| 410 | return t.UTC().Format(time.RFC3339) |
| 411 | } |
| 412 | |
| 413 | // listingIDRe is the shape of a listing id: the hex sha256 hashToken writes, |
| 414 | // exactly 64 characters. It accepts UPPERCASE hex as well as lower, and the |
| 415 | // id arm below folds what it matches to lower. |
| 416 | // |
| 417 | // That is not politeness, it is the difference between a credential being |
| 418 | // dead and an operator believing it is. hashToken writes lowercase, but the |
| 419 | // person reaching for this door is racing a compromise and is as likely to |
| 420 | // have the id from PowerShell's Get-FileHash, certutil, or a pasted audit |
| 421 | // extract — all of which emit uppercase — as from the listing. Case-strict, |
| 422 | // that paste matches neither arm, and the answer to an unaddressable handle |
| 423 | // is a 200 indistinguishable from a successful revoke. The operator walks |
| 424 | // away from a live admin credential believing they killed it. |
| 425 | // |
| 426 | // Folding is provably safe HERE and nowhere else. A 64-character segment can |
| 427 | // never be a credential name (ValidTunnelName caps a name at 32), so nothing |
| 428 | // this arm folds could have been meant as a name. The NAME arm stays |
| 429 | // byte-exact for exactly the opposite reason: folding there would make the |
| 430 | // segment HUB revoke the credential hub, which is a different credential |
| 431 | // than the one the caller typed, and silently. |
| 432 | var listingIDRe = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) |
| 433 | |
| 434 | // serviceHandle is the {name} path segment resolved to how it addresses a |
| 435 | // row: a NAME (create's grammar) or a listing ID (C36). |
| 436 | // |
| 437 | // The two grammars are disjoint — a name is at most 32 characters of |
| 438 | // [a-z0-9-] and a listing id is exactly 64 hex characters — so a segment can |
| 439 | // never be read as both, and no flag or query parameter is needed to say |
| 440 | // which one the caller meant. That disjointness is also what makes the id |
| 441 | // arm safe to case-fold while the name arm is not (see listingIDRe). A |
| 442 | // segment that is neither addresses nothing: no row this console ever wrote |
| 443 | // could match it. |
| 444 | // |
| 445 | // Refusing an unrecognisable segment here rather than passing it to the |
| 446 | // store also keeps an arbitrary, arbitrarily long path segment out of the |
| 447 | // audit trail's app column on the revoke path, which audits even when |
| 448 | // nothing matched. It narrows no answer: every caller turns an unaddressable |
| 449 | // segment into the same answer its door already gives for a handle that |
| 450 | // matches nothing. |
| 451 | type serviceHandle struct { |
| 452 | name string // "" unless the segment is a credential name |
| 453 | id string // "" unless the segment is a listing id |
| 454 | } |
| 455 | |
| 456 | func (h serviceHandle) addresses() bool { return h.name != "" || h.id != "" } |
| 457 | |
| 458 | func serviceHandleFrom(r *http.Request) serviceHandle { |
| 459 | // No TrimSpace: create stores the trimmed name, so the stored value |
| 460 | // always satisfies ValidTunnelName exactly, and a padded segment would |
| 461 | // not match a row in any case. |
| 462 | return serviceHandleOf(r.PathValue("name")) |
| 463 | } |
| 464 | |
| 465 | // serviceHandleOf is serviceHandleFrom's grammar, taken as a plain string so |
| 466 | // the console PAGE's revoke form (svcpage.go) reads a handle exactly the way |
| 467 | // the API door reads a path segment. One grammar, one place: a browser form |
| 468 | // that decided for itself what a name looked like would drift from the door |
| 469 | // its own buttons are meant to mirror. |
| 470 | func serviceHandleOf(seg string) serviceHandle { |
| 471 | switch { |
| 472 | case registry.ValidTunnelName(seg): |
| 473 | return serviceHandle{name: seg} |
| 474 | case listingIDRe.MatchString(seg): |
| 475 | // Folded to the case hashToken writes. See listingIDRe: safe on this |
| 476 | // arm alone, because a 64-character segment cannot be a name. |
| 477 | return serviceHandle{id: strings.ToLower(seg)} |
| 478 | } |
| 479 | return serviceHandle{} |
| 480 | } |
| 481 | |
| 482 | // serviceAuditKey is the app column an audit row about one credential is |
| 483 | // filed under: the app the credential is scoped to when it has one, and the |
| 484 | // credential's own name otherwise. |
| 485 | // |
| 486 | // An app-scoped credential's grant, rotation and revocation belong in THAT |
| 487 | // APP's activity feed, because that feed is the one place the app's owner |
| 488 | // looks. Filing them under the credential's name instead — the shape fleets |
| 489 | // uses, where the subject really is the thing named — would leave the owner |
| 490 | // of an app with no sight of the authority handed out over it (C38). An |
| 491 | // account-scoped credential has no app to file under and keeps the fleets |
| 492 | // shape. |
| 493 | func serviceAuditKey(name, app string) string { |
| 494 | if app != "" { |
| 495 | return app |
| 496 | } |
| 497 | return name |
| 498 | } |
| 499 | |
| 500 | // serviceWhat names the credential a revoke acted on: the SUBJECT an audit |
| 501 | // row is filed under and the WORDS both doors use to describe what happened. |
| 502 | // |
| 503 | // Shared by handleAPIServiceRevoke and handleServiceRevokePage (svcpage.go), |
| 504 | // which is the whole point of it being a function — the two doors write into |
| 505 | // the same activity feed about the same event, and a phrasing improvement |
| 506 | // made in one of them would otherwise leave the account's trail disagreeing |
| 507 | // with itself depending on whether an operator used the browser or the CLI. |
| 508 | // Same reason serviceHandleOf is shared rather than re-derived. |
| 509 | // |
| 510 | // A nameless legacy row is described by what it IS — its label and its app — |
| 511 | // because its id says nothing to whoever reads the trail a year later. The |
| 512 | // handle is the fallback for a row that could not be read back: not-found is |
| 513 | // never "there was nothing to revoke", only "we could not name it". |
| 514 | func serviceWhat(h serviceHandle, row CLIToken, found bool) (subject, what string) { |
| 515 | subject, what = h.name, "service credential "+h.name |
| 516 | if subject == "" { |
| 517 | subject, what = h.id, "credential "+h.id |
| 518 | } |
| 519 | if !found { |
| 520 | return subject, what |
| 521 | } |
| 522 | if row.Name != "" { |
| 523 | return row.Name, "service credential " + row.Name |
| 524 | } |
| 525 | what = "app credential for " + row.App |
| 526 | if row.Label != "" { |
| 527 | what = "app credential " + row.Label + " for " + row.App |
| 528 | } |
| 529 | return row.App, what |
| 530 | } |
| 531 | |
| 532 | // serviceAuditSubject is the app column an audit row is filed under when |
| 533 | // there is no credential to name — an attempted revoke whose handle |
| 534 | // addresses nothing (C42). |
| 535 | // |
| 536 | // The parentheses and the space are load-bearing: an app name is lowercase |
| 537 | // letters, digits and dashes starting with a letter (handleAPICreateApp), so |
| 538 | // this string cannot collide with a real app's activity feed. |
| 539 | const serviceAuditSubject = "(service credentials)" |
| 540 | |
| 541 | // findServiceCredential looks one of this account's LIVE credentials up by |
| 542 | // whichever handle addressed it. |
| 543 | // |
| 544 | // It exists for the audit trail alone. The store methods that do the work |
| 545 | // answer only "did anything match", and serviceAuditKey needs the app a |
| 546 | // credential is scoped to — which, on the revoke path, has to be read BEFORE |
| 547 | // the write, since a revoked row leaves the listing. |
| 548 | // |
| 549 | // Best-effort by design: a read failure is reported as not-found, and every |
| 550 | // caller treats not-found as "file it under the credential's own name", |
| 551 | // never as "there was nothing to revoke". That is why the authoritative |
| 552 | // write in each caller is keyed on what the CALLER named rather than on |
| 553 | // anything found here — a database hiccup must not turn an emergency revoke |
| 554 | // into a silent success. |
| 555 | func (s *Server) findServiceCredential(accountID int64, h serviceHandle) (CLIToken, bool) { |
| 556 | rows, err := s.store.ServicePrincipals(accountID) |
| 557 | if err != nil { |
| 558 | log.Printf("console: service lookup in %d: %v", accountID, err) |
| 559 | return CLIToken{}, false |
| 560 | } |
| 561 | for _, t := range rows { |
| 562 | if (h.name != "" && t.Name == h.name) || (h.id != "" && t.ID == h.id) { |
| 563 | return t, true |
| 564 | } |
| 565 | } |
| 566 | return CLIToken{}, false |
| 567 | } |
| 568 | |
| 569 | // handleAPIServiceRotate answers `carlos services rotate`: a fresh secret |
| 570 | // for a credential that already exists, its hash replacing the stored one. |
| 571 | // |
| 572 | // ROTATION KEEPS THE IDENTITY. Same row, same name, same role, same scope, |
| 573 | // same audit history and same rate-limit budget (principalKey keys a service |
| 574 | // on its name for exactly this reason) — only the secret moves. What it does |
| 575 | // do, immediately, is stop the OLD secret working: CLIPrincipal resolves by |
| 576 | // hash, and the old hash is gone from the table the moment this write lands. |
| 577 | func (s *Server) handleAPIServiceRotate(w http.ResponseWriter, r *http.Request) { |
| 578 | p, acct, ok := s.apiOwnerAccount(w, r) |
| 579 | if !ok { |
| 580 | return |
| 581 | } |
| 582 | if !s.allowWrite(p, time.Now()) { |
| 583 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 584 | return |
| 585 | } |
| 586 | // Rotate is NAME-only, where revoke also takes a listing id. A row a |
| 587 | // listing id addresses is either nameless — a legacy kind='app' row, |
| 588 | // which has no rotate path at all (RotateServicePrincipal refuses an |
| 589 | // empty name by design, so that one call cannot rotate several rows at |
| 590 | // once) — or it has a name, which is the handle to use. So an |
| 591 | // id-shaped segment here is the ordinary not-found, not a second way in. |
| 592 | h := serviceHandleFrom(r) |
| 593 | if h.name == "" { |
| 594 | http.NotFound(w, r) |
| 595 | return |
| 596 | } |
| 597 | name := h.name |
| 598 | var secret string |
| 599 | err := s.serviceCredentialWrite(r.Context(), acct, h, "sql.RotateServicePrincipal", func(st *Store) error { |
| 600 | var err error |
| 601 | secret, err = st.RotateServicePrincipal(acct.ID, name, time.Now()) |
| 602 | return err |
| 603 | }) |
| 604 | if err != nil { |
| 605 | // Within an account the caller already owns, an unknown name is an |
| 606 | // ordinary not-found rather than a cross-tenant probe — the same |
| 607 | // reasoning findFleetBox gives for a bad box label inside an owned |
| 608 | // fleet. The sentinel is what separates it from a store failure. |
| 609 | if errors.Is(err, ErrNoServicePrincipal) { |
| 610 | http.NotFound(w, r) |
| 611 | return |
| 612 | } |
| 613 | log.Printf("console: service rotate %s/%s: %v", acct.Sqid, name, err) |
| 614 | jsonError(w, http.StatusInternalServerError, "could not rotate the credential — try again") |
| 615 | return |
| 616 | } |
| 617 | // The row survives a rotation unchanged but for its secret, so this |
| 618 | // reads back the app it is scoped to and files the audit row there |
| 619 | // (serviceAuditKey). Best-effort: a failed read files under the name. |
| 620 | row, _ := s.findServiceCredential(acct.ID, h) |
| 621 | actor := auditActor(p, acct) |
| 622 | s.audit(acct, serviceAuditKey(name, row.App), actor, "service-rotate", |
| 623 | "rotated the secret of service credential "+name+" (cli)") |
| 624 | writeJSON(w, http.StatusOK, map[string]any{ |
| 625 | "name": name, |
| 626 | "account": acct.Sqid, |
| 627 | "token": secret, |
| 628 | }) |
| 629 | } |
| 630 | |
| 631 | // handleAPIServiceRevoke answers `carlos services revoke`: the credential |
| 632 | // stops resolving, at once and for good. |
| 633 | // |
| 634 | // IT TAKES A NAME OR A LISTING ID (C36). A named service credential is |
| 635 | // addressed the ergonomic way, by the name its owner chose. A legacy |
| 636 | // kind='app' row has no name — nothing does, every one of them carries the |
| 637 | // empty string — so the listing id is the only handle it will ever have, and |
| 638 | // without it an owner could see such a credential in the listing and have no |
| 639 | // way to kill it from here. serviceHandle tells the two apart by shape. |
| 640 | // |
| 641 | // REVOCATION IS IDEMPOTENT. An unknown name, an unknown id, an |
| 642 | // already-revoked credential and a live one all answer 200 |
| 643 | // "result":"revoked", because the person reaching for this door is usually |
| 644 | // racing a compromise and must not have to work out whether their first |
| 645 | // attempt landed. What they need afterwards — that no credential of that |
| 646 | // handle is live — is true in every one of those cases. |
| 647 | // |
| 648 | // AND IT SAYS WHICH IT WAS, in a non-authoritative "matched" boolean (C41). |
| 649 | // Idempotence demands only that a SECOND call on the same handle answer like |
| 650 | // the first, and "matched": false on a second revoke is both true and |
| 651 | // useful. The likely miss here is not a stolen id but a typo — `revoke hubb` |
| 652 | // — and an opaque 200 turns that into a false all-clear at the one door used |
| 653 | // in anger. It costs nothing in disclosure either: the caller is the |
| 654 | // account's owner, so "is there a live credential of this handle" is not a |
| 655 | // fact being kept from them; it is in the listing one door away. |
| 656 | // |
| 657 | // An id belonging to ANOTHER account is a different matter, and it is |
| 658 | // refused by construction: both store calls below are scoped to acct.ID, so |
| 659 | // a foreign id matches nothing, reports "matched": false, and has changed |
| 660 | // nothing. |
| 661 | func (s *Server) handleAPIServiceRevoke(w http.ResponseWriter, r *http.Request) { |
| 662 | p, acct, ok := s.apiOwnerAccount(w, r) |
| 663 | if !ok { |
| 664 | return |
| 665 | } |
| 666 | if !s.allowWrite(p, time.Now()) { |
| 667 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 668 | return |
| 669 | } |
| 670 | actor := auditActor(p, acct) |
| 671 | h := serviceHandleFrom(r) |
| 672 | if !h.addresses() { |
| 673 | // A segment that is neither a name nor a listing id matches no row |
| 674 | // this console ever wrote, so it reports the same "nothing matched" |
| 675 | // an unknown handle does. |
| 676 | // |
| 677 | // It is AUDITED, under a subject that is not an app (C42). An |
| 678 | // attempted revoke that could not have worked is exactly the event |
| 679 | // an operator goes looking for afterwards — "I definitely revoked |
| 680 | // that" — and the early return used to leave no trace of it at all, |
| 681 | // which contradicted the principle stated at the audit call below. |
| 682 | // |
| 683 | // The handle itself is not echoed into the row. By definition it is |
| 684 | // not something this console wrote, and it is an unbounded |
| 685 | // caller-controlled path segment; keeping it out of the trail is the |
| 686 | // same reason serviceHandle refuses to pass it to the store. Who, |
| 687 | // when, and "the handle was unusable" is what the operator needs. |
| 688 | s.audit(acct, serviceAuditSubject, actor, "service-revoke", |
| 689 | "revoke attempted with a handle that is neither a credential name nor a listing id (cli)") |
| 690 | writeJSON(w, http.StatusOK, map[string]any{"result": "revoked", "matched": false}) |
| 691 | return |
| 692 | } |
| 693 | // Read before the write, for the audit only: a revoked row leaves the |
| 694 | // listing, so the app it was scoped to is unknowable afterwards. The |
| 695 | // authoritative write below is keyed on what the CALLER named, never on |
| 696 | // what this found, so a failed read costs an audit row's filing and |
| 697 | // never a revoke. |
| 698 | row, found := s.findServiceCredential(acct.ID, h) |
| 699 | |
| 700 | err := s.serviceCredentialWrite(r.Context(), acct, h, "sql.RevokeServicePrincipal", func(st *Store) error { |
| 701 | if h.name != "" { |
| 702 | return st.RevokeServicePrincipal(acct.ID, h.name, time.Now()) |
| 703 | } |
| 704 | // The universal handle: account-scoped, and the only one that |
| 705 | // reaches a nameless row. Safe by construction — a person's own |
| 706 | // cli_tokens row carries account_id = 0, so an account-scoped |
| 707 | // revoke cannot reach a human's terminal token (RevokeAccountToken). |
| 708 | return st.RevokeAccountToken(acct.ID, h.id, time.Now()) |
| 709 | }) |
| 710 | if err != nil && !errors.Is(err, ErrNoServicePrincipal) { |
| 711 | log.Printf("console: service revoke %s: %v", acct.Sqid, err) |
| 712 | jsonError(w, http.StatusInternalServerError, "could not revoke the credential — try again") |
| 713 | return |
| 714 | } |
| 715 | // Audited even when nothing matched: "someone tried to revoke X" is |
| 716 | // worth having in the trail of an account whose credentials are being |
| 717 | // argued about, and the detail says which it was. |
| 718 | subject, what := serviceWhat(h, row, found) |
| 719 | detail := "revoked " + what + " (cli)" |
| 720 | if err != nil { |
| 721 | detail = "revoked " + what + " — nothing live matched (cli)" |
| 722 | } |
| 723 | s.audit(acct, serviceAuditKey(subject, row.App), actor, "service-revoke", detail) |
| 724 | // The body carries no name and nothing else request-derived, so two |
| 725 | // revokes of the same handle are the same bytes — which is what makes |
| 726 | // the idempotence above visible from the outside rather than merely true |
| 727 | // inside. `matched` is the one thing that varies, and it varies with |
| 728 | // what HAPPENED rather than with what was asked: false on the second |
| 729 | // call for the same credential the first call killed. |
| 730 | writeJSON(w, http.StatusOK, map[string]any{"result": "revoked", "matched": err == nil}) |
| 731 | } |
| 732 | |