feat(sts): let platform IdP tokens act as accounts that trust them - #237
Merged
Merged
Conversation
Contributor
3 tasks done
|
🚀 Latest commit deployed to https://source-data-proxy-pr-237.source-coop.workers.dev
|
alukach
added a commit
that referenced
this pull request
Sep 25, 2026
## What I'm changing `cargo audit` fails on `main`, and so the Security Audit check fails on every open PR, including the machine-identity PRs (#232, #235). The cause is a new advisory against rustls 0.23.42, [RUSTSEC-2026-0285](https://rustsec.org/advisories/RUSTSEC-2026-0285): TLS 1.3 handshake messages were accepted across encryption-level boundaries. It is patched in 0.23.45. This bumps the lockfile to it. ## How I did it `cargo update -p rustls --precise 0.23.45`. A plain `cargo update -p rustls` stops at 0.23.43; the precise bump moves aws-lc-rs to 1.18.1, aws-lc-sys to 0.45.0 and rustls-webpki to 0.103.15 with it. `Cargo.lock` only. rustls never reaches the Worker. `cargo tree -i rustls --target wasm32-unknown-unknown` prints nothing; natively it comes in through multistore → reqwest → hyper-rustls, which the native tests use. Production was not exposed. ## How to test it - `cargo audit`: no vulnerabilities, with the one allowed warning (chacha20) CI already allows. - The pre-commit hook: `cargo fmt --check`, `cargo clippy --target wasm32-unknown-unknown -- -D warnings`, `cargo check --target wasm32-unknown-unknown` and `cargo test`, all passing. ## PR Checklist - [x] This PR has **no** breaking changes. - [x] I have updated or added new tests to cover the changes in this PR. (None apply: lockfile only.) - [x] This PR does not affect the Source Cooperative Frontend & API. ## Related Issues Unblocks the Security Audit check on #232, #235 and the PRs stacked on #235 (#236, #237); their pull-request runs check out the merge with `main`, so a re-run passes once this lands. #220 would catch the next one on a schedule. Part of source-cooperative/source.coop#491 only in that it clears CI for it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alukach
force-pushed
the
feat/platform-trust
branch
from
September 25, 2026 23:02
ba94fbc to
12d784d
Compare
alukach
added this pull request to stack #241
September 29, 2026 20:29
alukach
force-pushed
the
feat/platform-trust
branch
from
September 29, 2026 23:26
12d784d to
e1b6959
Compare
The API-key and platform paths each parsed the query string and form body again. Parse once in fetch, trim the token there, and pass the request-scoped values the exchanges share as one Exchange. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cloudflare logs the URL, and production ships a sample of those logs: a GitHub token there could be replayed for credentials until it expires. Platform tokens are now accepted from the form body only, as API keys already are. test_writes.sts_exchange sends form bodies by default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Env::var fails on an object-valued var and the error was discarded, so writing PLATFORM_ISSUERS as [vars.PLATFORM_ISSUERS], Cloudflare's documented form for a JSON value, disabled every platform issuer with no log. Read it with object_var and take either a string of JSON or the object itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
STS_EXCHANGE_LIMIT was charged before either trust cache was read, so a job matrix behind one NAT address got 429s for answers that cost the Source API nothing, and throttled API-key exchanges from that address too. Read the cached answer first and charge the limit only before a lookup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed or hung fetch of GitHub's JWKS, or a token signed with a key published since the cached copy, came back as 400 InvalidIdentityToken, which SDKs never retry, and a hung fetch stalled the request until the runtime killed it. - Bound the key fetch by STS_REQUEST_TIMEOUT and report any failure as a 500 InternalError, which SDKs retry. - On an unknown key id, look again through a second cache held a minute, so a rotated key is found at once and a forged key id costs the issuer at most one fetch a minute per isolate. platform::verify now takes the keys and stays native. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ApiCaller::Account said the account was always one the proxy had authenticated, which the trusts lookup is not. The API-key and rate-limit comments predated the standing cache, the shared limit and charging platform exchanges only on a trust-cache miss; the README now also says how an unseen key id and a failed key fetch are handled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A forged token past the service-account check would fail verification with a 400, and the trust lookup only follows verification, so the 403 already proves no lookup happened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
They only bundled arguments. Each exchange now takes the request's values directly, within clippy's argument limit, and the platform path is one function whose verify-and-mint body is an async block, so its early refusals still return Err. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Run a second worker whose AUTH_ISSUER is GitHub, so CI's token is a
person token there: tests/test_person_route.py exchanges it at
_default and signs with the result, and refuses a wrong audience and a
tampered signature. No CI test reached the person route with a
validly signed token since GitHub became a platform issuer.
- Give the main worker's AUTH_AUDIENCE the wrong-audience token's
audience, so a platform path that checked the person audiences
instead of GitHub's own would fail CI.
- The stub trusts exactly the subject of the token CI minted, as
source.coop matches a trust, instead of a repository prefix.
- Pin that a 200 saying {"trusted": false} is still refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With FEDERATION_TEST_AUDIENCE set but FEDERATION_TEST_TRUST_ACCOUNT unset, the token named the stub's account, which staging lacks, and the copy-source test failed instead of skipping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wrangler.toml's [dev] host is localhost:8787, so the second worker verified SigV4 against the wrong Host and refused the credentials it had just minted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach
added a commit
that referenced
this pull request
Oct 1, 2026
## What I'm changing Bumps multistore from 0.7.2 to 0.8.0 (developmentseed/multistore#153), which answers `GetCallerIdentity`. `aws-actions/configure-aws-credentials`, the step source.coop's settings page hands out, makes that call after the exchange to check the credentials it exports, so until now the step failed after a successful exchange. The call needs no new routing here: it is answered by the STS handler this Worker already mounts with `with_sts("/.sts", …)`, which in 0.8.0 serves `/.sts/` too and verifies SigV4 over the path the client signed, so the existing `/.sts/` → `/.sts` rewrite doesn't break the signature. What 0.8.0 asked of this repo: - **`RoleConfig.subject_conditions` is `["*"]`.** In 0.8.0 an empty list accepts no subject (developmentseed/multistore#146), which would refuse every person. Any subject is right here: a person's token names the person, and a platform token acts only as an account that trusts its subject (ADR-014). - **`RoleConfig.allow_missing_exp_from` is empty**, so every issuer must set `exp`. Ory always does; API keys never reach `verify_token`. - `mint_temporary_credentials` returns a `Result`, and `BucketConfig::backend_type` is an enum (developmentseed/multistore#155), parsed from the backend string `backend_options` already produces (`s3`/`az`/`gcs`, which its `FromStr` accepts). The README's paragraph saying the action fails now says it works. ## Decisions to flag - **#237's own `exp` check in `platform::subject` stays.** #237 said to drop it with this bump, since multistore now requires `exp` for every issuer this Role trusts. It is redundant but harmless, and removing a security check plus its test is better done as its own reviewed change than inside a dependency bump. - **`GetCallerIdentity`'s `Account` is multistore's fixed synthetic id**, not the service account that #223's 2026-09-23 comment asked for. `Arn` and `UserId` do carry the Role and the account (the credentials' source identity). The action only needs the call to succeed, so this doesn't block the workflow; making `Account` the service account is an upstream change in multistore if we want the action's `aws-account-id` output to mean something. ## Testing - `cargo fmt --check`, `cargo clippy --target wasm32-unknown-unknown -- -D warnings`, `cargo check --target wasm32-unknown-unknown` and `cargo test` pass locally (the pre-push hook). - Not run locally: the Python integration tests and a real `configure-aws-credentials` run. CI's integration job runs the former on this PR's preview; the latter is the functional test for #223 once this deploys. ## Docs and ADRs ADR-014 says the proxy answers `GetCallerIdentity` "once developmentseed/multistore#126 lands"; this PR is that landing and implements the decision, so the ADR still holds. ADR-004 already lists `configure-aws-credentials` as a supported client. docs.source.coop: the GitHub Actions section of the automated-access guide (source-cooperative/docs.source.coop#37) is waiting on this. Part of #223: it is done once a workflow in an unrelated repository writes with only its ambient token, against a deployment. Upstream: developmentseed/multistore#126, developmentseed/multistore#146, developmentseed/multistore#153. Epic: source-cooperative/source.coop#491. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
main, now that feat(sts): exchange opaque API keys at /.sts by hash lookup #235 and feat(sts): serve the FullAccess and ReadOnly roles #236 are merged. This PR's own CI runs the integration tests that need CI's GitHub token.aws-actions/configure-aws-credentials. That action, which source.coop's settings page hands out, checks the credentials it exports withGetCallerIdentity. The proxy cannot answer that call until feat(sts): GetCallerIdentity + real configure-aws-credentials integration test developmentseed/multistore#126 lands, so the action fails after a successful exchange. An AWS SDK's own web-identity provider works now, as does any directAssumeRoleWithWebIdentitycall.exp, is made here. See "Decisions to flag".What I'm changing
A token from a platform issuer, GitHub Actions to begin with, can now be exchanged at
/.sts. It acts as the service account itsRoleArnnames,arn:aws:iam::<owner>--<name>:role/FullAccess, and only if that account trusts the token's issuer and subject (ADR-014). The path runs ahead of the STS route, next to the API-key exchange. Both share one parse of the request, and the token is trimmed, so a token file ending in a newline works.issis read unverified. If it is inPLATFORM_ISSUERS, this path takes the token; otherwise the STS route does, unchanged.InvalidParameterValue, because Cloudflare logs URLs and a logged token could be replayed until it expires.RoleArnmust name an account. That account must be a service account,{owner}--{name}as source.coop'sSERVICE_ACCOUNT_ID_REGEXdefines it (82 characters at most). Any other account is refused as an untrusting one is, before the token is verified or anything is signed as it.nbf, and a requiredexp. A key id missing from the cached keys is looked up once more, so a key GitHub has just rotated in works at once. A failed or timed-out key fetch is a retryableInternalError, notInvalidIdentityToken.POST {SOURCE_API_URL}/api/v1/accounts/{account}/trusts/exchangeswith the verified{issuer, subject}, authenticated as the account, which is the contract on source.coopmain(feat(accounts): trust subjects per account, the way a role's trust policy does source.coop#566). Per account, issuer and subject, a yes is cached for 60 seconds and a no for 10. Only a lookup the cache can't answer is charged toSTS_EXCHANGE_LIMIT, 100 a minute per client address, shared with API-key exchanges.AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity (request id …), and the proxy logs the account, issuer and subject at WARN.Nothing unverified reaches the Source API: a token that fails steps 2 to 4 costs no lookup.
Every refusal at
/.stsfrom the API-key and platform paths now carries the request id, and its message is XML-escaped (#246): it can echoRoleArnor a token'skid. The person route's errors still come from multistore's unescaped builder (developmentseed/multistore#160).Configuration (#223).
PLATFORM_ISSUERSis a JSON object from issuer to the audiences its tokens must carry, written as a string or as a TOML table. The audiences are per issuer, so one issuer's audience never admits another's token (ADR-009). An issuer with no audience is refused, and a value that doesn't parse trusts none. An entry forAUTH_ISSUERis dropped at load with an error, since it would take every person token away from the STS route.AUTH_ISSUERandAUTH_AUDIENCEstill configure the person issuer, whose tokens act as their own subject and ignoreRoleArn's account. An emptyAUTH_AUDIENCEnow disables only the person route with 501; API keys and platform tokens still exchange.wrangler.toml)https://data.source.coop[env.staging])https://data.staging.source.coopwrangler.preview.toml)https://data.staging.source.coopci.yml's.dev.vars)https://auth.example.invalid, which mints nothing, audiencenot-the-data-proxysource-data-proxy-cisource-data-proxy-ciAUTH_ISSUERGitHub's audience is the proxy's origin because source.coop's workflow snippet mints the token with
audience: <proxy origin>. A preview's hostname changes per PR, so previews accept staging's origin, as they already accept staging's Ory clients.Why one PR for #222 and #223. The trust check (#222) is reachable only once a second issuer is configured (#223). The configuration is safe only with the trust check: without it, a GitHub token would act as its own subject under an unlimited Role, the risk ADR-009's important note describes. Neither is useful or safe alone.
#222's issue body is superseded by its later comment, the source-cooperative/source.coop#566 contract. The body asked for an issuer-qualified principal. Under the comment's contract, a platform token's subject never becomes a principal at all: the principal is the account that trusts it. The trust answer is cached per issuer, so two issuers' identical subjects cannot share an entry, which is what #222's "done when" asks.
Decisions to flag
000000000000placeholder from_default's ARN is refused here too.cached_fetchcaches 200s only, and the route says no with a 403, or a 401 for an account it can't resolve. That refusal is stored as{"trusted": false}under the same key, through acache_putextracted fromcached_fetch. So replaying one token costs about one lookup per 10 seconds per account, issuer and subject in each data center, and a trust just added works within 10 seconds.trusted: falseis refused and mints nothing. It is cached like any 200, for 60 seconds; the route never sends one.KEY_EXCHANGE_LIMITbecomesSTS_EXCHANGE_LIMIT, one budget shared by both kinds of exchange, with the same namespace ids. A cached answer costs the Source API nothing and isn't charged, so a job matrix behind one NAT address sharing a subject stays under it. Naming a different account each time still costs a lookup and is charged.STS_REQUEST_TIMEOUT(10s). An unknown key id is looked up again through a second key cache held a minute, so a forged key id costs GitHub at most one fetch a minute per isolate. After a failed fetch, multistore backs off for 30 seconds and keeps no older keys, so a GitHub outage returns 500s for that long.expis required here, and multistore is not bumped. multistore 0.7.2'sverify_tokenchecksexponly when present, the exposure ADR-004's warning says must be closed before other issuers are admitted. Upstream closes it in feat(sts)!: fail closed on empty trust fields, check token type, require exp, log successes developmentseed/multistore#146, released as 1.0.0 by chore(main): release 0.8.0 developmentseed/multistore#153, not yet merged. This path needs only that one check (platform::subject). Bumping would also bring multistoremain'sBucketConfig::backend_typeenum (refactor(core): make BucketConfig::backend_type a typed BackendType enum developmentseed/multistore#155) into the registry for nothing this PR needs. The person path keeps 0.7.2's behaviour, which is harmless while Ory always setsexp. Drop the check when the bump happens.AUTH_ISSUER, so the same token is a person token there. The main worker'sAUTH_AUDIENCEis the audience of the wrong-audience token CI already mints: a platform path that checked the person audiences instead of GitHub's own would accept that token and refuse the real one.How I did it
src/platform.rs(new, wasm-free):parse_issuers, from a JSON string or the table's object;unverified, the header and claims, for routing and the key id;verify, multistore-sts'sfind_keyandverify_tokenagainst keys it is given;subject, which requiresexpand a non-emptysub.src/sts.rs:account(role_arn), the ARN's account segment, andis_service_account_id, source.coop's grammar without a regex crate.src/source_api/cache.rs:get_or_fetch_trust, throughcached_fetchas the account, with the 10-second refusal entry;cached_trust, the cache read alone, so the rate limit is charged only on a miss;cache_put, extracted fromcached_fetch.src/lib.rs:/.stsparses its parameters once, trims the token, and passes them toapi_key_exchangeandplatform_exchange. The 501 for an emptyAUTH_AUDIENCEcomes after both.platform_exchangeis the whole platform path.platform_keysandfetch_keysadd the timeout and the second key cache, throughfutures-util(already in the lockfile through worker) andworker::Delay.sts_refusalandsts_error_xmlbuild every exchange error, escaped and with the request id.STS_EXCHANGE_LIMITreplacesKEY_EXCHANGE_LIMIT.src/config.rs:PLATFORM_ISSUERS, read withvarand, for a table,object_var, withoutAUTH_ISSUER.Cargo.toml:base64(already in the lockfile through multistore-sts) andfutures-util.wrangler.toml(production and staging) andwrangler.preview.tomlgetPLATFORM_ISSUERSand the renamed rate-limit binding.[[ratelimits]]form ci: deploy with wrangler 4 and declare the rate limit as [[ratelimits]] #245 moved it to for wrangler 4, with the same namespace ids.GetCallerIdentitycaveat..github/workflows/ci.yml:.dev.varsas in the table above.subasCI_TRUSTED_SUBJECT.wrangler devruns on 8788, with its own--host, becausewrangler.toml's dev host islocalhost:8787and SigV4 is verified against it..github/workflows/staging.yml: the dormant federation smoke test mints its token only when bothFEDERATION_TEST_AUDIENCEandFEDERATION_TEST_TRUST_ACCOUNTare set. The latter is the staging service account the token acts as, whichtests/test_writes.pyreads asCI_TRUST_ACCOUNT.tests/stub_api.py: the trusts route.CI_TRUSTED_SUBJECTonci-tests--github-ci, as source.coop matches a trust exactly.ci-tests--says-no-with-200answers 200{"trusted": false}.How to test it
cargo test: all suites pass.tests/platform.rs:exp, a missingsuband an emptysubeach refused.tests/sts.rs, the account segment: none for a bare name, an empty account or a truncated ARN.tests/sts.rs, the service-account grammar: ids accepted up to 82 characters, and refused for handles, an Ory UUID, the placeholder, uppercase, underscores, one-character halves, a triple hyphen, a second separator, edge hyphens and 83 characters.cargo fmt --check,cargo clippy --target wasm32-unknown-unknown -- -D warningsandcargo check --target wasm32-unknown-unknown, through the pre-commit hook.pytest tests/ --ignore=tests/test_contract.py --ignore=tests/test_federation.pyagainstwrangler@4 devand the stub, with CI's.dev.vars: 42 passed, 20 skipped. These ran without a real token:RoleArnnames no account is refused withInvalidParameterValue.AccessDenied. The token is forged, so this proves no trusts lookup happened.InvalidIdentityToken, with the request id and no trusts lookup: the worker fetched GitHub's real JWKS, twice, and found no such key.RoleArncontaining markup and&comes back escaped, in a body that parses.PLATFORM_ISSUERSwritten as a TOML table inwrangler.toml, the platform tests pass as with the string.AUTH_AUDIENCEblank, a person token gets 501 and a platform token is still verified.With a real GitHub token, in this PR's CI at
dc8b141: 61 passed, 4 skipped. Beyondtest_writes.py's credentialed tier, which goes through the platform path, these need the token:_default, and the credentials sign a list. A wrong audience and a tampered signature are refused.End to end, after deploy, against staging or this PR's preview, both of which accept staging's audience: give a staging service account a trust for a workflow's subject in source.coop, then run in that workflow:
Then remove the trust, and within a minute the next exchange reads
AccessDenied … (request id …).Docs and ADRs
_default, and a platform token acts only as an account that trusts it. A note there now says so, and its status says implemented in part. ADR-014 (docs(adr): ADR-014 service accounts; amend ADR-010 and ADR-013 #232) does not list ADR-009 under Amends; docs(adr): ADR-014 service accounts; amend ADR-010 and ADR-013 #232 may want to.expmust be enforced before other issuers are admitted. A note under its trust model now points to platform issuers and ADR-014. Itsexpwarning now says platform tokens must carryexpand that the proxy checks it. It also saysalgis checked before the key fetch, which is not so on the platform path: a forged header still costs one cached key lookup beforeverify_tokenrejects its algorithm.source_identityis "the original OIDCsub— the caller's Ory identity". It now says it is the account an API key or a trusted platform token names, which also covers feat(sts): exchange opaque API keys at /.sts by hash lookup #235's keys.ApiCaller::Account's doc now says so. ADR-005's text does not, so it is for docs(adr): ADR-014 service accounts; amend ADR-010 and ADR-013 #232 or docs(adr): revise ADR-013 — API keys are opaque secrets resolved by the platform #234 to amend.maincovers GitHub Actions against the proxy. The unattended-workflow guide, Unattended workflow guide docs.source.coop#34, should give the token-file workflow above and theconfigure-aws-credentialscaveat until feat(sts): GetCallerIdentity + real configure-aws-credentials integration test developmentseed/multistore#126.src/lib/services/github-workflow.tsonmainhands outconfigure-aws-credentialswithaudience: <proxy origin>androle/FullAccess. The audience matchesPLATFORM_ISSUERSin production and staging, and the Role resolves since feat(sts): serve the FullAccess and ReadOnly roles #236. The action itself waits on feat(sts): GetCallerIdentity + real configure-aws-credentials integration test developmentseed/multistore#126.PR Checklist
AUTH_AUDIENCEno longer disables API-key exchange.main.Related Issues
Closes #222. Part of #223: this is the proxy side, and #223's "done when", a workflow in an unrelated repository writing with only its ambient token, also needs a deployment and, for the action source.coop hands out, developmentseed/multistore#126. Builds on #235 and #236, both merged. Includes #246 and #247. ADRs: #232 (ADR-014). Upstream: developmentseed/multistore#126, developmentseed/multistore#146, developmentseed/multistore#153, developmentseed/multistore#160. Epic: source-cooperative/source.coop#491.
🤖 Generated with Claude Code
https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd