@shipfox/api-auth 9.3.0 → 10.1.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +48 -0
- package/README.md +14 -3
- package/dist/core/administration.d.ts +19 -2
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +37 -4
- package/dist/core/administration.js.map +1 -1
- package/dist/core/entities/administrator-read-model.d.ts +21 -0
- package/dist/core/entities/administrator-read-model.d.ts.map +1 -0
- package/dist/core/entities/administrator-read-model.js +3 -0
- package/dist/core/entities/administrator-read-model.js.map +1 -0
- package/dist/db/admin-grants.d.ts +25 -1
- package/dist/db/admin-grants.d.ts.map +1 -1
- package/dist/db/admin-grants.js +51 -14
- package/dist/db/admin-grants.js.map +1 -1
- package/dist/db/admin-users.d.ts +21 -0
- package/dist/db/admin-users.d.ts.map +1 -0
- package/dist/db/admin-users.js +32 -0
- package/dist/db/admin-users.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +1 -1
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/routes/administration.d.ts +2 -0
- package/dist/presentation/routes/administration.d.ts.map +1 -1
- package/dist/presentation/routes/administration.js +113 -10
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +12 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/src/core/administration.ts +50 -4
- package/src/core/entities/administrator-read-model.ts +23 -0
- package/src/db/admin-grants.test.ts +12 -2
- package/src/db/admin-grants.ts +85 -39
- package/src/db/admin-users.ts +58 -0
- package/src/index.test.ts +4 -1
- package/src/index.ts +7 -2
- package/src/metrics/instance.ts +6 -1
- package/src/presentation/routes/administration.test.ts +432 -20
- package/src/presentation/routes/administration.ts +109 -10
- package/src/presentation/routes/rate-limit.ts +6 -0
- package/test/routes.ts +11 -2
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
|
|
2
2
|
import {sql} from 'drizzle-orm';
|
|
3
3
|
import type {FastifyInstance} from 'fastify';
|
|
4
|
+
import {type AuthRateLimitAction, hashAuthRateLimitIdentifier} from '#core/rate-limit.js';
|
|
4
5
|
import {db} from '#db/db.js';
|
|
5
6
|
import {authOutbox} from '#db/schema/outbox.js';
|
|
7
|
+
import {authRateLimits} from '#db/schema/rate-limits.js';
|
|
6
8
|
import {createAuthTestApp, createVerifiedSession, resetCapturedMail} from '#test/routes.js';
|
|
7
9
|
|
|
8
10
|
const BOOTSTRAP_TOKEN = 'test-bootstrap-token';
|
|
@@ -20,6 +22,30 @@ function authHeaders(token: string, idempotencyKey: string) {
|
|
|
20
22
|
};
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
async function seedExhaustedIpBucket(params: {
|
|
26
|
+
action: AuthRateLimitAction;
|
|
27
|
+
identifier: string;
|
|
28
|
+
limit: number;
|
|
29
|
+
windowSeconds: number;
|
|
30
|
+
}): Promise<void> {
|
|
31
|
+
const windowMs = params.windowSeconds * 1000;
|
|
32
|
+
const windowStart = new Date(Math.floor(Date.now() / windowMs) * windowMs);
|
|
33
|
+
await db()
|
|
34
|
+
.insert(authRateLimits)
|
|
35
|
+
.values({
|
|
36
|
+
action: params.action,
|
|
37
|
+
scope: 'ip',
|
|
38
|
+
identifierHmac: hashAuthRateLimitIdentifier({
|
|
39
|
+
action: params.action,
|
|
40
|
+
scope: 'ip',
|
|
41
|
+
identifier: params.identifier,
|
|
42
|
+
}),
|
|
43
|
+
windowStart,
|
|
44
|
+
count: params.limit,
|
|
45
|
+
expiresAt: new Date(windowStart.getTime() + windowMs),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
describe('Auth administration routes', () => {
|
|
24
50
|
let app: FastifyInstance;
|
|
25
51
|
|
|
@@ -40,7 +66,7 @@ describe('Auth administration routes', () => {
|
|
|
40
66
|
test('requires an authenticated session for bootstrap', async () => {
|
|
41
67
|
const response = await app.inject({
|
|
42
68
|
method: 'POST',
|
|
43
|
-
url: '/admin/
|
|
69
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
44
70
|
headers: {'idempotency-key': 'anonymous-bootstrap'},
|
|
45
71
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
46
72
|
});
|
|
@@ -48,12 +74,93 @@ describe('Auth administration routes', () => {
|
|
|
48
74
|
expect(response.statusCode).toBe(401);
|
|
49
75
|
});
|
|
50
76
|
|
|
77
|
+
test('requires an authenticated session for bootstrap state', async () => {
|
|
78
|
+
const response = await app.inject({
|
|
79
|
+
method: 'GET',
|
|
80
|
+
url: '/admin/auth/bootstrap-state',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(response.statusCode).toBe(401);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('reports only available or closed bootstrap state', async () => {
|
|
87
|
+
const account = await createVerifiedSession('admin-bootstrap-state');
|
|
88
|
+
|
|
89
|
+
const available = await app.inject({
|
|
90
|
+
method: 'GET',
|
|
91
|
+
url: '/admin/auth/bootstrap-state',
|
|
92
|
+
headers: {authorization: `Bearer ${account.token}`},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(available.statusCode).toBe(200);
|
|
96
|
+
expect(available.json()).toEqual({state: 'available'});
|
|
97
|
+
|
|
98
|
+
const bootstrap = await app.inject({
|
|
99
|
+
method: 'POST',
|
|
100
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
101
|
+
headers: authHeaders(account.token, 'bootstrap-state-bootstrap'),
|
|
102
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
103
|
+
});
|
|
104
|
+
expect(bootstrap.statusCode).toBe(201);
|
|
105
|
+
|
|
106
|
+
const closed = await app.inject({
|
|
107
|
+
method: 'GET',
|
|
108
|
+
url: '/admin/auth/bootstrap-state',
|
|
109
|
+
headers: {authorization: `Bearer ${account.token}`},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(closed.statusCode).toBe(200);
|
|
113
|
+
expect(closed.json()).toEqual({state: 'closed'});
|
|
114
|
+
expect(JSON.stringify(closed.json())).not.toContain(BOOTSTRAP_TOKEN);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('keeps bootstrap-state reads separate from the bootstrap write limit', async () => {
|
|
118
|
+
const account = await createVerifiedSession('admin-bootstrap-state-rate-limit');
|
|
119
|
+
const ip = '127.0.0.1';
|
|
120
|
+
|
|
121
|
+
await seedExhaustedIpBucket({
|
|
122
|
+
action: 'bootstrap',
|
|
123
|
+
identifier: ip,
|
|
124
|
+
limit: 5,
|
|
125
|
+
windowSeconds: 15 * 60,
|
|
126
|
+
});
|
|
127
|
+
const state = await app.inject({
|
|
128
|
+
method: 'GET',
|
|
129
|
+
url: '/admin/auth/bootstrap-state',
|
|
130
|
+
headers: {authorization: `Bearer ${account.token}`},
|
|
131
|
+
});
|
|
132
|
+
expect(state.statusCode).toBe(200);
|
|
133
|
+
|
|
134
|
+
await resetAdministrationState();
|
|
135
|
+
await seedExhaustedIpBucket({
|
|
136
|
+
action: 'bootstrap-state',
|
|
137
|
+
identifier: ip,
|
|
138
|
+
limit: 60,
|
|
139
|
+
windowSeconds: 5 * 60,
|
|
140
|
+
});
|
|
141
|
+
const blocked = await app.inject({
|
|
142
|
+
method: 'GET',
|
|
143
|
+
url: '/admin/auth/bootstrap-state',
|
|
144
|
+
headers: {authorization: `Bearer ${account.token}`},
|
|
145
|
+
});
|
|
146
|
+
expect(blocked.statusCode).toBe(429);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('does not register the removed versioned administration namespace', async () => {
|
|
150
|
+
const response = await app.inject({
|
|
151
|
+
method: 'GET',
|
|
152
|
+
url: '/admin/v1/auth/users?user_id=00000000-0000-4000-8000-000000000000',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(response.statusCode).toBe(404);
|
|
156
|
+
});
|
|
157
|
+
|
|
51
158
|
test('rejects an invalid bootstrap token without writing a grant or event', async () => {
|
|
52
159
|
const account = await createVerifiedSession('admin-bootstrap-invalid');
|
|
53
160
|
|
|
54
161
|
const response = await app.inject({
|
|
55
162
|
method: 'POST',
|
|
56
|
-
url: '/admin/
|
|
163
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
57
164
|
headers: authHeaders(account.token, 'invalid-bootstrap'),
|
|
58
165
|
payload: {bootstrap_token: 'wrong-token'},
|
|
59
166
|
});
|
|
@@ -69,7 +176,7 @@ describe('Auth administration routes', () => {
|
|
|
69
176
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
70
177
|
const response = await app.inject({
|
|
71
178
|
method: 'POST',
|
|
72
|
-
url: '/admin/
|
|
179
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
73
180
|
headers: authHeaders(account.token, `bootstrap-rate-limit-${attempt}`),
|
|
74
181
|
payload: {bootstrap_token: 'wrong-token'},
|
|
75
182
|
});
|
|
@@ -78,7 +185,7 @@ describe('Auth administration routes', () => {
|
|
|
78
185
|
|
|
79
186
|
const blocked = await app.inject({
|
|
80
187
|
method: 'POST',
|
|
81
|
-
url: '/admin/
|
|
188
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
82
189
|
headers: authHeaders(account.token, 'bootstrap-rate-limit-blocked'),
|
|
83
190
|
payload: {bootstrap_token: 'wrong-token'},
|
|
84
191
|
});
|
|
@@ -92,7 +199,7 @@ describe('Auth administration routes', () => {
|
|
|
92
199
|
|
|
93
200
|
const response = await app.inject({
|
|
94
201
|
method: 'POST',
|
|
95
|
-
url: '/admin/
|
|
202
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
96
203
|
headers: authHeaders(first.token, 'bootstrap-first'),
|
|
97
204
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
98
205
|
});
|
|
@@ -102,7 +209,7 @@ describe('Auth administration routes', () => {
|
|
|
102
209
|
|
|
103
210
|
const repeated = await app.inject({
|
|
104
211
|
method: 'POST',
|
|
105
|
-
url: '/admin/
|
|
212
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
106
213
|
headers: authHeaders(first.token, 'bootstrap-first'),
|
|
107
214
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
108
215
|
});
|
|
@@ -111,7 +218,7 @@ describe('Auth administration routes', () => {
|
|
|
111
218
|
|
|
112
219
|
const secondAttempt = await app.inject({
|
|
113
220
|
method: 'POST',
|
|
114
|
-
url: '/admin/
|
|
221
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
115
222
|
headers: authHeaders(second.token, 'bootstrap-second'),
|
|
116
223
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
117
224
|
});
|
|
@@ -131,13 +238,43 @@ describe('Auth administration routes', () => {
|
|
|
131
238
|
expect(JSON.stringify(events[0]?.payload)).not.toContain(BOOTSTRAP_TOKEN);
|
|
132
239
|
});
|
|
133
240
|
|
|
241
|
+
test('serializes concurrent bootstrap attempts to one active first owner', async () => {
|
|
242
|
+
const first = await createVerifiedSession('admin-bootstrap-race-first');
|
|
243
|
+
const second = await createVerifiedSession('admin-bootstrap-race-second');
|
|
244
|
+
|
|
245
|
+
const responses = await Promise.all(
|
|
246
|
+
[first, second].map((account, index) =>
|
|
247
|
+
app.inject({
|
|
248
|
+
method: 'POST',
|
|
249
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
250
|
+
headers: authHeaders(account.token, `bootstrap-race-${index}`),
|
|
251
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
252
|
+
}),
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(responses.map((response) => response.statusCode).sort()).toEqual([201, 409]);
|
|
257
|
+
const activeOwnerRoles = await Promise.all(
|
|
258
|
+
[first, second].map(async (account) => {
|
|
259
|
+
const response = await app.inject({
|
|
260
|
+
method: 'GET',
|
|
261
|
+
url: `/admin/auth/users?user_id=${account.userId}`,
|
|
262
|
+
headers: {authorization: `Bearer ${account.token}`},
|
|
263
|
+
});
|
|
264
|
+
return response.statusCode === 200 && response.json().admin_role === 'admin-owner';
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(activeOwnerRoles.filter(Boolean)).toHaveLength(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
134
271
|
test('lets an owner list, grant, and revoke roles with idempotent audited mutations', async () => {
|
|
135
272
|
const owner = await createVerifiedSession('admin-grant-owner');
|
|
136
273
|
const target = await createVerifiedSession('admin-grant-target');
|
|
137
274
|
|
|
138
275
|
const bootstrap = await app.inject({
|
|
139
276
|
method: 'POST',
|
|
140
|
-
url: '/admin/
|
|
277
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
141
278
|
headers: authHeaders(owner.token, 'grant-bootstrap'),
|
|
142
279
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
143
280
|
});
|
|
@@ -145,7 +282,7 @@ describe('Auth administration routes', () => {
|
|
|
145
282
|
|
|
146
283
|
const grant = await app.inject({
|
|
147
284
|
method: 'POST',
|
|
148
|
-
url: '/admin/
|
|
285
|
+
url: '/admin/auth/admin-grants',
|
|
149
286
|
headers: authHeaders(owner.token, 'grant-observer'),
|
|
150
287
|
payload: {
|
|
151
288
|
user_id: target.userId,
|
|
@@ -157,7 +294,7 @@ describe('Auth administration routes', () => {
|
|
|
157
294
|
|
|
158
295
|
const repeatedGrant = await app.inject({
|
|
159
296
|
method: 'POST',
|
|
160
|
-
url: '/admin/
|
|
297
|
+
url: '/admin/auth/admin-grants',
|
|
161
298
|
headers: authHeaders(owner.token, 'grant-observer'),
|
|
162
299
|
payload: {
|
|
163
300
|
user_id: target.userId,
|
|
@@ -170,17 +307,19 @@ describe('Auth administration routes', () => {
|
|
|
170
307
|
|
|
171
308
|
const grants = await app.inject({
|
|
172
309
|
method: 'GET',
|
|
173
|
-
url: '/admin/
|
|
310
|
+
url: '/admin/auth/admin-grants',
|
|
174
311
|
headers: {authorization: `Bearer ${owner.token}`},
|
|
175
312
|
});
|
|
176
313
|
expect(grants.statusCode).toBe(200);
|
|
177
314
|
expect(grants.json().grants).toEqual(
|
|
178
|
-
expect.arrayContaining([
|
|
315
|
+
expect.arrayContaining([
|
|
316
|
+
expect.objectContaining({user: expect.objectContaining({id: target.userId})}),
|
|
317
|
+
]),
|
|
179
318
|
);
|
|
180
319
|
|
|
181
320
|
const revoked = await app.inject({
|
|
182
321
|
method: 'DELETE',
|
|
183
|
-
url: `/admin/
|
|
322
|
+
url: `/admin/auth/admin-grants/${grant.json().id}`,
|
|
184
323
|
headers: authHeaders(owner.token, 'revoke-observer'),
|
|
185
324
|
payload: {reason: 'Support investigation complete'},
|
|
186
325
|
});
|
|
@@ -189,7 +328,7 @@ describe('Auth administration routes', () => {
|
|
|
189
328
|
|
|
190
329
|
const repeatedRevoke = await app.inject({
|
|
191
330
|
method: 'DELETE',
|
|
192
|
-
url: `/admin/
|
|
331
|
+
url: `/admin/auth/admin-grants/${grant.json().id}`,
|
|
193
332
|
headers: authHeaders(owner.token, 'revoke-observer'),
|
|
194
333
|
payload: {reason: 'Support investigation complete'},
|
|
195
334
|
});
|
|
@@ -203,20 +342,293 @@ describe('Auth administration routes', () => {
|
|
|
203
342
|
);
|
|
204
343
|
});
|
|
205
344
|
|
|
345
|
+
test('lets an observer find exact IDs and normalized emails without exposing user secrets', async () => {
|
|
346
|
+
const owner = await createVerifiedSession('admin-user-lookup-owner');
|
|
347
|
+
const observer = await createVerifiedSession('admin-user-lookup-observer');
|
|
348
|
+
|
|
349
|
+
await app.inject({
|
|
350
|
+
method: 'POST',
|
|
351
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
352
|
+
headers: authHeaders(owner.token, 'user-lookup-bootstrap'),
|
|
353
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
354
|
+
});
|
|
355
|
+
await app.inject({
|
|
356
|
+
method: 'POST',
|
|
357
|
+
url: '/admin/auth/admin-grants',
|
|
358
|
+
headers: authHeaders(owner.token, 'user-lookup-grant'),
|
|
359
|
+
payload: {
|
|
360
|
+
user_id: observer.userId,
|
|
361
|
+
role: 'admin-observer',
|
|
362
|
+
reason: 'Support lookup access',
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const byId = await app.inject({
|
|
367
|
+
method: 'GET',
|
|
368
|
+
url: `/admin/auth/users?user_id=${observer.userId}`,
|
|
369
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(byId.statusCode).toBe(200);
|
|
373
|
+
expect(byId.json()).toEqual({
|
|
374
|
+
id: observer.userId,
|
|
375
|
+
email: observer.email,
|
|
376
|
+
name: 'admin-user-lookup-observer',
|
|
377
|
+
status: 'active',
|
|
378
|
+
email_verified_at: expect.any(String),
|
|
379
|
+
created_at: expect.any(String),
|
|
380
|
+
admin_role: 'admin-observer',
|
|
381
|
+
});
|
|
382
|
+
expect(byId.json()).not.toHaveProperty('hashed_password');
|
|
383
|
+
expect(byId.json()).not.toHaveProperty('sessions');
|
|
384
|
+
expect(byId.json()).not.toHaveProperty('provider_payload');
|
|
385
|
+
|
|
386
|
+
const byEmail = await app.inject({
|
|
387
|
+
method: 'GET',
|
|
388
|
+
url: `/admin/auth/users?email=${encodeURIComponent(` ${observer.email.toUpperCase()} `)}`,
|
|
389
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
expect(byEmail.statusCode).toBe(200);
|
|
393
|
+
expect(byEmail.json().id).toBe(observer.userId);
|
|
394
|
+
expect(byEmail.json().email).toBe(observer.email);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('bounds and deterministically paginates administrator grant summaries for observers', async () => {
|
|
398
|
+
const owner = await createVerifiedSession('admin-summary-owner');
|
|
399
|
+
const observer = await createVerifiedSession('admin-summary-observer');
|
|
400
|
+
|
|
401
|
+
await app.inject({
|
|
402
|
+
method: 'POST',
|
|
403
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
404
|
+
headers: authHeaders(owner.token, 'summary-bootstrap'),
|
|
405
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
406
|
+
});
|
|
407
|
+
const grant = await app.inject({
|
|
408
|
+
method: 'POST',
|
|
409
|
+
url: '/admin/auth/admin-grants',
|
|
410
|
+
headers: authHeaders(owner.token, 'summary-grant'),
|
|
411
|
+
payload: {
|
|
412
|
+
user_id: observer.userId,
|
|
413
|
+
role: 'admin-observer',
|
|
414
|
+
reason: 'Read-only support access',
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const firstPage = await app.inject({
|
|
419
|
+
method: 'GET',
|
|
420
|
+
url: '/admin/auth/admin-grants?limit=1',
|
|
421
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
expect(firstPage.statusCode).toBe(200);
|
|
425
|
+
expect(firstPage.json().grants).toHaveLength(1);
|
|
426
|
+
expect(firstPage.json().grants[0]).toEqual({
|
|
427
|
+
grant_id: grant.json().id,
|
|
428
|
+
role: 'admin-observer',
|
|
429
|
+
created_at: expect.any(String),
|
|
430
|
+
revoked_at: null,
|
|
431
|
+
user: {
|
|
432
|
+
id: observer.userId,
|
|
433
|
+
email: observer.email,
|
|
434
|
+
name: 'admin-summary-observer',
|
|
435
|
+
status: 'active',
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
expect(firstPage.json().next_cursor).toEqual(expect.any(String));
|
|
439
|
+
|
|
440
|
+
const secondPage = await app.inject({
|
|
441
|
+
method: 'GET',
|
|
442
|
+
url: `/admin/auth/admin-grants?limit=1&cursor=${firstPage.json().next_cursor}`,
|
|
443
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(secondPage.statusCode).toBe(200);
|
|
447
|
+
expect(secondPage.json().grants).toHaveLength(1);
|
|
448
|
+
expect(secondPage.json().grants[0].user.id).toBe(owner.userId);
|
|
449
|
+
expect(secondPage.json().next_cursor).toBeNull();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('rejects ordinary users and unchecked user lookup filters', async () => {
|
|
453
|
+
const ordinary = await createVerifiedSession('admin-lookup-ordinary');
|
|
454
|
+
|
|
455
|
+
const forbidden = await app.inject({
|
|
456
|
+
method: 'GET',
|
|
457
|
+
url: `/admin/auth/users?id=${ordinary.userId}`,
|
|
458
|
+
headers: {authorization: `Bearer ${ordinary.token}`},
|
|
459
|
+
});
|
|
460
|
+
expect(forbidden.statusCode).toBe(403);
|
|
461
|
+
|
|
462
|
+
const missingFilter = await app.inject({
|
|
463
|
+
method: 'GET',
|
|
464
|
+
url: '/admin/auth/users',
|
|
465
|
+
headers: {authorization: `Bearer ${ordinary.token}`},
|
|
466
|
+
});
|
|
467
|
+
expect(missingFilter.statusCode).toBe(400);
|
|
468
|
+
|
|
469
|
+
const multipleFilters = await app.inject({
|
|
470
|
+
method: 'GET',
|
|
471
|
+
url: `/admin/auth/users?id=${ordinary.userId}&email=${encodeURIComponent(ordinary.email)}`,
|
|
472
|
+
headers: {authorization: `Bearer ${ordinary.token}`},
|
|
473
|
+
});
|
|
474
|
+
expect(multipleFilters.statusCode).toBe(400);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('returns 404 for a well-formed but unknown user lookup', async () => {
|
|
478
|
+
const owner = await createVerifiedSession('admin-lookup-unknown-owner');
|
|
479
|
+
const observer = await createVerifiedSession('admin-lookup-unknown-observer');
|
|
480
|
+
|
|
481
|
+
await app.inject({
|
|
482
|
+
method: 'POST',
|
|
483
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
484
|
+
headers: authHeaders(owner.token, 'unknown-lookup-bootstrap'),
|
|
485
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
486
|
+
});
|
|
487
|
+
await app.inject({
|
|
488
|
+
method: 'POST',
|
|
489
|
+
url: '/admin/auth/admin-grants',
|
|
490
|
+
headers: authHeaders(owner.token, 'unknown-lookup-grant'),
|
|
491
|
+
payload: {
|
|
492
|
+
user_id: observer.userId,
|
|
493
|
+
role: 'admin-observer',
|
|
494
|
+
reason: 'Support lookup access',
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const response = await app.inject({
|
|
499
|
+
method: 'GET',
|
|
500
|
+
url: '/admin/auth/users?user_id=00000000-0000-4000-8000-000000000000',
|
|
501
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(response.statusCode).toBe(404);
|
|
505
|
+
expect(response.json().code).toBe('not-found');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test('rejects a malformed pagination cursor', async () => {
|
|
509
|
+
const owner = await createVerifiedSession('admin-grants-bad-cursor-owner');
|
|
510
|
+
const observer = await createVerifiedSession('admin-grants-bad-cursor-observer');
|
|
511
|
+
|
|
512
|
+
await app.inject({
|
|
513
|
+
method: 'POST',
|
|
514
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
515
|
+
headers: authHeaders(owner.token, 'bad-cursor-bootstrap'),
|
|
516
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
517
|
+
});
|
|
518
|
+
await app.inject({
|
|
519
|
+
method: 'POST',
|
|
520
|
+
url: '/admin/auth/admin-grants',
|
|
521
|
+
headers: authHeaders(owner.token, 'bad-cursor-grant'),
|
|
522
|
+
payload: {
|
|
523
|
+
user_id: observer.userId,
|
|
524
|
+
role: 'admin-observer',
|
|
525
|
+
reason: 'Support lookup access',
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const response = await app.inject({
|
|
530
|
+
method: 'GET',
|
|
531
|
+
url: '/admin/auth/admin-grants?cursor=not-a-real-cursor',
|
|
532
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
expect(response.statusCode).toBe(400);
|
|
536
|
+
expect(response.json().code).toBe('invalid-cursor');
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('rate-limits repeated user lookups by source IP', async () => {
|
|
540
|
+
const owner = await createVerifiedSession('admin-lookup-rate-limit-owner');
|
|
541
|
+
const observer = await createVerifiedSession('admin-lookup-rate-limit-observer');
|
|
542
|
+
|
|
543
|
+
await app.inject({
|
|
544
|
+
method: 'POST',
|
|
545
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
546
|
+
headers: authHeaders(owner.token, 'lookup-rate-limit-bootstrap'),
|
|
547
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
548
|
+
});
|
|
549
|
+
await app.inject({
|
|
550
|
+
method: 'POST',
|
|
551
|
+
url: '/admin/auth/admin-grants',
|
|
552
|
+
headers: authHeaders(owner.token, 'lookup-rate-limit-grant'),
|
|
553
|
+
payload: {
|
|
554
|
+
user_id: observer.userId,
|
|
555
|
+
role: 'admin-observer',
|
|
556
|
+
reason: 'Support lookup access',
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
561
|
+
const response = await app.inject({
|
|
562
|
+
method: 'GET',
|
|
563
|
+
url: `/admin/auth/users?user_id=${observer.userId}`,
|
|
564
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
565
|
+
});
|
|
566
|
+
expect(response.statusCode).toBe(200);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const blocked = await app.inject({
|
|
570
|
+
method: 'GET',
|
|
571
|
+
url: `/admin/auth/users?user_id=${observer.userId}`,
|
|
572
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
expect(blocked.statusCode).toBe(429);
|
|
576
|
+
expect(blocked.json().code).toBe('rate-limited');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('checks the administrator role before consuming the lookup IP bucket', async () => {
|
|
580
|
+
const owner = await createVerifiedSession('admin-lookup-authz-order-owner');
|
|
581
|
+
const observer = await createVerifiedSession('admin-lookup-authz-order-observer');
|
|
582
|
+
const ordinary = await createVerifiedSession('admin-lookup-authz-order-ordinary');
|
|
583
|
+
|
|
584
|
+
await app.inject({
|
|
585
|
+
method: 'POST',
|
|
586
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
587
|
+
headers: authHeaders(owner.token, 'authz-order-bootstrap'),
|
|
588
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
589
|
+
});
|
|
590
|
+
await app.inject({
|
|
591
|
+
method: 'POST',
|
|
592
|
+
url: '/admin/auth/admin-grants',
|
|
593
|
+
headers: authHeaders(owner.token, 'authz-order-grant'),
|
|
594
|
+
payload: {
|
|
595
|
+
user_id: observer.userId,
|
|
596
|
+
role: 'admin-observer',
|
|
597
|
+
reason: 'Support lookup access',
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
602
|
+
const response = await app.inject({
|
|
603
|
+
method: 'GET',
|
|
604
|
+
url: `/admin/auth/users?user_id=${ordinary.userId}`,
|
|
605
|
+
headers: {authorization: `Bearer ${ordinary.token}`},
|
|
606
|
+
});
|
|
607
|
+
expect(response.statusCode).toBe(403);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const observerResponse = await app.inject({
|
|
611
|
+
method: 'GET',
|
|
612
|
+
url: `/admin/auth/users?user_id=${observer.userId}`,
|
|
613
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
614
|
+
});
|
|
615
|
+
expect(observerResponse.statusCode).toBe(200);
|
|
616
|
+
});
|
|
617
|
+
|
|
206
618
|
test('protects the final active owner and rejects idempotency-key reuse', async () => {
|
|
207
619
|
const owner = await createVerifiedSession('admin-final-owner');
|
|
208
620
|
const target = await createVerifiedSession('admin-final-target');
|
|
209
621
|
|
|
210
622
|
await app.inject({
|
|
211
623
|
method: 'POST',
|
|
212
|
-
url: '/admin/
|
|
624
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
213
625
|
headers: authHeaders(owner.token, 'final-bootstrap'),
|
|
214
626
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
215
627
|
});
|
|
216
628
|
|
|
217
629
|
const firstGrant = await app.inject({
|
|
218
630
|
method: 'POST',
|
|
219
|
-
url: '/admin/
|
|
631
|
+
url: '/admin/auth/admin-grants',
|
|
220
632
|
headers: authHeaders(owner.token, 'role-key'),
|
|
221
633
|
payload: {user_id: target.userId, role: 'admin-observer', reason: 'Initial review'},
|
|
222
634
|
});
|
|
@@ -224,7 +636,7 @@ describe('Auth administration routes', () => {
|
|
|
224
636
|
|
|
225
637
|
const reusedKey = await app.inject({
|
|
226
638
|
method: 'POST',
|
|
227
|
-
url: '/admin/
|
|
639
|
+
url: '/admin/auth/admin-grants',
|
|
228
640
|
headers: authHeaders(owner.token, 'role-key'),
|
|
229
641
|
payload: {user_id: target.userId, role: 'admin-operator', reason: 'Different command'},
|
|
230
642
|
});
|
|
@@ -233,16 +645,16 @@ describe('Auth administration routes', () => {
|
|
|
233
645
|
|
|
234
646
|
const finalOwnerRevoke = await app.inject({
|
|
235
647
|
method: 'DELETE',
|
|
236
|
-
url: `/admin/
|
|
648
|
+
url: `/admin/auth/admin-grants/${
|
|
237
649
|
(
|
|
238
650
|
await app.inject({
|
|
239
651
|
method: 'GET',
|
|
240
|
-
url: '/admin/
|
|
652
|
+
url: '/admin/auth/admin-grants',
|
|
241
653
|
headers: {authorization: `Bearer ${owner.token}`},
|
|
242
654
|
})
|
|
243
655
|
)
|
|
244
656
|
.json()
|
|
245
|
-
.grants.find((grant: {
|
|
657
|
+
.grants.find((grant: {user: {id: string}}) => grant.user.id === owner.userId).grant_id
|
|
246
658
|
}`,
|
|
247
659
|
headers: authHeaders(owner.token, 'revoke-final-owner'),
|
|
248
660
|
payload: {reason: 'Attempt to remove final owner'},
|