From 0c53df5ee5dda32c1df39fd8bad65758a90a065c Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 25 Aug 2026 16:40:39 -0700 Subject: [PATCH 1/8] docs(adrs): service accounts, subject resolution, and a delete action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds source-cooperative/source.coop#491 into the ADR series. About half of that epic re-derived decisions the corpus already records; this keeps what was new and cites what was not. New: - ADR-014, federated subject resolution. An account is found by (issuer, subject), not a bare subject. Answers the organisation-subject question ADR-010 defers, and closes a collision where two issuers share one subject namespace — which also keys the proxy's cache. - ADR-015, service accounts. A principal for unattended software, with an owner, many sign-in methods, and grants through ordinary memberships. - ADR-016, delete as an action distinct from write. No layer can express "may upload, may not delete" today. Amended: - ADR-013 keeps its JWT mechanism but drops optional expiry (undeliverable on a two-key JWKS), moves to a dedicated signing key rather than reusing the ADR-006 federation key, issues keys to service accounts, and records opaque keys and managed key storage as considered and rejected. - ADR-009 marks issuer namespacing a precondition rather than a follow-up, and states that empty audience and subject lists must mean deny. - ADR-010 points the organisation-subject problem at ADR-014 and adds a _read_only built-in. - ADR-004 corrects the claim that aws-actions/configure-aws-credentials works; it calls GetCallerIdentity, which is not implemented. - RFC-001 future work and decision index. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt --- adrs/004-sts.md | 3 +- adrs/009-platform-idps.md | 11 ++- adrs/010-account-owned-roles.md | 10 ++- adrs/013-api-keys.md | 68 ++++++++++----- adrs/014-federated-subject-resolution.md | 92 +++++++++++++++++++++ adrs/015-service-accounts.md | 100 +++++++++++++++++++++++ adrs/016-delete-action.md | 74 +++++++++++++++++ adrs/rfc-001.md | 10 ++- 8 files changed, 344 insertions(+), 24 deletions(-) create mode 100644 adrs/014-federated-subject-resolution.md create mode 100644 adrs/015-service-accounts.md create mode 100644 adrs/016-delete-action.md diff --git a/adrs/004-sts.md b/adrs/004-sts.md index 4c689a27..946ed227 100644 --- a/adrs/004-sts.md +++ b/adrs/004-sts.md @@ -57,7 +57,7 @@ Action=AssumeRoleWithWebIdentity ``` -Both transports are supported because AWS SDKs send query-protocol operations as a form-encoded POST with no query string at all. The Worker absorbs that form body before routing, or SDK clients would fall through to the S3 pipeline unhandled. This makes `/.sts` a drop-in target for `boto3.client('sts', endpoint_url='https://data.source.coop/.sts')`, for the SDKs' built-in web-identity credential providers (`AWS_WEB_IDENTITY_TOKEN_FILE` / `AWS_ROLE_ARN`), and for `aws-actions/configure-aws-credentials`. +Both transports are supported because AWS SDKs send query-protocol operations as a form-encoded POST with no query string at all. The Worker absorbs that form body before routing, or SDK clients would fall through to the S3 pipeline unhandled. This makes `/.sts` a drop-in target for `boto3.client('sts', endpoint_url='https://data.source.coop/.sts')`, and for the SDKs' built-in web-identity credential providers (`AWS_WEB_IDENTITY_TOKEN_FILE` / `AWS_ROLE_ARN`). **`AssumeRoleWithWebIdentity` is an action parameter, not a path segment.** The route is plain `/.sts`; the action travels in the query string or the form body. Parameters are read from exactly one source — the query string is tried first, then the body — and are **never merged across the two**, matching AWS. Collecting a form-encoded `POST` body never steals a payload the S3 forwarding path needs, because form-encoded `POST` is not part of the S3 protocol. This behaviour is implemented upstream in [multistore#112](https://github.com/developmentseed/multistore/pull/112). @@ -150,6 +150,7 @@ The JWKS cache is isolate-shared (a process-wide `OnceLock`, with `Arc> - **Only one Role exists, with an unlimited ceiling.** There is no way to issue a credential narrower than the account's full permissions. See ADR-010. - An unavailable JWKS causes all exchanges for that issuer to fail for 30 seconds — failures use their own short negative-cache TTL, not the 15-minute success TTL — after which one retry is attempted. There is no serve-stale path: entries are used only while fresh, so a *stale* JWKS never blocks anything. - The web flow's dependence on the Ory admin API means a compromised call site is a "become any user" primitive; it is guarded by construction rather than by input validation. +- **`aws-actions/configure-aws-credentials` does not work.** It calls `GetCallerIdentity` after the exchange, which `/.sts` does not implement. The web-identity credential providers built into the SDKs do work; the most popular GitHub Action for this does not. Tracked separately. --- diff --git a/adrs/009-platform-idps.md b/adrs/009-platform-idps.md index e76ec13a..196cbbf8 100644 --- a/adrs/009-platform-idps.md +++ b/adrs/009-platform-idps.md @@ -55,6 +55,12 @@ Each platform IdP record: This is the one substantive design decision in this ADR; the rest is plumbing. +### Empty Means Deny + +Two of the three trust fields on a Role currently fail open: an empty `required_audiences` accepts any audience, and an empty `subject_conditions` skips the subject check. Both carry `#[serde(default)]`. (`trusted_oidc_issuers` is also defaulted but fails closed — an empty list matches no issuer.) + +Today `data.source.coop` compensates by refusing to mount `/.sts` at all when `AUTH_AUDIENCE` is empty, which is sufficient for exactly one hardcoded Role. Once Roles are account-authored (ADR-010), a Role that omits a field would silently trust everything from its issuer. Fix upstream before Roles ship: empty must mean deny. + ### Fail-Closed Behaviour Is Preserved ADR-004's rule — an issuer with no audience restriction disables exchange rather than serving it unrestricted — must hold per issuer. An operator adding an issuer without an audience requirement should find that issuer refused, not silently trusted. @@ -65,7 +71,10 @@ ADR-004's rule — an issuer with no audience restriction disables exchange rath 2. Move the issuer→audience mapping into a structured variable, since a flat pair of lists cannot express per-issuer requirements. 3. Populate `trusted_oidc_issuers` on the `_default` Role from the parsed list. -Step 1 alone makes CI/CD workflows functional against the `_default` Role. Steps 2 and 3 are required before it is safe to enable in production. +Step 1 alone makes CI/CD workflows functional against the `_default` Role. Steps 2 and 3 are required before it is safe to enable in production, and so is ADR-014 — see below. + +> [!WARNING] +> **Issuer-namespaced subjects (ADR-014) are a precondition, not a follow-up.** The credential the proxy mints records the subject but not the issuer, so every issuer's subjects share one namespace. A second issuer that can choose its own subject strings can therefore name a subject belonging to someone else, and the API cannot tell. The subject is also part of the proxy's cache key. GitHub is a second issuer, so this applies to the first issuer added here, not only to user-registered ones. > [!IMPORTANT] > Until ADR-010 lands, every issuer added here can assume `_default`, whose ceiling is unlimited. A GitHub Actions workflow would receive the full permissions of whichever account the token's subject maps to. **Multi-issuer support without Roles widens the blast radius of any CI token to the user's entire account.** These two ADRs should ship together, or the issuer list should stay restricted to `auth.source.coop` until ADR-010 is ready. diff --git a/adrs/010-account-owned-roles.md b/adrs/010-account-owned-roles.md index 4d950f27..7393f366 100644 --- a/adrs/010-account-owned-roles.md +++ b/adrs/010-account-owned-roles.md @@ -98,6 +98,14 @@ Every account keeps a built-in `sc::{account_id}::role/_default`: This is the Role that ships today (ADR-004); this ADR generalises around it without changing its behaviour, so existing clients keep working unchanged. +### The `_read_only` Role + +Every account also keeps a built-in `sc::{account_id}::role/_read_only`, identical to `_default` except that its permissions carry `["read"]`. + +It exists because the common case for a narrower credential needs no authoring at all: a job that only reads should ask for a credential that cannot write, whether or not anyone has written a Role for it. Since a Role is a ceiling (ADR-011), asking for `_read_only` never grants anything the caller lacked — so no account needs to opt in, and any caller may name it. + +Two built-ins are the whole set. Anything narrower is an account-authored Role. + ### Validation at Creation 1. `name` matches `[a-z0-9][a-z0-9-]{0,62}` @@ -131,7 +139,7 @@ ADR-005 records that the proxy signs policy-store calls with the caller's **Ory Account-owned Roles break that assumption directly. A CI workflow assuming `sc::my-org::role/publisher` must resolve **my-org's** permissions, not those of the individual who configured it. So this ADR requires a paired change on the API side: accept an account id as `sub`, resolving either an individual or an organisation. -**This is the largest piece of work in this ADR and it is not proxy-side.** It should be designed before the Role schema is built, because it determines whether `sub` stays an Ory identity with the account carried separately, or becomes an account id as RFC-001 §11 assumed. +**This is the largest piece of work in this ADR and it is not proxy-side.** It is now answered by ADR-014: `sub` becomes neither an Ory identity nor a bare account id, but a subject qualified by its issuer, resolved through a binding table. That resolves the organisation case and the multi-issuer collision together. ADR-014 blocks this ADR. --- diff --git a/adrs/013-api-keys.md b/adrs/013-api-keys.md index 69718595..76dc70f3 100644 --- a/adrs/013-api-keys.md +++ b/adrs/013-api-keys.md @@ -1,12 +1,12 @@ -# ADR-013: API Keys for Environments Without OIDC +# ADR-013: API Keys — Long-Lived Credentials for Service Accounts **Status:** Proposed — not implemented **Date:** 2026-04-01 **RFC:** RFC-001 -**Depends on:** ADR-001, ADR-004, ADR-006, ADR-010 +**Depends on:** ADR-001, ADR-004, ADR-010, ADR-014, ADR-015 > [!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. +> 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 that six live HTTP routes in `source.coop` still read and write that table, so removing it needs a deprecation window rather than a straight delete — there is no remaining *consumer*, which is not the same as no code path. --- @@ -22,6 +22,8 @@ However, a significant class of users has neither: These users have Source Cooperative accounts but operate in compute environments that do not issue OIDC tokens and cannot perform interactive browser authentication at runtime. ADR-001 and ADR-004 both identify this gap as future work. +A key is issued to a service account (ADR-015), not to a person. The workload gets an identity of its own that can be granted and revoked without touching the account of whoever set it up — which is what makes an unattended credential safe to hand out. This ADR covers only the credential; the principal, its ownership and its grants are ADR-015. + --- ## Decision @@ -44,11 +46,27 @@ An API key JWT contains: ``` - `iss` is the proxy's own issuer URL, not `auth.source.coop` (which is Ory Network and outside Source Cooperative's control for token minting) -- `sub` identifies the Source Cooperative account that owns the key +- `sub` identifies the principal the key belongs to — a service account (ADR-015), resolved through an identity binding (ADR-014) like any other subject. A key is issued to a service account, never to a person. - `jti` is a unique key identifier used for revocation checks -- `exp` is optional — keys without an expiry are valid until explicitly revoked +- `exp` is **mandatory**, with a platform ceiling. See below. - `type` distinguishes API key JWTs from other tokens the proxy may issue (e.g. outbound federation tokens) +### Keys Always Expire + +An earlier draft of this ADR made `exp` optional, so a key would be "valid until explicitly revoked". Two things rule that out. + +The practical one: an indefinite key outlives the person who created it. This ADR's own Costs section names the failure — a researcher leaves a university and their key keeps working until an administrator happens to revoke it. + +The mechanical one: it cannot be delivered on the key material described below. A JWKS publishes the current key and one previous key, so a signature stops verifying two rotations after it was made, whatever the token says. A key with no `exp` would not be valid indefinitely; it would fail at an unpredictable point and look like an outage. + +Keys carry an expiry chosen at creation, bounded by a platform maximum. Renewal is issue-new, deploy, revoke-old: rotation has no overlap window and cannot extend an existing key's expiry. + +### Signing Key + +API keys are signed with a dedicated key, published under its own `kid`, **not** the outbound federation key from ADR-006. + +That key is a public contract: its issuer URL and thumbprint are registered in third-party cloud IAM configurations (ADR-012), so rotating it is a coordinated migration with external parties. Long-lived credentials must not depend on a key whose rotation schedule is set by someone else's cloud account, and a compromise of one should not force the other. + ### Key Lifecycle **Creation:** @@ -56,12 +74,12 @@ An API key JWT contains: Users create API keys via the Source Cooperative UI or CLI: ``` -source keys create --label "ncar-cronjob" --role sc::my-org::role/publisher +source keys create --service-account svc--ncar-cronjob --expires-in 90d ``` The system: 1. Generates a unique `jti` -2. Stores key metadata in the policy store: `jti`, account ID, label, bound Role (optional), created-at, expires-at (nullable) +2. Stores key metadata in the policy store: `jti`, service account id, label, created-at, expires-at 3. Mints and signs the JWT 4. Returns the raw JWT to the user — displayed once, never stored by the platform @@ -83,7 +101,7 @@ GET /api/accounts/{account_id}/keys DELETE /api/accounts/{account_id}/keys/{key_id} ``` -The `GET` endpoint returns key metadata (ID, label, created-at, expires-at, last-used-at) but never the JWT itself. Only account owners and org admins can manage keys. +The `GET` endpoint returns key metadata (ID, label, created-at, expires-at, last-used-at) but never the JWT itself. Keys are managed by whoever can manage the service account's owner (ADR-015). ### STS Exchange @@ -130,18 +148,21 @@ Roles that should be assumable via API key must include an identity constraint b { "idp": "source-coop-api-key", "claim_constraints": [ - {"claim": "type", "operator": "equals", "value": "api_key"} + {"claim": "type", "operator": "equals", "value": "api_key"}, + {"claim": "sub", "operator": "equals", "value": "svc--ncar-cronjob"} ] } ``` This reuses the Role and identity constraint model from ADR-010 without modification — and therefore depends on it, since no such model exists today. Account owners explicitly opt in to API key access per Role: a Role without a `source-coop-api-key` binding cannot be assumed with an API key. -### Role Binding +The `sub` constraint names the service account, so a Role states which machine identity may assume it in exactly the way it states which GitHub repository may. There is one place a reviewer looks to see who can assume a Role. -API keys can optionally be bound to a specific Role at creation time. A bound key can only be used to assume that Role. An unbound key can assume any Role the account owns that has a `source-coop-api-key` identity constraint. +### One Key, One Service Account -Bound keys reduce blast radius: if leaked, the key can only access what that specific Role permits. For high-value automated workflows, bound keys are recommended. +A key is bound to exactly one service account at creation and cannot be moved. Which Roles that service account may assume is recorded on the Roles themselves (ADR-010, ADR-015), so a key needs no Role binding of its own. + +An earlier draft let a key optionally bind to a single Role, to limit the blast radius of a leak. That is now expressed by making a second service account with narrower grants, which limits the blast radius the same way and keeps one place where access is described. ### Caching and Revocation Latency @@ -156,18 +177,19 @@ This means revocation takes effect within roughly a minute. For the target use c **Benefits** - Covers the authentication gap for environments without OIDC or browser access -- No new auth path at the proxy layer — API key JWTs flow through the existing `/.sts` exchange -- Reuses the proxy's existing OIDC issuer infrastructure (signing key, JWKS) from ADR-006 +- No new auth path at the proxy layer — API key JWTs flow through the existing `/.sts` exchange, and no exchange endpoint is needed elsewhere +- **The key is the token file.** A stock AWS SDK reads the key from `AWS_WEB_IDENTITY_TOKEN_FILE` and calls `/.sts` itself, so unattended use needs no Source-specific code and no background process keeping a token fresh - Reuses the Role and identity constraint model from ADR-010 - Revocation is explicit and auditable via `jti` lookup -- Optional Role binding limits blast radius of leaked keys +- A key belongs to a service account, so it can be revoked without touching any person's access **Costs / Risks** - API key JWTs are bearer tokens — anyone with the raw JWT can use it. Users must treat them like passwords (store in environment variables or secret files, not in source control) -- The `jti` revocation check adds a policy store dependency to the STS exchange path for API key tokens. Cache misses add latency. -- Keys without expiry are valid indefinitely until revoked. If a user loses access to the management UI (e.g. leaves a university), orphaned keys persist unless an org admin revokes them. -- The proxy's signing key is now used for two purposes: outbound federation tokens (ADR-006) and API key JWTs. A signing key compromise affects both. Key rotation must account for both uses. +- The `jti` revocation check adds a policy store dependency to the STS exchange path for API key tokens. Cache misses add latency, and the check must fail closed: an unavailable policy store denies rather than skipping the check. +- Keys expire, so an unattended workload stops working on a known date. That is the intended trade against indefinite keys, but it fails silently — there is no notification channel, so expiry must be documented and surfaced in the UI. +- A second signing key to manage, publish and rotate. +- Revocation takes effect within the cache TTL, and credentials already issued live out their session regardless. --- @@ -179,6 +201,14 @@ This means revocation takes effect within roughly a minute. For the target use c **Ory personal access tokens** — investigated. Ory Network's PAT/API key concept (`ory_pat_`) is for project admin API access, not end-user authentication. User-scoped PATs are an [open feature request](https://github.com/ory/kratos/issues/1106) on Ory Kratos but not available. -**Opaque API keys with hash-based validation** — considered. The platform generates a random secret, stores a hash, and validates by re-hashing. This works but requires a dedicated validation endpoint or a new auth path at `/.sts`. The JWT approach avoids this by making API keys indistinguishable from other OIDC tokens at the STS layer — no new endpoint, no new validation logic beyond the `jti` check. +**Opaque API keys with hash-based validation** — considered, and reconsidered. The platform generates a random secret, stores a hash, and validates by re-hashing. + +The case for it is that this ADR's `jti` check already costs a policy-store call on the exchange path, so the token is not self-contained either way; once that call exists, a hash comparison is barely more work. The case against is what the opaque key cannot do: it is not a JWT, so it cannot be presented at `/.sts`, which means a separate exchange endpoint in `source.coop` to trade the key for a short-lived token, that endpoint's own rate limiting and error semantics, and a background process on the workload keeping the resulting token file fresh. The JWT is itself a valid `AWS_WEB_IDENTITY_TOKEN_FILE`, so none of that exists. + +Rejected on that difference. Revocability does not distinguish the two: `jti` deny-listing revokes an individual key with the same lookup and the same lag as a hashed-secret check. + +**Managed key storage (Ory Talos)** — rejected. A hosted key service supplies hashing, forced expiry, rotation and a self-revoke endpoint. Since keys here are JWTs, there is no secret to store and hash, so what remains is metadata this platform already keeps. It would also add a vendor on the credential path with an undisclosed per-project key ceiling and an unstated verification cache TTL, which would set the real revocation window. Revisit only if opaque keys are adopted after all. + +**Long-lived static credentials in the proxy's credential registry** — rejected. `StsCredentialRegistry::get_credential` is a stub returning nothing, and upstream models a scoped, expiring, revocable stored credential, so this looks like the shortest path. It is not: S3 request signing is symmetric, so the proxy would have to recover each key's plaintext secret to verify a signature. That reintroduces a store of usable secrets, which is what ADR-001 and this ADR both exist to avoid. **Long-lived Ory refresh tokens** — considered as a near-term workaround. The user performs a one-time `source login` (device flow) and stores the refresh token. Cronjobs silently refresh access tokens. This works without new infrastructure but refresh tokens expire eventually, causing silent failures in unattended workflows. Suitable as an interim measure but not a durable solution for indefinitely recurring workloads. diff --git a/adrs/014-federated-subject-resolution.md b/adrs/014-federated-subject-resolution.md new file mode 100644 index 00000000..52557cc5 --- /dev/null +++ b/adrs/014-federated-subject-resolution.md @@ -0,0 +1,92 @@ +# ADR-014: Federated Subject Resolution — `(issuer, subject)` as the Account Key + +**Status:** Proposed — not implemented +**Date:** 2026-08-24 +**RFC:** RFC-001 §7 +**Depends on:** ADR-004, ADR-005 +**Blocks:** ADR-009, ADR-010, ADR-015 + +--- + +## Context + +ADR-005 records that the proxy signs its API calls with the caller's Ory identity id, and that `source.coop` looks that id up to find the account. The lookup runs against a single index and then filters the result to individual accounts, so it can only ever return a person. + +That is fine while there is one issuer and every caller is a human. ADR-009 adds more issuers, ADR-010 needs organisations to be the subject, and ADR-015 adds machines. All three break on the same lookup. + +There is a second problem, and it is a security one. The credential the proxy mints records **who** the caller is but not **who vouched for them**. Every issuer's subjects therefore share one flat namespace. If a second issuer can be told what subject to put in a token — which is the whole point of letting users register their own — it can name a subject belonging to someone else, and the API cannot tell the difference. The subject is also part of the proxy's cache key, so a collision poisons cached authorisation results too. + +ADR-009 states that its migration step 1 alone "makes CI/CD workflows functional". That is true and not safe: GitHub is a second issuer, and it is subject to exactly this collision. + +**The blocker is specific: an account can only be found by a bare subject string, and that string is not qualified by who issued it.** + +--- + +## Decision + +### Accounts Are Found by a Pair + +An account is resolved by `(issuer, subject)`, never by `subject` alone. The existing single-argument lookup becomes a thin wrapper that supplies the Source issuer. + +### Identity Bindings + +A binding records that one subject, from one issuer, is one account: + +| Field | Notes | +|---|---| +| `issuer` | The issuer URL exactly as it appears in the token's `iss` claim | +| `subject` | The `sub` claim, matched exactly — never a prefix or a pattern | +| `account_id` | The account this identity is | + +The pair `(issuer, subject)` is unique. Two bindings claiming the same pair is a hard error, not a last-writer-wins race: today's lookup takes the last row the index returns, which is not deterministic. + +An account may have many bindings. A person has one; a service account (ADR-015) may have several. + +> [!IMPORTANT] +> **Attaching a binding requires proof of control of that subject.** Otherwise anyone can claim another organisation's CI subject and receive their access. For GitHub the proof is a token minted by the workflow itself, carrying a one-time audience. For an issuer that cannot mint on demand, registration is reviewed rather than self-serve. + +### Issuer Provenance Reaches the Decision + +The minted credential already carries `assumed_role_id`. The proxy rewrites the principal it sends to the API into a namespaced form derived from the **verified** issuer — not from the Role, because one Role may trust several issuers and would collapse them back into one namespace. + +The API resolves that namespaced principal through the binding table. Cache keys use the namespaced form, so two issuers can no longer share a cache entry. + +### Migration + +Every existing account must keep working, so this ships in four steps: + +1. Add the new index. Write the namespaced attribute on account creation **and update**, or new rows silently miss it. +2. Backfill every existing account to `(Source issuer, Ory identity id)`. +3. Read from the new index, falling back to the old one on a miss. +4. Once the backfill is verified complete in production, remove the fallback. + +> [!WARNING] +> **A missed row is a person who cannot log in.** The failure is silent — the resolver returns nothing and the request 401s. Do not remove the fallback in step 4 until a count confirms every account carries the new attribute. Local fixtures and the local-development table definition need the same change, or local development breaks. + +--- + +## Consequences + +**Benefits** + +- Organisations and service accounts can be the subject of a token, which ADR-010 and ADR-015 both require. +- Two issuers can no longer be confused for one another, in authorisation or in the cache. +- ADR-009 becomes safe to enable, rather than merely functional. +- Every credential can be attributed to the issuer that vouched for it, which is the missing half of ADR-011's audit record. + +**Costs / Risks** + +- A full-table backfill on live account data, with no migration framework to lean on and a silent failure mode. +- A dual-read window in which two indexes must agree. +- One more index to define in both the deployed table and the local one. +- Proof of control is easy for GitHub and awkward for issuers that cannot mint a token on demand. + +--- + +## Alternatives Considered + +**Keep resolving by bare subject** — rejected. It works only while there is one issuer, and ADR-009 removes that condition. The collision is not theoretical: a user-registered issuer chooses its own subject strings. + +**Make `sub` an account id instead of an identity id** — considered, and this is the open question ADR-010 leaves. Rejected because it answers a narrower question: it lets an organisation be the subject but still cannot tell two issuers apart, so ADR-009 would remain unsafe. A binding keyed on the pair does both. + +**Namespace the subject using the Role's configured issuer list** — rejected. `trusted_oidc_issuers` is a list, so a Role trusting two issuers would map both into one namespace and reintroduce the collision. Derive the namespace from the verified `iss` claim. diff --git a/adrs/015-service-accounts.md b/adrs/015-service-accounts.md new file mode 100644 index 00000000..58c740a2 --- /dev/null +++ b/adrs/015-service-accounts.md @@ -0,0 +1,100 @@ +# ADR-015: Service Accounts — A Principal for Unattended Software + +**Status:** Proposed — not implemented +**Date:** 2026-08-24 +**RFC:** RFC-001 §7 +**Depends on:** ADR-010, ADR-014 +**Blocks:** ADR-013 + +--- + +## Context + +Source Cooperative can only authenticate people. Getting a credential needs a human at a browser, so anything that runs unattended — a nightly sync, a publishing pipeline, an instrument uploading observations — must either borrow a person's session or store a person's secret somewhere it does not belong. + +ADR-010 lets an account create Roles that a CI workflow can assume, which covers part of this. But a Role is a ceiling, not an identity: the permissions it narrows still belong to the account that owns the Role. There is no way to grant access to *the pipeline* as distinct from the person who set it up, to revoke the pipeline without touching that person, or to tell them apart in a log. + +ADR-013 assumes an API key's `sub` is a human account id, for the same reason — there is nothing else it could be. + +**What is missing is a principal: something that is not a person, can hold grants of its own, and can be revoked on its own.** + +--- + +## Decision + +### A Service Account Is an Account + +Service accounts reuse the account model rather than adding a parallel one. A service account has three parts: + +| Part | Answers | Where it lives | +|---|---|---| +| The account | Who is this? | A new `service` account type | +| Sign-in methods | How does it prove that? | Identity bindings (ADR-014), one or many | +| Grants | What may it reach? | Ordinary memberships, one or many | + +Sign-in methods and grants are independent. Adding a second way to sign in does not change what the account may reach, and changing what it may reach does not affect how it signs in. A GitHub workflow and an API key that resolve to the same service account get identical access — if a workflow should have narrower access than a key, make two service accounts. + +Ids are minted in a reserved namespace, `svc--`. The existing id pattern forbids a double hyphen anywhere in a human-chosen id, so the prefix cannot be forged. + +### Ownership + +Each service account names exactly one owner — a person or an organisation. A single owner is required because two rules need one: a service account can never do more than its owner currently can, and disabling the owner disables what it owns. + +Where the creator belongs to an organisation, the owner defaults to the organisation. An organisation-owned service account survives any member leaving; a personally-owned one is disabled when that person is. That is the intended safety property, and also an accidental outage if the default is wrong. + +The cap is evaluated per request, not frozen at grant time, and it applies to the authorisation **decision** rather than to the membership list — three paths in the API authorise without consulting memberships at all, and a cap applied only to the list would miss them. + +> [!NOTE] +> **The owner cap is undefined when the owner is an organisation,** which is also the recommended default. Organisations never authenticate, and the existing membership lookup returns rows where the organisation is the *member*, which is not the same as what it can reach. Either define it as the products the organisation owns plus grants it holds, or state that organisation-owned service accounts are uncapped. This must be settled before the cap is built. + +Deleting an account that owns service accounts is blocked. A missing owner denies, rather than skipping the check. + +### Scope Is a Restriction on Grant Types + +A service account may hold only data grants — read or write on a product, or (per ADR-016) delete. It cannot hold an owner or maintainer role. + +This is the whole scope statement, and it needs no separate list of prohibitions. Every other action in `source.coop` — creating products, creating organisations, managing members, owning a record — already requires an owner or maintainer role, so restricting the grant types makes all of them unreachable by construction. + +Two guardrails the grant model cannot express stay explicit: + +- **The platform admin flag** is read directly from account flags, outside the role system. It needs an invariant at write time, not an omission from the UI. +- **Ids** must be minted in the reserved namespace above. + +> [!WARNING] +> **Fix the self-authorisation shortcut first.** `hasRole` returns true before checking any role when the principal's own account id equals the account being acted on, and the read and write data checks do the same against a product's account. A service account has its own account id, so it would authorise itself for anything scoped to itself — including editing its own flags. The owner cap has the same hole. One fix serves both, and it must land before either. + +### Roles Are Unchanged + +A service account assumes a Role by URN exactly as any other caller does (ADR-010), and the credential is the Role's ceiling intersected with the account's live grants (ADR-011). Nothing here adds a second authorisation model. + +Which Roles a service account may assume is recorded on the Role, in its `identity_constraints`, alongside the binding that identifies the account. A management UI may present this as a per-service-account list, but there is one store, not two. + +--- + +## Consequences + +**Benefits** + +- Unattended software gets an identity that is not a person's, revocable on its own. +- Grants, revocation, and the pages that manage them are the ones that already exist. +- One identity can hold several sign-in methods, so swapping CI for a key is a configuration change rather than a re-grant. +- Logs can finally tell a pipeline apart from the person who configured it. + +**Costs / Risks** + +- Account type is branched on in roughly 47 places across 19 files in `source.coop`. Most keep compiling and quietly treat a machine as a person. The count is dominated not by explicit type checks but by binary "individual or organisation" helpers, which would render a machine as an organisation profile. +- The membership pages are the deliberate exception and must show service accounts, since that is where an owner revokes a grant. Membership listing currently returns false for any third account type. +- An account id is also the first segment of a public URL, so every service account consumes a name and gets a profile route. +- The owner cap adds an owner lookup to the request path, which is already sensitive to latency. + +--- + +## Alternatives Considered + +**A separate table instead of a new account type** — considered, and close. It would make exclusion a compile error rather than 47 judgement calls, and keep machines out of the public account schema. Rejected because grants would then need a parallel membership model, which is a larger and more duplicative change than the branch sites it avoids. If the branch sites prove unmanageable in practice, this is the fallback. + +**Permissions attached to each sign-in method** — rejected. It doubles the authorisation surface for a case ("CI may read, the key may write") that two service accounts already express. + +**Roles alone, with no principal** — rejected. This is ADR-010 as it stands. A Role narrows an account's permissions but is not an identity, so a pipeline cannot hold a grant of its own, cannot be revoked without touching its owner, and cannot be distinguished in an audit record. + +**Per-service-account Role definitions** — rejected. Roles are account-owned and reusable (ADR-010); minting one Role per service account would multiply near-identical definitions and move grant management out of the membership pages that already do it. diff --git a/adrs/016-delete-action.md b/adrs/016-delete-action.md new file mode 100644 index 00000000..8f479963 --- /dev/null +++ b/adrs/016-delete-action.md @@ -0,0 +1,74 @@ +# ADR-016: `delete` as an Action Distinct from `write` + +**Status:** Proposed — not implemented +**Date:** 2026-08-24 +**RFC:** RFC-001 §8 +**Depends on:** ADR-005, ADR-010, ADR-011 + +--- + +## Context + +The platform has no way to say "may upload, may not delete". + +The proxy classifies actions with a denylist: anything that is not `GetObject`, `HeadObject` or `ListBucket` is a write. Every write is then gated on a single string, `write`, returned by the Source Cooperative API. The API's permission vocabulary is two values, read and write. So `DeleteObject` and `PutObject` are indistinguishable at every layer, for people as well as for machines. + +That is a reasonable simplification for human collaborators and a poor one for unattended software, where "append-only publisher" is the common and sensible shape. ADR-010 anticipates this — its permission statements carry `actions`, with a note that finer actions can be added later — and ADR-011 lists agreeing on what read and write mean as a cost. + +**The extension point exists. The decision does not.** + +--- + +## Decision + +### A Third Permission Value + +`delete` joins `read` and `write` in the API's permission vocabulary, is derived alongside them by the product permissions endpoint, and is checked by the proxy's write gate for destructive actions only. + +`DeleteObject` and `AbortMultipartUpload` require `delete`. Every other non-read action continues to require `write`. + +### Both Layers, Deliberately + +The action set also appears in a Role's permission statements (ADR-010), which are sealed into the credential at mint time (ADR-011). Sealing makes the ceiling checkable with no network call, but it also means a sealed ceiling goes stale for up to a session. + +So `delete` lives in both places, and they do different jobs: + +| Layer | Job | Revocation lag | +|---|---|---| +| Role permission statements | The ceiling. Survives a bug in the live path. | Up to one session | +| API permission lookup | The live grant. | Roughly 60 seconds | + +Putting `delete` only in the sealed ceiling would mean "we revoked delete" takes an hour. Putting it only in the live lookup would leave the ceiling unable to express least privilege, which is the point of ADR-010. + +### The Classifier Keeps Failing Safe + +The denylist stays a denylist: an action that is not explicitly a read is still treated as a write, and an action that is not explicitly destructive is not treated as a delete. A new action added upstream is gated as a write until it is classified, never the reverse. + +> [!NOTE] +> **`GetObjectVersion` is currently misclassified.** multistore 0.7.2 added it, and the proxy's classifier does not list it as a read, so version reads are gated as writes today. Correct this with the same change — it is the denylist failing safe, but it is still wrong. + +--- + +## Consequences + +**Benefits** + +- "Upload but never delete" becomes expressible, for service accounts and people alike. +- Least privilege for publishing pipelines, which is the shape most of them want. +- Revoking delete takes effect as fast as revoking write. + +**Costs / Risks** + +- A third value in a vocabulary two codebases and the public API already agree on. Existing grants must map to something, and the safe mapping — existing `write` implies `delete` — preserves behaviour but grants nothing new that anyone asked for. +- One more thing for a Role author to get wrong. +- The UI question is unsettled: is `delete` a grant a product owner ticks, or only something a Role can subtract? A grant type is more expressive and more work. + +--- + +## Alternatives Considered + +**Leave delete inside write** — rejected. It is the status quo, and it makes the most common unattended shape inexpressible. + +**Express delete only in Role permission statements** — rejected. Sealed ceilings mean revocation waits out the session. Delete is the action where a slow revocation matters most. + +**A full S3 action vocabulary** — deferred. Ten actions exist upstream, but only the destructive split has a demand behind it. Adding more later is backwards-compatible; carrying nine unused permission values is not free. diff --git a/adrs/rfc-001.md b/adrs/rfc-001.md index d26fb4e0..846d107d 100644 --- a/adrs/rfc-001.md +++ b/adrs/rfc-001.md @@ -550,6 +550,9 @@ The following areas are acknowledged as future work. Each is designed to be addi - **Role-ceiling authorization** — enforcing a Role's ceiling against live account permissions at request time (ADR-011) - **Outbound federation coverage** — per-connection audiences, GCP/Azure federation, stored-credential fallback (ADR-012) - **Permanent API keys** — long-lived credentials exchangeable for temporary STS credentials, for environments without OIDC support (ADR-013) +- **Federated subject resolution** — finding an account by `(issuer, subject)` rather than a bare subject, so organisations, machines and multiple issuers can all be told apart (ADR-014) +- **Service accounts** — an identity for unattended software, with its own grants and its own revocation (ADR-015) +- **A `delete` action** — separating destructive writes from ordinary ones (ADR-016) **Not yet specified:** @@ -587,9 +590,12 @@ ADRs are grouped by status: those describing decisions already implemented and r | [ADR-010](010-account-owned-roles.md) | Account-owned Roles | | [ADR-011](011-role-ceiling-authorization.md) | Role-ceiling authorization | | [ADR-012](012-outbound-federation-coverage.md) | Outbound federation coverage — per-connection audience, GCP/Azure, stored credentials | -| [ADR-013](013-api-keys.md) | API keys for environments without OIDC | +| [ADR-013](013-api-keys.md) | API keys — long-lived credentials for service accounts | +| [ADR-014](014-federated-subject-resolution.md) | Federated subject resolution — `(issuer, subject)` as the account key | +| [ADR-015](015-service-accounts.md) | Service accounts — a principal for unattended software | +| [ADR-016](016-delete-action.md) | `delete` as an action distinct from `write` | -The proposed set has a dependency order: ADR-009 unblocks ADR-010, which unblocks ADR-011. ADR-009 should not ship ahead of ADR-010 — see the warning in ADR-009. +The proposed set has a dependency order: ADR-014 unblocks ADR-009 and ADR-010; ADR-009 unblocks ADR-010, which unblocks ADR-011; ADR-010 and ADR-014 together unblock ADR-015, which unblocks ADR-013. ADR-009 should not ship ahead of ADR-010 or ADR-014 — see the warnings in ADR-009. ADR-014 is the item everything else waits on. --- From 14d0ea9b169c25a734908e29c83cb7e0511f7605 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 25 Aug 2026 21:31:57 -0700 Subject: [PATCH 2/8] docs(adrs): drop the GetCallerIdentity note from ADR-004 Out of scope for ADR-004, which specifies the exchange rather than the compatibility of any one client. The gap is already tracked in #184 and developmentseed/multistore#126. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt --- adrs/004-sts.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/adrs/004-sts.md b/adrs/004-sts.md index 946ed227..4c689a27 100644 --- a/adrs/004-sts.md +++ b/adrs/004-sts.md @@ -57,7 +57,7 @@ Action=AssumeRoleWithWebIdentity ``` -Both transports are supported because AWS SDKs send query-protocol operations as a form-encoded POST with no query string at all. The Worker absorbs that form body before routing, or SDK clients would fall through to the S3 pipeline unhandled. This makes `/.sts` a drop-in target for `boto3.client('sts', endpoint_url='https://data.source.coop/.sts')`, and for the SDKs' built-in web-identity credential providers (`AWS_WEB_IDENTITY_TOKEN_FILE` / `AWS_ROLE_ARN`). +Both transports are supported because AWS SDKs send query-protocol operations as a form-encoded POST with no query string at all. The Worker absorbs that form body before routing, or SDK clients would fall through to the S3 pipeline unhandled. This makes `/.sts` a drop-in target for `boto3.client('sts', endpoint_url='https://data.source.coop/.sts')`, for the SDKs' built-in web-identity credential providers (`AWS_WEB_IDENTITY_TOKEN_FILE` / `AWS_ROLE_ARN`), and for `aws-actions/configure-aws-credentials`. **`AssumeRoleWithWebIdentity` is an action parameter, not a path segment.** The route is plain `/.sts`; the action travels in the query string or the form body. Parameters are read from exactly one source — the query string is tried first, then the body — and are **never merged across the two**, matching AWS. Collecting a form-encoded `POST` body never steals a payload the S3 forwarding path needs, because form-encoded `POST` is not part of the S3 protocol. This behaviour is implemented upstream in [multistore#112](https://github.com/developmentseed/multistore/pull/112). @@ -150,7 +150,6 @@ The JWKS cache is isolate-shared (a process-wide `OnceLock`, with `Arc> - **Only one Role exists, with an unlimited ceiling.** There is no way to issue a credential narrower than the account's full permissions. See ADR-010. - An unavailable JWKS causes all exchanges for that issuer to fail for 30 seconds — failures use their own short negative-cache TTL, not the 15-minute success TTL — after which one retry is attempted. There is no serve-stale path: entries are used only while fresh, so a *stale* JWKS never blocks anything. - The web flow's dependence on the Ory admin API means a compromised call site is a "become any user" primitive; it is guarded by construction rather than by input validation. -- **`aws-actions/configure-aws-credentials` does not work.** It calls `GetCallerIdentity` after the exchange, which `/.sts` does not implement. The web-identity credential providers built into the SDKs do work; the most popular GitHub Action for this does not. Tracked separately. --- From 70129cf71a2b9323c782141bf44c23181f812d42 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 26 Aug 2026 10:16:15 -0700 Subject: [PATCH 3/8] docs(adrs): correct the branch-site count and the session ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two numbers carried over from source.coop#491 were wrong. The account-type branch sites are ~48 across 20 files, not 47 across 19, and the binary isIndividualAccount / isOrganizationalAccount helpers are slightly under half of them rather than the majority. A sealed ceiling goes stale for up to a session, and production configures STS_MAX_SESSION_DURATION_SECS at 43200 — 12 hours, not the one hour the multistore default suggests. That is the number that matters for how long a revoked delete keeps working. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt --- adrs/015-service-accounts.md | 2 +- adrs/016-delete-action.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/adrs/015-service-accounts.md b/adrs/015-service-accounts.md index 58c740a2..c7ef776b 100644 --- a/adrs/015-service-accounts.md +++ b/adrs/015-service-accounts.md @@ -82,7 +82,7 @@ Which Roles a service account may assume is recorded on the Role, in its `identi **Costs / Risks** -- Account type is branched on in roughly 47 places across 19 files in `source.coop`. Most keep compiling and quietly treat a machine as a person. The count is dominated not by explicit type checks but by binary "individual or organisation" helpers, which would render a machine as an organisation profile. +- Account type is branched on in roughly 48 places across 20 files in `source.coop`. Most keep compiling and quietly treat a machine as a person. Slightly under half are the binary `isIndividualAccount` / `isOrganizationalAccount` helpers, which would render a machine as an organisation profile rather than failing. - The membership pages are the deliberate exception and must show service accounts, since that is where an owner revokes a grant. Membership listing currently returns false for any third account type. - An account id is also the first segment of a public URL, so every service account consumes a name and gets a profile route. - The owner cap adds an owner lookup to the request path, which is already sensitive to latency. diff --git a/adrs/016-delete-action.md b/adrs/016-delete-action.md index 8f479963..584f7349 100644 --- a/adrs/016-delete-action.md +++ b/adrs/016-delete-action.md @@ -35,10 +35,10 @@ So `delete` lives in both places, and they do different jobs: | Layer | Job | Revocation lag | |---|---|---| -| Role permission statements | The ceiling. Survives a bug in the live path. | Up to one session | +| Role permission statements | The ceiling. Survives a bug in the live path. | Up to one session, currently capped at 12h | | API permission lookup | The live grant. | Roughly 60 seconds | -Putting `delete` only in the sealed ceiling would mean "we revoked delete" takes an hour. Putting it only in the live lookup would leave the ceiling unable to express least privilege, which is the point of ADR-010. +Putting `delete` only in the sealed ceiling would mean "we revoked delete" waits out the session — up to 12 hours on the ceiling currently configured for `/.sts`. Putting it only in the live lookup would leave the ceiling unable to express least privilege, which is the point of ADR-010. ### The Classifier Keeps Failing Safe From e99e1eb6824866426c3ab240c2712b19c97c285d Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Sun, 6 Sep 2026 21:31:25 +0900 Subject: [PATCH 4/8] docs(adr-010): name the built-in Roles FullAccess and ReadOnly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_default` and `_read_only` read as internal identifiers in a user-facing UI. Name the built-ins `FullAccess` and `ReadOnly` instead. The leading underscore was doing real work — it kept built-in names out of the account-authorable namespace. Uppercase does the same job, because account-authored Role names are validated lowercase-only, so `FullAccess` cannot be created through the API either. `_default` stays accepted as an alias. It is not just a name in this document: it is in deployed client configuration as `AWS_ROLE_ARN=arn:aws:iam::000000000000:role/_default` and is matched by `is_default_role`, so renaming it would break every existing caller. Also merges the old "The `_default` Role, Restated" section into the new built-ins section, which had become a duplicate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt --- adrs/010-account-owned-roles.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/adrs/010-account-owned-roles.md b/adrs/010-account-owned-roles.md index 7393f366..5598e28e 100644 --- a/adrs/010-account-owned-roles.md +++ b/adrs/010-account-owned-roles.md @@ -87,24 +87,23 @@ Rules: - Actions are `read` (GetObject, HeadObject, ListObjects) and `write` (PutObject, DeleteObject, multipart). Finer actions can be added later as new values without breaking existing definitions. - Statements are additive (allow-only). No explicit denies. -### The `_default` Role, Restated +### Built-in Roles: `FullAccess` and `ReadOnly` -Every account keeps a built-in `sc::{account_id}::role/_default`: +Every account keeps two built-in Roles, synthesised rather than stored. Neither can be deleted, and owners may add claim constraints to a binding but cannot change the IdP binding itself. -- Cannot be deleted -- Constrained to the `auth.source.coop` platform IdP only -- Permissions `{"actions": ["read", "write"], "resources": ["*"]}` — unlimited ceiling -- Owners may add claim constraints to its binding, but cannot change the IdP binding itself - -This is the Role that ships today (ADR-004); this ADR generalises around it without changing its behaviour, so existing clients keep working unchanged. +| Role | Permissions | Notes | +|---|---|---| +| `sc::{account_id}::role/FullAccess` | `["read", "write"]` on `*` | The unlimited ceiling. `_default` is accepted as a deprecated alias. | +| `sc::{account_id}::role/ReadOnly` | `["read"]` on `*` | Same, with writing removed. | -### The `_read_only` Role +`ReadOnly` exists because the common case for a narrower credential needs no authoring at all: a job that only reads should be able to ask for a credential that cannot write, whether or not anyone has written a Role for it. Since a Role is a ceiling (ADR-011), naming it never grants anything the caller lacked — so no account opts in, and any caller may name it. -Every account also keeps a built-in `sc::{account_id}::role/_read_only`, identical to `_default` except that its permissions carry `["read"]`. +Two built-ins are the whole set. Anything narrower is an account-authored Role. -It exists because the common case for a narrower credential needs no authoring at all: a job that only reads should ask for a credential that cannot write, whether or not anyone has written a Role for it. Since a Role is a ceiling (ADR-011), asking for `_read_only` never grants anything the caller lacked — so no account needs to opt in, and any caller may name it. +> [!IMPORTANT] +> **`_default` must keep working.** It is not merely a name in this document — it is in deployed client configuration as `AWS_ROLE_ARN=arn:aws:iam::000000000000:role/_default`, accepted today by `is_default_role` in `src/sts.rs` (ADR-004). Renaming it breaks every existing caller. Accept it as an alias for `FullAccess` and deprecate it in documentation only. -Two built-ins are the whole set. Anything narrower is an account-authored Role. +**Why these names are safe from collision.** Account-authored Role names are validated against a lowercase pattern (below), so a name containing an uppercase letter cannot be created through the API. `FullAccess` and `ReadOnly` are therefore unreachable by an account-authored Role, the same reservation the leading underscore gave `_default` — without the leading underscore reading as an internal detail in a user-facing UI. ### Validation at Creation From cbd70feadad04a7ff0c0c0f9fb9da0c5e0c744d9 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Sun, 6 Sep 2026 21:35:15 +0900 Subject: [PATCH 5/8] docs(adrs): defer account-owned Roles; ship two hardcoded built-ins Service accounts ship with FullAccess and ReadOnly hardcoded in the proxy. ADR-010 keeps the account-owned Role design on record but carries a scope note saying that Role CRUD, per-Role trust policies and user-authored permission statements are deferred. This removes the Source API lookup from the credential-minting path, and it resolves where a per-service-account list of permitted Roles lives: nowhere. A Role can only subtract, so any caller may name either one safely. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt --- adrs/010-account-owned-roles.md | 5 +++++ adrs/015-service-accounts.md | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/adrs/010-account-owned-roles.md b/adrs/010-account-owned-roles.md index 5598e28e..dbbddca5 100644 --- a/adrs/010-account-owned-roles.md +++ b/adrs/010-account-owned-roles.md @@ -8,6 +8,11 @@ --- +> [!IMPORTANT] +> **Scope note (2026-08-26).** Service accounts (ADR-015) ship with the two **built-in Roles only** — `FullAccess` and `ReadOnly`, hardcoded in the proxy. The account-owned portion of this ADR — Role CRUD, per-Role `identity_constraints`, user-authored permission statements, and the management API — is **deferred** to keep the first implementation small. +> +> This ADR stays the record of where Roles are going. What ships first is the ceiling mechanism (ADR-011) applied to two fixed Roles. Two consequences follow while that is true: nothing restricts which Role a caller may name, which is safe because a Role can only subtract; and the fail-open defaults described in ADR-009 stay unreachable, because no Role is user-authored yet. + ## Context ADR-004 ships a single built-in Role, `_default`, with an unlimited ceiling and no subject conditions. Every credential the platform issues therefore carries the caller's full permissions. There is no way to obtain a narrower credential, and no way to say "this specific workload may write to this specific product". diff --git a/adrs/015-service-accounts.md b/adrs/015-service-accounts.md index c7ef776b..3124715f 100644 --- a/adrs/015-service-accounts.md +++ b/adrs/015-service-accounts.md @@ -65,9 +65,11 @@ Two guardrails the grant model cannot express stay explicit: ### Roles Are Unchanged -A service account assumes a Role by URN exactly as any other caller does (ADR-010), and the credential is the Role's ceiling intersected with the account's live grants (ADR-011). Nothing here adds a second authorisation model. +A service account names a Role when it asks for credentials, exactly as any other caller does, and the credential is the Role's ceiling intersected with the account's live grants (ADR-011). Nothing here adds a second authorisation model. -Which Roles a service account may assume is recorded on the Role, in its `identity_constraints`, alongside the binding that identifies the account. A management UI may present this as a per-service-account list, but there is one store, not two. +Two Roles ship, both hardcoded in the proxy: **`FullAccess`** and **`ReadOnly`**. The account-owned Roles of ADR-010 are deferred (see the scope note there), so there is no Role CRUD, no per-Role trust policy, and no per-service-account list of permitted Roles. + +Nothing restricts which of the two a caller may name, and nothing needs to: a Role can only subtract, so naming `ReadOnly` never grants anything the account lacked, and naming `FullAccess` grants no more than its memberships already allow. A workload that only reads should name `ReadOnly` anyway — if it is ever compromised, it still cannot write. --- From a1ed849aecb4b66c6772bbe5937b306cebf57f78 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Sun, 6 Sep 2026 21:38:41 +0900 Subject: [PATCH 6/8] docs(adr-010): correct the name-collision claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wording said account-authored Role names "are validated against a lowercase pattern", present tense. Nothing validates them: Role creation is the deferred half of this ADR and was never implemented, so that code path does not exist. What is true today is simpler — the proxy knows three names and rejects everything else. The lowercase rule is a property to preserve if account-owned Roles are ever built, not a mechanism protecting the built-ins now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt --- adrs/010-account-owned-roles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adrs/010-account-owned-roles.md b/adrs/010-account-owned-roles.md index dbbddca5..2549836b 100644 --- a/adrs/010-account-owned-roles.md +++ b/adrs/010-account-owned-roles.md @@ -108,7 +108,7 @@ Two built-ins are the whole set. Anything narrower is an account-authored Role. > [!IMPORTANT] > **`_default` must keep working.** It is not merely a name in this document — it is in deployed client configuration as `AWS_ROLE_ARN=arn:aws:iam::000000000000:role/_default`, accepted today by `is_default_role` in `src/sts.rs` (ADR-004). Renaming it breaks every existing caller. Accept it as an alias for `FullAccess` and deprecate it in documentation only. -**Why these names are safe from collision.** Account-authored Role names are validated against a lowercase pattern (below), so a name containing an uppercase letter cannot be created through the API. `FullAccess` and `ReadOnly` are therefore unreachable by an account-authored Role, the same reservation the leading underscore gave `_default` — without the leading underscore reading as an internal detail in a user-facing UI. +**Collision.** While Roles are hardcoded, there is nothing to collide with: the proxy knows three names — `FullAccess`, `ReadOnly`, and the `_default` alias — and rejects everything else. If account-owned Roles are ever built, the name validation specified below is lowercase-only, so a name containing an uppercase letter could not be created through the API and these two would stay reserved. That is a property to preserve when that work happens, not one that holds today. ### Validation at Creation From 7421fd3fbf1d7b1c9e5d05d77f1e46331aff2b92 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Sun, 6 Sep 2026 21:53:24 +0900 Subject: [PATCH 7/8] docs(adr-013): move key expiry into the jti record, allow no expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expiry lived in the token's `exp` claim and was mandatory, justified partly on the grounds that a JWKS publishes only the current and previous key, so an `exp`-less token would stop verifying at an unpredictable moment. That constraint was an artefact of the rotation policy, not of JWTs. Rotation here adds a key rather than replacing one: new keys are signed with the newest private key, and every key ever used stays published for verification under its own `kid`. Removing a key becomes the emergency lever for a suspected signing-key compromise instead of routine maintenance. With that settled, expiry moves to the `jti` record the exchange already reads for revocation. Keys may have no expiry, expiry can be changed after issuance, and expired and revoked collapse into one check returning one client-visible outcome — which the oracle argument wants anyway. Keys still default to a bounded expiry, now for the honest reason: an indefinite credential outlives the person who created it and nothing ever forces a review. "Never expires" is an explicit choice. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt --- adrs/013-api-keys.md | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/adrs/013-api-keys.md b/adrs/013-api-keys.md index 76dc70f3..cc48b3d9 100644 --- a/adrs/013-api-keys.md +++ b/adrs/013-api-keys.md @@ -48,25 +48,47 @@ An API key JWT contains: - `iss` is the proxy's own issuer URL, not `auth.source.coop` (which is Ory Network and outside Source Cooperative's control for token minting) - `sub` identifies the principal the key belongs to — a service account (ADR-015), resolved through an identity binding (ADR-014) like any other subject. A key is issued to a service account, never to a person. - `jti` is a unique key identifier used for revocation checks -- `exp` is **mandatory**, with a platform ceiling. See below. +- `exp` is optional and advisory. Validity is decided server-side against the `jti` record, not by the token's own claim. See below. - `type` distinguishes API key JWTs from other tokens the proxy may issue (e.g. outbound federation tokens) -### Keys Always Expire +### Expiry Lives in the `jti` Record -An earlier draft of this ADR made `exp` optional, so a key would be "valid until explicitly revoked". Two things rule that out. +Every exchange already looks the key's `jti` up to check revocation. Expiry lives in that same record rather than in the token's `exp` claim. -The practical one: an indefinite key outlives the person who created it. This ADR's own Costs section names the failure — a researcher leaves a university and their key keeps working until an administrator happens to revoke it. +| | Where | Effect | +|---|---|---| +| Revoked | `jti` record | Denied | +| Expired | `jti` record, `expires_at` | Denied | +| No expiry | `jti` record, `expires_at` null | Valid until revoked | -The mechanical one: it cannot be delivered on the key material described below. A JWKS publishes the current key and one previous key, so a signature stops verifying two rotations after it was made, whatever the token says. A key with no `exp` would not be valid indefinitely; it would fail at an unpredictable point and look like an outage. +This buys three things a signed `exp` cannot: -Keys carry an expiry chosen at creation, bounded by a platform maximum. Renewal is issue-new, deploy, revoke-old: rotation has no overlap window and cannot extend an existing key's expiry. +- **Keys may have no expiry**, when a user explicitly chooses it. +- **Expiry can be changed after issuance** — extended for a workload that needs longer, or shortened in response to an incident. +- **Expired and revoked are one check**, returning one client-visible outcome. Distinguishing them would make the endpoint a key-validity oracle. -### Signing Key +The cost is that the lookup can never be short-circuited. That is not a new cost: revocation already requires it. + +**Default to a bounded expiry.** Keys are issued with an expiry by default; "never expires" is an explicit choice, surfaced with a warning. The reason is not mechanical — it is that an indefinite credential outlives the person who created it and nothing ever forces a review. This ADR's own Costs section names the failure: a researcher leaves a university and their key keeps working until an administrator happens to notice. + +Renewal is issue-new, deploy, revoke-old. Rotation has no overlap window. + +> [!NOTE] +> **An earlier draft made `exp` mandatory** on the grounds that a JWKS publishes only the current and previous key, so an `exp`-less token would stop verifying at an unpredictable moment. That constraint is removed by the retention policy below: verification keys are kept indefinitely, so a signature stays verifiable for as long as its `jti` record says it is valid. + +### Signing Key and Rotation API keys are signed with a dedicated key, published under its own `kid`, **not** the outbound federation key from ADR-006. That key is a public contract: its issuer URL and thumbprint are registered in third-party cloud IAM configurations (ADR-012), so rotating it is a coordinated migration with external parties. Long-lived credentials must not depend on a key whose rotation schedule is set by someone else's cloud account, and a compromise of one should not force the other. +**Rotation adds a key; it does not replace one.** New keys are signed with the newest private key. Every key ever used for signing stays published for *verification*, each under its own `kid`. The keyset therefore grows by one entry per rotation, which is negligible at any sane cadence, and a key issued years ago still verifies. + +Removing a key from the set becomes the emergency lever rather than routine maintenance: it invalidates every key signed with it at once. That is the correct behaviour on a suspected signing-key compromise, and it is the only operation that invalidates keys without touching their `jti` records. + +> [!IMPORTANT] +> **This retention policy is what makes non-expiring keys deliverable.** If verification keys are ever pruned on the usual current-plus-previous schedule, every key older than two rotations breaks — silently, and regardless of what its `jti` record says. Whoever operates rotation needs to know that. + ### Key Lifecycle **Creation:** @@ -79,7 +101,7 @@ source keys create --service-account svc--ncar-cronjob --expires-in 90d The system: 1. Generates a unique `jti` -2. Stores key metadata in the policy store: `jti`, service account id, label, created-at, expires-at +2. Stores key metadata in the policy store: `jti`, service account id, label, created-at, `expires_at` (nullable), revoked flag 3. Mints and signs the JWT 4. Returns the raw JWT to the user — displayed once, never stored by the platform @@ -187,8 +209,8 @@ This means revocation takes effect within roughly a minute. For the target use c - API key JWTs are bearer tokens — anyone with the raw JWT can use it. Users must treat them like passwords (store in environment variables or secret files, not in source control) - The `jti` revocation check adds a policy store dependency to the STS exchange path for API key tokens. Cache misses add latency, and the check must fail closed: an unavailable policy store denies rather than skipping the check. -- Keys expire, so an unattended workload stops working on a known date. That is the intended trade against indefinite keys, but it fails silently — there is no notification channel, so expiry must be documented and surfaced in the UI. -- A second signing key to manage, publish and rotate. +- A key issued with an expiry stops an unattended workload on a known date, and it fails silently — there is no notification channel, so expiry must be surfaced in the UI and in the docs. A key issued without one never prompts a review, which is the opposite failure. Neither is solved here; the default is chosen to make the second one deliberate. +- A second signing key to manage, publish and rotate, whose old entries are retained rather than pruned. The operational rule — rotation adds, never removes — has to survive staff turnover, because breaking it silently invalidates every older key. - Revocation takes effect within the cache TTL, and credentials already issued live out their session regardless. --- From 42efa32c93c8987a2ee856e57b753fff3bd71033 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Sun, 6 Sep 2026 22:02:55 +0900 Subject: [PATCH 8/8] docs(adr-016): deletion is part of write, narrowing left to Roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-016 proposed a third permission value so a grant could say "may upload, never delete". Narrowed to record the opposite decision. A writer can already destroy data without DeleteObject, because PutObject overwrites. Splitting the action reads as stronger protection than it is, and it spends a public API change plus a migration on a guarantee that only holds against accidents. Roles carry a per-action list and can only subtract, so "may upload, never delete" is expressible there when account-owned Roles ship — with no change to the permission vocabulary and nothing new for a product owner to understand. Keeps two things from the original: AbortMultipartUpload must stay with write, or a writer cannot clean up its own failed uploads; and GetObjectVersion is currently misclassified as a write and should be fixed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HGZ598AJ1F7NWoyPpgepBt --- adrs/015-service-accounts.md | 2 +- adrs/016-delete-action.md | 57 ++++++++++++++++++------------------ adrs/rfc-001.md | 4 +-- 3 files changed, 31 insertions(+), 32 deletions(-) diff --git a/adrs/015-service-accounts.md b/adrs/015-service-accounts.md index 3124715f..1059d6cd 100644 --- a/adrs/015-service-accounts.md +++ b/adrs/015-service-accounts.md @@ -51,7 +51,7 @@ Deleting an account that owns service accounts is blocked. A missing owner denie ### Scope Is a Restriction on Grant Types -A service account may hold only data grants — read or write on a product, or (per ADR-016) delete. It cannot hold an owner or maintainer role. +A service account may hold only data grants — read or write on a product. It cannot hold an owner or maintainer role. Write includes deletion (ADR-016). This is the whole scope statement, and it needs no separate list of prohibitions. Every other action in `source.coop` — creating products, creating organisations, managing members, owning a record — already requires an owner or maintainer role, so restricting the grant types makes all of them unreachable by construction. diff --git a/adrs/016-delete-action.md b/adrs/016-delete-action.md index 584f7349..00d34196 100644 --- a/adrs/016-delete-action.md +++ b/adrs/016-delete-action.md @@ -1,4 +1,4 @@ -# ADR-016: `delete` as an Action Distinct from `write` +# ADR-016: Deletion Is Part of Write **Status:** Proposed — not implemented **Date:** 2026-08-24 @@ -11,41 +11,40 @@ The platform has no way to say "may upload, may not delete". -The proxy classifies actions with a denylist: anything that is not `GetObject`, `HeadObject` or `ListBucket` is a write. Every write is then gated on a single string, `write`, returned by the Source Cooperative API. The API's permission vocabulary is two values, read and write. So `DeleteObject` and `PutObject` are indistinguishable at every layer, for people as well as for machines. +The proxy classifies actions with a denylist: anything that is not `GetObject`, `GetObjectVersion`, `HeadObject` or `ListBucket` is a write. Every write is then gated on a single string, `write`, returned by the Source Cooperative API, whose permission vocabulary is two values. So `DeleteObject` and `PutObject` are indistinguishable at every layer, for people as well as for machines. -That is a reasonable simplification for human collaborators and a poor one for unattended software, where "append-only publisher" is the common and sensible shape. ADR-010 anticipates this — its permission statements carry `actions`, with a note that finer actions can be added later — and ADR-011 lists agreeing on what read and write mean as a cost. - -**The extension point exists. The decision does not.** +"Append-only publisher" is a reasonable thing to want, particularly for unattended software (ADR-015). The question is whether it needs a third permission value. --- ## Decision -### A Third Permission Value +### `write` includes deletion + +A grant of `write` on a product permits deletion. There is no third permission value in the API, no new grant type in the UI, and no migration of existing grants. + +Two reasons. -`delete` joins `read` and `write` in the API's permission vocabulary, is derived alongside them by the product permissions endpoint, and is checked by the proxy's write gate for destructive actions only. +**A writer can already destroy data without `DeleteObject`.** `PutObject` on an existing key overwrites it. Withholding deletion does not protect the data from a hostile or badly broken writer; it makes them take one extra step. Durability against that comes from versioning or object lock, not from splitting an action. -`DeleteObject` and `AbortMultipartUpload` require `delete`. Every other non-read action continues to require `write`. +**The vocabulary is public.** `RepositoryPermissions` is part of the Source Cooperative API. Adding a third value obliges every consumer to understand it, and obliges us to decide what existing `write` grants map to — where the only safe answer, `write` implies `delete`, grants nothing anyone asked for. -### Both Layers, Deliberately +### Roles may subset it -The action set also appears in a Role's permission statements (ADR-010), which are sealed into the credential at mint time (ADR-011). Sealing makes the ceiling checkable with no network call, but it also means a sealed ceiling goes stale for up to a session. +Where "may upload, never delete" is genuinely wanted, it is expressed as a Role rather than as a grant. -So `delete` lives in both places, and they do different jobs: +A Role carries a per-action list and can only subtract (ADR-011), so a Role whose actions omit `DeleteObject` yields credentials that cannot delete, no matter what the account's memberships allow. That gets the capability with no change to the permission vocabulary, no migration, and nothing new for a product owner to understand. -| Layer | Job | Revocation lag | -|---|---|---| -| Role permission statements | The ceiling. Survives a bug in the live path. | Up to one session, currently capped at 12h | -| API permission lookup | The live grant. | Roughly 60 seconds | +This is deferred work, not work this ADR schedules: the Roles that ship are the two built-ins (ADR-010, scope note), and neither omits deletion. It becomes available when account-owned Roles do. -Putting `delete` only in the sealed ceiling would mean "we revoked delete" waits out the session — up to 12 hours on the ceiling currently configured for `/.sts`. Putting it only in the live lookup would leave the ceiling unable to express least privilege, which is the point of ADR-010. +> [!IMPORTANT] +> **`AbortMultipartUpload` must stay with `write`.** It removes an incomplete upload, which is cleanup any writer has to be able to perform. A workload that cannot abort its own failed multipart uploads leaves orphaned parts accruing storage cost. If a Role ever subtracts destructive actions, this must not be in that set. -### The Classifier Keeps Failing Safe +### Fix the classifier -The denylist stays a denylist: an action that is not explicitly a read is still treated as a write, and an action that is not explicitly destructive is not treated as a delete. A new action added upstream is gated as a write until it is classified, never the reverse. +`GetObjectVersion` was added in multistore 0.7.2 and is not listed as a read, so version reads are currently gated as writes. The denylist is failing safe, but it is still wrong. Correct it. -> [!NOTE] -> **`GetObjectVersion` is currently misclassified.** multistore 0.7.2 added it, and the proxy's classifier does not list it as a read, so version reads are gated as writes today. Correct this with the same change — it is the denylist failing safe, but it is still wrong. +The denylist stays a denylist: an action that is not explicitly a read is treated as a write, so a new action added upstream is over-restricted until classified, never under-restricted. --- @@ -53,22 +52,22 @@ The denylist stays a denylist: an action that is not explicitly a read is still **Benefits** -- "Upload but never delete" becomes expressible, for service accounts and people alike. -- Least privilege for publishing pipelines, which is the shape most of them want. -- Revoking delete takes effect as fast as revoking write. +- No change to a public API vocabulary, and no migration of existing grants. +- Product owners keep a two-value model — read, or read and write — which is the thing they actually reason about. +- Least privilege for deletion stays available, through the mechanism built for narrowing. **Costs / Risks** -- A third value in a vocabulary two codebases and the public API already agree on. Existing grants must map to something, and the safe mapping — existing `write` implies `delete` — preserves behaviour but grants nothing new that anyone asked for. -- One more thing for a Role author to get wrong. -- The UI question is unsettled: is `delete` a grant a product owner ticks, or only something a Role can subtract? A grant type is more expressive and more work. +- `aws s3 sync --delete` remains the realistic accident: a pipeline meant only to add files removes half a product. Nothing in this decision prevents it until account-owned Roles ship. +- "May write but not delete" is unavailable in the meantime, so anyone asking for it today gets no answer. +- The distinction lives in two vocabularies that must agree — the API's `read`/`write`, and a Role's per-action list. ADR-011 already names divergence between them as a silent correctness risk. --- ## Alternatives Considered -**Leave delete inside write** — rejected. It is the status quo, and it makes the most common unattended shape inexpressible. +**A third permission value, `delete`** — rejected. It reads as stronger protection than it is, since `PutObject` can already destroy an object by overwriting it. It also spends a public API change and a migration on a guarantee that only holds against accidents, and Roles cover the accident case without either. -**Express delete only in Role permission statements** — rejected. Sealed ceilings mean revocation waits out the session. Delete is the action where a slow revocation matters most. +**Express deletion only in Role permission statements, and say nothing in the grant model** — accepted, and this is what the decision above amounts to. Recorded separately because the choice is easy to misread as an oversight. -**A full S3 action vocabulary** — deferred. Ten actions exist upstream, but only the destructive split has a demand behind it. Adding more later is backwards-compatible; carrying nine unused permission values is not free. +**Versioning or object lock on the backing store** — deferred, and the right answer for durability. It protects against overwrite as well as deletion, which no permission split does. Out of scope here because it is a storage-configuration decision per data connection, not an authorization one. diff --git a/adrs/rfc-001.md b/adrs/rfc-001.md index 846d107d..cfd64070 100644 --- a/adrs/rfc-001.md +++ b/adrs/rfc-001.md @@ -552,7 +552,7 @@ The following areas are acknowledged as future work. Each is designed to be addi - **Permanent API keys** — long-lived credentials exchangeable for temporary STS credentials, for environments without OIDC support (ADR-013) - **Federated subject resolution** — finding an account by `(issuer, subject)` rather than a bare subject, so organisations, machines and multiple issuers can all be told apart (ADR-014) - **Service accounts** — an identity for unattended software, with its own grants and its own revocation (ADR-015) -- **A `delete` action** — separating destructive writes from ordinary ones (ADR-016) +- **Deletion is part of write** — recorded rather than split, with narrowing left to Roles (ADR-016) **Not yet specified:** @@ -593,7 +593,7 @@ ADRs are grouped by status: those describing decisions already implemented and r | [ADR-013](013-api-keys.md) | API keys — long-lived credentials for service accounts | | [ADR-014](014-federated-subject-resolution.md) | Federated subject resolution — `(issuer, subject)` as the account key | | [ADR-015](015-service-accounts.md) | Service accounts — a principal for unattended software | -| [ADR-016](016-delete-action.md) | `delete` as an action distinct from `write` | +| [ADR-016](016-delete-action.md) | Deletion is part of write | The proposed set has a dependency order: ADR-014 unblocks ADR-009 and ADR-010; ADR-009 unblocks ADR-010, which unblocks ADR-011; ADR-010 and ADR-014 together unblock ADR-015, which unblocks ADR-013. ADR-009 should not ship ahead of ADR-010 or ADR-014 — see the warnings in ADR-009. ADR-014 is the item everything else waits on.