Repository navigation
fix(service-accounts): refuse new product access while disabled - #666
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @alukach's task in 14s —— View job ✅ No blocking issues — safe to merge. The guard sits in Non-blocking observations:
Docs The description names the docs.source.coop page and ADR 005 it checked, and says why both still hold. It also covers 💰 Estimated review cost: $0.10 · 0m13s · 5 turns |
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>
…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>
A disabled service account can still be granted a product, or have its grant raised from
read_datatowrite_data, throughinviteMemberandupdateMembership. That reaches it from the service-account page (setProductAccess) and fromPOST /accounts/{owner}/membersandPOST /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
UpdateServiceAccountschema. This PR stands on its own, offmain.Docs, ADRs, llms.txt
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.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 refusedread_dataandwrite_dataand that other accounts aren't held to the rule.🤖 Generated with Claude Code