Skip to content

feat(accounts): trust subjects per account, the way a role's trust policy does - #566

Merged
alukach merged 1 commit into
mainfrom
feat/service-account-integrations
Sep 24, 2026
Merged

alukach merged 1 commit into
mainfrom
feat/service-account-integrations

Conversation

@alukach

@alukach alukach commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on main (#564 and #565 have merged); the bottom of stack #571. Part of #491; the model half of #546 — the GitHub-specific half is #567.

What

A service account says which subjects may act as it, the way an AWS role's trust policy does. That replaces the identity-bindings model #565 merged, before anything was written to it. A decision from review, flagged: the old model resolved a subject alone to an account, which meant a subject could be squatted and every binding needed proof of control — a signed challenge, a one-off workflow run, a completion endpoint. Under this model the workload names the account it wants when it exchanges its token, so trusting a subject one doesn't control gains nothing, and there is nothing to prove first. It is also the flow people already know from integrating GitHub Actions with AWS.

  • account-trusts table, keyed by the account: partition account_id, sort identity (issuer + space + subject). "Does this account trust this subject" is one exact read; an account's trusts are one query; a subject may be trusted by any number of accounts. Replaces identity-bindings in the CDK construct, the api-stack grant and init-local. The merged table is empty and was never written to, so this is a rename, not a migration; the old table is retained by policy and can be deleted by hand.
  • AccountTrustsTable: isTrusted, and listByAccount (paged, with bypassCache for destructive callers). AccountsTable.delete removes an account and its trusts together, in transactions of at most 100 with the row last. Writing and removing a trust arrive with feat(accounts): manage service accounts from account settings #567, where the settings page calls them; nothing here writes one.
  • The exchange check: POST /api/v1/accounts/{id}/trusts/exchanges with { issuer, subject }, authenticated as the account. This is what the data proxy asks at /.sts after verifying a platform IdP's token and reading the account from RoleArn — the trust-policy check of an assume-role call. The only route here, and the proxy is its only caller.
  • Resolution: a proxy-signed subject that is a service account's own id resolves to it; a person's Ory identity is read alongside, and a subject that names both is refused as ambiguous. A person's or organization's handle never resolves. The exchange route needs this to authenticate the proxy as the account.
  • The GitHub issuer constant and the subject pin — one repository, one ref or environment, nothing organization-wide — arrive with feat(accounts): manage service accounts from account settings #567, where the trusts are written; nothing here reads them.

Removed from main: the identity-bindings table, its client and type, and AccountsTable.fetchByIdentity, none of which had a caller. Nothing in this app verifies a platform IdP's token: the proxy does, as it already does for every issuer it trusts (source-cooperative/data.source.coop#223).

The ARN a workflow uses: arn:aws:iam::<service-account-id>:role/FullAccess (or ReadOnly, or the _default alias) — the account where AWS puts the account number, the ceiling where AWS puts the role name. The partition is aws because aws-actions/configure-aws-credentials treats any other value as a bare role name; SDKs check only the value's length. The proxy already ignores the partition segment and now reads the account one (source-cooperative/data.source.coop#221, #222).

Testing

  • npx jest — all suites pass. account-trusts.test.ts: one exact read; cache bypass; a paged listing followed to the end. accounts.trusts.test.ts: Ory resolves through the index and never touches trusts; delete is one transaction over trusts and the row, and 150 trusts split into two with the row last. trusts/exchanges/route.test.ts: trusted, not trusted, an admin asking about any account, 401 for another account, 400 without both fields. oidc.test.ts: a service account resolves by its own id after Ory; a handle never does; a subject naming both a person and a service account is refused.
  • npm run type-check, next lint — clean.

Docs and ADRs

data.source.coop: ADR-014 (source-cooperative/data.source.coop#232) is amended to this model — trusts per account, no proof of control, RoleArn names the account, the exchange route. #221 (RoleArn account segment), #222 (what the proxy forwards: issuer, subject, and the account named) and #223 (GitHub as a trusted issuer, checked through the route) carry the contract as comments. docs.source.coop: the unattended-workflow guide (source-cooperative/docs.source.coop#34) will carry the workflow step #567 issues; nothing existing describes this.

🤖 Generated with Claude Code

https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z

@vercel

vercel Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
source-coop-ui Ready Ready Preview Sep 24, 2026 12:31am UTC
source-cooperative Ready Ready Preview Sep 24, 2026 12:31am UTC

Request Review

@alukach alukach added the blocked Waits on another PR merging first label Sep 21, 2026
@claude

claude Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 1m 40s —— View job


✅ No blocking issues — safe to merge.

  • Gather context (PR description, diff, changed files)
  • Review diff for correctness/security/best-practice issues
  • Ponytail pass for over-engineering
  • Check docs/ADR requirement per CLAUDE.md
  • Post final review

Review

The trust model is implemented consistently across the table, the exchange route, and OIDC resolution:

  • src/lib/clients/database/account-trusts.ts — the account_id/identity key and paged listByAccount match the CDK table definition in deploy/lib/database-construct.ts and the local table in scripts/init-local.ts.
  • src/lib/clients/database/accounts.ts:345-361 — the delete cascade reads trusts fresh (bypassing cache) and correctly puts the account row last in whichever transaction chunk it lands in, so a partial failure never leaves trusts without an account. The batching math holds at the boundary (verified against the added 150-trust test).
  • src/app/api/v1/accounts/[account_id]/trusts/exchanges/route.ts — only the account itself or an admin may ask; body validation and status codes (400/401/403/200) match the tests.
  • src/lib/api/oidc.ts:157-172 — resolving Ory and service-account namespaces in parallel and refusing a subject that names both is a real correctness fix over a naive "try Ory, then try id" sequence, since the latter would silently prefer one. identity_id is correctly typed as string | null for the session (checked against src/types/session.ts:17).
  • Confirmed the symbols the PR comments say moved to feat(accounts): manage service accounts from account settings #567 (canManageServiceAccount, AccountTrustsTable.create/delete, AlreadyTrustedError, GITHUB_ACTIONS_ISSUER) are indeed absent from this branch — nothing dead left behind.

Nothing here rises to blocking. One purely cosmetic leftover from editing, not worth a fix-it link: the comment above ServiceAccountSchema in src/types/account.ts:150-153 has an awkward mid-sentence line break ("...and no\n// profile beyond the shared fields.") from the diff — a nit a formatter won't catch since it's prose, but harmless.

I was not able to run npm run type-check or npx jest in this environment (sandbox denied the command); the PR description already reports both clean, and manual tracing of the changed call sites didn't surface anything that would contradict that.

Simplify (ponytail)

Nothing to cut. identityKey, the paged listByAccount, and the chunked transaction delete are all sized to what the exchange route and delete cascade actually need — no speculative flexibility, no reinvented stdlib.

Docs

The diff replaces the identity-bindings model with account-level trusts, which is exactly the shape of decision data.source.coop's ADRs track (authorization / STS credentials). The description names this directly: ADR-014 is amended in source-cooperative/data.source.coop#232, and #221/#222/#223 carry the RoleArn and issuer contract. docs.source.coop's unattended-workflow guide is noted as pending in #567, where the workflow-facing step lands, not here — consistent with this PR touching no user-facing flow. Nothing further to flag.


💰 Estimated review cost: $0.53 · 1m39s · 26 turns

@alukach

alukach commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up so the comparisons this branch added ask isServiceAccount (the guard now lives in src/types/account.ts, from #563). Description still holds.

@alukach

alukach commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Rebased: this branch now carries #565's no-backfill commit (fetchByOryId back to the identity_id index, no Ory binding on create, no backfill script). Nothing in the GitHub-integration diff changes; the description still holds.

@alukach

alukach commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #564 has merged (its commits are dropped from this branch), and carrying #565's latest commit. The GitHub-integration diff itself is unchanged.

@alukach

alukach commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

On the review: the description and title were rewritten for the trust model right after that run (the review ran against the push, before the rewrite landed). Two follow-ups from its ponytail notes: GITHUB_ACTIONS_ISSUER and the subject regex had no caller here, so they move to #567 where trusts are written; and this PR is now one commit. AccountTrustsTable.create/delete stay, as the staged surface #567 calls.

@alukach

alukach commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Pushed fixes from a /code-review pass: the delete cascade now pages through an account's trusts and splits the deletes into transactions of at most 100, the account row last, so a failure part-way never leaves trusts without an account; canManageServiceAccount checks the account type before the admin shortcut, so it is false for an admin over an organization too (test added); the resolver reads the Ory and service-account namespaces together and refuses a subject that names both, which is the invariant the comment used to promise a stacked PR would enforce; isTrusted logs like the other table methods; the sort-key comment no longer claims subjects have no whitespace, and #567's subject regex now admits environment names with spaces. Description still holds.

@alukach

alukach commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on unused code: canManageServiceAccount, AccountTrustsTable.create/delete and AlreadyTrustedError had no caller here either, so they and their tests move to #567 with the settings actions that use them. What stays is what this PR's own code calls: isTrusted (the exchange route), listByAccount and identityKey (the delete cascade), serviceAccountById (the resolver). Description updated.

…licy does

An account now says which subjects may act as it — an issuer and the exact subject its tokens carry — in an account-trusts table keyed by the account, replacing identity bindings keyed by the subject. This is AWS's shape: a workload names the account it wants when it exchanges its token, and the exchange succeeds only if that account trusts the token's subject. Nothing about a subject alone chooses an account, so there is nothing to squat and no proof of control to collect; a manager adds a trust from the settings page, the way one edits a role's trust policy.

The data proxy asks the question at /.sts through POST /api/v1/accounts/{id}/trusts/exchanges, authenticated as the account the caller named, and a proxy-signed subject that is a service account's own id now resolves to it. The four integration routes, the signed challenge and the GitHub token verifier are gone with the model that needed them; GitHub's tokens are verified by the proxy, which already verifies every issuer it trusts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
@alukach
alukach force-pushed the feat/service-account-integrations branch from e5d867e to 899a182 Compare September 24, 2026 00:30
@alukach

alukach commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

On the review: it ran between the push that moved create/delete/AlreadyTrustedError to #567 and the description update that followed, so the mismatch it saw was the description lagging the code by a few minutes; the current description lists only what this diff calls. Its one non-blocking note is taken: the exchange route's admin path now has a test. CI's type-check and tests are the confirmation it asked for.

@alukach
alukach merged commit 113ecae into main Sep 24, 2026
8 checks passed
@alukach
alukach deleted the feat/service-account-integrations branch September 24, 2026 00:39
alukach added a commit 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 added a commit to source-cooperative/data.source.coop that referenced this pull request Sep 29, 2026
Closes #230. The decision record for the service-account epic
(source-cooperative/source.coop#491), which the epic sequences before
its step 3 and which source-cooperative/source.coop#563,
source-cooperative/source.coop#564, source-cooperative/source.coop#565,
source-cooperative/source.coop#566 and
source-cooperative/source.coop#567 implement. #234 is stacked on this
and revises ADR-013 for opaque API keys.

## What it records

**ADR-014 — Service Accounts.** A third account type: a principal with
**its own grant**, owned by an individual or organisation and managed by
whoever manages the owner. It authenticates through account trusts: the
account lists the `(issuer, subject)` pairs that may act as it, the way
an AWS role's trust policy does. A manager adds a trust without proving
control of the subject, because a workload names the account it wants in
`RoleArn` and the exchange succeeds only if that account trusts it.
Trusting a subject you don't control gains nothing, since its workflows
never ask for your account. it holds `read_data`/`write_data`
memberships on its owner's products and nothing more; Roles still apply
as ceilings. The division of labour with ADR-010 is the heart of it: *a
Role answers "how narrow is this credential"; a service account answers
"whose grant is this".* It resolves ADR-010's Organisation Subject
Problem by making the service account the subject rather than making
organisations authenticate.

**Amendments**, as notes under each header:
- **ADR-004** — the account segment of `RoleArn` is no longer ignored
for a platform issuer's token: it names the service account whose trust
is checked.
- **ADR-005** — a subject may also be a service account's id; the proxy
asks as the account `RoleArn` names whether it trusts a platform token,
the one lookup made as an account before the caller is established.
- **ADR-010** — account-owned Roles deferred; two hardcoded Roles ship
(#221); the org-subject problem is resolved by ADR-014.
- **ADR-013** — `sub` is a service account; one key belongs to one
service account; no per-key Role binding in the first release, so the
dependency on ADR-010 becomes one on ADR-014; expiry optional and
changeable, several keys active at once. #234 then revises ADR-013 for
opaque keys, which have no `sub`, and trims this amendment to match.

Alternatives rejected, with reasons: ADR-010 Roles alone, organisations
that authenticate, OAuth2 client credentials, a `svc--` id namespace,
per-account Role tick-boxes.

## Why now

Repo convention (source.coop's `CLAUDE.md`) is that a change which moves
a recorded decision needs an ADR or an amendment. The epic moves four.
The implementation went ahead of the record —
source-cooperative/source.coop#563 (the model),
source-cooperative/source.coop#565 (subject lookup),
source-cooperative/source.coop#566 (account trusts, which replaced proof
of control) and source-cooperative/source.coop#567 (management) have
since merged — so this is the record catching up, written from what was
built and why.

🤖 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 added a commit that referenced this pull request Sep 29, 2026
…accounts (#570)

Closes #548. Closes #546. API keys are the second Integration type
beside the GitHub trusts #567 shipped, and #566 replaced proof of
control with per-account trust. The `source.coop` half of #548. Part of
#491.

**Merge order:** this can merge and deploy before the proxy. It no
longer calls the proxy; the proxy calls it.
source-cooperative/data.source.coop#235 is the proxy half and needs the
route here to be live, or every key exchange fails closed.

## What

API keys for environments without OIDC — a server, a scheduler, an
instrument — per ADR-013 as revised in
source-cooperative/data.source.coop#234: **a key is an opaque secret
that source.coop resolves by hash; nothing signs it.** Six commits on
`main`: the original feature, dropping the Ory-id guard once #567
namespaced service-account ids, #580's rework to opaque keys, the key
hint, a calmer key list, and that list as its own component with
stories.

**The key** is `sck_` + 32 random bytes in base64url: a fixed 47
characters, all entropy after the prefix, matching
`sck_[A-Za-z0-9_-]{43}`, which is what gets registered with secret
scanners (#561). It is shown once and never stored.

**The record** (`service-account-keys` table) is keyed by the key's hex
SHA-256, with a public `key_id` (a UUID) for listing, revoking and
changing expiry, a label, who issued it and when, an optional expiry,
`revoked_at` and `last_used_at`. `publicKey()` strips the hash before
any record reaches a client component.

**The hint**: the record also keeps the key's last four characters, and
the key list shows each key as `sck_…Xy9Q`, so someone holding a key can
tell which record, and so which service account, it is. Four characters
are 24 of the key's 256 random bits, leaving far too many to guess. The
hint only confirms a key in hand against its record; it is never used to
look a key up, since keys across the platform will share it. The issue
dialog says how the new key will be listed. Keys issued before the hint
existed are listed without one.

**Issuing** (`issueApiKey`): whoever manages the service account gives a
label and an expiry (30/90/365 days, or never). The action generates the
key, writes the record and returns the key once. A disabled service
account is refused. **Revoking** sets `revoked_at`; **expiry** can be
changed after issuance, including to never.

**Exchanging**: `POST /api/v1/service-account-keys/exchanges` with
`{key_hash}` is what the proxy calls at `/.sts` when a key is presented,
authenticated as the proxy itself (`verifyProxyAssertion`, sentinel
subject `urn:source:data-proxy`, recorded in the ADR-005 amendment in
source-cooperative/data.source.coop#234). It always answers 200 with
`{account_id, key_id, active}`. `active` means known, not revoked, not
expired, and its service account not disabled; an unknown hash is
answered as inactive, indistinguishable from a revoked one. It records
last use. The proxy caches the answer for 60s, so revocation takes
effect for new exchanges within that time; credentials already issued
live to their session cap.

**Resolving the subject**: after an exchange, the proxy's credentials
name the service account itself, and `authenticateWithOidcToken`
resolves it with `fetchByOryId`, then a service account by id. No
service account's id can be someone's Ory identity id: #567 namespaces
it as `{owner}--{id}`, and a UUID never contains `--`.

**UI**: the service account's page (#567's `ServiceAccountDetail`) has
an API keys section, rendered by `ApiKeyList`, a row per key. On the
left, the label with a Revoked or Expired marker, and the key's hint
beneath. On the right, two short lines: how it has been used ("Used 3
days ago", "Never used") and when it ends ("Expires in 5 months", "Never
expires", "Revoked 9 months ago"). The exact dates, and who issued the
key and when, are in their tooltip. Change expiry and Revoke are in a
"⋯" menu; a revoked key has none, and keeps an invisible copy of the
button so its lines align. The section header carries
`IssueApiKeyDialog`, which shows the key once with a copy button, and
the environment variables that point any AWS SDK or the AWS CLI at the
proxy. The list row counts live keys as a way to sign in.

![The API keys section: HPC cron job over sck_…Xy9Q, with Used 6 months
ago and Expires in 5 months on the right and a ⋯ menu; Old laptop,
marked REVOKED, over sck_…a_7k, with Never used and Revoked 9 months
ago](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/service-account-key-rows.png)

Every state a key can be in, from `ApiKeyList`'s Default story:

![Five keys: HPC cron job, used 3 days ago, expires in 5 months;
Instrument uploader, used today, never expires; Laptop, for testing,
never used, expires next month; Last year's sync marked EXPIRED, used
last month, expired last month; Old laptop marked REVOKED, never used,
revoked 9 months ago. Each shows its sck_… hint; live keys have a ⋯
menu](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/service-account-management/api-key-list-states.png)

## Stories

- `ApiKeyList` › **Default** (every key state), **Single**,
**WithoutHint**, **Empty** —
https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeylist--default
(dates are set relative to today; hover a row's dates for the exact
ones)
- `ServiceAccountDetail` › **Default** —
https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--default
- `ServiceAccountList` › **Default** —
https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountlist--default
- `IssueApiKeyDialog` › **Default** —
https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-issueapikeydialog--default
(submitting reaches the show-once view with the hint line)
- `ApiKeyExpiryField` —
https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeyexpiryfield--default

## Testing

- `npx jest` — 79 suites, 835 tests, all pass on the branch as rebased
onto `main`. `service-account-keys.test.ts` covers issuing (the record
holds the hash, never the key; the hint is the key's last four
characters; the key is returned once), no-expiry keys, refusals before
any write, revoking only own keys, and expiry changes including to
never. The exchanges route test covers the proxy-only auth, active and
inactive answers, and last-use recording.
- `npm run type-check`, `next lint`, `npm run build-storybook` — clean.

## Docs and ADRs

data.source.coop: this implements ADR-013 as revised in
source-cooperative/data.source.coop#234, with ADR-014 from
source-cooperative/data.source.coop#232; the proxy side is
source-cooperative/data.source.coop#235, which supersedes
source-cooperative/data.source.coop#233. The hint is a display detail
the ADR doesn't need. docs.source.coop: the unattended-workflow guide,
source-cooperative/docs.source.coop#37 for
source-cooperative/docs.source.coop#34, should mention matching a key to
its account by its last four characters.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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 pushed a commit that referenced this pull request Oct 5, 2026
🤖 I have created a release *beep* *boop*
---


##
[1.7.0](v1.6.0...v1.7.0)
(2026-10-05)


### Features

* **accounts:** service accounts, behind the `CREATE_SERVICE_ACCOUNTS`
flag
([#563](#563),
[#635](#635))
* create, rename, disable and delete them from account settings, and
grant them product access on the membership pages
([#567](#567),
[#564](#564))
* sign in with API keys: opaque `sck_` keys with a CRC-32 checksum that
can be revoked or set to expire, and leaked keys are revoked through
GitHub secret scanning
([#570](#570),
[#596](#596),
[#581](#581))
* sign in from GitHub Actions by trusting a repository's branch, tag or
environment
([#566](#566),
[#565](#565),
[#632](#632))
* **accounts:** label the create_repositories flag "Create Products"
([#637](#637))
([bbe9b14](bbe9b14))
* **object-browser:** delete in the background with progress, in batches
([#583](#583))
([b00c17e](b00c17e))
* **products:** refresh button for the object listing
([#625](#625))
([bf47211](bf47211))


### Bug Fixes

* **admin:** search users as you type and list every match
([#568](#568))
([73e3be9](73e3be9))
* **api:** answer unmatched /api paths with a JSON 404
([#630](#630))
([e79eb4a](e79eb4a))
* **api:** remove legacy API keys and the api-keys table
([#374](#374))
([f845857](f845857))
* **auth:** keep the session across a server action redirect, e.g. after
creating a product
([#604](#604))
([2572d85](2572d85))
* **profiles:** keep deactivated products listed for their owners
([#579](#579))
([1f06414](1f06414))
* **products:** render inline markdown in product list descriptions
([#603](#603))
([7e9a94a](7e9a94a))
* **seo:** disallow crawling on non-production deployments
([#585](#585))
([5e05878](5e05878))
* **seo:** keep crawlers on product pages, out of the file browser
([#586](#586))
([0ffe1e6](0ffe1e6))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: source-release-bot[bot] <265100246+source-release-bot[bot]@users.noreply.github.com>

This branch was successfully deployed

2 active deployments
Preview – source-cooperative — 899a182f Deployed Sep 24, 2026 by vercel[bot]
Preview – source-coop-ui — 899a182f Deployed Sep 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blocked Waits on another PR merging first feat

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant