| 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. |
| 15 | package main |
| 16 | |
| 17 | import ( |
| 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 | |
| 32 | const 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 | |
| 80 | Every verb here is signed-in only (carlos auth login), owner-only, and |
| 81 | closed to a service credential itself — no service credential can create, |
| 82 | rotate or revoke any credential, not even itself, not even an admin one. |
| 83 | Service credentials are console-tracked state with no bucket-direct fallback. |
| 84 | ` |
| 85 | |
| 86 | // RunServices dispatches the services sub-verbs. |
| 87 | func 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. |
| 112 | func 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. |
| 132 | func 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). |
| 145 | var 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. |
| 151 | func 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 | |
| 159 | func 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. |
| 217 | func 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. |
| 246 | func 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. |
| 260 | func 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 | |
| 302 | func 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 | |
| 367 | func 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). |
| 404 | func 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 | |
| 416 | func 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 | |