| index 8c872d0ca..e27a71607 100644 |
| --- a/docs/superpowers/specs/2026-10-05-deploy-credential-design.md |
| +++ b/docs/superpowers/specs/2026-10-05-deploy-credential-design.md |
| @@ -1,7 +1,17 @@ |
| # A deploy credential for CI: promote through the console, limited to rungs |
| |
| **Date:** 2026-10-05 |
| -**Revision:** 1. |
| +**Revision:** 2. |
| + |
| +- Rev 1 (d30a74c2) went to Astra (via codex). The findings are in |
| + `CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md`, unedited. All six were checked |
| + against the code and accepted. Changes are marked "(codex N)" and §15 |
| + records each disposition. |
| +- **Rev 2 changes one thing Paul was told about rev 1.** Rev 1 moved CI's |
| + rung limit from a grant every box verifies to a check in the console, and |
| + flagged that as the trade-off. Rev 2 keeps the box-verified limit: the |
| + console signs a `deploy` credential's pointer with a request-local scoped |
| + key (§7.4, codex 6). |
| **Discussion:** carlos/platform#229. |
| **Code read at:** main `8d8e4bae`. Every line reference below is at that commit. |
| |
| @@ -29,10 +39,14 @@ CI cannot move to the console today, because the narrowest credential it |
| could hold there is too wide. A `publish` service credential bound to one |
| app reaches (`internal/console/authcli.go:476-739`): |
| |
| -- promote and rollback on every channel but stable. For an app with no |
| - pipeline that includes `beta`, and it includes a hotfix that skips the |
| - ladder order (`apicli.go:802-811`: only the stable rung is refused when |
| - `pipe == nil`); |
| +- promote and rollback on `beta`, and a hotfix that skips the ladder order, |
| + for an app with no pipeline (`apicli.go:802-811`: only the stable rung is |
| + refused when `pipe == nil`). Promote refuses the stable rung when the app |
| + has no pipeline or its pipeline does not name `stable`. A pipeline that |
| + does name `stable` is governed by that channel's own rules and nothing |
| + else (`apicli.go:813`), and a pipeline may name it without a passkey rule |
| + (`internal/release/pipeline.go:255`). Rollback refuses stable |
| + unconditionally (codex 4); |
| - trash and restore of the app, retention, version-target, domains. |
| |
| Today's CI key is a scoped release key for `canary,edge`. It cannot sign |
| @@ -109,10 +123,11 @@ is a separate spec. Neither is part of this one. |
| `carlos services create --role deploy --app titogo --rungs canary,edge ci` |
| mints a service credential. CI exports it as `CARLOS_SERVICE_TOKEN` with |
| `CARLOS_CONSOLE`, and runs the same `carlos ship` and `carlos promote` it |
| -runs today. The console checks the role at the door, then checks the target |
| -channel against the credential's rungs before it signs anything. The promote |
| -is audited and bumps the serial, so the box adopts in seconds. The runner |
| -holds no AWS role and no signing key. |
| +runs today. The console checks the role at the door and the target channel |
| +against the credential's rungs. It then signs the pointer with a scoped key |
| +minted for that one request and limited to the same rungs, so every box |
| +still verifies the limit. The promote is audited and bumps the serial, so |
| +the box adopts in seconds. The runner holds no AWS role and no signing key. |
| |
| ## 5. The credential |
| |
| @@ -127,8 +142,8 @@ catalogue. `Allow()` keeps adding `Admin` to every set, unchanged. |
| `RungEdge`, `RungBeta`, `internal/release/ladder.go:30-32`). `canary` |
| covers every `canary/<branch>` channel, as it does in a key grant. |
| - `stable` is refused at mint and again at the door, mirroring |
| - `internal/release/grant.go:103` and `:219`. A stable promote needs a |
| - passkey and always has. |
| + `internal/release/grant.go:103` and `:219`. No delegated key has ever |
| + been able to reach stable, and no `deploy` credential can (codex 4). |
| - Stored canonically: deduplicated, in ladder order, comma-joined. |
| |
| ### 5.3 Storage |
| @@ -215,42 +230,117 @@ A pure function decides, so it can be table-tested without a server: |
| For any principal whose role is not `deploy` it returns nil. For a `deploy` |
| principal it refuses, in this order: |
| |
| -1. `hotfix` is set. A scoped release key cannot fast-track either. |
| +1. `hotfix` is set. This is a deliberate restriction beyond what a scoped |
| + release key carries: a scoped signer may skip ladder order with `Hotfix` |
| + (`internal/release/release.go:945`). What a scoped key cannot do is |
| + `FastTrack`, which waives a bake window and is a separate field |
| + (`release.go:383`). A `deploy` credential gets neither. The console's |
| + promote door has no fast-track input, and the plan pins that with a test |
| + (codex 5). |
| 2. `release.Rung(channel)` (`ladder.go:55`) says the channel is not a rung. |
| 3. The rung is `stable`. |
| -4. The rung is not in the credential's rungs. |
| -5. The app has a pipeline and the rung is not `canary`. |
| - |
| -### 7.2 Where it runs |
| - |
| -- **`handleAPIPromote`** (`apicli.go:701`): after `GetPipeline` has answered |
| - (it fails closed already) and before the rules switch at `:803`. That is |
| - before any proposal is created and before `release.PromoteOnLadder` at |
| - `:880`. |
| -- **`handleAPIRollback`** (`apicli.go:970`): after the pipeline read and |
| - before any pointer or proposal write. |
| - |
| -Everything after the check is unchanged. The ladder order, the pipeline |
| -rules, the audit line and the serial bump all still run for a `deploy` |
| -credential, because it takes the same path as any other caller from that |
| -point on. |
| +4. The rung is `canary` and the channel is not `canary/<name>`. |
| + `release.Rung("canary")` answers `canary`, so the bare name would |
| + otherwise pass. It is refused for every app, pipelined or not (codex 2). |
| +5. The rung is not in the credential's rungs. |
| +6. The app has a pipeline and the rung is not `canary`. |
| + |
| +### 7.2 Where it runs: on the snapshot that executes (codex 1) |
| + |
| +Rev 1 ran the check in the handler, against the handler's own pipeline |
| +read (`apicli.go:772`). `PromoteOnLadder` reads the pipeline again |
| +(`release.go:930`) and acts on that second read. A pipeline installed |
| +between the two would be judged as absent and executed as present, which |
| +is an outcome no serial order of the two operations produces. |
| + |
| +So the authoritative check runs inside the release primitive, on the |
| +pipeline read the primitive itself uses: |
| + |
| +- `release.PromoteOptions` gains an authorization hook that |
| + `PromoteOnLadder` calls immediately after its own `GetPipeline` and |
| + before the order check. The console passes |
| + `deployScopeAllows` bound to the principal, the channel and the hotfix |
| + flag. A refusal from the hook is returned as a typed error the handler |
| + maps to §7.3's 403. |
| +- `Signer.Rollback` (`release.go:1031`) gains the same hook. When it is |
| + set, the primitive reads the pipeline itself and calls it before it |
| + signs. |
| +- The handlers also call `deployScopeAllows` on their own read, before the |
| + rules switch at `apicli.go:803` and before any proposal is created. This |
| + is the early refusal that gives CI a clear message. It is not the |
| + authority. |
| + |
| +**The contract.** For a `deploy` credential, the scope decision and the |
| +pointer write use one pipeline snapshot. A pipeline installed after that |
| +read and before the write is not seen, and the result is exactly the state |
| +that "promote, then install the pipeline" produces. Nothing a `deploy` |
| +credential writes depends on which of the two landed first. If the two |
| +reads disagree the other way (the handler saw a pipeline and the primitive |
| +did not), the handler has already refused. |
| + |
| +Serializing pipeline writes against every pointer write is not attempted |
| +here. It is a property of every promote today, and direct-bucket writers |
| +are outside the console's reach whatever the console locks (§14). |
| + |
| +The plan adds a test that installs a pipeline between the handler's read |
| +and the primitive's read, and asserts the refusal. |
| + |
| +Everything after the check is unchanged. The ladder order, the audit line |
| +and the serial bump all still run for a `deploy` credential. |
| |
| ### 7.3 The refusal |
| |
| A 403 with a JSON `error` that names the credential and the rungs it holds, |
| so a CI log says what to fix. The wording goes through copy review (§11). |
| |
| -### 7.4 What this replaces, stated plainly |
| +### 7.4 The pointer is signed by a request-local scoped key (codex 6) |
| |
| Today the limit on titogo's CI is cryptographic: every box verifies the key |
| -grant, and a pointer signed outside the grant is rejected wherever it lands. |
| -With a `deploy` credential the console signs with its own key |
| -(`s.cfg.Key`), so the limit is the console's check in §7.1. A bug in that |
| -check would not be caught by a box. |
| - |
| -This is accepted under R3. It is why §7.1 is one small pure function with a |
| -table test, and why `stable` keeps its unconditional refusal independent of |
| -that function. |
| +grant, and a pointer signed outside the grant is rejected wherever it |
| +lands. Rev 1 gave that up, because the console signs with its own key |
| +(`s.cfg.Key`). It does not have to. |
| + |
| +For a `deploy` principal, promote and rollback sign like this: |
| + |
| +1. The console mints a scoped keypair and a root-signed grant with |
| + `release.MintKeyGrant` (`grant.go:88`), for exactly the credential's |
| + rungs, with a lifetime of five minutes. The grant's name identifies the |
| + service credential and satisfies `grantNameRe` (`grant.go:81`). |
| +2. It builds a `release.ScopedSigner` (`signer.go:42`) from them and passes |
| + that signer to `PromoteOnLadder` or `Rollback`. |
| +3. The key is never stored and never leaves the request. |
| + |
| +What this buys: |
| + |
| +- A box verifies the pointer against the grant, as it does for titogo's CI |
| + today. If §7.1 were wrong about a rung, the signer refuses |
| + (`Signer.allows`, `signer.go:71`; `Grant.allows`, `grant.go:254`), and a |
| + pointer that somehow got written would still fail verification on every |
| + box. |
| +- Stable gets a refusal that does not depend on §7.1: no grant can name it |
| + (`grant.go:219`) and no grant can sign for it (`grant.go:263`) (codex 4). |
| +- The pointer itself records which credential moved it. |
| + |
| +What it does not buy, stated plainly: |
| + |
| +- It does not defend against a compromised console. The console holds the |
| + root key and could sign anything. |
| +- It does not enforce R2's pipeline rule. A grant knows rungs, not |
| + pipelines, so a pipeline that names `edge` is `edge` to a grant. That |
| + rule rests on §7.1 and §7.2. |
| +- It does not refuse bare `canary`, which a grant accepts. That rests on |
| + §7.1. |
| + |
| +Failure is closed. If the grant cannot be minted or the signer cannot be |
| +built (for instance, the console's key is not a root key), the request is |
| +refused. A `deploy` request never falls back to the root signer. |
| + |
| +A grant's lifetime is checked against the pointer's own `promoted_at` |
| +(`release.go:396-441`), so a five-minute grant does not expire a pointer |
| +it has already signed. A pointer dated before its grant was minted is |
| +refused, so the grant is minted before the pointer is stamped. |
| + |
| +Ship is unchanged and uses the console's own signer. It moves no pointer. |
| |
| ## 8. The CLI in CI |
| |
| @@ -272,7 +362,12 @@ a delivery hint and read by nothing in the CLI. |
| unchanged. |
| - The token is never accepted as a flag, so it never lands in a process |
| list or a CI log's command echo. |
| -- `carlos auth whoami` reports the service identity when the token is set. |
| +- `carlos auth whoami` is unchanged and stays person-only. It reads the |
| + credentials file and never calls `resolveMode` (`auth.go:785`), and the |
| + console's whoami door refuses every service token |
| + (`authcli.go:1531`, `authstore.go:515`). Rev 1 promised a service |
| + identity here with no door behind it. CI does not need one, so the |
| + promise is withdrawn rather than built (codex 3). |
| |
| **To check in the plan.** `CARLOS_SERVICE_TOKEN` is also the name an app |
| receives its own service credential under at run time |
| @@ -317,11 +412,21 @@ credentials already are, and the mint line records the rungs. |
| ## 12. Testing |
| |
| - **Scope function.** A table over role, pipelined, channel and hotfix, |
| - covering every refusal in §7.1 and each allowed case. |
| + covering every refusal in §7.1 and each allowed case, including bare |
| + `canary` for both a pipelined and an unpipelined app. |
| - **Handlers.** For promote and rollback: an in-scope rung succeeds, writes |
| - the audit line and bumps the serial; an out-of-scope rung, `stable`, a |
| - hotfix, and a non-canary channel on a pipelined app each give a 403 and |
| - write nothing. |
| + the audit line and bumps the serial; an out-of-scope rung, `stable`, bare |
| + `canary`, a hotfix, and a non-canary channel on a pipelined app each give |
| + a 403 and write nothing. A pipeline that names `stable` with no passkey |
| + rule still refuses a `deploy` credential. |
| +- **One snapshot.** A pipeline installed between the handler's read and |
| + the primitive's read is refused, for promote and for rollback (§7.2). |
| +- **Signer.** A pointer written by a `deploy` promote carries a grant for |
| + exactly the credential's rungs and verifies with `verifyPointer`. With |
| + the scope function stubbed to allow everything, an out-of-rung promote |
| + and a stable promote are still refused by the signer. A grant that |
| + cannot be minted refuses the request, and the root signer is not used. |
| +- **Hotfix and fast-track.** Each is refused, tested separately. |
| - **Doors.** A test walks every registered `cliRoute` and asserts `deploy` |
| is permitted on exactly the §6 list. A route added later is refused for |
| `deploy` unless someone adds it to the list on purpose. |
| @@ -365,3 +470,25 @@ credentials already are, and the mint line records the rungs. |
| change `publish`. |
| - Pipeline channel scopes (R2). If a customer with a pipeline needs CI to |
| propose onto a pipeline channel, that is a follow-on spec. |
| +- For every caller, the promote handler judges pipeline rules (approvals, |
| + passkey) on its own pipeline read, and `PromoteOnLadder` acts on a second |
| + one. A pipeline changed between the two is judged on the old rules. §7.2 |
| + closes this for `deploy` and leaves it as found for people and for |
| + `publish`. Closing it for everyone means binding pipeline writes and |
| + pointer writes to one generation, which is its own piece of work. |
| +- A pipeline may name `stable` with no passkey rule, and promote then |
| + applies only that channel's rules (§1). Existing behaviour, unchanged. |
| + |
| +## 15. Review dispositions |
| + |
| +Astra round 1, `CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md`, against d30a74c2. |
| +Every citation was checked at main `8d8e4bae`. |
| + |
| +| Finding | Severity | Disposition | |
| +| --- | --- | --- | |
| +| R1-1 scope judged on a different pipeline read than the one executed | P1 | Accepted. §7.2: the check runs inside the primitive on its own read. Full serialization of pipeline writes against pointer writes is declined, with the reason in §7.2 and §14. | |
| +| R1-2 bare `canary` passes | P1 | Accepted. §7.1 rule 4. | |
| +| R1-3 `auth whoami` has no service path | P2 | Accepted by withdrawing the claim. §8. | |
| +| R1-4 promote's stable refusal is not unconditional | P2 | Accepted. §1 and §5.2 corrected; the independent refusal is now the grant, §7.4. | |
| +| R1-5 hotfix and fast-track conflated | P2 | Accepted. §7.1 rule 1. | |
| +| R1-6 keep box-verified limits with a request-local scoped signer | P3 | Accepted and adopted. §7.4. | |