feat(accounts): trust subjects per account, the way a role's trust policy does - #566
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @alukach's task in 1m 40s —— View job ✅ No blocking issues — safe to merge.
ReviewThe trust model is implemented consistently across the table, the exchange route, and OIDC resolution:
Nothing here rises to blocking. One purely cosmetic leftover from editing, not worth a fix-it link: the comment above I was not able to run Simplify (ponytail)Nothing to cut. DocsThe diff replaces the identity-bindings model with account-level trusts, which is exactly the shape of decision 💰 Estimated review cost: $0.53 · 1m39s · 26 turns |
|
Pushed a follow-up so the comparisons this branch added ask |
8788550 to
b9e364e
Compare
|
Rebased: this branch now carries #565's no-backfill commit ( |
d062628 to
c47354b
Compare
|
Rewritten on the trust model: an |
|
On the review: the description and title were rewritten for the trust model right after that run (the review ran against the push, before the rewrite landed). Two follow-ups from its ponytail notes: |
5b35c3e to
6c93910
Compare
|
Pushed fixes from a |
6c93910 to
da0539e
Compare
da0539e to
e5d867e
Compare
|
Follow-up on unused code: |
…licy does
An account now says which subjects may act as it — an issuer and the exact subject its tokens carry — in an account-trusts table keyed by the account, replacing identity bindings keyed by the subject. This is AWS's shape: a workload names the account it wants when it exchanges its token, and the exchange succeeds only if that account trusts the token's subject. Nothing about a subject alone chooses an account, so there is nothing to squat and no proof of control to collect; a manager adds a trust from the settings page, the way one edits a role's trust policy.
The data proxy asks the question at /.sts through POST /api/v1/accounts/{id}/trusts/exchanges, authenticated as the account the caller named, and a proxy-signed subject that is a service account's own id now resolves to it. The four integration routes, the signed challenge and the GitHub token verifier are gone with the model that needed them; GitHub's tokens are verified by the proxy, which already verifies every issuer it trusts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
e5d867e to
899a182
Compare
|
On the review: it ran between the push that moved |
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>
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>
…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>
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-truststable, keyed by the account: partitionaccount_id, sortidentity(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. Replacesidentity-bindingsin the CDK construct, theapi-stackgrant andinit-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, andlistByAccount(paged, withbypassCachefor destructive callers).AccountsTable.deleteremoves an account and its trusts together, in transactions of at most 100 with the row last. Writing and removing a trust arrive with feat(accounts): manage service accounts from account settings #567, where the settings page calls them; nothing here writes one.POST /api/v1/accounts/{id}/trusts/exchangeswith{ issuer, subject }, authenticated as the account. This is what the data proxy asks at/.stsafter verifying a platform IdP's token and reading the account fromRoleArn— the trust-policy check of an assume-role call. The only route here, and the proxy is its only caller.Removed from
main: theidentity-bindingstable, its client and type, andAccountsTable.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(orReadOnly, or the_defaultalias) — the account where AWS puts the account number, the ceiling where AWS puts the role name. The partition isawsbecauseaws-actions/configure-aws-credentialstreats 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,
RoleArnnames the account, the exchange route. #221 (RoleArnaccount segment), #222 (what the proxy forwards: issuer, subject, and the account named) and #223 (GitHub as a trusted issuer, checked through the route) carry the contract as comments. docs.source.coop: the unattended-workflow guide (source-cooperative/docs.source.coop#34) will carry the workflow step #567 issues; nothing existing describes this.🤖 Generated with Claude Code
https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z