Skip to content

ci: do not merge — CI for the #235–#237 stack after the checksum rebase - #243

Closed
alukach wants to merge 9 commits into
mainfrom
feat/platform-trust
Closed

alukach wants to merge 9 commits into
mainfrom
feat/platform-trust

Conversation

@alukach

@alukach alukach commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Do not merge. data.source.coop runs its full CI only on pull requests into main, so this draft exists to run it on the top of the stack — #237 (feat/platform-trust, f10d12a), which contains #235 and #236 — after they were rebased onto #235's API-key checksum commits (d6745e0, 30ad22f). It will be closed once CI reports.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

alukach and others added 9 commits September 25, 2026 16:01
ADR-013 as revised (#234): a service account's API key is an opaque `sck_` secret that source.coop stores as a SHA-256 hash. When `/.sts` receives one as `WebIdentityToken`, the proxy trims and format-checks it locally, hashes it, and asks `POST {SOURCE_API_URL}/api/v1/service-account-keys/exchanges` whether it is active and for which account, authenticated as itself with the sentinel subject `urn:source:data-proxy`. The answer is cached for 60 seconds, inactive answers included; an API failure fails closed with a 500 and caches nothing. It then mints credentials under the `_default` role for the account the API names, through the STS crate's minting and sealing.

A key is accepted only from a POST form body. One in the query string is refused before any lookup, with a message saying why, because Cloudflare logs request URLs. Every refusal of the key itself reads "API key was not accepted (request id …)" with the id also in `x-amzn-requestid`; the reason is logged once under that id. Attempts are rate-limited per client IP by a new `KEY_EXCHANGE_LIMIT` ratelimit binding in every wrangler config; a deployment without it logs an error and does not refuse traffic.

`ApiAuth` gains `authorization_header_as_self` and refuses the sentinel as an on-behalf-of subject; `cached_fetch` takes a method, an optional JSON body and an `ApiCaller`. `sts::default_role` is factored out so the key exchange mints under the same role.

Supersedes #233: nothing signs a key, so `/.keys`, self-verification against the proxy's own JWKS, the `api_key` role and the multistore pin are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
A key is now `sck_`, 30 random base62 characters and a six-character base62 CRC-32 of those 30 — 40 characters, GitHub's own token layout, as source.coop now issues them. The proxy checks the shape and the checksum before the rate limit's lookup, so a key that was cut short or mistyped costs nothing and is refused as malformed ("API key is malformed; check that it was copied whole"), a message that reveals nothing since the format is public. Every other refusal still reads "API key was not accepted".

The CRC-32 is the IEEE one zlib computes, written out in a few lines rather than adding a crate. The unit tests pin it to vectors computed independently with Python's zlib, including a CRC above 2^31 and one whose checksum keeps a leading zero; the integration stub builds its keys the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
Once `sck_` keys are registered with GitHub's secret scanning, any whole key in a public repository is reported to source.coop and may trip push protection. The unit tests' keys are now concatenated from pieces, as the integration stub and the Source CLI's tests already build theirs at run time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
`/.sts` now resolves `RoleArn` to one of three hardcoded Roles, for ID tokens and API keys alike: `FullAccess`, the unlimited Role that `_default` has been; `ReadOnly`, whose sealed ceiling allows only reads; and `_default`, kept as an alias of `FullAccess` because deployed clients name it. Each is accepted bare or as the `role/<name>` resource of an ARN of any partition and account, since SDKs insist on an ARN. Any other name is `RoleNotFound`, never a fallback to a default.

ReadOnly's ceiling rides in the session token's `allowed_scopes` as one scope over every product (`*`) that lists the read actions. multistore's own scope check never runs on this gateway, so the registry enforces it: `get_bucket` checks the ceiling before anything is fetched and refuses with the same `AccessDenied` as every other refusal (ADR-011). The ceiling only subtracts, so a write it allows still needs the account's write permission. No scopes means no ceiling, which keeps every `_default` session already issued working unchanged.

source.coop's GitHub integration snippet already names `role/FullAccess`; this is what makes that name resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
ADR-004 described a single built-in `_default` Role, and ADR-001 said the sealed `allowed_scopes` was always empty and consulted nowhere, with empty meaning deny-all wherever scopes are evaluated. Both are dated by the hardcoded `FullAccess` and `ReadOnly` Roles: ADR-004 gains a note pointing to ADR-014, and ADR-001 now says the bucket registry enforces the ceiling and reads an empty one as no ceiling. ADR-011's status records that its step 2 and denial semantics are implemented for `ReadOnly`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
A token from a platform issuer, GitHub Actions to begin with, now acts at `/.sts` as the account its `RoleArn` names (`arn:aws:iam::<account>:role/FullAccess`), and only if that account trusts the token's issuer and subject (ADR-014). The proxy reads the token's issuer unverified to route it, checks the Role and that `RoleArn` names an account, then verifies the token against the issuer's JWKS with that issuer's own audiences and a required `exp`, since multistore checks `exp` only when the claim is present. Only then does it ask `POST /api/v1/accounts/{account}/trusts/exchanges` with the verified issuer and subject, as the account. A yes is cached for 60 seconds per account, issuer and subject. A no, the route's 403, is not cached, so a trust just added works on the next attempt. The credentials' principal is the account, never the token's subject, and every refusal of the trust reads `AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity (request id …)`.

Platform issuers are configured in `PLATFORM_ISSUERS`, a JSON object from issuer to the audiences its tokens must carry, so that one issuer's audience never admits another's token (ADR-009); an issuer with no audience is refused. `AUTH_ISSUER` and `AUTH_AUDIENCE` still configure the person issuer, whose tokens act as their own subject as before. Production trusts GitHub with the proxy's origin as the audience, which is how source.coop's workflow snippet mints the token; staging and previews use staging's origin.

CI now configures GitHub as a platform issuer, as production does, and the stub answers the trusts route: yes for this repository's workflows on one account, no otherwise. The credentialed write tests name that account.

`aws-actions/configure-aws-credentials` still fails after a successful exchange, on `GetCallerIdentity`, until developmentseed/multistore#126 lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
ADR-009's migration planned to add platform issuers to `_default` and warned that a CI token would then act as its subject's whole account. ADR-014's trust path replaces that: a platform token acts only as an account that trusts its issuer and subject. ADR-009 gains a note saying so, and its status now reads implemented in part. ADR-004 now notes platform issuers under its trust model, and its `exp` warning says platform tokens must carry the claim, which the proxy checks itself. ADR-001's `source_identity` now says it is the account an API key or a trusted platform token names, never a platform token's subject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
`get_or_fetch_trust` said only a yes is cached. `cached_fetch` caches every 200, so a `200 {"trusted": false}`, which the route does not send today, would be cached too, and still refused. The comments now say that the route's yes is a 200 cached like any other and its no is a 403 that never is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
A platform token's `RoleArn` must now name a service account, `{owner}--{name}` as source.coop's `SERVICE_ACCOUNT_ID_REGEX` defines it (82 characters at most). Anything else is refused as an untrusting account is, before the token is verified and before the proxy signs anything as it. The minted principal is that segment as given, and source.coop resolves a principal as an Ory identity first; an Ory id fits the person and organisation handle grammar, so without this, a trust on such an account would let platform credentials act as a person. source.coop writes trusts only to service accounts today, and ADR-014 makes trusts a service account's, so this is defense in depth.

Anyone can mint a GitHub token for the proxy's audience in their own workflow, and a refusal was not cached, so replaying one valid token cost a trusts lookup per request. Two bounds follow. The per-address rate limit from the API-key exchange now also covers platform exchanges, after everything local and before the trusts lookup; the binding is renamed `STS_EXCHANGE_LIMIT`, since it now bounds every exchange that costs a Source API call, and stays at 100 a minute. And a refusal from the trusts route is cached for 10 seconds under its own key, so a replay flood from many addresses costs about one lookup per 10 seconds per account, issuer and subject, while a trust just added still works within seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
@alukach

alukach commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #244. This PR conflicts with main in adrs/004-sts.md, so GitHub ran no pull_request workflows on it; #244 runs CI on the same stack with main merged in.

@alukach alukach closed this Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant