@tumbaland/backend-core 1.37.0 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/apiKeys/ApiKey.d.ts +23 -3
  2. package/dist/apiKeys/ApiKey.d.ts.map +1 -1
  3. package/dist/apiKeys/ApiKey.js +19 -2
  4. package/dist/apiKeys/ApiKey.js.map +1 -1
  5. package/dist/apiKeys/index.d.ts +2 -2
  6. package/dist/apiKeys/index.d.ts.map +1 -1
  7. package/dist/apiKeys/index.js +5 -1
  8. package/dist/apiKeys/index.js.map +1 -1
  9. package/dist/apiKeys/middleware.d.ts +17 -3
  10. package/dist/apiKeys/middleware.d.ts.map +1 -1
  11. package/dist/apiKeys/middleware.js +71 -34
  12. package/dist/apiKeys/middleware.js.map +1 -1
  13. package/dist/apiKeys/service.d.ts +5 -3
  14. package/dist/apiKeys/service.d.ts.map +1 -1
  15. package/dist/apiKeys/service.js +11 -3
  16. package/dist/apiKeys/service.js.map +1 -1
  17. package/dist/apiKeys/types.d.ts +43 -8
  18. package/dist/apiKeys/types.d.ts.map +1 -1
  19. package/dist/apiKeys/types.js +35 -1
  20. package/dist/apiKeys/types.js.map +1 -1
  21. package/dist/audit/AuditEvent.d.ts +51 -0
  22. package/dist/audit/AuditEvent.d.ts.map +1 -0
  23. package/dist/audit/AuditEvent.js +69 -0
  24. package/dist/audit/AuditEvent.js.map +1 -0
  25. package/dist/audit/actor.d.ts +41 -0
  26. package/dist/audit/actor.d.ts.map +1 -0
  27. package/dist/audit/actor.js +39 -0
  28. package/dist/audit/actor.js.map +1 -0
  29. package/dist/audit/context.d.ts +40 -0
  30. package/dist/audit/context.d.ts.map +1 -0
  31. package/dist/audit/context.js +60 -0
  32. package/dist/audit/context.js.map +1 -0
  33. package/dist/audit/index.d.ts +12 -0
  34. package/dist/audit/index.d.ts.map +1 -0
  35. package/dist/audit/index.js +22 -0
  36. package/dist/audit/index.js.map +1 -0
  37. package/dist/audit/plugin.d.ts +23 -0
  38. package/dist/audit/plugin.d.ts.map +1 -0
  39. package/dist/audit/plugin.js +226 -0
  40. package/dist/audit/plugin.js.map +1 -0
  41. package/dist/audit/reads.d.ts +47 -0
  42. package/dist/audit/reads.d.ts.map +1 -0
  43. package/dist/audit/reads.js +94 -0
  44. package/dist/audit/reads.js.map +1 -0
  45. package/dist/audit/service.d.ts +49 -0
  46. package/dist/audit/service.d.ts.map +1 -0
  47. package/dist/audit/service.js +65 -0
  48. package/dist/audit/service.js.map +1 -0
  49. package/dist/index.d.ts +1 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/oauth/models.d.ts +13 -2
  54. package/dist/oauth/models.d.ts.map +1 -1
  55. package/dist/oauth/models.js +19 -0
  56. package/dist/oauth/models.js.map +1 -1
  57. package/dist/oauth/service.d.ts +15 -8
  58. package/dist/oauth/service.d.ts.map +1 -1
  59. package/dist/oauth/service.js +10 -3
  60. package/dist/oauth/service.js.map +1 -1
  61. package/dist/oauth/tokens.d.ts +37 -5
  62. package/dist/oauth/tokens.d.ts.map +1 -1
  63. package/dist/oauth/tokens.js +9 -2
  64. package/dist/oauth/tokens.js.map +1 -1
  65. package/package.json +1 -1
  66. package/src/apiKeys/ApiKey.test.ts +25 -1
  67. package/src/apiKeys/ApiKey.ts +31 -4
  68. package/src/apiKeys/index.ts +16 -2
  69. package/src/apiKeys/middleware.test.ts +126 -20
  70. package/src/apiKeys/middleware.ts +99 -39
  71. package/src/apiKeys/service.test.ts +58 -9
  72. package/src/apiKeys/service.ts +25 -6
  73. package/src/apiKeys/types.ts +63 -8
  74. package/src/audit/AuditEvent.ts +79 -0
  75. package/src/audit/actor.test.ts +95 -0
  76. package/src/audit/actor.ts +68 -0
  77. package/src/audit/context.test.ts +91 -0
  78. package/src/audit/context.ts +83 -0
  79. package/src/audit/index.ts +11 -0
  80. package/src/audit/plugin.test.ts +258 -0
  81. package/src/audit/plugin.ts +254 -0
  82. package/src/audit/reads.test.ts +164 -0
  83. package/src/audit/reads.ts +88 -0
  84. package/src/audit/service.test.ts +115 -0
  85. package/src/audit/service.ts +92 -0
  86. package/src/index.ts +1 -0
  87. package/src/middleware/authMiddleware.test.ts +6 -1
  88. package/src/oauth/models.ts +32 -2
  89. package/src/oauth/service.test.ts +58 -6
  90. package/src/oauth/service.ts +25 -7
  91. package/src/oauth/tokens.test.ts +22 -5
  92. package/src/oauth/tokens.ts +52 -7
@@ -9,6 +9,7 @@ jest.mock('../logging/logger', () => ({
9
9
  import jwt from 'jsonwebtoken';
10
10
  import { verifyApiKey } from './service';
11
11
  import { mintAccessToken, type MintAccessTokenInput } from '../oauth/tokens';
12
+ import { PERSONAL_TENANT } from './types';
12
13
  import { authenticateAgent, requireScope, denyApiKeys } from './middleware';
13
14
 
14
15
  const mockedVerify = verifyApiKey as jest.Mock;
@@ -27,6 +28,9 @@ const req = (over: Partial<Request> = {}): Request =>
27
28
  const withKey = (token = 'tmb_live_abcdef123456_secretsecretsecret', over: Partial<Request> = {}) =>
28
29
  req({ headers: { authorization: `Bearer ${token}` }, ...over });
29
30
 
31
+ /** A grant of one or more tenants; the first is the default unless said otherwise. */
32
+ const grant = (...allowed: string[]) => ({ allowed, default: allowed[0] });
33
+
30
34
  const okVerification = (over: Record<string, unknown> = {}) => ({
31
35
  ok: true,
32
36
  userId: 'u1',
@@ -34,7 +38,8 @@ const okVerification = (over: Record<string, unknown> = {}) => ({
34
38
  userName: 'Tester',
35
39
  keyId: 'k1',
36
40
  scopes: ['relationship:read', 'relationship:write'],
37
- groupId: null,
41
+ tenants: grant(PERSONAL_TENANT),
42
+ label: 'Claude Code',
38
43
  ...over
39
44
  });
40
45
 
@@ -111,7 +116,12 @@ describe('authenticateAgent — API keys', () => {
111
116
  expect(request.apiKey).toEqual({
112
117
  keyId: 'k1',
113
118
  scopes: ['relationship:read', 'relationship:write'],
114
- groupId: null
119
+ tenants: grant(PERSONAL_TENANT),
120
+ actingAs: PERSONAL_TENANT,
121
+ kind: 'api_key',
122
+ // What the owner called it, so a trail reads "Claude Code" not an id.
123
+ label: 'Claude Code',
124
+ credentialId: 'k1'
115
125
  });
116
126
  });
117
127
 
@@ -167,7 +177,7 @@ it('authenticates a key stored before the owner snapshot existed', async () => {
167
177
  userId: 'u1',
168
178
  keyId: 'k1',
169
179
  scopes: ['relationship:read'],
170
- groupId: null
180
+ tenants: grant(PERSONAL_TENANT)
171
181
  });
172
182
  const request = withKey();
173
183
  const next = jest.fn();
@@ -179,7 +189,7 @@ it('authenticates a key stored before the owner snapshot existed', async () => {
179
189
  });
180
190
 
181
191
  it('treats a verification result with no scopes as granting nothing', async () => {
182
- mockedVerify.mockResolvedValue({ ok: true, userId: 'u1', keyId: 'k1', groupId: null });
192
+ mockedVerify.mockResolvedValue({ ok: true, userId: 'u1', keyId: 'k1', tenants: grant(PERSONAL_TENANT) });
183
193
  const res = mockRes();
184
194
  const next = jest.fn();
185
195
 
@@ -189,9 +199,14 @@ it('treats a verification result with no scopes as granting nothing', async () =
189
199
  expect(next).not.toHaveBeenCalled();
190
200
  });
191
201
 
192
- describe('authenticateAgent — tenant pinning', () => {
202
+ describe('authenticateAgent — a single-tenant grant', () => {
203
+ /**
204
+ * The behaviour a pinned credential always had, kept intact now that a grant
205
+ * is a set. An agent given one tenant should never learn that a tenant is a
206
+ * thing it could name — nothing to choose, nothing to get wrong.
207
+ */
193
208
  it('grants a personal key no groups at all', async () => {
194
- mockedVerify.mockResolvedValue(okVerification({ groupId: null }));
209
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant(PERSONAL_TENANT) }));
195
210
  const request = withKey();
196
211
 
197
212
  await authenticateAgent()(request, mockRes(), jest.fn());
@@ -200,7 +215,7 @@ describe('authenticateAgent — tenant pinning', () => {
200
215
  });
201
216
 
202
217
  it('grants a group key exactly its own group, not the owner’s whole membership', async () => {
203
- mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
218
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
204
219
  const request = withKey();
205
220
 
206
221
  await authenticateAgent()(request, mockRes(), jest.fn());
@@ -208,8 +223,8 @@ describe('authenticateAgent — tenant pinning', () => {
208
223
  expect(request.userGroups).toEqual(['g1']);
209
224
  });
210
225
 
211
- it('fills in the pinned group so the caller never has to know it', async () => {
212
- mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
226
+ it('fills in the group so the caller never has to know it', async () => {
227
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
213
228
  const request = withKey();
214
229
  const next = jest.fn();
215
230
 
@@ -221,7 +236,7 @@ describe('authenticateAgent — tenant pinning', () => {
221
236
  });
222
237
 
223
238
  it('refuses a query that names a different group', async () => {
224
- mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
239
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
225
240
  const res = mockRes();
226
241
  const next = jest.fn();
227
242
 
@@ -232,7 +247,7 @@ describe('authenticateAgent — tenant pinning', () => {
232
247
  });
233
248
 
234
249
  it('refuses a body that names a different group', async () => {
235
- mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
250
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
236
251
  const res = mockRes();
237
252
  const next = jest.fn();
238
253
 
@@ -243,7 +258,7 @@ describe('authenticateAgent — tenant pinning', () => {
243
258
  });
244
259
 
245
260
  it('refuses a personal key that tries to reach into a group', async () => {
246
- mockedVerify.mockResolvedValue(okVerification({ groupId: null }));
261
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant(PERSONAL_TENANT) }));
247
262
  const res = mockRes();
248
263
  const next = jest.fn();
249
264
 
@@ -253,8 +268,8 @@ describe('authenticateAgent — tenant pinning', () => {
253
268
  expect(next).not.toHaveBeenCalled();
254
269
  });
255
270
 
256
- it('allows a request that names the pinned group explicitly', async () => {
257
- mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
271
+ it('allows a request that names the granted group explicitly', async () => {
272
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
258
273
  const next = jest.fn();
259
274
 
260
275
  await authenticateAgent()(withKey(undefined, { query: { groupId: 'g1' } }), mockRes(), next);
@@ -263,9 +278,84 @@ describe('authenticateAgent — tenant pinning', () => {
263
278
  });
264
279
  });
265
280
 
281
+ describe('authenticateAgent — a multi-tenant grant', () => {
282
+ /**
283
+ * What the set buys: one connection reaching a shared journal and private data
284
+ * without reconnecting. The boundary is unchanged — a tenant outside the grant
285
+ * is still refused — so these tests are about the choice *inside* it.
286
+ */
287
+ const both = { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' };
288
+
289
+ it('acts in the default when the request names nothing', async () => {
290
+ mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
291
+ const request = withKey();
292
+
293
+ await authenticateAgent()(request, mockRes(), jest.fn());
294
+
295
+ expect(request.query.groupId).toBe('g1');
296
+ expect(request.apiKey?.actingAs).toBe('g1');
297
+ });
298
+
299
+ it('acts in another granted tenant when the request names it', async () => {
300
+ mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
301
+ const request = withKey(undefined, { query: { groupId: PERSONAL_TENANT } });
302
+
303
+ await authenticateAgent()(request, mockRes(), jest.fn());
304
+
305
+ // `personal` is erased rather than passed on: handlers have always read the
306
+ // personal tenant as an absent groupId, and a literal would reach a query as
307
+ // a group that cannot exist.
308
+ expect(request.query.groupId).toBeUndefined();
309
+ expect(request.apiKey?.actingAs).toBe(PERSONAL_TENANT);
310
+ });
311
+
312
+ it('erases a stale groupId from the body too, not only the query', async () => {
313
+ mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
314
+ const request = withKey(undefined, { body: { groupId: PERSONAL_TENANT } });
315
+
316
+ await authenticateAgent()(request, mockRes(), jest.fn());
317
+
318
+ expect((request.body as Record<string, unknown>).groupId).toBeUndefined();
319
+ });
320
+
321
+ it('still refuses a tenant outside the grant', async () => {
322
+ mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
323
+ const res = mockRes();
324
+ const next = jest.fn();
325
+
326
+ await authenticateAgent()(withKey(undefined, { query: { groupId: 'g2' } }), res, next);
327
+
328
+ expect(res.status).toHaveBeenCalledWith(403);
329
+ expect(next).not.toHaveBeenCalled();
330
+ });
331
+
332
+ it('carries every granted group into userGroups, and only those', async () => {
333
+ mockedVerify.mockResolvedValue(
334
+ okVerification({ tenants: { allowed: [PERSONAL_TENANT, 'g1', 'g2'], default: 'g1' } })
335
+ );
336
+ const request = withKey();
337
+
338
+ await authenticateAgent()(request, mockRes(), jest.fn());
339
+
340
+ // The personal tenant is not a group and must not appear here, or it would
341
+ // reach a `groupId: { $in: ... }` clause as a group nobody belongs to.
342
+ expect(request.userGroups).toEqual(['g1', 'g2']);
343
+ });
344
+ });
345
+
266
346
  describe('requireScope', () => {
267
347
  const keyReq = (scopes: string[]) =>
268
- req({ apiKey: { keyId: 'k1', scopes, groupId: null } } as Partial<Request>);
348
+ req({
349
+ apiKey: {
350
+ keyId: 'k1',
351
+ scopes,
352
+ tenants: grant(PERSONAL_TENANT),
353
+ actingAs: PERSONAL_TENANT,
354
+ kind: 'api_key' as const,
355
+ label: 'A key',
356
+ credentialId: 'k1'
357
+ }
358
+ } as Partial<Request>);
269
359
 
270
360
  it('lets a session through unconditionally', () => {
271
361
  // Scopes narrow what software may do on a person's behalf, not what the
@@ -343,7 +433,17 @@ describe('denyApiKeys', () => {
343
433
  const next = jest.fn();
344
434
 
345
435
  denyApiKeys(
346
- req({ apiKey: { keyId: 'k1', scopes: [], groupId: null } }) as Request,
436
+ req({
437
+ apiKey: {
438
+ keyId: 'k1',
439
+ scopes: [],
440
+ tenants: grant(PERSONAL_TENANT),
441
+ actingAs: PERSONAL_TENANT,
442
+ kind: 'api_key' as const,
443
+ label: 'A key',
444
+ credentialId: 'k1'
445
+ }
446
+ }) as Request,
347
447
  res,
348
448
  next
349
449
  );
@@ -369,7 +469,11 @@ describe('authenticateAgent — OAuth access tokens', () => {
369
469
  resource: 'https://mcp.example.com',
370
470
  issuer: 'https://auth.example.com',
371
471
  scopes: ['relationship:read', 'relationship:write'],
372
- groupId: null,
472
+ tenants: [PERSONAL_TENANT],
473
+ defaultTenant: PERSONAL_TENANT,
474
+ tenantNames: { [PERSONAL_TENANT]: 'My own data' },
475
+ clientId: 'client-1',
476
+ clientName: 'Claude',
373
477
  ...over
374
478
  }).accessToken;
375
479
 
@@ -418,8 +522,8 @@ describe('authenticateAgent — OAuth access tokens', () => {
418
522
  expect(next).not.toHaveBeenCalled();
419
523
  });
420
524
 
421
- it('is pinned to the tenant chosen at consent', async () => {
422
- const request = withToken(mint({ groupId: 'g1' }));
525
+ it('acts in the tenant chosen at consent', async () => {
526
+ const request = withToken(mint({ tenants: ['g1'], defaultTenant: 'g1' }));
423
527
  const next = jest.fn();
424
528
 
425
529
  await authenticateAgent()(request, mockRes(), next);
@@ -434,7 +538,9 @@ describe('authenticateAgent — OAuth access tokens', () => {
434
538
  it('refuses a request naming a different tenant', async () => {
435
539
  const res = mockRes();
436
540
  const next = jest.fn();
437
- const request = withToken(mint({ groupId: 'g1' }), { query: { groupId: 'g2' } });
541
+ const request = withToken(mint({ tenants: ['g1'], defaultTenant: 'g1' }), {
542
+ query: { groupId: 'g2' }
543
+ });
438
544
 
439
545
  await authenticateAgent()(request, res, next);
440
546
 
@@ -2,11 +2,20 @@ import { NextFunction, Request, RequestHandler, Response } from 'express';
2
2
  import jwt from 'jsonwebtoken';
3
3
  import { requireEnv } from '../config/env';
4
4
  import logger from '../logging/logger';
5
+ import { beginAudit } from '../audit/context';
5
6
  import { isAccessTokenClaims, type AccessTokenClaims } from '../oauth/tokens';
6
7
  import type { UserPayload } from '../types/auth';
7
8
  import { looksLikeApiKey } from './crypto';
8
9
  import { verifyApiKey } from './service';
9
- import { isApiKeyScope, type ApiKeyScope } from './types';
10
+ import {
11
+ PERSONAL_TENANT,
12
+ groupIdsOf,
13
+ isApiKeyScope,
14
+ readTenants,
15
+ type ApiKeyScope,
16
+ type ApiKeyTenant,
17
+ type Tenant
18
+ } from './types';
10
19
 
11
20
  /**
12
21
  * Request-scoped facts about the key a request arrived on. Absent on ordinary
@@ -16,8 +25,22 @@ import { isApiKeyScope, type ApiKeyScope } from './types';
16
25
  export interface ApiKeyContext {
17
26
  keyId: string;
18
27
  scopes: ApiKeyScope[];
19
- /** the tenant this key is pinned to; null means the owner's personal data */
20
- groupId: string | null;
28
+ /** every tenant this credential may act in, and the one it acts in by default */
29
+ tenants: ApiKeyTenant;
30
+ /** the tenant this particular request resolved to */
31
+ actingAs: Tenant;
32
+ /** which credential this is; they are revoked and audited differently */
33
+ kind: 'api_key' | 'oauth';
34
+ /** what the owner called it — a key's name, or the connected app's */
35
+ label: string;
36
+ /**
37
+ * The stable identity of the credential.
38
+ *
39
+ * A key's document id, or an OAuth client id — never the access token's
40
+ * `jti`, which rotates hourly and would make one connection look like a new
41
+ * actor every time it refreshed.
42
+ */
43
+ credentialId: string;
21
44
  }
22
45
 
23
46
  declare global {
@@ -40,10 +63,12 @@ const unauthorized = (res: Response): void => {
40
63
  * The tenant a request is asking to act in, wherever it named one.
41
64
  *
42
65
  * Handlers read `groupId` from either the query string or the body depending on
43
- * the verb, so both are checked — a pin that only covered one of them would be
44
- * no pin at all.
66
+ * the verb, so both are checked — a rule that only covered one of them would be
67
+ * no rule at all. A caller names the personal tenant with the literal
68
+ * `personal`, since "no group" is what the resolved default gets written into
69
+ * and would otherwise be indistinguishable from not having asked.
45
70
  */
46
- const requestedGroupId = (req: Request): string | undefined => {
71
+ const requestedTenant = (req: Request): Tenant | undefined => {
47
72
  const fromQuery = req.query?.groupId;
48
73
  if (typeof fromQuery === 'string' && fromQuery.length > 0) return fromQuery;
49
74
 
@@ -53,46 +78,64 @@ const requestedGroupId = (req: Request): string | undefined => {
53
78
  return undefined;
54
79
  };
55
80
 
81
+ /** Express 5 makes `req.query` a getter, so it is redefined rather than assigned. */
82
+ const setQueryGroupId = (req: Request, groupId: string | undefined): void => {
83
+ const query = { ...req.query } as Record<string, unknown>;
84
+ if (groupId === undefined) delete query.groupId;
85
+ else query.groupId = groupId;
86
+
87
+ Object.defineProperty(req, 'query', {
88
+ value: query,
89
+ writable: true,
90
+ configurable: true,
91
+ enumerable: true
92
+ });
93
+ };
94
+
56
95
  /**
57
- * Hold a key to the tenant it was issued for.
96
+ * Hold a credential to the tenants it was granted, and settle which one this
97
+ * request is acting in.
98
+ *
99
+ * A request naming a tenant outside the grant is refused outright — that
100
+ * boundary is the whole reason a credential is safe to hand to an agent, and it
101
+ * is unchanged by the grant being a set rather than one. What the set adds is a
102
+ * choice *inside* it, which is what lets one connection reach a shared journal
103
+ * and a private photo library without reconnecting.
58
104
  *
59
- * Two things happen here, and the second is the one that makes keys pleasant to
60
- * use. A request that names a *different* tenant is refused outright — the pin
61
- * is the whole reason a key is safe to hand to an agent. A request that names
62
- * none has the pinned tenant written in for it, so the agent never has to know a
63
- * group id exists, and a handler's `if (groupId) ... else personal` branch lands
64
- * where the key's owner intended.
105
+ * A request that names nothing gets the default written in for it, so an agent
106
+ * granted a single tenant never has to know a group id exists and a handler's
107
+ * `if (groupId) … else personal` branch lands where the owner intended.
65
108
  */
66
- const applyTenantPin = (req: Request, groupId: string | null): boolean => {
67
- const requested = requestedGroupId(req);
109
+ const applyTenantGrant = (req: Request, tenants: ApiKeyTenant): Tenant | null => {
110
+ const requested = requestedTenant(req) ?? tenants.default;
68
111
 
69
- if (requested !== undefined && requested !== groupId) return false;
112
+ if (!tenants.allowed.includes(requested)) return null;
70
113
 
71
- if (groupId !== null && requested === undefined) {
72
- // Express 5 makes req.query a getter, so it is redefined rather than assigned.
73
- Object.defineProperty(req, 'query', {
74
- value: { ...req.query, groupId },
75
- writable: true,
76
- configurable: true,
77
- enumerable: true
78
- });
79
- if (req.body && typeof req.body === 'object') {
80
- (req.body as Record<string, unknown>).groupId = groupId;
81
- }
114
+ // Resolved either way, including when the caller asked for exactly what the
115
+ // default already was: handlers read the personal tenant as an absent
116
+ // `groupId`, so `personal` has to be erased rather than passed through.
117
+ const groupId = requested === PERSONAL_TENANT ? undefined : requested;
118
+ setQueryGroupId(req, groupId);
119
+ if (req.body && typeof req.body === 'object') {
120
+ if (groupId === undefined) delete (req.body as Record<string, unknown>).groupId;
121
+ else (req.body as Record<string, unknown>).groupId = groupId;
82
122
  }
83
123
 
84
- return true;
124
+ return requested;
85
125
  };
86
126
 
87
127
  /** A verified non-session credential, whichever kind it arrived as. */
88
128
  interface AgentCredential {
89
- /** the key id or token id, for the request-scoped context */
129
+ /** the key's document id, or the OAuth client id */
90
130
  credentialId: string;
131
+ kind: 'api_key' | 'oauth';
132
+ /** what the owner called it, for a trail a person can read */
133
+ label: string;
91
134
  userId: string;
92
135
  email: string;
93
136
  name: string;
94
137
  scopes: ApiKeyScope[];
95
- groupId: string | null;
138
+ tenants: ApiKeyTenant;
96
139
  }
97
140
 
98
141
  /**
@@ -120,7 +163,8 @@ function admitAgent(
120
163
  return;
121
164
  }
122
165
 
123
- if (!applyTenantPin(req, credential.groupId)) {
166
+ const actingAs = applyTenantGrant(req, credential.tenants);
167
+ if (actingAs === null) {
124
168
  res.status(403).json({
125
169
  success: false,
126
170
  message: 'Credential is not permitted to act in the requested group'
@@ -129,13 +173,24 @@ function admitAgent(
129
173
  }
130
174
 
131
175
  req.user = { id: credential.userId, email: credential.email, name: credential.name };
132
- // Only the pinned group, never the owner's full membership: this is what
176
+ // Only the granted groups, never the owner's full membership: this is what
133
177
  // stops a credential reaching a group it was not issued for through any
134
178
  // handler that consults `userGroups` instead of the `groupId` parameter.
135
- req.userGroups = credential.groupId ? [credential.groupId] : [];
136
- req.apiKey = { keyId: credential.credentialId, scopes: credential.scopes, groupId: credential.groupId };
179
+ req.userGroups = groupIdsOf(credential.tenants.allowed);
180
+ req.apiKey = {
181
+ keyId: credential.credentialId,
182
+ scopes: credential.scopes,
183
+ tenants: credential.tenants,
184
+ actingAs,
185
+ kind: credential.kind,
186
+ label: credential.label,
187
+ credentialId: credential.credentialId
188
+ };
137
189
 
138
- next();
190
+ // Attribution comes from authentication, so it is established here rather
191
+ // than mounted separately — a router that authenticates per route would
192
+ // otherwise have no actor in scope when a router-level middleware ran.
193
+ beginAudit(req, res, next);
139
194
  }
140
195
 
141
196
  /**
@@ -171,7 +226,7 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
171
226
  const user = claims as UserPayload;
172
227
  req.user = user;
173
228
  req.userGroups = user.groups ?? [];
174
- next();
229
+ beginAudit(req, res, next);
175
230
  return;
176
231
  }
177
232
 
@@ -186,12 +241,15 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
186
241
  // whose scope collapsed to nothing fell through to "no user context" and
187
242
  // answered from the whole collection.
188
243
  admitAgent(req, res, next, requiredScopes, {
189
- credentialId: claims.jti,
244
+ // The client, not the token: `jti` is a new value every hour.
245
+ credentialId: claims.clientId ?? 'unknown-client',
246
+ kind: 'oauth',
247
+ label: claims.clientName || 'A connected app',
190
248
  userId: claims.sub,
191
249
  email: claims.email ?? '',
192
250
  name: claims.name ?? '',
193
251
  scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
194
- groupId: claims.groupId ?? null
252
+ tenants: readTenants(claims)
195
253
  });
196
254
  return;
197
255
  }
@@ -207,11 +265,13 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
207
265
 
208
266
  admitAgent(req, res, next, requiredScopes, {
209
267
  credentialId: result.keyId!,
268
+ kind: 'api_key',
269
+ label: result.label || 'An API key',
210
270
  userId: result.userId!,
211
271
  email: result.userEmail ?? '',
212
272
  name: result.userName ?? '',
213
273
  scopes: result.scopes ?? [],
214
- groupId: result.groupId ?? null
274
+ tenants: result.tenants ?? readTenants({})
215
275
  });
216
276
  };
217
277
 
@@ -11,6 +11,7 @@ jest.mock('./ApiKey', () => ({
11
11
  }));
12
12
 
13
13
  import { ApiKey } from './ApiKey';
14
+ import { PERSONAL_TENANT } from './types';
14
15
  import {
15
16
  createApiKey,
16
17
  listApiKeys,
@@ -43,7 +44,8 @@ function storedKey(over: Record<string, unknown> = {}) {
43
44
  sealedIv: 'iv',
44
45
  sealedTag: 'tag',
45
46
  scopes: ['relationship:read'],
46
- groupId: null,
47
+ tenants: [PERSONAL_TENANT],
48
+ defaultTenant: PERSONAL_TENANT,
47
49
  revealCount: 0,
48
50
  lastRevealedAt: undefined as Date | undefined,
49
51
  createdAt: CREATED_AT,
@@ -98,14 +100,30 @@ describe('createApiKey', () => {
98
100
  expect(stored.sealedTag).toBeTruthy();
99
101
  });
100
102
 
101
- it('defaults an unpinned key to the owner’s personal scope', async () => {
103
+ it('defaults a key with no stated tenants to the owner’s personal data', async () => {
102
104
  await createApiKey(input);
103
- expect(mockedCreate.mock.calls[0][0].groupId).toBeNull();
105
+ expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT]);
106
+ expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe(PERSONAL_TENANT);
104
107
  });
105
108
 
106
- it('pins a key to the group it was issued for', async () => {
107
- await createApiKey({ ...input, groupId: 'g1' });
108
- expect(mockedCreate.mock.calls[0][0].groupId).toBe('g1');
109
+ it('stores every tenant a key was granted', async () => {
110
+ await createApiKey({ ...input, tenants: [PERSONAL_TENANT, 'g1'], defaultTenant: 'g1' });
111
+
112
+ expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT, 'g1']);
113
+ expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe('g1');
114
+ });
115
+
116
+ it('refuses to store a default outside the grant', async () => {
117
+ // A key acting by default in something it cannot reach would authenticate
118
+ // and then fail every call that did not name a tenant explicitly.
119
+ await createApiKey({ ...input, tenants: ['g1'], defaultTenant: 'g2' });
120
+
121
+ expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe('g1');
122
+ });
123
+
124
+ it('reads an empty grant as the safest non-empty one', async () => {
125
+ await createApiKey({ ...input, tenants: [] });
126
+ expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT]);
109
127
  });
110
128
 
111
129
  it('snapshots the owner so verification needs no second query', async () => {
@@ -311,10 +329,14 @@ describe('verifyApiKey', () => {
311
329
  expect(mockedFindOne).toHaveBeenCalledWith({ keyId: parseKey(token)!.id });
312
330
  });
313
331
 
314
- it('returns the identity, scopes and tenant a valid key carries', async () => {
332
+ it('returns the identity, scopes and tenants a valid key carries', async () => {
315
333
  const { token } = generateKey();
316
334
  mockedFindOne.mockResolvedValue(
317
- acceptingKey(token, { scopes: ['relationship:read', 'album:write'], groupId: 'g1' })
335
+ acceptingKey(token, {
336
+ scopes: ['relationship:read', 'album:write'],
337
+ tenants: [PERSONAL_TENANT, 'g1'],
338
+ defaultTenant: 'g1'
339
+ })
318
340
  );
319
341
 
320
342
  await expect(verifyApiKey(token)).resolves.toEqual({
@@ -324,7 +346,34 @@ describe('verifyApiKey', () => {
324
346
  userName: 'Tester',
325
347
  keyId: 'k1',
326
348
  scopes: ['relationship:read', 'album:write'],
327
- groupId: 'g1'
349
+ // The key's own name, so an audit trail reads "Claude" and not "k1".
350
+ label: 'Claude',
351
+ tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' }
352
+ });
353
+ });
354
+
355
+ it('reads a key issued before tenants were a set', async () => {
356
+ // Keys stored under the single-tenant model carry `groupId` and no array.
357
+ // Without this fallback every connected assistant would break at once on
358
+ // the deploy that introduced the set.
359
+ const { token } = generateKey();
360
+ mockedFindOne.mockResolvedValue(
361
+ acceptingKey(token, { tenants: undefined, defaultTenant: undefined, groupId: 'g1' })
362
+ );
363
+
364
+ await expect(verifyApiKey(token)).resolves.toMatchObject({
365
+ tenants: { allowed: ['g1'], default: 'g1' }
366
+ });
367
+ });
368
+
369
+ it('reads an old personal key as the personal tenant, not as no tenant', async () => {
370
+ const { token } = generateKey();
371
+ mockedFindOne.mockResolvedValue(
372
+ acceptingKey(token, { tenants: undefined, defaultTenant: undefined, groupId: null })
373
+ );
374
+
375
+ await expect(verifyApiKey(token)).resolves.toMatchObject({
376
+ tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
328
377
  });
329
378
  });
330
379
 
@@ -7,7 +7,14 @@ import {
7
7
  parseKey,
8
8
  secretMatches
9
9
  } from './crypto';
10
- import type { ApiKeyScope, ApiKeySummary, ApiKeyVerification } from './types';
10
+ import {
11
+ PERSONAL_TENANT,
12
+ readTenants,
13
+ type ApiKeyScope,
14
+ type ApiKeySummary,
15
+ type ApiKeyVerification,
16
+ type Tenant
17
+ } from './types';
11
18
 
12
19
  export interface CreateApiKeyInput {
13
20
  userId: string;
@@ -15,8 +22,10 @@ export interface CreateApiKeyInput {
15
22
  userName: string;
16
23
  name: string;
17
24
  scopes: ApiKeyScope[];
18
- /** null pins the key to the user's own non-group data */
19
- groupId?: string | null;
25
+ /** every tenant the key may act in; defaults to the user's own data alone */
26
+ tenants?: Tenant[];
27
+ /** which of them it acts in when a request names none */
28
+ defaultTenant?: Tenant;
20
29
  expiresAt?: Date | null;
21
30
  }
22
31
 
@@ -31,7 +40,9 @@ const toSummary = (key: IApiKey): ApiKeySummary => ({
31
40
  name: key.name,
32
41
  prefix: displayPrefix(key.keyId),
33
42
  scopes: key.scopes,
34
- groupId: key.groupId ?? null,
43
+ ...(({ allowed, default: fallback }) => ({ tenants: allowed, defaultTenant: fallback }))(
44
+ readTenants(key)
45
+ ),
35
46
  createdAt: key.createdAt.toISOString(),
36
47
  lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
37
48
  expiresAt: key.expiresAt?.toISOString() ?? null,
@@ -39,6 +50,10 @@ const toSummary = (key: IApiKey): ApiKeySummary => ({
39
50
  });
40
51
 
41
52
  export const createApiKey = async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
53
+ // A key that reaches nowhere would authenticate and then fail every call, so
54
+ // an empty grant is read as the safest non-empty one rather than stored.
55
+ const tenants = input.tenants?.length ? input.tenants : [PERSONAL_TENANT];
56
+
42
57
  const { token, id, hash } = generateKey();
43
58
  const sealed = encryptSecret(token);
44
59
 
@@ -53,7 +68,10 @@ export const createApiKey = async (input: CreateApiKeyInput): Promise<CreatedApi
53
68
  sealedIv: sealed.iv,
54
69
  sealedTag: sealed.tag,
55
70
  scopes: input.scopes,
56
- groupId: input.groupId ?? null,
71
+ tenants,
72
+ defaultTenant: tenants.includes(input.defaultTenant ?? '')
73
+ ? input.defaultTenant
74
+ : (tenants[0] as Tenant),
57
75
  expiresAt: input.expiresAt ?? undefined
58
76
  });
59
77
 
@@ -143,7 +161,8 @@ export const verifyApiKey = async (token: unknown): Promise<ApiKeyVerification>
143
161
  userEmail: key.userEmail,
144
162
  userName: key.userName,
145
163
  keyId: String(key._id),
164
+ label: key.name,
146
165
  scopes: key.scopes,
147
- groupId: key.groupId ?? null
166
+ tenants: readTenants(key)
148
167
  };
149
168
  };