@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.
- package/dist/apiKeys/ApiKey.d.ts +23 -3
- package/dist/apiKeys/ApiKey.d.ts.map +1 -1
- package/dist/apiKeys/ApiKey.js +19 -2
- package/dist/apiKeys/ApiKey.js.map +1 -1
- package/dist/apiKeys/index.d.ts +2 -2
- package/dist/apiKeys/index.d.ts.map +1 -1
- package/dist/apiKeys/index.js +5 -1
- package/dist/apiKeys/index.js.map +1 -1
- package/dist/apiKeys/middleware.d.ts +17 -3
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +71 -34
- package/dist/apiKeys/middleware.js.map +1 -1
- package/dist/apiKeys/service.d.ts +5 -3
- package/dist/apiKeys/service.d.ts.map +1 -1
- package/dist/apiKeys/service.js +11 -3
- package/dist/apiKeys/service.js.map +1 -1
- package/dist/apiKeys/types.d.ts +43 -8
- package/dist/apiKeys/types.d.ts.map +1 -1
- package/dist/apiKeys/types.js +35 -1
- package/dist/apiKeys/types.js.map +1 -1
- package/dist/audit/AuditEvent.d.ts +51 -0
- package/dist/audit/AuditEvent.d.ts.map +1 -0
- package/dist/audit/AuditEvent.js +69 -0
- package/dist/audit/AuditEvent.js.map +1 -0
- package/dist/audit/actor.d.ts +41 -0
- package/dist/audit/actor.d.ts.map +1 -0
- package/dist/audit/actor.js +39 -0
- package/dist/audit/actor.js.map +1 -0
- package/dist/audit/context.d.ts +40 -0
- package/dist/audit/context.d.ts.map +1 -0
- package/dist/audit/context.js +60 -0
- package/dist/audit/context.js.map +1 -0
- package/dist/audit/index.d.ts +12 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +22 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/audit/plugin.d.ts +23 -0
- package/dist/audit/plugin.d.ts.map +1 -0
- package/dist/audit/plugin.js +226 -0
- package/dist/audit/plugin.js.map +1 -0
- package/dist/audit/reads.d.ts +47 -0
- package/dist/audit/reads.d.ts.map +1 -0
- package/dist/audit/reads.js +94 -0
- package/dist/audit/reads.js.map +1 -0
- package/dist/audit/service.d.ts +49 -0
- package/dist/audit/service.d.ts.map +1 -0
- package/dist/audit/service.js +65 -0
- package/dist/audit/service.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/oauth/models.d.ts +13 -2
- package/dist/oauth/models.d.ts.map +1 -1
- package/dist/oauth/models.js +19 -0
- package/dist/oauth/models.js.map +1 -1
- package/dist/oauth/service.d.ts +15 -8
- package/dist/oauth/service.d.ts.map +1 -1
- package/dist/oauth/service.js +10 -3
- package/dist/oauth/service.js.map +1 -1
- package/dist/oauth/tokens.d.ts +37 -5
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +9 -2
- package/dist/oauth/tokens.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.test.ts +25 -1
- package/src/apiKeys/ApiKey.ts +31 -4
- package/src/apiKeys/index.ts +16 -2
- package/src/apiKeys/middleware.test.ts +126 -20
- package/src/apiKeys/middleware.ts +99 -39
- package/src/apiKeys/service.test.ts +58 -9
- package/src/apiKeys/service.ts +25 -6
- package/src/apiKeys/types.ts +63 -8
- package/src/audit/AuditEvent.ts +79 -0
- package/src/audit/actor.test.ts +95 -0
- package/src/audit/actor.ts +68 -0
- package/src/audit/context.test.ts +91 -0
- package/src/audit/context.ts +83 -0
- package/src/audit/index.ts +11 -0
- package/src/audit/plugin.test.ts +258 -0
- package/src/audit/plugin.ts +254 -0
- package/src/audit/reads.test.ts +164 -0
- package/src/audit/reads.ts +88 -0
- package/src/audit/service.test.ts +115 -0
- package/src/audit/service.ts +92 -0
- package/src/index.ts +1 -0
- package/src/middleware/authMiddleware.test.ts +6 -1
- package/src/oauth/models.ts +32 -2
- package/src/oauth/service.test.ts +58 -6
- package/src/oauth/service.ts +25 -7
- package/src/oauth/tokens.test.ts +22 -5
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
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({
|
|
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({
|
|
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
|
|
212
|
-
mockedVerify.mockResolvedValue(okVerification({
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
|
257
|
-
mockedVerify.mockResolvedValue(okVerification({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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('
|
|
422
|
-
const request = withToken(mint({
|
|
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({
|
|
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 {
|
|
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
|
-
/**
|
|
20
|
-
|
|
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
|
|
44
|
-
* no
|
|
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
|
|
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
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
67
|
-
const requested =
|
|
109
|
+
const applyTenantGrant = (req: Request, tenants: ApiKeyTenant): Tenant | null => {
|
|
110
|
+
const requested = requestedTenant(req) ?? tenants.default;
|
|
68
111
|
|
|
69
|
-
if (requested
|
|
112
|
+
if (!tenants.allowed.includes(requested)) return null;
|
|
70
113
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
138
|
+
tenants: ApiKeyTenant;
|
|
96
139
|
}
|
|
97
140
|
|
|
98
141
|
/**
|
|
@@ -120,7 +163,8 @@ function admitAgent(
|
|
|
120
163
|
return;
|
|
121
164
|
}
|
|
122
165
|
|
|
123
|
-
|
|
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
|
|
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.
|
|
136
|
-
req.apiKey = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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].
|
|
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('
|
|
107
|
-
await createApiKey({ ...input,
|
|
108
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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
|
|
package/src/apiKeys/service.ts
CHANGED
|
@@ -7,7 +7,14 @@ import {
|
|
|
7
7
|
parseKey,
|
|
8
8
|
secretMatches
|
|
9
9
|
} from './crypto';
|
|
10
|
-
import
|
|
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
|
-
/**
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
+
tenants: readTenants(key)
|
|
148
167
|
};
|
|
149
168
|
};
|