feat(accounts): resolve accounts by (issuer, subject) - #565
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @alukach's task in 2m 18s —— View job Review complete
✅ No blocking issues — safe to merge. Went through
Simplify (ponytail)
DocsPR description satisfies the CLAUDE.md doc/ADR-linking requirement: it names 💰 Estimated review cost: $0.70 · 2m17s · 30 turns |
|
Addressed the delete finding: |
|
Both addressed. |
An account is found by how it signed in: a new identity-bindings table maps (issuer, subject) to an account_id. The pair is the table's key, so a subject binds to one account per issuer and nothing more. AccountsTable.fetchByIdentity(issuer, subject) resolves any account type through it. fetchByOryId keeps its signature and becomes the wrapper that supplies this environment's Ory issuer; while the backfill lands it still falls back to the identity_id index for an account with no binding yet, and logs that fallback so the unbacked accounts are countable. create() binds an individual's Ory identity in the same breath, and removes the row again if the identity is already bound elsewhere. The table is declared in CDK, the local-dev script, and the CDK grant list; the local-dev script seeds bindings from the fixture accounts. scripts/backfill-identity-bindings.ts backfills every individual (MODE= backfill, idempotent) and verifies the counts match (MODE=verify). Removing the fallback and the identity_id index follows once verify passes in production. Part of #543 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
An orphaned (issuer, subject) row would keep that identity from ever binding to an account again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
… bindings table IdentityBindingsTable.create converts the conditional-write failure into IdentityAlreadyBoundError, so createAccount can tell "this sign-in already has an account" apart from "that account id is taken" — the two conflicts arrived under the same exception name. And it treats a table that does not exist yet as "not written": the identity_id fallback still resolves the account and the backfill writes the binding later, so the app and the CDK change deploy in either order for writes as well as reads. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
… other issuers An individual's Ory identity is not a binding. identity_id cannot leave the row — the session cookie, the Ory email lookup, proxy credentials and key minting all read it — so backfilling it into the bindings table would have kept a second copy of the same fact in step, in exchange for one index. The issuer it would have been keyed under is the Ory SDK URL from config, which no token carries as iss, so a change to that variable would have orphaned every person's login row at once. And the conditional write only buys something for attacker-chosen subjects, which an Ory subject never is. So fetchByOryId is as it was on main, create writes no binding, and the backfill, the dual-read fallback and the missing-table tolerance are gone. The table holds identities the platform does not own, and refuses an empty issuer or subject before touching DynamoDB. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
fe1d93f to
226d15a
Compare
|
Rebased onto |
…e none Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
… keyed as the table is AccountsTable.delete sent a key with a type attribute the accounts table is not keyed on, which DynamoDB rejects; the fake in its test accepted any key, and its delete handler removed the last binding on a miss, so nothing caught it. The bindings and the row now go in one TransactWriteCommand, so a failure leaves nothing half-done, and the bindings are read past the request cache so a binding written earlier in the request is not missed. IdentityBindingsTable.create validates the row with the schema instead of a duplicate check, and the schema's examples show a GitHub Actions binding rather than the Ory one this table does not hold. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
|
Pushed 788c22d for the code-review findings: |
…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>
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>
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 anaccount_idindex 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 asIdentityAlreadyBoundError. Declared in the places a table has to be —deploy/lib/database-construct.ts, theapi-stackgrant 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, andAccountsTable.deleteremoves 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_idindex 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_idcan'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 asiss; 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). SofetchByOryIdis untouched,createwrites 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 realissare the migration then.Testing
npx jest— all suites pass.identity-bindings.test.ts: conditional write, the conflict named asIdentityAlreadyBoundError, 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;fetchByIdentityresolves any type whilefetchByOryIdstays individual-only; deletion removes bindings; issuers are told apart.accounts.create.test.tsis unchanged frommain.npm run type-check— clean (scripts/is in the project).next lint— clean apart from pre-existing warnings inaccounts.tsandinit-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
fetchByIdentityto 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.ai/code/session_01REZWKgQy2PDETn6j9YpM4z