Skip to content

feat(sts): let platform IdP tokens act as accounts that trust them - #237

Merged
alukach merged 19 commits into
mainfrom
feat/platform-trust
Oct 1, 2026
Merged

alukach merged 19 commits into
mainfrom
feat/platform-trust

Conversation

@alukach

@alukach alukach commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Important

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 its RoleArn names, 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.

  1. Route by issuer. The token's iss is read unverified. If it is in PLATFORM_ISSUERS, this path takes the token; otherwise the STS route does, unchanged.
  2. Body only. As with API keys, the token must come in the form body. One in the URL is refused with InvalidParameterValue, because Cloudflare logs URLs and a logged token could be replayed until it expires.
  3. Check the request locally. The Role must be one the proxy serves (feat(sts): serve the FullAccess and ReadOnly roles #236), and RoleArn must name an account. That account must be a service account, {owner}--{name} as source.coop's SERVICE_ACCOUNT_ID_REGEX defines 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.
  4. Verify the token against the issuer's JWKS: signature, issuer, that issuer's own audiences, nbf, and a required exp. 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 retryable InternalError, not InvalidIdentityToken.
  5. Ask the account. POST {SOURCE_API_URL}/api/v1/accounts/{account}/trusts/exchanges with the verified {issuer, subject}, authenticated as the account, which is the contract on source.coop main (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 to STS_EXCHANGE_LIMIT, 100 a minute per client address, shared with API-key exchanges.
  6. Mint under the named Role, with the account as the principal, never the token's subject. Every refusal of the trust reads 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 /.sts from the API-key and platform paths now carries the request id, and its message is XML-escaped (#246): it can echo RoleArn or a token's kid. The person route's errors still come from multistore's unescaped builder (developmentseed/multistore#160).

Configuration (#223). PLATFORM_ISSUERS is 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 for AUTH_ISSUER is dropped at load with an error, since it would take every person token away from the STS route. AUTH_ISSUER and AUTH_AUDIENCE still configure the person issuer, whose tokens act as their own subject and ignore RoleArn's account. An empty AUTH_AUDIENCE now disables only the person route with 501; API keys and platform tokens still exchange.

Config Person issuer Platform issuers
production (wrangler.toml) Ory, as before GitHub, audience https://data.source.coop
staging ([env.staging]) staging Ory, as before GitHub, audience https://data.staging.source.coop
previews (wrangler.preview.toml) staging Ory, as before GitHub, audience https://data.staging.source.coop
CI, main worker (ci.yml's .dev.vars) https://auth.example.invalid, which mints nothing, audience not-the-data-proxy GitHub, audience source-data-proxy-ci
CI, second worker (port 8788) GitHub, audience source-data-proxy-ci none: the GitHub entry is dropped as AUTH_ISSUER

GitHub'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

  • Only a service account can be named. The minted principal is the account segment as given, and source.coop resolves a principal as an Ory identity first. An Ory UUID fits the person and organisation handle grammar, so if a trust ever existed on such an account, platform credentials would act as a person. source.coop writes trusts only to service accounts today, so this is defense in depth, and it matches ADR-014. The 000000000000 placeholder from _default's ARN is refused here too.
  • A yes is cached for 60 seconds, a no for 10, under one key. cached_fetch caches 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 a cache_put extracted from cached_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.
  • The body is read as well as the status. A 200 whose body says trusted: false is refused and mints nothing. It is cached like any 200, for 60 seconds; the route never sends one.
  • The rate limit is charged only on a cache miss. Anyone can mint a GitHub token for this audience, so a lookup is bounded like a flood of junk keys. feat(sts): exchange opaque API keys at /.sts by hash lookup #235's KEY_EXCHANGE_LIMIT becomes STS_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.
  • Key fetches are bounded and retried, not refused. The fetch races 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.
  • A 404 from the trusts route is a 500, not a refusal. The route answers for any account (401 when it doesn't exist), so a 404 means the API doesn't serve the route at all. That is a deployment mismatch, the same for every account, so it reveals nothing about any one account.
  • 401 and 403 read the same to the caller. source.coop answers 401 for an account that doesn't exist, and also when it can't authenticate the proxy. Both are refused like "not trusted" and cached for 10 seconds. The WARN line has the account, issuer and subject, and source.coop's log says which case it was.
  • The proxy vouches for the account before it is established. To ask the question, it signs its usual on-behalf-of assertion as the account the caller names, and sends it only to that account's trusts route. This is ADR-014's design. ADR-005 describes such assertions only for callers the proxy has already authenticated (see Docs and ADRs).
  • exp is required here, and multistore is not bumped. multistore 0.7.2's verify_token checks exp only 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 multistore main's BucketConfig::backend_type enum (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 sets exp. Drop the check when the bump happens.
  • CI covers both routes with GitHub's token. On the main worker the token takes the platform path, as in production. A second worker names GitHub as AUTH_ISSUER, so the same token is a person token there. The main worker's AUTH_AUDIENCE is 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.
  • Successful exchanges are logged at INFO, which production drops. A cached yes never reaches source.coop, so nothing durable records which workflow minted a set of credentials. Not addressed here.

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's find_key and verify_token against keys it is given;
    • subject, which requires exp and a non-empty sub.
  • src/sts.rs: account(role_arn), the ARN's account segment, and is_service_account_id, source.coop's grammar without a regex crate.
  • src/source_api/cache.rs:
    • get_or_fetch_trust, through cached_fetch as 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 from cached_fetch.
  • src/lib.rs:
    • Dispatch: /.sts parses its parameters once, trims the token, and passes them to api_key_exchange and platform_exchange. The 501 for an empty AUTH_AUDIENCE comes after both.
    • Platform path: platform_exchange is the whole platform path. platform_keys and fetch_keys add the timeout and the second key cache, through futures-util (already in the lockfile through worker) and worker::Delay.
    • Errors: sts_refusal and sts_error_xml build every exchange error, escaped and with the request id.
    • Rate limit: STS_EXCHANGE_LIMIT replaces KEY_EXCHANGE_LIMIT.
  • src/config.rs: PLATFORM_ISSUERS, read with var and, for a table, object_var, without AUTH_ISSUER. Cargo.toml: base64 (already in the lockfile through multistore-sts) and futures-util.
  • Wrangler config:
  • README: the variable, the binding, and a "Platform identity providers" section with the GetCallerIdentity caveat.
  • .github/workflows/ci.yml:
    • .dev.vars as in the table above.
    • The mint step exports the token's sub as CI_TRUSTED_SUBJECT.
    • A second wrangler dev runs on 8788, with its own --host, because wrangler.toml's dev host is localhost:8787 and SigV4 is verified against it.
  • .github/workflows/staging.yml: the dormant federation smoke test mints its token only when both FEDERATION_TEST_AUDIENCE and FEDERATION_TEST_TRUST_ACCOUNT are set. The latter is the staging service account the token acts as, which tests/test_writes.py reads as CI_TRUST_ACCOUNT.
  • tests/stub_api.py: the trusts route.
    • It says yes only for exactly CI_TRUSTED_SUBJECT on ci-tests--github-ci, as source.coop matches a trust exactly.
    • ci-tests--says-no-with-200 answers 200 {"trusted": false}.
    • It refuses an assertion made as anyone but the account, though it reads the assertion without verifying its signature.
    • It also keeps a per-account lookup counter and records who each product was looked up as.

How to test it

  • cargo test: all suites pass.

    • tests/platform.rs:
      • audiences kept per issuer;
      • the table form read like the string;
      • an issuer without an audience dropped;
      • unparseable config trusting none;
      • a JWT read unverified, and non-JWTs (an API key among them) not read;
      • a verified token's subject, with a missing exp, a missing sub and an empty sub each 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 warnings and cargo check --target wasm32-unknown-unknown, through the pre-commit hook.

  • pytest tests/ --ignore=tests/test_contract.py --ignore=tests/test_federation.py against wrangler@4 dev and the stub, with CI's .dev.vars: 42 passed, 20 skipped. These ran without a real token:

    • A GitHub-issuer token whose RoleArn names no account is refused with InvalidParameterValue.
    • A person handle, an Ory UUID or the placeholder named as the account is refused with the untrusted AccessDenied. The token is forged, so this proves no trusts lookup happened.
    • A forged GitHub token naming a service account is refused as InvalidIdentityToken, with the request id and no trusts lookup: the worker fetched GitHub's real JWKS, twice, and found no such key.
    • A platform token in the URL is refused before any trusts lookup.
    • A RoleArn containing markup and & comes back escaped, in a body that parses.
    • With PLATFORM_ISSUERS written as a TOML table in wrangler.toml, the platform tests pass as with the string.
    • With AUTH_AUDIENCE blank, 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. Beyond test_writes.py's credentialed tier, which goes through the platform path, these need the token:

    • A trusted workflow's credentials act as the account: the stub records the account, not the GitHub subject, as the product lookup's subject.
    • A token file's trailing newline is accepted.
    • An account that doesn't trust the workflow refuses it with the request id, and a replay within 10 seconds costs no second lookup. A 200 saying no is refused too.
    • A yes is cached.
    • On the second worker the token is a person token. It exchanges at _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:

    permissions: { id-token: write }
    steps:
      - run: |
          curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://data.staging.source.coop" | jq -r .value > "$RUNNER_TEMP/token"
      - env:
          AWS_WEB_IDENTITY_TOKEN_FILE: ${{ runner.temp }}/token
          AWS_ROLE_ARN: arn:aws:iam::<service-account-id>:role/FullAccess
          AWS_ENDPOINT_URL_STS: https://<proxy>/.sts
          AWS_ENDPOINT_URL_S3: https://<proxy>
          AWS_REGION: us-west-2
        run: aws s3 ls s3://<owner>/<product>/

    Then remove the trust, and within a minute the next exchange reads AccessDenied … (request id …).

Docs and ADRs

PR Checklist

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

@claude

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

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


I'll analyze this and get back to you.


💰 Estimated review cost: $0.23 · 0m26s · 6 turns

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

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

  • Date: 2026-10-01T16:06:13Z
  • Commit: 671d4a9

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
alukach force-pushed the feat/platform-trust branch from ba94fbc to 12d784d Compare September 25, 2026 23:02
@alukach
alukach added this pull request to stack #241 September 29, 2026 20:29
@alukach
alukach force-pushed the feat/platform-trust branch from 12d784d to e1b6959 Compare September 29, 2026 23:26
alukach and others added 9 commits October 1, 2026 00:12
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>
alukach and others added 2 commits October 1, 2026 00:14
- 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
alukach merged commit 14efd33 into main Oct 1, 2026
20 checks passed
@alukach
alukach deleted the feat/platform-trust branch October 1, 2026 16:20
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

1 active deployment
preview — d8613f7d Deployed Oct 1, 2026 by alukach via Deploy & Test / Deploy #406
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.

Namespace the credential subject by verified issuer

1 participant