Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/rotatable-personal-access-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@hyperdx/api': patch
'@hyperdx/app': patch
'@hyperdx/common-utils': patch
---

Add a Rotate action for the personal API access key in Team Settings → API & Agents. Previously the personal access key — the bearer token for the external API v2 and the MCP server — was generated once at account creation and could never be changed, so a leaked key could only be remediated by deleting the user. Rotating immediately revokes the previous key, so MCP / AI agent configs, external API v2 clients, Terraform / IaC providers, and CI scripts using the old key must be updated with the new one. Browser sessions are unaffected.
12 changes: 9 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,12 @@ directory:
- `agent_docs/architecture.md` - Detailed architecture patterns and data models
- `agent_docs/tech_stack.md` - Technology stack details and component patterns
- `agent_docs/development.md` - Development workflows, testing, and common tasks
- `agent_docs/code_style.md` - Code patterns and best practices (read only when
actively coding)
- `agent_docs/code_style.md` - Code patterns and best practices. **Read this
before writing or planning any `packages/app` UI change**, not just while
typing code. It carries required patterns that are invisible from the
surrounding file (sentence-case UI text, mandated Button/ActionIcon variants,
`useConfirm` for confirmation dialogs, `EmptyState`), so copying the
conventions of the component you are editing is not sufficient.
- `agent_docs/observability.md` - Instrumentation standards (tracing, metrics,
context) and the shared helpers (read when adding or changing a feature)

Expand All @@ -87,7 +91,9 @@ before stopping.
similar files before implementing
4. **Component size**: Keep files under 300 lines; break down large components
5. **UI Components**: Use custom Button/ActionIcon variants (`primary`,
`secondary`, `danger`) - see `agent_docs/code_style.md` for required patterns
`secondary`, `danger`), `useConfirm` for "are you sure?" dialogs rather than
a hand-rolled `Modal`, and sentence case for all user-facing text - see
`agent_docs/code_style.md` for required patterns
6. **Testing**: Tests live in `__tests__/` directories; use Jest for
unit/integration tests
7. **Observability**: This is an observability product - instrument new code as
Expand Down
4 changes: 2 additions & 2 deletions MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ data sources directly.

- A running HyperDX instance (see [CONTRIBUTING.md](/CONTRIBUTING.md) for local
development setup, or [DEPLOY.md](/DEPLOY.md) for self-hosted deployment)
- A **Personal API Access Key** — find yours in the HyperDX UI under **Team
Settings > API Keys > Personal API Access Key**
- A **Personal API access key** — find yours in the HyperDX UI under **Team
Settings > API keys > Personal API access key**

> **Note:** HyperDX v1 ([hyperdx.io](https://hyperdx.io)) does not yet support
> the MCP server. The documentation below applies to self-hosted HyperDX v2
Expand Down
50 changes: 50 additions & 0 deletions agent_docs/code_style.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,56 @@ The variant → token mapping is centralized in `packages/app/src/theme/themes/s

**Note**: Existing `<Alert color="...">` call sites are untouched; the semantic variants are opt-in. Prefer the variant for any **new** callout, and migrate nearby `color="..."` alerts when you touch them.

### Confirmation dialogs: use `useConfirm` (REQUIRED)

**Use `useConfirm` (`@/useConfirm`) for any "are you sure?" step. Do not
hand-roll a `<Modal>` with Cancel/Confirm buttons.** The provider is already
mounted app-wide in `pages/_app.tsx`, so there is no setup at the call site.

```tsx
const confirm = useConfirm();

const handleDelete = async () => {
if (
await confirm(
<>
Deleting {name} is <b>not reversible</b>.
</>,
'Delete',
{ variant: 'danger' },
)
) {
await deleteThing.mutateAsync({ id });
}
};
```

- The message is a `ReactNode`, so it can carry emphasis and multiple sentences.
- Pass `{ variant: 'danger' }` for destructive actions; the confirm label
defaults to `Confirm`.
- It resolves **exactly once**, so a double click on Confirm during the modal's
exit transition cannot fire the action twice. A hand-rolled modal has to guard
that itself.
- Test ids are shared and already exist: `confirm-modal`,
`confirm-confirm-button`, `confirm-cancel-button`. **Do not invent per-flow
confirm/cancel test ids** — E2E page objects key off the shared ones.

**Known limits.** It passes no `title` to the Modal and renders the body at
`size="sm" opacity={0.7}`, and CSS opacity applies to the whole subtree so a
nested `<Text>` cannot opt back out. If a flow genuinely needs a heading or
full-contrast body, **extend `useConfirm`** (an optional prop, applied to all
call sites) rather than forking a one-off modal.

**In component tests**, mock it — `ConfirmProvider` pulls in `next/router`,
which is not available in jsdom:

```tsx
jest.mock('@/useConfirm', () => ({ useConfirm: jest.fn() }));
```

Assert on the arguments (and render the message `ReactNode` if you need to check
the copy). Exercise the real dialog in E2E instead.

### EmptyState Component (REQUIRED)

**Use `EmptyState` (`@/components/EmptyState`) for all empty/no-data states.** Do not create ad-hoc inline empty states.
Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/controllers/user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';

import type { ObjectId } from '@/models';
import Alert from '@/models/alert';
Expand All @@ -7,6 +8,17 @@ export function findUserByAccessKey(accessKey: string) {
return User.findOne({ accessKey });
}

/**
* Rotates a user's personal access key, immediately revoking the previous one.
*
* There is exactly one key per user and no grace period: findUserByAccessKey
* above is hit uncached on every bearer request (see validateUserAccessKey), so
* requests presenting the old key start 401ing the instant this returns.
*/
export function rotateUserAccessKey(userId: string | ObjectId) {
return User.findByIdAndUpdate(userId, { accessKey: uuidv4() }, { new: true });
}

export function findUserById(id: string) {
return User.findById(id);
}
Expand Down
105 changes: 105 additions & 0 deletions packages/api/src/routers/api/__tests__/me.int.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { getAgent, getLoggedInAgent, getServer } from '@/fixtures';
import User from '@/models/user';

describe('me router', () => {
const server = getServer();

beforeAll(async () => {
await server.start();
});

afterEach(async () => {
await server.clearDBs();
});

afterAll(async () => {
await server.stop();
});

describe('GET /me', () => {
it('returns the calling user', async () => {
const { agent, team, user } = await getLoggedInAgent(server);

const resp = await agent.get('/me').expect(200);

expect(resp.body.id).toEqual(user._id.toString());
expect(resp.body.email).toEqual('fake@deploysentinel.com');
expect(resp.body.accessKey).toEqual(user.accessKey);
expect(resp.body.team.id).toEqual(team._id.toString());
});

it('rejects an unauthenticated request', async () => {
await getAgent(server).get('/me').expect(401);
});
});

describe('PATCH /me/accessKey', () => {
it('rejects an unauthenticated request', async () => {
// The new verb is covered by the mount-time isUserAuthenticated in
// api-app.ts, not by anything in the handler itself.
await getAgent(server).patch('/me/accessKey').expect(401);
});

it('returns a new key and persists it', async () => {
const { agent, user } = await getLoggedInAgent(server);

const resp = await agent.patch('/me/accessKey').expect(200);

expect(resp.body.newAccessKey).toEqual(expect.any(String));
expect(resp.body.newAccessKey).not.toEqual(user.accessKey);
expect((await User.findById(user._id))?.accessKey).toEqual(
resp.body.newAccessKey,
);
});

it('revokes the old key and accepts the new one', async () => {
const { agent, user } = await getLoggedInAgent(server);
const oldAccessKey = user.accessKey;

// GET /api/v2 is the bearer-authed surface with no rate limiter attached,
// so three sequential calls here are safe.
await agent
.get('/api/v2')
.set('Authorization', `Bearer ${oldAccessKey}`)
.expect(200);

const { body } = await agent.patch('/me/accessKey').expect(200);

await agent
.get('/api/v2')
.set('Authorization', `Bearer ${oldAccessKey}`)
.expect(401);
await agent
.get('/api/v2')
.set('Authorization', `Bearer ${body.newAccessKey}`)
.expect(200);
});

it('does not sign the user out of their browser session', async () => {
// Session auth never reads accessKey (see isUserAuthenticated), so
// rotating must leave the cookie session intact.
const { agent } = await getLoggedInAgent(server);

const { body } = await agent.patch('/me/accessKey').expect(200);

const resp = await agent.get('/me').expect(200);
expect(resp.body.accessKey).toEqual(body.newAccessKey);
});

it("does not touch another user's key", async () => {
const { agent, user } = await getLoggedInAgent(server);
// Created directly rather than via /register/password, which is gated to
// the first user. We only read the schema-defaulted accessKey off it.
const other = await User.create({
email: 'other@deploysentinel.com',
team: user.team,
});

await agent.patch('/me/accessKey').expect(200);

expect((await User.findById(other._id))?.accessKey).toEqual(
other.accessKey,
);
});
});
});
33 changes: 32 additions & 1 deletion packages/api/src/routers/api/me.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import type { MeApiResponse } from '@hyperdx/common-utils/dist/types';
import type {
MeApiResponse,
RotateAccessKeyApiResponse,
} from '@hyperdx/common-utils/dist/types';
import express from 'express';

import { AI_API_KEY, ANTHROPIC_API_KEY, USAGE_STATS_ENABLED } from '@/config';
import { getTeam } from '@/controllers/team';
import { rotateUserAccessKey } from '@/controllers/user';
import { Api404Error } from '@/utils/errors';
import { sendJson } from '@/utils/serialization';

Expand Down Expand Up @@ -43,4 +47,31 @@ router.get('/', async (req, res: express.Response<MeApiResponse>, next) => {
}
});

type RotateAccessKeyExpRes = express.Response<RotateAccessKeyApiResponse>;

// Rotating your own personal access key. The user id comes from the session
// (isUserAuthenticated, applied at mount time in api-app.ts) and never from the
// request, so this route can only ever rotate the caller's own key.
//
// Deliberately NOT mirrored onto the bearer-authed external API v2: `GET /api/v2`
// echoes the caller's accessKey back, so a leaked key that could also rotate
// would let an attacker lock the legitimate owner out of their own tooling.
router.patch('/accessKey', async (req, res: RotateAccessKeyExpRes, next) => {
try {
const userId = req.user?._id;
if (userId == null) {
throw new Api404Error('Request without user found');
}

const user = await rotateUserAccessKey(userId);
if (user?.accessKey == null) {
throw new Error(`Failed to rotate access key for user ${userId}`);
}

return sendJson(res, { newAccessKey: user.accessKey });
} catch (e) {
next(e);
}
});

export default router;
26 changes: 25 additions & 1 deletion packages/app/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
MeApiResponse,
PresetDashboard,
PresetDashboardFilter,
RotateAccessKeyApiResponse,
RotateApiKeyApiResponse,
TeamApiResponse,
TeamClickHouseSettingsUpdate,
Expand All @@ -23,7 +24,12 @@ import type {
WebhookTestApiResponse,
WebhookUpdateApiResponse,
} from '@hyperdx/common-utils/dist/types';
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';

import { IS_LOCAL_MODE } from './config';
import { getLocalDashboardTags } from './dashboard';
Expand Down Expand Up @@ -269,6 +275,24 @@ const api = {
}).json<RotateApiKeyApiResponse>(),
});
},
useRotatePersonalAccessKey() {
const queryClient = useQueryClient();
return useMutation<RotateAccessKeyApiResponse, Error | HTTPError>({
mutationFn: async () =>
hdxServer(`me/accessKey`, {
method: 'PATCH',
}).json<RotateAccessKeyApiResponse>(),
// Seed the cache from the response rather than refetching `me`. The old
// key is already revoked by the time this runs, so a refetch that fails
// would leave every `useMe` consumer rendering a dead credential with no
// way to reach the new one short of a reload.
onSuccess: data => {
queryClient.setQueryData<MeApiResponse | null>(['me'], prev =>
prev == null ? prev : { ...prev, accessKey: data.newAccessKey },
);
},
});
},
useDeleteTeamMember() {
return useMutation<
{ message: string },
Expand Down
Loading
Loading