Skip to content

Epic: machine identity for unattended workflows #491

Open
15 of 17 issues completed
Open

Description

@alukach

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

  1. From their user or organization settings, create a Service Account.
  2. Configure one or more Integrations — the ways software may authenticate as the Service Account.
  3. Grant it membership on the products it needs, as a reader or a writer.
  4. Point the software at it. The software names a Role when it asks for credentials to further limit the session's permissions.

Prerequisites

Proposed solution

Summary

  1. Add a new account type: Service Account.

    1. 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.
    2. 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.
  2. 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.

  3. 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_data or write_data role on a product — never owners or maintainers.

  4. 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: FullAccess and ReadOnly; _default remains an alias for FullAccess because 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.

    1. 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.
    2. 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.
  5. API keys are proxy-signed JWTs (ADR-013). A key is a long-lived JWT signed by the data proxy, carrying type: api_key, a jti, the Service Account as sub, and an optional exp. It is exchanged at /.sts like any other token; the only addition to the exchange is a jti revocation 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.ts is 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: hasRole returns 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. canManageAccount has no self-check of its own and relies on it for "an individual account managing itself". This is a behaviour-preserving authz refactor across 27 hasRole and 132 isAuthorized call 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.SERVICE already exists as a commented-out stub.
  • Do: add AccountType.SERVICE, a persisted owner_account_id and its index, and restrict a service account to read_data / write_data memberships. No reserved id namespace: type is the discriminator and owner_account_id records 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 / isOrganizationalAccount helpers, which would otherwise render a machine as an organization profile rather than failing. AccountSchema is a discriminated union whose identity_id typing 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 hasRole shortcut, 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 _default Role with an unlimited ceiling.
  • Do: add a second hardcoded Role. Keep _default as an alias for FullAccess; reject unknown Role names outright, never downgrade.
  • Done when: naming ReadOnly returns credentials that cannot write on an account whose memberships allow writing; an existing _default caller 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 before ReadOnly can 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 iss claim.
  • 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_id and 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 exp are 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 exp from 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

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-keys table 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_key tokens from its own issuer, the jti check 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 /.sts to obtain credentials, and revoked — with revocation effective for new exchanges within the jti cache 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; rotating SESSION_TOKEN_KEY is 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_ARN and AWS_ENDPOINT_URL_STS alone.
  • 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, DuckDB httpfs, 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

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 (sub is 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 eyJ like every other, so the marker is a design decision in its own right; the legacy SC prefix 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 /.sts should 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

  1. jedsundwall commented on Aug 21, 2026

    @jedsundwall
  2. alukach commented on Aug 22, 2026

    @alukach
    Author
  3. alukach commented on Aug 22, 2026

    @alukach
    Author
  4. alukach commented on Aug 22, 2026

    @alukach
    Author
  5. tylere commented on Aug 24, 2026

    @tylere
  6. tylere commented on Aug 24, 2026

    @tylere
  7. tylere commented on Aug 24, 2026

    @tylere
  8. tylere commented on Aug 24, 2026

    @tylere
  9. tylere commented on Aug 24, 2026

    @tylere
  10. alukach commented on Aug 26, 2026

    @alukach
    Author
  11. alukach commented on Sep 21, 2026

    @alukach
    ContributorAuthor

    Rewrote the body. What changed, and why:

    Decisions taken

    • API keys are ADR-013 proxy-signed JWTs. Expiry is optional and changeable after issuance; a service account may hold several active keys, so rotation is overlap by construction. The Ory Talos route is dropped.
    • No reserved id namespace for service accounts. svc-- was never valid — ID_REGEX forbids --, and -- is already the data-connection id delimiter. type is the discriminator.
    • Role names follow Add the ReadOnly Role alongside FullAccess data.source.coop#221: FullAccess / ReadOnly, with _default kept as an alias. API-sourced Roles (ADR-010) stay deferred.
    • Epic: GitHub Actions OIDC write access to products #432 is closed in favour of this epic.

    Corrections

    • Step 2's justification was wrong: a service account cannot grant itself the admin flag through the hasRole shortcut, because putAccountFlags denies every non-admin unconditionally. The step is still required (it self-authorizes profile, memberships and data connections), and it is not small — canManageAccount relies on the shortcut, so this is a behaviour-preserving refactor.
    • "First shippable milestone" is now "first demonstrable": seven user-invisible steps precede it, and it is self-serve only with step 5.
    • Step 1 does not block everything; 2, 3, 8, 14, 15, 16 start on day one.
    • "Within the stated cache TTL" now states one: 60s for new exchanges, with the 12-hour session cap and SESSION_TOKEN_KEY rotation named as the real bounds.
    • The legacy 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

    • 11 (CLI unattended refresh — dropped in the last edit, but nothing else writes the token file), 15 (ADR), 16 (secret-scanner registration).
    • Steps 6–9 now link the issues that already existed in data.source.coop and multistore instead of restating them.

    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.

  12. 21 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions