Skip to content

ci: do not merge — CI for the #235–#237 stack on current main - #244

Closed
alukach wants to merge 10 commits into
mainfrom
ci/checksum-stack
Closed

alukach wants to merge 10 commits into
mainfrom
ci/checksum-stack

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, and #243 (feat/platform-trust into main) got none: the stack conflicts with main in adrs/004-sts.md, where main's ADR-014 amendment note (#232) and #236's note on the FullAccess and ReadOnly Roles were written into the same spot, and GitHub runs no pull_request workflows for a conflicting PR. This throwaway branch is #237's tip (f10d12a, containing #235's checksum commits d6745e0 and 30ad22f and #236) with main merged in and both notes kept, so CI can run on the stack as it will land. It will be closed once CI reports; the stack's own branches are unchanged, and #236 will need the same resolution when it is rebased onto main after #235 merges.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

alukach and others added 10 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
@claude

claude Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 37s —— View job


✅ No blocking issues — safe to merge. (This is a throwaway CI branch, so this is a review of the stack it carries.)

I read the src/, Cargo.toml and wrangler.toml diff. I did not read the tests/ or ADR changes, and I did not run CI or the tests.

Checked and fine

  • keys::parse_api_key checks length and the alphanumeric charset before it slices rest[30..], so it can't panic.
  • credentials_for calls clamp(900, max), which panics if max < 900. config.rs already floors STS_MAX_SESSION_DURATION_SECS at 900, so that's safe.
  • The platform path refuses non-service-account ids before it verifies the token or signs anything.
  • exp is required on platform tokens, and the audience list is per issuer.
  • authorization_header refuses PROXY_SELF_SUBJECT, so a caller can't claim the proxy's own identity.
  • The API key is only read from the form body. It is sent to the API only as a hash.

Non-blocking notes

  • src/lib.rs sts_error_xml puts message into XML unescaped. with_request_id appends request_id to that message. If request_id comes from a client-supplied x-request-id, that is XML injection into the error body. I didn't check where request_id is set. If it's client-controlled, escape it or restrict it to [A-Za-z0-9-].
  • within_rate_limit fails open when the STS_EXCHANGE_LIMIT binding is missing or the call errors. It logs an error, which is a reasonable choice, but a misconfigured deploy would have no limit on the exchange routes.

Simplify (ponytail)

  • src/lib.rs exchange_platform_token takes 9 arguments and needs #[allow(clippy::too_many_arguments)]. Pass config, sts and request_id in one small context struct, or move the header parsing inside so it takes fewer.
  • src/source_api/cache.rs cached_fetch is another #[allow(clippy::too_many_arguments)], now with method and body. Replace the pair with a small enum Request { Get, PostJson(&str) }.
  • src/keys.rs key_hash formats each byte with format!. hex is not a dependency today, so this is fine. If hex ever comes in, use hex::encode.
  • src/keys.rs checksum hand-rolls CRC-32. crc32fast would replace it, but it's a new dependency for about 10 lines, so keeping the hand-rolled version is reasonable.

💰 Estimated review cost: $0.28 · 0m36s · 8 turns

@github-actions

Copy link
Copy Markdown

🚀 Latest commit deployed to https://source-data-proxy-pr-244.source-coop.workers.dev

  • Date: 2026-09-29T23:56:08Z
  • Commit: c8526b2

@alukach

alukach commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

CI passed on the #235–#237 stack with main merged in (unit, integration, clippy, format, build, deploy & test, security audit; automated review: no blocking issues). Closing: this branch existed only for that run.

@alukach alukach closed this Sep 30, 2026
@alukach
alukach deleted the ci/checksum-stack branch September 30, 2026 00:01

This branch was successfully deployed

1 active deployment
preview — da344c9c Deployed Sep 29, 2026 by alukach via Deploy & Test / Deploy #387
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant