Skip to content

feat(sts): GetCallerIdentity + real configure-aws-credentials integration test - #126

Merged
alukach merged 6 commits into
mainfrom
worktree-aws-action-integration-test
Sep 29, 2026
Merged

alukach merged 6 commits into
mainfrom
worktree-aws-action-integration-test

Conversation

@alukach

@alukach alukach commented Jul 23, 2026 •

Copy link
Copy Markdown
Member

What I'm changing

aws-actions/configure-aws-credentials is the flagship consumer of the STS endpoint, but it could never work against the proxy: after AssumeRoleWithWebIdentity it issues an unconditional, SigV4-signed GetCallerIdentity call to validate the credentials before exporting them (no opt-out; 12 retries then the step fails). multistore-sts only parsed AssumeRoleWithWebIdentity, 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 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. Payload hash comes from x-amz-content-sha256 when 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_identity detects the action in a query string or form body.
  • responses::build_caller_identity_response emits STS-shaped XML. Account is the fabricated SYNTHETIC_ACCOUNT_ID (000000000000) — the proxy has no real AWS account — used consistently in the assumed-role ARN. build_sts_error_response now maps SignatureDoesNotMatch/ExpiredCredentials to STS-shaped 403s.
  • route_handler: dispatches GetCallerIdentity ahead of the assume-role exchange; with_sts registers both /.sts and /.sts/ (adds a Clone bound) so SDK-JS callers reach the handler.

Integration test — the real action

  • ci.yml: the integration job now runs aws-actions/configure-aws-credentials@v6 (SHA-pinned) with sts-endpoint at the proxy, then aws s3 cp round-trips an object in private-uploads with 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 ARN arn:aws:iam::000000000000:role/github-actions (write scope), since the action sends role-to-assume verbatim as the RoleArn and get_role matches role_id exactly.
  • test_integration.py: adds test_get_caller_identity (botocore signs a real GetCallerIdentity over the assumed creds) and test_get_caller_identity_rejects_static_credentials; rescopes the earlier form-POST test to test_sdk_form_post_assume_role_grants_write_access.

Docs: auth/proxy-auth.md (GetCallerIdentity, synthetic account, trailing-slash rule, configure-aws-credentials workflow example), reference/operations.md, architecture/crate-layout.md.

Test plan

  • cargo test -p multistore-sts (35 pass), cargo fmt, cargo clippy --fix
  • cargo check --all-targets, cargo check -p multistore-cf-workers --target wasm32-unknown-unknown
  • integration CI job: real action assume-role -> GetCallerIdentity validation -> aws s3 cp write, 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 to verify_sigv4_signature.

🤖 Generated with Claude Code

@claude

claude Bot commented Jul 23, 2026 •

Copy link
Copy Markdown

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


✅ No blocking issues — safe to merge.

I read the diff and traced GetCallerIdentity through unseal, access-key match, payload hash, SigV4 verify and the response. I did not run anything. The signed happy path is only covered by the integration CI job, as the PR notes.

Non-blocking observations

  • crates/sts/src/caller_identity.rs:86: nothing checks the x-amz-date timestamp against the current time. The signature is verified, but a captured signed GetCallerIdentity request can be replayed until the session expires. It only returns the identity document, so exposure is low. An x-amz-date skew check of about ±15 minutes would match AWS behaviour if you want it.
  • crates/sts/src/caller_identity.rs:75: the payload hash is taken from the client-supplied x-amz-content-sha256 when present, and never compared to the actual body. This is harmless today because the body is only used for action detection. It becomes a footgun if the handler ever reads body parameters. Either always recompute from the body (the SDKs sign that hash anyway) or leave a comment.
  • crates/sts/src/route_handler.rs:24: the GetCallerIdentity check runs on req.query first and then on the form body. A signed request with Action in the query and a different form body is still handled as GetCallerIdentity. That is fine, but the check is not exclusive with AssumeRoleWithWebIdentity.

Simplify (ponytail)

  • crates/sts/src/request.rs, is_get_caller_identity: nothing left to cut. It is one line over form_urlencoded, which is already a dependency.
  • crates/sts/src/route_handler.rs, with_sts: the manual trim_end_matches / duplicate-route guard is a bit fiddly. Registering format!("{path}/") only when !path.ends_with('/') reads more simply.
  • crates/sts/src/caller_identity.rs, resolve_caller_identity: the tests only cover the two error paths. That is acceptable given the CI coverage. A unit test that signs with verify_sigv4_signature's counterpart would be the smallest extra check.
  • crates/sts/Cargo.toml: the new hex dependency is already a workspace dependency, so it costs nothing extra. Skip replacing it.

💰 Estimated review cost: $0.16 · 0m18s · 6 turns

@github-actions github-actions Bot added the test label Jul 23, 2026
@alukach alukach changed the title test(integration): assert configure-aws-credentials grants write access feat(sts): GetCallerIdentity + real configure-aws-credentials integration test Jul 23, 2026
@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown

📖 Docs preview deployed to https://multistore-docs-pr-126.development-seed.workers.dev

  • Date: 2026-09-28T23:35:07Z
  • Commit: 3677b22

@github-actions github-actions Bot added feat and removed test labels Jul 23, 2026
@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown

🚀 Latest commit deployed to https://multistore-proxy-pr-126.development-seed.workers.dev

  • Date: 2026-09-28T23:35:07Z
  • Commit: 3677b22

@alukach
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 New Service Account form: Name "Nightly Sync" with the account id
nightly-sync shown beneath it and an Edit link; a workflow card with
Repository miskatonic/climate-data, a note on the immutable
owner@123/repo@456 form, a trash button, a Ref / Environment segmented
control on Ref with refs/heads/main, and the text Trusts
repo:miskatonic/climate-data:ref:refs/heads/main; then Climate Data set
to Read and write and Reference Data set to
Read](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-form-redesign.png)

The list, one row per account:

![Two rows in a bordered list: Nightly Sync, nightly-sync, 1 workflow ·
1 product; Archive Mirror, archive-mirror, cannot sign in · 0 products;
each with a
chevron](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-list-rows.png)

The id, derived from the name under the owner, and the same id opened
for editing:

![Account ID: miskatonic--nightly-upload, made from the name Nightly
Upload, with an Edit
link](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-id-derived.png)

![The Account ID field open for editing: the prefix miskatonic-- set
flush against the editable nightly-upload, in one monospace
face](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-id-edit.png)

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:

![Nightly Sync, miskatonic--nightly-sync; Signs in as, two workflows
each with Example usage and a red X; Can reach, Climate Data set to Read
and write and Reference Data set to Read, each with a red X, then a
Grant a product button; Danger zone, with Disable and
Delete](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-detail-grants-v2.png)

After Grant a product: a row to choose the product and its access,
finalized with the check:

![Below the two granted products, a row with Field Notes chosen in a
dropdown, Read and write selected, a green check and a grey
X](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-grant-row-check.png)

A trust row, with the Remove caption showing on the X:

![A trust row for repo:miskatonic/climate-data:ref:refs/heads/main with
Example usage and a red X button, hovered, captioned
Remove](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-trust-remove-x.png)

The grant row's dropdown, each product's name over its path:

![The Choose a product dropdown open, listing Climate Data over
miskatonic/climate-data, Reference Data over miskatonic/reference-data,
and Field Notes over
miskatonic/field-notes](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-grant-dropdown.png)

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:

![A modal titled Sign in from this workflow: add to the job in
repo:miskatonic/climate-data:ref:refs/heads/main the
configure-aws-credentials step whose role-to-assume is
arn:aws:iam::miskatonic--nightly-sync:role/FullAccess, with a copy
button and
Close](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-example-usage.jpg)

`WorkflowSnippet` on its own, with a subject in the immutable form
pinned to an environment — the block scrolls sideways rather than
wrapping the YAML:

![Add to the job in
repo:miskatonic@8123456/climate-data@9456789:environment:production,
before it uses the data, followed by the configure-aws-credentials step
for archive-mirror and a copy
button](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/workflow-snippet-immutable.jpg)

`GithubWorkflowFields` as the create form stacks it, an immutable
repository name pinned to an environment, with the Remove button:

![Repository miskatonic@8123456/climate-data@9456789, Pinned to An
environment, Environment production, a Remove button, and the text
Trusts
repo:miskatonic@8123456/climate-data@9456789:environment:production with
the note on naming the repository the way its tokens
do](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/github-workflow-fields-immutable.jpg)

A service account's hover card:

![A hover card for Nightly Sync, @miskatonic--nightly-sync, with an
outlined Service account
badge](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-hover-card-badge.png)

## 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>
alukach and others added 3 commits September 25, 2026 15:56
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
alukach force-pushed the worktree-aws-action-integration-test branch from 5906b30 to f5f9068 Compare September 25, 2026 23:00
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
alukach merged commit 05f9e60 into main Sep 29, 2026
20 checks passed
@alukach
alukach deleted the worktree-aws-action-integration-test branch September 29, 2026 06:02
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

This branch was successfully deployed

1 active deployment
preview — dd6f01b1 Deployed Sep 28, 2026 by alukach via Deploy & Test / Deploy #466
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.

multistore-sts: implement GetCallerIdentity (unblocks aws-actions/configure-aws-credentials)

1 participant