// The `carlos services` verbs: mint, list, rotate and revoke a service // credential — a principal a SERVER holds, not an address a person types a // password for. This is the CLI door onto internal/console/apiservices.go, // modelled on fleets.go the way that file's own doc comment says: same // account resolution, same mint-once discipline (the plaintext is printed // exactly once, here, because only its hash is ever stored), same // byte-identical refusals. // // Every door here is owner-gated and closed to service credentials // themselves — the escalation cut spec §3 (docs/superpowers/specs/ // 2026-08-18-service-principals-design.md) turns on, enforced console-side // before any handler in apiservices.go runs. This file re-checks none of // that; it is the terminal surface on top of doors that already refuse // everything but an owner's own session. package main import ( "context" "flag" "fmt" "log" "os" "regexp" "strings" "text/tabwriter" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/runs" "github.com/carlosframework/platform/internal/svcrole" ) const servicesUsage = `usage: carlos services [flags] create --role [--app ] [--account ] mint a credential a SERVER holds, bound to this account — not to you. Unlike your own CLI token, its reach is fixed at mint: adding you to another account later does not widen it, and it draws on its own rate-limit budgets rather than yours. THE SECRET IS PRINTED EXACTLY ONCE, HERE. Only its hash is stored, so losing this output means rotate, not a re-run. --role admin INCLUDES ` + "`store grant`" + `, which mints a path-scoped IAM user. It is a real handover, not a convenience bundle. Prefer the narrowest role that works. --app binds it to ONE app, which also means it cannot create apps. Omit --app for an account-scoped credential. --role run needs --app and --commands, the command prefixes it may run: --commands "import classic,import sync". Words are matched exactly, from the start of the command. ls [--account ] list every LIVE credential the account owns — name, role, scope, when it was minted and last rotated and used, and who minted it. A revoked credential does not appear; there is no history view here. rotate [--account ] mint a FRESH secret for a credential that already exists, printed exactly once. This refuses the credential's NEXT call with the old secret. ROTATION IS NOT REVOCATION: it does not stop work already in flight, and a credential that may be COMPROMISED needs revoke. revoke [--account ] the hard stop rotate is not: the credential's next call fails, whatever secret it holds. Idempotent — revoking a name twice, or one that never existed, answers the same. Takes a credential's name, or the listing id ` + "`ls`" + ` shows (the only handle a nameless legacy credential has). --account account the credential belongs to (default: your only account, or the configured/chosen one) — resolved by the console against your own memberships. --account-id account sqid, matched as a sqid ONLY — supersedes --account --console which logged-in console to use (only needed with several) Every verb here is signed-in only (carlos auth login), owner-only, and closed to a service credential itself — no service credential can create, rotate or revoke any credential, not even itself, not even an admin one. Service credentials are console-tracked state with no bucket-direct fallback. ` // RunServices dispatches the services sub-verbs. func RunServices(args []string) { if len(args) == 0 { fmt.Fprint(os.Stderr, servicesUsage) os.Exit(1) } switch args[0] { case "create": runServicesCreate(args[1:]) case "ls": runServicesList(args[1:]) case "rotate": runServicesRotate(args[1:]) case "revoke": runServicesRevoke(args[1:]) default: fmt.Fprint(os.Stderr, servicesUsage) os.Exit(1) } } // servicesSignedInSession resolves API mode and fatals with a // services-specific message when this shell is in direct bucket mode — // every services verb is console-API-only, the same posture // fleetsSignedInSession takes: a service credential is console-tracked // state with nothing in the bucket to fall back to. func servicesSignedInSession(consoleFlag string) *apiSession { sess, err := resolveMode(consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatal("services works through a console, and this shell is in direct bucket mode\n" + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Service credentials are\n" + "console-tracked state with no bucket-direct fallback: unset the store env and run\n" + "carlos auth login --console instead.") } return sess } // validServiceName is the client-side gate for a credential name, checked // BEFORE any API call — the same "a shape the server is certain to refuse // should cost nothing" principle apps.go's validAppName states, and fleets.go's // validFleetName/validBoxLabel already apply to the fleets family. Uses // registry.ValidTunnelName, the same grammar handleAPIServiceCreate checks // server-side. func validServiceName(name string) error { if registry.ValidTunnelName(name) { return nil } return fmt.Errorf("%q is not a usable service credential name — 1-32 characters of a-z, 0-9 and hyphen", name) } // listingIDRe mirrors internal/console/apiservices.go's own listingIDRe: the // shape of a listing id, 64 hex characters, accepted in either case. Kept as // its own copy rather than an import — this package has no reach into // internal/console, and the shape is simple enough that duplicating it here // costs nothing and drifts from nothing (both sides check "is this 64 hex // characters", not a computed value). var listingIDRe = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) // validServiceHandle is revoke's client-side gate: a segment must be EITHER // a usable credential name OR a 64-character hex listing id — the same two // disjoint grammars serviceHandleFrom accepts server-side. Checked before // any API call, for the same cost-nothing reason validServiceName is. func validServiceHandle(handle string) error { if registry.ValidTunnelName(handle) || listingIDRe.MatchString(handle) { return nil } return fmt.Errorf("%q is not a usable service credential name or listing id — "+ "a name (1-32 characters of a-z, 0-9 and hyphen) or a 64-character hex id", handle) } func runServicesCreate(args []string) { fs := flag.NewFlagSet("services create", flag.ExitOnError) role := fs.String("role", "", "role: publish, deploy, instance, operate, service, run or admin (required)") app := appFlag(fs, "bind the credential to one app (default: account-wide)") rungsRaw := fs.String("rungs", "", "rungs a deploy credential may move, comma-separated: canary, edge, beta (required with --role deploy)") commandsRaw := fs.String("commands", "", `command prefixes a run credential may run, comma-separated, words space-separated: "import classic,import sync" (required with --role run)`) acctRaw := fs.String("account", "", "account (sqid or name) to mint the credential in (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") consoleFlag := consoleOption(fs, "console to create it through (default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) const usageMsg = "usage: carlos services create --role [--app ] " + "[--rungs ] [--commands ] [--account ] [--account-id ] [--console ] " if fs.NArg() != 1 || strings.TrimSpace(*role) == "" { log.Fatal(usageMsg) } name := strings.TrimSpace(fs.Arg(0)) if err := validServiceName(name); err != nil { log.Fatalf("services create: %v", err) } roleV := strings.TrimSpace(*role) if _, ok := svcrole.Parse(roleV); !ok { log.Fatalf("services create: unknown role %q — one of: %s", roleV, strings.Join(roleNames(), ", ")) } var rungsV []string for _, r := range strings.Split(*rungsRaw, ",") { if r = strings.TrimSpace(r); r != "" { rungsV = append(rungsV, r) } } if len(rungsV) > 0 && roleV != string(svcrole.Deploy) { log.Fatal("services create: --rungs is only for --role deploy") } // The console is the authority on a command list's shape; these two // are refused here because the server is certain to refuse them. commandsV := runs.ParseCommandList(*commandsRaw) if len(commandsV) > 0 && roleV != string(svcrole.Run) { log.Fatal("services create: --commands is only for --role run") } if len(commandsV) == 0 && roleV == string(svcrole.Run) { log.Fatal(`services create: --role run needs --commands, the command prefixes it may run: --commands "import classic,import sync"`) } appV := strings.TrimSpace(*app) sess := servicesSignedInSession(*consoleFlag) fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) acct := resolvedAccount(sess, acctRaw, acctIDRaw) token, doors, gotRungs, gotCommands, err := sess.createService(context.Background(), acct, name, roleV, appV, rungsV, commandsV) if err != nil { log.Fatalf("services create: %v", err) } printServiceCreated(acct.Seg, name, roleV, appV, gotRungs, gotCommands, token, doors) } // roleNames is svcrole's catalogue as strings, read from the package rather // than hand-listed here — the same "read it from the source of truth" // discipline doorsFor (internal/console/apiservices.go) applies to the door // list itself. func roleNames() []string { roles := svcrole.All() out := make([]string, len(roles)) for i, r := range roles { out[i] = string(r) } return out } // deliverLine composes the ONE command this file ever tells an operator to // run to get a minted secret into a running app — shared by create and // rotate so the two cannot drift apart on it again. C43 (review): the // original text here was `carlos env set --app -secret // CARLOS_SERVICE_TOKEN=` — env set has no -secret flag at all, so // an operator who copied it verbatim got "flag provided but not defined" // while holding a secret that had just been printed and could never be // shown again. The fix is not to drop -secret and keep env set: that turns // it into a VALID command that writes UNSEALED plaintext config // (appconfig.Var{Kind:"env"}). The sealing verb is `carlos secrets set` // (Kind:"secret", Envelope) — the only correct destination for a // credential. // // appHint is the real app name when the caller already has it (create, // straight from --app) and "" when it does not (rotate: handleAPIServiceRotate's // response carries name/account/token only, no app) — in which case a // generic "" placeholder is printed instead, exactly like the secret // itself. Never the token: only ever the literal "" placeholder, // so the secret appears exactly once in the surrounding print, on its own // CARLOS_SERVICE_TOKEN= line, not a second time inside a copy-paste command. func deliverLine(appHint string) string { app := "" if appHint != "" { app = appHint } return "carlos secrets set --app " + app + " CARLOS_SERVICE_TOKEN=" } // printServiceCreated is the ONE place a service credential's secret // reaches stdout on create — printed exactly once, immediately, with the // scope it was minted into stated in words (requirement: an app-scoped // credential's door list must not read as "any app"; the {app} wildcard in // every releases/promote/etc. pattern below is only ever true for THIS app), // the doors it reaches, how to deliver it, and how to revoke it. func printServiceCreated(acctSeg, name, role, app string, rungs []string, commands [][]string, token string, doors []string) { scope := "account-wide" if app != "" { scope = "app " + app } if role == string(svcrole.Deploy) { fmt.Fprintf(stdout, "minted service:%s/%s (role deploy, app %s, rungs %s)\n\n", acctSeg, name, app, strings.Join(rungs, ", ")) } else if role == string(svcrole.Run) { fmt.Fprintf(stdout, "minted service:%s/%s (role run, app %s, commands %s)\n\n", acctSeg, name, app, runs.FormatCommandList(commands)) } else { fmt.Fprintf(stdout, "minted service:%s/%s (role %s, %s)\n\n", acctSeg, name, role, scope) } fmt.Fprintf(stdout, " CARLOS_SERVICE_TOKEN=%s\n\n", token) fmt.Fprintln(stdout, " ^ shown once. Only its hash is stored — a lost secret means rotate, not a re-run.") fmt.Fprintln(stdout) if app != "" { fmt.Fprintf(stdout, "it may (bound to app %s ONLY — despite the {app} wildcard shown below):\n", app) } else { fmt.Fprintln(stdout, "it may:") } sawStoreGrant := false for _, d := range doors { method, path, ok := strings.Cut(d, " ") if !ok { fmt.Fprintf(stdout, " %s\n", d) continue } fmt.Fprintf(stdout, " %-6s %s\n", method, path) if strings.HasSuffix(path, "/store/grant") { sawStoreGrant = true } } if sawStoreGrant { fmt.Fprintln(stdout) fmt.Fprintln(stdout, " (store grant also needs this principal listed on the console's") fmt.Fprintln(stdout, " CARLOS_STORE_OPERATORS — being able to dial the door isn't enough)") } fmt.Fprintln(stdout) fmt.Fprintln(stdout, "deliver it: "+deliverLine(app)) fmt.Fprintf(stdout, "revoke it: carlos services revoke --account %s %s\n", acctSeg, name) } func runServicesList(args []string) { fs := flag.NewFlagSet("services ls", flag.ExitOnError) acctRaw := fs.String("account", "", "account (sqid or name) to list credentials for (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) sess := servicesSignedInSession(*consoleFlag) fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) acct := resolvedAccount(sess, acctRaw, acctIDRaw) rows, err := sess.listServices(context.Background(), acct) if err != nil { log.Fatalf("services ls: %v", err) } if len(rows) == 0 { fmt.Fprintln(stdout, "no service credentials") return } w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) fmt.Fprintln(w, "NAME\tROLE\tAPP\tRUNGS\tCOMMANDS\tLABEL\tCREATED\tROTATED\tLAST USED\tMINTED BY") for _, r := range rows { name, label, app := r.Name, r.Label, r.App if name == "" { name = "-" } if label == "" { label = "-" } if app == "" { app = "-" } rungs := strings.Join(r.Rungs, ",") if rungs == "" { rungs = "-" } // Comma-separated, words space-separated: the --commands form, so // a list can be copied straight into another mint. commands := make([]string, len(r.Commands)) for i, c := range r.Commands { commands[i] = strings.Join(c, " ") } cmds := strings.Join(commands, ",") switch { case r.CommandsUnreadable: cmds = runs.UnreadableCommands case cmds == "": cmds = "-" } created, rotated, lastUsed := r.Created, r.Rotated, r.LastUsed if created == "" { created = "-" } if rotated == "" { rotated = "-" } if lastUsed == "" { lastUsed = "-" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", name, r.Role, app, rungs, cmds, label, created, rotated, lastUsed, r.MintedBy) } w.Flush() } func runServicesRotate(args []string) { fs := flag.NewFlagSet("services rotate", flag.ExitOnError) acctRaw := fs.String("account", "", "account (sqid or name) the credential belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") consoleFlag := consoleOption(fs, "console to rotate through (default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) if fs.NArg() != 1 { log.Fatal("usage: carlos services rotate [--account ] [--account-id ] " + "[--console ] ") } name := strings.TrimSpace(fs.Arg(0)) if err := validServiceName(name); err != nil { log.Fatalf("services rotate: %v", err) } sess := servicesSignedInSession(*consoleFlag) fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) acct := resolvedAccount(sess, acctRaw, acctIDRaw) token, err := sess.rotateService(context.Background(), acct, name) if err != nil { log.Fatalf("services rotate: %v", err) } printServiceRotated(acct.Seg, name, token) } // printServiceRotated is the ONE place a rotated secret reaches stdout — // printed exactly once, with the same ROTATION IS NOT REVOCATION warning // printFleetToken carries for fleets, in this family's own words: rotation // refuses the credential's NEXT call with the old secret, but does not stop // anything already in flight, and it is not what a compromised credential // needs — that is revoke. C45 (review): a credential nobody can deliver is // not finished, and rotation is exactly the moment a RUNNING service's // secret stops working — this now carries the same deliver-it line create's // output does, using deliverLine's own generic "" placeholder: unlike // create, this verb never learns which app the credential is scoped to // (handleAPIServiceRotate's response carries name/account/token only). func printServiceRotated(acctSeg, name, token string) { fmt.Fprintf(stdout, "rotated %s\n\n", name) fmt.Fprintf(stdout, " CARLOS_SERVICE_TOKEN=%s\n\n", token) fmt.Fprintln(stdout, " ^ shown once. Only its hash is stored — a lost secret means rotate again, not a re-run.") fmt.Fprintln(stdout) fmt.Fprintln(stdout, "ROTATION IS NOT REVOCATION: this refuses the credential's NEXT call with the OLD") fmt.Fprintln(stdout, "secret, but does not stop work already in flight — a credential that may be") fmt.Fprintf(stdout, "COMPROMISED needs `carlos services revoke --account %s %s`, not another rotate.\n", acctSeg, name) fmt.Fprintln(stdout) fmt.Fprintln(stdout, "deliver it: "+deliverLine("")) } func runServicesRevoke(args []string) { fs := flag.NewFlagSet("services revoke", flag.ExitOnError) acctRaw := fs.String("account", "", "account (sqid or name) the credential belongs to (default: your only account, or the configured/chosen one)") acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") consoleFlag := consoleOption(fs, "console to revoke through (default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) if fs.NArg() != 1 { log.Fatal("usage: carlos services revoke [--account ] [--account-id ] " + "[--console ] ") } handle := strings.TrimSpace(fs.Arg(0)) if err := validServiceHandle(handle); err != nil { log.Fatalf("services revoke: %v", err) } sess := servicesSignedInSession(*consoleFlag) fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) acct := resolvedAccount(sess, acctRaw, acctIDRaw) matched, err := sess.revokeService(context.Background(), acct, handle) if err != nil { log.Fatalf("services revoke: %v", err) } // matched is the field this door was given for (C41): the request // itself is idempotent and always answers 200, but a false matched // means the handle killed nothing — most often a typo. Printing that // as plain "revoked" is the exact failure the field exists to catch, // at the door used during an incident, so the two cases get visibly // different wording. Both still exit 0: the state the caller wanted — // no live credential of that handle — is the state that holds either // way. if matched { fmt.Fprintf(stdout, "revoked %s — its next call fails, whatever secret it holds\n", handle) return } // "already revoked" would work for a human reader but leaves the // substring "revoked" in a matched=false line — a diluted version of // the exact failure `matched` exists to prevent, this time against a // script doing `carlos services revoke $n | grep -q revoked` rather // than a person reading the line. "gone" says the same thing without // it (review finding, minor). fmt.Fprintf(stdout, "no live credential of %q — nothing to revoke (already gone, or never existed)\n", handle) }