Repository navigation
feat(sts): GetCallerIdentity + real configure-aws-credentials integration test - #126
Merged
Merged
Conversation
|
Claude finished @alukach's task in 18s —— View job ✅ No blocking issues — safe to merge. I read the diff and traced Non-blocking observations
Simplify (ponytail)
💰 Estimated review cost: $0.16 · 0m18s · 6 turns |
|
📖 Docs preview deployed to https://multistore-docs-pr-126.development-seed.workers.dev
|
|
🚀 Latest commit deployed to https://multistore-proxy-pr-126.development-seed.workers.dev
|
alukach
marked this pull request as ready for review
July 27, 2026 19:40
alukach
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Aug 26, 2026
Out of scope for ADR-004, which specifies the exchange rather than the compatibility of any one client. The gap is already tracked in #184 and developmentseed/multistore#126. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt
alukach
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Sep 23, 2026
The action requires the aws partition and validates with GetCallerIdentity (developmentseed/multistore#126). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
alukach
added a commit
to source-cooperative/source.coop
that referenced
this pull request
Sep 24, 2026
Closes #547. Part of #491. Stacked on #566, now merged, so the diff is this branch alone: the feature commit, the immutable-subject commit, two review-fix commits, a redesign of the form and the list, a fix for writing trusts, owner-namespaced ids, and an account page that is where creating lands and where grants are edited, with the create form's product list, showing what it reaches and granting more from a new row. ## What The real version of the flow #495 mocked, built on the model (#563), the membership pages (#564) and account trusts (#566). Rebuilt on the trust model in one commit; what review changed follows as commits of its own. **Settings → Service Accounts** appears for individuals and organizations alike, to whoever `canManageAccount` says manages the account. The list page loads each service account with its trusts, its grants and (from #570) its keys. **`ServiceAccountForm`** — who it is (the name, and the id derived from it, shown as code with an Edit link the way the data-connection form shows its id), how software signs in (any number of GitHub workflows, each a card with the repository on its own row — `owner/repo`, or `owner@id/repo@id` for the immutable subjects GitHub mints for repositories created after July 2026 or opted in — then a Ref/Environment segmented control and its value, and the exact subject shown as you type), what it may reach (the products it reaches, each with Read / Read and write and a red X, and **Grant a product**, which adds a row with a dropdown of the owner's other products (each option the product's name over its `owner/product` path in small monospace), a Read / Read and write choice, and a green check that finalizes it, beside an X that cancels — the same `ProductAccessList` on the create form and the account's page; each title opens the product in a new tab, and the form holds its grants until it is submitted). **The id is namespaced under the owner:** `{owner}--{id}`, such as `acme--nightly-sync`, joined by the `--` account-owned data connections already use. Two owners can each have a `nightly-sync`, a service account never takes a handle a person or organization might want (their ids cannot contain `--`), and no service-account id can equal an Ory identity id, which is a UUID. The form asks for the short id and shows the whole one; the action composes it. `ServiceAccountSchema` requires the composed form, and a membership's member id accepts it, which granting a namespaced service account a product needs. The general `createAccount` action no longer takes `type=service`: nothing posted it, and it created service accounts without the owner checks or the namespace. Submitting writes the account, its grants and its trusts, and redirects to the account's page, where each trusted workflow's example usage is a click away. Nothing has to run first, and the step carries nothing that expires. **`ServiceAccountList`** — one row per account in the data connections' own `ConnectionList`/`ConnectionRow`: the name linking to the account's page, its id, a marker when it is disabled, and how many workflows it trusts and products it reaches. **`ServiceAccountDetail`**, at `/edit/account/{owner}/service-accounts/{id}` — the account's page: the workflows it trusts as rows, each removed with a red X (captioned "Remove" on hover, labelled with the subject for screen readers), each with an **Example usage** link that opens the step the workflow adds in a modal, and the "Trust a GitHub workflow" dialog on the section header, which writes the trust and closes onto the new row; the products it reaches, each with Read / Read and write and a red X, and **Grant a product**, which adds a row with a dropdown of the owner's other products (each option the product's name over its `owner/product` path in small monospace), a Read / Read and write choice, and a green check that finalizes it, beside an X that cancels — the same `ProductAccessList` on the create form and the account's page, here **saved as each is made** (the check, a change of access, or an X), shown at once (`useOptimistic`) with the controls held while it saves; each title opens the product in a new tab so a viewer can check what it holds; and Disable and Delete set apart in a danger zone. The page resolves the account through `managedServiceAccount` and 404s unless it is under its own owner. The actions revalidate the list and the page. Delete confirms in an alert dialog whose submit runs the action (a plain button, not `AlertDialog.Action`, which would close the dialog before the action ran) and shows the action's message if it refuses; deleting removes the grants and the account with its trusts, then redirects to the list. The trust dialog's form lives inside `Dialog.Content`, so each open starts fresh. The page passes the data-proxy origin down for the example; without one, no example is offered. **The workflow snippet** (`src/lib/services/github-workflow.ts`) is the `aws-actions/configure-aws-credentials@v6` step pointed at the data proxy — `sts-endpoint`, `audience` = the proxy origin — naming the account in `role-to-assume: arn:aws:iam::<service-account-id>:role/FullAccess`, plus `env` pointing S3 clients at the proxy. From then on any S3 client in the job works, if the account trusts the workflow's subject. **Depends on developmentseed/multistore#126:** the action validates the credentials it exports with `GetCallerIdentity`, which the proxy answers once that lands. **`AccountInfoHoverCard`**, the card an account name opens on hover anywhere in the app, marks a service account with the outlined "Service account" badge the picker and memberships table already use. **What this PR adds to the model:** `canManageServiceAccount(session, account, owner)` — whoever manages the owner manages the service account, answered through `canManageAccount` on the fetched owner, so an admin is included, a disabled owner takes its service accounts out of reach with it (admins aside), and nothing that is not a service account qualifies; a disabled service account is still managed so it can be re-enabled or deleted. `managedServiceAccount(session, id)` in `src/lib/accounts/service-accounts.ts` is the one resolver the lifecycle actions (and, in #570, the key actions) go through: it loads the account and its owner and answers null for anything not managed. And `AccountTrustsTable.create` (schema-validated, conditional, `AlreadyTrustedError`) and `delete`, which were in #566 until review noted nothing there called them. **Server actions** (`src/lib/actions/service-accounts.ts`): `createServiceAccount`, `addGithubTrust`, `removeTrust`, `setServiceAccountDisabled`, `deleteServiceAccount`, and for grants `setProductAccess`. Create loads the owner and requires an enabled individual or organization the caller manages — settled before any of its products are read, so the form can't be used to probe another account's catalogue — deduplicates the workflows and grants it was posted. The rest go through `managedServiceAccount`; `setProductAccess` also applies `serviceAccountGrantProblem` (read or write, the owner's products only), changes the grant the account already has rather than adding a second, touches only the account's own grants, and on none revokes by setting the membership Revoked, as a person's is. No action builds a workflow step: the page does, from the proxy origin. A disabled account takes no new trust, and a repeat is named for what it is. `removeTrust` deletes by the account's own key, so it can never touch another account's. ## Stories On this branch's deploy: - `ServiceAccountForm` › **Default** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountform--default (submitting redirects in the app; Storybook cannot follow that, so the mocked action just resolves) - `ServiceAccountForm` › **NoProducts** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountform--no-products - `ServiceAccountList` › **Default**, **Disabled**, **Empty** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountlist--default - `AccountInfoHoverCard` › **ServiceAccount** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/components-accounts-accountinfohovercard--service-account (hover the name) - `ProductAccessList` › **NothingGranted**, **SomeGranted**, **EverythingGranted**, **Saving**, **NoProducts** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-productaccesslist--some-granted (controlled, so every control works, Grant a product included) - `ServiceAccountDetail` › **Default**, **Disabled**, **Empty** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--default ("Example usage" opens the step in a modal; "Grant a product" adds the row to choose a product and its access; an access change shows while the mocked save runs, then springs back, since the mock saves nothing) - `WorkflowSnippet` › **Default**, **ImmutableSubject** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-workflowsnippet--default - `GithubWorkflowFields` › **Empty**, **ImmutableRepositoryWithRemove** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-githubworkflowfields--empty (controlled, so the stories hold the workflow in state and the fields can be typed into) The form, the detail and the dialog use `useActionState`, so they are on the smoke test's cannot-render-under-jest list alongside the other action-state forms; `WorkflowSnippet`, `GithubWorkflowFields` and `ProductAccessList` render there. The stories and the Storybook mock build the step with `githubWorkflowStep` rather than carrying a copy of it. ## Screenshots The create form: the id derived from the name, one workflow card, and each product's access chosen in place:  The list, one row per account:  The id, derived from the name under the owner, and the same id opened for editing:   An account's page, where creating one lands. Each trusted workflow has Example usage; the products it reaches are listed with their access and an X, with Grant a product below:  After Grant a product: a row to choose the product and its access, finalized with the check:  A trust row, with the Remove caption showing on the X:  The grant row's dropdown, each product's name over its path:  The create form uses the same list and grant row, and holds its grants until it is submitted. Example usage, opened from a workflow's row:  `WorkflowSnippet` on its own, with a subject in the immutable form pinned to an environment — the block scrolls sideways rather than wrapping the YAML:  `GithubWorkflowFields` as the create form stacks it, an immutable repository name pinned to an environment, with the Remove button:  A service account's hover card:  ## Testing - `npx jest` — all suites pass, re-run after the redesign. `service-accounts.test.ts`: create writes the account, grants and trusts once each however many times a field was posted, and redirects to the account's page; refuses an unpinned workflow, a product the owner lacks and a bad role before writing; refuses a non-manager, a missing owner and a service-account owner, and reports a taken id on the short-id field; delete removes grants then the account and redirects to the list; a short id containing its own `--` is refused; disable/enable; remove a trust by the account's key; trust one more workflow, refusing an unpinned subject, a repeat and a disabled account; every lifecycle action refuses what `managedServiceAccount` does not hand back. `accounts/service-accounts.test.ts`: the resolver hands back the account only when it is a service account whose owner is found and managed. `github-workflow.test.ts`: the step names the account and the proxy and carries nothing that expires. - `authz.test.ts`: the manager rules — admin-over-organization refused, some other account offered as the owner refused, a disabled owner refused for its managers and allowed for an admin. `account-trusts.test.ts`: write keyed by account and identity, only once; `AlreadyTrustedError`; malformed rows refused before the table; delete by the same key. - `service-accounts.test.ts`, `setProductAccess`: a product the account does not reach is granted as a member of the owner; one it reaches has its grant changed rather than a second added, and none revokes it (and is a no-op where there is no grant); a product the owner lacks, a role beyond read or write, and a non-manager are refused before any write; only the account's own grants are read. - `account-trusts.test.ts` also asserts the create condition names `identity` by placeholder. Unit tests mock DynamoDB, so they could not catch the reserved word; it surfaced when a trust write failed on a deployment with "Attribute name is a reserved keyword; reserved keyword: identity". - `npm run type-check`, `next lint`, `npm run build-storybook` — clean. ## Docs and ADRs data.source.coop: ADR-014 (source-cooperative/data.source.coop#232) describes this flow as amended with #566, and is amended again for the namespaced id (commit ae85ff72 on that PR), which replaces its "no reserved id namespace". docs.source.coop: the unattended-workflow guide (source-cooperative/docs.source.coop#34) will carry the workflow step this issues; it should read the way the AWS one does. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
3 of 4 tasks
PR #112 made /.sts a drop-in AssumeRoleWithWebIdentity target for AWS SDK STS clients, which send parameters in a form-encoded POST body rather than the query string. aws-actions/configure-aws-credentials relies on that path. The integration suite only exercised the query-string GET, so a regression in form-body parsing would go unnoticed. - Extract XML->creds parsing into `_parse_sts_credentials`, shared by both the existing query GET helper and the new form-POST helper. - Add `assume_role_form_post`, reproducing the exact wire request the action emits (application/x-www-form-urlencoded body via requests `data=`). - Add `test_configure_aws_credentials_action_grants_write_access`: assume the github-actions role that way and prove a PUT/GET round-trip on private-uploads succeeds. Runs in the OIDC-gated class (id-token: write CI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`aws-actions/configure-aws-credentials` (and other AWS tooling) validates freshly assumed credentials by issuing an unconditional, SigV4-signed `GetCallerIdentity` call before exporting them. multistore-sts only parsed `AssumeRoleWithWebIdentity`, so the action could never succeed against the proxy — it would retry GetCallerIdentity 12 times and then fail the step. Closes #127. - **`caller_identity`** (new module) — `handle_get_caller_identity` authenticates the call against the sealed session token: it recovers the minted credentials from `x-amz-security-token`, checks the auth-header access key matches, and verifies the SigV4 signature with the proxy's own `verify_sigv4_signature` over the recovered secret. AWS SDKs sign STS POSTs over the SHA-256 of the form body, usually without an `x-amz-content-sha256` header, so the payload hash is taken from that header when present and recomputed from the collected body otherwise. Verification runs over the raw signing path so the trailing slash AWS SDK JS v3 appends (`/.sts` -> `/.sts/`) is honored, not normalized. - **`request::is_get_caller_identity`** — detects the action in a query string or form body (the two places AWS SDKs put STS parameters). - **`responses`** — `build_caller_identity_response` emits STS-shaped `GetCallerIdentityResponse` XML. `Account` is the fabricated `SYNTHETIC_ACCOUNT_ID` (`000000000000`) — the proxy has no real AWS account — used consistently in the assumed-role ARN so the identity is coherent. `build_sts_error_response` now maps `SignatureDoesNotMatch`/`ExpiredCredentials` to STS-shaped 403s instead of a generic 500. - **`route_handler`** — `StsHandler` dispatches GetCallerIdentity (authenticated, needs the full request) ahead of the unauthenticated assume-role exchange. `with_sts` now registers both `/.sts` and `/.sts/` (adding a `Clone` bound on the config) so SDK-JS callers, which hit the trailing-slash path, reach the handler. - **docs** — `auth/proxy-auth.md` documents GetCallerIdentity, the synthetic account, the trailing-slash rule, and a `configure-aws-credentials` workflow example; `reference/operations.md` and `architecture/crate-layout.md` updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Python suite only reproduced the action's wire requests. Now that the proxy serves GetCallerIdentity (#127), run the actual action end to end and prove the credentials it exports can write. - **ci.yml** — after pytest, the `integration` job runs `aws-actions/configure-aws-credentials@v6` (SHA-pinned) with `sts-endpoint` pointed at the proxy, then `aws s3 cp` uploads/downloads/deletes an object in `private-uploads` using the exported credentials. The action's own mandatory GetCallerIdentity validation must pass for the step to succeed, so this covers the full assume-role -> validate -> use flow. - **wrangler.integration.toml** — adds a role keyed by the full ARN `arn:aws:iam::000000000000:role/github-actions` (write scope on private-uploads). The action sends `role-to-assume` verbatim as the STS RoleArn and `get_role` matches `role_id` exactly, so the config key must be the full ARN; account `000000000000` matches the synthetic GetCallerIdentity account. - **test_integration.py** — adds `test_get_caller_identity` (botocore signs a real GetCallerIdentity over the assumed creds; asserts the synthetic account and ARN) and `test_get_caller_identity_rejects_static_credentials` (no session token -> 403). Rescopes the earlier form-POST test to `test_sdk_form_post_assume_role_grants_write_access`, since the headline "action works" claim is now proven by the real action in CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alukach
force-pushed
the
worktree-aws-action-integration-test
branch
from
September 25, 2026 23:00
5906b30 to
f5f9068
Compare
alukach
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Sep 25, 2026
The action requires the aws partition and validates with GetCallerIdentity (developmentseed/multistore#126). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
alukach
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Sep 25, 2026
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
Since #146 a role with no required_audiences fails config validation at startup, because it could never accept a token. The role this PR adds for the configure-aws-credentials test had none, so wrangler dev refused the whole PROXY_CONFIG and every integration test failed. It now requires sts.amazonaws.com, the action's default audience and the one the other GitHub Actions roles use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
Align with the access-key check in auth/identity.rs by exporting constant_time_eq from multistore::auth. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Sep 29, 2026
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
alukach
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Sep 29, 2026
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
alukach
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Sep 30, 2026
On `main`, now that #235 is merged; two commits. **How to test it** lists what I ran locally. ## What I'm changing `/.sts` serves three hardcoded Roles instead of one, for ID tokens and API keys alike, as ADR-014 (#232) specifies: | Role | Credentials may | | --- | --- | | `FullAccess` | do everything the account's memberships allow | | `ReadOnly` | do the same, except write | | `_default` | do what `FullAccess` does; kept because deployed clients name it | - **Names.** Each Role is accepted bare or as the `role/<name>` resource of an ARN of any partition and account (`arn:aws:iam::000000000000:role/ReadOnly`), because SDKs check the ARN shape before sending. A pathed resource (`role/team/ReadOnly`), another case (`readonly`) or any other name is `RoleNotFound`, never a fallback to a default. - **The ceiling.** `ReadOnly` seals one scope into the session token's `allowed_scopes`: every product (`*`), read actions only (`GetObject`, `HeadObject`, `ListBucket`). The gateway never calls multistore's own scope check (`auth::authorize`), because this proxy's registry is the authorizer, so the registry enforces it. `get_bucket` checks the ceiling first, before any Source API lookup, and refuses with the same `AccessDenied` as every other refusal (ADR-011, Denial Semantics). An INFO log line is the only record of why. - **It only subtracts.** A write the ceiling allows still needs the account's own write permission, fetched as before. - **No scopes, no ceiling.** `FullAccess` and `_default` seal no scopes, as `_default` never has, so every session already issued keeps working. A `_default` exchange returns the same response as before, `AssumedRoleId` included. - **Both entry points.** The STS route (`StsCredentialRegistry::get_role`) and the API-key exchange (`exchange_api_key`, which accepted only `_default`) share one lookup, `sts::role`. The key exchange's log line now names the Role. source.coop `main`'s GitHub integration snippet (`src/lib/services/github-workflow.ts`) already hands out `arn:aws:iam::<service-account-id>:role/FullAccess`. This PR makes that Role name resolve. The rest of that path is the next PR in this stack, which trusts a GitHub token for the account it names (#222, #223), plus `GetCallerIdentity` in developmentseed/multistore#126. **Decisions to flag** - **The first comment on #221 is superseded.** It says `ReadOnly` cannot be enforced until multistore plumbs the assumed Role through to the registry. That isn't needed: what is sealed is the Role's ceiling, not its name, and `AuthenticatedIdentity.allowed_scopes` already reaches `BucketRegistry::get_bucket` (multistore 0.7.2, `auth/identity.rs`). No multistore change. - **The ceiling understands only what the two Roles need**: a scope over every product (`*`) with no prefix. A scope naming one product or a prefix permits nothing rather than being half-interpreted. ADR-011's resource matching can arrive with account-owned Roles. The `*` sentinel means something only to this proxy, because multistore's exact-match `authorize` never runs here. - **Empty scopes mean no ceiling in the registry.** This is the reverse of multistore's `authorize`, where empty means deny-all, as ADR-001 noted. It is safe because only this proxy mints session tokens, sealed with `SESSION_TOKEN_KEY`, and every `_default` session carries empty scopes. - **`ReadOnly` excludes `GetObjectVersion`** (reading a versioned copy source). The write gate already classes it as a write, and a native test pins that the ceiling and `is_write_action` agree on every action. That is the divergence ADR-011 warns about. - **ARNs are now parsed as six colon-separated fields with a `role/<name>` resource.** The old `_default` check matched only the `arn:` prefix and the `:role/_default` suffix, so it also accepted malformed strings such as `arn:role/_default`. Those are now refused, and no SDK sends them anyway: they fail the SDKs' own 20-character minimum. ## How I did it - `src/sts.rs`: `role(role_arn, issuer, audiences, max_session)` returns the named Role or `None`, replacing `default_role` and `is_default_role`. `ALL_PRODUCTS` is the `*` sentinel. - `src/authz.rs`: `ceiling_permits(scopes, action)`. - `src/source_api/registry.rs`: the check at the top of `get_bucket`. - `src/lib.rs`: the key exchange resolves the named Role before its lookup, as before, and mints under it. - `README.md`: a Roles section. `adrs/001`, `004` and `011`, in a separate commit: see below. ## How to test it - `cargo test`: all suites pass. `tests/sts.rs` covers bare and ARN names for all three Roles, including a service-account-shaped account segment, and refusal of unknown, lower-case, pathed, suffixed and non-role names. `tests/authz.rs` covers that `ReadOnly`'s sealed ceiling allows exactly the actions `is_write_action` calls reads, that `FullAccess` and `_default` allow every action, and that narrower scopes permit nothing. `tests/keys.rs` checks that a `ReadOnly` key's session unseals with `ReadOnly`'s ceiling. - `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` against `wrangler dev` (wrangler 3.114) and `tests/stub_api.py`: 35 passed, 15 skipped. The skipped ones are the credentialed tests that need CI's GitHub token. The new `test_read_only_refuses_a_write_before_anything_is_looked_up` exchanges the stub's live key for `ReadOnly` and for `FullAccess`, then writes to a product the stub has never heard of. `ReadOnly` gets `AccessDenied`. `FullAccess` gets past the ceiling to the product lookup and gets `NoSuchBucket`, and so does a `ReadOnly` read. The upstream write itself fails closed in CI, so the product lookup is where the ceiling's path can be told apart. I also disabled the check, saw this test fail (`ReadOnly` got `NoSuchBucket`), and restored it. ## Docs and ADRs - **ADR-011** (Role-Ceiling Authorization) still describes the model. This PR implements its step 2 and its denial semantics for the two hardcoded Roles, so its status line now says it is implemented in part. - **ADR-004** described "a single built-in Role, `_default`". A note under that section now points to ADR-014 and this PR, and ADR-004's "Implemented by" line lists this PR. - **ADR-001** said `assumed_role_id` is "currently always `_default`" and that the sealed `allowed_scopes` is empty and "not enforced on this path". Its credential table now names the three Roles and their ceilings. Its bullet now says the registry enforces the ceiling and reads empty as no ceiling. - **ADR-014** (#232) says "two Roles are hardcoded, `FullAccess` and `ReadOnly`, with `_default` kept as an alias (#221)". This PR implements that, so it still holds. ADR-010's scope note is ADR-014's amendment and also holds. - **ADR-013 as revised** (#234) says a key's "role must be one the proxy serves". That still holds, and keys can now name all three Roles. - **docs.source.coop**: no page on `main` describes `/.sts` or its Roles yet. The unattended-workflow guide, source-cooperative/docs.source.coop#34, is where the Role names belong. ## PR Checklist - [x] This PR has **no** breaking changes. `_default` behaves and answers as before; only malformed ARNs that no SDK can send are newly refused. - [x] I have updated or added new tests to cover the changes in this PR. - [x] This PR affects the [Source Cooperative Frontend & API](https://github.com/source-cooperative/source.coop): source.coop `main`'s GitHub snippet names `role/FullAccess`, which resolves from this PR on. No source.coop change is needed. ## Related Issues Closes #221. Builds on #235, merged. ADRs: #232 (ADR-014), #234 (ADR-013 revised). Epic: source-cooperative/source.coop#491. 🤖 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
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Sep 30, 2026
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
alukach
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Sep 30, 2026
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
alukach
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Sep 30, 2026
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
alukach
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Oct 1, 2026
) > [!IMPORTANT] > - **On `main`**, now that #235 and #236 are merged. This PR's own CI runs the integration tests that need CI's GitHub token. > - **Blocked externally for `aws-actions/configure-aws-credentials`.** That action, which source.coop's settings page hands out, checks the credentials it exports with `GetCallerIdentity`. The proxy cannot answer that call until 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 direct `AssumeRoleWithWebIdentity` call. > - **No multistore bump.** multistore 0.7.2 is enough: the one fail-closed check this needs, a required `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 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 (#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` (source-cooperative/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. #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 developmentseed/multistore#146, released as 1.0.0 by 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 (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:** - `wrangler.toml` (production and staging) and `wrangler.preview.toml` get `PLATFORM_ISSUERS` and the renamed rate-limit binding. - The binding keeps the `[[ratelimits]]` form #245 moved it to for wrangler 4, with the same namespace ids. - **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: ```yaml 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 - **ADR-009** (platform IdPs): its decision holds (platform issuers, per-issuer audiences, fail closed per issuer), and this implements it. Its migration step 3 and its important note are superseded by ADR-014: platform issuers are not added to `_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 (#232) does not list ADR-009 under **Amends**; #232 may want to. - **ADR-004** trusted "exactly one OIDC issuer" and warned that `exp` must be enforced before other issuers are admitted. A note under its trust model now points to platform issuers and ADR-014. Its `exp` warning now says platform tokens must carry `exp` and that the proxy checks it. It also says `alg` is checked before the key fetch, which is not so on the platform path: a forged header still costs one cached key lookup before `verify_token` rejects its algorithm. - **ADR-001** said `source_identity` is "the original OIDC `sub` — the caller's Ory identity". It now says it is the account an API key or a trusted platform token names, which also covers #235's keys. - **ADR-005** says the proxy signs lookups as a caller it has authenticated. The trusts lookup is signed as the account a caller names, before that account is established, and only for that one route. ADR-014 (#232) records this, and `ApiCaller::Account`'s doc now says so. ADR-005's text does not, so it is for #232 or #234 to amend. - **ADR-014** (#232), "the token path is then…", is what this implements, and it still holds. - **docs.source.coop**: no page on `main` covers GitHub Actions against the proxy. The unattended-workflow guide, source-cooperative/docs.source.coop#34, should give the token-file workflow above and the `configure-aws-credentials` caveat until developmentseed/multistore#126. - **source.coop**: `src/lib/services/github-workflow.ts` on `main` hands out `configure-aws-credentials` with `audience: <proxy origin>` and `role/FullAccess`. The audience matches `PLATFORM_ISSUERS` in production and staging, and the Role resolves since #236. The action itself waits on developmentseed/multistore#126. ## PR Checklist - [x] This PR has **no** breaking changes. Person-issuer tokens and API keys behave as before; GitHub tokens, refused until now as an untrusted issuer, take the new path. An empty `AUTH_AUDIENCE` no longer disables API-key exchange. - [x] I have updated or added new tests to cover the changes in this PR. - [x] This PR affects the [Source Cooperative Frontend & API](https://github.com/source-cooperative/source.coop): it calls the trusts route from source-cooperative/source.coop#566, already on `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.com/claude-code) https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alukach
added a commit
to source-cooperative/data.source.coop
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.
What I'm changing
aws-actions/configure-aws-credentialsis the flagship consumer of the STS endpoint, but it could never work against the proxy: afterAssumeRoleWithWebIdentityit issues an unconditional, SigV4-signedGetCallerIdentitycall to validate the credentials before exporting them (no opt-out; 12 retries then the step fails). multistore-sts only parsedAssumeRoleWithWebIdentity, so that call fell through unhandled.This PR implements
GetCallerIdentity(closes #127) and adds an integration test that runs the real action end to end and proves the credentials it exports can write.How I did it
STS crate —
GetCallerIdentity(#127)caller_identity(new module): authenticates the call against the sealed session token — recovers the minted credentials fromx-amz-security-token, checks the auth-header access key matches, and verifies the SigV4 signature with the proxy's ownverify_sigv4_signature. Payload hash comes fromx-amz-content-sha256when present, else recomputed from the collected form body (AWS SDKs sign STS POSTs over the body hash, often without the header). Verifies over the raw signing path so the trailing slash AWS SDK JS v3 appends (/.sts->/.sts/) is honored, not normalized.request::is_get_caller_identitydetects the action in a query string or form body.responses::build_caller_identity_responseemits STS-shaped XML.Accountis the fabricatedSYNTHETIC_ACCOUNT_ID(000000000000) — the proxy has no real AWS account — used consistently in the assumed-role ARN.build_sts_error_responsenow mapsSignatureDoesNotMatch/ExpiredCredentialsto STS-shaped 403s.route_handler: dispatches GetCallerIdentity ahead of the assume-role exchange;with_stsregisters both/.stsand/.sts/(adds aClonebound) so SDK-JS callers reach the handler.Integration test — the real action
ci.yml: theintegrationjob now runsaws-actions/configure-aws-credentials@v6(SHA-pinned) withsts-endpointat the proxy, thenaws s3 cpround-trips an object inprivate-uploadswith the exported credentials. The action's own GetCallerIdentity validation must pass for the step to succeed.wrangler.integration.toml: adds a role keyed by the full ARNarn:aws:iam::000000000000:role/github-actions(write scope), since the action sendsrole-to-assumeverbatim as the RoleArn andget_rolematchesrole_idexactly.test_integration.py: addstest_get_caller_identity(botocore signs a real GetCallerIdentity over the assumed creds) andtest_get_caller_identity_rejects_static_credentials; rescopes the earlier form-POST test totest_sdk_form_post_assume_role_grants_write_access.Docs:
auth/proxy-auth.md(GetCallerIdentity, synthetic account, trailing-slash rule,configure-aws-credentialsworkflow example),reference/operations.md,architecture/crate-layout.md.Test plan
cargo test -p multistore-sts(35 pass),cargo fmt,cargo clippy --fixcargo check --all-targets,cargo check -p multistore-cf-workers --target wasm32-unknown-unknownintegrationCI job: real action assume-role -> GetCallerIdentity validation ->aws s3 cpwrite, plus the two new OIDC-gated pytest cases. The signed GetCallerIdentity happy-path is verified in CI (botocore + the action), not locally — locally I audited the exact inputs handed toverify_sigv4_signature.🤖 Generated with Claude Code