Skip to content

fix(service-accounts): refuse new product access while disabled - #666

Merged
alukach merged 1 commit into
mainfrom
fix/disabled-service-account-grants
Oct 15, 2026
Merged

alukach merged 1 commit into
mainfrom
fix/disabled-service-account-grants

Conversation

@alukach

@alukach alukach commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

A disabled service account can still be granted a product, or have its grant raised from read_data to write_data, through inviteMember and updateMembership. That reaches it from the service-account page (setProductAccess) and from POST /accounts/{owner}/members and POST /products/{account}/{product}/members. Enabling the account again brings that access with it, although disabling is meant to freeze it, which is how adding a trust or issuing an API key already treats it.

serviceAccountGrantProblem, which every grant and role change already asks, now returns "That service account is disabled" for a disabled service account (a 400 from the API). Revoking access still works, so a disabled account can still be stripped down. People and organizations are unaffected.

Found reviewing #665, which documents the rule in its UpdateServiceAccount schema. This PR stands on its own, off main.

Docs, ADRs, llms.txt

  • docs.source.coop docs/using-source/upload/with-service-account.md: I checked it, and it doesn't describe granting access to a disabled account, so it still holds.
  • data.source.coop ADRs: I checked 005 (authorization). It covers what a grant permits, not when one may be made, so it still holds.
  • public/llms.txt: no line covers this.

Storybook

No component changed. The service-account page shows the refusal through the action-state message it already renders.

Testing

  • npm run type-check: clean.
  • npx jest src/lib/accounts src/lib/operations src/lib/actions/service-accounts.test.ts src/lib/actions/memberships.test.ts: 119 tests pass, including a new test that a disabled service account is refused read_data and write_data and that other accounts aren't held to the rule.

🤖 Generated with Claude Code

A disabled service account could still be granted a product, or have its grant raised, through the membership operations (and so the service-account page and the product members endpoints). Enabling it again brought that access with it, though disabling an account is meant to freeze it. serviceAccountGrantProblem, which every grant and role change already asks, now refuses one for a disabled service account. Revoking access still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
source-coop-ui Ready Ready Preview Oct 15, 2026 6:12am UTC
source-cooperative Ready Ready Preview Oct 15, 2026 6:12am UTC

Request Review

@claude

claude Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 14s —— View job


✅ No blocking issues — safe to merge.

The guard sits in serviceAccountGrantProblem (src/lib/accounts/service-accounts.ts:28). All three callers go through it: inviteMember (operations/memberships.ts:120), updateMembership (operations/memberships.ts:290) and setProductAccess (actions/service-accounts.ts:323). That is the right place for the check. In setProductAccess, access === "none" revokes before the check, so stripping a disabled account still works. Only service accounts are checked, and the new test covers that.

Non-blocking observations:

  • updateMembership also refuses a downgrade (write_data to read_data) on a disabled account, because it asks the same function. The PR says revoking still works, and that holds, but it can't lower a grant either. Revoking is the same end state, so this is probably fine. It does mean an API client gets a 400 for a change that only reduces access.
  • On that path the error is attached to the role field (operations/memberships.ts:291). For the disabled case the role isn't the problem. inviteMember already handles this by dropping the field when the role is allowed. A small inconsistency in field-level error display.

Docs

The description names the docs.source.coop page and ADR 005 it checked, and says why both still hold. It also covers llms.txt. Nothing further is needed.


💰 Estimated review cost: $0.10 · 0m13s · 5 turns

@alukach
alukach marked this pull request as ready for review October 15, 2026 20:12
@alukach
alukach merged commit 6e21bd3 into main Oct 15, 2026
11 checks passed
@alukach
alukach deleted the fix/disabled-service-account-grants branch October 15, 2026 20:18
alukach added a commit that referenced this pull request Oct 16, 2026
main's updateAccount and deleteAccount (#674, #675) now edit and delete service accounts, so this branch drops its own updateServiceAccount, deleteServiceAccount and UpdateServiceAccountSchema, and PATCH/DELETE /accounts/{account_id} are main's. The rename and disable actions call updateAccount. Grants use product_id (#680). setProductAccess keeps going through the membership operations, which refuse a disabled service account through serviceAccountGrantProblem (#666).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit that referenced this pull request Oct 17, 2026
…665)

Closes #594. Part of #588.

## What

Service accounts and their API keys over `/api/v1`, documented at
`/api/docs`:

| Endpoint | Operation |
|---|---|
| `GET /accounts/{owner}/service-accounts` | `listServiceAccounts` →
`{items, next_cursor: null}` |
| `POST /accounts/{owner}/service-accounts` | `createServiceAccount` —
`local_id`, `name`, `github_subjects[]`, `grants[{product_id, role}]`,
optional `key{label, expires_at}`; the response carries the issued key
when one is asked for |
| `GET /accounts/{sa}/trusts` | `listTrusts` → `{items, next_cursor:
null}` |
| `POST /accounts/{sa}/trusts` | `addGithubTrust` — `{subject}` |
| `DELETE /accounts/{sa}/trusts?issuer=&subject=` | `removeTrust` — 404
for a trust the account doesn't have; returns the trust as it was |
| `GET /accounts/{sa}/keys` | `listKeys` → `{items, next_cursor: null}`,
revoked and expired keys included, never a hash |
| `POST /accounts/{sa}/keys` | `issueKey` — `{label, expires_at}` → 201
with `key`, the only time it appears |
| `PATCH /accounts/{sa}/keys/{key_id}` | `updateKey` — `{expires_at?,
revoked?: true}` |

`src/lib/operations/service-accounts.ts` holds the rules the issue
names: `GITHUB_ACTIONS_SUBJECT_REGEX` (as a Zod schema shared with the
OpenAPI doc), the read/write-only grant roles, `managedServiceAccount`
(every "not yours / missing / not a service account" answers 404,
keeping the single answer it gave before), and the rule that a service
account can't own another. The server actions in
`src/lib/actions/service-accounts.ts` now adapt FormData into those
operations, and the service-accounts settings page lists through
`listServiceAccounts`. Editing and deleting a service account are main's
`PATCH` and `DELETE /accounts/{account_id}` (#674, #675): rename and
disable call `updateAccount`, and delete calls `deleteAccount`, so this
PR adds no operation of its own for them. Trusts are returned through
`AccountTrustSchema`, so the table's `identity` sort key stays out of
responses.

`setProductAccess` now calls `inviteMember` / `updateMembership` /
`revokeMembership`, and creation grants each product through
`inviteMember` too, so every grant passes the same authorization,
duplicate check and audit log; so product access over the API is the
product members endpoints from #589 with role `read_data` or
`write_data`, and gets no endpoint of its own.

API keys were first left to #570 and #580–#582, which built the keys and
their settings UI but no API; with those merged, this PR adds the API
for them (scope noted on #594). `issueApiKey`, `revokeApiKey`,
`setApiKeyExpiry` and the create action now map form fields onto
`issueKey`, `updateKey` and `createServiceAccount`, so no action touches
the keys table, and the key rules (hash-only storage, the six-character
hint, refusing a disabled account, the expiry range) are tested once, on
the operations.

## Worth a look

- Revoking is `PATCH {"revoked": true}`, not `DELETE`: the record stays,
with `revoked_at`, `revoked_via` and `revoked_by`, as disabling an
account is a `PATCH`. `revoked: false` is refused with 400, since a
revoked key can't be restored; revoking one twice is 409, and a PATCH
with neither field is 400.
- `expires_at` is an ISO time, at most 3650 days away, or `null` for a
key that lasts until revoked. It is required on issue, so a caller
chooses no-expiry deliberately. The settings form still takes days and
converts them.
- A key requested with `POST /service-accounts` is validated with the
rest of the body, before anything is written, and stored after the
grants and trusts.
- `createServiceAccount` checks every grant against `InviteMembership`
before it writes the account, so a grant the caller may not make leaves
nothing behind and a retry with the same `local_id` succeeds.
`inviteMember` can refuse nothing else afterwards: the account is new
and enabled, and the grants are deduplicated first.
- Deleting a service account is main's `deleteAccount` on both the API
and the settings page. It deletes the account's API keys rather than
revoking them, and the key exchange also refuses a key older than its
account, so a key from a deleted account can't sign in as the next
account to take its id. ADR 013 still holds.
- `setProductAccess` refuses a new grant to a disabled service account
(#666) through `inviteMember` and `updateMembership`, which both check
`serviceAccountGrantProblem`. Revoking still works while the account is
disabled.
- Service-account lists use the `{items, next_cursor}` page shape, as
products and data connections do, so pagination can arrive later without
a breaking change.

## Docs, ADRs, llms.txt

- docs.source.coop `docs/using-source/upload/with-service-account.md`
walks through the settings UI, which is unchanged, so it still holds; it
could later mention issuing a key over the API, which #595's reference
covers meanwhile. The API reference is #595's to publish.
- data.source.coop ADRs: I checked 005 (authorization), 010
(account-owned roles) and 013 (API keys). ADR 013 has source.coop
generate the key, store only its hash, show it once, refuse a disabled
service account and keep expiry editable, with `key_id` as the public
handle; the key endpoints do exactly that, so this PR implements
existing decisions and moves none.
- `public/llms.txt`: no change. These endpoints all need auth, so they
can't go on the file's no-auth URL list, and its existing "API
reference" line points at `/api/openapi`, which now includes them.

## Storybook

No component changed. The settings page now gets its list from the
operation, and the key actions keep their form fields and state shapes,
so `IssueApiKeyDialog` and the rest are untouched.

## Testing

- `npm run type-check`: clean.
- `npx jest`: 96 suites, 1068 tests pass. That includes the
service-account action tests, now run through the operations; the key
action tests, now checking only the form mapping; new operation tests
(list authz, JSON create, the 404 on an unknown trust, a refused grant
or bad key writing nothing, issuing, listing, expiring and revoking
keys); and the OpenAPI validity and route-coverage tests.
- Not exercised against a running server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

2 active deployments
Preview – source-cooperative — 40ee1b6d Deployed Oct 15, 2026 by vercel[bot]
Preview – source-coop-ui — 40ee1b6d Deployed Oct 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant