| 1 | # Deploy Credential Implementation Plan |
| 2 | |
| 3 | > **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. |
| 4 | |
| 5 | **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. |
| 6 | |
| 7 | **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. |
| 8 | |
| 9 | **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. |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-10-05-deploy-credential-design.md` (rev 5.2, approved 2026-10-05). Read §3 (rulings R1 to R6), §5 to §9 and §12 before starting any task. |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | - Rungs a `deploy` credential may hold: `canary`, `edge`, `beta`. `stable` is refused at mint and at every door (spec §5.2). |
| 16 | - `deploy` requires a non-empty app and at least one rung. Any other role with rungs is refused (spec §5.4). |
| 17 | - A stored `deploy` row whose rungs are empty or do not parse resolves to no principal at all (spec §5.5). |
| 18 | - A `deploy` credential is permitted on exactly the doors in spec §6, and on nothing else. |
| 19 | - A caller that passes no `Authorize` hook gets exactly today's behaviour and today's store calls (spec §7.2). |
| 20 | - A `deploy` request never falls back to the root signer. If the scoped signer cannot be built, the request is refused (spec §7.4). |
| 21 | - Request-local grant lifetime: 5 minutes. Grant name: `deploy.` + the credential's name. |
| 22 | - 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. |
| 23 | - `carlos auth whoami` is unchanged (spec §8). |
| 24 | - Existing `publish` credentials and existing roles are unchanged (R1). |
| 25 | - 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. |
| 26 | - 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. |
| 27 | - 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>`. |
| 28 | |
| 29 | ## Review Focus |
| 30 | |
| 31 | 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. |
| 32 | 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. |
| 33 | 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. |
| 34 | 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. |
| 35 | 5. **`carlos deploy` with a deploy token (R6).** Expected: the routine path on an app that is already set up (instance list, channels, ship, promote, and `--region`'s region read) uses only §6 doors; first-deploy setup and `--host` are refused at the role gate; the watch's log read is refused and the deploy still reports its result. Pinned in Task 9. |
| 36 | |
| 37 | --- |
| 38 | |
| 39 | ## File map |
| 40 | |
| 41 | | File | Responsibility | Tasks | |
| 42 | | --- | --- | --- | |
| 43 | | `internal/svcrole/svcrole.go` | the `Deploy` role in the catalogue | 1 | |
| 44 | | `internal/console/deployscope.go` (new) | rung parsing, `deployScopeAllows`, the refusal type, `deploySigner` | 1, 5, 7 | |
| 45 | | `internal/console/authstore.go` | `rungs` column, `CLIToken.Rungs`, row scanning | 1 | |
| 46 | | `internal/console/svcprincipal.go` | `Principal.Rungs`, `NewServicePrincipalScoped`, read-back | 1 | |
| 47 | | `internal/console/apiservices.go` | API mint door and listing | 2 | |
| 48 | | `internal/console/svcpage.go`, `templates/account_settings.html`, `server.go` | browser mint form and listing | 2 | |
| 49 | | `internal/console/authcli.go` | the §6 door list | 4 | |
| 50 | | `internal/console/apicli.go` | promote and rollback handlers | 5, 7 | |
| 51 | | `internal/release/authorize.go` (new) | `AuthorizationError` | 6 | |
| 52 | | `internal/release/release.go` | the `Authorize` hook in promote and rollback | 6 | |
| 53 | | `services.go`, `apimode.go` | `services create --rungs`, `services ls`, `resolveMode` | 3, 9 | |
| 54 | | `README.md`, `docs/site/cli.md` | CI uses the console | 11 | |
| 55 | | tests | one new `_test.go` per task, named in each task | all | |
| 56 | |
| 57 | --- |
| 58 | |
| 59 | ### Task 0: Copy review (orchestrator with Paul; no subagent) |
| 60 | |
| 61 | Every string below is a draft. This task replaces the drafts with Paul's approved text before any task writes them into a file. |
| 62 | |
| 63 | **Files:** |
| 64 | - Modify: this plan's Copy table, in place. |
| 65 | |
| 66 | - [ ] **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): |
| 67 | |
| 68 | ```bash |
| 69 | cd /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential |
| 70 | 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 |
| 71 | ``` |
| 72 | |
| 73 | - [ ] **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. |
| 74 | - [ ] **Step 3: Write the approved text into the Copy table verbatim**, replacing each draft. Mark the table header "approved 2026-MM-DD". |
| 75 | - [ ] **Step 4: Commit.** |
| 76 | |
| 77 | ```bash |
| 78 | git add docs/superpowers/plans/2026-10-05-deploy-credential.md |
| 79 | git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "plan: deploy credential copy approved |
| 80 | |
| 81 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 82 | ``` |
| 83 | |
| 84 | ### Copy table (approved 2026-10-05, all 32 strings as drafted) |
| 85 | |
| 86 | `%s` and `%q` are Go format verbs filled at run time. `{{...}}` is template syntax. |
| 87 | |
| 88 | | ID | Where | Draft | |
| 89 | | --- | --- | --- | |
| 90 | | D1 | `services create` `--role` help | `role: publish, deploy, instance, operate, service or admin (required)` | |
| 91 | | 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>` | |
| 92 | | D3 | `services create` `--rungs` help | `rungs a deploy credential may move, comma-separated: canary, edge, beta (required with --role deploy)` | |
| 93 | | D4 | CLI pre-check | `services create: --rungs is only for --role deploy` | |
| 94 | | D5 | mint output, deploy | `minted service:%s/%s (role deploy, app %s, rungs %s)` | |
| 95 | | D6 | `services ls` column header | `RUNGS` | |
| 96 | | D7 | API mint refusal: deploy, no app | `a deploy credential must name one app (--app)` | |
| 97 | | D8 | API mint refusal: deploy, no rungs | `a deploy credential must name its rungs (--rungs): canary, edge or beta` | |
| 98 | | D9 | API mint refusal: stable | `stable cannot be delegated: moving it needs a passkey in the console` | |
| 99 | | D10 | API mint refusal: unknown rung | `unknown rung %q: one of canary, edge, beta` | |
| 100 | | D11 | API mint refusal: rungs on another role | `only a deploy credential takes rungs` | |
| 101 | | D12 | form flash: deploy, no app | `A deploy credential must name one app. Nothing was minted.` | |
| 102 | | D13 | form flash: deploy, no rungs | `A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted.` | |
| 103 | | D14 | form flash: stable | `Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted.` | |
| 104 | | D15 | form flash: unknown rung | `“%s” is not a rung. Choose canary, edge or beta. Nothing was minted.` | |
| 105 | | D16 | form flash: rungs on another role | `Only a deploy credential takes rungs. Nothing was minted.` | |
| 106 | | D17 | audit detail, deploy mint | `minted service credential %s with role deploy over app %s, rungs %s (%s)` (last verb is `cli` or `console`) | |
| 107 | | D18 | form: rung checkboxes label | `Rungs, for a deploy credential:` | |
| 108 | | D19 | listing column header (page) | `Rungs` | |
| 109 | | D20 | secret banner, deploy | `Minted {{.Name}}: role {{.Role}}, over {{.Scope}}, rungs {{.Rungs}}:` | |
| 110 | | D21 | 403: hotfix | `deploy credential %s cannot hotfix. Promote in ladder order, or ask a member to hotfix.` | |
| 111 | | 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`) | |
| 112 | | D23 | 403: bare canary | `deploy credential %s may move canary/<name> channels, not canary itself.` | |
| 113 | | D24 | 403: pipelined app | `%s has a pipeline, so deploy credential %s may move canary/<name> channels only.` | |
| 114 | | D25 | console log line, scoped signer failure | `console: deploy signer for %s/%s: %v` | |
| 115 | | D26 | 503: scoped signer failure | `could not prepare a signing key for this deploy credential. Nothing was promoted.` | |
| 116 | | D27 | CLI: token, no console | `CARLOS_SERVICE_TOKEN is set but no console is named: set CARLOS_CONSOLE or pass --console` | |
| 117 | | 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.` | |
| 118 | | D29 | CLI session label (`using X as …`) | `a service credential (CARLOS_SERVICE_TOKEN)` | |
| 119 | | D30 | README.md replacing lines 170-180 | see Task 11 Step 1 | |
| 120 | | D31 | docs/site/cli.md `carlos services` section | see Task 11 Step 2 | |
| 121 | | D31b | docs/site/cli.md, the role list | `` `publish` ships releases and manages the app; `` (replaces "`publish` ships releases;") | |
| 122 | |
| 123 | --- |
| 124 | |
| 125 | ### Task 1: The role, the rungs column, minting rules and read-back |
| 126 | |
| 127 | **Files:** |
| 128 | - Modify: `internal/svcrole/svcrole.go` (catalogue, comments) |
| 129 | - Modify: `internal/svcrole/svcrole_test.go` |
| 130 | - Create: `internal/console/deployscope.go` |
| 131 | - Modify: `internal/console/authstore.go:99-125` (`CLIToken`), `:209-216` (ALTER list), `scanCLITokenRows` and every SELECT that feeds it |
| 132 | - Modify: `internal/console/svcprincipal.go` (`Principal`, `CLIPrincipal`, `NewServicePrincipal`) |
| 133 | - Create: `internal/console/deploycred_test.go` |
| 134 | |
| 135 | **Interfaces:** |
| 136 | - Produces: |
| 137 | - `svcrole.Deploy Role = "deploy"`, in `All()` right after `Publish`. |
| 138 | - `func canonicalRungs(in []string) (string, error)` returns `""` for no rungs, else `canary,edge,beta` order joined by `,`. Errors: `ErrStableNotDelegable`, `*UnknownRungError{Rung string}`. |
| 139 | - `func parseStoredRungs(s string) ([]string, bool)`. |
| 140 | - `Principal.Rungs []string` (nil for every role but `deploy`). |
| 141 | - `CLIToken.Rungs string` (canonical stored form) and `func (t CLIToken) RungList() string` (`"canary, edge"`). |
| 142 | - `func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (string, error)`. |
| 143 | - Sentinels: `ErrAccountScopedDeploy`, `ErrDeployNeedsRungs`, `ErrRungsNeedDeploy`, `ErrStableNotDelegable`. |
| 144 | |
| 145 | - [ ] **Step 1: Write the failing svcrole test.** Append to `internal/svcrole/svcrole_test.go`: |
| 146 | |
| 147 | ```go |
| 148 | func TestDeployIsMintableAndNarrow(t *testing.T) { |
| 149 | got, ok := Parse("deploy") |
| 150 | if !ok || got != Deploy { |
| 151 | t.Fatalf("Parse(\"deploy\") = %q, %v", got, ok) |
| 152 | } |
| 153 | all := All() |
| 154 | if len(all) < 2 || all[0] != Publish || all[1] != Deploy { |
| 155 | t.Fatalf("All() = %v; want deploy right after publish", all) |
| 156 | } |
| 157 | // Existing doors do not admit it: adding a role must not widen anything. |
| 158 | for _, door := range []Set{Allow(Publish), Allow(Instance), Allow(Operate), Allow(Service)} { |
| 159 | if door.Permits(Deploy) { |
| 160 | t.Fatalf("%v admits deploy", door.Roles()) |
| 161 | } |
| 162 | } |
| 163 | if !Allow(Deploy).Permits(Admin) { |
| 164 | t.Fatal("Allow adds Admin to every set; that changed") |
| 165 | } |
| 166 | } |
| 167 | ``` |
| 168 | |
| 169 | - [ ] **Step 2: Run it and see it fail.** |
| 170 | |
| 171 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/svcrole/ -run TestDeployIsMintableAndNarrow -count=1` |
| 172 | Expected: FAIL, `undefined: Deploy`. |
| 173 | |
| 174 | - [ ] **Step 3: Add the role.** In `internal/svcrole/svcrole.go`, after the `Publish` constant: |
| 175 | |
| 176 | ```go |
| 177 | // Deploy is a deploy pipeline's credential: ship, and promote or roll |
| 178 | // back only the rungs the credential was minted with. It is always bound |
| 179 | // to one app, and it never reaches app lifecycle, retention or domains. |
| 180 | // The rungs live on the credential, not here: this package knows roles, |
| 181 | // not channels (spec 2026-10-05-deploy-credential-design.md §5). |
| 182 | Deploy Role = "deploy" |
| 183 | ``` |
| 184 | |
| 185 | Change the catalogue line to: |
| 186 | |
| 187 | ```go |
| 188 | var catalogue = []Role{Publish, Deploy, Instance, Operate, Service, Admin} |
| 189 | ``` |
| 190 | |
| 191 | Change the `Admin` comment's first line from "the union of the four above" to "the union of the five above". |
| 192 | |
| 193 | - [ ] **Step 4: Run the svcrole package.** |
| 194 | |
| 195 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/svcrole/ -count=1` |
| 196 | Expected: PASS. |
| 197 | |
| 198 | - [ ] **Step 5: Write the failing console tests.** Create `internal/console/deploycred_test.go`: |
| 199 | |
| 200 | ```go |
| 201 | package console |
| 202 | |
| 203 | import ( |
| 204 | "errors" |
| 205 | "reflect" |
| 206 | "testing" |
| 207 | "time" |
| 208 | |
| 209 | "github.com/carlosframework/platform/internal/svcrole" |
| 210 | ) |
| 211 | |
| 212 | func TestCanonicalRungs(t *testing.T) { |
| 213 | for _, tc := range []struct { |
| 214 | in []string |
| 215 | want string |
| 216 | }{ |
| 217 | {nil, ""}, |
| 218 | {[]string{" ", ""}, ""}, |
| 219 | {[]string{"edge"}, "edge"}, |
| 220 | {[]string{"edge", " canary ", "edge", ""}, "canary,edge"}, |
| 221 | {[]string{"beta", "canary", "edge"}, "canary,edge,beta"}, |
| 222 | } { |
| 223 | got, err := canonicalRungs(tc.in) |
| 224 | if err != nil || got != tc.want { |
| 225 | t.Errorf("canonicalRungs(%q) = %q, %v; want %q", tc.in, got, err, tc.want) |
| 226 | } |
| 227 | } |
| 228 | if _, err := canonicalRungs([]string{"edge", "stable"}); !errors.Is(err, ErrStableNotDelegable) { |
| 229 | t.Errorf("stable: err = %v, want ErrStableNotDelegable", err) |
| 230 | } |
| 231 | for _, bad := range []string{"Edge", "canary/x", "prod", "edge;beta"} { |
| 232 | var ue *UnknownRungError |
| 233 | if _, err := canonicalRungs([]string{bad}); !errors.As(err, &ue) || ue.Rung != bad { |
| 234 | t.Errorf("canonicalRungs(%q) err = %v, want UnknownRungError naming it", bad, err) |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | func TestParseStoredRungs(t *testing.T) { |
| 240 | if got, ok := parseStoredRungs("canary,edge"); !ok || !reflect.DeepEqual(got, []string{"canary", "edge"}) { |
| 241 | t.Fatalf("parseStoredRungs(canary,edge) = %v, %v", got, ok) |
| 242 | } |
| 243 | // Not canonical, empty, or naming stable: never a usable scope. |
| 244 | for _, bad := range []string{"", "edge,canary", "edge,edge", "edge,stable", "edge,", " edge"} { |
| 245 | if got, ok := parseStoredRungs(bad); ok { |
| 246 | t.Errorf("parseStoredRungs(%q) = %v, true; want false", bad, got) |
| 247 | } |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | func TestMintDeployRules(t *testing.T) { |
| 252 | st := newDeployStore(t) |
| 253 | now := time.Now() |
| 254 | mint := func(app string, role svcrole.Role, rungs []string) error { |
| 255 | _, err := st.NewServicePrincipalScoped(1, "ci", app, role, rungs, "paul@example.test", now) |
| 256 | return err |
| 257 | } |
| 258 | if err := mint("", svcrole.Deploy, []string{"edge"}); !errors.Is(err, ErrAccountScopedDeploy) { |
| 259 | t.Errorf("deploy with no app: %v", err) |
| 260 | } |
| 261 | if err := mint("hello", svcrole.Deploy, nil); !errors.Is(err, ErrDeployNeedsRungs) { |
| 262 | t.Errorf("deploy with no rungs: %v", err) |
| 263 | } |
| 264 | if err := mint("hello", svcrole.Deploy, []string{"stable"}); !errors.Is(err, ErrStableNotDelegable) { |
| 265 | t.Errorf("deploy with stable: %v", err) |
| 266 | } |
| 267 | if err := mint("hello", svcrole.Publish, []string{"edge"}); !errors.Is(err, ErrRungsNeedDeploy) { |
| 268 | t.Errorf("publish with rungs: %v", err) |
| 269 | } |
| 270 | // The old entry point cannot mint a deploy credential: it has no rungs. |
| 271 | if _, err := st.NewServicePrincipal(1, "ci", "hello", svcrole.Deploy, "paul@example.test", now); !errors.Is(err, ErrDeployNeedsRungs) { |
| 272 | t.Errorf("NewServicePrincipal(deploy): %v", err) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | func TestDeployPrincipalReadsBackItsRungs(t *testing.T) { |
| 277 | st := newDeployStore(t) |
| 278 | now := time.Now() |
| 279 | raw, err := st.NewServicePrincipalScoped(1, "ci", "hello", svcrole.Deploy, []string{"edge", "canary"}, "paul@example.test", now) |
| 280 | if err != nil { |
| 281 | t.Fatal(err) |
| 282 | } |
| 283 | p, ok := st.CLIPrincipal(raw, now) |
| 284 | if !ok || p.Role != svcrole.Deploy || p.App != "hello" || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) { |
| 285 | t.Fatalf("CLIPrincipal = %+v, %v", p, ok) |
| 286 | } |
| 287 | // Review Focus 3: rotation keeps the rungs, and the old secret dies. |
| 288 | fresh, err := st.RotateServicePrincipal(1, "ci", now) |
| 289 | if err != nil { |
| 290 | t.Fatal(err) |
| 291 | } |
| 292 | if _, ok := st.CLIPrincipal(raw, now); ok { |
| 293 | t.Fatal("the old secret still resolves after rotate") |
| 294 | } |
| 295 | p, ok = st.CLIPrincipal(fresh, now) |
| 296 | if !ok || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) { |
| 297 | t.Fatalf("after rotate: %+v, %v", p, ok) |
| 298 | } |
| 299 | rows, err := st.ServicePrincipals(1) |
| 300 | if err != nil || len(rows) != 1 || rows[0].Rungs != "canary,edge" || rows[0].RungList() != "canary, edge" { |
| 301 | t.Fatalf("ServicePrincipals = %+v, %v", rows, err) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | func TestDeployRowWithBadRungsDoesNotResolve(t *testing.T) { |
| 306 | st := newDeployStore(t) |
| 307 | now := time.Now() |
| 308 | for i, rungs := range []string{"", "edge,canary", "stable", "edge,beta,"} { |
| 309 | raw, err := st.NewServicePrincipalScoped(1, "ci"+string(rune('a'+i)), "hello", svcrole.Deploy, []string{"edge"}, "paul@example.test", now) |
| 310 | if err != nil { |
| 311 | t.Fatal(err) |
| 312 | } |
| 313 | if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = ? WHERE token_hash = ?`, rungs, hashToken(raw)); err != nil { |
| 314 | t.Fatal(err) |
| 315 | } |
| 316 | if p, ok := st.CLIPrincipal(raw, now); ok { |
| 317 | t.Errorf("rungs %q: resolved to %+v; want refused", rungs, p) |
| 318 | } |
| 319 | } |
| 320 | // A non-deploy row that somehow carries rungs is refused too. |
| 321 | raw, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, "paul@example.test", now) |
| 322 | if err != nil { |
| 323 | t.Fatal(err) |
| 324 | } |
| 325 | if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = 'edge' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| 326 | t.Fatal(err) |
| 327 | } |
| 328 | if _, ok := st.CLIPrincipal(raw, now); ok { |
| 329 | t.Error("a publish row carrying rungs resolved") |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | // newDeployStore is a bare console store, enough for the store-level tests. |
| 334 | func newDeployStore(t *testing.T) *Store { |
| 335 | t.Helper() |
| 336 | st, err := Open(t.TempDir() + "/console.db") |
| 337 | if err != nil { |
| 338 | t.Fatal(err) |
| 339 | } |
| 340 | t.Cleanup(func() { st.Close() }) |
| 341 | return st |
| 342 | } |
| 343 | ``` |
| 344 | |
| 345 | - [ ] **Step 6: Run them and see them fail.** |
| 346 | |
| 347 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestCanonicalRungs|TestParseStoredRungs|TestMintDeployRules|TestDeployPrincipalReadsBackItsRungs|TestDeployRowWithBadRungsDoesNotResolve' -count=1` |
| 348 | Expected: FAIL to compile, `undefined: canonicalRungs` and the other new names. |
| 349 | |
| 350 | - [ ] **Step 7: Create `internal/console/deployscope.go`** with the rung helpers (Task 5 and Task 7 add to this file): |
| 351 | |
| 352 | ```go |
| 353 | // The deploy credential's scope: which rungs it may move, and the one |
| 354 | // function that decides whether a promote or rollback by it is in scope. |
| 355 | // |
| 356 | // Spec: docs/superpowers/specs/2026-10-05-deploy-credential-design.md. |
| 357 | package console |
| 358 | |
| 359 | import ( |
| 360 | "errors" |
| 361 | "strings" |
| 362 | |
| 363 | "github.com/carlosframework/platform/internal/release" |
| 364 | ) |
| 365 | |
| 366 | // deployRungs is the order rungs are stored and shown in. stable is not in |
| 367 | // it: no delegated credential may move stable (spec §5.2). |
| 368 | var deployRungs = []string{release.RungCanary, release.RungEdge, release.RungBeta} |
| 369 | |
| 370 | // ErrStableNotDelegable is a mint that names stable. |
| 371 | var ErrStableNotDelegable = errors.New("console: stable cannot be delegated") |
| 372 | |
| 373 | // UnknownRungError is a mint that names something that is not a rung. It |
| 374 | // carries the name so both mint doors can say which one (D10, D15). |
| 375 | type UnknownRungError struct{ Rung string } |
| 376 | |
| 377 | func (e *UnknownRungError) Error() string { return "console: unknown rung " + e.Rung } |
| 378 | |
| 379 | // canonicalRungs turns what a person typed into the stored form: spaces and |
| 380 | // empty entries dropped, duplicates collapsed, ladder order, joined by ",". |
| 381 | // It returns "" when nothing was named. It never lower-cases: "Edge" is not |
| 382 | // a rung, and accepting it would make the stored form differ from the input |
| 383 | // in a way the minter did not ask for. |
| 384 | func canonicalRungs(in []string) (string, error) { |
| 385 | seen := map[string]bool{} |
| 386 | for _, raw := range in { |
| 387 | r := strings.TrimSpace(raw) |
| 388 | if r == "" { |
| 389 | continue |
| 390 | } |
| 391 | if r == release.RungStable { |
| 392 | return "", ErrStableNotDelegable |
| 393 | } |
| 394 | known := false |
| 395 | for _, d := range deployRungs { |
| 396 | if r == d { |
| 397 | known = true |
| 398 | } |
| 399 | } |
| 400 | if !known { |
| 401 | return "", &UnknownRungError{Rung: r} |
| 402 | } |
| 403 | seen[r] = true |
| 404 | } |
| 405 | var out []string |
| 406 | for _, d := range deployRungs { |
| 407 | if seen[d] { |
| 408 | out = append(out, d) |
| 409 | } |
| 410 | } |
| 411 | return strings.Join(out, ","), nil |
| 412 | } |
| 413 | |
| 414 | // parseStoredRungs reads the column back. Only the exact canonical form of a |
| 415 | // non-empty set is accepted: anything else is a row this console did not |
| 416 | // write, and a deploy credential with an unreadable scope must not resolve |
| 417 | // (spec §5.5). |
| 418 | func parseStoredRungs(s string) ([]string, bool) { |
| 419 | if s == "" { |
| 420 | return nil, false |
| 421 | } |
| 422 | parts := strings.Split(s, ",") |
| 423 | canon, err := canonicalRungs(parts) |
| 424 | if err != nil || canon != s { |
| 425 | return nil, false |
| 426 | } |
| 427 | return parts, true |
| 428 | } |
| 429 | ``` |
| 430 | |
| 431 | - [ ] **Step 8: Add the column and the listing field** in `internal/console/authstore.go`. |
| 432 | |
| 433 | Add to the ALTER list (after the `rotated_at` line, `:216`): |
| 434 | |
| 435 | ```go |
| 436 | `ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT ''`, |
| 437 | ``` |
| 438 | |
| 439 | Add to `CLIToken` (after `RotatedAt`): |
| 440 | |
| 441 | ```go |
| 442 | // Rungs is a deploy credential's scope in its stored, canonical form |
| 443 | // ("canary,edge"); empty for every other kind and role. |
| 444 | Rungs string |
| 445 | ``` |
| 446 | |
| 447 | And the display method, right after the struct: |
| 448 | |
| 449 | ```go |
| 450 | // RungList is Rungs for a person to read: "canary, edge". |
| 451 | func (t CLIToken) RungList() string { return strings.ReplaceAll(t.Rungs, ",", ", ") } |
| 452 | ``` |
| 453 | |
| 454 | 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: |
| 455 | |
| 456 | ```bash |
| 457 | grep -n "scanCLITokenRows(" internal/console/*.go |
| 458 | ``` |
| 459 | |
| 460 | 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. |
| 461 | |
| 462 | - [ ] **Step 9: Add `Rungs` to `Principal`, the scoped mint and the read-back** in `internal/console/svcprincipal.go`. |
| 463 | |
| 464 | In `Principal`, after `Name`: |
| 465 | |
| 466 | ```go |
| 467 | // Rungs is a deploy credential's scope, in ladder order. nil for every |
| 468 | // other role, and never empty for a deploy principal: CLIPrincipal |
| 469 | // refuses a deploy row it cannot read a scope from. |
| 470 | Rungs []string |
| 471 | ``` |
| 472 | |
| 473 | Add the sentinels next to `ErrAccountScopedService`: |
| 474 | |
| 475 | ```go |
| 476 | // ErrAccountScopedDeploy is a deploy credential minted without an app. |
| 477 | var ErrAccountScopedDeploy = errors.New("console: a deploy credential must be bound to one app") |
| 478 | |
| 479 | // ErrDeployNeedsRungs is a deploy credential minted with no rungs. There is |
| 480 | // no default set: the minter says what the credential may move. |
| 481 | var ErrDeployNeedsRungs = errors.New("console: a deploy credential needs at least one rung") |
| 482 | |
| 483 | // ErrRungsNeedDeploy is rungs named for any other role, where they would |
| 484 | // read as a limit and enforce nothing. |
| 485 | var ErrRungsNeedDeploy = errors.New("console: only a deploy credential takes rungs") |
| 486 | ``` |
| 487 | |
| 488 | Replace the body of `NewServicePrincipal` with a delegation, keeping its doc comment, and move the body into the new function: |
| 489 | |
| 490 | ```go |
| 491 | func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (string, error) { |
| 492 | return s.NewServicePrincipalScoped(accountID, name, app, role, nil, mintedBy, now) |
| 493 | } |
| 494 | |
| 495 | // NewServicePrincipalScoped is NewServicePrincipal with a rung scope, which |
| 496 | // only a deploy credential takes. The rules live here rather than in a |
| 497 | // handler for the reason the Service rule does: there are two mint doors. |
| 498 | func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (string, error) { |
| 499 | if name == "" { |
| 500 | return "", errors.New("console: a service credential needs a name") |
| 501 | } |
| 502 | if _, ok := svcrole.Parse(string(role)); !ok { |
| 503 | return "", fmt.Errorf("console: unknown service role %q", role) |
| 504 | } |
| 505 | if role == svcrole.Service && app == "" { |
| 506 | return "", ErrAccountScopedService |
| 507 | } |
| 508 | canon, err := canonicalRungs(rungs) |
| 509 | if err != nil { |
| 510 | return "", err |
| 511 | } |
| 512 | if role == svcrole.Deploy { |
| 513 | if app == "" { |
| 514 | return "", ErrAccountScopedDeploy |
| 515 | } |
| 516 | if canon == "" { |
| 517 | return "", ErrDeployNeedsRungs |
| 518 | } |
| 519 | } else if canon != "" { |
| 520 | return "", ErrRungsNeedDeploy |
| 521 | } |
| 522 | // ... the existing transaction from NewServicePrincipal, unchanged except |
| 523 | // for the INSERT, which gains the rungs column: |
| 524 | ``` |
| 525 | |
| 526 | with the INSERT becoming: |
| 527 | |
| 528 | ```go |
| 529 | if _, err := tx.Exec(`INSERT INTO cli_tokens |
| 530 | (token_hash, address, label, remote_addr, created_at, rotated_at, expires_at, kind, account_id, app, role, name, rungs) |
| 531 | VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?, ?)`, |
| 532 | hashToken(raw), mintedBy, now.Unix(), now.Unix(), cliKindService, accountID, app, string(role), name, canon); err != nil { |
| 533 | return "", err |
| 534 | } |
| 535 | ``` |
| 536 | |
| 537 | Keep the whole existing comment block about `role == svcrole.Service && app == ""` above that check. |
| 538 | |
| 539 | In `CLIPrincipal`, select the column and check it. The query becomes: |
| 540 | |
| 541 | ```go |
| 542 | var storedHash, addr, kind, app, role, name, rungs string |
| 543 | var accountID, expires, revoked int64 |
| 544 | err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs |
| 545 | FROM cli_tokens WHERE token_hash = ?`, hash). |
| 546 | Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs) |
| 547 | ``` |
| 548 | |
| 549 | and the `cliKindService` case becomes: |
| 550 | |
| 551 | ```go |
| 552 | case cliKindService: |
| 553 | r, ok := svcrole.Parse(role) |
| 554 | if !ok { |
| 555 | return Principal{}, false |
| 556 | } |
| 557 | var scope []string |
| 558 | if r == svcrole.Deploy { |
| 559 | // Fail closed: a deploy row with no readable scope, or with no |
| 560 | // app, is not treated as unscoped (spec §5.5). |
| 561 | scope, ok = parseStoredRungs(rungs) |
| 562 | if !ok || app == "" { |
| 563 | return Principal{}, false |
| 564 | } |
| 565 | } else if rungs != "" { |
| 566 | return Principal{}, false |
| 567 | } |
| 568 | p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name, Rungs: scope} |
| 569 | ``` |
| 570 | |
| 571 | - [ ] **Step 10: Run the new tests, then the whole console package.** |
| 572 | |
| 573 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestCanonicalRungs|TestParseStoredRungs|TestMintDeployRules|TestDeployPrincipalReadsBackItsRungs|TestDeployRowWithBadRungsDoesNotResolve' -count=1` |
| 574 | Expected: PASS. |
| 575 | |
| 576 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ ./internal/svcrole/ -count=1` |
| 577 | 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. |
| 578 | |
| 579 | - [ ] **Step 11: Commit.** |
| 580 | |
| 581 | ```bash |
| 582 | gofmt -l internal/ ; git add internal/svcrole internal/console/deployscope.go internal/console/authstore.go internal/console/svcprincipal.go internal/console/deploycred_test.go |
| 583 | git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "console: deploy role and rung-scoped service credentials (#229) |
| 584 | |
| 585 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 586 | ``` |
| 587 | |
| 588 | --- |
| 589 | |
| 590 | ### Task 2: The console's mint doors and listings |
| 591 | |
| 592 | **Files:** |
| 593 | - Modify: `internal/console/apiservices.go` (`serviceView`, `handleAPIServiceCreate`, `handleAPIServiceList`) |
| 594 | - Modify: `internal/console/svcpage.go` (`ServiceSecretView`, `handleServiceCreatePage`, the page fill near `:177`) |
| 595 | - Modify: `internal/console/server.go:1723-1724` (page struct) |
| 596 | - Modify: `internal/console/templates/account_settings.html` (banner, listing, form) |
| 597 | - Create: `internal/console/deploymint_test.go` |
| 598 | |
| 599 | **Interfaces:** |
| 600 | - Consumes: Task 1's `NewServicePrincipalScoped`, sentinels, `UnknownRungError`, `CLIToken.Rungs`, `RungList`. |
| 601 | - Produces: API create accepts `"rungs": ["edge"]` and answers `"rungs": ["canary","edge"]`; listing rows carry `"rungs": [...]` (empty array, never null, for non-deploy rows). |
| 602 | |
| 603 | - [ ] **Step 1: Write the failing tests.** Create `internal/console/deploymint_test.go`: |
| 604 | |
| 605 | ```go |
| 606 | package console |
| 607 | |
| 608 | import ( |
| 609 | "net/http" |
| 610 | "strings" |
| 611 | "testing" |
| 612 | "time" |
| 613 | ) |
| 614 | |
| 615 | func TestAPIMintDeploy(t *testing.T) { |
| 616 | fx := newConsoleFixture(t) |
| 617 | if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| 618 | t.Fatal(err) |
| 619 | } |
| 620 | token := cliToken(t, fx, "paul@keymail.dev", "code-deploy-mint") |
| 621 | u := fx.ts.URL + "/api/cli/services/bab" |
| 622 | status, out := postJSON(t, u, map[string]any{"name": "ci", "role": "deploy", "app": "hello", "rungs": []string{"edge", "canary"}}, token) |
| 623 | if status != http.StatusCreated { |
| 624 | t.Fatalf("mint deploy = %d %v", status, out) |
| 625 | } |
| 626 | if got, _ := out["rungs"].([]any); len(got) != 2 || got[0] != "canary" || got[1] != "edge" { |
| 627 | t.Fatalf("rungs in response = %v", out["rungs"]) |
| 628 | } |
| 629 | for _, tc := range []struct { |
| 630 | body map[string]any |
| 631 | want string // D-text fragment from the approved Copy table |
| 632 | }{ |
| 633 | {map[string]any{"name": "a1", "role": "deploy", "rungs": []string{"edge"}}, "must name one app"}, |
| 634 | {map[string]any{"name": "a2", "role": "deploy", "app": "hello"}, "must name its rungs"}, |
| 635 | {map[string]any{"name": "a3", "role": "deploy", "app": "hello", "rungs": []string{"stable"}}, "stable cannot be delegated"}, |
| 636 | {map[string]any{"name": "a4", "role": "deploy", "app": "hello", "rungs": []string{"prod"}}, `"prod"`}, |
| 637 | {map[string]any{"name": "a5", "role": "publish", "app": "hello", "rungs": []string{"edge"}}, "only a deploy credential"}, |
| 638 | } { |
| 639 | status, out := postJSON(t, u, tc.body, token) |
| 640 | if status != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), tc.want) { |
| 641 | t.Errorf("%v = %d %v; want 400 containing %q", tc.body, status, out, tc.want) |
| 642 | } |
| 643 | } |
| 644 | status, out = getJSON(t, u, token) |
| 645 | if status != http.StatusOK { |
| 646 | t.Fatalf("list = %d %v", status, out) |
| 647 | } |
| 648 | rows, _ := out["services"].([]any) |
| 649 | if len(rows) != 1 { |
| 650 | t.Fatalf("listing = %v", rows) |
| 651 | } |
| 652 | if r, _ := rows[0].(map[string]any)["rungs"].([]any); len(r) != 2 { |
| 653 | t.Fatalf("listing rungs = %v", rows[0]) |
| 654 | } |
| 655 | } |
| 656 | ``` |
| 657 | |
| 658 | 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. |
| 659 | |
| 660 | 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. |
| 661 | |
| 662 | - [ ] **Step 2: Run them and see them fail.** |
| 663 | |
| 664 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestAPIMintDeploy|TestFormMintDeploy' -count=1` |
| 665 | Expected: FAIL (`rungs` missing from the response; refusals answer 500 or mint). |
| 666 | |
| 667 | - [ ] **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: |
| 668 | |
| 669 | ```go |
| 670 | var ue *UnknownRungError |
| 671 | switch { |
| 672 | case errors.Is(err, ErrAccountScopedDeploy): |
| 673 | jsonError(w, http.StatusBadRequest, "<D7>") |
| 674 | return |
| 675 | case errors.Is(err, ErrDeployNeedsRungs): |
| 676 | jsonError(w, http.StatusBadRequest, "<D8>") |
| 677 | return |
| 678 | case errors.Is(err, ErrStableNotDelegable): |
| 679 | jsonError(w, http.StatusBadRequest, "<D9>") |
| 680 | return |
| 681 | case errors.As(err, &ue): |
| 682 | jsonError(w, http.StatusBadRequest, fmt.Sprintf("<D10>", ue.Rung)) |
| 683 | return |
| 684 | case errors.Is(err, ErrRungsNeedDeploy): |
| 685 | jsonError(w, http.StatusBadRequest, "<D11>") |
| 686 | return |
| 687 | } |
| 688 | ``` |
| 689 | |
| 690 | `<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: |
| 691 | |
| 692 | ```go |
| 693 | canon, _ := canonicalRungs(req.Rungs) // already validated by the mint |
| 694 | rungs := []string{} |
| 695 | if canon != "" { |
| 696 | rungs = strings.Split(canon, ",") |
| 697 | } |
| 698 | ``` |
| 699 | |
| 700 | 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. |
| 701 | |
| 702 | In `serviceView` add `Rungs []string \`json:"rungs"\``, and in `handleAPIServiceList` fill it: `[]string{}` when `t.Rungs == ""`, else `strings.Split(t.Rungs, ",")`. |
| 703 | |
| 704 | - [ ] **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>")`: |
| 705 | |
| 706 | ```go |
| 707 | canon, rerr := canonicalRungs(r.Form["rungs"]) |
| 708 | var ue *UnknownRungError |
| 709 | switch { |
| 710 | case errors.Is(rerr, ErrStableNotDelegable): |
| 711 | back("err", "<D14>") |
| 712 | return |
| 713 | case errors.As(rerr, &ue): |
| 714 | back("err", fmt.Sprintf("<D15>", ue.Rung)) |
| 715 | return |
| 716 | case role == svcrole.Deploy && app == "": |
| 717 | back("err", "<D12>") |
| 718 | return |
| 719 | case role == svcrole.Deploy && canon == "": |
| 720 | back("err", "<D13>") |
| 721 | return |
| 722 | case role != svcrole.Deploy && canon != "": |
| 723 | back("err", "<D16>") |
| 724 | return |
| 725 | } |
| 726 | ``` |
| 727 | |
| 728 | `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, ",", ", ")`. |
| 729 | |
| 730 | In `server.go` next to `ServiceRoles`, add: |
| 731 | |
| 732 | ```go |
| 733 | ServiceRungs []string // the mint form's rung choices, deploy only |
| 734 | ``` |
| 735 | |
| 736 | and where `svcpage.go:177` fills `ServiceRoles`, set `p.ServiceRungs = deployRungs`. |
| 737 | |
| 738 | - [ ] **Step 5: Template.** In `templates/account_settings.html`: |
| 739 | - Banner: in the not-rotated branch, render D20 when `.Rungs` is non-empty, else the existing line: |
| 740 | |
| 741 | ```html |
| 742 | {{else}} |
| 743 | {{if .Rungs}}<p><strong>D20</strong></p>{{else}}<p><strong>Minted {{.Name}} — role {{.Role}}, over {{.Scope}}:</strong></p>{{end}} |
| 744 | {{end}} |
| 745 | ``` |
| 746 | |
| 747 | (`D20` replaced by the approved template text.) |
| 748 | - Listing: add a header cell `<th>D19</th>` after `Scope`, and a cell `<td>{{emptyOrDash .RungList}}</td>` after the scope cell. |
| 749 | - Form: after the app `<select>`, add: |
| 750 | |
| 751 | ```html |
| 752 | <span class="small">D18</span> |
| 753 | {{range .ServiceRungs}}<label class="small"><input type="checkbox" name="rungs" value="{{.}}"> {{.}}</label>{{end}} |
| 754 | ``` |
| 755 | |
| 756 | - [ ] **Step 6: Run the tests, then the package.** |
| 757 | |
| 758 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1` |
| 759 | Expected: PASS. |
| 760 | |
| 761 | - [ ] **Step 7: Commit** with message `console: mint and list deploy credentials (#229)`. |
| 762 | |
| 763 | --- |
| 764 | |
| 765 | ### Task 3: `carlos services create --rungs` and `services ls` |
| 766 | |
| 767 | **Files:** |
| 768 | - Modify: `services.go` (`runServicesCreate`, `printServiceCreated`, `runServicesList`) |
| 769 | - Modify: `apimode.go:4285-4330` (`apiServiceView`, `createService`) |
| 770 | - Modify: `services_cli_test.go` |
| 771 | |
| 772 | **Interfaces:** |
| 773 | - Consumes: Task 2's API fields `rungs` (request and response, listing). |
| 774 | - 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`. |
| 775 | |
| 776 | - [ ] **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`. |
| 777 | |
| 778 | - [ ] **Step 2: Run and see them fail.** |
| 779 | |
| 780 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'TestServicesCreate|TestAPISessionServiceCreate' -count=1` |
| 781 | Expected: FAIL to compile (signature). |
| 782 | |
| 783 | - [ ] **Step 3: Implement.** |
| 784 | - `apiServiceView`: add `Rungs []string \`json:"rungs"\``. |
| 785 | - `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. |
| 786 | - `runServicesCreate`: replace the `--role` help with D1 and `usageMsg` with D2. Add: |
| 787 | |
| 788 | ```go |
| 789 | rungsRaw := fs.String("rungs", "", "<D3>") |
| 790 | ``` |
| 791 | |
| 792 | After the role parse: |
| 793 | |
| 794 | ```go |
| 795 | var rungsV []string |
| 796 | for _, r := range strings.Split(*rungsRaw, ",") { |
| 797 | if r = strings.TrimSpace(r); r != "" { |
| 798 | rungsV = append(rungsV, r) |
| 799 | } |
| 800 | } |
| 801 | if len(rungsV) > 0 && roleV != string(svcrole.Deploy) { |
| 802 | log.Fatal("<D4>") |
| 803 | } |
| 804 | ``` |
| 805 | |
| 806 | Pass `rungsV` to `createService` and the returned rungs to `printServiceCreated`. |
| 807 | - `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. |
| 808 | - `runServicesList`: header gains `\tD6` after `APP`, and each row prints `strings.Join(r.Rungs, ",")` or `-` when empty. |
| 809 | - Update every other caller of the two changed functions: `grep -n 'createService(\|printServiceCreated(' *.go` and pass `nil` / ignore the extra return. |
| 810 | |
| 811 | - [ ] **Step 4: Run.** |
| 812 | |
| 813 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'Service' -count=1 && go vet .` |
| 814 | Expected: PASS. |
| 815 | |
| 816 | - [ ] **Step 5: Commit** with message `cli: services create --rungs and a RUNGS column (#229)`. |
| 817 | |
| 818 | --- |
| 819 | |
| 820 | ### Task 4: The doors a deploy credential reaches |
| 821 | |
| 822 | **Files:** |
| 823 | - Modify: `internal/console/authcli.go:476-544` (route registrations) |
| 824 | - Create: `internal/console/deploydoors_test.go` |
| 825 | |
| 826 | **Interfaces:** |
| 827 | - Consumes: `svcrole.Deploy`; Task 1's `NewServicePrincipalScoped`. |
| 828 | - Produces: `deployDoors` (test-only) — the exact §6 list. |
| 829 | |
| 830 | - [ ] **Step 1: Write the failing tests.** Create `internal/console/deploydoors_test.go`: |
| 831 | |
| 832 | ```go |
| 833 | package console |
| 834 | |
| 835 | import ( |
| 836 | "net/http" |
| 837 | "reflect" |
| 838 | "sort" |
| 839 | "testing" |
| 840 | "time" |
| 841 | |
| 842 | "github.com/carlosframework/platform/internal/svcrole" |
| 843 | ) |
| 844 | |
| 845 | // deployDoors is spec §6, verbatim. A door added later is refused to a |
| 846 | // deploy credential unless someone adds it here on purpose. |
| 847 | var deployDoors = []string{ |
| 848 | "GET /api/cli/apps", |
| 849 | "GET /api/cli/apps/{account}/{app}/channels", |
| 850 | "POST /api/cli/apps/{account}/{app}/releases", |
| 851 | "GET /api/cli/apps/{account}/{app}/releases", |
| 852 | "POST /api/cli/apps/{account}/{app}/promote", |
| 853 | "POST /api/cli/apps/{account}/{app}/rollback", |
| 854 | "GET /api/cli/apps/{account}/{app}/tags", |
| 855 | "GET /api/cli/apps/{account}/{app}/tags/{version}", |
| 856 | "GET /api/cli/apps/{account}/{app}/version-target", |
| 857 | "GET /api/cli/apps/{account}/{app}/pipeline", |
| 858 | "GET /api/cli/apps/{account}/{app}/pending", |
| 859 | "GET /api/cli/apps/{account}/{app}/instances", // R6: carlos deploy |
| 860 | "GET /api/cli/apps/{account}/{app}/regions", // R6: carlos deploy --region |
| 861 | } |
| 862 | |
| 863 | func TestDeployReachesExactlyItsDoors(t *testing.T) { |
| 864 | s := newConsoleFixture(t).srv |
| 865 | var got []string |
| 866 | for pattern, set := range s.cliRoles { |
| 867 | if set.Permits(svcrole.Deploy) { |
| 868 | got = append(got, pattern) |
| 869 | } |
| 870 | } |
| 871 | sort.Strings(got) |
| 872 | want := append([]string(nil), deployDoors...) |
| 873 | sort.Strings(want) |
| 874 | if !reflect.DeepEqual(got, want) { |
| 875 | t.Fatalf("deploy reaches:\n got %q\nwant %q", got, want) |
| 876 | } |
| 877 | } |
| 878 | |
| 879 | // Review Focus 2: the app binding holds for deploy like any service. |
| 880 | func TestDeployIsBoundToItsApp(t *testing.T) { |
| 881 | fx := newConsoleFixture(t) |
| 882 | for _, app := range []string{"hello", "other"} { |
| 883 | if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil { |
| 884 | t.Fatal(err) |
| 885 | } |
| 886 | } |
| 887 | tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now()) |
| 888 | if err != nil { |
| 889 | t.Fatal(err) |
| 890 | } |
| 891 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/other/promote", |
| 892 | map[string]string{"version": "v1", "channel": "edge"}, tok) |
| 893 | if status != http.StatusNotFound { |
| 894 | t.Fatalf("deploy on a sibling app = %d %v, want 404", status, out) |
| 895 | } |
| 896 | // And a door outside §6 is the role gate's 403, not a 404. |
| 897 | status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/delete", map[string]string{}, tok) |
| 898 | if status != http.StatusForbidden { |
| 899 | t.Fatalf("deploy on trash = %d %v, want 403", status, out) |
| 900 | } |
| 901 | } |
| 902 | ``` |
| 903 | |
| 904 | - [ ] **Step 2: Run and see the first fail.** |
| 905 | |
| 906 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeployReachesExactlyItsDoors|TestDeployIsBoundToItsApp' -count=1` |
| 907 | Expected: FAIL, `deploy reaches: got []`. |
| 908 | |
| 909 | - [ ] **Step 3: Register the doors.** In `internal/console/authcli.go`, change exactly these registrations: |
| 910 | |
| 911 | ```go |
| 912 | s.cliRoute("GET /api/cli/apps", svcrole.Allow(svcrole.Publish, svcrole.Deploy, svcrole.Instance, svcrole.Operate), s.handleAPIApps) |
| 913 | s.cliRoute("GET /api/cli/apps/{account}/{app}/channels", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIChannels) |
| 914 | s.cliRoute("POST /api/cli/apps/{account}/{app}/promote", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPromote) |
| 915 | s.cliRoute("POST /api/cli/apps/{account}/{app}/rollback", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIRollback) |
| 916 | s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIShip) |
| 917 | s.cliRoute("GET /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIVersionTargetGet) |
| 918 | s.cliRoute("GET /api/cli/apps/{account}/{app}/tags", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagsList) |
| 919 | s.cliRoute("GET /api/cli/apps/{account}/{app}/tags/{version}", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagGet) |
| 920 | s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIReleasesList) |
| 921 | s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPipelineGet) |
| 922 | s.cliRoute("GET /api/cli/apps/{account}/{app}/pending", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPending) |
| 923 | s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIInstanceList) |
| 924 | s.cliRoute("GET /api/cli/apps/{account}/{app}/regions", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIRegions) |
| 925 | ``` |
| 926 | |
| 927 | The last two are R6. Both handlers go through `instanceGate`, which has no role logic of its own, so adding the role to the set is the whole change. If a test pins the exact role set of either door (search `grep -n '/instances"\|/regions"' internal/console/*_test.go`), add `deploy` to its expectation. Change nothing else in the table. |
| 928 | |
| 929 | - [ ] **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. |
| 930 | |
| 931 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1` |
| 932 | Expected: PASS, including `TestEveryCLIDoorNamesItsRoles` and `TestNoRoleReachesAMintFleetOrPolicyDoor`. |
| 933 | |
| 934 | - [ ] **Step 5: Commit** with message `console: the doors a deploy credential reaches (#229)`. |
| 935 | |
| 936 | --- |
| 937 | |
| 938 | ### Task 5: The scope function and the handlers' early refusal |
| 939 | |
| 940 | **Files:** |
| 941 | - Modify: `internal/console/deployscope.go` |
| 942 | - Modify: `internal/console/apicli.go` (`handleAPIPromote` after the pipeline read near `:772-777`; `handleAPIRollback` after its pipeline read) |
| 943 | - Create: `internal/console/deployscope_test.go` |
| 944 | |
| 945 | **Interfaces:** |
| 946 | - Consumes: `Principal.Rungs`, `release.Rung`, `release.RungCanary`, `release.RungStable`. |
| 947 | - Produces: |
| 948 | - `func deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error` — nil for any non-deploy principal. |
| 949 | - `type deployRefusal struct{ msg string }` implementing `error`. |
| 950 | - `func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error)` — 403 `{"error": <message>}`. |
| 951 | - `func deployRefusalFor(app string, err error) error` — fills the app name for D24 (see Step 3). |
| 952 | |
| 953 | - [ ] **Step 1: Write the failing table test.** Create `internal/console/deployscope_test.go`: |
| 954 | |
| 955 | ```go |
| 956 | package console |
| 957 | |
| 958 | import ( |
| 959 | "testing" |
| 960 | |
| 961 | "github.com/carlosframework/platform/internal/svcrole" |
| 962 | ) |
| 963 | |
| 964 | func TestDeployScopeAllows(t *testing.T) { |
| 965 | ce := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"canary", "edge"}} |
| 966 | beta := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"beta"}} |
| 967 | pub := Principal{Kind: PrincipalService, Role: svcrole.Publish, Name: "ci", App: "hello"} |
| 968 | person := Principal{Kind: PrincipalUser, Address: "paul@keymail.dev"} |
| 969 | for _, tc := range []struct { |
| 970 | name string |
| 971 | p Principal |
| 972 | pipelined bool |
| 973 | channel string |
| 974 | hotfix bool |
| 975 | ok bool |
| 976 | }{ |
| 977 | {"edge in scope", ce, false, "edge", false, true}, |
| 978 | {"canary branch in scope", ce, false, "canary/feature-x", false, true}, |
| 979 | {"bare canary refused", ce, false, "canary", false, false}, |
| 980 | {"bare canary refused on a pipelined app", ce, true, "canary", false, false}, |
| 981 | {"beta outside rungs", ce, false, "beta", false, false}, |
| 982 | {"stable refused", ce, false, "stable", false, false}, |
| 983 | {"stable refused even when granted beta", beta, false, "stable", false, false}, |
| 984 | {"not a rung", ce, false, "staging", false, false}, |
| 985 | {"hotfix refused", ce, false, "edge", true, false}, |
| 986 | {"pipelined: edge refused", ce, true, "edge", false, false}, |
| 987 | {"pipelined: canary branch allowed", ce, true, "canary/x", false, true}, |
| 988 | {"beta credential on beta", beta, false, "beta", false, true}, |
| 989 | {"publish untouched", pub, true, "stable", true, true}, |
| 990 | {"person untouched", person, true, "stable", true, true}, |
| 991 | } { |
| 992 | err := deployScopeAllows(tc.p, tc.pipelined, tc.channel, tc.hotfix) |
| 993 | if (err == nil) != tc.ok { |
| 994 | t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok) |
| 995 | } |
| 996 | } |
| 997 | } |
| 998 | ``` |
| 999 | |
| 1000 | 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): |
| 1001 | - `{"version":"v1","channel":"beta"}` (D22) |
| 1002 | - `{"version":"v1","channel":"stable"}` (D22) |
| 1003 | - `{"version":"v1","channel":"canary"}` (D23) |
| 1004 | - `{"version":"v1","channel":"edge","hotfix":true}` (D21) |
| 1005 | - 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. |
| 1006 | - rollback: `{"channel":"beta"}` gives 403 D22. |
| 1007 | |
| 1008 | - [ ] **Step 2: Run and see it fail.** |
| 1009 | |
| 1010 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeployScope' -count=1` |
| 1011 | Expected: FAIL to compile, `undefined: deployScopeAllows`. |
| 1012 | |
| 1013 | - [ ] **Step 3: Implement the function.** Append to `internal/console/deployscope.go` (add imports `fmt`, `net/http`, `svcrole`): |
| 1014 | |
| 1015 | ```go |
| 1016 | // deployRefusal is a promote or rollback a deploy credential may not make. |
| 1017 | // Its text is the 403's error, so a CI log says what to fix (spec §7.3). |
| 1018 | type deployRefusal struct{ msg string } |
| 1019 | |
| 1020 | func (e *deployRefusal) Error() string { return e.msg } |
| 1021 | |
| 1022 | // deployScopeAllows is the whole scope decision for a deploy credential |
| 1023 | // (spec §7.1). It is pure so it can be table-tested, and it is called three |
| 1024 | // times per write: early in the handler for the message, and by the release |
| 1025 | // primitives on their own pipeline reads (Task 7). For every principal that |
| 1026 | // is not a deploy credential it returns nil and changes nothing. |
| 1027 | func deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error { |
| 1028 | if p.Kind != PrincipalService || p.Role != svcrole.Deploy { |
| 1029 | return nil |
| 1030 | } |
| 1031 | held := deployHeldWords(p.Rungs) |
| 1032 | if hotfix { |
| 1033 | return &deployRefusal{fmt.Sprintf("<D21>", p.Name)} |
| 1034 | } |
| 1035 | rung, ok := release.Rung(channel) |
| 1036 | if !ok || rung == release.RungStable { |
| 1037 | return &deployRefusal{fmt.Sprintf("<D22>", p.Name, held, channel)} |
| 1038 | } |
| 1039 | if rung == release.RungCanary && !strings.HasPrefix(channel, release.RungCanary+"/") { |
| 1040 | return &deployRefusal{fmt.Sprintf("<D23>", p.Name)} |
| 1041 | } |
| 1042 | inScope := false |
| 1043 | for _, r := range p.Rungs { |
| 1044 | if r == rung { |
| 1045 | inScope = true |
| 1046 | } |
| 1047 | } |
| 1048 | if !inScope { |
| 1049 | return &deployRefusal{fmt.Sprintf("<D22>", p.Name, held, channel)} |
| 1050 | } |
| 1051 | if pipelined && rung != release.RungCanary { |
| 1052 | return &deployRefusal{fmt.Sprintf("<D24>", p.App, p.Name)} |
| 1053 | } |
| 1054 | return nil |
| 1055 | } |
| 1056 | |
| 1057 | // deployHeldWords is the rung list as the refusal names it: canary shown as |
| 1058 | // the channels it covers. |
| 1059 | func deployHeldWords(rungs []string) string { |
| 1060 | words := make([]string, len(rungs)) |
| 1061 | for i, r := range rungs { |
| 1062 | words[i] = r |
| 1063 | if r == release.RungCanary { |
| 1064 | words[i] = release.RungCanary + "/<name>" |
| 1065 | } |
| 1066 | } |
| 1067 | return strings.Join(words, ", ") |
| 1068 | } |
| 1069 | |
| 1070 | // writeDeployRefusal answers a scope refusal. Anything that is not a |
| 1071 | // deployRefusal is a programming error and answers 500. |
| 1072 | func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error) { |
| 1073 | var dr *deployRefusal |
| 1074 | if errors.As(err, &dr) { |
| 1075 | jsonError(w, http.StatusForbidden, dr.msg) |
| 1076 | return |
| 1077 | } |
| 1078 | jsonError(w, http.StatusInternalServerError, "internal error") |
| 1079 | } |
| 1080 | ``` |
| 1081 | |
| 1082 | `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. |
| 1083 | |
| 1084 | - [ ] **Step 4: Call it early in both handlers.** In `handleAPIPromote`, immediately after the `GetPipeline` error block (the one ending `return` near `:777`): |
| 1085 | |
| 1086 | ```go |
| 1087 | if err := deployScopeAllows(p, pipe != nil, channel, req.Hotfix); err != nil { |
| 1088 | s.writeDeployRefusal(w, err) |
| 1089 | return |
| 1090 | } |
| 1091 | ``` |
| 1092 | |
| 1093 | In `handleAPIRollback`, immediately after its `GetPipeline` error block: |
| 1094 | |
| 1095 | ```go |
| 1096 | if err := deployScopeAllows(p, pipe != nil, channel, false); err != nil { |
| 1097 | s.writeDeployRefusal(w, err) |
| 1098 | return |
| 1099 | } |
| 1100 | ``` |
| 1101 | |
| 1102 | `p` is already in scope in both handlers (`principalFrom`). |
| 1103 | |
| 1104 | - [ ] **Step 5: Run.** |
| 1105 | |
| 1106 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1` |
| 1107 | Expected: PASS. |
| 1108 | |
| 1109 | - [ ] **Step 6: Commit** with message `console: deploy scope check at promote and rollback (#229)`. |
| 1110 | |
| 1111 | --- |
| 1112 | |
| 1113 | ### Task 6: The Authorize hook in the release primitives |
| 1114 | |
| 1115 | **Files:** |
| 1116 | - Create: `internal/release/authorize.go` |
| 1117 | - Modify: `internal/release/release.go` (`Signer.Promote` `:694`, `PromoteOptions` `:765`, `Signer.PromoteOnLadder` `:849`, `Signer.Rollback` `:1031`) |
| 1118 | - Create: `internal/release/authorize_test.go` |
| 1119 | |
| 1120 | **Interfaces:** |
| 1121 | - Produces: |
| 1122 | - `PromoteOptions.Authorize func(p *Pipeline) error` |
| 1123 | - `type AuthorizationError struct{ Err error }` with `Error()` and `Unwrap()` |
| 1124 | - `func (sg Signer) RollbackAuthorized(ctx context.Context, s Store, acct, app, channel string, authorize func(*Pipeline) error) error` |
| 1125 | - unexported `func (sg Signer) promote(ctx context.Context, s Store, acct, app, channel, version string, authorize func(*Pipeline) error, rec ...Promotion) error` |
| 1126 | |
| 1127 | - [ ] **Step 1: Write the test store and the failing tests.** Create `internal/release/authorize_test.go`: |
| 1128 | |
| 1129 | ```go |
| 1130 | package release |
| 1131 | |
| 1132 | import ( |
| 1133 | "context" |
| 1134 | "crypto/ed25519" |
| 1135 | "errors" |
| 1136 | "os" |
| 1137 | "testing" |
| 1138 | ) |
| 1139 | |
| 1140 | // storeCall is one call a request made through hookStore. |
| 1141 | type storeCall struct { |
| 1142 | seq int |
| 1143 | op, key string |
| 1144 | ifMatch string // put only |
| 1145 | } |
| 1146 | |
| 1147 | // hookStore runs before(call) ahead of every call it forwards, and after() |
| 1148 | // once a get returns, so a test can make another actor's writes land at an |
| 1149 | // exact point inside a request. The other actor writes to inner directly: |
| 1150 | // the same handle, which is where G2 holds for a directory store (spec |
| 1151 | // §7.2). seq counts only the calls made through hookStore. |
| 1152 | type hookStore struct { |
| 1153 | inner Store |
| 1154 | before func(c storeCall) |
| 1155 | after func(c storeCall, body []byte, etag string, err error) |
| 1156 | seq int |
| 1157 | } |
| 1158 | |
| 1159 | func (h *hookStore) call(op, key, ifMatch string) storeCall { |
| 1160 | h.seq++ |
| 1161 | c := storeCall{seq: h.seq, op: op, key: key, ifMatch: ifMatch} |
| 1162 | if h.before != nil { |
| 1163 | h.before(c) |
| 1164 | } |
| 1165 | return c |
| 1166 | } |
| 1167 | |
| 1168 | func (h *hookStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 1169 | c := h.call("get", key, "") |
| 1170 | body, etag, err := h.inner.Get(ctx, key) |
| 1171 | if h.after != nil { |
| 1172 | h.after(c, body, etag, err) |
| 1173 | } |
| 1174 | return body, etag, err |
| 1175 | } |
| 1176 | |
| 1177 | func (h *hookStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 1178 | h.call("put", key, ifMatch) |
| 1179 | return h.inner.Put(ctx, key, body, ifMatch) |
| 1180 | } |
| 1181 | |
| 1182 | func (h *hookStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 1183 | h.call("list", prefix, "") |
| 1184 | return h.inner.List(ctx, prefix) |
| 1185 | } |
| 1186 | |
| 1187 | func (h *hookStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| 1188 | h.call("listdirs", prefix, "") |
| 1189 | return h.inner.ListDirs(ctx, prefix) |
| 1190 | } |
| 1191 | |
| 1192 | func (h *hookStore) Delete(ctx context.Context, key string) error { |
| 1193 | h.call("delete", key, "") |
| 1194 | return h.inner.Delete(ctx, key) |
| 1195 | } |
| 1196 | |
| 1197 | // onNth runs fn once, just before the nth (1-based) call matching op and key. |
| 1198 | func onNth(op, key string, n int, fn func()) func(storeCall) { |
| 1199 | seen := 0 |
| 1200 | return func(c storeCall) { |
| 1201 | if c.op == op && c.key == key { |
| 1202 | seen++ |
| 1203 | if seen == n { |
| 1204 | fn() |
| 1205 | } |
| 1206 | } |
| 1207 | } |
| 1208 | } |
| 1209 | |
| 1210 | // noPipelineOnly is R2's rule for a non-canary channel: refuse if the app |
| 1211 | // has a pipeline. |
| 1212 | func noPipelineOnly(p *Pipeline) error { |
| 1213 | if p != nil { |
| 1214 | return errors.New("pipelined") |
| 1215 | } |
| 1216 | return nil |
| 1217 | } |
| 1218 | |
| 1219 | var edgeKey = ChannelKey("bab", "myapp", "edge") |
| 1220 | |
| 1221 | func setupEdge(t *testing.T) (context.Context, Store, ed25519.PrivateKey, ed25519.PublicKey) { |
| 1222 | t.Helper() |
| 1223 | ctx := context.Background() |
| 1224 | inner := NewDirStore(t.TempDir()) |
| 1225 | priv, pub := testKeys(t) |
| 1226 | for _, v := range []string{"v1", "v2", "v3", "v4"} { |
| 1227 | shipRelease(t, inner, "bab", "myapp", v, "linux-arm64", []byte("bin "+v)) |
| 1228 | } |
| 1229 | return ctx, inner, priv, pub |
| 1230 | } |
| 1231 | |
| 1232 | func installPipeline(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, names ...string) { |
| 1233 | t.Helper() |
| 1234 | var chans []PipelineChannel |
| 1235 | for _, n := range names { |
| 1236 | chans = append(chans, PipelineChannel{Name: n}) |
| 1237 | } |
| 1238 | if _, err := SetPipeline(ctx, s, priv, "bab", "myapp", chans, "owner"); err != nil { |
| 1239 | t.Fatalf("SetPipeline(%v): %v", names, err) |
| 1240 | } |
| 1241 | } |
| 1242 | |
| 1243 | func ownerPromote(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, v string) { |
| 1244 | t.Helper() |
| 1245 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}); err != nil { |
| 1246 | t.Fatalf("owner promote %s: %v", v, err) |
| 1247 | } |
| 1248 | } |
| 1249 | |
| 1250 | func edgeVersion(t *testing.T, ctx context.Context, s Store, pub ed25519.PublicKey) string { |
| 1251 | t.Helper() |
| 1252 | p, err := Channel(ctx, s, pub, "bab", "myapp", "edge") |
| 1253 | if errors.Is(err, os.ErrNotExist) { |
| 1254 | return "" |
| 1255 | } |
| 1256 | if err != nil { |
| 1257 | t.Fatal(err) |
| 1258 | } |
| 1259 | return p.Version |
| 1260 | } |
| 1261 | |
| 1262 | func wantAuthorizationError(t *testing.T, err error) { |
| 1263 | t.Helper() |
| 1264 | var ae *AuthorizationError |
| 1265 | if !errors.As(err, &ae) { |
| 1266 | t.Fatalf("err = %v; want *AuthorizationError", err) |
| 1267 | } |
| 1268 | } |
| 1269 | |
| 1270 | func TestAuthorizeSeesAPipelineInstalledBeforeTheRequest(t *testing.T) { |
| 1271 | ctx, inner, priv, pub := setupEdge(t) |
| 1272 | installPipeline(t, ctx, inner, priv, "edge", "stable") |
| 1273 | _, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1", |
| 1274 | PromoteOptions{Authorize: noPipelineOnly}) |
| 1275 | wantAuthorizationError(t, err) |
| 1276 | if got := edgeVersion(t, ctx, inner, pub); got != "" { |
| 1277 | t.Fatalf("edge = %q after a refusal", got) |
| 1278 | } |
| 1279 | } |
| 1280 | |
| 1281 | // The refusal is the hook's, not the ladder's order error, even when the |
| 1282 | // pipeline would make the ladder refuse too (spec §7.2 point 1, codex R3-3). |
| 1283 | func TestAuthorizeRefusesBeforeTheLadderDoes(t *testing.T) { |
| 1284 | ctx, inner, priv, _ := setupEdge(t) |
| 1285 | h := &hookStore{inner: inner} |
| 1286 | h.before = onNth("get", PipelineKey("bab", "myapp"), 1, func() { |
| 1287 | installPipeline(t, ctx, inner, priv, "preview") |
| 1288 | }) |
| 1289 | _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1", |
| 1290 | PromoteOptions{Authorize: noPipelineOnly}) |
| 1291 | wantAuthorizationError(t, err) |
| 1292 | } |
| 1293 | |
| 1294 | // The ladder's read saw no pipeline; one installed before the authoritative |
| 1295 | // read (after the pointer read) is still seen. |
| 1296 | func TestAuthorizeSeesAPipelineInstalledBeforeTheAuthoritativeRead(t *testing.T) { |
| 1297 | ctx, inner, priv, pub := setupEdge(t) |
| 1298 | h := &hookStore{inner: inner} |
| 1299 | h.before = onNth("get", PipelineKey("bab", "myapp"), 2, func() { |
| 1300 | installPipeline(t, ctx, inner, priv, "edge", "stable") |
| 1301 | }) |
| 1302 | _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1", |
| 1303 | PromoteOptions{Authorize: noPipelineOnly}) |
| 1304 | wantAuthorizationError(t, err) |
| 1305 | if got := edgeVersion(t, ctx, inner, pub); got != "" { |
| 1306 | t.Fatalf("edge = %q after a refusal", got) |
| 1307 | } |
| 1308 | } |
| 1309 | |
| 1310 | // Astra's round 2 counterexample: an owner promotes under a new pipeline |
| 1311 | // after the deploy request read the pointer. The deploy write must lose. |
| 1312 | func TestOwnerPromoteAfterInstallDefeatsTheDeployWrite(t *testing.T) { |
| 1313 | ctx, inner, priv, pub := setupEdge(t) |
| 1314 | ownerPromote(t, ctx, inner, priv, "v1") |
| 1315 | h := &hookStore{inner: inner} |
| 1316 | h.before = onNth("put", edgeKey, 1, func() { |
| 1317 | installPipeline(t, ctx, inner, priv, "edge", "stable") |
| 1318 | ownerPromote(t, ctx, inner, priv, "v3") |
| 1319 | }) |
| 1320 | _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v2", |
| 1321 | PromoteOptions{Authorize: noPipelineOnly}) |
| 1322 | if !errors.Is(err, ErrCASConflict) { |
| 1323 | t.Fatalf("err = %v; want ErrCASConflict", err) |
| 1324 | } |
| 1325 | if got := edgeVersion(t, ctx, inner, pub); got != "v3" { |
| 1326 | t.Fatalf("edge = %q; want the owner's v3", got) |
| 1327 | } |
| 1328 | } |
| 1329 | |
| 1330 | // R5, accepted: a pipeline installed after the authorization read, and the |
| 1331 | // pointer written and restored to identical bytes, before the write. G2 |
| 1332 | // holds (the bytes are what was read), so the write lands. |
| 1333 | func TestRestoredBytesLandAsTheAcceptedInFlightCase(t *testing.T) { |
| 1334 | ctx, inner, priv, pub := setupEdge(t) |
| 1335 | ownerPromote(t, ctx, inner, priv, "v1") |
| 1336 | ownerPromote(t, ctx, inner, priv, "v2") |
| 1337 | h := &hookStore{inner: inner} |
| 1338 | h.before = onNth("put", edgeKey, 1, func() { |
| 1339 | orig, _, err := inner.Get(ctx, edgeKey) |
| 1340 | if err != nil { |
| 1341 | t.Fatal(err) |
| 1342 | } |
| 1343 | installPipeline(t, ctx, inner, priv, "edge", "stable") |
| 1344 | ownerPromote(t, ctx, inner, priv, "v4") |
| 1345 | _, cur, err := inner.Get(ctx, edgeKey) |
| 1346 | if err != nil { |
| 1347 | t.Fatal(err) |
| 1348 | } |
| 1349 | if _, err := inner.Put(ctx, edgeKey, orig, cur); err != nil { |
| 1350 | t.Fatal(err) |
| 1351 | } |
| 1352 | }) |
| 1353 | if _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v3", |
| 1354 | PromoteOptions{Authorize: noPipelineOnly}); err != nil { |
| 1355 | t.Fatalf("err = %v; R5 accepts this write (spec §16)", err) |
| 1356 | } |
| 1357 | if got := edgeVersion(t, ctx, inner, pub); got != "v3" { |
| 1358 | t.Fatalf("edge = %q; want v3", got) |
| 1359 | } |
| 1360 | } |
| 1361 | |
| 1362 | // R5, accepted: a first promote onto an empty channel, with a pipeline |
| 1363 | // installed after the authorization read. |
| 1364 | func TestFirstPromoteOntoEmptyChannelLandsAsTheAcceptedInFlightCase(t *testing.T) { |
| 1365 | ctx, inner, priv, pub := setupEdge(t) |
| 1366 | h := &hookStore{inner: inner} |
| 1367 | h.before = onNth("put", edgeKey, 1, func() { |
| 1368 | installPipeline(t, ctx, inner, priv, "edge", "stable") |
| 1369 | }) |
| 1370 | if _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1", |
| 1371 | PromoteOptions{Authorize: noPipelineOnly}); err != nil { |
| 1372 | t.Fatalf("err = %v; R5 accepts this write (spec §16)", err) |
| 1373 | } |
| 1374 | if got := edgeVersion(t, ctx, inner, pub); got != "v1" { |
| 1375 | t.Fatalf("edge = %q; want v1", got) |
| 1376 | } |
| 1377 | } |
| 1378 | |
| 1379 | func TestRepeatPromoteRunsTheHook(t *testing.T) { |
| 1380 | ctx, inner, priv, _ := setupEdge(t) |
| 1381 | ownerPromote(t, ctx, inner, priv, "v1") |
| 1382 | installPipeline(t, ctx, inner, priv, "edge", "stable") |
| 1383 | res, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1", |
| 1384 | PromoteOptions{Authorize: noPipelineOnly}) |
| 1385 | wantAuthorizationError(t, err) |
| 1386 | if res.NoOp { |
| 1387 | t.Fatal("a refused repeat reported NoOp") |
| 1388 | } |
| 1389 | } |
| 1390 | |
| 1391 | func TestRepeatPromoteWithNoPipelineIsANoOp(t *testing.T) { |
| 1392 | ctx, inner, priv, _ := setupEdge(t) |
| 1393 | ownerPromote(t, ctx, inner, priv, "v1") |
| 1394 | var seen []bool |
| 1395 | hook := func(p *Pipeline) error { seen = append(seen, p != nil); return noPipelineOnly(p) } |
| 1396 | res, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1", |
| 1397 | PromoteOptions{Authorize: hook}) |
| 1398 | if err != nil || !res.NoOp { |
| 1399 | t.Fatalf("= %+v, %v; want a no-op", res, err) |
| 1400 | } |
| 1401 | if len(seen) != 1 || seen[0] { |
| 1402 | t.Fatalf("hook calls = %v; want one, with no pipeline", seen) |
| 1403 | } |
| 1404 | } |
| 1405 | |
| 1406 | // A caller with no hook makes no extra pipeline reads (spec §7.2). |
| 1407 | func TestNoHookNoExtraReads(t *testing.T) { |
| 1408 | ctx, inner, priv, _ := setupEdge(t) |
| 1409 | count := func(run func(Store) error) int { |
| 1410 | n := 0 |
| 1411 | h := &hookStore{inner: inner, before: func(c storeCall) { |
| 1412 | if c.op == "get" && c.key == PipelineKey("bab", "myapp") { |
| 1413 | n++ |
| 1414 | } |
| 1415 | }} |
| 1416 | if err := run(h); err != nil { |
| 1417 | t.Fatal(err) |
| 1418 | } |
| 1419 | return n |
| 1420 | } |
| 1421 | fresh := count(func(s Store) error { |
| 1422 | _, err := RootSigner(priv).PromoteOnLadder(ctx, s, "bab", "myapp", "edge", "v1", PromoteOptions{}) |
| 1423 | return err |
| 1424 | }) |
| 1425 | repeat := count(func(s Store) error { |
| 1426 | _, err := RootSigner(priv).PromoteOnLadder(ctx, s, "bab", "myapp", "edge", "v1", PromoteOptions{}) |
| 1427 | return err |
| 1428 | }) |
| 1429 | ownerPromote(t, ctx, inner, priv, "v2") |
| 1430 | rollback := count(func(s Store) error { return RootSigner(priv).Rollback(ctx, s, "bab", "myapp", "edge") }) |
| 1431 | if fresh != 1 || repeat != 0 || rollback != 0 { |
| 1432 | t.Fatalf("pipeline reads: fresh %d, repeat %d, rollback %d; want 1, 0, 0", fresh, repeat, rollback) |
| 1433 | } |
| 1434 | } |
| 1435 | |
| 1436 | func TestRollbackAuthorized(t *testing.T) { |
| 1437 | ctx, inner, priv, pub := setupEdge(t) |
| 1438 | ownerPromote(t, ctx, inner, priv, "v1") |
| 1439 | ownerPromote(t, ctx, inner, priv, "v2") |
| 1440 | installPipeline(t, ctx, inner, priv, "edge", "stable") |
| 1441 | err := RootSigner(priv).RollbackAuthorized(ctx, inner, "bab", "myapp", "edge", noPipelineOnly) |
| 1442 | wantAuthorizationError(t, err) |
| 1443 | if got := edgeVersion(t, ctx, inner, pub); got != "v2" { |
| 1444 | t.Fatalf("edge = %q after a refused rollback", got) |
| 1445 | } |
| 1446 | if err := inner.Delete(ctx, PipelineKey("bab", "myapp")); err != nil { |
| 1447 | t.Fatal(err) |
| 1448 | } |
| 1449 | h := &hookStore{inner: inner} |
| 1450 | h.before = onNth("put", edgeKey, 1, func() { ownerPromote(t, ctx, inner, priv, "v3") }) |
| 1451 | err = RootSigner(priv).RollbackAuthorized(ctx, h, "bab", "myapp", "edge", noPipelineOnly) |
| 1452 | if !errors.Is(err, ErrCASConflict) { |
| 1453 | t.Fatalf("err = %v; want ErrCASConflict", err) |
| 1454 | } |
| 1455 | if got := edgeVersion(t, ctx, inner, pub); got != "v3" { |
| 1456 | t.Fatalf("edge = %q; want the owner's v3", got) |
| 1457 | } |
| 1458 | } |
| 1459 | ``` |
| 1460 | |
| 1461 | 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". |
| 1462 | |
| 1463 | - [ ] **Step 2: Run and see them fail.** |
| 1464 | |
| 1465 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/release/ -run 'TestAuthorize|TestOwnerPromote|TestRestoredBytes|TestFirstPromote|TestRepeatPromote|TestNoHook|TestRollbackAuthorized' -count=1` |
| 1466 | Expected: FAIL to compile (`Authorize`, `AuthorizationError`, `RollbackAuthorized` undefined). |
| 1467 | |
| 1468 | - [ ] **Step 3: Create `internal/release/authorize.go`:** |
| 1469 | |
| 1470 | ```go |
| 1471 | package release |
| 1472 | |
| 1473 | // AuthorizationError is a refusal from a caller's Authorize hook. The |
| 1474 | // primitives wrap the hook's own error in it so a caller can tell "you may |
| 1475 | // not" from a store or ladder failure with errors.As. |
| 1476 | // |
| 1477 | // The hook runs on a pipeline the primitive has just read (spec |
| 1478 | // 2026-10-05-deploy-credential-design.md §7.2): on the ladder's read, on the |
| 1479 | // repeat path, and between the pointer read and the conditional write. A |
| 1480 | // caller that passes no hook gets no extra reads. |
| 1481 | type AuthorizationError struct{ Err error } |
| 1482 | |
| 1483 | func (e *AuthorizationError) Error() string { return e.Err.Error() } |
| 1484 | func (e *AuthorizationError) Unwrap() error { return e.Err } |
| 1485 | ``` |
| 1486 | |
| 1487 | - [ ] **Step 4: Thread the hook through `release.go`.** |
| 1488 | |
| 1489 | `PromoteOptions` gains: |
| 1490 | |
| 1491 | ```go |
| 1492 | // Authorize, when set, is called with the app's pipeline as read at each |
| 1493 | // point a write could depend on it: the ladder's read, the repeat path, |
| 1494 | // and between the pointer read and the conditional write. A non-nil |
| 1495 | // error refuses the promotion as an *AuthorizationError. nil means |
| 1496 | // today's behaviour exactly, with no extra reads. |
| 1497 | Authorize func(p *Pipeline) error |
| 1498 | ``` |
| 1499 | |
| 1500 | 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`: |
| 1501 | |
| 1502 | ```go |
| 1503 | func (sg Signer) Promote(ctx context.Context, s Store, acct, app, channel, version string, rec ...Promotion) error { |
| 1504 | return sg.promote(ctx, s, acct, app, channel, version, nil, rec...) |
| 1505 | } |
| 1506 | ``` |
| 1507 | |
| 1508 | Inside `promote`, after the `switch` that sets `old` and `etag` from `GetQualified` (`:732-744`) and before `next := ...`, add: |
| 1509 | |
| 1510 | ```go |
| 1511 | if authorize != nil { |
| 1512 | // Between the pointer read and the conditional write, so the write |
| 1513 | // lands only if the pointer is still what was authorized against |
| 1514 | // (spec §7.2, G1 and G2). |
| 1515 | pipe, perr := GetPipeline(ctx, s, pub, acct, app) |
| 1516 | if perr != nil { |
| 1517 | return fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr) |
| 1518 | } |
| 1519 | if aerr := authorize(pipe); aerr != nil { |
| 1520 | return &AuthorizationError{Err: aerr} |
| 1521 | } |
| 1522 | } |
| 1523 | ``` |
| 1524 | |
| 1525 | In `Signer.PromoteOnLadder`: |
| 1526 | - On the repeat path, inside `if p, verr := ...; verr == nil && p.Version == version {` and before `res.NoOp = true`: |
| 1527 | |
| 1528 | ```go |
| 1529 | if opt.Authorize != nil { |
| 1530 | pipe, perr := GetPipeline(ctx, s, pub, acct, app) |
| 1531 | if perr != nil { |
| 1532 | return res, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr) |
| 1533 | } |
| 1534 | if aerr := opt.Authorize(pipe); aerr != nil { |
| 1535 | return res, &AuthorizationError{Err: aerr} |
| 1536 | } |
| 1537 | } |
| 1538 | ``` |
| 1539 | |
| 1540 | - Right after its own `pipe, perr := GetPipeline(...)` error check (`:930-933`): |
| 1541 | |
| 1542 | ```go |
| 1543 | if opt.Authorize != nil { |
| 1544 | if aerr := opt.Authorize(pipe); aerr != nil { |
| 1545 | return res, &AuthorizationError{Err: aerr} |
| 1546 | } |
| 1547 | } |
| 1548 | ``` |
| 1549 | |
| 1550 | - Replace the `sg.Promote(ctx, s, acct, app, channel, version, rec)` call with `sg.promote(ctx, s, acct, app, channel, version, opt.Authorize, rec)`. |
| 1551 | |
| 1552 | 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 := ...`: |
| 1553 | |
| 1554 | ```go |
| 1555 | if authorize != nil { |
| 1556 | pipe, perr := GetPipeline(ctx, s, pub, acct, app) |
| 1557 | if perr != nil { |
| 1558 | return fmt.Errorf("rollback: cannot read the pipeline for %s/%s: %w", acct, app, perr) |
| 1559 | } |
| 1560 | if aerr := authorize(pipe); aerr != nil { |
| 1561 | return &AuthorizationError{Err: aerr} |
| 1562 | } |
| 1563 | } |
| 1564 | ``` |
| 1565 | |
| 1566 | - [ ] **Step 5: Run the new tests, then the package.** |
| 1567 | |
| 1568 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/release/ -count=1` |
| 1569 | Expected: PASS, including `golden_test.go` (root-signed bytes unchanged). |
| 1570 | |
| 1571 | - [ ] **Step 6: Commit** with message `release: an Authorize hook between pointer read and write (#229)`. |
| 1572 | |
| 1573 | --- |
| 1574 | |
| 1575 | ### Task 7: The request-local scoped signer and the handler wiring |
| 1576 | |
| 1577 | **Files:** |
| 1578 | - Modify: `internal/console/deployscope.go` (`deploySigner`) |
| 1579 | - Modify: `internal/console/apicli.go` (`handleAPIPromote` near `:880`, `handleAPIRollback` near the `release.Rollback` call) |
| 1580 | - Create: `internal/console/deploysign_test.go` |
| 1581 | |
| 1582 | **Interfaces:** |
| 1583 | - Consumes: Task 5's `deployScopeAllows`, `writeDeployRefusal`; Task 6's `PromoteOptions.Authorize`, `AuthorizationError`, `RollbackAuthorized`. |
| 1584 | - Produces: `func (s *Server) deploySigner(p Principal) (release.Signer, error)`; `const deployGrantTTL = 5 * time.Minute`. |
| 1585 | |
| 1586 | - [ ] **Step 1: Write the failing tests.** Create `internal/console/deploysign_test.go` with a fixture helper: |
| 1587 | |
| 1588 | ```go |
| 1589 | // deployFixture is a console with app hello in bab, v1 and v2 shipped by |
| 1590 | // paul, and a deploy credential ci over hello with rungs canary and edge. |
| 1591 | func deployFixture(t *testing.T) (fx *consoleFixture, person, deploy string) { |
| 1592 | t.Helper() |
| 1593 | fx = newConsoleFixture(t) |
| 1594 | if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| 1595 | t.Fatal(err) |
| 1596 | } |
| 1597 | person = cliToken(t, fx, "paul@keymail.dev", "code-deploy-sign") |
| 1598 | liftShipBudget(fx) |
| 1599 | for _, v := range []string{"v1", "v2"} { |
| 1600 | status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/hello/releases", person, |
| 1601 | []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("bin " + v)}}) |
| 1602 | if status != http.StatusCreated { |
| 1603 | t.Fatalf("ship %s = %d %v", v, status, out) |
| 1604 | } |
| 1605 | } |
| 1606 | tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, "paul@keymail.dev", time.Now()) |
| 1607 | if err != nil { |
| 1608 | t.Fatal(err) |
| 1609 | } |
| 1610 | return fx, person, tok |
| 1611 | } |
| 1612 | ``` |
| 1613 | |
| 1614 | Then these tests, each written out in full: |
| 1615 | - `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"`. |
| 1616 | - `TestDeployRepeatIsANoOp` (Review Focus 4): the same promote twice; the second answers 200 `"result":"noop"`, serial unchanged. |
| 1617 | - `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"). |
| 1618 | - `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`. |
| 1619 | - `TestPersonPromoteUnchanged`: the person promotes `v1` to `edge` → 200; the raw pointer has no `grant` field. |
| 1620 | - `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`. |
| 1621 | |
| 1622 | - [ ] **Step 2: Run and see them fail.** |
| 1623 | |
| 1624 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeploy|TestPersonPromoteUnchanged' -count=1` |
| 1625 | Expected: FAIL (`deploySigner` undefined; pointers carry no grant). |
| 1626 | |
| 1627 | - [ ] **Step 3: Add `deploySigner`** to `internal/console/deployscope.go` (imports `time`): |
| 1628 | |
| 1629 | ```go |
| 1630 | // deployGrantTTL is how long a request-local grant lives. It only has to |
| 1631 | // outlast one request: a box checks a grant against the pointer's own |
| 1632 | // promoted_at, so a pointer signed inside the window stays valid after the |
| 1633 | // grant expires (spec §7.4). |
| 1634 | const deployGrantTTL = 5 * time.Minute |
| 1635 | |
| 1636 | // deploySigner mints a scoped key and a root-signed grant for exactly the |
| 1637 | // credential's rungs, for one request. Boxes then verify the rung limit as |
| 1638 | // they do for a scoped CI key today. Nothing is stored. Any failure refuses |
| 1639 | // the request: a deploy write never falls back to the root signer. |
| 1640 | func (s *Server) deploySigner(p Principal) (release.Signer, error) { |
| 1641 | if len(p.Rungs) == 0 { |
| 1642 | return release.Signer{}, errors.New("deploy credential carries no rungs") |
| 1643 | } |
| 1644 | seed, grant, err := release.MintKeyGrant(s.cfg.Key, "deploy."+p.Name, p.Rungs, deployGrantTTL) |
| 1645 | if err != nil { |
| 1646 | return release.Signer{}, err |
| 1647 | } |
| 1648 | key, err := release.ParseSigningKey(seed, "deploy grant") |
| 1649 | if err != nil { |
| 1650 | return release.Signer{}, err |
| 1651 | } |
| 1652 | return release.ScopedSigner(key, grant, s.cfg.ReleasePub) |
| 1653 | } |
| 1654 | ``` |
| 1655 | |
| 1656 | - [ ] **Step 4: Wire `handleAPIPromote`.** Replace the single `release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, ...)` call and its error block with: |
| 1657 | |
| 1658 | ```go |
| 1659 | signer := release.RootSigner(s.cfg.Key) |
| 1660 | var authorize func(*release.Pipeline) error |
| 1661 | if p.Kind == PrincipalService && p.Role == svcrole.Deploy { |
| 1662 | sg, serr := s.deploySigner(p) |
| 1663 | if serr != nil { |
| 1664 | log.Printf("<D25>", acct.Sqid, app, serr) |
| 1665 | jsonError(w, http.StatusServiceUnavailable, "<D26>") |
| 1666 | return |
| 1667 | } |
| 1668 | signer = sg |
| 1669 | authorize = func(pp *release.Pipeline) error { |
| 1670 | return deployScopeAllows(p, pp != nil, channel, req.Hotfix) |
| 1671 | } |
| 1672 | } |
| 1673 | res, err := signer.PromoteOnLadder(ctx, s.cfg.Releases, acct.Sqid, app, channel, version, |
| 1674 | release.PromoteOptions{Actor: addr, Hotfix: hotfix, Authorize: authorize}) |
| 1675 | if err != nil { |
| 1676 | var ae *release.AuthorizationError |
| 1677 | if errors.As(err, &ae) { |
| 1678 | s.writeDeployRefusal(w, ae.Err) |
| 1679 | return |
| 1680 | } |
| 1681 | log.Printf("console: api promote %s %s -> %s: %v", app, version, channel, err) |
| 1682 | jsonError(w, http.StatusConflict, err.Error()) |
| 1683 | return |
| 1684 | } |
| 1685 | ``` |
| 1686 | |
| 1687 | `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. |
| 1688 | |
| 1689 | - [ ] **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. |
| 1690 | |
| 1691 | - [ ] **Step 6: Run the package.** |
| 1692 | |
| 1693 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ ./internal/release/ -count=1` |
| 1694 | Expected: PASS. |
| 1695 | |
| 1696 | - [ ] **Step 7: Commit** with message `console: sign deploy writes with a request-local scoped grant (#229)`. |
| 1697 | |
| 1698 | --- |
| 1699 | |
| 1700 | ### Task 8: The randomized interleaving test and its two mutants |
| 1701 | |
| 1702 | **Files:** |
| 1703 | - Create: `internal/release/authorize_sim_test.go` |
| 1704 | |
| 1705 | **Interfaces:** |
| 1706 | - Consumes: Task 6's `hookStore`, `noPipelineOnly`, `installPipeline`, `PromoteOptions.Authorize`, `RollbackAuthorized`. |
| 1707 | |
| 1708 | - [ ] **Step 1: Write the simulation.** Create `internal/release/authorize_sim_test.go`: |
| 1709 | |
| 1710 | ```go |
| 1711 | package release |
| 1712 | |
| 1713 | import ( |
| 1714 | "bytes" |
| 1715 | "context" |
| 1716 | "crypto/ed25519" |
| 1717 | "errors" |
| 1718 | "fmt" |
| 1719 | "math/rand" |
| 1720 | "os" |
| 1721 | "strconv" |
| 1722 | "strings" |
| 1723 | "testing" |
| 1724 | ) |
| 1725 | |
| 1726 | // TestAuthorizeInterleavings runs random schedules of one deploy request |
| 1727 | // against an owner who installs and removes pipelines, promotes and rolls |
| 1728 | // back, at random points inside the request. It checks G1 and G2 and |
| 1729 | // nothing else (spec §7.2, §12): |
| 1730 | // |
| 1731 | // G1: the last hook call before the write came after the pointer read the |
| 1732 | // write was conditioned on, and the pipeline it was given permits it. |
| 1733 | // G2: the bytes the write replaced equal the bytes that read returned. |
| 1734 | // |
| 1735 | // Seeds: DEPLOY_SIM_SEEDS (default 400). A failure prints the seed and the |
| 1736 | // event log; DEPLOY_SIM_SEED=<n> replays exactly that seed. |
| 1737 | func TestAuthorizeInterleavings(t *testing.T) { |
| 1738 | first, last := 0, 400 |
| 1739 | if v := os.Getenv("DEPLOY_SIM_SEEDS"); v != "" { |
| 1740 | n, err := strconv.Atoi(v) |
| 1741 | if err != nil { |
| 1742 | t.Fatal(err) |
| 1743 | } |
| 1744 | last = n |
| 1745 | } |
| 1746 | if v := os.Getenv("DEPLOY_SIM_SEED"); v != "" { |
| 1747 | n, err := strconv.Atoi(v) |
| 1748 | if err != nil { |
| 1749 | t.Fatal(err) |
| 1750 | } |
| 1751 | first, last = n, n+1 |
| 1752 | } |
| 1753 | priv, _ := testKeys(t) |
| 1754 | for seed := first; seed < last; seed++ { |
| 1755 | simulateOnce(t, seed, priv) |
| 1756 | } |
| 1757 | } |
| 1758 | |
| 1759 | type simRead struct { |
| 1760 | seq int |
| 1761 | body []byte |
| 1762 | etag string |
| 1763 | absent bool |
| 1764 | } |
| 1765 | |
| 1766 | type simHook struct { |
| 1767 | seq int |
| 1768 | pipelined bool |
| 1769 | } |
| 1770 | |
| 1771 | func simulateOnce(t *testing.T, seed int, priv ed25519.PrivateKey) { |
| 1772 | t.Helper() |
| 1773 | ctx := context.Background() |
| 1774 | rng := rand.New(rand.NewSource(int64(seed))) |
| 1775 | inner := NewDirStore(t.TempDir()) |
| 1776 | for _, v := range []string{"v1", "v2", "v3", "v4"} { |
| 1777 | shipRelease(t, inner, "bab", "myapp", v, "linux-arm64", []byte("bin "+v)) |
| 1778 | } |
| 1779 | for _, v := range []string{"v1", "v2"} { |
| 1780 | if _, err := PromoteOnLadder(ctx, inner, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}); err != nil { |
| 1781 | t.Fatalf("seed %d setup: %v", seed, err) |
| 1782 | } |
| 1783 | } |
| 1784 | var ( |
| 1785 | events []string |
| 1786 | reads []simRead |
| 1787 | hooks []simHook |
| 1788 | put *storeCall |
| 1789 | replaced []byte |
| 1790 | replacedAbsent bool |
| 1791 | ) |
| 1792 | owner := func() { |
| 1793 | var err error |
| 1794 | var what string |
| 1795 | switch rng.Intn(4) { |
| 1796 | case 0: |
| 1797 | what = "install" |
| 1798 | _, err = SetPipeline(ctx, inner, priv, "bab", "myapp", []PipelineChannel{{Name: "edge"}, {Name: "stable"}}, "owner") |
| 1799 | case 1: |
| 1800 | what = "remove pipeline" |
| 1801 | err = inner.Delete(ctx, PipelineKey("bab", "myapp")) |
| 1802 | case 2: |
| 1803 | v := fmt.Sprintf("v%d", 1+rng.Intn(4)) |
| 1804 | what = "promote " + v |
| 1805 | _, err = PromoteOnLadder(ctx, inner, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}) |
| 1806 | case 3: |
| 1807 | what = "rollback" |
| 1808 | err = Rollback(ctx, inner, priv, "bab", "myapp", "edge") |
| 1809 | } |
| 1810 | events = append(events, fmt.Sprintf(" owner %s: %v", what, err)) |
| 1811 | } |
| 1812 | h := &hookStore{inner: inner} |
| 1813 | h.before = func(c storeCall) { |
| 1814 | if rng.Float64() < 0.3 { |
| 1815 | for i := 0; i <= rng.Intn(2); i++ { |
| 1816 | owner() |
| 1817 | } |
| 1818 | } |
| 1819 | events = append(events, fmt.Sprintf("%d %s %s %s", c.seq, c.op, c.key, c.ifMatch)) |
| 1820 | if c.op == "put" && c.key == edgeKey { |
| 1821 | cc := c |
| 1822 | put = &cc |
| 1823 | body, _, err := inner.Get(ctx, edgeKey) |
| 1824 | replaced, replacedAbsent = body, errors.Is(err, os.ErrNotExist) |
| 1825 | } |
| 1826 | } |
| 1827 | h.after = func(c storeCall, body []byte, etag string, err error) { |
| 1828 | if c.key == edgeKey { |
| 1829 | reads = append(reads, simRead{c.seq, body, etag, errors.Is(err, os.ErrNotExist)}) |
| 1830 | } |
| 1831 | } |
| 1832 | hook := func(p *Pipeline) error { |
| 1833 | hooks = append(hooks, simHook{h.seq, p != nil}) |
| 1834 | events = append(events, fmt.Sprintf("%d hook pipelined=%v", h.seq, p != nil)) |
| 1835 | return noPipelineOnly(p) |
| 1836 | } |
| 1837 | var err error |
| 1838 | noop := false |
| 1839 | switch rng.Intn(3) { |
| 1840 | case 0: |
| 1841 | var res PromoteResult |
| 1842 | res, err = RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v3", PromoteOptions{Authorize: hook}) |
| 1843 | noop = res.NoOp |
| 1844 | case 1: |
| 1845 | var res PromoteResult |
| 1846 | res, err = RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v2", PromoteOptions{Authorize: hook}) |
| 1847 | noop = res.NoOp |
| 1848 | case 2: |
| 1849 | err = RootSigner(priv).RollbackAuthorized(ctx, h, "bab", "myapp", "edge", hook) |
| 1850 | } |
| 1851 | fail := func(msg string) { |
| 1852 | t.Fatalf("seed %d: %s\nreplay with DEPLOY_SIM_SEED=%d\n%s", seed, msg, seed, strings.Join(events, "\n")) |
| 1853 | } |
| 1854 | if err != nil { |
| 1855 | return // refusals and conflicts write nothing |
| 1856 | } |
| 1857 | if noop { |
| 1858 | if len(hooks) == 0 || hooks[len(hooks)-1].pipelined { |
| 1859 | fail("a no-op succeeded without a clean hook call") |
| 1860 | } |
| 1861 | return |
| 1862 | } |
| 1863 | if put == nil { |
| 1864 | fail("succeeded without writing") |
| 1865 | } |
| 1866 | var r *simRead |
| 1867 | for i := len(reads) - 1; i >= 0; i-- { |
| 1868 | rd := reads[i] |
| 1869 | if rd.seq >= put.seq { |
| 1870 | continue |
| 1871 | } |
| 1872 | if (put.ifMatch == "" && rd.absent) || (put.ifMatch != "" && !rd.absent && rd.etag == put.ifMatch) { |
| 1873 | r = &rd |
| 1874 | break |
| 1875 | } |
| 1876 | } |
| 1877 | if r == nil { |
| 1878 | fail("no pointer read matches the write's condition") |
| 1879 | } |
| 1880 | var a *simHook |
| 1881 | for i := len(hooks) - 1; i >= 0; i-- { |
| 1882 | if hooks[i].seq < put.seq { |
| 1883 | a = &hooks[i] |
| 1884 | break |
| 1885 | } |
| 1886 | } |
| 1887 | if a == nil { |
| 1888 | fail("a write landed with no hook call") |
| 1889 | } |
| 1890 | if a.seq <= r.seq { |
| 1891 | fail(fmt.Sprintf("G1: the hook at %d is not after the pointer read at %d", a.seq, r.seq)) |
| 1892 | } |
| 1893 | if a.pipelined { |
| 1894 | fail("G1: the hook saw a pipeline and the write landed") |
| 1895 | } |
| 1896 | if replacedAbsent != r.absent || !bytes.Equal(replaced, r.body) { |
| 1897 | fail("G2: the write replaced bytes other than the ones it read") |
| 1898 | } |
| 1899 | } |
| 1900 | ``` |
| 1901 | |
| 1902 | - [ ] **Step 2: Run it.** |
| 1903 | |
| 1904 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=2000 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1` |
| 1905 | 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. |
| 1906 | |
| 1907 | - [ ] **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. |
| 1908 | |
| 1909 | ```bash |
| 1910 | M=$TMPDIR/deploy-mutant1; rm -rf "$M"; mkdir -p "$M" |
| 1911 | git -C /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential archive HEAD | tar -x -C "$M" |
| 1912 | cd "$M" && python3 - <<'EOF' |
| 1913 | p="internal/release/release.go"; s=open(p).read() |
| 1914 | start=s.index("\tif authorize != nil {\n\t\t// Between the pointer read") |
| 1915 | end=s.index("\t\t}\n\t}\n", start)+len("\t\t}\n\t}\n") |
| 1916 | block=s[start:end]; s=s[:start]+s[end:] |
| 1917 | anchor="\tcur, etag, err := GetQualified(ctx, s, key)\n\tvar old *ChannelPointer" |
| 1918 | assert s.count(anchor)==1; s=s.replace(anchor, block+anchor) |
| 1919 | open(p,"w").write(s) |
| 1920 | EOF |
| 1921 | export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=400 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1; echo "exit=$?" |
| 1922 | ``` |
| 1923 | |
| 1924 | Expected: `exit=1`, a G1 failure with a seed. Replay that seed with `DEPLOY_SIM_SEED=<n>` and confirm it fails again. |
| 1925 | |
| 1926 | - [ ] **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. |
| 1927 | |
| 1928 | ```bash |
| 1929 | M=$TMPDIR/deploy-mutant2; rm -rf "$M"; mkdir -p "$M" |
| 1930 | git -C /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential archive HEAD | tar -x -C "$M" |
| 1931 | cd "$M" && sed -i 's/if ifMatch != "" \&\& (!exists || etagOf(cur) != ifMatch) {/if false \&\& ifMatch != "" \&\& (!exists || etagOf(cur) != ifMatch) {/' internal/release/store.go |
| 1932 | grep -c 'if false && ifMatch' internal/release/store.go |
| 1933 | export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=2000 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1; echo "exit=$?" |
| 1934 | ``` |
| 1935 | |
| 1936 | Expected: the grep prints `1`, then `exit=1` with a G2 failure and a seed that replays. |
| 1937 | |
| 1938 | - [ ] **Step 5: Clean up the copies.** |
| 1939 | |
| 1940 | ```bash |
| 1941 | rm -rf "$TMPDIR/deploy-mutant1" "$TMPDIR/deploy-mutant2" |
| 1942 | ``` |
| 1943 | |
| 1944 | - [ ] **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. |
| 1945 | |
| 1946 | --- |
| 1947 | |
| 1948 | ### Task 9: The CLI reads `CARLOS_SERVICE_TOKEN` |
| 1949 | |
| 1950 | **Files:** |
| 1951 | - Modify: `apimode.go:93-143` (`resolveMode`) |
| 1952 | - Modify: `apimode_test.go` (`clearDeploymentEnv` and new tests) |
| 1953 | |
| 1954 | **Interfaces:** |
| 1955 | - Consumes: `normalizeConsole(in string) (string, error)` (`auth.go:354`), `newConsoleClient(origin, token string)`. |
| 1956 | - Produces: `const serviceSessionLabel = "<D29>"`. |
| 1957 | |
| 1958 | - [ ] **Step 1: Keep existing tests hermetic.** In `apimode_test.go`'s `clearDeploymentEnv`, add: |
| 1959 | |
| 1960 | ```go |
| 1961 | t.Setenv("CARLOS_SERVICE_TOKEN", "") |
| 1962 | ``` |
| 1963 | |
| 1964 | - [ ] **Step 2: Write the failing tests** in `apimode_test.go`: |
| 1965 | |
| 1966 | ```go |
| 1967 | func TestResolveModeServiceToken(t *testing.T) { |
| 1968 | useIsolatedConfig(t) |
| 1969 | clearDeploymentEnv(t) |
| 1970 | var gotAuth string |
| 1971 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 1972 | gotAuth = r.Header.Get("Authorization") |
| 1973 | w.Header().Set("Content-Type", "application/json") |
| 1974 | w.Write([]byte(`{"accounts":[]}`)) |
| 1975 | })) |
| 1976 | defer ts.Close() |
| 1977 | // A person's credential for another console is on disk too. The token |
| 1978 | // wins and the file is not consulted. |
| 1979 | f, _ := loadCredentials() |
| 1980 | f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "person", ExpiresAt: time.Now().Add(time.Hour)}) |
| 1981 | if err := f.save(); err != nil { |
| 1982 | t.Fatal(err) |
| 1983 | } |
| 1984 | t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") |
| 1985 | t.Setenv("CARLOS_CONSOLE", ts.URL) |
| 1986 | sess, err := resolveMode("") |
| 1987 | if err != nil || sess == nil { |
| 1988 | t.Fatalf("resolveMode = %v, %v", sess, err) |
| 1989 | } |
| 1990 | if sess.address != serviceSessionLabel { |
| 1991 | t.Fatalf("address = %q", sess.address) |
| 1992 | } |
| 1993 | if _, _, err := sess.getJSON(context.Background(), "/api/cli/apps"); err != nil { |
| 1994 | t.Fatal(err) |
| 1995 | } |
| 1996 | if gotAuth != "Bearer svc-secret" { |
| 1997 | t.Fatalf("Authorization = %q, want the service token", gotAuth) |
| 1998 | } |
| 1999 | } |
| 2000 | |
| 2001 | func TestResolveModeServiceTokenNeedsAConsole(t *testing.T) { |
| 2002 | useIsolatedConfig(t) |
| 2003 | clearDeploymentEnv(t) |
| 2004 | t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") |
| 2005 | if _, err := resolveMode(""); err == nil || !strings.Contains(err.Error(), "CARLOS_CONSOLE") { |
| 2006 | t.Fatalf("err = %v, want D27", err) |
| 2007 | } |
| 2008 | } |
| 2009 | |
| 2010 | func TestResolveModeServiceTokenRefusedWithStoreEnv(t *testing.T) { |
| 2011 | for _, env := range []string{"CARLOS_DEPLOYMENT_BUCKET", "CARLOS_DEPLOYMENT_DIR"} { |
| 2012 | t.Run(env, func(t *testing.T) { |
| 2013 | useIsolatedConfig(t) |
| 2014 | clearDeploymentEnv(t) |
| 2015 | t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret") |
| 2016 | t.Setenv("CARLOS_CONSOLE", "https://c.example") |
| 2017 | t.Setenv(env, t.TempDir()) |
| 2018 | sess, err := resolveMode("") |
| 2019 | if err == nil || sess != nil || !strings.Contains(err.Error(), "CARLOS_SERVICE_TOKEN") { |
| 2020 | t.Fatalf("= %v, %v; want D28", sess, err) |
| 2021 | } |
| 2022 | }) |
| 2023 | } |
| 2024 | } |
| 2025 | ``` |
| 2026 | |
| 2027 | 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`). Also drive `sess.instanceList` and `sess.appRegions`, the two reads `carlos deploy` adds (R6). Map each recorded path back to a route pattern and assert each is in Task 4's §6 list; name the test `TestCarlosDeployRoutinePathUsesOnlyDeployDoors`. Separately assert that `sess.logsGet`'s path (`.../logs`) is not in the list, so the watch's diagnosis stays a best-effort read a deploy credential is refused. |
| 2028 | |
| 2029 | - [ ] **Step 3: Run and see them fail.** |
| 2030 | |
| 2031 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'TestResolveMode|TestCarlosDeployRoutinePath' -count=1` |
| 2032 | Expected: FAIL (`serviceSessionLabel` undefined). |
| 2033 | |
| 2034 | - [ ] **Step 4: Implement** in `resolveMode`, after the existing `--console` conflict check and before `if bucketSet || dirSet { return nil, nil }`: |
| 2035 | |
| 2036 | ```go |
| 2037 | token := os.Getenv("CARLOS_SERVICE_TOKEN") |
| 2038 | if token != "" && (bucketSet || dirSet) { |
| 2039 | return nil, errors.New("<D28>") |
| 2040 | } |
| 2041 | ``` |
| 2042 | |
| 2043 | and after `if bucketSet || dirSet { return nil, nil }`: |
| 2044 | |
| 2045 | ```go |
| 2046 | if token != "" { |
| 2047 | // CI's path (spec §8): the console is named outright, never taken |
| 2048 | // from a config default or a stored credential, and the credentials |
| 2049 | // file is not read. |
| 2050 | console := consoleFlag |
| 2051 | if console == "" { |
| 2052 | console = os.Getenv("CARLOS_CONSOLE") |
| 2053 | } |
| 2054 | if console == "" { |
| 2055 | return nil, errors.New("<D27>") |
| 2056 | } |
| 2057 | origin, err := normalizeConsole(console) |
| 2058 | if err != nil { |
| 2059 | return nil, err |
| 2060 | } |
| 2061 | return &apiSession{c: newConsoleClient(origin, token), console: origin, address: serviceSessionLabel}, nil |
| 2062 | } |
| 2063 | ``` |
| 2064 | |
| 2065 | Add near `apiSession`: |
| 2066 | |
| 2067 | ```go |
| 2068 | // serviceSessionLabel stands in for the address in "using X as Y" lines |
| 2069 | // when the session is a service token: the CLI has no door that tells it |
| 2070 | // the credential's name, and a person's whoami does not answer for one. |
| 2071 | const serviceSessionLabel = "<D29>" |
| 2072 | ``` |
| 2073 | |
| 2074 | Update `resolveMode`'s doc comment to describe the token arm. |
| 2075 | |
| 2076 | - [ ] **Step 5: Run.** |
| 2077 | |
| 2078 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -count=1 && go vet .` |
| 2079 | Expected: PASS. |
| 2080 | |
| 2081 | - [ ] **Step 6: Commit** with message `cli: CARLOS_SERVICE_TOKEN selects the console for CI (#229)`. |
| 2082 | |
| 2083 | --- |
| 2084 | |
| 2085 | ### Task 10: Mixed versions fail closed |
| 2086 | |
| 2087 | **Files:** |
| 2088 | - Create: `internal/console/deploymixed_test.go` |
| 2089 | |
| 2090 | - [ ] **Step 1: Write the tests.** |
| 2091 | |
| 2092 | ```go |
| 2093 | // TestUnknownStoredRoleIsNotSignedInAtEveryDoor is spec §9's first check: a |
| 2094 | // console that does not know a stored role (as v0.21.0 does not know |
| 2095 | // deploy) answers its token exactly as it answers no token at all. |
| 2096 | func TestUnknownStoredRoleIsNotSignedInAtEveryDoor(t *testing.T) { |
| 2097 | fx := newConsoleFixture(t) |
| 2098 | if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| 2099 | t.Fatal(err) |
| 2100 | } |
| 2101 | raw, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now()) |
| 2102 | if err != nil { |
| 2103 | t.Fatal(err) |
| 2104 | } |
| 2105 | if _, err := fx.st.db.Exec(`UPDATE cli_tokens SET role = 'futurerole' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| 2106 | t.Fatal(err) |
| 2107 | } |
| 2108 | fill := regexp.MustCompile(`\{[a-z]+\}`) |
| 2109 | for pattern := range fx.srv.cliRoles { |
| 2110 | method, path, _ := strings.Cut(pattern, " ") |
| 2111 | path = strings.Replace(path, "{account}", "bab", 1) |
| 2112 | path = strings.Replace(path, "{app}", "hello", 1) |
| 2113 | path = fill.ReplaceAllString(path, "x") |
| 2114 | with := doRaw(t, method, fx.ts.URL+path, raw) |
| 2115 | without := doRaw(t, method, fx.ts.URL+path, "") |
| 2116 | if with.status != without.status || with.body != without.body { |
| 2117 | t.Errorf("%s: unknown role answered %d %q; no token answered %d %q", |
| 2118 | pattern, with.status, with.body, without.status, without.body) |
| 2119 | } |
| 2120 | } |
| 2121 | } |
| 2122 | ``` |
| 2123 | |
| 2124 | `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). |
| 2125 | |
| 2126 | - [ ] **Step 2: Run.** |
| 2127 | |
| 2128 | Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run TestUnknownStoredRole -count=1` |
| 2129 | 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. |
| 2130 | |
| 2131 | - [ ] **Step 3: Read v0.21.0.** Confirm both refusals exist in the release Tito's console runs: |
| 2132 | |
| 2133 | ```bash |
| 2134 | git show v0.21.0:internal/console/svcprincipal.go | grep -n -A3 'r, ok := svcrole.Parse(role)' |
| 2135 | git show v0.21.0:internal/console/authcli.go | grep -n 'allow.Permits(p.Role)' |
| 2136 | ``` |
| 2137 | |
| 2138 | Expected: the first prints the `if !ok { return Principal{}, false }` arm; the second prints one line. Put both outputs in the commit body. |
| 2139 | |
| 2140 | - [ ] **Step 4: Commit** with message `console: pin fail-closed handling of an unknown stored role (#229)`. |
| 2141 | |
| 2142 | --- |
| 2143 | |
| 2144 | ### Task 11: Docs |
| 2145 | |
| 2146 | **Files:** |
| 2147 | - Modify: `README.md:170-180` |
| 2148 | - Modify: `docs/site/cli.md` (the `carlos services` section, `:1409-1440`) |
| 2149 | |
| 2150 | - [ ] **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: |
| 2151 | |
| 2152 | ```markdown |
| 2153 | **CI uses the console too.** Mint a deploy credential for the app, limited |
| 2154 | to the rungs CI may move, and give CI the secret and the console's address: |
| 2155 | |
| 2156 | carlos services create --role deploy --app blog --rungs canary,edge ci |
| 2157 | # in CI: |
| 2158 | export CARLOS_SERVICE_TOKEN=... CARLOS_CONSOLE=https://console.example.com |
| 2159 | carlos deploy --app blog --version "$GITHUB_SHA" --channel edge blog-linux-arm64 |
| 2160 | |
| 2161 | `carlos deploy` ships, promotes and watches the app's hosts, the same as |
| 2162 | from a laptop. The first deploy of a new app still needs a person: a deploy |
| 2163 | credential cannot create the app or set up its first host. Every promote is |
| 2164 | audited in the console and reaches the boxes within seconds. A deploy credential can ship, and promote or roll back only its |
| 2165 | rungs. It cannot reach stable, hotfix, delete the app, or change retention |
| 2166 | or domains. |
| 2167 | |
| 2168 | **Direct bucket mode is for operators.** With `CARLOS_DEPLOYMENT_BUCKET` |
| 2169 | (or `_DIR`) set, every verb goes straight to the bucket and the console |
| 2170 | never sees it. Use it to set up a deployment before its console exists, to |
| 2171 | run one that has no console, to recover when the console is down, and for |
| 2172 | the platform's own releases. A scoped `CARLOS_RELEASE_KEY` limits that path |
| 2173 | only. The CLI refuses to run with both `CARLOS_SERVICE_TOKEN` and a bucket |
| 2174 | variable set. |
| 2175 | ``` |
| 2176 | |
| 2177 | Leave "**On a box, or in CI, don't log in.**" and what follows unchanged. |
| 2178 | |
| 2179 | - [ ] **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: |
| 2180 | |
| 2181 | ```markdown |
| 2182 | `deploy` is for a CI pipeline: it ships, and promotes or rolls back only |
| 2183 | the rungs named with `--rungs` (`canary`, `edge`, `beta`; never `stable`). |
| 2184 | It must name one app and cannot hotfix. If the app has a pipeline, it may |
| 2185 | move `canary/<name>` channels only. CI reads it from |
| 2186 | `CARLOS_SERVICE_TOKEN` with `CARLOS_CONSOLE`. |
| 2187 | ``` |
| 2188 | |
| 2189 | and change "`publish` ships releases;" to "`publish` ships releases and manages the app;". |
| 2190 | |
| 2191 | - [ ] **Step 3: Check links and formatting.** |
| 2192 | |
| 2193 | Run: `grep -n "CARLOS_SERVICE_TOKEN" README.md docs/site/cli.md` |
| 2194 | Expected: the new passages and nothing contradicting them. |
| 2195 | |
| 2196 | - [ ] **Step 4: Commit** with message `docs: CI promotes through the console with a deploy credential (#229)`. |
| 2197 | |
| 2198 | --- |
| 2199 | |
| 2200 | ### Task 12: The full gate, push and CI |
| 2201 | |
| 2202 | - [ ] **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): |
| 2203 | |
| 2204 | ```bash |
| 2205 | mkdir -p ~/.cache/deploy-cred/gotmp |
| 2206 | cat > ~/.cache/deploy-cred/texec.sh <<'EOF' |
| 2207 | #!/bin/sh |
| 2208 | case "$1" in |
| 2209 | */activator.test) root=/tmp/k ;; |
| 2210 | *) root=/var/tmp/ck ;; |
| 2211 | esac |
| 2212 | mkdir -p "$root" |
| 2213 | export GOTMPDIR="$root" TMPDIR="$root" |
| 2214 | exec "$@" |
| 2215 | EOF |
| 2216 | chmod +x ~/.cache/deploy-cred/texec.sh |
| 2217 | cd /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential |
| 2218 | go build ./... && GOTMPDIR=~/.cache/deploy-cred/gotmp CARLOS_REQUIRE_JS_PARITY=1 \ |
| 2219 | GOFLAGS="-buildvcs=false -exec=$HOME/.cache/deploy-cred/texec.sh" make test |
| 2220 | ``` |
| 2221 | |
| 2222 | Expected: build, vet, gofmt and every package's tests pass. A failure is read first, never just re-run. |
| 2223 | |
| 2224 | - [ ] **Step 2: Cross-compile** (the CLI links `internal/host`): |
| 2225 | |
| 2226 | ```bash |
| 2227 | for p in darwin/arm64 windows/amd64 linux/arm64; do GOOS=${p%/*} GOARCH=${p#*/} go build -o /dev/null . || echo "FAIL $p"; done |
| 2228 | ``` |
| 2229 | |
| 2230 | Expected: no `FAIL` lines. |
| 2231 | |
| 2232 | - [ ] **Step 3: Clean up and push.** |
| 2233 | |
| 2234 | ```bash |
| 2235 | rm -rf /tmp/k /var/tmp/ck ~/.cache/deploy-cred |
| 2236 | git push origin design-deploy-credential |
| 2237 | amadan ci status carlos/platform -branch design-deploy-credential; echo "exit=$?" |
| 2238 | ``` |
| 2239 | |
| 2240 | Expected: the push succeeds; CI status eventually exits 0. Do not merge: merging and rolling wait for Paul. |
| 2241 | |
| 2242 | --- |
| 2243 | |
| 2244 | ## Self-review notes |
| 2245 | |
| 2246 | - 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. |
| 2247 | - R5 is pinned by the two "lands" tests in Task 6 and the simulation's acceptance of restored bytes in Task 8. |
| 2248 | |