Repository navigation
Description
The problem
Source Cooperative can't authenticate software acting on a user's behalf. Getting credentials requires a human at a browser. So anything unattended — a nightly sync, a publishing pipeline, an instrument uploading observations — has two bad options: babysit a login, or embed a person's session somewhere it doesn't belong.
What a user does
- From their user or organization settings, create a Service Account.
- Configure one or more Integrations — the ways software may authenticate as the Service Account.
- Grant it membership on the products it needs, as a reader or a writer.
- Point the software at it. The software names a Role when it asks for credentials to further limit the session's permissions.
Prerequisites
- Epic: GitHub Actions OIDC write access to products #432 is closed in favour of this epic. It covered GitHub Actions write access through per-product grants, organization-wide
repo:*wildcards and a flat subject namespace. This epic routes the same capability through Service Accounts, memberships, subjects pinned to a repository and ref, and an issuer-qualified namespace. The GitHub-specific work lands in Trust GitHub Actions alongside the Source issuer data.source.coop#223. - An ADR records the Service Account decision (step 15) before step 3 starts.
Proposed solution
Summary
-
Add a new account type: Service Account.
- Why: The existing Organization account is prohibited from most of our authentication and authorization logic. An organization can't authenticate with the API (account lookup filters to individuals), nor can an organization be a member of an organization or product (invitations reject any non-individual account). We could refactor the system to let orgs authenticate and hold memberships, but that doesn't get us much closer to the goal — we want to specify a separate set of permissions for a service account than for the org that owns it.
- Requires: accounts must become findable by how they signed in, not just by an individual's identity id. Today that lookup filters to individual accounts, so no new account type can authenticate at all. This means a new index plus a backfill across every existing account — a missed row is a person who can't log in, so it needs a dual-read window. This is the largest piece of work in the epic, and it gates steps 4, 5 and 7. Steps 2, 8, 14, 15 and 16 do not depend on it and start on day one alongside it.
-
Specify permitted Integrations for Service Accounts. Users can create Service Accounts under their own user account, or under any organization account they own or maintain. During creation, or at any time after, the Service Account can be configured with approved Integrations. These specify how a Service Account may authenticate. Initially we will allow two: API Key and GitHub. More strategies may be added later.
-
Service Accounts gain permissions to interact with products via Memberships. We will use the existing Memberships model to manage a Service Account's permissions. Initially a Service Account can only become a member of products owned by its parent account (its "owner"); later we may lift this to allow granting membership across an entire organization, or to products owned by another account. Unlike memberships today, a Service Account can only hold the
read_dataorwrite_datarole on a product — neverownersormaintainers. -
Service Accounts assume Roles to subset their permissions. A Role is named when software performs the STS AssumeRoleWithWebIdentity operation. Each Role carries a policy specifying what the caller may do with a product's files. Policies can only ever subset the Service Account's permissions — never expand them. Initially two built-in Roles are available:
FullAccessandReadOnly;_defaultremains an alias forFullAccessbecause it is already in deployed client configuration. Later we will support user-defined Roles for more complex policies, such as granting read access to an entire product while granting write access to only one prefix.- Note: Roles are owned by the account, not stored on the Service Account (see ADR-010). A Service Account assumes a Role; the Role itself records who may assume it.
- Note: enforcing that a Role subsets rather than replaces permissions is ADR-011, "Role-Ceiling Authorization". Nothing evaluates a ceiling today; step 6 builds it.
-
API keys are proxy-signed JWTs (ADR-013). A key is a long-lived JWT signed by the data proxy, carrying
type: api_key, ajti, the Service Account assub, and an optionalexp. It is exchanged at/.stslike any other token; the only addition to the exchange is ajtirevocation check. Expiry is optional and can be changed after issuance. A Service Account may hold several active keys, so rotation is overlap by construction.
Implementation
Each step states the current behaviour, the change, and its acceptance criteria. Implementation detail lives in the linked issue.
Phase 1 — Foundations
1. Resolve accounts by (issuer, subject) · source.coop · #543
- Today: a token is turned into an account by looking up its subject alone, and that lookup discards anything that isn't an individual. No other account type can authenticate at all. The issuer is already pinned — to the data proxy, not Ory — so this is a subject-namespace problem, not an unvalidated-issuer one.
- Do: add a table of identity bindings —
(issuer, subject) → account— and make the lookup take both claims. Keep the existing single-argument lookup as a wrapper that supplies the Source issuer. Backfill every existing account with an(Ory, identity_id)binding. - Done when: every existing account has a binding, confirmed by count; the lookup reads from the new table; the fallback to the old index has been removed.
- Watch: a missed row is a silent 401 for a real person, so keep the fallback until the count is verified.
scripts/backfill-allowed-visibilities.tsis a worked two-phase precedent for exactly this migration. - Gates 4, 5, 7. This is the schedule risk in the epic.
2. Fix the self-authorization shortcut in hasRole · source.coop · #544
- Today:
hasRolereturns true before checking any role whenever the caller's own account id equals the account being acted on (authz.ts). Harmless for a person — you may act on yourself. - Do: require a real role check even when the ids match, or exclude service accounts from the shortcut — while preserving every path where an individual manages themselves.
- Done when: a test proves a service account cannot edit its own flags, profile, or memberships, and every existing self-management path still passes.
- Why first: a service account has its own account id, so until this is fixed it authorizes itself for anything scoped to itself — its own profile, memberships and data connections. The owner cap has the same hole; one fix serves both.
- Watch: the shortcut is load-bearing.
canManageAccounthas no self-check of its own and relies on it for "an individual account managing itself". This is a behaviour-preserving authz refactor across 27hasRoleand 132isAuthorizedcall sites, not a one-line fix. - Blocks 3. Starts day one, in parallel with 1.
Phase 2 — The principal
3. Add the Service Account type · source.coop · #545
- Today: two account types, individual and organization. 48 places across 19 files branch on which — 77 across 31 including tests, stories and mocks.
AccountType.SERVICEalready exists as a commented-out stub. - Do: add
AccountType.SERVICE, a persistedowner_account_idand its index, and restrict a service account toread_data/write_datamemberships. No reserved id namespace:typeis the discriminator andowner_account_idrecords ownership. - Done when: a service account can be created; it appears on the two membership-management pages, visually distinguished; and it cannot be invited as owner or maintainer, cannot create products or organizations, and cannot hold the admin flag.
- Watch: the default at every branch site is exclude. Most sites are the binary
isIndividualAccount/isOrganizationalAccounthelpers, which would otherwise render a machine as an organization profile rather than failing.AccountSchemais a discriminated union whoseidentity_idtyping fits neither existing arm — this is a schema change, not an enum addition. - Depends on 2, 15. Starts alongside 1.
4. Integrations model · source.coop · #546
- Today: nothing records how an account may authenticate. There is one implicit route, and it is assumed to be Ory.
- Do: let a service account hold many identity bindings, each naming an issuer and an exact subject. Attaching one requires proving control of that subject.
- Scope: step 1 builds the
(issuer, subject)table. This step is multiplicity and proof of control. - Done when: a service account with two Integrations authenticates by either and gets identical access; attaching a subject the user can't prove control of is rejected.
- Why proof matters: without it, anyone could claim another organization's CI subject and receive their access.
- Watch: there is no proof-of-control machinery to build on. Budget this as a new subsystem.
- Depends on 1, 3.
5. Management API and UI · source.coop · #547
- Today: none of the above is visible or manageable.
- Do: create, list, disable and delete under the owner account; add and remove Integrations and memberships; show when it last authenticated.
- Done when: an organization maintainer can create a service account, grant it a product, and revoke it, without an engineer.
- Follow: the data-connection admin UI (feat(admin): data connection management (CRUD, S3/Azure/GCP/R2, product mirrors) #364) is the template — the same route shape under the owner account and the same gate pattern.
- Watch: that gate reaches the
hasRoleshortcut, so land 2 first. - Depends on 3, 4. Required for step 9 to be self-serve.
Phase 3 — Roles and the proxy
6. ReadOnly alongside FullAccess · data.source.coop · source-cooperative/data.source.coop#221
- Today: the proxy serves one hardcoded
_defaultRole with an unlimited ceiling. - Do: add a second hardcoded Role. Keep
_defaultas an alias forFullAccess; reject unknown Role names outright, never downgrade. - Done when: naming
ReadOnlyreturns credentials that cannot write on an account whose memberships allow writing; an existing_defaultcaller keeps working; an unknown name is rejected. - Watch: the assumed Role is dropped before it reaches authorization (
identity.rs), so the proxy cannot see which Role a caller assumed. That needs an upstream change beforeReadOnlycan be enforced. The ceiling evaluation itself is ADR-011. - Later: Roles looked up from the API — ADR-010, deferred by scope note. Not in the first release.
- Depends on the upstream plumbing, which should ship in the same multistore release as 8.
7. Namespace the credential subject by verified issuer · data.source.coop · source-cooperative/data.source.coop#222
- Today: the minted credential records who the caller is but not who vouched for them, so every issuer's subjects share one namespace. That subject is also part of the proxy's cache key.
- Do: rewrite the principal into an issuer-qualified form before it reaches the API, derived from the verified
issclaim. - Done when: two issuers using the same subject string resolve to different accounts and do not share a cache entry.
- Watch: derive the namespace from the verified claim, not from Role config — one Role may trust several issuers. This is a breaking format change across three repositories (multistore release → proxy → step 1) with a dual-format window during rollout. The analytics
user_idand audit logging ride along. - Depends on 1. Blocks 9.
8. Upstream hygiene · multistore · developmentseed/multistore#143
- Today: an empty audience list accepts any audience; an empty subject condition skips the check; there is no token-type check; tokens with no
expare accepted; successful exchanges are never logged; a missing claim in a scope template grants full-bucket access. All verified in the 0.7.2 the proxy builds against. - Do: make empty mean deny, add the token-type check, require
expfrom issuers other than the proxy itself, log successes, and make an unresolved scope placeholder a hard error. - Done when: released upstream and the dependency bumped here.
- Why now: all of them become reachable the moment Roles are user-authored — or sooner, the moment a second issuer is trusted.
- Blocks 9. Starts immediately. A different GitHub organization, but this team has write access and releases are automated.
Phase 4 — First demonstrable milestone
Everything in Phases 1–3 precedes this one, and none of it is visible to users. This is the first point at which the model works end to end, self-serve.
9. GitHub Actions as an Integration · data.source.coop + source.coop · source-cooperative/data.source.coop#223
- Today: CI already exchanges real GitHub tokens on every same-repo run — but by swapping the trusted issuer, never running GitHub alongside our own.
- Do: trust both issuers at once, pin subjects to a repository and ref (no organization-wide wildcards), and wire GitHub up as an Integration type end to end.
- Done when: a workflow in an unrelated repository writes to a product using only its ambient OIDC token, with no stored secret.
- Watch:
aws-actions/configure-aws-credentialswill not work — it callsGetCallerIdentityafter the exchange, which is unimplemented (multistore-sts: implement GetCallerIdentity (unblocks aws-actions/configure-aws-credentials) developmentseed/multistore#127; the implementing PR feat(sts): GetCallerIdentity + real configure-aws-credentials integration test developmentseed/multistore#126 has been stalled since July). The SDKs' built-in web-identity providers do work. Multi-issuer is not a one-line change (STS roles should support multiple trusted OIDC providers, each with their own audience and subject conditions data.source.coop#152), and config spans three wrangler files. - Depends on 4, 5, 6, 7, 8.
Phase 5 — API keys
10. API key lifecycle · source.coop + data.source.coop · #548, source-cooperative/data.source.coop#231
- Today: no key mechanism exists that grants access. The legacy
api-keystable is unrelated and no longer honoured by the proxy. - Do: per ADR-013 — issue (shown once, bound to one service account, optional expiry), list, revoke, rotate, and record last-used. Several active keys per service account; expiry changeable after issuance. The proxy half — accepting
type: api_keytokens from its own issuer, thejticheck and its cache, the latency floor, and minting — is Accept API-key JWTs at /.sts: jti revocation check, latency floor, and key minting data.source.coop#231. - Done when: a key can be issued, used at
/.ststo obtain credentials, and revoked — with revocation effective for new exchanges within thejticache TTL, 60s, aligned with the permissions lookup. - Watch: credentials already issued live to their session cap — up to 12 hours at
STS_MAX_SESSION_DURATION_SECS; rotatingSESSION_TOKEN_KEYis the emergency stop. Expired, revoked and unknown must return one client-visible outcome with a request id and a matching latency floor. Non-expiring keys need every signing key ever used retained for verification. - Depends on 4, 16.
11. Unattended refresh in the CLI · source-coop-cli · source-cooperative/source-coop-cli#17
- Today: the CLI caches credentials and, on expiry, stops with "Run 'source-coop login'". It also sends the token as a URL query parameter, which lands in access logs.
- Do: a mode that reads an API key, exchanges it without a browser, and keeps a token file fresh. Send the token in the request body; the proxy already accepts form bodies.
- Done when: a daemon holding a key and no browser runs across a credential expiry.
- Depends on 10.
12. Verify stock SDK acquisition · verification · source-cooperative/data.source.coop#229
- Do: confirm an unmodified AWS SDK acquires and refreshes credentials from
AWS_WEB_IDENTITY_TOKEN_FILE,AWS_ROLE_ARNandAWS_ENDPOINT_URL_STSalone. - Done when: a plain boto3 script runs unattended across a credential expiry with no Source-specific code.
- Watch: the existing integration tests pass
RoleArn: "_default", which is not ARN-shaped, so the SDK's client-side ARN validation has never been exercised. - Depends on 6, 10, 11.
Alongside
13. Documentation · docs.source.coop · source-cooperative/docs.source.coop#34
- Do: one page, with short snippets per client environment (server or VM, Kubernetes, GitHub Actions) — how to get credentials, how they refresh, how long they last, and what failure looks like when refresh is skipped.
- Supersede:
docs/using-source/data-upload.md"Option 3: Long-standing or automated access" is 200 lines of "create an IAM role, then send us the ARN" — the current answer to this problem, wrong once this ships. - Include: tools with their own credential handling (GDAL
/vsis3, DuckDBhttpfs,rclone) capture environment variables at process start, so a transfer longer than the credential lifetime fails mid-flight. That is the failure users will actually hit. - Depends on 9, 10, 12.
14. Retire the legacy api-keys table · source.coop · #549
- Today: fix(api): stop minting legacy API keys and make revoking one delete it #582 re-cuts the stale fix(api): remove legacy API keys and the api-keys table #374, which removed the legacy table, from current
main, and retires the routes first. Six live HTTP routes onmainstill touch it: two POSTs that write, three GETs that read, and one DELETE that has never deleted anything — the table client has no delete method. - Do: return 410 on the two POSTs immediately; deprecate the GETs for one release; drop the table, then delete it out-of-band, since production CDK retains it.
- Done when: the routes are gone, the table is dropped and deleted, and the retained rows — every key any user ever "revoked", stored in plaintext — have been audited.
- Watch: fix(api): remove legacy API keys and the api-keys table #374 was 142 commits behind
mainand untouched since July, so fix(api): stop minting legacy API keys and make revoking one delete it #582 re-cuts it rather than rebasing it. - No dependencies. Starts immediately.
15. Write the ADR · data.source.coop · source-cooperative/data.source.coop#230
- Do: a new ADR for the Service Account principal, and amendments where this epic moves a recorded decision: ADR-013 (
subis a service account; no per-key Role binding in the first release; the ADR-010 dependency dropped) and the ADR-010 scope note from Add the ReadOnly Role alongside FullAccess data.source.coop#221. - Why: an ADR-010 Role already carries identity constraints and a permission ceiling. The case for a Service Account — a principal with its own grant rather than a subset of its owner's — is what justifies steps 1 and 3, and it is written down nowhere.
- No dependencies. Precedes 3.
16. Register the key marker with secret scanners · source.coop · #561
- Do: choose a distinctive marker for ADR-013 keys and start the scanner partner registration. Expose a public self-revoke endpoint so a stranger who finds a leaked key can kill it without holding rights on the account.
- Why now: a multi-week external process, the only item in the epic on someone else's clock. The marker must be fixed before step 10's schema. A JWT begins
eyJlike every other, so the marker is a design decision in its own right; the legacySCprefix is far too low-entropy to register. - No dependencies. Starts day one.
Sequencing
- Day one, in parallel: 1, 2, 8, 14, 15, 16.
- Critical path: 1 → 4 → 10 → 11 → 12 → 13. Step 16 also gates 10 and runs on an external clock, so it is on the path too.
- Not yet startable: 6, until multistore exposes the assumed Role; 9, until 4–8 land.
- Open questions: a per-owner cap on service accounts — each takes a public name and a profile page, so unbounded creation is a spam surface. Rate limiting on
/.stsshould use the Workers rate-limiting binding and be designed together with step 10's latency floor. Account deletion ([Proposed Feature] Delete Account #355) is unimplemented, so the "block deleting an owner of service accounts" guard attaches there. - Out of scope for now: user-registered issuers. Adding a subject under an already-trusted issuer is ordinary UI (step 5); registering a new issuer stays admin-reviewed, hand-configured for the first few.
Activity
jedsundwall commentedon Aug 21, 2026
alukach commentedon Aug 22, 2026
alukach commentedon Aug 22, 2026
feat(settings): detail view, derived ids and AWS action in the mock
alukach commentedon Aug 22, 2026
tylere commentedon Aug 24, 2026
tylere commentedon Aug 24, 2026
tylere commentedon Aug 24, 2026
tylere commentedon Aug 24, 2026
tylere commentedon Aug 24, 2026
alukach commentedon Aug 26, 2026
alukach commentedon Sep 21, 2026
Rewrote the body. What changed, and why:
Decisions taken
svc--was never valid —ID_REGEXforbids--, and--is already the data-connection id delimiter.typeis the discriminator.FullAccess/ReadOnly, with_defaultkept as an alias. API-sourced Roles (ADR-010) stay deferred.Corrections
hasRoleshortcut, becauseputAccountFlagsdenies every non-admin unconditionally. The step is still required (it self-authorizes profile, memberships and data connections), and it is not small —canManageAccountrelies on the shortcut, so this is a behaviour-preserving refactor.SESSION_TOKEN_KEYrotation named as the real bounds.DELETE /api/v1/api-keys/{id}route has never deleted anything, so step 14 audits the retained plaintext rows and 410s the POSTs rather than deprecating them.Added
Removed
Sub-issues #543–#549 were updated in the same pass to match; #549 retitled. New issues filed: source-cooperative/source-coop-cli#17, source-cooperative/data.source.coop#229, source-cooperative/docs.source.coop#34, source-cooperative/data.source.coop#230, #561.
21 remaining items
fix(deps): bump rustls to 0.23.45 for RUSTSEC-2026-0285 (#238)
docs(adr): ADR-014 service accounts; amend ADR-010 and ADR-013 (#232)
docs(adr): revise ADR-013 — API keys are opaque secrets resolved by t…
feat(accounts): opaque API keys resolved by hash, and a working key UI (
feat(accounts): opaque API keys resolved by hash, and a working key UI (
feat(accounts): issue, revoke and expire opaque API keys for service …
feat(accounts): end API keys in a CRC-32 checksum (#596)
feat(accounts): revoke leaked API keys via self-revoke and GitHub sec…
feat(sts): exchange opaque API keys at /.sts by hash lookup (#235)
feat(sts): serve the FullAccess and ReadOnly roles (#236)
feat(sts): let platform IdP tokens act as accounts that trust them (#237
feat(sts): answer GetCallerIdentity with multistore 0.8.0 (#248)
feat(accounts): offer a repository's immutable name in the trust form (…
fix(accounts): offer a whole workflow or the step alone as a trusted …
fix(api): remove legacy API keys and the api-keys table (#374)