Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 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;
9 changes: 9 additions & 0 deletions 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 Down Expand Up @@ -269,6 +270,14 @@ const api = {
}).json<RotateApiKeyApiResponse>(),
});
},
useRotatePersonalAccessKey() {
return useMutation<RotateAccessKeyApiResponse, Error | HTTPError>({
mutationFn: async () =>
hdxServer(`me/accessKey`, {
method: 'PATCH',
}).json<RotateAccessKeyApiResponse>(),
});
},
useDeleteTeamMember() {
return useMutation<
{ message: string },
Expand Down
Loading
Loading