From 73b5e87d5aebb092d7e24644d26d837670554e08 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 21 Sep 2026 16:40:00 -0700 Subject: [PATCH 1/8] docs(adr): ADR-014 service accounts; amend ADR-010 and ADR-013 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 Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- adrs/010-account-owned-roles.md | 3 + adrs/013-api-keys.md | 3 + adrs/014-service-accounts.md | 103 ++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 adrs/014-service-accounts.md diff --git a/adrs/010-account-owned-roles.md b/adrs/010-account-owned-roles.md index 4d950f27..3ce13a51 100644 --- a/adrs/010-account-owned-roles.md +++ b/adrs/010-account-owned-roles.md @@ -6,6 +6,9 @@ **Depends on:** ADR-004, ADR-009 **Blocks:** ADR-011 +> [!NOTE] +> **Amended by ADR-014 (Service Accounts).** Account-owned Roles are deferred; two hardcoded Roles, `FullAccess` and `ReadOnly` (`_default` as an alias), ship in their place (source-cooperative/data.source.coop#221). The Organisation Subject Problem below is resolved by ADR-014 rather than by making organisations authenticate: the subject of a workload's credential is a service account. + --- ## Context diff --git a/adrs/013-api-keys.md b/adrs/013-api-keys.md index 69718595..ee5cf554 100644 --- a/adrs/013-api-keys.md +++ b/adrs/013-api-keys.md @@ -8,6 +8,9 @@ > [!NOTE] > The `api-keys` endpoints that exist in `source.coop` today are the **legacy** admin-managed keys used by the pre-Workers proxy. They are unrelated to this design, and the current proxy has no code path that accepts them (ADR-001). This ADR proposes a replacement, not a formalisation of what is there. +> [!NOTE] +> **Amended by ADR-014 (Service Accounts).** The `sub` of an API-key JWT is a **service account** id; a key belongs to one service account. There is no per-key Role binding in the first release — the account's memberships are the grant and the hardcoded Roles only subtract — so the dependency on ADR-010 is replaced by one on ADR-014. Expiry is optional and may be changed after issuance; several keys may be active at once. + --- ## Context diff --git a/adrs/014-service-accounts.md b/adrs/014-service-accounts.md new file mode 100644 index 00000000..a6faed62 --- /dev/null +++ b/adrs/014-service-accounts.md @@ -0,0 +1,103 @@ +# ADR-014: Service Accounts + +**Status:** Proposed — implemented in part (source-cooperative/source.coop#563–#567) +**Date:** 2026-09-21 +**RFC:** RFC-001 §7 +**Depends on:** ADR-004, ADR-005, ADR-009 +**Amends:** ADR-010 (scope), ADR-013 (subject and Role binding) + +--- + +## Context + +Source Cooperative cannot authenticate software acting on a user's behalf. Getting credentials requires a person at a browser, so anything unattended — a nightly sync, a publishing pipeline, an instrument uploading observations — either babysits a login or embeds a person's session where it does not belong. + +ADR-010 answers part of this. An account-owned Role carries identity constraints ("who may assume it") and a permission ceiling, so a CI workflow can obtain a credential *narrower* than the account that authored the Role. But a Role only ever subsets the owning account's permissions. Two things follow: + +- **There is no principal whose access is separate from a person's or an organisation's.** A workflow assuming an organisation's Role acts with a subset of the organisation's access; revoking it means editing the Role, and widening the organisation's access silently widens what the workflow can reach. +- **An organisation cannot be a subject.** ADR-010's "Organisation Subject Problem" records that `source.coop` resolves a token's subject through an individual's identity index, that organisations never authenticate, and that every invitation path rejects a non-individual account. Making organisations authenticate would touch the most sensitive code in the API without giving automation a permission set of its own. + +What an unattended workload needs is a principal with **its own grant**: revocable without touching a person, never inheriting a person's broader access, and able to be a *member* of products the way a person is. + +--- + +## Decision + +### A third account type + +`service` joins `individual` and `organization`. A service account is owned by exactly one account, individual or organisation (`owner_account_id`), and is managed by whoever manages the owner — the owner's `owners` and `maintainers`, or an individual owner themselves. It has no Ory identity and no public profile. It never acts as admin whatever its flags say, and it creates neither products nor accounts. It has no rights over itself: the self-authorization shortcut that lets a person edit their own account is a person's alone. + +No reserved id namespace. `type` is the discriminator and `owner_account_id` records ownership; the id is an ordinary account id. + +### How it authenticates: identity bindings + +An account is found by *how it signed in*. `source.coop` holds an `identity-bindings` table keyed `(issuer, subject) → account_id` — the pair is the key, so a subject binds to one account per issuer and nothing more. Every individual is bound under the Ory issuer; a service account is bound under whichever platform IdPs (ADR-009) it integrates with, one binding per exact subject. + +**Attaching a binding requires proof of control of the subject.** For GitHub Actions: whoever manages the service account names the exact subject — one repository and one ref or one environment, never organisation-wide — and receives a short-lived signed challenge. The workflow proves it controls that subject by minting its ambient OIDC token with the challenge as the audience and posting it back; the token is verified against GitHub's keys *for that audience*, its subject must equal the challenge's, and only then is the binding written. Without proof, anyone could claim another organisation's CI subject and receive its access. + +The token path is then: the proxy verifies a token from a trusted platform IdP, forwards the **issuer-qualified** subject to the API (source-cooperative/data.source.coop#222), and the API resolves `(issuer, subject)` through the bindings table to an account of any type. This is how the Organisation Subject Problem is resolved: the subject of a workload's credential is the *service account*, not the organisation that owns it. + +### What it may reach: memberships + +A service account holds permissions as ordinary memberships — the same rows, and the same revocation, as a person's. Two rules narrow them: + +- Only `read_data` or `write_data`, never `owners` or `maintainers`. A machine does not manage people or products. +- Only on products its owner owns, and only per product. Organisation-wide grants, and grants on another account's products, are deferred. + +Its owner grants access directly, as a member: there is nobody at the keyboard to accept an invitation. + +### Roles still apply, as ceilings + +ADR-010 and ADR-011 are unchanged in kind. A service account names a Role when it asks for credentials, and the Role only ever subtracts from its memberships. Today two Roles are hardcoded, `FullAccess` and `ReadOnly`, with `_default` kept as an alias (source-cooperative/data.source.coop#221); when account-owned Roles arrive, a service account assumes a Role its owner authored, exactly as ADR-010 describes. + +The division of labour: **a Role answers "how narrow is this credential"; a service account answers "whose grant is this".** + +--- + +## Amendments + +### ADR-013 — API keys + +- The `sub` of an API-key JWT is a **service account** id, not an arbitrary account id. A key belongs to one service account; an individual or organisation does not hold keys directly. +- Under the bindings model, enabling keys on a service account is one binding: `(proxy issuer, account id)`. Revocation stays per key, by `jti`. +- **No per-key Role binding in the first release.** The service account's memberships are the grant, and the hardcoded Roles only subtract, so any key may name either. The dependency on ADR-010 is dropped; ADR-013 depends on this ADR instead. +- Expiry is optional and may be changed after issuance; a service account may hold several active keys, so rotation is overlap by construction. + +### ADR-010 — account-owned Roles + +- **Scope:** account-owned Roles — CRUD, per-Role trust policies, user-authored permission statements, the API lookup on the credential path — are deferred. Two hardcoded Roles ship in their place (source-cooperative/data.source.coop#221). +- The Organisation Subject Problem is resolved by this ADR rather than by making organisations authenticate: the subject is the service account. +- When account-owned Roles do land, their identity constraints and a service account's bindings are not redundant. A binding says which subjects *are* this account; a Role's constraints say which of an account's subjects may assume *this* ceiling. + +--- + +## Consequences + +**Benefits** + +- Automation gets a grant of its own — revocable in one place, never inheriting a person's broader access, and visible on the same membership pages as a person's. +- Organisations can own automation without becoming subjects themselves. +- Multi-issuer trust (ADR-009) becomes safe to enable per subject: a GitHub token maps to a service account with exactly the memberships it was given, not to a person's whole account. +- The bindings table is the store every later issuer resolves against; GitLab, Azure DevOps and the rest are one proof-of-control flow each. + +**Costs / Risks** + +- A new account type touches every place that branches on the existing two — around fifty sites — and the default at each is *exclude*. +- The bindings table is a second source of truth for "who is this identity" until the `identity_id` index is retired; the dual-read window is a period in which a missed backfill row is a person who cannot sign in. +- Proof of control is a new subsystem per issuer, and its weakest point is the challenge's key handling. +- Each service account consumes a public name; a per-owner cap is an open question. +- Deleting an owner that owns service accounts must be blocked (account deletion is itself unimplemented, source-cooperative/source.coop#355). + +--- + +## Alternatives Considered + +**ADR-010 Roles alone** — rejected. A Role subsets its owner's permissions; it cannot give automation a grant that is separate from, and revocable independently of, a person's or an organisation's. And an organisation cannot be a subject. + +**Let organisations authenticate and hold memberships** — rejected. Every authentication and invitation path filters to individuals, so this is the same amount of work as a new type, and it still does not give the organisation's automation a permission set *separate* from the organisation's. + +**OAuth2 client credentials** — ADR-013 already rejected it as requiring "a bespoke service account system". This ADR is that system, built on the account model rather than beside it. + +**A reserved `svc--` id namespace** — rejected. `ID_REGEX` forbids consecutive hyphens in account ids, and `--` is already the data-connection composite-id delimiter; relaxing the rule would let user-chosen ids collide with connection ids. The account type is the discriminator. + +**Roles selectable per service account ("tick which Roles it may use")** — rejected for the first release. A Role can only subtract, so any caller may safely name either hardcoded one; a tick-box would be a no-op that reads as a restriction. From 97f3853a73d0362a43b84c03fb9eb1d9cd9bd22b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 22 Sep 2026 22:58:10 -0700 Subject: [PATCH 2/8] docs(adr): ADR-014 keeps Ory identities on the account row 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 Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- adrs/014-service-accounts.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/adrs/014-service-accounts.md b/adrs/014-service-accounts.md index a6faed62..2ab3bef6 100644 --- a/adrs/014-service-accounts.md +++ b/adrs/014-service-accounts.md @@ -31,11 +31,11 @@ No reserved id namespace. `type` is the discriminator and `owner_account_id` rec ### How it authenticates: identity bindings -An account is found by *how it signed in*. `source.coop` holds an `identity-bindings` table keyed `(issuer, subject) → account_id` — the pair is the key, so a subject binds to one account per issuer and nothing more. Every individual is bound under the Ory issuer; a service account is bound under whichever platform IdPs (ADR-009) it integrates with, one binding per exact subject. +An account is found by *how it signed in*. `source.coop` holds an `identity-bindings` table keyed `(issuer, subject) → account_id` — the pair is the key, so a subject binds to one account per issuer and nothing more. An individual's Ory identity is not a binding: it stays on the account row as `identity_id`, which the session, the email lookup and the proxy credentials already read, and resolves through that index. The table holds identities the platform does not own — a service account is bound under the data proxy's own issuer for its API keys (ADR-013) and under whichever platform IdPs (ADR-009) it integrates with, one binding per exact subject. **Attaching a binding requires proof of control of the subject.** For GitHub Actions: whoever manages the service account names the exact subject — one repository and one ref or one environment, never organisation-wide — and receives a short-lived signed challenge. The workflow proves it controls that subject by minting its ambient OIDC token with the challenge as the audience and posting it back; the token is verified against GitHub's keys *for that audience*, its subject must equal the challenge's, and only then is the binding written. Without proof, anyone could claim another organisation's CI subject and receive its access. -The token path is then: the proxy verifies a token from a trusted platform IdP, forwards the **issuer-qualified** subject to the API (source-cooperative/data.source.coop#222), and the API resolves `(issuer, subject)` through the bindings table to an account of any type. This is how the Organisation Subject Problem is resolved: the subject of a workload's credential is the *service account*, not the organisation that owns it. +The token path is then: the proxy verifies a token from a trusted platform IdP, forwards the **issuer-qualified** subject to the API (source-cooperative/data.source.coop#222), and the API resolves `(issuer, subject)` through the bindings table to an account of any type — the Ory issuer excepted, which resolves through `identity_id`. This is how the Organisation Subject Problem is resolved: the subject of a workload's credential is the *service account*, not the organisation that owns it. ### What it may reach: memberships @@ -83,7 +83,7 @@ The division of labour: **a Role answers "how narrow is this credential"; a serv **Costs / Risks** - A new account type touches every place that branches on the existing two — around fifty sites — and the default at each is *exclude*. -- The bindings table is a second source of truth for "who is this identity" until the `identity_id` index is retired; the dual-read window is a period in which a missed backfill row is a person who cannot sign in. +- Two lookups by design, not one: an Ory identity resolves through `identity_id`, everything else through a binding. Until source-cooperative/data.source.coop#222 qualifies the subject with its issuer, the proxy forwards a bare `sub` and the API tries Ory first, so a service account whose id equals a person's Ory identity id would resolve to the person; such an account is refused a key. - Proof of control is a new subsystem per issuer, and its weakest point is the challenge's key handling. - Each service account consumes a public name; a per-owner cap is an open question. - Deleting an owner that owns service accounts must be blocked (account deletion is itself unimplemented, source-cooperative/source.coop#355). From 7fe4242cd7590e56c01c06ac78e20faf8847b630 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 22 Sep 2026 23:32:05 -0700 Subject: [PATCH 3/8] docs(adr): ADR-014 writes no binding for API keys 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 Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- adrs/014-service-accounts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/adrs/014-service-accounts.md b/adrs/014-service-accounts.md index 2ab3bef6..c414c3c3 100644 --- a/adrs/014-service-accounts.md +++ b/adrs/014-service-accounts.md @@ -31,7 +31,7 @@ No reserved id namespace. `type` is the discriminator and `owner_account_id` rec ### How it authenticates: identity bindings -An account is found by *how it signed in*. `source.coop` holds an `identity-bindings` table keyed `(issuer, subject) → account_id` — the pair is the key, so a subject binds to one account per issuer and nothing more. An individual's Ory identity is not a binding: it stays on the account row as `identity_id`, which the session, the email lookup and the proxy credentials already read, and resolves through that index. The table holds identities the platform does not own — a service account is bound under the data proxy's own issuer for its API keys (ADR-013) and under whichever platform IdPs (ADR-009) it integrates with, one binding per exact subject. +An account is found by *how it signed in*. `source.coop` holds an `identity-bindings` table keyed `(issuer, subject) → account_id` — the pair is the key, so a subject binds to one account per issuer and nothing more. An individual's Ory identity is not a binding: it stays on the account row as `identity_id`, which the session, the email lookup and the proxy credentials already read, and resolves through that index. The table holds subjects the platform cannot derive from an account: a service account is bound under whichever platform IdPs (ADR-009) it integrates with, one binding per exact subject. An API key's subject is the service account's own id (ADR-013), which the API resolves directly, so a key writes no binding either. **Attaching a binding requires proof of control of the subject.** For GitHub Actions: whoever manages the service account names the exact subject — one repository and one ref or one environment, never organisation-wide — and receives a short-lived signed challenge. The workflow proves it controls that subject by minting its ambient OIDC token with the challenge as the audience and posting it back; the token is verified against GitHub's keys *for that audience*, its subject must equal the challenge's, and only then is the binding written. Without proof, anyone could claim another organisation's CI subject and receive its access. @@ -59,7 +59,7 @@ The division of labour: **a Role answers "how narrow is this credential"; a serv ### ADR-013 — API keys - The `sub` of an API-key JWT is a **service account** id, not an arbitrary account id. A key belongs to one service account; an individual or organisation does not hold keys directly. -- Under the bindings model, enabling keys on a service account is one binding: `(proxy issuer, account id)`. Revocation stays per key, by `jti`. +- Enabling keys on a service account writes no binding: the key's subject is the account id itself, and the API resolves it as a service account by id after trying Ory. Whether an account has keys is the keys table's to say; revocation stays per key, by `jti`. - **No per-key Role binding in the first release.** The service account's memberships are the grant, and the hardcoded Roles only subtract, so any key may name either. The dependency on ADR-010 is dropped; ADR-013 depends on this ADR instead. - Expiry is optional and may be changed after issuance; a service account may hold several active keys, so rotation is overlap by construction. From bfe299dfeb3c16ff39d417fb208be52f05319549 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 23 Sep 2026 15:39:39 -0700 Subject: [PATCH 4/8] docs(adr): ADR-014 trusts subjects per account, with no proof of control 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 Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- adrs/014-service-accounts.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/adrs/014-service-accounts.md b/adrs/014-service-accounts.md index c414c3c3..960fcb19 100644 --- a/adrs/014-service-accounts.md +++ b/adrs/014-service-accounts.md @@ -29,13 +29,13 @@ What an unattended workload needs is a principal with **its own grant**: revocab No reserved id namespace. `type` is the discriminator and `owner_account_id` records ownership; the id is an ordinary account id. -### How it authenticates: identity bindings +### How it authenticates: account trusts -An account is found by *how it signed in*. `source.coop` holds an `identity-bindings` table keyed `(issuer, subject) → account_id` — the pair is the key, so a subject binds to one account per issuer and nothing more. An individual's Ory identity is not a binding: it stays on the account row as `identity_id`, which the session, the email lookup and the proxy credentials already read, and resolves through that index. The table holds subjects the platform cannot derive from an account: a service account is bound under whichever platform IdPs (ADR-009) it integrates with, one binding per exact subject. An API key's subject is the service account's own id (ADR-013), which the API resolves directly, so a key writes no binding either. +An account says which subjects may act as it, the way an AWS role's trust policy does. `source.coop` holds an `account-trusts` table keyed by the account, with one row per issuer and exact subject the account trusts. A subject may be trusted by any number of accounts; nothing about a subject alone chooses an account. An individual's Ory identity is not a trust: it stays on the account row as `identity_id`, which the session, the email lookup and the proxy credentials already read. An API key's subject is the service account's own id (ADR-013), which the API resolves directly, so a key writes no trust either. The table holds what the platform cannot derive from an account: a service account's trust in whichever platform-IdP subjects (ADR-009) it integrates with — for GitHub Actions, one repository pinned to one ref or one environment, never organisation-wide. -**Attaching a binding requires proof of control of the subject.** For GitHub Actions: whoever manages the service account names the exact subject — one repository and one ref or one environment, never organisation-wide — and receives a short-lived signed challenge. The workflow proves it controls that subject by minting its ambient OIDC token with the challenge as the audience and posting it back; the token is verified against GitHub's keys *for that audience*, its subject must equal the challenge's, and only then is the binding written. Without proof, anyone could claim another organisation's CI subject and receive its access. +**A trust is written when a manager adds it; nothing has to prove control of the subject first.** The workload names the account it wants when it exchanges its token — the account segment of `RoleArn`, `arn:aws:iam:::role/FullAccess` (or `ReadOnly`, or the `_default` alias) — and the exchange succeeds only if that account trusts the token's issuer and subject. Trusting a subject one does not control gains nothing: its workflows never ask for the account. This is AWS's model, and the flow people already know from integrating GitHub Actions with AWS. -The token path is then: the proxy verifies a token from a trusted platform IdP, forwards the **issuer-qualified** subject to the API (source-cooperative/data.source.coop#222), and the API resolves `(issuer, subject)` through the bindings table to an account of any type — the Ory issuer excepted, which resolves through `identity_id`. This is how the Organisation Subject Problem is resolved: the subject of a workload's credential is the *service account*, not the organisation that owns it. +The token path is then: the proxy verifies a token from a trusted platform IdP, reads the account named in `RoleArn`, and asks the API — `POST /api/v1/accounts/{id}/trusts/exchanges`, authenticated as that account — whether it trusts the token's issuer and subject (source-cooperative/data.source.coop#222, #223). Yes means credentials carrying the account's memberships; no means denied. For an Ory ID token the account segment is ignored, because the token itself says who the person is. This is how the Organisation Subject Problem is resolved: the subject of a workload's credential is the *service account*, not the organisation that owns it. ### What it may reach: memberships @@ -67,7 +67,7 @@ The division of labour: **a Role answers "how narrow is this credential"; a serv - **Scope:** account-owned Roles — CRUD, per-Role trust policies, user-authored permission statements, the API lookup on the credential path — are deferred. Two hardcoded Roles ship in their place (source-cooperative/data.source.coop#221). - The Organisation Subject Problem is resolved by this ADR rather than by making organisations authenticate: the subject is the service account. -- When account-owned Roles do land, their identity constraints and a service account's bindings are not redundant. A binding says which subjects *are* this account; a Role's constraints say which of an account's subjects may assume *this* ceiling. +- When account-owned Roles do land, their identity constraints and a service account's trusts are not redundant. A trust says which subjects may *be* this account; a Role's constraints say which of an account's subjects may assume *this* ceiling. --- @@ -78,13 +78,13 @@ The division of labour: **a Role answers "how narrow is this credential"; a serv - Automation gets a grant of its own — revocable in one place, never inheriting a person's broader access, and visible on the same membership pages as a person's. - Organisations can own automation without becoming subjects themselves. - Multi-issuer trust (ADR-009) becomes safe to enable per subject: a GitHub token maps to a service account with exactly the memberships it was given, not to a person's whole account. -- The bindings table is the store every later issuer resolves against; GitLab, Azure DevOps and the rest are one proof-of-control flow each. +- The trusts table is the store every later issuer checks against; GitLab, Azure DevOps and the rest are one subject grammar each, with nothing to prove. **Costs / Risks** - A new account type touches every place that branches on the existing two — around fifty sites — and the default at each is *exclude*. -- Two lookups by design, not one: an Ory identity resolves through `identity_id`, everything else through a binding. Until source-cooperative/data.source.coop#222 qualifies the subject with its issuer, the proxy forwards a bare `sub` and the API tries Ory first, so a service account whose id equals a person's Ory identity id would resolve to the person; such an account is refused a key. -- Proof of control is a new subsystem per issuer, and its weakest point is the challenge's key handling. +- Two paths by design, not one: an Ory identity resolves through `identity_id`; a service account is named by the caller and checked against its trusts. The proxy forwards a bare `sub` and the API tries Ory first, so a service account whose id equals a person's Ory identity id would resolve to the person; such an account is refused a key. +- A trust is only as narrow as its subject: the platform pins GitHub subjects to one repository and one ref or environment, and every later issuer needs the same care. - Each service account consumes a public name; a per-owner cap is an open question. - Deleting an owner that owns service accounts must be blocked (account deletion is itself unimplemented, source-cooperative/source.coop#355). From 9c61af0035932e883b502e5f7d257bc8dbacf399 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 23 Sep 2026 15:51:41 -0700 Subject: [PATCH 5/8] docs(adr): the role ARN uses the sc partition Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- adrs/014-service-accounts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adrs/014-service-accounts.md b/adrs/014-service-accounts.md index 960fcb19..44d14efc 100644 --- a/adrs/014-service-accounts.md +++ b/adrs/014-service-accounts.md @@ -33,7 +33,7 @@ No reserved id namespace. `type` is the discriminator and `owner_account_id` rec An account says which subjects may act as it, the way an AWS role's trust policy does. `source.coop` holds an `account-trusts` table keyed by the account, with one row per issuer and exact subject the account trusts. A subject may be trusted by any number of accounts; nothing about a subject alone chooses an account. An individual's Ory identity is not a trust: it stays on the account row as `identity_id`, which the session, the email lookup and the proxy credentials already read. An API key's subject is the service account's own id (ADR-013), which the API resolves directly, so a key writes no trust either. The table holds what the platform cannot derive from an account: a service account's trust in whichever platform-IdP subjects (ADR-009) it integrates with — for GitHub Actions, one repository pinned to one ref or one environment, never organisation-wide. -**A trust is written when a manager adds it; nothing has to prove control of the subject first.** The workload names the account it wants when it exchanges its token — the account segment of `RoleArn`, `arn:aws:iam:::role/FullAccess` (or `ReadOnly`, or the `_default` alias) — and the exchange succeeds only if that account trusts the token's issuer and subject. Trusting a subject one does not control gains nothing: its workflows never ask for the account. This is AWS's model, and the flow people already know from integrating GitHub Actions with AWS. +**A trust is written when a manager adds it; nothing has to prove control of the subject first.** The workload names the account it wants when it exchanges its token — the account segment of `RoleArn`, `arn:sc:iam:::role/FullAccess` (or `ReadOnly`, or the `_default` alias; the `sc` partition marks it as not an AWS role, and SDKs check only the value's length) — and the exchange succeeds only if that account trusts the token's issuer and subject. Trusting a subject one does not control gains nothing: its workflows never ask for the account. This is AWS's model, and the flow people already know from integrating GitHub Actions with AWS. The token path is then: the proxy verifies a token from a trusted platform IdP, reads the account named in `RoleArn`, and asks the API — `POST /api/v1/accounts/{id}/trusts/exchanges`, authenticated as that account — whether it trusts the token's issuer and subject (source-cooperative/data.source.coop#222, #223). Yes means credentials carrying the account's memberships; no means denied. For an Ory ID token the account segment is ignored, because the token itself says who the person is. This is how the Organisation Subject Problem is resolved: the subject of a workload's credential is the *service account*, not the organisation that owns it. From 01e313089eb46b86733657503b5898b7f8531205 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 23 Sep 2026 16:08:37 -0700 Subject: [PATCH 6/8] docs(adr): workflows obtain credentials with configure-aws-credentials The action requires the aws partition and validates with GetCallerIdentity (developmentseed/multistore#126). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- adrs/014-service-accounts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adrs/014-service-accounts.md b/adrs/014-service-accounts.md index 44d14efc..80018eac 100644 --- a/adrs/014-service-accounts.md +++ b/adrs/014-service-accounts.md @@ -33,7 +33,7 @@ No reserved id namespace. `type` is the discriminator and `owner_account_id` rec An account says which subjects may act as it, the way an AWS role's trust policy does. `source.coop` holds an `account-trusts` table keyed by the account, with one row per issuer and exact subject the account trusts. A subject may be trusted by any number of accounts; nothing about a subject alone chooses an account. An individual's Ory identity is not a trust: it stays on the account row as `identity_id`, which the session, the email lookup and the proxy credentials already read. An API key's subject is the service account's own id (ADR-013), which the API resolves directly, so a key writes no trust either. The table holds what the platform cannot derive from an account: a service account's trust in whichever platform-IdP subjects (ADR-009) it integrates with — for GitHub Actions, one repository pinned to one ref or one environment, never organisation-wide. -**A trust is written when a manager adds it; nothing has to prove control of the subject first.** The workload names the account it wants when it exchanges its token — the account segment of `RoleArn`, `arn:sc:iam:::role/FullAccess` (or `ReadOnly`, or the `_default` alias; the `sc` partition marks it as not an AWS role, and SDKs check only the value's length) — and the exchange succeeds only if that account trusts the token's issuer and subject. Trusting a subject one does not control gains nothing: its workflows never ask for the account. This is AWS's model, and the flow people already know from integrating GitHub Actions with AWS. +**A trust is written when a manager adds it; nothing has to prove control of the subject first.** The workload names the account it wants when it exchanges its token — the account segment of `RoleArn`, `arn:aws:iam:::role/FullAccess` (or `ReadOnly`, or the `_default` alias). The partition stays `aws`: `aws-actions/configure-aws-credentials`, which is how a workflow is meant to obtain credentials, treats any other partition as a bare role name, and SDKs check only the value's length. The action validates the credentials it exports with `GetCallerIdentity`, which the proxy answers once developmentseed/multistore#126 lands — and the exchange succeeds only if that account trusts the token's issuer and subject. Trusting a subject one does not control gains nothing: its workflows never ask for the account. This is AWS's model, and the flow people already know from integrating GitHub Actions with AWS. The token path is then: the proxy verifies a token from a trusted platform IdP, reads the account named in `RoleArn`, and asks the API — `POST /api/v1/accounts/{id}/trusts/exchanges`, authenticated as that account — whether it trusts the token's issuer and subject (source-cooperative/data.source.coop#222, #223). Yes means credentials carrying the account's memberships; no means denied. For an Ory ID token the account segment is ignored, because the token itself says who the person is. This is how the Organisation Subject Problem is resolved: the subject of a workload's credential is the *service account*, not the organisation that owns it. From bd11d87fa010ded8a8869906f53fcb1173944789 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 23 Sep 2026 20:49:26 -0700 Subject: [PATCH 7/8] ADR-014: namespace service-account ids under their owner 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) Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z --- adrs/014-service-accounts.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/adrs/014-service-accounts.md b/adrs/014-service-accounts.md index 80018eac..e43ea1fa 100644 --- a/adrs/014-service-accounts.md +++ b/adrs/014-service-accounts.md @@ -27,7 +27,7 @@ What an unattended workload needs is a principal with **its own grant**: revocab `service` joins `individual` and `organization`. A service account is owned by exactly one account, individual or organisation (`owner_account_id`), and is managed by whoever manages the owner — the owner's `owners` and `maintainers`, or an individual owner themselves. It has no Ory identity and no public profile. It never acts as admin whatever its flags say, and it creates neither products nor accounts. It has no rights over itself: the self-authorization shortcut that lets a person edit their own account is a person's alone. -No reserved id namespace. `type` is the discriminator and `owner_account_id` records ownership; the id is an ordinary account id. +A service account's id is namespaced under its owner: `{owner_account_id}--{id}`, such as `acme--nightly-sync`. The `--` is one no person's or organisation's id may contain, and the one account-owned data connections already use between owner and name. So the id is unique per owner, and every owner can have its own `nightly-sync`. A service account never takes a handle a person or organisation might want, and its id can never equal an Ory identity id, which is a UUID. `type` stays the discriminator and `owner_account_id` records ownership; the prefix only repeats it. ### How it authenticates: account trusts @@ -83,9 +83,9 @@ The division of labour: **a Role answers "how narrow is this credential"; a serv **Costs / Risks** - A new account type touches every place that branches on the existing two — around fifty sites — and the default at each is *exclude*. -- Two paths by design, not one: an Ory identity resolves through `identity_id`; a service account is named by the caller and checked against its trusts. The proxy forwards a bare `sub` and the API tries Ory first, so a service account whose id equals a person's Ory identity id would resolve to the person; such an account is refused a key. +- Two paths by design, not one: an Ory identity resolves through `identity_id`; a service account is named by the caller and checked against its trusts. The proxy forwards a bare `sub` and the API tries Ory first; a service account's id always contains `--` and an Ory identity id never does, so the two cannot be confused. - A trust is only as narrow as its subject: the platform pins GitHub subjects to one repository and one ref or environment, and every later issuer needs the same care. -- Each service account consumes a public name; a per-owner cap is an open question. +- A service account's id takes no public name, since it lives under its owner's, and the owner is fixed for good: it is part of the id. A per-owner cap is an open question. - Deleting an owner that owns service accounts must be blocked (account deletion is itself unimplemented, source-cooperative/source.coop#355). --- @@ -98,6 +98,8 @@ The division of labour: **a Role answers "how narrow is this credential"; a serv **OAuth2 client credentials** — ADR-013 already rejected it as requiring "a bespoke service account system". This ADR is that system, built on the account model rather than beside it. -**A reserved `svc--` id namespace** — rejected. `ID_REGEX` forbids consecutive hyphens in account ids, and `--` is already the data-connection composite-id delimiter; relaxing the rule would let user-chosen ids collide with connection ids. The account type is the discriminator. +**A reserved `svc--` id prefix** — rejected. It marks the type, which `type` already does, and it keeps every id platform-wide: one owner's `svc--nightly-sync` is every owner's. Namespacing by owner uses the same `--` to scope the id instead. Loosening the id rule for everyone was the concern; it loosens for service accounts only, whose ids the platform composes from two ids that each pass the strict rule, and connection ids live in their own table. + +**Globally unique, un-namespaced ids** — rejected. The create form derives the id from the name, so the second owner to name a service account "Nightly Sync" is told `nightly-sync` is taken, and each one spends a handle a person or organisation might later want. **Roles selectable per service account ("tick which Roles it may use")** — rejected for the first release. A Role can only subtract, so any caller may safely name either hardcoded one; a tick-box would be a no-op that reads as a restriction. From 67d98934ab3f66484786c71b6e4c8ef9cebed8d9 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 25 Sep 2026 16:11:40 -0700 Subject: [PATCH 8/8] docs(adr): ADR-014 amends ADR-004 and ADR-005 too 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 Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd --- adrs/004-sts.md | 3 +++ adrs/005-authorization.md | 3 +++ adrs/013-api-keys.md | 2 +- adrs/014-service-accounts.md | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/adrs/004-sts.md b/adrs/004-sts.md index 4c689a27..5909d173 100644 --- a/adrs/004-sts.md +++ b/adrs/004-sts.md @@ -73,6 +73,9 @@ A single built-in Role, `_default`, is served from a hardcoded registry: `RoleArn` is accepted either literally as `_default` or as an ARN-shaped alias whose resource is `role/_default` (e.g. `arn:aws:iam::000000000000:role/_default`, any partition or account ID). The alias exists because AWS SDKs validate `RoleArn` client-side — ARN shape, 20-character minimum — before the request is ever sent, so a bare `_default` cannot reach the server from unmodified tooling. The partition and account portions carry no meaning here and are ignored rather than validated. +> [!NOTE] +> **Amended by ADR-014 (Service Accounts).** For a platform issuer's token, the account portion of `RoleArn` is no longer ignored: it names the service account whose trust in the token's issuer and subject is checked, and the credentials act as that account. For an Ory ID token it is still ignored, because the token itself names the person. + ### Trust Model — Issuer and Audience **Issuer.** `AUTH_ISSUER` names the single trusted OIDC issuer: Source Cooperative's Ory-based auth system (`https://auth.source.coop`, or the staging equivalent). A token from any other issuer is rejected before any network call. diff --git a/adrs/005-authorization.md b/adrs/005-authorization.md index 41a22873..33a4d6be 100644 --- a/adrs/005-authorization.md +++ b/adrs/005-authorization.md @@ -75,6 +75,9 @@ The API trusts the proxy to assert any `sub`. That trust rests on the JWT signat > [!NOTE] > **The subject is an individual identity, not an account.** The proxy signs with the caller's Ory identity id, and the API resolves it through the identity index. Organisation accounts are never the subject of a proxy-issued token. The RFC anticipated `sub` = `account_id`, which "may be a user or an organisation", to support a CI workflow assuming an org-owned Role. That path arrives with ADR-010; until then there is no Role for an organisation to own. +> [!NOTE] +> **Amended by ADR-014 (Service Accounts).** The workload path the note above expects from ADR-010 arrives through ADR-014 instead: a subject may also be a service account's id, which the API resolves as that account. Organisations still never authenticate. For a platform issuer's token, the proxy signs as the account `RoleArn` names to ask whether that account trusts the token, which is the one lookup made as an account before the caller is established. + ### Batch Delete Per-key authorization for batch delete confirms only that the operation is a write, relying on the product-level authorization already performed during resolution. This is sufficient because Source Cooperative authorizes writes at the product level, and defensible as defence in depth — it is only reached for write batch operations and never blanket-allows a read. It would be insufficient if a future multistore invoked it without a prior successful resolution for the same bucket. diff --git a/adrs/013-api-keys.md b/adrs/013-api-keys.md index ee5cf554..80b16ed1 100644 --- a/adrs/013-api-keys.md +++ b/adrs/013-api-keys.md @@ -9,7 +9,7 @@ > The `api-keys` endpoints that exist in `source.coop` today are the **legacy** admin-managed keys used by the pre-Workers proxy. They are unrelated to this design, and the current proxy has no code path that accepts them (ADR-001). This ADR proposes a replacement, not a formalisation of what is there. > [!NOTE] -> **Amended by ADR-014 (Service Accounts).** The `sub` of an API-key JWT is a **service account** id; a key belongs to one service account. There is no per-key Role binding in the first release — the account's memberships are the grant and the hardcoded Roles only subtract — so the dependency on ADR-010 is replaced by one on ADR-014. Expiry is optional and may be changed after issuance; several keys may be active at once. +> **Amended by ADR-014 (Service Accounts).** The `sub` of an API-key JWT is a **service account** id; a key belongs to one service account. There is no per-key Role binding in the first release — the account's memberships are the grant and the hardcoded Roles only subtract — so the dependency on ADR-010 is replaced by one on ADR-014. Expiry is optional and may be changed after issuance; several keys may be active at once. The examples below use ADR-010's `sc::` `RoleArn` grammar; clients send the AWS form ADR-014 describes, `arn:aws:iam:::role/`. --- diff --git a/adrs/014-service-accounts.md b/adrs/014-service-accounts.md index e43ea1fa..830e33cf 100644 --- a/adrs/014-service-accounts.md +++ b/adrs/014-service-accounts.md @@ -4,7 +4,7 @@ **Date:** 2026-09-21 **RFC:** RFC-001 §7 **Depends on:** ADR-004, ADR-005, ADR-009 -**Amends:** ADR-010 (scope), ADR-013 (subject and Role binding) +**Amends:** ADR-004 (the `RoleArn` account segment), ADR-005 (who a subject may be), ADR-010 (scope), ADR-013 (subject and Role binding) ---