Skip to content

Commit 105aff4

Browse files
Merge branch 'main' into brandon/brandon-improve-tool-attributes
2 parents 092d0b8 + 0b7926d commit 105aff4

25 files changed

Lines changed: 1784 additions & 56 deletions

File tree

‎.changeset/org-api-keys.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@hyperdx/api': minor
3+
'@hyperdx/app': minor
4+
'@hyperdx/common-utils': minor
5+
---
6+
7+
feat: Team members can create team-owned API keys with a description and expiry that keep working after their creator leaves.

‎packages/api/openapi.json‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8937,6 +8937,9 @@
89378937
},
89388938
"400": {
89398939
"description": "User already exists"
8940+
},
8941+
"403": {
8942+
"description": "Org API keys can't invite people"
89408943
}
89418944
}
89428945
}
@@ -8951,7 +8954,7 @@
89518954
],
89528955
"responses": {
89538956
"200": {
8954-
"description": "Successfully retrieved invitations"
8957+
"description": "Successfully retrieved invitations. Invite links are left out for org API keys."
89558958
}
89568959
}
89578960
}
@@ -9007,7 +9010,7 @@
90079010
"description": "Successfully removed the member"
90089011
},
90099012
"400": {
9010-
"description": "Cannot remove yourself from the team"
9013+
"description": "Cannot remove yourself or the last team member"
90119014
},
90129015
"404": {
90139016
"description": "Member not found"
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import mongoose from 'mongoose';
2+
3+
import {
4+
createOrgApiKey,
5+
listOrgApiKeys,
6+
revokeOrgApiKey,
7+
touchOrgApiKeyLastUsed,
8+
updateOrgApiKeyDescription,
9+
} from '@/controllers/orgApiKey';
10+
import { createTeam } from '@/controllers/team';
11+
import {
12+
createVirtualUser,
13+
deleteTeamMember,
14+
findUserByAccessKey,
15+
findUsersByTeam,
16+
hashOrgApiKey,
17+
} from '@/controllers/user';
18+
import { clearDBCollections, closeDB, connectDB } from '@/fixtures';
19+
import User from '@/models/user';
20+
21+
const DAY_MS = 24 * 60 * 60 * 1000;
22+
23+
describe('orgApiKey controller', () => {
24+
const creator = new mongoose.Types.ObjectId();
25+
26+
beforeAll(async () => {
27+
await connectDB();
28+
});
29+
30+
afterEach(async () => {
31+
await clearDBCollections();
32+
});
33+
34+
afterAll(async () => {
35+
await closeDB();
36+
});
37+
38+
const create = async (
39+
teamId: mongoose.Types.ObjectId,
40+
expiry: '30d' | '1y' | 'never' = '30d',
41+
) =>
42+
createOrgApiKey(teamId, creator, {
43+
description: 'terraform',
44+
expiry,
45+
});
46+
47+
it('returns a prefixed key once and stores only its hash', async () => {
48+
const team = await createTeam({ name: 'A' });
49+
const { doc, key } = await create(team._id);
50+
51+
expect(key).toMatch(/^hdx_org_[A-Za-z0-9_-]{43}$/);
52+
const stored = await User.findById(doc._id).lean();
53+
expect(stored?.accessKey).toBe(hashOrgApiKey(key));
54+
expect(Object.values(stored ?? {})).not.toContain(key);
55+
});
56+
57+
it.each([
58+
['30d', 30 * DAY_MS],
59+
['1y', 365 * DAY_MS],
60+
] as const)('sets expiresAt for %s', async (expiry, offset) => {
61+
const team = await createTeam({ name: 'A' });
62+
const before = Date.now();
63+
const { doc } = await create(team._id, expiry);
64+
const expiresAt = doc.expiresAt?.getTime() ?? 0;
65+
expect(Math.abs(expiresAt - (before + offset))).toBeLessThan(1000);
66+
});
67+
68+
it('stores null expiresAt for never', async () => {
69+
const team = await createTeam({ name: 'A' });
70+
const { doc } = await create(team._id, 'never');
71+
expect(doc.expiresAt).toBeNull();
72+
});
73+
74+
it('finds active keys and rejects expired, unknown, and non-org keys', async () => {
75+
const team = await createTeam({ name: 'A' });
76+
const { doc, key } = await create(team._id);
77+
78+
expect((await findUserByAccessKey(key))?._id).toEqual(doc._id);
79+
expect(
80+
await findUserByAccessKey(key, doc.expiresAt ?? new Date(0)),
81+
).toBeNull();
82+
expect(await findUserByAccessKey(`hdx_org_${'x'.repeat(43)}`)).toBeNull();
83+
expect(
84+
await findUserByAccessKey('4b9e3c1a-7f2d-4e8b-9a6c-1d2e3f4a5b6c'),
85+
).toBeNull();
86+
// The stored hash is not a credential.
87+
expect(await findUserByAccessKey(hashOrgApiKey(key))).toBeNull();
88+
});
89+
90+
it('scopes list and delete to the team', async () => {
91+
const teamA = await createTeam({ name: 'A' });
92+
// OSS allows a single team, so team B only needs to be a distinct id.
93+
const teamB = new mongoose.Types.ObjectId();
94+
const { doc } = await create(teamA._id);
95+
96+
expect(await listOrgApiKeys(teamB)).toHaveLength(0);
97+
expect(await revokeOrgApiKey(teamB, doc._id.toString())).toBeNull();
98+
expect(await listOrgApiKeys(teamA._id)).toHaveLength(1);
99+
});
100+
101+
it('leaves virtual users that are not org keys alone', async () => {
102+
const team = await createTeam({ name: 'A' });
103+
const other = await createVirtualUser({
104+
teamId: team._id,
105+
name: 'actor',
106+
email: 'actor@example.com',
107+
});
108+
109+
expect(await listOrgApiKeys(team._id)).toHaveLength(0);
110+
expect(await revokeOrgApiKey(team._id, other._id.toString())).toBeNull();
111+
});
112+
113+
it('writes lastUsedAt at most once per 5 minutes', async () => {
114+
const team = await createTeam({ name: 'A' });
115+
const { doc } = await create(team._id);
116+
const t0 = new Date();
117+
118+
const lastUsed = async () => {
119+
// Writes are fire-and-forget; give them a moment to land.
120+
await new Promise(r => setTimeout(r, 100));
121+
return (await User.findById(doc._id))?.lastUsedAt?.getTime();
122+
};
123+
124+
touchOrgApiKeyLastUsed(doc, t0);
125+
expect(await lastUsed()).toBe(t0.getTime());
126+
127+
const fresh = (await User.findById(doc._id))!;
128+
touchOrgApiKeyLastUsed(fresh, new Date(t0.getTime() + 60_000));
129+
expect(await lastUsed()).toBe(t0.getTime());
130+
131+
// A request holding a stale copy passes the in-memory check, so only the
132+
// conditional filter stops it overwriting a recent write.
133+
touchOrgApiKeyLastUsed(
134+
{ _id: doc._id, lastUsedAt: null },
135+
new Date(t0.getTime() + 60_000),
136+
);
137+
expect(await lastUsed()).toBe(t0.getTime());
138+
139+
const t6 = new Date(t0.getTime() + 6 * 60_000);
140+
touchOrgApiKeyLastUsed(fresh, t6);
141+
expect(await lastUsed()).toBe(t6.getTime());
142+
});
143+
144+
describe('service accounts', () => {
145+
it('creates a service account user for each key', async () => {
146+
const team = await createTeam({ name: 'A' });
147+
const { doc } = await create(team._id);
148+
const account = await User.findById(doc._id);
149+
150+
expect(account).toMatchObject({
151+
isVirtual: true,
152+
name: 'terraform',
153+
});
154+
expect(account?.team).toEqual(team._id);
155+
expect(account?.email).toMatch(
156+
/^sa-[0-9a-f]{24}@service-accounts\.hyperdx\.invalid$/,
157+
);
158+
});
159+
160+
it('keeps service accounts out of members', async () => {
161+
const team = await createTeam({ name: 'A' });
162+
await create(team._id);
163+
expect(await findUsersByTeam(team._id)).toHaveLength(0);
164+
});
165+
166+
it('renames the service account with the key', async () => {
167+
const team = await createTeam({ name: 'A' });
168+
const { doc } = await create(team._id);
169+
await updateOrgApiKeyDescription(team._id, doc._id.toString(), 'ci');
170+
expect((await User.findById(doc._id))?.name).toBe('ci');
171+
});
172+
173+
it('keeps the service account when the key is revoked', async () => {
174+
const team = await createTeam({ name: 'A' });
175+
const { doc, key } = await create(team._id);
176+
await revokeOrgApiKey(team._id, doc._id.toString());
177+
expect(await findUserByAccessKey(key)).toBeNull();
178+
expect(await listOrgApiKeys(team._id)).toHaveLength(0);
179+
expect(await User.findById(doc._id)).not.toBeNull();
180+
});
181+
});
182+
183+
describe('member removal by a service account', () => {
184+
const setupTeam = async (people: number) => {
185+
const team = await createTeam({ name: 'A' });
186+
const { doc } = await create(team._id);
187+
const users = await Promise.all(
188+
Array.from({ length: people }, (_, i) =>
189+
User.create({ email: `p${i}@example.com`, team: team._id }),
190+
),
191+
);
192+
return { team, serviceAccount: doc._id, users };
193+
};
194+
195+
afterEach(() => jest.restoreAllMocks());
196+
197+
it('refuses the last person without deleting them', async () => {
198+
const { team, serviceAccount, users } = await setupTeam(1);
199+
const deleteSpy = jest.spyOn(User, 'findOneAndDelete');
200+
201+
const result = await deleteTeamMember(
202+
team._id,
203+
users[0]._id.toString(),
204+
serviceAccount,
205+
);
206+
expect(result).toBe('last_member');
207+
expect(deleteSpy).not.toHaveBeenCalled();
208+
});
209+
210+
it('retries putting a member back when the first insert fails', async () => {
211+
const { team, serviceAccount, users } = await setupTeam(2);
212+
// Simulate a concurrent removal: two people before the delete, none after.
213+
// Widened to SpyInstance: the Mongoose overloads type these mocks as never.
214+
const count: jest.SpyInstance = jest.spyOn(User, 'countDocuments');
215+
count.mockResolvedValueOnce(2).mockResolvedValueOnce(0);
216+
const insert: jest.SpyInstance = jest.spyOn(User.collection, 'insertOne');
217+
insert.mockRejectedValueOnce(new Error('transient'));
218+
219+
const result = await deleteTeamMember(
220+
team._id,
221+
users[0]._id.toString(),
222+
serviceAccount,
223+
);
224+
expect(result).toBe('last_member');
225+
expect(await User.findById(users[0]._id)).not.toBeNull();
226+
});
227+
228+
it('treats a duplicate _id on restore as already restored', async () => {
229+
const { team, serviceAccount, users } = await setupTeam(2);
230+
const count: jest.SpyInstance = jest.spyOn(User, 'countDocuments');
231+
count.mockResolvedValueOnce(2).mockResolvedValueOnce(0);
232+
const insert: jest.SpyInstance = jest.spyOn(User.collection, 'insertOne');
233+
insert.mockRejectedValueOnce(
234+
Object.assign(new Error('E11000'), {
235+
code: 11000,
236+
keyPattern: { _id: 1 },
237+
}),
238+
);
239+
240+
const result = await deleteTeamMember(
241+
team._id,
242+
users[0]._id.toString(),
243+
serviceAccount,
244+
);
245+
expect(result).toBe('last_member');
246+
expect(insert).toHaveBeenCalledTimes(1);
247+
});
248+
});
249+
});

0 commit comments

Comments
 (0)