Skip to content

feat(accounts): resolve accounts by (issuer, subject) - #565

Merged
alukach merged 7 commits into
mainfrom
feat/identity-bindings
Sep 23, 2026
Merged

alukach merged 7 commits into
mainfrom
feat/identity-bindings

Conversation

@alukach

@alukach alukach commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

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.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 23, 2026 6:50am UTC
source-cooperative Ready Ready Preview Sep 23, 2026 6:50am UTC

Request Review

@claude

claude Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

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


Review complete

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

✅ No blocking issues — safe to merge.

Went through identity-bindings.ts, the accounts.ts changes (fetchByIdentity, delete, the bindings DI), the table/deploy wiring (database-construct.ts, api-stack.ts, scripts/init-local.ts), the new schema, and both new test files against origin/main.

  • The conditional-write idiom in IdentityBindingsTable.create (ConditionExpression: "attribute_not_exists(issuer)" against the full (issuer, subject) key) is correct and mirrors AccountsTable.create's existing attribute_not_exists(account_id) pattern.
  • AccountsTable.delete's signature change from { account_id, type } to account_id is actually a fix, not a break — database-construct.ts:42 shows accounts has only account_id as its key (type is a GSI partition key, not part of the table key), so the old signature accepted a field the table never used. No other callers of .delete( exist in this tree to update.
  • The TransactWriteCommand in AccountsTable.delete reads bindings with bypassCache: true before building the transaction, matching the stated intent (act on what the table holds now, not a memoized read). Test at accounts.identity.test.ts:75 confirms the transaction shape.
  • fetchByOryId is untouched and still resolves off the identity_id GSI, matching the PR's "Ory identities stay on the row" decision — confirmed no path from it reaches the bindings table (accounts.identity.test.ts:41).
  • Zod validation in identity-bindings.ts:create runs before the table is touched, covering the empty-issuer/empty-subject partition-collision concern the PR description calls out; test coverage matches (identity-bindings.test.ts "refuses a malformed row").
  • Deploy wiring (api-stack.ts grant list, database-construct.ts GSI, init-local.ts create/delete/exists) all follow the exact shape used for the other tables (e.g. api-keys's account_id index) — nothing inconsistent there.
  • Didn't re-run npm run type-check or npx jest myself (no tool access to execute them in this environment) — taking the PR description's reported clean results at face value; nothing in the diff looks like it would contradict that.

Simplify (ponytail)

  • src/lib/clients/database/identity-bindings.ts:88 — delete(issuer, subject) is unused and untested in this PR; AccountsTable.delete builds its own TransactWriteCommand deletes directly rather than calling it. Not wrong to include as the natural counterpart to create, but if no caller lands with the rest of the stack, cut it until one needs to unbind a single identity.

Docs

PR description satisfies the CLAUDE.md doc/ADR-linking requirement: it names docs.source.coop (no user-facing flow changed — sign-in behavior is unchanged) and data.source.coop ADR-014, explaining the amendment (dual-read/backfill dropped, Ory identities stay on the account row) rather than staying silent on it. No further doc/ADR gaps found in the diff.


💰 Estimated review cost: $0.70 · 2m17s · 30 turns

@alukach

alukach commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the delete finding: AccountsTable.delete now removes the account's bindings first, with a test. Left the rollback as-is on purpose — removing the row on any binding failure is the safer side: no account may exist without a binding, whatever went wrong writing it.

@alukach

alukach commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Both addressed. IdentityBindingsTable.create now (a) returns without writing when the table doesn't exist yet — the identity_id fallback carries signups until the backfill, so either-order deploys hold for writes too — and (b) throws a named IdentityAlreadyBoundError on a pair conflict, which createAccount reports as "This sign-in already has an account" rather than "account ID taken". Tests cover the missing-table signup, the conversion, and the rollback's new error name.

alukach and others added 4 commits September 22, 2026 22:53
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
@alukach
alukach force-pushed the feat/identity-bindings branch from fe1d93f to 226d15a Compare September 23, 2026 05:57
@alukach

alukach commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main and pushed a commit that takes the no-backfill outcome from the three-way review: fetchByOryId is back to the identity_id index, create writes no Ory binding, and the backfill script, dual-read fallback and missing-table tolerance are gone. The bindings client now refuses an empty issuer or subject. Description rewritten; #543 and ADR-014 (source-cooperative/data.source.coop#232) updated to match.

… 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
@alukach

alukach commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 788c22d for the code-review findings: AccountsTable.delete takes the account id alone (the key it sent carried a type attribute the table isn't keyed on, so DynamoDB would have rejected it after the bindings were already gone), the bindings and the row now go in one TransactWriteCommand, the bindings are listed past the request cache, create validates the row with IdentityBindingSchema instead of a duplicate check, the test fake no longer removes the last binding on a miss, and the schema's OpenAPI example shows a GitHub Actions binding. Description still holds.

@alukach
alukach merged commit 6ed15cf into main Sep 23, 2026
7 checks passed
@alukach
alukach deleted the feat/identity-bindings branch September 23, 2026 20:31
alukach added a commit that referenced this pull request Sep 24, 2026
…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>
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>

This branch was successfully deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Resolve accounts by (issuer, subject)

1 participant