plan: deploy credential implementation plan (#229)
Human
Prompt
yes go ahead.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
Twelve tasks plus a copy-review Task 0, from spec rev 5.1: the role and rung storage, mint doors, CLI verbs, the §6 door list, the scope function, the release Authorize hook, the request-local scoped signer, a randomized G1/G2 simulation with two mutants, CARLOS_SERVICE_TOKEN in the CLI, the mixed-version check, docs, and the gate.
Agent notes
CARLOS_SERVICE_TOKEN was checked for collisions: nothing in the platform or titogo reads it today. `carlos deploy` reads GET .../regions, which is not a §6 door; Task 9 pins that as a known gap for Paul.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+2241
−0
docs/superpowers/plans/2026-10-05-deploy-credential.md+2241 −0
CI passed — run details
diff --git a/docs/superpowers/plans/2026-10-05-deploy-credential.md b/docs/superpowers/plans/2026-10-05-deploy-credential.md| new file mode 100644 |
| index 000000000..0f7c06723 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-05-deploy-credential.md |
| @@ -0,0 +1,2241 @@ |
| +# Deploy Credential Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** A `deploy` service credential, limited to rungs, that lets CI ship, promote and roll back through the console with no more authority than a scoped release key. |
| + |
| +**Architecture:** A new `svcrole.Deploy` role and a `rungs` column on `cli_tokens`. A pure function, `deployScopeAllows`, decides every promote and rollback by a `deploy` credential. It runs early in the handler for the message, and authoritatively inside the release primitives through a new `Authorize` hook placed between the pointer read and the conditional write. A `deploy` promote is signed by a scoped key minted for that one request, so boxes still verify the rung limit. The CLI reads `CARLOS_SERVICE_TOKEN` and `CARLOS_CONSOLE` so CI needs no credentials file. |
| + |
| +**Tech Stack:** Go (stdlib, `net/http`, `database/sql` on SQLite), the repo's own `internal/release`, `internal/console`, `internal/svcrole` packages, and the root `carlos` CLI package. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-05-deploy-credential-design.md` (rev 5.1, approved 2026-10-05). Read §3 (rulings R1 to R5), §5 to §9 and §12 before starting any task. |
| + |
| +## Global Constraints |
| + |
| +- Rungs a `deploy` credential may hold: `canary`, `edge`, `beta`. `stable` is refused at mint and at every door (spec §5.2). |
| +- `deploy` requires a non-empty app and at least one rung. Any other role with rungs is refused (spec §5.4). |
| +- A stored `deploy` row whose rungs are empty or do not parse resolves to no principal at all (spec §5.5). |
| +- A `deploy` credential is permitted on exactly the doors in spec §6, and on nothing else. |
| +- A caller that passes no `Authorize` hook gets exactly today's behaviour and today's store calls (spec §7.2). |
| +- A `deploy` request never falls back to the root signer. If the scoped signer cannot be built, the request is refused (spec §7.4). |
| +- Request-local grant lifetime: 5 minutes. Grant name: `deploy.` + the credential's name. |
| +- The CLI reads `CARLOS_SERVICE_TOKEN` and the console from `--console` or `CARLOS_CONSOLE` only. Token plus `CARLOS_DEPLOYMENT_BUCKET` or `CARLOS_DEPLOYMENT_DIR` is refused (spec §8). `CARLOS_SERVICE_TOKEN` was checked for collisions on 2026-10-05: nothing in the platform or in titogo reads it today; it is only the name `services create` tells people to deliver the secret under, so the CLI keeps that name. |
| +- `carlos auth whoami` is unchanged (spec §8). |
| +- Existing `publish` credentials and existing roles are unchanged (R1). |
| +- Every user-facing string in this plan is a draft with an ID (D1 to D31) until Task 0 replaces it with Paul's approved text. Implementers copy the approved text from the Copy table verbatim. No new string uses an em dash. |
| +- Tests run inside the sandbox with `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off` (re-export in every shell; nothing persists between commands). Task 12 runs the full gate outside the sandbox. |
| +- Commits in this worktree use `git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit`, and end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. |
| + |
| +## Review Focus |
| + |
| +1. **Rung lists as people type them.** `--rungs "edge, canary"`, `Edge`, `edge,edge`, a trailing comma. Expected: spaces and empties dropped, duplicates collapsed, stored in ladder order (`canary,edge`); anything not exactly a lower-case rung name is refused with D10 naming it. Pinned in Task 1. |
| +2. **A deploy token aimed at a sibling app.** The app binding already 404s a service credential on another app's path. Expected: a `deploy` credential for `hello` gets 404 on `/api/cli/apps/bab/other/promote`, and nothing moves. Pinned in Task 4. |
| +3. **Rotating a deploy credential.** Expected: the new secret keeps the same rungs and promotes exactly as before; the old secret is refused. Pinned in Task 1. |
| +4. **A CI retry of the same promote.** Expected: the second call answers 200 with `"result": "noop"`, writes nothing and bumps nothing. Pinned in Task 7. |
| +5. **`carlos deploy` with a deploy token.** `carlos deploy` reads `GET .../regions`, which is not a §6 door. Expected: CI that uses `ship` then `promote` works; `deploy` is refused at that door with the role gate's message. This is a known limit to report to Paul, not to fix here. Pinned in Task 9 by a test that records it. |
| + |
| +--- |
| + |
| +## File map |
| + |
| +| File | Responsibility | Tasks | |
| +| --- | --- | --- | |
| +| `internal/svcrole/svcrole.go` | the `Deploy` role in the catalogue | 1 | |
| +| `internal/console/deployscope.go` (new) | rung parsing, `deployScopeAllows`, the refusal type, `deploySigner` | 1, 5, 7 | |
| +| `internal/console/authstore.go` | `rungs` column, `CLIToken.Rungs`, row scanning | 1 | |
| +| `internal/console/svcprincipal.go` | `Principal.Rungs`, `NewServicePrincipalScoped`, read-back | 1 | |
| +| `internal/console/apiservices.go` | API mint door and listing | 2 | |
| +| `internal/console/svcpage.go`, `templates/account_settings.html`, `server.go` | browser mint form and listing | 2 | |
| +| `internal/console/authcli.go` | the §6 door list | 4 | |
| +| `internal/console/apicli.go` | promote and rollback handlers | 5, 7 | |
| +| `internal/release/authorize.go` (new) | `AuthorizationError` | 6 | |
| +| `internal/release/release.go` | the `Authorize` hook in promote and rollback | 6 | |
| +| `services.go`, `apimode.go` | `services create --rungs`, `services ls`, `resolveMode` | 3, 9 | |
| +| `README.md`, `docs/site/cli.md` | CI uses the console | 11 | |
| +| tests | one new `_test.go` per task, named in each task | all | |
| + |
| +--- |
| + |
| +### Task 0: Copy review (orchestrator with Paul; no subagent) |
| + |
| +Every string below is a draft. This task replaces the drafts with Paul's approved text before any task writes them into a file. |
| + |
| +**Files:** |
| +- Modify: this plan's Copy table, in place. |
| + |
| +- [ ] **Step 1: Count each string at the base commit.** For each draft, grep the tree to confirm it does not already exist under another ID (a shared string needs its own review): |
| + |
| +```bash |
| +cd /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential |
| +for s in "deploy credential" "rungs" "CARLOS_SERVICE_TOKEN is set"; do echo "== $s"; grep -rn --include=*.go --include=*.html --include=*.md "$s" . | grep -v docs/superpowers | wc -l; done |
| +``` |
| + |
| +- [ ] **Step 2: Run the copy-review skill** (`copy-review`) with every row of the Copy table, IDs D1 to D31. Wait for Paul's edits. |
| +- [ ] **Step 3: Write the approved text into the Copy table verbatim**, replacing each draft. Mark the table header "approved 2026-MM-DD". |
| +- [ ] **Step 4: Commit.** |
| + |
| +```bash |
| +git add docs/superpowers/plans/2026-10-05-deploy-credential.md |
| +git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "plan: deploy credential copy approved |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +### Copy table (drafts until Task 0) |
| + |
| +`%s` and `%q` are Go format verbs filled at run time. `{{...}}` is template syntax. |
| + |
| +| ID | Where | Draft | |
| +| --- | --- | --- | |
| +| D1 | `services create` `--role` help | `role: publish, deploy, instance, operate, service or admin (required)` | |
| +| D2 | `services create` usage | `usage: carlos services create --role <publish\|deploy\|instance\|operate\|service\|admin> [--app <app>] [--rungs <list>] [--account <sqid\|name>] [--account-id <sqid>] [--console <origin>] <name>` | |
| +| D3 | `services create` `--rungs` help | `rungs a deploy credential may move, comma-separated: canary, edge, beta (required with --role deploy)` | |
| +| D4 | CLI pre-check | `services create: --rungs is only for --role deploy` | |
| +| D5 | mint output, deploy | `minted service:%s/%s (role deploy, app %s, rungs %s)` | |
| +| D6 | `services ls` column header | `RUNGS` | |
| +| D7 | API mint refusal: deploy, no app | `a deploy credential must name one app (--app)` | |
| +| D8 | API mint refusal: deploy, no rungs | `a deploy credential must name its rungs (--rungs): canary, edge or beta` | |
| +| D9 | API mint refusal: stable | `stable cannot be delegated: moving it needs a passkey in the console` | |
| +| D10 | API mint refusal: unknown rung | `unknown rung %q: one of canary, edge, beta` | |
| +| D11 | API mint refusal: rungs on another role | `only a deploy credential takes rungs` | |
| +| D12 | form flash: deploy, no app | `A deploy credential must name one app. Nothing was minted.` | |
| +| D13 | form flash: deploy, no rungs | `A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted.` | |
| +| D14 | form flash: stable | `Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted.` | |
| +| D15 | form flash: unknown rung | `“%s” is not a rung. Choose canary, edge or beta. Nothing was minted.` | |
| +| D16 | form flash: rungs on another role | `Only a deploy credential takes rungs. Nothing was minted.` | |
| +| D17 | audit detail, deploy mint | `minted service credential %s with role deploy over app %s, rungs %s (%s)` (last verb is `cli` or `console`) | |
| +| D18 | form: rung checkboxes label | `Rungs, for a deploy credential:` | |
| +| D19 | listing column header (page) | `Rungs` | |
| +| D20 | secret banner, deploy | `Minted {{.Name}}: role {{.Role}}, over {{.Scope}}, rungs {{.Rungs}}:` | |
| +| D21 | 403: hotfix | `deploy credential %s cannot hotfix. Promote in ladder order, or ask a member to hotfix.` | |
| +| D22 | 403: channel outside rungs, not a rung, or stable | `deploy credential %s may move %s only, and %s is not one of them.` (first list e.g. `canary/<name>, edge`) | |
| +| D23 | 403: bare canary | `deploy credential %s may move canary/<name> channels, not canary itself.` | |
| +| D24 | 403: pipelined app | `%s has a pipeline, so deploy credential %s may move canary/<name> channels only.` | |
| +| D25 | console log line, scoped signer failure | `console: deploy signer for %s/%s: %v` | |
| +| D26 | 503: scoped signer failure | `could not prepare a signing key for this deploy credential. Nothing was promoted.` | |
| +| D27 | CLI: token, no console | `CARLOS_SERVICE_TOKEN is set but no console is named: set CARLOS_CONSOLE or pass --console` | |
| +| D28 | CLI: token and store variable | `CARLOS_SERVICE_TOKEN and CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are both set. The token works through the console and the store variables go straight to the bucket. Unset one of them.` | |
| +| D29 | CLI session label (`using X as …`) | `a service credential (CARLOS_SERVICE_TOKEN)` | |
| +| D30 | README.md replacing lines 170-180 | see Task 11 Step 1 | |
| +| D31 | docs/site/cli.md `carlos services` section | see Task 11 Step 2 | |
| + |
| +--- |
| + |
| +### Task 1: The role, the rungs column, minting rules and read-back |
| + |
| +**Files:** |
| +- Modify: `internal/svcrole/svcrole.go` (catalogue, comments) |
| +- Modify: `internal/svcrole/svcrole_test.go` |
| +- Create: `internal/console/deployscope.go` |
| +- Modify: `internal/console/authstore.go:99-125` (`CLIToken`), `:209-216` (ALTER list), `scanCLITokenRows` and every SELECT that feeds it |
| +- Modify: `internal/console/svcprincipal.go` (`Principal`, `CLIPrincipal`, `NewServicePrincipal`) |
| +- Create: `internal/console/deploycred_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `svcrole.Deploy Role = "deploy"`, in `All()` right after `Publish`. |
| + - `func canonicalRungs(in []string) (string, error)` returns `""` for no rungs, else `canary,edge,beta` order joined by `,`. Errors: `ErrStableNotDelegable`, `*UnknownRungError{Rung string}`. |
| + - `func parseStoredRungs(s string) ([]string, bool)`. |
| + - `Principal.Rungs []string` (nil for every role but `deploy`). |
| + - `CLIToken.Rungs string` (canonical stored form) and `func (t CLIToken) RungList() string` (`"canary, edge"`). |
| + - `func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (string, error)`. |
| + - Sentinels: `ErrAccountScopedDeploy`, `ErrDeployNeedsRungs`, `ErrRungsNeedDeploy`, `ErrStableNotDelegable`. |
| + |
| +- [ ] **Step 1: Write the failing svcrole test.** Append to `internal/svcrole/svcrole_test.go`: |
| + |
| +```go |
| +func TestDeployIsMintableAndNarrow(t *testing.T) { |
| + got, ok := Parse("deploy") |
| + if !ok || got != Deploy { |
| + t.Fatalf("Parse(\"deploy\") = %q, %v", got, ok) |
| + } |
| + all := All() |
| + if len(all) < 2 || all[0] != Publish || all[1] != Deploy { |
| + t.Fatalf("All() = %v; want deploy right after publish", all) |
| + } |
| + // Existing doors do not admit it: adding a role must not widen anything. |
| + for _, door := range []Set{Allow(Publish), Allow(Instance), Allow(Operate), Allow(Service)} { |
| + if door.Permits(Deploy) { |
| + t.Fatalf("%v admits deploy", door.Roles()) |
| + } |
| + } |
| + if !Allow(Deploy).Permits(Admin) { |
| + t.Fatal("Allow adds Admin to every set; that changed") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it and see it fail.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/svcrole/ -run TestDeployIsMintableAndNarrow -count=1` |
| +Expected: FAIL, `undefined: Deploy`. |
| + |
| +- [ ] **Step 3: Add the role.** In `internal/svcrole/svcrole.go`, after the `Publish` constant: |
| + |
| +```go |
| + // Deploy is a deploy pipeline's credential: ship, and promote or roll |
| + // back only the rungs the credential was minted with. It is always bound |
| + // to one app, and it never reaches app lifecycle, retention or domains. |
| + // The rungs live on the credential, not here: this package knows roles, |
| + // not channels (spec 2026-10-05-deploy-credential-design.md §5). |
| + Deploy Role = "deploy" |
| +``` |
| + |
| +Change the catalogue line to: |
| + |
| +```go |
| +var catalogue = []Role{Publish, Deploy, Instance, Operate, Service, Admin} |
| +``` |
| + |
| +Change the `Admin` comment's first line from "the union of the four above" to "the union of the five above". |
| + |
| +- [ ] **Step 4: Run the svcrole package.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/svcrole/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Write the failing console tests.** Create `internal/console/deploycred_test.go`: |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "errors" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +func TestCanonicalRungs(t *testing.T) { |
| + for _, tc := range []struct { |
| + in []string |
| + want string |
| + }{ |
| + {nil, ""}, |
| + {[]string{" ", ""}, ""}, |
| + {[]string{"edge"}, "edge"}, |
| + {[]string{"edge", " canary ", "edge", ""}, "canary,edge"}, |
| + {[]string{"beta", "canary", "edge"}, "canary,edge,beta"}, |
| + } { |
| + got, err := canonicalRungs(tc.in) |
| + if err != nil || got != tc.want { |
| + t.Errorf("canonicalRungs(%q) = %q, %v; want %q", tc.in, got, err, tc.want) |
| + } |
| + } |
| + if _, err := canonicalRungs([]string{"edge", "stable"}); !errors.Is(err, ErrStableNotDelegable) { |
| + t.Errorf("stable: err = %v, want ErrStableNotDelegable", err) |
| + } |
| + for _, bad := range []string{"Edge", "canary/x", "prod", "edge;beta"} { |
| + var ue *UnknownRungError |
| + if _, err := canonicalRungs([]string{bad}); !errors.As(err, &ue) || ue.Rung != bad { |
| + t.Errorf("canonicalRungs(%q) err = %v, want UnknownRungError naming it", bad, err) |
| + } |
| + } |
| +} |
| + |
| +func TestParseStoredRungs(t *testing.T) { |
| + if got, ok := parseStoredRungs("canary,edge"); !ok || !reflect.DeepEqual(got, []string{"canary", "edge"}) { |
| + t.Fatalf("parseStoredRungs(canary,edge) = %v, %v", got, ok) |
| + } |
| + // Not canonical, empty, or naming stable: never a usable scope. |
| + for _, bad := range []string{"", "edge,canary", "edge,edge", "edge,stable", "edge,", " edge"} { |
| + if got, ok := parseStoredRungs(bad); ok { |
| + t.Errorf("parseStoredRungs(%q) = %v, true; want false", bad, got) |
| + } |
| + } |
| +} |
| + |
| +func TestMintDeployRules(t *testing.T) { |
| + st := newDeployStore(t) |
| + now := time.Now() |
| + mint := func(app string, role svcrole.Role, rungs []string) error { |
| + _, err := st.NewServicePrincipalScoped(1, "ci", app, role, rungs, "paul@example.test", now) |
| + return err |
| + } |
| + if err := mint("", svcrole.Deploy, []string{"edge"}); !errors.Is(err, ErrAccountScopedDeploy) { |
| + t.Errorf("deploy with no app: %v", err) |
| + } |
| + if err := mint("hello", svcrole.Deploy, nil); !errors.Is(err, ErrDeployNeedsRungs) { |
| + t.Errorf("deploy with no rungs: %v", err) |
| + } |
| + if err := mint("hello", svcrole.Deploy, []string{"stable"}); !errors.Is(err, ErrStableNotDelegable) { |
| + t.Errorf("deploy with stable: %v", err) |
| + } |
| + if err := mint("hello", svcrole.Publish, []string{"edge"}); !errors.Is(err, ErrRungsNeedDeploy) { |
| + t.Errorf("publish with rungs: %v", err) |
| + } |
| + // The old entry point cannot mint a deploy credential: it has no rungs. |
| + if _, err := st.NewServicePrincipal(1, "ci", "hello", svcrole.Deploy, "paul@example.test", now); !errors.Is(err, ErrDeployNeedsRungs) { |
| + t.Errorf("NewServicePrincipal(deploy): %v", err) |
| + } |
| +} |
| + |
| +func TestDeployPrincipalReadsBackItsRungs(t *testing.T) { |
| + st := newDeployStore(t) |
| + now := time.Now() |
| + raw, err := st.NewServicePrincipalScoped(1, "ci", "hello", svcrole.Deploy, []string{"edge", "canary"}, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p, ok := st.CLIPrincipal(raw, now) |
| + if !ok || p.Role != svcrole.Deploy || p.App != "hello" || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) { |
| + t.Fatalf("CLIPrincipal = %+v, %v", p, ok) |
| + } |
| + // Review Focus 3: rotation keeps the rungs, and the old secret dies. |
| + fresh, err := st.RotateServicePrincipal(1, "ci", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := st.CLIPrincipal(raw, now); ok { |
| + t.Fatal("the old secret still resolves after rotate") |
| + } |
| + p, ok = st.CLIPrincipal(fresh, now) |
| + if !ok || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) { |
| + t.Fatalf("after rotate: %+v, %v", p, ok) |
| + } |
| + rows, err := st.ServicePrincipals(1) |
| + if err != nil || len(rows) != 1 || rows[0].Rungs != "canary,edge" || rows[0].RungList() != "canary, edge" { |
| + t.Fatalf("ServicePrincipals = %+v, %v", rows, err) |
| + } |
| +} |
| + |
| +func TestDeployRowWithBadRungsDoesNotResolve(t *testing.T) { |
| + st := newDeployStore(t) |
| + now := time.Now() |
| + for i, rungs := range []string{"", "edge,canary", "stable", "edge,beta,"} { |
| + raw, err := st.NewServicePrincipalScoped(1, "ci"+string(rune('a'+i)), "hello", svcrole.Deploy, []string{"edge"}, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = ? WHERE token_hash = ?`, rungs, hashToken(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, ok := st.CLIPrincipal(raw, now); ok { |
| + t.Errorf("rungs %q: resolved to %+v; want refused", rungs, p) |
| + } |
| + } |
| + // A non-deploy row that somehow carries rungs is refused too. |
| + raw, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = 'edge' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := st.CLIPrincipal(raw, now); ok { |
| + t.Error("a publish row carrying rungs resolved") |
| + } |
| +} |
| + |
| +// newDeployStore is a bare console store, enough for the store-level tests. |
| +func newDeployStore(t *testing.T) *Store { |
| + t.Helper() |
| + st, err := Open(t.TempDir() + "/console.db") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + return st |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run them and see them fail.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestCanonicalRungs|TestParseStoredRungs|TestMintDeployRules|TestDeployPrincipalReadsBackItsRungs|TestDeployRowWithBadRungsDoesNotResolve' -count=1` |
| +Expected: FAIL to compile, `undefined: canonicalRungs` and the other new names. |
| + |
| +- [ ] **Step 7: Create `internal/console/deployscope.go`** with the rung helpers (Task 5 and Task 7 add to this file): |
| + |
| +```go |
| +// The deploy credential's scope: which rungs it may move, and the one |
| +// function that decides whether a promote or rollback by it is in scope. |
| +// |
| +// Spec: docs/superpowers/specs/2026-10-05-deploy-credential-design.md. |
| +package console |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// deployRungs is the order rungs are stored and shown in. stable is not in |
| +// it: no delegated credential may move stable (spec §5.2). |
| +var deployRungs = []string{release.RungCanary, release.RungEdge, release.RungBeta} |
| + |
| +// ErrStableNotDelegable is a mint that names stable. |
| +var ErrStableNotDelegable = errors.New("console: stable cannot be delegated") |
| + |
| +// UnknownRungError is a mint that names something that is not a rung. It |
| +// carries the name so both mint doors can say which one (D10, D15). |
| +type UnknownRungError struct{ Rung string } |
| + |
| +func (e *UnknownRungError) Error() string { return "console: unknown rung " + e.Rung } |
| + |
| +// canonicalRungs turns what a person typed into the stored form: spaces and |
| +// empty entries dropped, duplicates collapsed, ladder order, joined by ",". |
| +// It returns "" when nothing was named. It never lower-cases: "Edge" is not |
| +// a rung, and accepting it would make the stored form differ from the input |
| +// in a way the minter did not ask for. |
| +func canonicalRungs(in []string) (string, error) { |
| + seen := map[string]bool{} |
| + for _, raw := range in { |
| + r := strings.TrimSpace(raw) |
| + if r == "" { |
| + continue |
| + } |
| + if r == release.RungStable { |
| + return "", ErrStableNotDelegable |
| + } |
| + known := false |
| + for _, d := range deployRungs { |
| + if r == d { |
| + known = true |
| + } |
| + } |
| + if !known { |
| + return "", &UnknownRungError{Rung: r} |
| + } |
| + seen[r] = true |
| + } |
| + var out []string |
| + for _, d := range deployRungs { |
| + if seen[d] { |
| + out = append(out, d) |
| + } |
| + } |
| + return strings.Join(out, ","), nil |
| +} |
| + |
| +// parseStoredRungs reads the column back. Only the exact canonical form of a |
| +// non-empty set is accepted: anything else is a row this console did not |
| +// write, and a deploy credential with an unreadable scope must not resolve |
| +// (spec §5.5). |
| +func parseStoredRungs(s string) ([]string, bool) { |
| + if s == "" { |
| + return nil, false |
| + } |
| + parts := strings.Split(s, ",") |
| + canon, err := canonicalRungs(parts) |
| + if err != nil || canon != s { |
| + return nil, false |
| + } |
| + return parts, true |
| +} |
| +``` |
| + |
| +- [ ] **Step 8: Add the column and the listing field** in `internal/console/authstore.go`. |
| + |
| +Add to the ALTER list (after the `rotated_at` line, `:216`): |
| + |
| +```go |
| + `ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT ''`, |
| +``` |
| + |
| +Add to `CLIToken` (after `RotatedAt`): |
| + |
| +```go |
| + // Rungs is a deploy credential's scope in its stored, canonical form |
| + // ("canary,edge"); empty for every other kind and role. |
| + Rungs string |
| +``` |
| + |
| +And the display method, right after the struct: |
| + |
| +```go |
| +// RungList is Rungs for a person to read: "canary, edge". |
| +func (t CLIToken) RungList() string { return strings.ReplaceAll(t.Rungs, ",", ", ") } |
| +``` |
| + |
| +In `scanCLITokenRows` (`:558`), add `&t.Rungs` as the last `Scan` destination. Then find every query that feeds it and append `, rungs` as the last selected column, in the same position: |
| + |
| +```bash |
| +grep -n "scanCLITokenRows(" internal/console/*.go |
| +``` |
| + |
| +Each caller's `SELECT ... rotated_at` becomes `SELECT ... rotated_at, rungs`. `ServicePrincipals` (`svcprincipal.go`) and `CLITokens` (`authstore.go:595`) are two of them; update every one the grep lists. |
| + |
| +- [ ] **Step 9: Add `Rungs` to `Principal`, the scoped mint and the read-back** in `internal/console/svcprincipal.go`. |
| + |
| +In `Principal`, after `Name`: |
| + |
| +```go |
| + // Rungs is a deploy credential's scope, in ladder order. nil for every |
| + // other role, and never empty for a deploy principal: CLIPrincipal |
| + // refuses a deploy row it cannot read a scope from. |
| + Rungs []string |
| +``` |
| + |
| +Add the sentinels next to `ErrAccountScopedService`: |
| + |
| +```go |
| +// ErrAccountScopedDeploy is a deploy credential minted without an app. |
| +var ErrAccountScopedDeploy = errors.New("console: a deploy credential must be bound to one app") |
| + |
| +// ErrDeployNeedsRungs is a deploy credential minted with no rungs. There is |
| +// no default set: the minter says what the credential may move. |
| +var ErrDeployNeedsRungs = errors.New("console: a deploy credential needs at least one rung") |
| + |
| +// ErrRungsNeedDeploy is rungs named for any other role, where they would |
| +// read as a limit and enforce nothing. |
| +var ErrRungsNeedDeploy = errors.New("console: only a deploy credential takes rungs") |
| +``` |
| + |
| +Replace the body of `NewServicePrincipal` with a delegation, keeping its doc comment, and move the body into the new function: |
| + |
| +```go |
| +func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (string, error) { |
| + return s.NewServicePrincipalScoped(accountID, name, app, role, nil, mintedBy, now) |
| +} |
| + |
| +// NewServicePrincipalScoped is NewServicePrincipal with a rung scope, which |
| +// only a deploy credential takes. The rules live here rather than in a |
| +// handler for the reason the Service rule does: there are two mint doors. |
| +func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (string, error) { |
| + if name == "" { |
| + return "", errors.New("console: a service credential needs a name") |
| + } |
| + if _, ok := svcrole.Parse(string(role)); !ok { |
| + return "", fmt.Errorf("console: unknown service role %q", role) |
| + } |
| + if role == svcrole.Service && app == "" { |
| + return "", ErrAccountScopedService |
| + } |
| + canon, err := canonicalRungs(rungs) |
| + if err != nil { |
| + return "", err |
| + } |
| + if role == svcrole.Deploy { |
| + if app == "" { |
| + return "", ErrAccountScopedDeploy |
| + } |
| + if canon == "" { |
| + return "", ErrDeployNeedsRungs |
| + } |
| + } else if canon != "" { |
| + return "", ErrRungsNeedDeploy |
| + } |
| + // ... the existing transaction from NewServicePrincipal, unchanged except |
| + // for the INSERT, which gains the rungs column: |
| +``` |
| + |
| +with the INSERT becoming: |
| + |
| +```go |
| + if _, err := tx.Exec(`INSERT INTO cli_tokens |
| +(token_hash, address, label, remote_addr, created_at, rotated_at, expires_at, kind, account_id, app, role, name, rungs) |
| +VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?, ?)`, |
| + hashToken(raw), mintedBy, now.Unix(), now.Unix(), cliKindService, accountID, app, string(role), name, canon); err != nil { |
| + return "", err |
| + } |
| +``` |
| + |
| +Keep the whole existing comment block about `role == svcrole.Service && app == ""` above that check. |
| + |
| +In `CLIPrincipal`, select the column and check it. The query becomes: |
| + |
| +```go |
| + var storedHash, addr, kind, app, role, name, rungs string |
| + var accountID, expires, revoked int64 |
| + err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs |
| +FROM cli_tokens WHERE token_hash = ?`, hash). |
| + Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs) |
| +``` |
| + |
| +and the `cliKindService` case becomes: |
| + |
| +```go |
| + case cliKindService: |
| + r, ok := svcrole.Parse(role) |
| + if !ok { |
| + return Principal{}, false |
| + } |
| + var scope []string |
| + if r == svcrole.Deploy { |
| + // Fail closed: a deploy row with no readable scope, or with no |
| + // app, is not treated as unscoped (spec §5.5). |
| + scope, ok = parseStoredRungs(rungs) |
| + if !ok || app == "" { |
| + return Principal{}, false |
| + } |
| + } else if rungs != "" { |
| + return Principal{}, false |
| + } |
| + p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name, Rungs: scope} |
| +``` |
| + |
| +- [ ] **Step 10: Run the new tests, then the whole console package.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestCanonicalRungs|TestParseStoredRungs|TestMintDeployRules|TestDeployPrincipalReadsBackItsRungs|TestDeployRowWithBadRungsDoesNotResolve' -count=1` |
| +Expected: PASS. |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ ./internal/svcrole/ -count=1` |
| +Expected: PASS. If a test that counts roles or lists `All()` fails, read it: it is asserting the catalogue, and the fix is to add `deploy` to its expectation, not to change the catalogue. |
| + |
| +- [ ] **Step 11: Commit.** |
| + |
| +```bash |
| +gofmt -l internal/ ; git add internal/svcrole internal/console/deployscope.go internal/console/authstore.go internal/console/svcprincipal.go internal/console/deploycred_test.go |
| +git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "console: deploy role and rung-scoped service credentials (#229) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: The console's mint doors and listings |
| + |
| +**Files:** |
| +- Modify: `internal/console/apiservices.go` (`serviceView`, `handleAPIServiceCreate`, `handleAPIServiceList`) |
| +- Modify: `internal/console/svcpage.go` (`ServiceSecretView`, `handleServiceCreatePage`, the page fill near `:177`) |
| +- Modify: `internal/console/server.go:1723-1724` (page struct) |
| +- Modify: `internal/console/templates/account_settings.html` (banner, listing, form) |
| +- Create: `internal/console/deploymint_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1's `NewServicePrincipalScoped`, sentinels, `UnknownRungError`, `CLIToken.Rungs`, `RungList`. |
| +- Produces: API create accepts `"rungs": ["edge"]` and answers `"rungs": ["canary","edge"]`; listing rows carry `"rungs": [...]` (empty array, never null, for non-deploy rows). |
| + |
| +- [ ] **Step 1: Write the failing tests.** Create `internal/console/deploymint_test.go`: |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestAPIMintDeploy(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-deploy-mint") |
| + u := fx.ts.URL + "/api/cli/services/bab" |
| + status, out := postJSON(t, u, map[string]any{"name": "ci", "role": "deploy", "app": "hello", "rungs": []string{"edge", "canary"}}, token) |
| + if status != http.StatusCreated { |
| + t.Fatalf("mint deploy = %d %v", status, out) |
| + } |
| + if got, _ := out["rungs"].([]any); len(got) != 2 || got[0] != "canary" || got[1] != "edge" { |
| + t.Fatalf("rungs in response = %v", out["rungs"]) |
| + } |
| + for _, tc := range []struct { |
| + body map[string]any |
| + want string // D-text fragment from the approved Copy table |
| + }{ |
| + {map[string]any{"name": "a1", "role": "deploy", "rungs": []string{"edge"}}, "must name one app"}, |
| + {map[string]any{"name": "a2", "role": "deploy", "app": "hello"}, "must name its rungs"}, |
| + {map[string]any{"name": "a3", "role": "deploy", "app": "hello", "rungs": []string{"stable"}}, "stable cannot be delegated"}, |
| + {map[string]any{"name": "a4", "role": "deploy", "app": "hello", "rungs": []string{"prod"}}, `"prod"`}, |
| + {map[string]any{"name": "a5", "role": "publish", "app": "hello", "rungs": []string{"edge"}}, "only a deploy credential"}, |
| + } { |
| + status, out := postJSON(t, u, tc.body, token) |
| + if status != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), tc.want) { |
| + t.Errorf("%v = %d %v; want 400 containing %q", tc.body, status, out, tc.want) |
| + } |
| + } |
| + status, out = getJSON(t, u, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("list = %d %v", status, out) |
| + } |
| + rows, _ := out["services"].([]any) |
| + if len(rows) != 1 { |
| + t.Fatalf("listing = %v", rows) |
| + } |
| + if r, _ := rows[0].(map[string]any)["rungs"].([]any); len(r) != 2 { |
| + t.Fatalf("listing rungs = %v", rows[0]) |
| + } |
| +} |
| +``` |
| + |
| +The create and list doors are `POST` and `GET /api/cli/services/{account}` (`authcli.go:789-790`). The `want` fragments must match the approved Copy table text for D7 to D11; adjust them to the approved wording. |
| + |
| +Add a browser-form test in the same file, modelled on the existing mint-form tests (find them with `grep -n "services/new" internal/console/*_test.go`), asserting: a deploy mint with rung checkboxes `edge` and `canary` renders the secret banner containing `rungs canary, edge` (D20); a deploy mint with no app flashes D12; with no rungs flashes D13; a publish mint with a rung flashes D16; and in each refusal no row was created (`fx.st.ServicePrincipals(OpsAccountID)` unchanged). Use the same session and passkey helpers those tests use. |
| + |
| +- [ ] **Step 2: Run them and see them fail.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestAPIMintDeploy|TestFormMintDeploy' -count=1` |
| +Expected: FAIL (`rungs` missing from the response; refusals answer 500 or mint). |
| + |
| +- [ ] **Step 3: API door.** In `handleAPIServiceCreate`, add `Rungs []string \`json:"rungs"\`` to the request struct, call `s.store.NewServicePrincipalScoped(acct.ID, name, app, role, req.Rungs, actor, time.Now())`, and extend the error mapping after the `ErrAccountScopedService` arm: |
| + |
| +```go |
| + var ue *UnknownRungError |
| + switch { |
| + case errors.Is(err, ErrAccountScopedDeploy): |
| + jsonError(w, http.StatusBadRequest, "<D7>") |
| + return |
| + case errors.Is(err, ErrDeployNeedsRungs): |
| + jsonError(w, http.StatusBadRequest, "<D8>") |
| + return |
| + case errors.Is(err, ErrStableNotDelegable): |
| + jsonError(w, http.StatusBadRequest, "<D9>") |
| + return |
| + case errors.As(err, &ue): |
| + jsonError(w, http.StatusBadRequest, fmt.Sprintf("<D10>", ue.Rung)) |
| + return |
| + case errors.Is(err, ErrRungsNeedDeploy): |
| + jsonError(w, http.StatusBadRequest, "<D11>") |
| + return |
| + } |
| +``` |
| + |
| +`<Dn>` means the approved text from the Copy table, as a Go string literal. After a successful mint, compute the canonical list for the response and the audit line: |
| + |
| +```go |
| + canon, _ := canonicalRungs(req.Rungs) // already validated by the mint |
| + rungs := []string{} |
| + if canon != "" { |
| + rungs = strings.Split(canon, ",") |
| + } |
| +``` |
| + |
| +Use D17 for the audit detail when `role == svcrole.Deploy` (verb `cli`), and keep today's detail for every other role. Add `"rungs": rungs` to the response map. |
| + |
| +In `serviceView` add `Rungs []string \`json:"rungs"\``, and in `handleAPIServiceList` fill it: `[]string{}` when `t.Rungs == ""`, else `strings.Split(t.Rungs, ",")`. |
| + |
| +- [ ] **Step 4: Browser door.** In `handleServiceCreatePage`, after the existing `role == svcrole.Service && app == ""` pre-check, add pre-checks that refuse before the passkey trip, each with `back("err", "<Dn>")`: |
| + |
| +```go |
| + canon, rerr := canonicalRungs(r.Form["rungs"]) |
| + var ue *UnknownRungError |
| + switch { |
| + case errors.Is(rerr, ErrStableNotDelegable): |
| + back("err", "<D14>") |
| + return |
| + case errors.As(rerr, &ue): |
| + back("err", fmt.Sprintf("<D15>", ue.Rung)) |
| + return |
| + case role == svcrole.Deploy && app == "": |
| + back("err", "<D12>") |
| + return |
| + case role == svcrole.Deploy && canon == "": |
| + back("err", "<D13>") |
| + return |
| + case role != svcrole.Deploy && canon != "": |
| + back("err", "<D16>") |
| + return |
| + } |
| +``` |
| + |
| +`r.Form` is populated because `FormValue` has already been called above. Mint with `s.store.NewServicePrincipalScoped(acct.ID, name, app, role, r.Form["rungs"], addr, time.Now())` and map the same sentinels to D12 to D16 in its error arm (the store is still the authority). Use D17 (verb `console`) for a deploy mint's audit detail. Add `Rungs string` to `ServiceSecretView` and set it to `strings.ReplaceAll(canon, ",", ", ")`. |
| + |
| +In `server.go` next to `ServiceRoles`, add: |
| + |
| +```go |
| + ServiceRungs []string // the mint form's rung choices, deploy only |
| +``` |
| + |
| +and where `svcpage.go:177` fills `ServiceRoles`, set `p.ServiceRungs = deployRungs`. |
| + |
| +- [ ] **Step 5: Template.** In `templates/account_settings.html`: |
| + - Banner: in the not-rotated branch, render D20 when `.Rungs` is non-empty, else the existing line: |
| + |
| +```html |
| + {{else}} |
| + {{if .Rungs}}<p><strong>D20</strong></p>{{else}}<p><strong>Minted {{.Name}} — role {{.Role}}, over {{.Scope}}:</strong></p>{{end}} |
| + {{end}} |
| +``` |
| + |
| + (`D20` replaced by the approved template text.) |
| + - Listing: add a header cell `<th>D19</th>` after `Scope`, and a cell `<td>{{emptyOrDash .RungList}}</td>` after the scope cell. |
| + - Form: after the app `<select>`, add: |
| + |
| +```html |
| + <span class="small">D18</span> |
| + {{range .ServiceRungs}}<label class="small"><input type="checkbox" name="rungs" value="{{.}}"> {{.}}</label>{{end}} |
| +``` |
| + |
| +- [ ] **Step 6: Run the tests, then the package.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 7: Commit** with message `console: mint and list deploy credentials (#229)`. |
| + |
| +--- |
| + |
| +### Task 3: `carlos services create --rungs` and `services ls` |
| + |
| +**Files:** |
| +- Modify: `services.go` (`runServicesCreate`, `printServiceCreated`, `runServicesList`) |
| +- Modify: `apimode.go:4285-4330` (`apiServiceView`, `createService`) |
| +- Modify: `services_cli_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 2's API fields `rungs` (request and response, listing). |
| +- Produces: `func (s *apiSession) createService(ctx context.Context, a apiAcct, name, role, app string, rungs []string) (token string, doors []string, gotRungs []string, err error)`; `func printServiceCreated(acctSeg, name, role, app string, rungs []string, token string, doors []string)`; `apiServiceView.Rungs []string`. |
| + |
| +- [ ] **Step 1: Write the failing tests.** In `services_cli_test.go`, add a test modelled on `TestServicesCreatePrintsTheSecretAndTheDoors` that calls `printServiceCreated("bab", "ci", "deploy", "hello", []string{"canary", "edge"}, "tok", nil)` and asserts the first line is D5 filled as `minted service:bab/ci (role deploy, app hello, rungs canary, edge)`. Add a test modelled on `TestAPISessionServiceCreateAndList` whose fake console records the create request body and answers `{"token":"t","doors":[],"rungs":["canary","edge"]}`; assert the request JSON carried `"rungs":["edge","canary"]` and `createService` returned `gotRungs == []string{"canary","edge"}`; and that a listing row with `"rungs":["edge"]` decodes into `apiServiceView.Rungs`. |
| + |
| +- [ ] **Step 2: Run and see them fail.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'TestServicesCreate|TestAPISessionServiceCreate' -count=1` |
| +Expected: FAIL to compile (signature). |
| + |
| +- [ ] **Step 3: Implement.** |
| + - `apiServiceView`: add `Rungs []string \`json:"rungs"\``. |
| + - `createService`: add the `rungs []string` parameter, send `Rungs []string \`json:"rungs,omitempty"\`` in the request struct, decode `Rungs []string \`json:"rungs"\`` from the response, return it as the third value. |
| + - `runServicesCreate`: replace the `--role` help with D1 and `usageMsg` with D2. Add: |
| + |
| +```go |
| + rungsRaw := fs.String("rungs", "", "<D3>") |
| +``` |
| + |
| + After the role parse: |
| + |
| +```go |
| + 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("<D4>") |
| + } |
| +``` |
| + |
| + Pass `rungsV` to `createService` and the returned rungs to `printServiceCreated`. |
| + - `printServiceCreated`: new signature above; when `role == "deploy"` print D5 with `strings.Join(rungs, ", ")` instead of today's first line. Every other role prints exactly what it prints today. |
| + - `runServicesList`: header gains `\tD6` after `APP`, and each row prints `strings.Join(r.Rungs, ",")` or `-` when empty. |
| + - Update every other caller of the two changed functions: `grep -n 'createService(\|printServiceCreated(' *.go` and pass `nil` / ignore the extra return. |
| + |
| +- [ ] **Step 4: Run.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'Service' -count=1 && go vet .` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** with message `cli: services create --rungs and a RUNGS column (#229)`. |
| + |
| +--- |
| + |
| +### Task 4: The doors a deploy credential reaches |
| + |
| +**Files:** |
| +- Modify: `internal/console/authcli.go:476-544` (route registrations) |
| +- Create: `internal/console/deploydoors_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `svcrole.Deploy`; Task 1's `NewServicePrincipalScoped`. |
| +- Produces: `deployDoors` (test-only) — the exact §6 list. |
| + |
| +- [ ] **Step 1: Write the failing tests.** Create `internal/console/deploydoors_test.go`: |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "reflect" |
| + "sort" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// deployDoors is spec §6, verbatim. A door added later is refused to a |
| +// deploy credential unless someone adds it here on purpose. |
| +var deployDoors = []string{ |
| + "GET /api/cli/apps", |
| + "GET /api/cli/apps/{account}/{app}/channels", |
| + "POST /api/cli/apps/{account}/{app}/releases", |
| + "GET /api/cli/apps/{account}/{app}/releases", |
| + "POST /api/cli/apps/{account}/{app}/promote", |
| + "POST /api/cli/apps/{account}/{app}/rollback", |
| + "GET /api/cli/apps/{account}/{app}/tags", |
| + "GET /api/cli/apps/{account}/{app}/tags/{version}", |
| + "GET /api/cli/apps/{account}/{app}/version-target", |
| + "GET /api/cli/apps/{account}/{app}/pipeline", |
| + "GET /api/cli/apps/{account}/{app}/pending", |
| +} |
| + |
| +func TestDeployReachesExactlyItsDoors(t *testing.T) { |
| + s := newConsoleFixture(t).srv |
| + var got []string |
| + for pattern, set := range s.cliRoles { |
| + if set.Permits(svcrole.Deploy) { |
| + got = append(got, pattern) |
| + } |
| + } |
| + sort.Strings(got) |
| + want := append([]string(nil), deployDoors...) |
| + sort.Strings(want) |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("deploy reaches:\n got %q\nwant %q", got, want) |
| + } |
| +} |
| + |
| +// Review Focus 2: the app binding holds for deploy like any service. |
| +func TestDeployIsBoundToItsApp(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + for _, app := range []string{"hello", "other"} { |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/other/promote", |
| + map[string]string{"version": "v1", "channel": "edge"}, tok) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("deploy on a sibling app = %d %v, want 404", status, out) |
| + } |
| + // And a door outside §6 is the role gate's 403, not a 404. |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/delete", map[string]string{}, tok) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("deploy on trash = %d %v, want 403", status, out) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run and see the first fail.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeployReachesExactlyItsDoors|TestDeployIsBoundToItsApp' -count=1` |
| +Expected: FAIL, `deploy reaches: got []`. |
| + |
| +- [ ] **Step 3: Register the doors.** In `internal/console/authcli.go`, change exactly these registrations: |
| + |
| +```go |
| + s.cliRoute("GET /api/cli/apps", svcrole.Allow(svcrole.Publish, svcrole.Deploy, svcrole.Instance, svcrole.Operate), s.handleAPIApps) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/channels", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIChannels) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/promote", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPromote) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/rollback", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIRollback) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIShip) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIVersionTargetGet) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/tags", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagsList) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/tags/{version}", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagGet) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIReleasesList) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPipelineGet) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/pending", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPending) |
| +``` |
| + |
| +Change nothing else in the table. |
| + |
| +- [ ] **Step 4: Run the package.** Promote and rollback by a deploy credential are now reachable with no scope check. That is closed in Task 5, and nothing is released between the two tasks. |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1` |
| +Expected: PASS, including `TestEveryCLIDoorNamesItsRoles` and `TestNoRoleReachesAMintFleetOrPolicyDoor`. |
| + |
| +- [ ] **Step 5: Commit** with message `console: the doors a deploy credential reaches (#229)`. |
| + |
| +--- |
| + |
| +### Task 5: The scope function and the handlers' early refusal |
| + |
| +**Files:** |
| +- Modify: `internal/console/deployscope.go` |
| +- Modify: `internal/console/apicli.go` (`handleAPIPromote` after the pipeline read near `:772-777`; `handleAPIRollback` after its pipeline read) |
| +- Create: `internal/console/deployscope_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Principal.Rungs`, `release.Rung`, `release.RungCanary`, `release.RungStable`. |
| +- Produces: |
| + - `func deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error` — nil for any non-deploy principal. |
| + - `type deployRefusal struct{ msg string }` implementing `error`. |
| + - `func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error)` — 403 `{"error": <message>}`. |
| + - `func deployRefusalFor(app string, err error) error` — fills the app name for D24 (see Step 3). |
| + |
| +- [ ] **Step 1: Write the failing table test.** Create `internal/console/deployscope_test.go`: |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +func TestDeployScopeAllows(t *testing.T) { |
| + ce := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"canary", "edge"}} |
| + beta := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"beta"}} |
| + pub := Principal{Kind: PrincipalService, Role: svcrole.Publish, Name: "ci", App: "hello"} |
| + person := Principal{Kind: PrincipalUser, Address: "paul@keymail.dev"} |
| + for _, tc := range []struct { |
| + name string |
| + p Principal |
| + pipelined bool |
| + channel string |
| + hotfix bool |
| + ok bool |
| + }{ |
| + {"edge in scope", ce, false, "edge", false, true}, |
| + {"canary branch in scope", ce, false, "canary/feature-x", false, true}, |
| + {"bare canary refused", ce, false, "canary", false, false}, |
| + {"bare canary refused on a pipelined app", ce, true, "canary", false, false}, |
| + {"beta outside rungs", ce, false, "beta", false, false}, |
| + {"stable refused", ce, false, "stable", false, false}, |
| + {"stable refused even when granted beta", beta, false, "stable", false, false}, |
| + {"not a rung", ce, false, "staging", false, false}, |
| + {"hotfix refused", ce, false, "edge", true, false}, |
| + {"pipelined: edge refused", ce, true, "edge", false, false}, |
| + {"pipelined: canary branch allowed", ce, true, "canary/x", false, true}, |
| + {"beta credential on beta", beta, false, "beta", false, true}, |
| + {"publish untouched", pub, true, "stable", true, true}, |
| + {"person untouched", person, true, "stable", true, true}, |
| + } { |
| + err := deployScopeAllows(tc.p, tc.pipelined, tc.channel, tc.hotfix) |
| + if (err == nil) != tc.ok { |
| + t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +Then add handler tests to the same file using `newConsoleFixture`, an app `hello` created in `OpsAccountID`, a person token to ship `v1` (pattern: `TestOrdinaryAppShipIsNotArchChecked`, `internal/console/controlplane_test.go:772`) and a deploy token from `fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, ...)`. For each of these, POST `/api/cli/apps/bab/hello/promote` with the deploy token and assert 403, the approved message text, and that `release.Channel(ctx, fx.rel, fx.pub, "bab", "hello", <channel>)` is unchanged (still absent, or still the person's version): |
| + - `{"version":"v1","channel":"beta"}` (D22) |
| + - `{"version":"v1","channel":"stable"}` (D22) |
| + - `{"version":"v1","channel":"canary"}` (D23) |
| + - `{"version":"v1","channel":"edge","hotfix":true}` (D21) |
| + - after `release.SetPipeline(ctx, fx.rel, fx.priv, "bab", "hello", []release.PipelineChannel{{Name: "edge"}, {Name: "stable"}}, "paul@keymail.dev")`: `{"version":"v1","channel":"edge"}` (D24), and `{"version":"v1","channel":"stable"}` — a pipeline naming `stable` with no passkey rule still refuses deploy. |
| + - rollback: `{"channel":"beta"}` gives 403 D22. |
| + |
| +- [ ] **Step 2: Run and see it fail.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeployScope' -count=1` |
| +Expected: FAIL to compile, `undefined: deployScopeAllows`. |
| + |
| +- [ ] **Step 3: Implement the function.** Append to `internal/console/deployscope.go` (add imports `fmt`, `net/http`, `svcrole`): |
| + |
| +```go |
| +// deployRefusal is a promote or rollback a deploy credential may not make. |
| +// Its text is the 403's error, so a CI log says what to fix (spec §7.3). |
| +type deployRefusal struct{ msg string } |
| + |
| +func (e *deployRefusal) Error() string { return e.msg } |
| + |
| +// deployScopeAllows is the whole scope decision for a deploy credential |
| +// (spec §7.1). It is pure so it can be table-tested, and it is called three |
| +// times per write: early in the handler for the message, and by the release |
| +// primitives on their own pipeline reads (Task 7). For every principal that |
| +// is not a deploy credential it returns nil and changes nothing. |
| +func deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error { |
| + if p.Kind != PrincipalService || p.Role != svcrole.Deploy { |
| + return nil |
| + } |
| + held := deployHeldWords(p.Rungs) |
| + if hotfix { |
| + return &deployRefusal{fmt.Sprintf("<D21>", p.Name)} |
| + } |
| + rung, ok := release.Rung(channel) |
| + if !ok || rung == release.RungStable { |
| + return &deployRefusal{fmt.Sprintf("<D22>", p.Name, held, channel)} |
| + } |
| + if rung == release.RungCanary && !strings.HasPrefix(channel, release.RungCanary+"/") { |
| + return &deployRefusal{fmt.Sprintf("<D23>", p.Name)} |
| + } |
| + inScope := false |
| + for _, r := range p.Rungs { |
| + if r == rung { |
| + inScope = true |
| + } |
| + } |
| + if !inScope { |
| + return &deployRefusal{fmt.Sprintf("<D22>", p.Name, held, channel)} |
| + } |
| + if pipelined && rung != release.RungCanary { |
| + return &deployRefusal{fmt.Sprintf("<D24>", p.App, p.Name)} |
| + } |
| + return nil |
| +} |
| + |
| +// deployHeldWords is the rung list as the refusal names it: canary shown as |
| +// the channels it covers. |
| +func deployHeldWords(rungs []string) string { |
| + words := make([]string, len(rungs)) |
| + for i, r := range rungs { |
| + words[i] = r |
| + if r == release.RungCanary { |
| + words[i] = release.RungCanary + "/<name>" |
| + } |
| + } |
| + return strings.Join(words, ", ") |
| +} |
| + |
| +// writeDeployRefusal answers a scope refusal. Anything that is not a |
| +// deployRefusal is a programming error and answers 500. |
| +func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error) { |
| + var dr *deployRefusal |
| + if errors.As(err, &dr) { |
| + jsonError(w, http.StatusForbidden, dr.msg) |
| + return |
| + } |
| + jsonError(w, http.StatusInternalServerError, "internal error") |
| +} |
| +``` |
| + |
| +`p.App` is the app the credential is bound to, which the app binding already proved equals the path's app, so D24 names the right app without a parameter. |
| + |
| +- [ ] **Step 4: Call it early in both handlers.** In `handleAPIPromote`, immediately after the `GetPipeline` error block (the one ending `return` near `:777`): |
| + |
| +```go |
| + if err := deployScopeAllows(p, pipe != nil, channel, req.Hotfix); err != nil { |
| + s.writeDeployRefusal(w, err) |
| + return |
| + } |
| +``` |
| + |
| +In `handleAPIRollback`, immediately after its `GetPipeline` error block: |
| + |
| +```go |
| + if err := deployScopeAllows(p, pipe != nil, channel, false); err != nil { |
| + s.writeDeployRefusal(w, err) |
| + return |
| + } |
| +``` |
| + |
| +`p` is already in scope in both handlers (`principalFrom`). |
| + |
| +- [ ] **Step 5: Run.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** with message `console: deploy scope check at promote and rollback (#229)`. |
| + |
| +--- |
| + |
| +### Task 6: The Authorize hook in the release primitives |
| + |
| +**Files:** |
| +- Create: `internal/release/authorize.go` |
| +- Modify: `internal/release/release.go` (`Signer.Promote` `:694`, `PromoteOptions` `:765`, `Signer.PromoteOnLadder` `:849`, `Signer.Rollback` `:1031`) |
| +- Create: `internal/release/authorize_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `PromoteOptions.Authorize func(p *Pipeline) error` |
| + - `type AuthorizationError struct{ Err error }` with `Error()` and `Unwrap()` |
| + - `func (sg Signer) RollbackAuthorized(ctx context.Context, s Store, acct, app, channel string, authorize func(*Pipeline) error) error` |
| + - unexported `func (sg Signer) promote(ctx context.Context, s Store, acct, app, channel, version string, authorize func(*Pipeline) error, rec ...Promotion) error` |
| + |
| +- [ ] **Step 1: Write the test store and the failing tests.** Create `internal/release/authorize_test.go`: |
| + |
| +```go |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "errors" |
| + "os" |
| + "testing" |
| +) |
| + |
| +// storeCall is one call a request made through hookStore. |
| +type storeCall struct { |
| + seq int |
| + op, key string |
| + ifMatch string // put only |
| +} |
| + |
| +// hookStore runs before(call) ahead of every call it forwards, and after() |
| +// once a get returns, so a test can make another actor's writes land at an |
| +// exact point inside a request. The other actor writes to inner directly: |
| +// the same handle, which is where G2 holds for a directory store (spec |
| +// §7.2). seq counts only the calls made through hookStore. |
| +type hookStore struct { |
| + inner Store |
| + before func(c storeCall) |
| + after func(c storeCall, body []byte, etag string, err error) |
| + seq int |
| +} |
| + |
| +func (h *hookStore) call(op, key, ifMatch string) storeCall { |
| + h.seq++ |
| + c := storeCall{seq: h.seq, op: op, key: key, ifMatch: ifMatch} |
| + if h.before != nil { |
| + h.before(c) |
| + } |
| + return c |
| +} |
| + |
| +func (h *hookStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + c := h.call("get", key, "") |
| + body, etag, err := h.inner.Get(ctx, key) |
| + if h.after != nil { |
| + h.after(c, body, etag, err) |
| + } |
| + return body, etag, err |
| +} |
| + |
| +func (h *hookStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + h.call("put", key, ifMatch) |
| + return h.inner.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (h *hookStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + h.call("list", prefix, "") |
| + return h.inner.List(ctx, prefix) |
| +} |
| + |
| +func (h *hookStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| + h.call("listdirs", prefix, "") |
| + return h.inner.ListDirs(ctx, prefix) |
| +} |
| + |
| +func (h *hookStore) Delete(ctx context.Context, key string) error { |
| + h.call("delete", key, "") |
| + return h.inner.Delete(ctx, key) |
| +} |
| + |
| +// onNth runs fn once, just before the nth (1-based) call matching op and key. |
| +func onNth(op, key string, n int, fn func()) func(storeCall) { |
| + seen := 0 |
| + return func(c storeCall) { |
| + if c.op == op && c.key == key { |
| + seen++ |
| + if seen == n { |
| + fn() |
| + } |
| + } |
| + } |
| +} |
| + |
| +// noPipelineOnly is R2's rule for a non-canary channel: refuse if the app |
| +// has a pipeline. |
| +func noPipelineOnly(p *Pipeline) error { |
| + if p != nil { |
| + return errors.New("pipelined") |
| + } |
| + return nil |
| +} |
| + |
| +var edgeKey = ChannelKey("bab", "myapp", "edge") |
| + |
| +func setupEdge(t *testing.T) (context.Context, Store, ed25519.PrivateKey, ed25519.PublicKey) { |
| + t.Helper() |
| + ctx := context.Background() |
| + inner := NewDirStore(t.TempDir()) |
| + priv, pub := testKeys(t) |
| + for _, v := range []string{"v1", "v2", "v3", "v4"} { |
| + shipRelease(t, inner, "bab", "myapp", v, "linux-arm64", []byte("bin "+v)) |
| + } |
| + return ctx, inner, priv, pub |
| +} |
| + |
| +func installPipeline(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, names ...string) { |
| + t.Helper() |
| + var chans []PipelineChannel |
| + for _, n := range names { |
| + chans = append(chans, PipelineChannel{Name: n}) |
| + } |
| + if _, err := SetPipeline(ctx, s, priv, "bab", "myapp", chans, "owner"); err != nil { |
| + t.Fatalf("SetPipeline(%v): %v", names, err) |
| + } |
| +} |
| + |
| +func ownerPromote(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, v string) { |
| + t.Helper() |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}); err != nil { |
| + t.Fatalf("owner promote %s: %v", v, err) |
| + } |
| +} |
| + |
| +func edgeVersion(t *testing.T, ctx context.Context, s Store, pub ed25519.PublicKey) string { |
| + t.Helper() |
| + p, err := Channel(ctx, s, pub, "bab", "myapp", "edge") |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "" |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return p.Version |
| +} |
| + |
| +func wantAuthorizationError(t *testing.T, err error) { |
| + t.Helper() |
| + var ae *AuthorizationError |
| + if !errors.As(err, &ae) { |
| + t.Fatalf("err = %v; want *AuthorizationError", err) |
| + } |
| +} |
| + |
| +func TestAuthorizeSeesAPipelineInstalledBeforeTheRequest(t *testing.T) { |
| + ctx, inner, priv, pub := setupEdge(t) |
| + installPipeline(t, ctx, inner, priv, "edge", "stable") |
| + _, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1", |
| + PromoteOptions{Authorize: noPipelineOnly}) |
| + wantAuthorizationError(t, err) |
| + if got := edgeVersion(t, ctx, inner, pub); got != "" { |
| + t.Fatalf("edge = %q after a refusal", got) |
| + } |
| +} |
| + |
| +// The refusal is the hook's, not the ladder's order error, even when the |
| +// pipeline would make the ladder refuse too (spec §7.2 point 1, codex R3-3). |
| +func TestAuthorizeRefusesBeforeTheLadderDoes(t *testing.T) { |
| + ctx, inner, priv, _ := setupEdge(t) |
| + h := &hookStore{inner: inner} |
| + h.before = onNth("get", PipelineKey("bab", "myapp"), 1, func() { |
| + installPipeline(t, ctx, inner, priv, "preview") |
| + }) |
| + _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1", |
| + PromoteOptions{Authorize: noPipelineOnly}) |
| + wantAuthorizationError(t, err) |
| +} |
| + |
| +// The ladder's read saw no pipeline; one installed before the authoritative |
| +// read (after the pointer read) is still seen. |
| +func TestAuthorizeSeesAPipelineInstalledBeforeTheAuthoritativeRead(t *testing.T) { |
| + ctx, inner, priv, pub := setupEdge(t) |
| + h := &hookStore{inner: inner} |
| + h.before = onNth("get", PipelineKey("bab", "myapp"), 2, func() { |
| + installPipeline(t, ctx, inner, priv, "edge", "stable") |
| + }) |
| + _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1", |
| + PromoteOptions{Authorize: noPipelineOnly}) |
| + wantAuthorizationError(t, err) |
| + if got := edgeVersion(t, ctx, inner, pub); got != "" { |
| + t.Fatalf("edge = %q after a refusal", got) |
| + } |
| +} |
| + |
| +// Astra's round 2 counterexample: an owner promotes under a new pipeline |
| +// after the deploy request read the pointer. The deploy write must lose. |
| +func TestOwnerPromoteAfterInstallDefeatsTheDeployWrite(t *testing.T) { |
| + ctx, inner, priv, pub := setupEdge(t) |
| + ownerPromote(t, ctx, inner, priv, "v1") |
| + h := &hookStore{inner: inner} |
| + h.before = onNth("put", edgeKey, 1, func() { |
| + installPipeline(t, ctx, inner, priv, "edge", "stable") |
| + ownerPromote(t, ctx, inner, priv, "v3") |
| + }) |
| + _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v2", |
| + PromoteOptions{Authorize: noPipelineOnly}) |
| + if !errors.Is(err, ErrCASConflict) { |
| + t.Fatalf("err = %v; want ErrCASConflict", err) |
| + } |
| + if got := edgeVersion(t, ctx, inner, pub); got != "v3" { |
| + t.Fatalf("edge = %q; want the owner's v3", got) |
| + } |
| +} |
| + |
| +// R5, accepted: a pipeline installed after the authorization read, and the |
| +// pointer written and restored to identical bytes, before the write. G2 |
| +// holds (the bytes are what was read), so the write lands. |
| +func TestRestoredBytesLandAsTheAcceptedInFlightCase(t *testing.T) { |
| + ctx, inner, priv, pub := setupEdge(t) |
| + ownerPromote(t, ctx, inner, priv, "v1") |
| + ownerPromote(t, ctx, inner, priv, "v2") |
| + h := &hookStore{inner: inner} |
| + h.before = onNth("put", edgeKey, 1, func() { |
| + orig, _, err := inner.Get(ctx, edgeKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + installPipeline(t, ctx, inner, priv, "edge", "stable") |
| + ownerPromote(t, ctx, inner, priv, "v4") |
| + _, cur, err := inner.Get(ctx, edgeKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := inner.Put(ctx, edgeKey, orig, cur); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + if _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v3", |
| + PromoteOptions{Authorize: noPipelineOnly}); err != nil { |
| + t.Fatalf("err = %v; R5 accepts this write (spec §16)", err) |
| + } |
| + if got := edgeVersion(t, ctx, inner, pub); got != "v3" { |
| + t.Fatalf("edge = %q; want v3", got) |
| + } |
| +} |
| + |
| +// R5, accepted: a first promote onto an empty channel, with a pipeline |
| +// installed after the authorization read. |
| +func TestFirstPromoteOntoEmptyChannelLandsAsTheAcceptedInFlightCase(t *testing.T) { |
| + ctx, inner, priv, pub := setupEdge(t) |
| + h := &hookStore{inner: inner} |
| + h.before = onNth("put", edgeKey, 1, func() { |
| + installPipeline(t, ctx, inner, priv, "edge", "stable") |
| + }) |
| + if _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1", |
| + PromoteOptions{Authorize: noPipelineOnly}); err != nil { |
| + t.Fatalf("err = %v; R5 accepts this write (spec §16)", err) |
| + } |
| + if got := edgeVersion(t, ctx, inner, pub); got != "v1" { |
| + t.Fatalf("edge = %q; want v1", got) |
| + } |
| +} |
| + |
| +func TestRepeatPromoteRunsTheHook(t *testing.T) { |
| + ctx, inner, priv, _ := setupEdge(t) |
| + ownerPromote(t, ctx, inner, priv, "v1") |
| + installPipeline(t, ctx, inner, priv, "edge", "stable") |
| + res, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1", |
| + PromoteOptions{Authorize: noPipelineOnly}) |
| + wantAuthorizationError(t, err) |
| + if res.NoOp { |
| + t.Fatal("a refused repeat reported NoOp") |
| + } |
| +} |
| + |
| +func TestRepeatPromoteWithNoPipelineIsANoOp(t *testing.T) { |
| + ctx, inner, priv, _ := setupEdge(t) |
| + ownerPromote(t, ctx, inner, priv, "v1") |
| + var seen []bool |
| + hook := func(p *Pipeline) error { seen = append(seen, p != nil); return noPipelineOnly(p) } |
| + res, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1", |
| + PromoteOptions{Authorize: hook}) |
| + if err != nil || !res.NoOp { |
| + t.Fatalf("= %+v, %v; want a no-op", res, err) |
| + } |
| + if len(seen) != 1 || seen[0] { |
| + t.Fatalf("hook calls = %v; want one, with no pipeline", seen) |
| + } |
| +} |
| + |
| +// A caller with no hook makes no extra pipeline reads (spec §7.2). |
| +func TestNoHookNoExtraReads(t *testing.T) { |
| + ctx, inner, priv, _ := setupEdge(t) |
| + count := func(run func(Store) error) int { |
| + n := 0 |
| + h := &hookStore{inner: inner, before: func(c storeCall) { |
| + if c.op == "get" && c.key == PipelineKey("bab", "myapp") { |
| + n++ |
| + } |
| + }} |
| + if err := run(h); err != nil { |
| + t.Fatal(err) |
| + } |
| + return n |
| + } |
| + fresh := count(func(s Store) error { |
| + _, err := RootSigner(priv).PromoteOnLadder(ctx, s, "bab", "myapp", "edge", "v1", PromoteOptions{}) |
| + return err |
| + }) |
| + repeat := count(func(s Store) error { |
| + _, err := RootSigner(priv).PromoteOnLadder(ctx, s, "bab", "myapp", "edge", "v1", PromoteOptions{}) |
| + return err |
| + }) |
| + ownerPromote(t, ctx, inner, priv, "v2") |
| + rollback := count(func(s Store) error { return RootSigner(priv).Rollback(ctx, s, "bab", "myapp", "edge") }) |
| + if fresh != 1 || repeat != 0 || rollback != 0 { |
| + t.Fatalf("pipeline reads: fresh %d, repeat %d, rollback %d; want 1, 0, 0", fresh, repeat, rollback) |
| + } |
| +} |
| + |
| +func TestRollbackAuthorized(t *testing.T) { |
| + ctx, inner, priv, pub := setupEdge(t) |
| + ownerPromote(t, ctx, inner, priv, "v1") |
| + ownerPromote(t, ctx, inner, priv, "v2") |
| + installPipeline(t, ctx, inner, priv, "edge", "stable") |
| + err := RootSigner(priv).RollbackAuthorized(ctx, inner, "bab", "myapp", "edge", noPipelineOnly) |
| + wantAuthorizationError(t, err) |
| + if got := edgeVersion(t, ctx, inner, pub); got != "v2" { |
| + t.Fatalf("edge = %q after a refused rollback", got) |
| + } |
| + if err := inner.Delete(ctx, PipelineKey("bab", "myapp")); err != nil { |
| + t.Fatal(err) |
| + } |
| + h := &hookStore{inner: inner} |
| + h.before = onNth("put", edgeKey, 1, func() { ownerPromote(t, ctx, inner, priv, "v3") }) |
| + err = RootSigner(priv).RollbackAuthorized(ctx, h, "bab", "myapp", "edge", noPipelineOnly) |
| + if !errors.Is(err, ErrCASConflict) { |
| + t.Fatalf("err = %v; want ErrCASConflict", err) |
| + } |
| + if got := edgeVersion(t, ctx, inner, pub); got != "v3" { |
| + t.Fatalf("edge = %q; want the owner's v3", got) |
| + } |
| +} |
| +``` |
| + |
| +If `SetPipeline` refuses a one-channel pipeline named `preview`, or a channel named `stable`, read `validatePipeline` (`pipeline.go:255`) and use the nearest valid shape: the tests need "a pipeline that names edge" and "a pipeline that does not". |
| + |
| +- [ ] **Step 2: Run and see them fail.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/release/ -run 'TestAuthorize|TestOwnerPromote|TestRestoredBytes|TestFirstPromote|TestRepeatPromote|TestNoHook|TestRollbackAuthorized' -count=1` |
| +Expected: FAIL to compile (`Authorize`, `AuthorizationError`, `RollbackAuthorized` undefined). |
| + |
| +- [ ] **Step 3: Create `internal/release/authorize.go`:** |
| + |
| +```go |
| +package release |
| + |
| +// AuthorizationError is a refusal from a caller's Authorize hook. The |
| +// primitives wrap the hook's own error in it so a caller can tell "you may |
| +// not" from a store or ladder failure with errors.As. |
| +// |
| +// The hook runs on a pipeline the primitive has just read (spec |
| +// 2026-10-05-deploy-credential-design.md §7.2): on the ladder's read, on the |
| +// repeat path, and between the pointer read and the conditional write. A |
| +// caller that passes no hook gets no extra reads. |
| +type AuthorizationError struct{ Err error } |
| + |
| +func (e *AuthorizationError) Error() string { return e.Err.Error() } |
| +func (e *AuthorizationError) Unwrap() error { return e.Err } |
| +``` |
| + |
| +- [ ] **Step 4: Thread the hook through `release.go`.** |
| + |
| +`PromoteOptions` gains: |
| + |
| +```go |
| + // Authorize, when set, is called with the app's pipeline as read at each |
| + // point a write could depend on it: the ladder's read, the repeat path, |
| + // and between the pointer read and the conditional write. A non-nil |
| + // error refuses the promotion as an *AuthorizationError. nil means |
| + // today's behaviour exactly, with no extra reads. |
| + Authorize func(p *Pipeline) error |
| +``` |
| + |
| +Rename the body of `Signer.Promote` into `func (sg Signer) promote(ctx context.Context, s Store, acct, app, channel, version string, authorize func(*Pipeline) error, rec ...Promotion) error`, and make `Promote` call it with `nil`: |
| + |
| +```go |
| +func (sg Signer) Promote(ctx context.Context, s Store, acct, app, channel, version string, rec ...Promotion) error { |
| + return sg.promote(ctx, s, acct, app, channel, version, nil, rec...) |
| +} |
| +``` |
| + |
| +Inside `promote`, after the `switch` that sets `old` and `etag` from `GetQualified` (`:732-744`) and before `next := ...`, add: |
| + |
| +```go |
| + if authorize != nil { |
| + // Between the pointer read and the conditional write, so the write |
| + // lands only if the pointer is still what was authorized against |
| + // (spec §7.2, G1 and G2). |
| + pipe, perr := GetPipeline(ctx, s, pub, acct, app) |
| + if perr != nil { |
| + return fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr) |
| + } |
| + if aerr := authorize(pipe); aerr != nil { |
| + return &AuthorizationError{Err: aerr} |
| + } |
| + } |
| +``` |
| + |
| +In `Signer.PromoteOnLadder`: |
| +- On the repeat path, inside `if p, verr := ...; verr == nil && p.Version == version {` and before `res.NoOp = true`: |
| + |
| +```go |
| + if opt.Authorize != nil { |
| + pipe, perr := GetPipeline(ctx, s, pub, acct, app) |
| + if perr != nil { |
| + return res, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr) |
| + } |
| + if aerr := opt.Authorize(pipe); aerr != nil { |
| + return res, &AuthorizationError{Err: aerr} |
| + } |
| + } |
| +``` |
| + |
| +- Right after its own `pipe, perr := GetPipeline(...)` error check (`:930-933`): |
| + |
| +```go |
| + if opt.Authorize != nil { |
| + if aerr := opt.Authorize(pipe); aerr != nil { |
| + return res, &AuthorizationError{Err: aerr} |
| + } |
| + } |
| +``` |
| + |
| +- Replace the `sg.Promote(ctx, s, acct, app, channel, version, rec)` call with `sg.promote(ctx, s, acct, app, channel, version, opt.Authorize, rec)`. |
| + |
| +Rename the body of `Signer.Rollback` into `RollbackAuthorized` with the extra parameter, make `Rollback` call it with `nil`, and inside it add, after the `len(old.History) == 0` check and before `next := ...`: |
| + |
| +```go |
| + if authorize != nil { |
| + pipe, perr := GetPipeline(ctx, s, pub, acct, app) |
| + if perr != nil { |
| + return fmt.Errorf("rollback: cannot read the pipeline for %s/%s: %w", acct, app, perr) |
| + } |
| + if aerr := authorize(pipe); aerr != nil { |
| + return &AuthorizationError{Err: aerr} |
| + } |
| + } |
| +``` |
| + |
| +- [ ] **Step 5: Run the new tests, then the package.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/release/ -count=1` |
| +Expected: PASS, including `golden_test.go` (root-signed bytes unchanged). |
| + |
| +- [ ] **Step 6: Commit** with message `release: an Authorize hook between pointer read and write (#229)`. |
| + |
| +--- |
| + |
| +### Task 7: The request-local scoped signer and the handler wiring |
| + |
| +**Files:** |
| +- Modify: `internal/console/deployscope.go` (`deploySigner`) |
| +- Modify: `internal/console/apicli.go` (`handleAPIPromote` near `:880`, `handleAPIRollback` near the `release.Rollback` call) |
| +- Create: `internal/console/deploysign_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 5's `deployScopeAllows`, `writeDeployRefusal`; Task 6's `PromoteOptions.Authorize`, `AuthorizationError`, `RollbackAuthorized`. |
| +- Produces: `func (s *Server) deploySigner(p Principal) (release.Signer, error)`; `const deployGrantTTL = 5 * time.Minute`. |
| + |
| +- [ ] **Step 1: Write the failing tests.** Create `internal/console/deploysign_test.go` with a fixture helper: |
| + |
| +```go |
| +// deployFixture is a console with app hello in bab, v1 and v2 shipped by |
| +// paul, and a deploy credential ci over hello with rungs canary and edge. |
| +func deployFixture(t *testing.T) (fx *consoleFixture, person, deploy string) { |
| + t.Helper() |
| + fx = newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + person = cliToken(t, fx, "paul@keymail.dev", "code-deploy-sign") |
| + liftShipBudget(fx) |
| + for _, v := range []string{"v1", "v2"} { |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/hello/releases", person, |
| + []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("bin " + v)}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship %s = %d %v", v, status, out) |
| + } |
| + } |
| + tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, "paul@keymail.dev", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, person, tok |
| +} |
| +``` |
| + |
| +Then these tests, each written out in full: |
| +- `TestDeployPromoteIsSignedByAScopedGrant`: deploy promotes `v1` to `edge` → 200 `"result":"promoted"`; `release.ReadSerial` advanced by one; `fx.st.Events(OpsAccountID, "hello", 10)` holds a `promote` row; `release.Channel(ctx, fx.rel, fx.pub, "bab", "hello", "edge")` verifies and is `v1`; the raw pointer (`release.GetQualified(ctx, fx.rel, release.ChannelKey("bab","hello","edge"))`) has a `grant` object whose base64 `payload` decodes to JSON with `"rungs":["canary","edge"]` and `"name":"deploy.ci"`. |
| +- `TestDeployRepeatIsANoOp` (Review Focus 4): the same promote twice; the second answers 200 `"result":"noop"`, serial unchanged. |
| +- `TestDeploySignerRefusesWithoutTheScopeFunction`: `sg, err := fx.srv.deploySigner(Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"edge"}})`; then `sg.PromoteOnLadder(ctx, fx.rel, "bab", "hello", "beta", "v1", release.PromoteOptions{Hotfix: true})` errors, and so does `"stable"`. This is the signer refusing with no `Authorize` hook at all (spec §12 "Signer"). |
| +- `TestDeploySignerFailureFailsClosed`: set `fx.srv.cfg.ReleasePub` to a fresh, unrelated public key (`ed25519.GenerateKey(nil)`); deploy promote `v1` to `edge` → 503 with D26; `release.GetQualified` on the edge key returns `os.ErrNotExist`. |
| +- `TestPersonPromoteUnchanged`: the person promotes `v1` to `edge` → 200; the raw pointer has no `grant` field. |
| +- `TestDeployRollback`: person promotes `v1` then `v2` to `edge`; deploy rolls back `edge` → 200 `"result":"rolled back"`; edge is `v1`; its raw pointer carries a grant naming `deploy.ci`. |
| + |
| +- [ ] **Step 2: Run and see them fail.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeploy|TestPersonPromoteUnchanged' -count=1` |
| +Expected: FAIL (`deploySigner` undefined; pointers carry no grant). |
| + |
| +- [ ] **Step 3: Add `deploySigner`** to `internal/console/deployscope.go` (imports `time`): |
| + |
| +```go |
| +// deployGrantTTL is how long a request-local grant lives. It only has to |
| +// outlast one request: a box checks a grant against the pointer's own |
| +// promoted_at, so a pointer signed inside the window stays valid after the |
| +// grant expires (spec §7.4). |
| +const deployGrantTTL = 5 * time.Minute |
| + |
| +// deploySigner mints a scoped key and a root-signed grant for exactly the |
| +// credential's rungs, for one request. Boxes then verify the rung limit as |
| +// they do for a scoped CI key today. Nothing is stored. Any failure refuses |
| +// the request: a deploy write never falls back to the root signer. |
| +func (s *Server) deploySigner(p Principal) (release.Signer, error) { |
| + if len(p.Rungs) == 0 { |
| + return release.Signer{}, errors.New("deploy credential carries no rungs") |
| + } |
| + seed, grant, err := release.MintKeyGrant(s.cfg.Key, "deploy."+p.Name, p.Rungs, deployGrantTTL) |
| + if err != nil { |
| + return release.Signer{}, err |
| + } |
| + key, err := release.ParseSigningKey(seed, "deploy grant") |
| + if err != nil { |
| + return release.Signer{}, err |
| + } |
| + return release.ScopedSigner(key, grant, s.cfg.ReleasePub) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Wire `handleAPIPromote`.** Replace the single `release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, ...)` call and its error block with: |
| + |
| +```go |
| + signer := release.RootSigner(s.cfg.Key) |
| + var authorize func(*release.Pipeline) error |
| + if p.Kind == PrincipalService && p.Role == svcrole.Deploy { |
| + sg, serr := s.deploySigner(p) |
| + if serr != nil { |
| + log.Printf("<D25>", acct.Sqid, app, serr) |
| + jsonError(w, http.StatusServiceUnavailable, "<D26>") |
| + return |
| + } |
| + signer = sg |
| + authorize = func(pp *release.Pipeline) error { |
| + return deployScopeAllows(p, pp != nil, channel, req.Hotfix) |
| + } |
| + } |
| + res, err := signer.PromoteOnLadder(ctx, s.cfg.Releases, acct.Sqid, app, channel, version, |
| + release.PromoteOptions{Actor: addr, Hotfix: hotfix, Authorize: authorize}) |
| + if err != nil { |
| + var ae *release.AuthorizationError |
| + if errors.As(err, &ae) { |
| + s.writeDeployRefusal(w, ae.Err) |
| + return |
| + } |
| + log.Printf("console: api promote %s %s -> %s: %v", app, version, channel, err) |
| + jsonError(w, http.StatusConflict, err.Error()) |
| + return |
| + } |
| +``` |
| + |
| +`release.RootSigner(s.cfg.Key).PromoteOnLadder(...)` is exactly what `release.PromoteOnLadder(..., s.cfg.Key, ...)` does today, so a person's and a `publish` credential's promote are unchanged. Keep the existing comment above the old call. |
| + |
| +- [ ] **Step 5: Wire `handleAPIRollback`.** Replace `release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel)` with the same signer selection (no hotfix: pass `false`), then `signer.RollbackAuthorized(ctx, s.cfg.Releases, acct.Sqid, app, channel, authorize)`, with the same `AuthorizationError` arm before the existing 409. |
| + |
| +- [ ] **Step 6: Run the package.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ ./internal/release/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 7: Commit** with message `console: sign deploy writes with a request-local scoped grant (#229)`. |
| + |
| +--- |
| + |
| +### Task 8: The randomized interleaving test and its two mutants |
| + |
| +**Files:** |
| +- Create: `internal/release/authorize_sim_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 6's `hookStore`, `noPipelineOnly`, `installPipeline`, `PromoteOptions.Authorize`, `RollbackAuthorized`. |
| + |
| +- [ ] **Step 1: Write the simulation.** Create `internal/release/authorize_sim_test.go`: |
| + |
| +```go |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "errors" |
| + "fmt" |
| + "math/rand" |
| + "os" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestAuthorizeInterleavings runs random schedules of one deploy request |
| +// against an owner who installs and removes pipelines, promotes and rolls |
| +// back, at random points inside the request. It checks G1 and G2 and |
| +// nothing else (spec §7.2, §12): |
| +// |
| +// G1: the last hook call before the write came after the pointer read the |
| +// write was conditioned on, and the pipeline it was given permits it. |
| +// G2: the bytes the write replaced equal the bytes that read returned. |
| +// |
| +// Seeds: DEPLOY_SIM_SEEDS (default 400). A failure prints the seed and the |
| +// event log; DEPLOY_SIM_SEED=<n> replays exactly that seed. |
| +func TestAuthorizeInterleavings(t *testing.T) { |
| + first, last := 0, 400 |
| + if v := os.Getenv("DEPLOY_SIM_SEEDS"); v != "" { |
| + n, err := strconv.Atoi(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + last = n |
| + } |
| + if v := os.Getenv("DEPLOY_SIM_SEED"); v != "" { |
| + n, err := strconv.Atoi(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + first, last = n, n+1 |
| + } |
| + priv, _ := testKeys(t) |
| + for seed := first; seed < last; seed++ { |
| + simulateOnce(t, seed, priv) |
| + } |
| +} |
| + |
| +type simRead struct { |
| + seq int |
| + body []byte |
| + etag string |
| + absent bool |
| +} |
| + |
| +type simHook struct { |
| + seq int |
| + pipelined bool |
| +} |
| + |
| +func simulateOnce(t *testing.T, seed int, priv ed25519.PrivateKey) { |
| + t.Helper() |
| + ctx := context.Background() |
| + rng := rand.New(rand.NewSource(int64(seed))) |
| + inner := NewDirStore(t.TempDir()) |
| + for _, v := range []string{"v1", "v2", "v3", "v4"} { |
| + shipRelease(t, inner, "bab", "myapp", v, "linux-arm64", []byte("bin "+v)) |
| + } |
| + for _, v := range []string{"v1", "v2"} { |
| + if _, err := PromoteOnLadder(ctx, inner, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}); err != nil { |
| + t.Fatalf("seed %d setup: %v", seed, err) |
| + } |
| + } |
| + var ( |
| + events []string |
| + reads []simRead |
| + hooks []simHook |
| + put *storeCall |
| + replaced []byte |
| + replacedAbsent bool |
| + ) |
| + owner := func() { |
| + var err error |
| + var what string |
| + switch rng.Intn(4) { |
| + case 0: |
| + what = "install" |
| + _, err = SetPipeline(ctx, inner, priv, "bab", "myapp", []PipelineChannel{{Name: "edge"}, {Name: "stable"}}, "owner") |
| + case 1: |
| + what = "remove pipeline" |
| + err = inner.Delete(ctx, PipelineKey("bab", "myapp")) |
| + case 2: |
| + v := fmt.Sprintf("v%d", 1+rng.Intn(4)) |
| + what = "promote " + v |
| + _, err = PromoteOnLadder(ctx, inner, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}) |
| + case 3: |
| + what = "rollback" |
| + err = Rollback(ctx, inner, priv, "bab", "myapp", "edge") |
| + } |
| + events = append(events, fmt.Sprintf(" owner %s: %v", what, err)) |
| + } |
| + h := &hookStore{inner: inner} |
| + h.before = func(c storeCall) { |
| + if rng.Float64() < 0.3 { |
| + for i := 0; i <= rng.Intn(2); i++ { |
| + owner() |
| + } |
| + } |
| + events = append(events, fmt.Sprintf("%d %s %s %s", c.seq, c.op, c.key, c.ifMatch)) |
| + if c.op == "put" && c.key == edgeKey { |
| + cc := c |
| + put = &cc |
| + body, _, err := inner.Get(ctx, edgeKey) |
| + replaced, replacedAbsent = body, errors.Is(err, os.ErrNotExist) |
| + } |
| + } |
| + h.after = func(c storeCall, body []byte, etag string, err error) { |
| + if c.key == edgeKey { |
| + reads = append(reads, simRead{c.seq, body, etag, errors.Is(err, os.ErrNotExist)}) |
| + } |
| + } |
| + hook := func(p *Pipeline) error { |
| + hooks = append(hooks, simHook{h.seq, p != nil}) |
| + events = append(events, fmt.Sprintf("%d hook pipelined=%v", h.seq, p != nil)) |
| + return noPipelineOnly(p) |
| + } |
| + var err error |
| + noop := false |
| + switch rng.Intn(3) { |
| + case 0: |
| + var res PromoteResult |
| + res, err = RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v3", PromoteOptions{Authorize: hook}) |
| + noop = res.NoOp |
| + case 1: |
| + var res PromoteResult |
| + res, err = RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v2", PromoteOptions{Authorize: hook}) |
| + noop = res.NoOp |
| + case 2: |
| + err = RootSigner(priv).RollbackAuthorized(ctx, h, "bab", "myapp", "edge", hook) |
| + } |
| + fail := func(msg string) { |
| + t.Fatalf("seed %d: %s\nreplay with DEPLOY_SIM_SEED=%d\n%s", seed, msg, seed, strings.Join(events, "\n")) |
| + } |
| + if err != nil { |
| + return // refusals and conflicts write nothing |
| + } |
| + if noop { |
| + if len(hooks) == 0 || hooks[len(hooks)-1].pipelined { |
| + fail("a no-op succeeded without a clean hook call") |
| + } |
| + return |
| + } |
| + if put == nil { |
| + fail("succeeded without writing") |
| + } |
| + var r *simRead |
| + for i := len(reads) - 1; i >= 0; i-- { |
| + rd := reads[i] |
| + if rd.seq >= put.seq { |
| + continue |
| + } |
| + if (put.ifMatch == "" && rd.absent) || (put.ifMatch != "" && !rd.absent && rd.etag == put.ifMatch) { |
| + r = &rd |
| + break |
| + } |
| + } |
| + if r == nil { |
| + fail("no pointer read matches the write's condition") |
| + } |
| + var a *simHook |
| + for i := len(hooks) - 1; i >= 0; i-- { |
| + if hooks[i].seq < put.seq { |
| + a = &hooks[i] |
| + break |
| + } |
| + } |
| + if a == nil { |
| + fail("a write landed with no hook call") |
| + } |
| + if a.seq <= r.seq { |
| + fail(fmt.Sprintf("G1: the hook at %d is not after the pointer read at %d", a.seq, r.seq)) |
| + } |
| + if a.pipelined { |
| + fail("G1: the hook saw a pipeline and the write landed") |
| + } |
| + if replacedAbsent != r.absent || !bytes.Equal(replaced, r.body) { |
| + fail("G2: the write replaced bytes other than the ones it read") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=2000 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1` |
| +Expected: PASS. If it fails, the printed log is a real fault in Task 6 or in the oracle; read it before changing anything, and do not loosen the oracle without re-reading spec §7.2. |
| + |
| +- [ ] **Step 3: Mutant 1, hook before the pointer read.** In a copy of the tree, move the `if authorize != nil { ... }` block in `promote` to just before `cur, etag, err := GetQualified(ctx, s, key)`, and run the simulation. It must fail on G1 on a printed seed. |
| + |
| +```bash |
| +M=$TMPDIR/deploy-mutant1; rm -rf "$M"; mkdir -p "$M" |
| +git -C /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential archive HEAD | tar -x -C "$M" |
| +cd "$M" && python3 - <<'EOF' |
| +p="internal/release/release.go"; s=open(p).read() |
| +start=s.index("\tif authorize != nil {\n\t\t// Between the pointer read") |
| +end=s.index("\t\t}\n\t}\n", start)+len("\t\t}\n\t}\n") |
| +block=s[start:end]; s=s[:start]+s[end:] |
| +anchor="\tcur, etag, err := GetQualified(ctx, s, key)\n\tvar old *ChannelPointer" |
| +assert s.count(anchor)==1; s=s.replace(anchor, block+anchor) |
| +open(p,"w").write(s) |
| +EOF |
| +export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=400 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1; echo "exit=$?" |
| +``` |
| + |
| +Expected: `exit=1`, a G1 failure with a seed. Replay that seed with `DEPLOY_SIM_SEED=<n>` and confirm it fails again. |
| + |
| +- [ ] **Step 4: Mutant 2, unconditional write.** In a fresh copy, make the directory store's compare-and-swap a no-op and run the simulation. It must fail on G2 on a printed seed. |
| + |
| +```bash |
| +M=$TMPDIR/deploy-mutant2; rm -rf "$M"; mkdir -p "$M" |
| +git -C /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential archive HEAD | tar -x -C "$M" |
| +cd "$M" && sed -i 's/if ifMatch != "" \&\& (!exists || etagOf(cur) != ifMatch) {/if false \&\& ifMatch != "" \&\& (!exists || etagOf(cur) != ifMatch) {/' internal/release/store.go |
| +grep -c 'if false && ifMatch' internal/release/store.go |
| +export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=2000 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1; echo "exit=$?" |
| +``` |
| + |
| +Expected: the grep prints `1`, then `exit=1` with a G2 failure and a seed that replays. |
| + |
| +- [ ] **Step 5: Clean up the copies.** |
| + |
| +```bash |
| +rm -rf "$TMPDIR/deploy-mutant1" "$TMPDIR/deploy-mutant2" |
| +``` |
| + |
| +- [ ] **Step 6: Commit** the simulation with message `release: randomized G1/G2 check for the Authorize hook (#229)`, and put both mutants' failing seeds in the commit body. |
| + |
| +--- |
| + |
| +### Task 9: The CLI reads `CARLOS_SERVICE_TOKEN` |
| + |
| +**Files:** |
| +- Modify: `apimode.go:93-143` (`resolveMode`) |
| +- Modify: `apimode_test.go` (`clearDeploymentEnv` and new tests) |
| + |
| +**Interfaces:** |
| +- Consumes: `normalizeConsole(in string) (string, error)` (`auth.go:354`), `newConsoleClient(origin, token string)`. |
| +- Produces: `const serviceSessionLabel = "<D29>"`. |
| + |
| +- [ ] **Step 1: Keep existing tests hermetic.** In `apimode_test.go`'s `clearDeploymentEnv`, add: |
| + |
| +```go |
| + t.Setenv("CARLOS_SERVICE_TOKEN", "") |
| +``` |
| + |
| +- [ ] **Step 2: Write the failing tests** in `apimode_test.go`: |
| + |
| +```go |
| +func TestResolveModeServiceToken(t *testing.T) { |
| + useIsolatedConfig(t) |
| + clearDeploymentEnv(t) |
| + var gotAuth string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotAuth = r.Header.Get("Authorization") |
| + w.Header().Set("Content-Type", "application/json") |
| + w.Write([]byte(`{"accounts":[]}`)) |
| + })) |
| + defer ts.Close() |
| + // A person's credential for another console is on disk too. The token |
| + // wins and the file is not consulted. |
| + f, _ := loadCredentials() |
| + f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "person", ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := f.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") |
| + t.Setenv("CARLOS_CONSOLE", ts.URL) |
| + sess, err := resolveMode("") |
| + if err != nil || sess == nil { |
| + t.Fatalf("resolveMode = %v, %v", sess, err) |
| + } |
| + if sess.address != serviceSessionLabel { |
| + t.Fatalf("address = %q", sess.address) |
| + } |
| + if _, _, err := sess.getJSON(context.Background(), "/api/cli/apps"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if gotAuth != "Bearer svc-secret" { |
| + t.Fatalf("Authorization = %q, want the service token", gotAuth) |
| + } |
| +} |
| + |
| +func TestResolveModeServiceTokenNeedsAConsole(t *testing.T) { |
| + useIsolatedConfig(t) |
| + clearDeploymentEnv(t) |
| + t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") |
| + if _, err := resolveMode(""); err == nil || !strings.Contains(err.Error(), "CARLOS_CONSOLE") { |
| + t.Fatalf("err = %v, want D27", err) |
| + } |
| +} |
| + |
| +func TestResolveModeServiceTokenRefusedWithStoreEnv(t *testing.T) { |
| + for _, env := range []string{"CARLOS_DEPLOYMENT_BUCKET", "CARLOS_DEPLOYMENT_DIR"} { |
| + t.Run(env, func(t *testing.T) { |
| + useIsolatedConfig(t) |
| + clearDeploymentEnv(t) |
| + t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") |
| + t.Setenv("CARLOS_CONSOLE", "https://c.example") |
| + t.Setenv(env, t.TempDir()) |
| + sess, err := resolveMode("") |
| + if err == nil || sess != nil || !strings.Contains(err.Error(), "CARLOS_SERVICE_TOKEN") { |
| + t.Fatalf("= %v, %v; want D28", sess, err) |
| + } |
| + }) |
| + } |
| +} |
| +``` |
| + |
| +Adjust imports (`net/http`, `net/http/httptest`, `context`, `strings`). If `normalizeConsole` refuses the `http://127.0.0.1` origin, read how `TestAPISessionShipMultipart` points a session at an httptest server and do the same. Also add a door-coverage test (Review Focus 5): a fake console that records `METHOD path` for every request and answers the shapes `TestAPISessionShipMultipart` and `TestAPISessionStableRefusal` already use; drive `sess.ship`, `sess.promote`, `sess.rollback` and the channels read with `--account-id bab` resolution (`apiAcct{Seg: "bab", Exact: true}` or whatever `resolvedAccount` builds for an exact sqid; read `apiAccount` at `apimode.go:399`). Map each recorded path back to a route pattern and assert each is in Task 4's §6 list. Then call `sess.appRegions` and assert its path is `.../regions`, which is not in the list; name the test `TestCarlosDeployNeedsADoorDeployDoesNotHave` so the gap is visible. |
| + |
| +- [ ] **Step 3: Run and see them fail.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'TestResolveMode|TestCarlosDeployNeedsADoor' -count=1` |
| +Expected: FAIL (`serviceSessionLabel` undefined). |
| + |
| +- [ ] **Step 4: Implement** in `resolveMode`, after the existing `--console` conflict check and before `if bucketSet || dirSet { return nil, nil }`: |
| + |
| +```go |
| + token := os.Getenv("CARLOS_SERVICE_TOKEN") |
| + if token != "" && (bucketSet || dirSet) { |
| + return nil, errors.New("<D28>") |
| + } |
| +``` |
| + |
| +and after `if bucketSet || dirSet { return nil, nil }`: |
| + |
| +```go |
| + if token != "" { |
| + // CI's path (spec §8): the console is named outright, never taken |
| + // from a config default or a stored credential, and the credentials |
| + // file is not read. |
| + console := consoleFlag |
| + if console == "" { |
| + console = os.Getenv("CARLOS_CONSOLE") |
| + } |
| + if console == "" { |
| + return nil, errors.New("<D27>") |
| + } |
| + origin, err := normalizeConsole(console) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &apiSession{c: newConsoleClient(origin, token), console: origin, address: serviceSessionLabel}, nil |
| + } |
| +``` |
| + |
| +Add near `apiSession`: |
| + |
| +```go |
| +// serviceSessionLabel stands in for the address in "using X as Y" lines |
| +// when the session is a service token: the CLI has no door that tells it |
| +// the credential's name, and a person's whoami does not answer for one. |
| +const serviceSessionLabel = "<D29>" |
| +``` |
| + |
| +Update `resolveMode`'s doc comment to describe the token arm. |
| + |
| +- [ ] **Step 5: Run.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -count=1 && go vet .` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** with message `cli: CARLOS_SERVICE_TOKEN selects the console for CI (#229)`. |
| + |
| +--- |
| + |
| +### Task 10: Mixed versions fail closed |
| + |
| +**Files:** |
| +- Create: `internal/console/deploymixed_test.go` |
| + |
| +- [ ] **Step 1: Write the tests.** |
| + |
| +```go |
| +// TestUnknownStoredRoleIsNotSignedInAtEveryDoor is spec §9's first check: a |
| +// console that does not know a stored role (as v0.21.0 does not know |
| +// deploy) answers its token exactly as it answers no token at all. |
| +func TestUnknownStoredRoleIsNotSignedInAtEveryDoor(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.db.Exec(`UPDATE cli_tokens SET role = 'futurerole' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + fill := regexp.MustCompile(`\{[a-z]+\}`) |
| + for pattern := range fx.srv.cliRoles { |
| + method, path, _ := strings.Cut(pattern, " ") |
| + path = strings.Replace(path, "{account}", "bab", 1) |
| + path = strings.Replace(path, "{app}", "hello", 1) |
| + path = fill.ReplaceAllString(path, "x") |
| + with := doRaw(t, method, fx.ts.URL+path, raw) |
| + without := doRaw(t, method, fx.ts.URL+path, "") |
| + if with.status != without.status || with.body != without.body { |
| + t.Errorf("%s: unknown role answered %d %q; no token answered %d %q", |
| + pattern, with.status, with.body, without.status, without.body) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +`doRaw` sends `{}` as the body for non-GET methods, sets `Authorization: Bearer <tok>` only when `tok != ""`, and returns `{status int; body string}`. The role-set exclusion half of §9 is already pinned by `TestDeployIsBoundToItsApp` (Task 4). |
| + |
| +- [ ] **Step 2: Run.** |
| + |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run TestUnknownStoredRole -count=1` |
| +Expected: PASS. A failure names a door that treats an unknown role differently from no token; stop and report it, do not special-case the test. |
| + |
| +- [ ] **Step 3: Read v0.21.0.** Confirm both refusals exist in the release Tito's console runs: |
| + |
| +```bash |
| +git show v0.21.0:internal/console/svcprincipal.go | grep -n -A3 'r, ok := svcrole.Parse(role)' |
| +git show v0.21.0:internal/console/authcli.go | grep -n 'allow.Permits(p.Role)' |
| +``` |
| + |
| +Expected: the first prints the `if !ok { return Principal{}, false }` arm; the second prints one line. Put both outputs in the commit body. |
| + |
| +- [ ] **Step 4: Commit** with message `console: pin fail-closed handling of an unknown stored role (#229)`. |
| + |
| +--- |
| + |
| +### Task 11: Docs |
| + |
| +**Files:** |
| +- Modify: `README.md:170-180` |
| +- Modify: `docs/site/cli.md` (the `carlos services` section, `:1409-1440`) |
| + |
| +- [ ] **Step 1: README.** Replace the paragraph beginning "CI and operators keep the existing path unchanged" (through "...the rung gates) is what decides.") with D30 as approved. The draft for Task 0: |
| + |
| +```markdown |
| +**CI uses the console too.** Mint a deploy credential for the app, limited |
| +to the rungs CI may move, and give CI the secret and the console's address: |
| + |
| + carlos services create --role deploy --app blog --rungs canary,edge ci |
| + # in CI: |
| + export CARLOS_SERVICE_TOKEN=... CARLOS_CONSOLE=https://console.example.com |
| + carlos ship --app blog --version "$GITHUB_SHA" --target linux-arm64 blog-linux-arm64 |
| + carlos promote --app blog "$GITHUB_SHA" edge |
| + |
| +Every promote is audited in the console and reaches the boxes within |
| +seconds. A deploy credential can ship, and promote or roll back only its |
| +rungs. It cannot reach stable, hotfix, delete the app, or change retention |
| +or domains. |
| + |
| +**Direct bucket mode is for operators.** With `CARLOS_DEPLOYMENT_BUCKET` |
| +(or `_DIR`) set, every verb goes straight to the bucket and the console |
| +never sees it. Use it to set up a deployment before its console exists, to |
| +run one that has no console, to recover when the console is down, and for |
| +the platform's own releases. A scoped `CARLOS_RELEASE_KEY` limits that path |
| +only. The CLI refuses to run with both `CARLOS_SERVICE_TOKEN` and a bucket |
| +variable set. |
| +``` |
| + |
| +Leave "**On a box, or in CI, don't log in.**" and what follows unchanged. |
| + |
| +- [ ] **Step 2: docs/site/cli.md.** In `### carlos services`, change the example to `carlos services create --role deploy --app hello --rungs canary,edge ci-shipper`, and add D31 as approved after "Pick the narrowest role that works." The draft for Task 0: |
| + |
| +```markdown |
| +`deploy` is for a CI pipeline: it ships, and promotes or rolls back only |
| +the rungs named with `--rungs` (`canary`, `edge`, `beta`; never `stable`). |
| +It must name one app and cannot hotfix. If the app has a pipeline, it may |
| +move `canary/<name>` channels only. CI reads it from |
| +`CARLOS_SERVICE_TOKEN` with `CARLOS_CONSOLE`. |
| +``` |
| + |
| +and change "`publish` ships releases;" to "`publish` ships releases and manages the app;". |
| + |
| +- [ ] **Step 3: Check links and formatting.** |
| + |
| +Run: `grep -n "CARLOS_SERVICE_TOKEN" README.md docs/site/cli.md` |
| +Expected: the new passages and nothing contradicting them. |
| + |
| +- [ ] **Step 4: Commit** with message `docs: CI promotes through the console with a deploy credential (#229)`. |
| + |
| +--- |
| + |
| +### Task 12: The full gate, push and CI |
| + |
| +- [ ] **Step 1: Full gate, outside the sandbox**, using the `-exec` wrapper (activator tests need a short temp root; the root package needs one on disk): |
| + |
| +```bash |
| +mkdir -p ~/.cache/deploy-cred/gotmp |
| +cat > ~/.cache/deploy-cred/texec.sh <<'EOF' |
| +#!/bin/sh |
| +case "$1" in |
| +*/activator.test) root=/tmp/k ;; |
| +*) root=/var/tmp/ck ;; |
| +esac |
| +mkdir -p "$root" |
| +export GOTMPDIR="$root" TMPDIR="$root" |
| +exec "$@" |
| +EOF |
| +chmod +x ~/.cache/deploy-cred/texec.sh |
| +cd /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential |
| +go build ./... && GOTMPDIR=~/.cache/deploy-cred/gotmp CARLOS_REQUIRE_JS_PARITY=1 \ |
| + GOFLAGS="-buildvcs=false -exec=$HOME/.cache/deploy-cred/texec.sh" make test |
| +``` |
| + |
| +Expected: build, vet, gofmt and every package's tests pass. A failure is read first, never just re-run. |
| + |
| +- [ ] **Step 2: Cross-compile** (the CLI links `internal/host`): |
| + |
| +```bash |
| +for p in darwin/arm64 windows/amd64 linux/arm64; do GOOS=${p%/*} GOARCH=${p#*/} go build -o /dev/null . || echo "FAIL $p"; done |
| +``` |
| + |
| +Expected: no `FAIL` lines. |
| + |
| +- [ ] **Step 3: Clean up and push.** |
| + |
| +```bash |
| +rm -rf /tmp/k /var/tmp/ck ~/.cache/deploy-cred |
| +git push origin design-deploy-credential |
| +amadan ci status carlos/platform -branch design-deploy-credential; echo "exit=$?" |
| +``` |
| + |
| +Expected: the push succeeds; CI status eventually exits 0. Do not merge: merging and rolling wait for Paul. |
| + |
| +--- |
| + |
| +## Self-review notes |
| + |
| +- Spec coverage: §5 → Tasks 1 to 3; §6 → Task 4; §7.1 and §7.3 → Task 5; §7.2 → Tasks 6 and 8; §7.4 → Task 7; §8 → Task 9; §9 → Tasks 4 and 10; §10 → Task 2 (audit) and Task 7 (promote audit unchanged); §11 → Tasks 0 and 11; §12 → every task's tests; §13 rollout is Paul's and titogo's, after merge, and is not a task here. |
| +- R5 is pinned by the two "lands" tests in Task 6 and the simulation's acceptance of restored bytes in Task 8. |