Repository navigation
feat: team-owned API keys - #3435
jordan-simonovski wants to merge 7 commits into
Conversation
Admins can create API keys owned by the team rather than a person, so Terraform, CI and other automation keep working when someone leaves. Each key has a description, an admin or member scope, and a 30 day, 1 year or no expiry. Keys are shown once, stored hashed, and accepted by the external API v2 and MCP. Member-scoped keys can't manage team members or invitations. Closes HDX-5784
🦋 Changeset detectedLatest commit: 12fa9fd The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🔴 Tier 4 — CriticalTouches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI. Why this tier:
Additional context: touches background tasks or the delivery pipeline lightly (3 lines, under the 30-line bar for Tier 4) Review process: Deep review from a domain expert. Synchronous walkthrough may be required. Stats
|
Duplicate code analysisNo new duplicate code introduced by this PR. jscpd compared this branch against |
E2E Test Results✅ All tests passed • 459 passed • 1 skipped • 1740s
Tests ran across 4 shards in parallel. |
| }; | ||
|
|
||
| return ( | ||
| <Modal opened={opened} onClose={close} title="Create API key" centered> |
There was a problem hiding this comment.
🔵 minor — Closing the modal by clicking outside it or pressing Escape after creation discards the only copy of the key
Once createdKey is set, the Modal's onClose={close} still fires on an overlay click or Escape. That clears createdKey, and the plaintext can never be fetched again. Fix: pass closeOnClickOutside={createdKey == null} and closeOnEscape={createdKey == null} (or hide the close button) so only "Done" dismisses the reveal step.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| return ( | ||
| <Table.Tr> | ||
| <Table.Td> | ||
| <InlineNameInput |
There was a problem hiding this comment.
🔵 minor — A failed description rename silently reverts with no error, and the 100-char limit isn't enforced in the field
The server rejects descriptions over 100 chars (OrgApiKeyDescriptionSchema). InlineNameInput (packages/app/src/components/InlineNameInput/InlineNameInput.tsx:141) swallows the rejection and just reverts the field. The user's edit vanishes with no message. Fix: pass maxLength={100} to InlineNameInput, and attach an onError notification to the update mutation, as the revoke path already does.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| </Table.Td> | ||
| <Table.Td> | ||
| {isExpired ? ( | ||
| <Badge color="red" variant="light"> |
There was a problem hiding this comment.
🔵 minor — The Expired badge uses the raw Mantine palette color="red" variant="light"
This breaks the "Semantic design tokens (prefer over raw Mantine colors)" convention. Fix: style the status with the danger tokens (e.g. var(--color-text-danger) / --color-bg-danger-subtle) or use <Text variant="danger">Expired</Text>.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| expect(await lastUsed()).toBe(t0.getTime()); | ||
|
|
||
| const fresh = (await OrgApiKey.findById(doc._id))!; | ||
| touchOrgApiKeyLastUsed(fresh, new Date(t0.getTime() + 60_000)); |
There was a problem hiding this comment.
🔵 minor — The lastUsedAt throttle test never exercises the DB-side conditional filter that is meant to guard concurrent writes
The second call passes fresh with lastUsedAt = t0, so touchOrgApiKeyLastUsed returns early in JS and never calls updateOne. The test would still pass if the $or: [{lastUsedAt: null}, {lastUsedAt: {$lt: staleBefore}}] filter were removed. Fix: add a case that passes a stale in-memory doc (e.g. the original doc with lastUsedAt: null) after a recent write, and assert that the stored value doesn't change.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
PR Review
5 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 4 minor 5 posted as inline comment(s) on the changed lines. Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides. |
[High impact] The PR appears safe to merge; no new blocking issue was found. SummaryAdds team-owned API keys for automation, backed by separate service accounts.
|
Deep ReviewScope: 24 files (+1777/-33) vs ✅ No critical issues found. Security (key entropy, hashing, team isolation, expiry/revocation, session exclusion) and the previously-flagged bugs all verified clean. 🟡 P2 — recommended
🔵 P3 nitpicks (9)
Reviewers (11): correctness, security, adversarial, reliability, api-contract, kieran-typescript, frontend-races, testing, maintainability, project-standards, previous-comments. Testing gaps:
No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond |
- An org key removing members concurrently can no longer empty the team. A removal that leaves it empty puts the member back, credentials intact. - Bearer auth responds with an error on a database failure instead of leaving the request hanging. - Express.User no longer claims _id and email exist on org-key requests; session-only routes get them through getSessionUser. - Failed last-used writes are counted and recorded on the span. - The Expired badge updates while the page is open, failed description edits show an error, and the create dialog can't close mid-request.
|
Deep Review P2s fixed in 256d5e9:
The P3s that touch these lines are also fixed: the last-member race, and a 404 rather than 400 for a non-member. I've left the other P3s alone. |
Each key now creates a service-account user and requests made with the key authenticate as that user, so everything downstream treats it like any other user and work done with a key is attributed to it. Service accounts can't sign in, don't use a personal access key, and don't appear as team members. Revoking a key keeps its service account so attribution survives. This replaces the team-only principal, so the special handling for requests with no user goes back to how main has it.
An org key's service account now checks that the target is a person in the team and that someone else would remain before deleting anyone. The delete then restore fallback only runs when two removals race, retries a failed restore, and records a lost member on the trace.
|
Deep Review P2s on c28468e:
|
| }, | ||
| }); | ||
| }, | ||
| useOrgApiKeys() { |
There was a problem hiding this comment.
🔵 minor — useOrgApiKeys has no IS_LOCAL_MODE guard, so local mode always shows a load error
useMe, useTeam, useInstallation and useTags in this file all skip the request when IS_LOCAL_MODE is set. useOrgApiKeys doesn't, and OrgApiKeysSection is mounted with no local-mode check in TeamPage.tsx:171. In local mode (no API, including the Vercel preview the PR description mentions) the section always fetches team/api-keys, fails, shows "Couldn't load organization API keys" and offers a Create button that can't work. Fix: short-circuit the query in local mode (or gate the section on !IS_LOCAL_MODE) and hide the section there.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| </Table.Td> | ||
| <Table.Td> | ||
| {isExpired ? ( | ||
| <Badge color="red" variant="light"> |
There was a problem hiding this comment.
🔵 minor — Expired badge uses a raw Mantine palette color instead of the themed badge used elsewhere
The conventions say to prefer semantic tokens or click-ui primitives (Badge is listed) over raw Mantine colors. The sibling sections TeamMembersSection.tsx and IacMigrationSection.tsx use click-ui <Badge text=… state=…>. Fix: render the Expired badge as <Badge text="Expired" state="danger" /> from @clickhouse/click-ui instead of <Badge color="red" variant="light">.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| _hdx_connection?: Connection; | ||
| // Set when the bearer token is an org API key; req.user is its service | ||
| // account. | ||
| orgApiKey?: { id: string; description: string; scope: OrgApiKeyScope }; |
There was a problem hiding this comment.
🔵 minor — req.orgApiKey.description is set on every request but never read
Grepping packages/api/src finds req.orgApiKey read only for .scope (utils/orgApiKeyPolicy.ts:11) and .id (the business-context attributes). description is assigned in authenticateOrgApiKey but nothing reads it. Fix: drop description from the global Request.orgApiKey type and from the assignment.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
The team opted out of RBAC in this change, so a key's service account has the same permissions as any other user. Removes the admin/member scope, the admin-only gates on the external API team routes, and the policy module. Key management stays session-only and refuses service accounts, and a key still can't remove the last person in the team.
| // Express 4 drops rejected promises from middleware, which would leave the | ||
| // request hanging on a database error. | ||
| try { | ||
| const user = await findUserByAccessKey(key); |
There was a problem hiding this comment.
🔵 minor — Every org-key request makes a wasted personal-key lookup before the hash lookup
validateUserAccessKey always runs findUserByAccessKey(key) first, so every hdx_org_… bearer request does an uncached User.findOne({accessKey}) that can never match before it reaches findActiveOrgApiKey. That doubles the Mongo round-trips on the v2 and MCP hot path for org keys. Check the prefix first: if (key.startsWith(ORG_API_KEY_PREFIX)) return authenticateOrgApiKey(...), and only then fall back to findUserByAccessKey.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| const RESTORE_ATTEMPTS = 3; | ||
| const RESTORE_RETRY_DELAY_MS = 100; | ||
|
|
||
| const isDuplicateKey = (err: unknown): err is { keyPattern?: object } => |
There was a problem hiding this comment.
🔵 minor — isDuplicateKey re-implements the existing isDuplicateKeyError helper
packages/api/src/utils/errors.ts:62 already exports isDuplicateKeyError (Mongo code 11000). Import it and check keyPattern on the narrowed error, instead of adding a second local 11000 type guard. The repo's DRY rule is marked REQUIRED.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
|
|
||
| import CreateOrgApiKeyModal from './CreateOrgApiKeyModal'; | ||
|
|
||
| const formatDate = (iso: string) => new Date(iso).toLocaleDateString(); |
There was a problem hiding this comment.
🔵 minor — Local formatDate ignores the user's UTC and clock preferences
new Date(iso).toLocaleDateString() always renders in browser-local time. Every other timestamp goes through useFormatTime/FormatTime (packages/app/src/useFormatTime.tsx, backed by formatDate in common-utils), which applies the user's isUTC setting. With UTC turned on, the created, last used and expiry dates here can be off by a day compared with the rest of the app. Use useFormatTime() with a date format, or extend it with a date-only format, instead of adding a new helper.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| }); | ||
| } | ||
|
|
||
| // People can't remove themselves, so only a key can empty the team. |
There was a problem hiding this comment.
🟠 major — Last-member guard only covers keys, but people can also empty the team
The comment says "People can't remove themselves", but the session route DELETE /team/member/:id (packages/api/src/routers/api/team.ts:284-311) has no self check and no last-member check. A last member who sends their own id is deleted. Two members with personal keys can also remove each other at the same time through this route's non-service-account branch. Either way, no person is left in the team while the org keys keep working, and those keys can only be revoked from a logged-in session, so nobody can revoke them. Move the last-member check into deleteTeamMember so every removal path gets it, or at least add it to the session route, and fix the comment.
Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond
| // Express 4 drops rejected promises from middleware, which would leave the | ||
| // request hanging on a database error. | ||
| try { | ||
| const user = await findUserByAccessKey(key); |
There was a problem hiding this comment.
🔵 minor — Org API keys pay for a wasted personal-access-key lookup on every request
Every hdx_org_ bearer token first runs findUserByAccessKey, which can never match it, before authenticateOrgApiKey runs. That adds a second uncached Mongo round trip to every org-key request (controllers/user.ts:23 already notes this path is hit uncached on every bearer request). Check key.startsWith(ORG_API_KEY_PREFIX) first and go straight to authenticateOrgApiKey when it matches.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| const RESTORE_ATTEMPTS = 3; | ||
| const RESTORE_RETRY_DELAY_MS = 100; | ||
|
|
||
| const isDuplicateKey = (err: unknown): err is { keyPattern?: object } => |
There was a problem hiding this comment.
🔵 minor — isDuplicateKey re-implements the existing isDuplicateKeyError
isDuplicateKeyError in packages/api/src/utils/errors.ts:62 already detects Mongo error code 11000 and is used by the webhook and source routes. Import it, or extend it to narrow the type to { keyPattern?: object }, instead of adding a second copy.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| <Table.Td> | ||
| {isExpired ? ( | ||
| <Badge color="red" variant="light"> | ||
| Expired |
There was a problem hiding this comment.
🔵 minor — Expired badge uses a raw palette color instead of a semantic token
<Badge color="red" variant="light"> uses the raw Mantine palette for status, which the 'Semantic design tokens' convention rules out. Use a semantic danger token, or <Text variant="danger">Expired</Text>, so the badge follows the brand and light/dark theme.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| }; | ||
|
|
||
| return ( | ||
| <Modal opened={opened} onClose={close} title="Create API key" centered> |
There was a problem hiding this comment.
🔵 minor — One stray click outside the modal throws away the key that is only shown once
While the new key is on screen, the Modal still closes on outside click and Escape. close() then clears createdKey, and the key can never be shown again. Pass closeOnClickOutside={createdKey == null} (and the same for closeOnEscape) so only the Done button dismisses the key.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Summary
Team members can create API keys owned by the team instead of a person, so Terraform, CI and other automation keep working when the person who set them up leaves. Today the only bearer key is each user's personal access key, which stops working when that user is removed. Teams work around that with bot users.
Implementation detail
UserwithisServiceAccount: true, a synthetic@service-accounts.hyperdx.invalidemail and the description as its name, linked from theOrgApiKeyrecord that holds the hash, expiry and last-used time. Bearer auth resolves the key to that user, so every handler gets a normal user.GET /api/v2/leaves out their unused personal access key.OrgApiKeyand keeps the service account, so anything it created still shows who made it. Editing a key's description renames the service account too.lastUsedAtis written at most once every 5 minutes per key, without waiting on the write.hyperdx.org_api_key.auth_failures,hyperdx.org_api_key.operations(by operation),hyperdx.org_api_key.last_used_write_failuresandhyperdx.team.member_restore_failures.Tested with integration tests covering key create, list, edit and revoke, team isolation, expiry, last use, a deleted creator, service-account sign-in and personal-key rejection, member removal, and the external API and MCP with an org key. The Team Settings section has unit tests.
Screenshots or video
How to reproduce
Starting point:
yarn dev(this needs the API).On
main:On this branch:
curl -H "Authorization: Bearer <key>" localhost:<api port>/api/v2/dashboardsand confirm it returns 200.How to test on Vercel preview
N/A: the Vercel preview runs in local mode without the API, where Team Settings keys aren't available.
References