carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The `carlos services` verbs: mint, list, rotate and revoke a service
2// credential — a principal a SERVER holds, not an address a person types a
3// password for. This is the CLI door onto internal/console/apiservices.go,
4// modelled on fleets.go the way that file's own doc comment says: same
5// account resolution, same mint-once discipline (the plaintext is printed
6// exactly once, here, because only its hash is ever stored), same
7// byte-identical refusals.
8//
9// Every door here is owner-gated and closed to service credentials
10// themselves — the escalation cut spec §3 (docs/superpowers/specs/
11// 2026-08-18-service-principals-design.md) turns on, enforced console-side
12// before any handler in apiservices.go runs. This file re-checks none of
13// that; it is the terminal surface on top of doors that already refuse
14// everything but an owner's own session.
15package main
16
17import (
18 "context"
19 "flag"
20 "fmt"
21 "log"
22 "os"
23 "regexp"
24 "strings"
25 "text/tabwriter"
26
27 "github.com/carlosframework/platform/internal/registry"
28 "github.com/carlosframework/platform/internal/runs"
29 "github.com/carlosframework/platform/internal/svcrole"
30)
31
32const servicesUsage = `usage: carlos services <create|ls|rotate|revoke> [flags]
33
34 create --role <publish|deploy|instance|operate|service|run|admin> [--app <app>] [--account <sqid|name>] <name>
35 mint a credential a SERVER holds, bound to this account —
36 not to you. Unlike your own CLI token, its reach is fixed
37 at mint: adding you to another account later does not widen
38 it, and it draws on its own rate-limit budgets rather than
39 yours.
40 THE SECRET IS PRINTED EXACTLY ONCE, HERE. Only its hash is
41 stored, so losing this output means rotate, not a re-run.
42 --role admin INCLUDES ` + "`store grant`" + `, which mints a
43 path-scoped IAM user. It is a real handover, not a
44 convenience bundle. Prefer the narrowest role that works.
45 --app binds it to ONE app, which also means it cannot create
46 apps. Omit --app for an account-scoped credential.
47 --role run needs --app and --commands, the command
48 prefixes it may run: --commands "import classic,import sync".
49 Words are matched exactly, from the start of the command.
50
51 ls [--account <sqid|name>]
52 list every LIVE credential the account owns — name, role,
53 scope, when it was minted and last rotated and used, and
54 who minted it. A revoked credential does not appear; there
55 is no history view here.
56
57 rotate [--account <sqid|name>] <name>
58 mint a FRESH secret for a credential that already exists,
59 printed exactly once. This refuses the credential's NEXT
60 call with the old secret. ROTATION IS NOT REVOCATION: it
61 does not stop work already in flight, and a credential that
62 may be COMPROMISED needs revoke.
63
64 revoke [--account <sqid|name>] <name-or-id>
65 the hard stop rotate is not: the credential's next call
66 fails, whatever secret it holds. Idempotent — revoking a
67 name twice, or one that never existed, answers the same.
68 Takes a credential's name, or the listing id ` + "`ls`" + ` shows
69 (the only handle a nameless legacy credential has).
70
71 --account <sqid|name> account the credential belongs to (default: your only
72 account, or the configured/chosen one) —
73 resolved by the console against your own
74 memberships.
75 --account-id <sqid> account sqid, matched as a sqid ONLY — supersedes
76 --account
77 --console <origin> which logged-in console to use (only needed with
78 several)
79
80Every verb here is signed-in only (carlos auth login), owner-only, and
81closed to a service credential itself — no service credential can create,
82rotate or revoke any credential, not even itself, not even an admin one.
83Service credentials are console-tracked state with no bucket-direct fallback.
84`
85
86// RunServices dispatches the services sub-verbs.
87func RunServices(args []string) {
88 if len(args) == 0 {
89 fmt.Fprint(os.Stderr, servicesUsage)
90 os.Exit(1)
91 }
92 switch args[0] {
93 case "create":
94 runServicesCreate(args[1:])
95 case "ls":
96 runServicesList(args[1:])
97 case "rotate":
98 runServicesRotate(args[1:])
99 case "revoke":
100 runServicesRevoke(args[1:])
101 default:
102 fmt.Fprint(os.Stderr, servicesUsage)
103 os.Exit(1)
104 }
105}
106
107// servicesSignedInSession resolves API mode and fatals with a
108// services-specific message when this shell is in direct bucket mode —
109// every services verb is console-API-only, the same posture
110// fleetsSignedInSession takes: a service credential is console-tracked
111// state with nothing in the bucket to fall back to.
112func servicesSignedInSession(consoleFlag string) *apiSession {
113 sess, err := resolveMode(consoleFlag)
114 if err != nil {
115 log.Fatal(err)
116 }
117 if sess == nil {
118 log.Fatal("services works through a console, and this shell is in direct bucket mode\n" +
119 "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Service credentials are\n" +
120 "console-tracked state with no bucket-direct fallback: unset the store env and run\n" +
121 "carlos auth login --console <url> instead.")
122 }
123 return sess
124}
125
126// validServiceName is the client-side gate for a credential name, checked
127// BEFORE any API call — the same "a shape the server is certain to refuse
128// should cost nothing" principle apps.go's validAppName states, and fleets.go's
129// validFleetName/validBoxLabel already apply to the fleets family. Uses
130// registry.ValidTunnelName, the same grammar handleAPIServiceCreate checks
131// server-side.
132func validServiceName(name string) error {
133 if registry.ValidTunnelName(name) {
134 return nil
135 }
136 return fmt.Errorf("%q is not a usable service credential name — 1-32 characters of a-z, 0-9 and hyphen", name)
137}
138
139// listingIDRe mirrors internal/console/apiservices.go's own listingIDRe: the
140// shape of a listing id, 64 hex characters, accepted in either case. Kept as
141// its own copy rather than an import — this package has no reach into
142// internal/console, and the shape is simple enough that duplicating it here
143// costs nothing and drifts from nothing (both sides check "is this 64 hex
144// characters", not a computed value).
145var listingIDRe = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
146
147// validServiceHandle is revoke's client-side gate: a segment must be EITHER
148// a usable credential name OR a 64-character hex listing id — the same two
149// disjoint grammars serviceHandleFrom accepts server-side. Checked before
150// any API call, for the same cost-nothing reason validServiceName is.
151func validServiceHandle(handle string) error {
152 if registry.ValidTunnelName(handle) || listingIDRe.MatchString(handle) {
153 return nil
154 }
155 return fmt.Errorf("%q is not a usable service credential name or listing id — "+
156 "a name (1-32 characters of a-z, 0-9 and hyphen) or a 64-character hex id", handle)
157}
158
159func runServicesCreate(args []string) {
160 fs := flag.NewFlagSet("services create", flag.ExitOnError)
161 role := fs.String("role", "", "role: publish, deploy, instance, operate, service, run or admin (required)")
162 app := appFlag(fs, "bind the credential to one app (default: account-wide)")
163 rungsRaw := fs.String("rungs", "", "rungs a deploy credential may move, comma-separated: canary, edge, beta (required with --role deploy)")
164 commandsRaw := fs.String("commands", "", `command prefixes a run credential may run, comma-separated, words space-separated: "import classic,import sync" (required with --role run)`)
165 acctRaw := fs.String("account", "", "account (sqid or name) to mint the credential in (default: your only account, or the configured/chosen one)")
166 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
167 consoleFlag := consoleOption(fs, "console to create it through (default: the only logged-in one)")
168 fs.Usage = flagUsage(fs, "")
169 fs.Parse(args)
170 const usageMsg = "usage: carlos services create --role <publish|deploy|instance|operate|service|run|admin> [--app <app>] " +
171 "[--rungs <list>] [--commands <list>] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>] <name>"
172 if fs.NArg() != 1 || strings.TrimSpace(*role) == "" {
173 log.Fatal(usageMsg)
174 }
175 name := strings.TrimSpace(fs.Arg(0))
176 if err := validServiceName(name); err != nil {
177 log.Fatalf("services create: %v", err)
178 }
179 roleV := strings.TrimSpace(*role)
180 if _, ok := svcrole.Parse(roleV); !ok {
181 log.Fatalf("services create: unknown role %q — one of: %s", roleV, strings.Join(roleNames(), ", "))
182 }
183 var rungsV []string
184 for _, r := range strings.Split(*rungsRaw, ",") {
185 if r = strings.TrimSpace(r); r != "" {
186 rungsV = append(rungsV, r)
187 }
188 }
189 if len(rungsV) > 0 && roleV != string(svcrole.Deploy) {
190 log.Fatal("services create: --rungs is only for --role deploy")
191 }
192 // The console is the authority on a command list's shape; these two
193 // are refused here because the server is certain to refuse them.
194 commandsV := runs.ParseCommandList(*commandsRaw)
195 if len(commandsV) > 0 && roleV != string(svcrole.Run) {
196 log.Fatal("services create: --commands is only for --role run")
197 }
198 if len(commandsV) == 0 && roleV == string(svcrole.Run) {
199 log.Fatal(`services create: --role run needs --commands, the command prefixes it may run: --commands "import classic,import sync"`)
200 }
201 appV := strings.TrimSpace(*app)
202
203 sess := servicesSignedInSession(*consoleFlag)
204 fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
205 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
206 token, doors, gotRungs, gotCommands, err := sess.createService(context.Background(), acct, name, roleV, appV, rungsV, commandsV)
207 if err != nil {
208 log.Fatalf("services create: %v", err)
209 }
210 printServiceCreated(acct.Seg, name, roleV, appV, gotRungs, gotCommands, token, doors)
211}
212
213// roleNames is svcrole's catalogue as strings, read from the package rather
214// than hand-listed here — the same "read it from the source of truth"
215// discipline doorsFor (internal/console/apiservices.go) applies to the door
216// list itself.
217func roleNames() []string {
218 roles := svcrole.All()
219 out := make([]string, len(roles))
220 for i, r := range roles {
221 out[i] = string(r)
222 }
223 return out
224}
225
226// deliverLine composes the ONE command this file ever tells an operator to
227// run to get a minted secret into a running app — shared by create and
228// rotate so the two cannot drift apart on it again. C43 (review): the
229// original text here was `carlos env set --app <app> -secret
230// CARLOS_SERVICE_TOKEN=<secret>` — env set has no -secret flag at all, so
231// an operator who copied it verbatim got "flag provided but not defined"
232// while holding a secret that had just been printed and could never be
233// shown again. The fix is not to drop -secret and keep env set: that turns
234// it into a VALID command that writes UNSEALED plaintext config
235// (appconfig.Var{Kind:"env"}). The sealing verb is `carlos secrets set`
236// (Kind:"secret", Envelope) — the only correct destination for a
237// credential.
238//
239// appHint is the real app name when the caller already has it (create,
240// straight from --app) and "" when it does not (rotate: handleAPIServiceRotate's
241// response carries name/account/token only, no app) — in which case a
242// generic "<app>" placeholder is printed instead, exactly like the secret
243// itself. Never the token: only ever the literal "<secret>" placeholder,
244// so the secret appears exactly once in the surrounding print, on its own
245// CARLOS_SERVICE_TOKEN= line, not a second time inside a copy-paste command.
246func deliverLine(appHint string) string {
247 app := "<app>"
248 if appHint != "" {
249 app = appHint
250 }
251 return "carlos secrets set --app " + app + " CARLOS_SERVICE_TOKEN=<secret>"
252}
253
254// printServiceCreated is the ONE place a service credential's secret
255// reaches stdout on create — printed exactly once, immediately, with the
256// scope it was minted into stated in words (requirement: an app-scoped
257// credential's door list must not read as "any app"; the {app} wildcard in
258// every releases/promote/etc. pattern below is only ever true for THIS app),
259// the doors it reaches, how to deliver it, and how to revoke it.
260func printServiceCreated(acctSeg, name, role, app string, rungs []string, commands [][]string, token string, doors []string) {
261 scope := "account-wide"
262 if app != "" {
263 scope = "app " + app
264 }
265 if role == string(svcrole.Deploy) {
266 fmt.Fprintf(stdout, "minted service:%s/%s (role deploy, app %s, rungs %s)\n\n", acctSeg, name, app, strings.Join(rungs, ", "))
267 } else if role == string(svcrole.Run) {
268 fmt.Fprintf(stdout, "minted service:%s/%s (role run, app %s, commands %s)\n\n", acctSeg, name, app, runs.FormatCommandList(commands))
269 } else {
270 fmt.Fprintf(stdout, "minted service:%s/%s (role %s, %s)\n\n", acctSeg, name, role, scope)
271 }
272 fmt.Fprintf(stdout, " CARLOS_SERVICE_TOKEN=%s\n\n", token)
273 fmt.Fprintln(stdout, " ^ shown once. Only its hash is stored — a lost secret means rotate, not a re-run.")
274 fmt.Fprintln(stdout)
275 if app != "" {
276 fmt.Fprintf(stdout, "it may (bound to app %s ONLY — despite the {app} wildcard shown below):\n", app)
277 } else {
278 fmt.Fprintln(stdout, "it may:")
279 }
280 sawStoreGrant := false
281 for _, d := range doors {
282 method, path, ok := strings.Cut(d, " ")
283 if !ok {
284 fmt.Fprintf(stdout, " %s\n", d)
285 continue
286 }
287 fmt.Fprintf(stdout, " %-6s %s\n", method, path)
288 if strings.HasSuffix(path, "/store/grant") {
289 sawStoreGrant = true
290 }
291 }
292 if sawStoreGrant {
293 fmt.Fprintln(stdout)
294 fmt.Fprintln(stdout, " (store grant also needs this principal listed on the console's")
295 fmt.Fprintln(stdout, " CARLOS_STORE_OPERATORS — being able to dial the door isn't enough)")
296 }
297 fmt.Fprintln(stdout)
298 fmt.Fprintln(stdout, "deliver it: "+deliverLine(app))
299 fmt.Fprintf(stdout, "revoke it: carlos services revoke --account %s %s\n", acctSeg, name)
300}
301
302func runServicesList(args []string) {
303 fs := flag.NewFlagSet("services ls", flag.ExitOnError)
304 acctRaw := fs.String("account", "", "account (sqid or name) to list credentials for (default: your only account, or the configured/chosen one)")
305 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
306 consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)")
307 fs.Usage = flagUsage(fs, "")
308 fs.Parse(args)
309
310 sess := servicesSignedInSession(*consoleFlag)
311 fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
312 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
313 rows, err := sess.listServices(context.Background(), acct)
314 if err != nil {
315 log.Fatalf("services ls: %v", err)
316 }
317 if len(rows) == 0 {
318 fmt.Fprintln(stdout, "no service credentials")
319 return
320 }
321 w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
322 fmt.Fprintln(w, "NAME\tROLE\tAPP\tRUNGS\tCOMMANDS\tLABEL\tCREATED\tROTATED\tLAST USED\tMINTED BY")
323 for _, r := range rows {
324 name, label, app := r.Name, r.Label, r.App
325 if name == "" {
326 name = "-"
327 }
328 if label == "" {
329 label = "-"
330 }
331 if app == "" {
332 app = "-"
333 }
334 rungs := strings.Join(r.Rungs, ",")
335 if rungs == "" {
336 rungs = "-"
337 }
338 // Comma-separated, words space-separated: the --commands form, so
339 // a list can be copied straight into another mint.
340 commands := make([]string, len(r.Commands))
341 for i, c := range r.Commands {
342 commands[i] = strings.Join(c, " ")
343 }
344 cmds := strings.Join(commands, ",")
345 switch {
346 case r.CommandsUnreadable:
347 cmds = runs.UnreadableCommands
348 case cmds == "":
349 cmds = "-"
350 }
351 created, rotated, lastUsed := r.Created, r.Rotated, r.LastUsed
352 if created == "" {
353 created = "-"
354 }
355 if rotated == "" {
356 rotated = "-"
357 }
358 if lastUsed == "" {
359 lastUsed = "-"
360 }
361 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
362 name, r.Role, app, rungs, cmds, label, created, rotated, lastUsed, r.MintedBy)
363 }
364 w.Flush()
365}
366
367func runServicesRotate(args []string) {
368 fs := flag.NewFlagSet("services rotate", flag.ExitOnError)
369 acctRaw := fs.String("account", "", "account (sqid or name) the credential belongs to (default: your only account, or the configured/chosen one)")
370 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
371 consoleFlag := consoleOption(fs, "console to rotate through (default: the only logged-in one)")
372 fs.Usage = flagUsage(fs, "")
373 fs.Parse(args)
374 if fs.NArg() != 1 {
375 log.Fatal("usage: carlos services rotate [--account <sqid|name>] [--account-id <sqid>] " +
376 "[--console <origin>] <name>")
377 }
378 name := strings.TrimSpace(fs.Arg(0))
379 if err := validServiceName(name); err != nil {
380 log.Fatalf("services rotate: %v", err)
381 }
382
383 sess := servicesSignedInSession(*consoleFlag)
384 fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
385 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
386 token, err := sess.rotateService(context.Background(), acct, name)
387 if err != nil {
388 log.Fatalf("services rotate: %v", err)
389 }
390 printServiceRotated(acct.Seg, name, token)
391}
392
393// printServiceRotated is the ONE place a rotated secret reaches stdout —
394// printed exactly once, with the same ROTATION IS NOT REVOCATION warning
395// printFleetToken carries for fleets, in this family's own words: rotation
396// refuses the credential's NEXT call with the old secret, but does not stop
397// anything already in flight, and it is not what a compromised credential
398// needs — that is revoke. C45 (review): a credential nobody can deliver is
399// not finished, and rotation is exactly the moment a RUNNING service's
400// secret stops working — this now carries the same deliver-it line create's
401// output does, using deliverLine's own generic "<app>" placeholder: unlike
402// create, this verb never learns which app the credential is scoped to
403// (handleAPIServiceRotate's response carries name/account/token only).
404func printServiceRotated(acctSeg, name, token string) {
405 fmt.Fprintf(stdout, "rotated %s\n\n", name)
406 fmt.Fprintf(stdout, " CARLOS_SERVICE_TOKEN=%s\n\n", token)
407 fmt.Fprintln(stdout, " ^ shown once. Only its hash is stored — a lost secret means rotate again, not a re-run.")
408 fmt.Fprintln(stdout)
409 fmt.Fprintln(stdout, "ROTATION IS NOT REVOCATION: this refuses the credential's NEXT call with the OLD")
410 fmt.Fprintln(stdout, "secret, but does not stop work already in flight — a credential that may be")
411 fmt.Fprintf(stdout, "COMPROMISED needs `carlos services revoke --account %s %s`, not another rotate.\n", acctSeg, name)
412 fmt.Fprintln(stdout)
413 fmt.Fprintln(stdout, "deliver it: "+deliverLine(""))
414}
415
416func runServicesRevoke(args []string) {
417 fs := flag.NewFlagSet("services revoke", flag.ExitOnError)
418 acctRaw := fs.String("account", "", "account (sqid or name) the credential belongs to (default: your only account, or the configured/chosen one)")
419 acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
420 consoleFlag := consoleOption(fs, "console to revoke through (default: the only logged-in one)")
421 fs.Usage = flagUsage(fs, "")
422 fs.Parse(args)
423 if fs.NArg() != 1 {
424 log.Fatal("usage: carlos services revoke [--account <sqid|name>] [--account-id <sqid>] " +
425 "[--console <origin>] <name-or-id>")
426 }
427 handle := strings.TrimSpace(fs.Arg(0))
428 if err := validServiceHandle(handle); err != nil {
429 log.Fatalf("services revoke: %v", err)
430 }
431
432 sess := servicesSignedInSession(*consoleFlag)
433 fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
434 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
435 matched, err := sess.revokeService(context.Background(), acct, handle)
436 if err != nil {
437 log.Fatalf("services revoke: %v", err)
438 }
439 // matched is the field this door was given for (C41): the request
440 // itself is idempotent and always answers 200, but a false matched
441 // means the handle killed nothing — most often a typo. Printing that
442 // as plain "revoked" is the exact failure the field exists to catch,
443 // at the door used during an incident, so the two cases get visibly
444 // different wording. Both still exit 0: the state the caller wanted —
445 // no live credential of that handle — is the state that holds either
446 // way.
447 if matched {
448 fmt.Fprintf(stdout, "revoked %s — its next call fails, whatever secret it holds\n", handle)
449 return
450 }
451 // "already revoked" would work for a human reader but leaves the
452 // substring "revoked" in a matched=false line — a diluted version of
453 // the exact failure `matched` exists to prevent, this time against a
454 // script doing `carlos services revoke $n | grep -q revoked` rather
455 // than a person reading the line. "gone" says the same thing without
456 // it (review finding, minor).
457 fmt.Fprintf(stdout, "no live credential of %q — nothing to revoke (already gone, or never existed)\n", handle)
458}
459