feat(accounts): issue, revoke and expire opaque API keys for service accounts - #570
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @alukach's task in 19s —— View job ✅ No blocking issues — safe to merge. I read the actions, the exchanges route, the keys table client, the key types and the
Simplify (ponytail)
DocsThe description covers this. It names ADR-013 as revised in data.source.coop#234, ADR-014 in #232, the proxy PR #235, and docs.source.coop#37/#34, and it says why the hint needs no ADR change. Nothing is missing. 💰 Estimated review cost: $0.19 · 0m18s · 10 turns |
|
Pushed f93dcac for the review finding: the keys table now sets one field at a time with an |
|
Pushed a follow-up so the comparisons this branch added ask |
2e73687 to
05e68e9
Compare
05e68e9 to
3c38c1f
Compare
|
Stack #571 rebased to carry #565's no-backfill change (Ory identities stay on |
569c7ca to
766b553
Compare
|
Rebased onto #567's redesign. The API keys section moved from the old service-account card to the account's own page, |
|
Rebased onto |
API keys for environments without OIDC, per ADR-013 as amended by ADR-014: a JWT the data proxy signs, sub the service account, jti the record's id, exp optional, handed out once as sck_ plus the JWT. Issuing writes the record and asks the proxy to sign; a refused signature removes the record. Revocation and expiry each write one field. The proxy checks a key's standing on every exchange through the exchanges route, as the account. A key's subject is the account's own id, which the API resolves directly, so nothing else is written for it; an account whose id is also someone's Ory identity id, or that is disabled, gets no key. Rebuilt on the trust model in one commit; the earlier history is on GitHub in the PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
A key's subject is the service account's id, and the guard refused an account whose id was also someone's Ory identity id, since the resolver tries Ory first. Service account ids are now `{owner}--{id}`, and an Ory identity id is a UUID, which never contains `--`, so no service account can be refused by it. The keys tests and stories use namespaced ids.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
#580) Into #570's branch, as the in-place rework that PR's description will need once this merges; #570 then no longer depends on the proxy and can land before it. Implements ADR-013 as revised in source-cooperative/data.source.coop#234. Part of #491 and #548. ## What **The key is opaque.** `sck_` + 32 random bytes in base64url, a fixed 47 characters matching `^sck_[A-Za-z0-9_-]{43}$`, from `randomBytes` (not the modulo-biased legacy generator). Its SHA-256 is the record's partition key; a random public `key_id` is what the UI, revoke and expiry actions handle. The table's `account_id` index stands. Nothing signs the key and nothing calls the proxy at issue: `proxy-keys.ts`, the Ory ID-token round-trip and the compensating delete are gone, and `getOryIdToken` is private again. **The proxy asks by hash, as itself.** `POST /api/v1/service-account-keys/exchanges` takes `{key_hash}` and answers `{account_id, key_id, active: true}` for a live key, or `{active: false}` for an unknown, revoked, expired or disabled one, with the reason only in the log under the forwarded `x-request-id`. Because the proxy does not yet know which account is calling, the route authenticates the proxy itself: `verifyProxyAssertion`, extracted from `authenticateWithOidcToken` and logging under its own operation name, verifies the assertion, and the route accepts only the sentinel subject `urn:source:data-proxy`. `authenticateWithOidcToken` refuses that subject outright, so it can never become a session, and the route never touches `getApiSession`, so no cookie reaches it. Last use is recorded best-effort: a throttled write must not refuse a live key. **The hash stays on the server.** `ServiceAccountKeyRecord` is the stored row; `publicKey` strips `key_hash` before either page hands records to the client component. The Storybook mock returns a key of the real shape. **The key UI works end to end.** Opening the issue dialog threw on #570's branch: its "Never" expiry option had an empty value, which Radix Select refuses. The select is now `ApiKeyExpiryField`, where "never" stands for the empty `expires_in_days` the actions read as no expiry. The smoke test renders its stories, and it skips the dialogs that contain it, which is how the crash went unnoticed. The show-once view prints the five variables a stock AWS SDK or the AWS CLI needs to use the key from a file, filled in for this environment's proxy, with the same `role/FullAccess` ARN form and region as the GitHub workflow snippet. `setApiKeyExpiry` had no caller: each live key now has a "Change expiry" dialog on the same field. The key list says to disable the account to stop every key at once, and the danger zone says what disabling does to credentials already issued and warns that enabling lets unrevoked keys sign in again. **Decisions to flag.** - `revokeApiKey` and `setApiKeyExpiry` find the key via `listByAccount(...).find(key_id)` rather than a third index: one query, and the ownership check comes free. - The route answers `active: false` rather than 404 for an unknown hash, so the proxy's existing cache code applies and the client learns nothing it did not already hold. - `after()` is not used for the last-use write; it is awaited in a `try/catch` because `after()` throws outside a request scope and would need mocking in every route test. - The printed `AWS_ROLE_ARN` names `FullAccess`, like the GitHub snippet already on `main`; both work once source-cooperative/data.source.coop#221 serves the named roles. Until then, `role/_default` is the name the proxy accepts. ## Stories - `IssueApiKeyDialog` › **Default**: https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-issueapikeydialog--default. Open it, pick "Never", and submit to reach the show-once view with the variables. - `ApiKeyExpiryField` › **Default**, **Never**: https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeyexpiryfield--default - `ServiceAccountDetail` › **Default**, with "Change expiry" on each live key: https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--default - `ServiceAccountDetail` › **Disabled**, with the re-enable warning: https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--disabled - `ServiceAccountList` › **Default**: https://source-coop-ui-git-feat-opaque-api-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountlist--default Captured from a static Storybook build with headless Chromium, with no page errors:     ## Testing - `npx jest` on the touched suites, 5 suites and 204 tests, all passing: - `service-account-keys.test.ts`: stores only the hash and returns the key once; a different key each time; no-expiry; disabled account, non-manager, bad label and bad expiry refused before any write; revoke and expiry by `key_id`. - `oidc.test.ts`: the existing 16, plus: the sentinel never becomes a session even if an account had that id; `verifyProxyAssertion` returns claims without resolving anyone, and null for wrong issuer, wrong audience, expired, or no token. - The exchanges route: active with account and last-use written; still active when the write fails; revoked, expired, disabled and unknown all answer inactive without a write; 401 for any other subject or none; 400 for a non-hex body. - `ApiKeyExpiryField.test.tsx`: 90 days by default; an empty value for "never". - The stories smoke test, which now renders both `ApiKeyExpiryField` stories. - `npm run type-check`, `npm run lint` (one `require()` warning in the new route test, the same pattern as the trusts route test), `npm run build-storybook`: clean. - The screenshots above drive both dialogs in a real browser, including choosing "Never", which crashed before. ## Docs and ADRs data.source.coop: ADR-013 as revised in source-cooperative/data.source.coop#234 is what this implements. The route contract here (`{key_hash}` in, always-200 standing out, proxy-as-itself auth) is the seam that source-cooperative/data.source.coop#235 calls; source-cooperative/data.source.coop#235 is the proxy PR replacing source-cooperative/data.source.coop#233. The ADR-005 amendment in source-cooperative/data.source.coop#234 records the sentinel subject. docs.source.coop: the unattended-workflow guide (source-cooperative/docs.source.coop#34) carries the setup the show-once view prints, and uses the same variables. Nothing existing describes keys. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ch key is which A key is shown once and stored only as a hash, so nothing on the service account's page tied a record to the key in someone's environment. The record now keeps the key's last four characters, set at issue, and the key list shows it as `sck_…Xy9Q`; the issue dialog says how it will be listed. Four characters are 24 of the key's 256 random bits. The hint confirms a key in hand against its record and is never used to look one up, since keys across the platform will share it. Keys issued before this have no hint and are listed without one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Rebased onto |
… menu
Each key's row ran its hint and three dates together in one wrapping line, beside two action links. Now the label and its Revoked or Expired marker sit over the key's hint alone; to the right, two short lines say how it has been used and when it ends ("Used 3 days ago", "Expires in 5 months", "Revoked 9 months ago"), with the exact dates, who issued it and when, in their tooltip; and Change expiry and Revoke move into a "⋯" menu. A revoked key keeps an invisible copy of that button so its lines align with the others.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
New commit: the key rows were congested, with the hint and three dates wrapping in one line beside two links. Each row now shows the label and hint on the left, two short relative lines on the right ("Used 3 days ago" / "Expires in 5 months"), the exact dates in a tooltip, and Change expiry and Revoke in a "⋯" menu. Description and screenshot updated. |
The key list lived inside ServiceAccountDetail, so Storybook showed it only as two rows of that page. ApiKeyList now holds the rows, their expiry dialog and revoking, and the page renders it in its API keys section. Its stories cover every state a key can be in — live, never expiring, never used, expired, revoked — plus a single key, a key from before hints were recorded, and none. Their dates are set relative to today, so the rows read the same whenever the story is opened. Like its neighbours, it uses useActionState, so the Jest smoke test skips it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
New commit: the API key list is its own component, |
Closes #548. Closes #546. API keys are the second Integration type beside the GitHub trusts #567 shipped, and #566 replaced proof of control with per-account trust. The
source.coophalf of #548. Part of #491.Merge order: this can merge and deploy before the proxy. It no longer calls the proxy; the proxy calls it. source-cooperative/data.source.coop#235 is the proxy half and needs the route here to be live, or every key exchange fails closed.
What
API keys for environments without OIDC — a server, a scheduler, an instrument — per ADR-013 as revised in source-cooperative/data.source.coop#234: a key is an opaque secret that source.coop resolves by hash; nothing signs it. Six commits on
main: the original feature, dropping the Ory-id guard once #567 namespaced service-account ids, #580's rework to opaque keys, the key hint, a calmer key list, and that list as its own component with stories.The key is
sck_+ 32 random bytes in base64url: a fixed 47 characters, all entropy after the prefix, matchingsck_[A-Za-z0-9_-]{43}, which is what gets registered with secret scanners (#561). It is shown once and never stored.The record (
service-account-keystable) is keyed by the key's hex SHA-256, with a publickey_id(a UUID) for listing, revoking and changing expiry, a label, who issued it and when, an optional expiry,revoked_atandlast_used_at.publicKey()strips the hash before any record reaches a client component.The hint: the record also keeps the key's last four characters, and the key list shows each key as
sck_…Xy9Q, so someone holding a key can tell which record, and so which service account, it is. Four characters are 24 of the key's 256 random bits, leaving far too many to guess. The hint only confirms a key in hand against its record; it is never used to look a key up, since keys across the platform will share it. The issue dialog says how the new key will be listed. Keys issued before the hint existed are listed without one.Issuing (
issueApiKey): whoever manages the service account gives a label and an expiry (30/90/365 days, or never). The action generates the key, writes the record and returns the key once. A disabled service account is refused. Revoking setsrevoked_at; expiry can be changed after issuance, including to never.Exchanging:
POST /api/v1/service-account-keys/exchangeswith{key_hash}is what the proxy calls at/.stswhen a key is presented, authenticated as the proxy itself (verifyProxyAssertion, sentinel subjecturn:source:data-proxy, recorded in the ADR-005 amendment in source-cooperative/data.source.coop#234). It always answers 200 with{account_id, key_id, active}.activemeans known, not revoked, not expired, and its service account not disabled; an unknown hash is answered as inactive, indistinguishable from a revoked one. It records last use. The proxy caches the answer for 60s, so revocation takes effect for new exchanges within that time; credentials already issued live to their session cap.Resolving the subject: after an exchange, the proxy's credentials name the service account itself, and
authenticateWithOidcTokenresolves it withfetchByOryId, then a service account by id. No service account's id can be someone's Ory identity id: #567 namespaces it as{owner}--{id}, and a UUID never contains--.UI: the service account's page (#567's
ServiceAccountDetail) has an API keys section, rendered byApiKeyList, a row per key. On the left, the label with a Revoked or Expired marker, and the key's hint beneath. On the right, two short lines: how it has been used ("Used 3 days ago", "Never used") and when it ends ("Expires in 5 months", "Never expires", "Revoked 9 months ago"). The exact dates, and who issued the key and when, are in their tooltip. Change expiry and Revoke are in a "⋯" menu; a revoked key has none, and keeps an invisible copy of the button so its lines align. The section header carriesIssueApiKeyDialog, which shows the key once with a copy button, and the environment variables that point any AWS SDK or the AWS CLI at the proxy. The list row counts live keys as a way to sign in.Every state a key can be in, from
ApiKeyList's Default story:Stories
ApiKeyList› Default (every key state), Single, WithoutHint, Empty — https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeylist--default (dates are set relative to today; hover a row's dates for the exact ones)ServiceAccountDetail› Default — https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountdetail--defaultServiceAccountList› Default — https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-serviceaccountlist--defaultIssueApiKeyDialog› Default — https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-issueapikeydialog--default (submitting reaches the show-once view with the hint line)ApiKeyExpiryField— https://source-coop-ui-git-feat-service-account-keys-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeyexpiryfield--defaultTesting
npx jest— 79 suites, 835 tests, all pass on the branch as rebased ontomain.service-account-keys.test.tscovers issuing (the record holds the hash, never the key; the hint is the key's last four characters; the key is returned once), no-expiry keys, refusals before any write, revoking only own keys, and expiry changes including to never. The exchanges route test covers the proxy-only auth, active and inactive answers, and last-use recording.npm run type-check,next lint,npm run build-storybook— clean.Docs and ADRs
data.source.coop: this implements ADR-013 as revised in source-cooperative/data.source.coop#234, with ADR-014 from source-cooperative/data.source.coop#232; the proxy side is source-cooperative/data.source.coop#235, which supersedes source-cooperative/data.source.coop#233. The hint is a display detail the ADR doesn't need. docs.source.coop: the unattended-workflow guide, source-cooperative/docs.source.coop#37 for source-cooperative/docs.source.coop#34, should mention matching a key to its account by its last four characters.
🤖 Generated with Claude Code