docs(adr): ADR-014 service accounts; amend ADR-010 and ADR-013 - #232
Conversation
|
Claude finished @alukach's task in 1m 48s —— View job ✅ No blocking issues — safe to merge. Reviewed the diff (
One non-blocking nit: Simplify (ponytail)Nothing to cut — this PR adds no code, only ADR prose recording decisions already implemented elsewhere; the "Alternatives Considered" sections already show the simpler options (Roles alone, org-authenticated, OAuth2 client credentials, 💰 Estimated review cost: $0.39 · 1m48s · 19 turns |
|
🚀 Latest commit deployed to https://source-data-proxy-pr-232.source-coop.workers.dev
|
|
Amended ADR-014 after a three-way review of source-cooperative/source.coop#565: an individual's Ory identity stays on the account row as |
|
Second amendment to ADR-014 after another three-way review: an API key's subject is the service account's own id, resolved by the API directly, so enabling keys writes no binding. The bindings table holds only subjects the platform can't derive from an account (platform IdPs). Matches source-cooperative/source.coop#570. |
Closes #543. Independent of stack #571 (#564 → #566 → #567 → #570); based on `main`, and #566 carries these commits until this merges. Part of #491. ## What An account becomes findable by *how it signed in*, for identities the platform does not own: a service account's subject under the data proxy's issuer (#570), a GitHub Actions workflow (#566), and whatever issuer comes next. **The table.** `identity-bindings`, keyed `(issuer, subject)` → `account_id`, with an `account_id` index for listing an account's bindings. The composite key *is* the uniqueness rule the issue asks for: a subject binds to one account per issuer and nothing more, enforced by DynamoDB's conditional write rather than a check, and surfaced as `IdentityAlreadyBoundError`. Declared in the places a table has to be — `deploy/lib/database-construct.ts`, the `api-stack` grant list, `scripts/init-local.ts`. Both halves of the key must be non-empty: an issuer read from unset config would be `""`, and every binding under it would share one partition. **The lookup.** `AccountsTable.fetchByIdentity(issuer, subject)` resolves any account type through a binding, and `AccountsTable.delete` removes an account's bindings with it, so an orphaned pair can't keep a subject from ever binding again. **Ory identities stay where they are — a decision, flagged.** #543 asked for every individual to be backfilled into the table under an Ory issuer, with a dual-read window and the `identity_id` index retired after. Three reviews of that plan reached the same answer, and this PR takes it: an individual's Ory identity is not a binding. `identity_id` can't leave the row (the session cookie, the Ory email lookup, proxy credentials and key minting all read it), so a backfill would have made a second copy of the same fact to keep in step, in exchange for dropping one index. The "issuer" it would be keyed under is the Ory SDK URL from config, which no token carries as `iss`; a change to that variable would have orphaned every person's login row at once. And the conditional write only buys something when the subject is attacker-chosen, which an Ory subject never is (it comes from the verified session, and authz already refuses a second individual per principal). So `fetchByOryId` is untouched, `create` writes no binding, there is no backfill, no dual-read and no follow-up. The OIDC resolver's chain, `fetchByOryId(sub) ?? fetchByIdentity(proxyIssuer, sub)` in #570, already dispatches between the two. If a person ever needs several identities, or the platform leaves Ory, bindings under a real `iss` are the migration then. ## Testing - `npx jest` — all suites pass. `identity-bindings.test.ts`: conditional write, the conflict named as `IdentityAlreadyBoundError`, resolution by key, and an empty issuer or subject refused before the table is touched. `accounts.identity.test.ts`: an Ory identity resolves through the index and never reaches the bindings table; `fetchByIdentity` resolves any type while `fetchByOryId` stays individual-only; deletion removes bindings; issuers are told apart. `accounts.create.test.ts` is unchanged from `main`. - `npm run type-check` — clean (`scripts/` is in the project). - `next lint` — clean apart from pre-existing warnings in `accounts.ts` and `init-local.ts`. ## Docs and ADRs docs.source.coop: no user-facing flow changes — sign-in works as before. data.source.coop: ADR-014 (source-cooperative/data.source.coop#232) said every individual is bound under the Ory issuer and listed the dual-read window as a cost; that PR is amended to say Ory identities stay on the account row and the table holds only other issuers. source-cooperative/data.source.coop#222 (issuer-qualified subjects) still has `fetchByIdentity` to resolve against, with one branch for the Ory issuer. #543's "Do" and "Done when" are updated to match. 🤖 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>
|
Amended ADR-014 to the trust model decided on source-cooperative/source.coop#566: an |
…licy does (#566) 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 #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 #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.com/claude-code) https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
Amended in a new commit: a service account's id is now namespaced under its owner, |
Closes #547. Part of #491. Stacked on #566, now merged, so the diff is this branch alone: the feature commit, the immutable-subject commit, two review-fix commits, a redesign of the form and the list, a fix for writing trusts, owner-namespaced ids, and an account page that is where creating lands and where grants are edited, with the create form's product list, showing what it reaches and granting more from a new row. ## What The real version of the flow #495 mocked, built on the model (#563), the membership pages (#564) and account trusts (#566). Rebuilt on the trust model in one commit; what review changed follows as commits of its own. **Settings → Service Accounts** appears for individuals and organizations alike, to whoever `canManageAccount` says manages the account. The list page loads each service account with its trusts, its grants and (from #570) its keys. **`ServiceAccountForm`** — who it is (the name, and the id derived from it, shown as code with an Edit link the way the data-connection form shows its id), how software signs in (any number of GitHub workflows, each a card with the repository on its own row — `owner/repo`, or `owner@id/repo@id` for the immutable subjects GitHub mints for repositories created after July 2026 or opted in — then a Ref/Environment segmented control and its value, and the exact subject shown as you type), what it may reach (the products it reaches, each with Read / Read and write and a red X, and **Grant a product**, which adds a row with a dropdown of the owner's other products (each option the product's name over its `owner/product` path in small monospace), a Read / Read and write choice, and a green check that finalizes it, beside an X that cancels — the same `ProductAccessList` on the create form and the account's page; each title opens the product in a new tab, and the form holds its grants until it is submitted). **The id is namespaced under the owner:** `{owner}--{id}`, such as `acme--nightly-sync`, joined by the `--` account-owned data connections already use. Two owners can each have a `nightly-sync`, a service account never takes a handle a person or organization might want (their ids cannot contain `--`), and no service-account id can equal an Ory identity id, which is a UUID. The form asks for the short id and shows the whole one; the action composes it. `ServiceAccountSchema` requires the composed form, and a membership's member id accepts it, which granting a namespaced service account a product needs. The general `createAccount` action no longer takes `type=service`: nothing posted it, and it created service accounts without the owner checks or the namespace. Submitting writes the account, its grants and its trusts, and redirects to the account's page, where each trusted workflow's example usage is a click away. Nothing has to run first, and the step carries nothing that expires. **`ServiceAccountList`** — one row per account in the data connections' own `ConnectionList`/`ConnectionRow`: the name linking to the account's page, its id, a marker when it is disabled, and how many workflows it trusts and products it reaches. **`ServiceAccountDetail`**, at `/edit/account/{owner}/service-accounts/{id}` — the account's page: the workflows it trusts as rows, each removed with a red X (captioned "Remove" on hover, labelled with the subject for screen readers), each with an **Example usage** link that opens the step the workflow adds in a modal, and the "Trust a GitHub workflow" dialog on the section header, which writes the trust and closes onto the new row; the products it reaches, each with Read / Read and write and a red X, and **Grant a product**, which adds a row with a dropdown of the owner's other products (each option the product's name over its `owner/product` path in small monospace), a Read / Read and write choice, and a green check that finalizes it, beside an X that cancels — the same `ProductAccessList` on the create form and the account's page, here **saved as each is made** (the check, a change of access, or an X), shown at once (`useOptimistic`) with the controls held while it saves; each title opens the product in a new tab so a viewer can check what it holds; and Disable and Delete set apart in a danger zone. The page resolves the account through `managedServiceAccount` and 404s unless it is under its own owner. The actions revalidate the list and the page. Delete confirms in an alert dialog whose submit runs the action (a plain button, not `AlertDialog.Action`, which would close the dialog before the action ran) and shows the action's message if it refuses; deleting removes the grants and the account with its trusts, then redirects to the list. The trust dialog's form lives inside `Dialog.Content`, so each open starts fresh. The page passes the data-proxy origin down for the example; without one, no example is offered. **The workflow snippet** (`src/lib/services/github-workflow.ts`) is the `aws-actions/configure-aws-credentials@v6` step pointed at the data proxy — `sts-endpoint`, `audience` = the proxy origin — naming the account in `role-to-assume: arn:aws:iam::<service-account-id>:role/FullAccess`, plus `env` pointing S3 clients at the proxy. From then on any S3 client in the job works, if the account trusts the workflow's subject. **Depends on developmentseed/multistore#126:** the action validates the credentials it exports with `GetCallerIdentity`, which the proxy answers once that lands. **`AccountInfoHoverCard`**, the card an account name opens on hover anywhere in the app, marks a service account with the outlined "Service account" badge the picker and memberships table already use. **What this PR adds to the model:** `canManageServiceAccount(session, account, owner)` — whoever manages the owner manages the service account, answered through `canManageAccount` on the fetched owner, so an admin is included, a disabled owner takes its service accounts out of reach with it (admins aside), and nothing that is not a service account qualifies; a disabled service account is still managed so it can be re-enabled or deleted. `managedServiceAccount(session, id)` in `src/lib/accounts/service-accounts.ts` is the one resolver the lifecycle actions (and, in #570, the key actions) go through: it loads the account and its owner and answers null for anything not managed. And `AccountTrustsTable.create` (schema-validated, conditional, `AlreadyTrustedError`) and `delete`, which were in #566 until review noted nothing there called them. **Server actions** (`src/lib/actions/service-accounts.ts`): `createServiceAccount`, `addGithubTrust`, `removeTrust`, `setServiceAccountDisabled`, `deleteServiceAccount`, and for grants `setProductAccess`. Create loads the owner and requires an enabled individual or organization the caller manages — settled before any of its products are read, so the form can't be used to probe another account's catalogue — deduplicates the workflows and grants it was posted. The rest go through `managedServiceAccount`; `setProductAccess` also applies `serviceAccountGrantProblem` (read or write, the owner's products only), changes the grant the account already has rather than adding a second, touches only the account's own grants, and on none revokes by setting the membership Revoked, as a person's is. No action builds a workflow step: the page does, from the proxy origin. A disabled account takes no new trust, and a repeat is named for what it is. `removeTrust` deletes by the account's own key, so it can never touch another account's. ## Stories On this branch's deploy: - `ServiceAccountForm` › **Default** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountform--default (submitting redirects in the app; Storybook cannot follow that, so the mocked action just resolves) - `ServiceAccountForm` › **NoProducts** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountform--no-products - `ServiceAccountList` › **Default**, **Disabled**, **Empty** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountlist--default - `AccountInfoHoverCard` › **ServiceAccount** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/components-accounts-accountinfohovercard--service-account (hover the name) - `ProductAccessList` › **NothingGranted**, **SomeGranted**, **EverythingGranted**, **Saving**, **NoProducts** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-productaccesslist--some-granted (controlled, so every control works, Grant a product included) - `ServiceAccountDetail` › **Default**, **Disabled**, **Empty** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--default ("Example usage" opens the step in a modal; "Grant a product" adds the row to choose a product and its access; an access change shows while the mocked save runs, then springs back, since the mock saves nothing) - `WorkflowSnippet` › **Default**, **ImmutableSubject** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-workflowsnippet--default - `GithubWorkflowFields` › **Empty**, **ImmutableRepositoryWithRemove** — https://source-coop-ui-git-feat-service-account-management-radiantearth.vercel.app/?path=/story/features-service-accounts-githubworkflowfields--empty (controlled, so the stories hold the workflow in state and the fields can be typed into) The form, the detail and the dialog use `useActionState`, so they are on the smoke test's cannot-render-under-jest list alongside the other action-state forms; `WorkflowSnippet`, `GithubWorkflowFields` and `ProductAccessList` render there. The stories and the Storybook mock build the step with `githubWorkflowStep` rather than carrying a copy of it. ## Screenshots The create form: the id derived from the name, one workflow card, and each product's access chosen in place:  The list, one row per account:  The id, derived from the name under the owner, and the same id opened for editing:   An account's page, where creating one lands. Each trusted workflow has Example usage; the products it reaches are listed with their access and an X, with Grant a product below:  After Grant a product: a row to choose the product and its access, finalized with the check:  A trust row, with the Remove caption showing on the X:  The grant row's dropdown, each product's name over its path:  The create form uses the same list and grant row, and holds its grants until it is submitted. Example usage, opened from a workflow's row:  `WorkflowSnippet` on its own, with a subject in the immutable form pinned to an environment — the block scrolls sideways rather than wrapping the YAML:  `GithubWorkflowFields` as the create form stacks it, an immutable repository name pinned to an environment, with the Remove button:  A service account's hover card:  ## Testing - `npx jest` — all suites pass, re-run after the redesign. `service-accounts.test.ts`: create writes the account, grants and trusts once each however many times a field was posted, and redirects to the account's page; refuses an unpinned workflow, a product the owner lacks and a bad role before writing; refuses a non-manager, a missing owner and a service-account owner, and reports a taken id on the short-id field; delete removes grants then the account and redirects to the list; a short id containing its own `--` is refused; disable/enable; remove a trust by the account's key; trust one more workflow, refusing an unpinned subject, a repeat and a disabled account; every lifecycle action refuses what `managedServiceAccount` does not hand back. `accounts/service-accounts.test.ts`: the resolver hands back the account only when it is a service account whose owner is found and managed. `github-workflow.test.ts`: the step names the account and the proxy and carries nothing that expires. - `authz.test.ts`: the manager rules — admin-over-organization refused, some other account offered as the owner refused, a disabled owner refused for its managers and allowed for an admin. `account-trusts.test.ts`: write keyed by account and identity, only once; `AlreadyTrustedError`; malformed rows refused before the table; delete by the same key. - `service-accounts.test.ts`, `setProductAccess`: a product the account does not reach is granted as a member of the owner; one it reaches has its grant changed rather than a second added, and none revokes it (and is a no-op where there is no grant); a product the owner lacks, a role beyond read or write, and a non-manager are refused before any write; only the account's own grants are read. - `account-trusts.test.ts` also asserts the create condition names `identity` by placeholder. Unit tests mock DynamoDB, so they could not catch the reserved word; it surfaced when a trust write failed on a deployment with "Attribute name is a reserved keyword; reserved keyword: identity". - `npm run type-check`, `next lint`, `npm run build-storybook` — clean. ## Docs and ADRs data.source.coop: ADR-014 (source-cooperative/data.source.coop#232) describes this flow as amended with #566, and is amended again for the namespaced id (commit ae85ff72 on that PR), which replaces its "no reserved id namespace". docs.source.coop: the unattended-workflow guide (source-cooperative/docs.source.coop#34) will carry the workflow step this issues; it should read the way the AWS one does. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
## What I'm changing `cargo audit` fails on `main`, and so the Security Audit check fails on every open PR, including the machine-identity PRs (#232, #235). The cause is a new advisory against rustls 0.23.42, [RUSTSEC-2026-0285](https://rustsec.org/advisories/RUSTSEC-2026-0285): TLS 1.3 handshake messages were accepted across encryption-level boundaries. It is patched in 0.23.45. This bumps the lockfile to it. ## How I did it `cargo update -p rustls --precise 0.23.45`. A plain `cargo update -p rustls` stops at 0.23.43; the precise bump moves aws-lc-rs to 1.18.1, aws-lc-sys to 0.45.0 and rustls-webpki to 0.103.15 with it. `Cargo.lock` only. rustls never reaches the Worker. `cargo tree -i rustls --target wasm32-unknown-unknown` prints nothing; natively it comes in through multistore → reqwest → hyper-rustls, which the native tests use. Production was not exposed. ## How to test it - `cargo audit`: no vulnerabilities, with the one allowed warning (chacha20) CI already allows. - The pre-commit hook: `cargo fmt --check`, `cargo clippy --target wasm32-unknown-unknown -- -D warnings`, `cargo check --target wasm32-unknown-unknown` and `cargo test`, all passing. ## PR Checklist - [x] This PR has **no** breaking changes. - [x] I have updated or added new tests to cover the changes in this PR. (None apply: lockfile only.) - [x] This PR does not affect the Source Cooperative Frontend & API. ## Related Issues Unblocks the Security Audit check on #232, #235 and the PRs stacked on #235 (#236, #237); their pull-request runs check out the merge with `main`, so a re-run passes once this lands. #220 would catch the next one on a schedule. Part of source-cooperative/source.coop#491 only in that it clears CI for it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A service account is a principal with its own grant: owned by an individual or organisation, authenticating through (issuer, subject) bindings attached with proof of control, holding read_data/write_data memberships on its owner's products, with Roles still applying as ceilings. It resolves ADR-010's Organisation Subject Problem by making the service account the subject, and rebases ADR-013's API keys onto it. Closes #230 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
A person's Ory identity is not a binding: identity_id stays on the account row and resolves through its index, and the bindings table holds only identities the platform does not own. The dual-read cost is replaced by the one that remains: two lookups by design, and a service account whose id equals an Ory identity id is refused a key until the proxy qualifies subjects with their issuer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
A key's subject is the service account's own id, which the API resolves directly after trying Ory. A binding under the proxy's issuer would encode an identity function, and the bindings table holds only subjects the platform cannot derive from an account. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
An account says which subjects may act as it, the way a role's trust policy does; the workload names the account in RoleArn 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 nothing to prove first. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
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
A service account's id is {owner}--{id}: unique per owner, never a handle a person or organisation might want, never equal to an Ory identity id. Replaces 'no reserved id namespace', and with it the key refusal for an id that equals an Ory identity id. Follows source-cooperative/source.coop#567.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
ae85ff7 to
bd11d87
Compare
ADR-013 was never implemented on main, so the opaque-key decision belongs in it rather than in a superseding record. The revised ADR-013 keeps its Context, records under it why the proxy-signed JWT design was built and withdrawn, and carries the new Decision, the ADR-005 amendment for the one proxy-as-itself route, and the alternatives. ADR-015 is removed; ADR-001 and the RFC pointers to ADR-013 stand as they were, and the RFC index gains the ADR-014 row that #232 omitted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ADR-014 makes the account segment of RoleArn name the service account whose trust is checked, which ADR-004 records as ignored, and it lets a service account's id be a subject, which ADR-005's note defers to ADR-010. Both now carry an amendment note, and ADR-014's Amends line lists them. ADR-005's note also records the one lookup the proxy makes as an account before the caller is established: asking whether that account trusts a platform token. ADR-013's amendment note says its examples use ADR-010's sc:: RoleArn grammar while clients send the AWS form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
…he platform ADR-013's first Decision, proxy-signed JWT keys, was implemented (#233, source-cooperative/source.coop#570) and withdrawn on review before release. The revision records the replacement in place, since ADR-013 was never live: a key is an opaque sck_ secret stored as a SHA-256 hash in source.coop and resolved at /.sts by one lookup the proxy makes as itself, accepted only from a POST body, cached 60 seconds and rate-limited per client IP. Its Context keeps why the JWT design was withdrawn, and its Alternatives record the source.coop-signed JWT and the two-hop exchange. ADR-005 gains a note for that one proxy-as-itself lookup and its sentinel subject. ADR-014's amendment of ADR-013 loses its sub and jti wording, and the sentence in "How it authenticates" that said a key's subject is the service account's id now says a key names its account on the key record, which the automated review caught. The RFC index gains ADR-014's row. This replaces the branch's earlier commits, which drafted an ADR-015 and then folded it back into ADR-013, as one commit on top of #232's ADR-004 and ADR-005 amendments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
…he platform ADR-013's first Decision, proxy-signed JWT keys, was implemented (#233, source-cooperative/source.coop#570) and withdrawn on review before release. The revision records the replacement in place, since ADR-013 was never live: a key is an opaque sck_ secret stored as a SHA-256 hash in source.coop and resolved at /.sts by one lookup the proxy makes as itself, accepted only from a POST body, cached 60 seconds and rate-limited per client IP. Its Context keeps why the JWT design was withdrawn, and its Alternatives record the source.coop-signed JWT and the two-hop exchange. ADR-005 gains a note for that one proxy-as-itself lookup and its sentinel subject. ADR-014's amendment of ADR-013 loses its sub and jti wording, and the sentence in "How it authenticates" that said a key's subject is the service account's id now says a key names its account on the key record, which the automated review caught. The RFC index gains ADR-014's row. This replaces the branch's earlier commits, which drafted an ADR-015 and then folded it back into ADR-013, as one commit on top of #232's ADR-004 and ADR-005 amendments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
…he platform (#234) One commit on `main`, now that #232 (ADR-014) is merged. It revises ADR-013 in place, since ADR-013 was never implemented, and it replaces an earlier draft that added an ADR-015 and then folded it back. Part of source-cooperative/source.coop#491. Records the decision that replaces #233, developmentseed/multistore#147 and the proxy half of source-cooperative/source.coop#570. ## What it records **ADR-013, revised: API keys are opaque secrets resolved by the platform.** A key is `sck_` + 32 random bytes (a fixed 47 characters, `^sck_[A-Za-z0-9_-]{43}$`), stored as a sha256 hash on the key record in source.coop and shown once. Nothing signs it. At `/.sts` the proxy accepts it as `WebIdentityToken` from a POST body only, trims and format-checks it locally, hashes it, and asks `POST /api/v1/service-account-keys/exchanges` for `{account_id, key_id, active}`, cached 60s positive and negative, failing closed; then mints session credentials through the same code as every other exchange. The cache-miss path is rate-limited by client IP. A stock AWS SDK does the exchange and the refresh itself from `AWS_WEB_IDENTITY_TOKEN_FILE`; the emergency stop for a leaked key is disabling the service account. **Why the first Decision was withdrawn** is recorded under Context, each point checkable against #233 and source.coop#570: the revocation lookup was never optional, so the signature verified what the lookup restates; a Worker cannot fetch its own JWKS, so the "no new path" benefit was gone; minting was a source.coop→Ory→proxy→source.coop cycle whose `/.keys` would sign any `jti` for a manager; `exp` in the token overrode the editable record; and non-expiring keys died on the second rotation of a signing key shared with outbound federation. The ADR's original Context and its rejected alternatives stand. **Amendments**, in ADR-014's house form: ADR-005 gains the one proxy-as-itself route, with the sentinel subject `urn:source:data-proxy` that fails both account-id grammars; ADR-014's first two bullets amending ADR-013 lose their `sub`/`jti` wording. The RFC index gains ADR-014's row, which #232 omitted. It also fixes a sentence in ADR-014's "How it authenticates" section, which the automated review caught: it said a key's subject is the service account's id, and now says a key names its account on the key record. **Alternatives** rejected with reasons: the proxy-signed JWT (the first Decision), source.coop-signed JWTs verified via a source.coop JWKS, the two-hop exchange (opaque key → short-lived Ory ID token → `/.sts`), the proxy's `get_credential` slot, Ory-native tokens, and long-lived Ory refresh tokens. ## The two-hop spike the ADR cites Run on 2026-09-25 against the staging Ory project, driving the headless authorization-code flow source.coop already uses for a person's proxy credentials (`getOryIdToken` on `main`) with a service-account-shaped subject and, as a control, a random UUID that matches no identity. Both returned an RS256 ID token from `https://auth.staging.source.coop` whose `sub` was exactly the subject given, with the default 3600s lifetime. The run used a throwaway confidential OAuth2 client created and deleted for the purpose. Conclusion: Ory Network will mint for a subject with no identity record, so the two-hop design needs no proxy change and remains available; the ADR rejects it for the first release on the client-side cost to HPC and VM users, not on feasibility. ## Review Five targeted reviews of the plan and the decision text (security, proxy implementer, source.coop implementer, ops and client experience, ADR and issue hygiene), each returning "approve with changes"; all changes are folded in. The ones that moved a decision: keys refused in the query string (invocation logs are on; GDAL sends STS as a GET and is routed through the CLI); the sentinel is a URN, not a slug; unknown keys answer `active: false` rather than 404 so the existing cache code applies; rate limiting is per IP on misses and ships with the branch; the revocation numbers distinguish writes (60s) from restricted reads (300s) per the proxy's caches; ADR-005 is amended, not merely depended on. ## What follows #235, the proxy PR replacing #233 (its exchange branch survives; minting, self-verification and the multistore pin do not), source.coop#570 reworked in place, multistore#147 closed, #231 rewritten, and the epic's Phase 5 items edited. ## PR Checklist - [x] This PR has **no** breaking changes. (Documentation only.) - [x] I have updated or added new tests to cover the changes in this PR. (None apply; no code.) - [x] This PR affects the [Source Cooperative Frontend & API](https://github.com/source-cooperative/source.coop), and I have opened issue/PR source-cooperative/source.coop#570 to track the change. (Existing PR, to be reworked to this ADR.) ## Related Issues #230, #231, #232, #233, #235; developmentseed/multistore#146, developmentseed/multistore#147; source-cooperative/source.coop#491, source-cooperative/source.coop#548, source-cooperative/source.coop#561, source-cooperative/source.coop#570, source-cooperative/source.coop#580. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…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.  Every state a key can be in, from `ApiKeyList`'s Default story:  ## 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>
Supersedes #233. Implements the proxy half of API keys as recorded in the revised ADR-013 (#234, stacked on #232). Pairs with source-cooperative/source.coop#580 (into source-cooperative/source.coop#570's branch), which serves the route this calls. Closes #231. Part of source-cooperative/source.coop#491. ## What I'm changing A service account's API key is an opaque `sck_` secret, 30 random base62 characters and a six-character CRC-32 checksum of them (ADR-013, #242), that source.coop stores only as a SHA-256 hash. Nothing signs it. `/.sts` now accepts one as `WebIdentityToken` and resolves it by asking source.coop: - **Body only.** A key is read from the POST form body. One in the query string is refused before any lookup with `API key must be sent in the request body, not the URL (request id …)`, because Cloudflare logs request URLs. A JWT in the query string still goes to the STS route as before. - **Local checks first.** Trim surrounding whitespace, since every hand-made token file ends in a newline, then require exactly `sck_` + 36 base62 characters whose last six are the CRC-32 of the thirty before them (IEEE, as zlib computes it, written out in a few lines rather than a crate). Anything else with the `sck_` prefix is refused without a lookup. - **One lookup, cached.** `POST {SOURCE_API_URL}/api/v1/service-account-keys/exchanges` with `{"key_hash"}`, authenticated as the proxy itself (sentinel subject `urn:source:data-proxy`, the one route ADR-005 now lets the proxy call as itself). The route always answers 200: `{account_id, key_id, active: true}` or `{active: false}`. Both are cached for 60 seconds, so revocation takes effect within a minute and an unknown key costs one lookup a minute. A non-200 or network failure fails closed as a 500 `InternalError`, which SDKs retry, and caches nothing. - **One refusal for the key, and one for a mangled one.** A key that fails its shape or checksum was cut short or mistyped, and reads `InvalidIdentityToken: API key is malformed; check that it was copied whole (request id …)`; the format is public, so this reveals nothing, and it still counts against the rate limit. Unknown, revoked, expired and disabled all read `InvalidIdentityToken: API key was not accepted (request id …)`, with the id also in `x-amzn-requestid`. The id is in the message because SDKs show the user nothing else. The proxy logs one WARN line per refusal with the reason it knows (`malformed`, `inactive`, `query_string`, `rate_limited`) plus the key id and an 8-hex hash prefix; source.coop logs which of unknown, revoked, expired or disabled under the same request id. - **Minting.** Credentials for the named account under the `_default` role, sealed like every other session, with the same 900s floor, 3600s default and `STS_MAX_SESSION_DURATION_SECS` cap. `RoleArn`'s account segment is ignored, as for an Ory token. `ReadOnly`/`FullAccess` arrive with #221. - **Rate limit.** A new `KEY_EXCHANGE_LIMIT` ratelimit binding, 100 attempts a minute per client IP, in every wrangler config. A client exchanges about once a session, so a cluster behind one NAT stays far under it. A deployment without the binding logs an error and does not refuse traffic. **Decisions to flag** - **The limit applies to every `sck_` attempt, not only cache misses.** The cache sits inside the fetch helper, and at 100 a minute per IP the distinction makes no difference to legitimate traffic. ADR-013's wording is updated in #234 to match. - **No `<RequestId>` element in the STS error XML.** That lives in multistore-sts's `build_sts_error_response`; the header plus the message cover what SDKs surface, so no upstream change is needed now. - **`namespace_id`s `1001` (production), `1002` (staging) and `1003` (previews)** for the ratelimit binding. They only need to be unique within the Cloudflare account. - **CodeQL flags `key_hash` as weak password hashing (`src/keys.rs`), and it should be dismissed as a false positive.** The rule matches on the name: it takes the key for a password. A key is 256 random bits, so a slow hash or salt adds nothing, and the lookup is a key get, so there is no comparison to time. This is how GitHub stores its own tokens, and ADR-013 (#234) records it so nobody later "fixes" it to bcrypt. I have not dismissed the alert; that is the repo owner's call. - **Security Audit.** It failed here because `main`'s lockfile carried a rustls advisory (RUSTSEC-2026-0285). #238 fixed that on `main`, and this branch is rebased onto the fix. ## How I did it - `src/keys.rs` (new, wasm-free): `parse_api_key`, `looks_like_api_key`, `key_hash`, `KeyStanding`, `credentials_for`. - `src/lib.rs`: `api_key_exchange` runs after `ApiAuth` is built and before the router, and returns `None` for anything that isn't an `sck_` exchange so the STS route handles it unchanged. `exchange_api_key` does the lookup and minting; `key_refusal` builds the uniform refusal; `finish` adds CORS and the request-id headers to a pre-gateway response; `within_rate_limit` wraps the binding. - `src/source_api/auth.rs`: `PROXY_SELF_SUBJECT`, `ApiCaller` (anonymous, an account, or the proxy), `authorization_header_as_self`; `authorization_header` refuses the sentinel so no request can claim it. - `src/source_api/cache.rs`: `get_or_fetch_key_standing`; `cached_fetch` takes a method, an optional JSON body and an `ApiCaller`. The cache key is `{api_url}?key_hash={hash}`, so it is a URL, as the Cache API requires, and is scoped to the environment's API. - `src/sts.rs`: `default_role` factored out of `StsCredentialRegistry::new`. - `wrangler.toml`, `wrangler.preview.toml`: the binding per environment. `README.md`: a Bindings table and an API keys section. From #233 this keeps `finish`, the shape of `api_key_exchange`/`exchange_api_key`, `credentials_for` and the method argument to `cached_fetch`. It drops `/.keys`, minting, self-verification against the proxy's own JWKS, the `api_key` role, `chrono`/`rsa` and the `[patch.crates-io]` pin on multistore; developmentseed/multistore#147 is not needed. ## How to test it - `cargo test`: all suites, including the new `tests/keys.rs` (a key's shape, trimming `\n` and `\r\n`, rejection of short, long, wrong-case, mistyped, wrong-checksum, non-base62, checksum-less 47-character and JWT tokens, checksum vectors computed independently with Python's zlib (a CRC above 2^31, one with a leading zero), assembled with `concat!` so scanners don't flag the file, the SHA-256 test vector, and credentials sealed for the account within floor, default and cap). Run by the pre-commit hook, along with `cargo clippy --target wasm32-unknown-unknown -- -D warnings` and `cargo check --target wasm32-unknown-unknown`. - `pytest tests/test_api_keys.py` against `wrangler dev` and `tests/stub_api.py`, which gains the exchanges route keyed by the hash of fixed test keys, with a per-hash call counter. Nine tests, all passing locally with wrangler 3.114: a live key exchanges; **an unmodified boto3, configured only by `AWS_ROLE_ARN`, `AWS_WEB_IDENTITY_TOKEN_FILE` (a file holding the key and a trailing newline) and `AWS_ENDPOINT_URL_STS`, acquires credentials**; the second exchange within 60s never reaches the API; a trailing newline is harmless; unknown and revoked keys get byte-identical refusals apart from the id, and the refusal is cached; a key in the query string is refused without a lookup; malformed keys, including a mistyped one whose checksum fails, are refused without a lookup and with the malformed message; a wrong role is reported as such; an API 500 fails closed and is not cached. `test_control_plane.py` and `test_writes.py` still pass; their credentialed tests need CI's GitHub token and were skipped locally. The checksum commits (d6745e0, 30ad22f) were run by CI's Integration Tests job, which exchanges the new-format keys against the worker; it passed on both. - End to end, once source.coop#580 is on a deployment this preview points at: issue a key from a service account's page, save it to a file, then ```sh AWS_WEB_IDENTITY_TOKEN_FILE=./key AWS_ROLE_ARN=arn:aws:iam::000000000000:role/_default \ AWS_ENDPOINT_URL_STS=https://<preview>/.sts AWS_ENDPOINT_URL_S3=https://<preview> AWS_REGION=us-east-1 \ aws s3 ls s3://<owner>/<product>/ ``` Revoke the key and see the next exchange refused within 60s, then quote the printed request id to find the proxy's and source.coop's log lines. Not run here: it needs source-cooperative/source.coop#580 deployed. ## PR Checklist - [x] This PR has **no** breaking changes. (JWT exchanges at `/.sts` are unchanged; the new binding is additive.) - [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), and I have opened issue/PR source-cooperative/source.coop#580 to track the change. ## Related Issues Closes #231. Supersedes #233. ADR: #234 (revises ADR-013, amends ADR-005). Route: source-cooperative/source.coop#580, into source-cooperative/source.coop#570. 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>
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>
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 inRoleArnand 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 holdsread_data/write_datamemberships 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:
RoleArnis no longer ignored for a platform issuer's token: it names the service account whose trust is checked.RoleArnnames whether it trusts a platform token, the one lookup made as an account before the caller is established.subis 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. docs(adr): revise ADR-013 — API keys are opaque secrets resolved by the platform #234 then revises ADR-013 for opaque keys, which have nosub, 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.ai/code/session_01REZWKgQy2PDETn6j9YpM4z