@shipfox/api-auth 9.3.0 → 10.0.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 +35 -0
- package/README.md +10 -3
- package/dist/core/administration.d.ts +18 -2
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +34 -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 +16 -1
- package/dist/db/admin-grants.d.ts.map +1 -1
- package/dist/db/admin-grants.js +32 -4
- 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 +4 -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 +1 -0
- package/dist/presentation/routes/administration.d.ts.map +1 -1
- package/dist/presentation/routes/administration.js +91 -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 +6 -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 +45 -4
- package/src/core/entities/administrator-read-model.ts +23 -0
- package/src/db/admin-grants.test.ts +1 -7
- package/src/db/admin-grants.ts +52 -4
- package/src/db/admin-users.ts +58 -0
- package/src/index.test.ts +1 -1
- package/src/index.ts +5 -2
- package/src/metrics/instance.ts +1 -1
- package/src/presentation/routes/administration.test.ts +304 -20
- package/src/presentation/routes/administration.ts +91 -10
- package/src/presentation/routes/rate-limit.ts +3 -0
- package/test/routes.ts +5 -2
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -40,7 +40,7 @@ describe('Auth administration routes', () => {
|
|
|
40
40
|
test('requires an authenticated session for bootstrap', async () => {
|
|
41
41
|
const response = await app.inject({
|
|
42
42
|
method: 'POST',
|
|
43
|
-
url: '/admin/
|
|
43
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
44
44
|
headers: {'idempotency-key': 'anonymous-bootstrap'},
|
|
45
45
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
46
46
|
});
|
|
@@ -48,12 +48,21 @@ describe('Auth administration routes', () => {
|
|
|
48
48
|
expect(response.statusCode).toBe(401);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
test('does not register the removed versioned administration namespace', async () => {
|
|
52
|
+
const response = await app.inject({
|
|
53
|
+
method: 'GET',
|
|
54
|
+
url: '/admin/v1/auth/users?user_id=00000000-0000-4000-8000-000000000000',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(response.statusCode).toBe(404);
|
|
58
|
+
});
|
|
59
|
+
|
|
51
60
|
test('rejects an invalid bootstrap token without writing a grant or event', async () => {
|
|
52
61
|
const account = await createVerifiedSession('admin-bootstrap-invalid');
|
|
53
62
|
|
|
54
63
|
const response = await app.inject({
|
|
55
64
|
method: 'POST',
|
|
56
|
-
url: '/admin/
|
|
65
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
57
66
|
headers: authHeaders(account.token, 'invalid-bootstrap'),
|
|
58
67
|
payload: {bootstrap_token: 'wrong-token'},
|
|
59
68
|
});
|
|
@@ -69,7 +78,7 @@ describe('Auth administration routes', () => {
|
|
|
69
78
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
70
79
|
const response = await app.inject({
|
|
71
80
|
method: 'POST',
|
|
72
|
-
url: '/admin/
|
|
81
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
73
82
|
headers: authHeaders(account.token, `bootstrap-rate-limit-${attempt}`),
|
|
74
83
|
payload: {bootstrap_token: 'wrong-token'},
|
|
75
84
|
});
|
|
@@ -78,7 +87,7 @@ describe('Auth administration routes', () => {
|
|
|
78
87
|
|
|
79
88
|
const blocked = await app.inject({
|
|
80
89
|
method: 'POST',
|
|
81
|
-
url: '/admin/
|
|
90
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
82
91
|
headers: authHeaders(account.token, 'bootstrap-rate-limit-blocked'),
|
|
83
92
|
payload: {bootstrap_token: 'wrong-token'},
|
|
84
93
|
});
|
|
@@ -92,7 +101,7 @@ describe('Auth administration routes', () => {
|
|
|
92
101
|
|
|
93
102
|
const response = await app.inject({
|
|
94
103
|
method: 'POST',
|
|
95
|
-
url: '/admin/
|
|
104
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
96
105
|
headers: authHeaders(first.token, 'bootstrap-first'),
|
|
97
106
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
98
107
|
});
|
|
@@ -102,7 +111,7 @@ describe('Auth administration routes', () => {
|
|
|
102
111
|
|
|
103
112
|
const repeated = await app.inject({
|
|
104
113
|
method: 'POST',
|
|
105
|
-
url: '/admin/
|
|
114
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
106
115
|
headers: authHeaders(first.token, 'bootstrap-first'),
|
|
107
116
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
108
117
|
});
|
|
@@ -111,7 +120,7 @@ describe('Auth administration routes', () => {
|
|
|
111
120
|
|
|
112
121
|
const secondAttempt = await app.inject({
|
|
113
122
|
method: 'POST',
|
|
114
|
-
url: '/admin/
|
|
123
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
115
124
|
headers: authHeaders(second.token, 'bootstrap-second'),
|
|
116
125
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
117
126
|
});
|
|
@@ -137,7 +146,7 @@ describe('Auth administration routes', () => {
|
|
|
137
146
|
|
|
138
147
|
const bootstrap = await app.inject({
|
|
139
148
|
method: 'POST',
|
|
140
|
-
url: '/admin/
|
|
149
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
141
150
|
headers: authHeaders(owner.token, 'grant-bootstrap'),
|
|
142
151
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
143
152
|
});
|
|
@@ -145,7 +154,7 @@ describe('Auth administration routes', () => {
|
|
|
145
154
|
|
|
146
155
|
const grant = await app.inject({
|
|
147
156
|
method: 'POST',
|
|
148
|
-
url: '/admin/
|
|
157
|
+
url: '/admin/auth/admin-grants',
|
|
149
158
|
headers: authHeaders(owner.token, 'grant-observer'),
|
|
150
159
|
payload: {
|
|
151
160
|
user_id: target.userId,
|
|
@@ -157,7 +166,7 @@ describe('Auth administration routes', () => {
|
|
|
157
166
|
|
|
158
167
|
const repeatedGrant = await app.inject({
|
|
159
168
|
method: 'POST',
|
|
160
|
-
url: '/admin/
|
|
169
|
+
url: '/admin/auth/admin-grants',
|
|
161
170
|
headers: authHeaders(owner.token, 'grant-observer'),
|
|
162
171
|
payload: {
|
|
163
172
|
user_id: target.userId,
|
|
@@ -170,17 +179,19 @@ describe('Auth administration routes', () => {
|
|
|
170
179
|
|
|
171
180
|
const grants = await app.inject({
|
|
172
181
|
method: 'GET',
|
|
173
|
-
url: '/admin/
|
|
182
|
+
url: '/admin/auth/admin-grants',
|
|
174
183
|
headers: {authorization: `Bearer ${owner.token}`},
|
|
175
184
|
});
|
|
176
185
|
expect(grants.statusCode).toBe(200);
|
|
177
186
|
expect(grants.json().grants).toEqual(
|
|
178
|
-
expect.arrayContaining([
|
|
187
|
+
expect.arrayContaining([
|
|
188
|
+
expect.objectContaining({user: expect.objectContaining({id: target.userId})}),
|
|
189
|
+
]),
|
|
179
190
|
);
|
|
180
191
|
|
|
181
192
|
const revoked = await app.inject({
|
|
182
193
|
method: 'DELETE',
|
|
183
|
-
url: `/admin/
|
|
194
|
+
url: `/admin/auth/admin-grants/${grant.json().id}`,
|
|
184
195
|
headers: authHeaders(owner.token, 'revoke-observer'),
|
|
185
196
|
payload: {reason: 'Support investigation complete'},
|
|
186
197
|
});
|
|
@@ -189,7 +200,7 @@ describe('Auth administration routes', () => {
|
|
|
189
200
|
|
|
190
201
|
const repeatedRevoke = await app.inject({
|
|
191
202
|
method: 'DELETE',
|
|
192
|
-
url: `/admin/
|
|
203
|
+
url: `/admin/auth/admin-grants/${grant.json().id}`,
|
|
193
204
|
headers: authHeaders(owner.token, 'revoke-observer'),
|
|
194
205
|
payload: {reason: 'Support investigation complete'},
|
|
195
206
|
});
|
|
@@ -203,20 +214,293 @@ describe('Auth administration routes', () => {
|
|
|
203
214
|
);
|
|
204
215
|
});
|
|
205
216
|
|
|
217
|
+
test('lets an observer find exact IDs and normalized emails without exposing user secrets', async () => {
|
|
218
|
+
const owner = await createVerifiedSession('admin-user-lookup-owner');
|
|
219
|
+
const observer = await createVerifiedSession('admin-user-lookup-observer');
|
|
220
|
+
|
|
221
|
+
await app.inject({
|
|
222
|
+
method: 'POST',
|
|
223
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
224
|
+
headers: authHeaders(owner.token, 'user-lookup-bootstrap'),
|
|
225
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
226
|
+
});
|
|
227
|
+
await app.inject({
|
|
228
|
+
method: 'POST',
|
|
229
|
+
url: '/admin/auth/admin-grants',
|
|
230
|
+
headers: authHeaders(owner.token, 'user-lookup-grant'),
|
|
231
|
+
payload: {
|
|
232
|
+
user_id: observer.userId,
|
|
233
|
+
role: 'admin-observer',
|
|
234
|
+
reason: 'Support lookup access',
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const byId = await app.inject({
|
|
239
|
+
method: 'GET',
|
|
240
|
+
url: `/admin/auth/users?user_id=${observer.userId}`,
|
|
241
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(byId.statusCode).toBe(200);
|
|
245
|
+
expect(byId.json()).toEqual({
|
|
246
|
+
id: observer.userId,
|
|
247
|
+
email: observer.email,
|
|
248
|
+
name: 'admin-user-lookup-observer',
|
|
249
|
+
status: 'active',
|
|
250
|
+
email_verified_at: expect.any(String),
|
|
251
|
+
created_at: expect.any(String),
|
|
252
|
+
admin_role: 'admin-observer',
|
|
253
|
+
});
|
|
254
|
+
expect(byId.json()).not.toHaveProperty('hashed_password');
|
|
255
|
+
expect(byId.json()).not.toHaveProperty('sessions');
|
|
256
|
+
expect(byId.json()).not.toHaveProperty('provider_payload');
|
|
257
|
+
|
|
258
|
+
const byEmail = await app.inject({
|
|
259
|
+
method: 'GET',
|
|
260
|
+
url: `/admin/auth/users?email=${encodeURIComponent(` ${observer.email.toUpperCase()} `)}`,
|
|
261
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(byEmail.statusCode).toBe(200);
|
|
265
|
+
expect(byEmail.json().id).toBe(observer.userId);
|
|
266
|
+
expect(byEmail.json().email).toBe(observer.email);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('bounds and deterministically paginates administrator grant summaries for observers', async () => {
|
|
270
|
+
const owner = await createVerifiedSession('admin-summary-owner');
|
|
271
|
+
const observer = await createVerifiedSession('admin-summary-observer');
|
|
272
|
+
|
|
273
|
+
await app.inject({
|
|
274
|
+
method: 'POST',
|
|
275
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
276
|
+
headers: authHeaders(owner.token, 'summary-bootstrap'),
|
|
277
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
278
|
+
});
|
|
279
|
+
const grant = await app.inject({
|
|
280
|
+
method: 'POST',
|
|
281
|
+
url: '/admin/auth/admin-grants',
|
|
282
|
+
headers: authHeaders(owner.token, 'summary-grant'),
|
|
283
|
+
payload: {
|
|
284
|
+
user_id: observer.userId,
|
|
285
|
+
role: 'admin-observer',
|
|
286
|
+
reason: 'Read-only support access',
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const firstPage = await app.inject({
|
|
291
|
+
method: 'GET',
|
|
292
|
+
url: '/admin/auth/admin-grants?limit=1',
|
|
293
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(firstPage.statusCode).toBe(200);
|
|
297
|
+
expect(firstPage.json().grants).toHaveLength(1);
|
|
298
|
+
expect(firstPage.json().grants[0]).toEqual({
|
|
299
|
+
grant_id: grant.json().id,
|
|
300
|
+
role: 'admin-observer',
|
|
301
|
+
created_at: expect.any(String),
|
|
302
|
+
revoked_at: null,
|
|
303
|
+
user: {
|
|
304
|
+
id: observer.userId,
|
|
305
|
+
email: observer.email,
|
|
306
|
+
name: 'admin-summary-observer',
|
|
307
|
+
status: 'active',
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
expect(firstPage.json().next_cursor).toEqual(expect.any(String));
|
|
311
|
+
|
|
312
|
+
const secondPage = await app.inject({
|
|
313
|
+
method: 'GET',
|
|
314
|
+
url: `/admin/auth/admin-grants?limit=1&cursor=${firstPage.json().next_cursor}`,
|
|
315
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
expect(secondPage.statusCode).toBe(200);
|
|
319
|
+
expect(secondPage.json().grants).toHaveLength(1);
|
|
320
|
+
expect(secondPage.json().grants[0].user.id).toBe(owner.userId);
|
|
321
|
+
expect(secondPage.json().next_cursor).toBeNull();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('rejects ordinary users and unchecked user lookup filters', async () => {
|
|
325
|
+
const ordinary = await createVerifiedSession('admin-lookup-ordinary');
|
|
326
|
+
|
|
327
|
+
const forbidden = await app.inject({
|
|
328
|
+
method: 'GET',
|
|
329
|
+
url: `/admin/auth/users?id=${ordinary.userId}`,
|
|
330
|
+
headers: {authorization: `Bearer ${ordinary.token}`},
|
|
331
|
+
});
|
|
332
|
+
expect(forbidden.statusCode).toBe(403);
|
|
333
|
+
|
|
334
|
+
const missingFilter = await app.inject({
|
|
335
|
+
method: 'GET',
|
|
336
|
+
url: '/admin/auth/users',
|
|
337
|
+
headers: {authorization: `Bearer ${ordinary.token}`},
|
|
338
|
+
});
|
|
339
|
+
expect(missingFilter.statusCode).toBe(400);
|
|
340
|
+
|
|
341
|
+
const multipleFilters = await app.inject({
|
|
342
|
+
method: 'GET',
|
|
343
|
+
url: `/admin/auth/users?id=${ordinary.userId}&email=${encodeURIComponent(ordinary.email)}`,
|
|
344
|
+
headers: {authorization: `Bearer ${ordinary.token}`},
|
|
345
|
+
});
|
|
346
|
+
expect(multipleFilters.statusCode).toBe(400);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('returns 404 for a well-formed but unknown user lookup', async () => {
|
|
350
|
+
const owner = await createVerifiedSession('admin-lookup-unknown-owner');
|
|
351
|
+
const observer = await createVerifiedSession('admin-lookup-unknown-observer');
|
|
352
|
+
|
|
353
|
+
await app.inject({
|
|
354
|
+
method: 'POST',
|
|
355
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
356
|
+
headers: authHeaders(owner.token, 'unknown-lookup-bootstrap'),
|
|
357
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
358
|
+
});
|
|
359
|
+
await app.inject({
|
|
360
|
+
method: 'POST',
|
|
361
|
+
url: '/admin/auth/admin-grants',
|
|
362
|
+
headers: authHeaders(owner.token, 'unknown-lookup-grant'),
|
|
363
|
+
payload: {
|
|
364
|
+
user_id: observer.userId,
|
|
365
|
+
role: 'admin-observer',
|
|
366
|
+
reason: 'Support lookup access',
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const response = await app.inject({
|
|
371
|
+
method: 'GET',
|
|
372
|
+
url: '/admin/auth/users?user_id=00000000-0000-4000-8000-000000000000',
|
|
373
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(response.statusCode).toBe(404);
|
|
377
|
+
expect(response.json().code).toBe('not-found');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('rejects a malformed pagination cursor', async () => {
|
|
381
|
+
const owner = await createVerifiedSession('admin-grants-bad-cursor-owner');
|
|
382
|
+
const observer = await createVerifiedSession('admin-grants-bad-cursor-observer');
|
|
383
|
+
|
|
384
|
+
await app.inject({
|
|
385
|
+
method: 'POST',
|
|
386
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
387
|
+
headers: authHeaders(owner.token, 'bad-cursor-bootstrap'),
|
|
388
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
389
|
+
});
|
|
390
|
+
await app.inject({
|
|
391
|
+
method: 'POST',
|
|
392
|
+
url: '/admin/auth/admin-grants',
|
|
393
|
+
headers: authHeaders(owner.token, 'bad-cursor-grant'),
|
|
394
|
+
payload: {
|
|
395
|
+
user_id: observer.userId,
|
|
396
|
+
role: 'admin-observer',
|
|
397
|
+
reason: 'Support lookup access',
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const response = await app.inject({
|
|
402
|
+
method: 'GET',
|
|
403
|
+
url: '/admin/auth/admin-grants?cursor=not-a-real-cursor',
|
|
404
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
expect(response.statusCode).toBe(400);
|
|
408
|
+
expect(response.json().code).toBe('invalid-cursor');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('rate-limits repeated user lookups by source IP', async () => {
|
|
412
|
+
const owner = await createVerifiedSession('admin-lookup-rate-limit-owner');
|
|
413
|
+
const observer = await createVerifiedSession('admin-lookup-rate-limit-observer');
|
|
414
|
+
|
|
415
|
+
await app.inject({
|
|
416
|
+
method: 'POST',
|
|
417
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
418
|
+
headers: authHeaders(owner.token, 'lookup-rate-limit-bootstrap'),
|
|
419
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
420
|
+
});
|
|
421
|
+
await app.inject({
|
|
422
|
+
method: 'POST',
|
|
423
|
+
url: '/admin/auth/admin-grants',
|
|
424
|
+
headers: authHeaders(owner.token, 'lookup-rate-limit-grant'),
|
|
425
|
+
payload: {
|
|
426
|
+
user_id: observer.userId,
|
|
427
|
+
role: 'admin-observer',
|
|
428
|
+
reason: 'Support lookup access',
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
433
|
+
const response = await app.inject({
|
|
434
|
+
method: 'GET',
|
|
435
|
+
url: `/admin/auth/users?user_id=${observer.userId}`,
|
|
436
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
437
|
+
});
|
|
438
|
+
expect(response.statusCode).toBe(200);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const blocked = await app.inject({
|
|
442
|
+
method: 'GET',
|
|
443
|
+
url: `/admin/auth/users?user_id=${observer.userId}`,
|
|
444
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expect(blocked.statusCode).toBe(429);
|
|
448
|
+
expect(blocked.json().code).toBe('rate-limited');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('checks the administrator role before consuming the lookup IP bucket', async () => {
|
|
452
|
+
const owner = await createVerifiedSession('admin-lookup-authz-order-owner');
|
|
453
|
+
const observer = await createVerifiedSession('admin-lookup-authz-order-observer');
|
|
454
|
+
const ordinary = await createVerifiedSession('admin-lookup-authz-order-ordinary');
|
|
455
|
+
|
|
456
|
+
await app.inject({
|
|
457
|
+
method: 'POST',
|
|
458
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
459
|
+
headers: authHeaders(owner.token, 'authz-order-bootstrap'),
|
|
460
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
461
|
+
});
|
|
462
|
+
await app.inject({
|
|
463
|
+
method: 'POST',
|
|
464
|
+
url: '/admin/auth/admin-grants',
|
|
465
|
+
headers: authHeaders(owner.token, 'authz-order-grant'),
|
|
466
|
+
payload: {
|
|
467
|
+
user_id: observer.userId,
|
|
468
|
+
role: 'admin-observer',
|
|
469
|
+
reason: 'Support lookup access',
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
474
|
+
const response = await app.inject({
|
|
475
|
+
method: 'GET',
|
|
476
|
+
url: `/admin/auth/users?user_id=${ordinary.userId}`,
|
|
477
|
+
headers: {authorization: `Bearer ${ordinary.token}`},
|
|
478
|
+
});
|
|
479
|
+
expect(response.statusCode).toBe(403);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const observerResponse = await app.inject({
|
|
483
|
+
method: 'GET',
|
|
484
|
+
url: `/admin/auth/users?user_id=${observer.userId}`,
|
|
485
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
486
|
+
});
|
|
487
|
+
expect(observerResponse.statusCode).toBe(200);
|
|
488
|
+
});
|
|
489
|
+
|
|
206
490
|
test('protects the final active owner and rejects idempotency-key reuse', async () => {
|
|
207
491
|
const owner = await createVerifiedSession('admin-final-owner');
|
|
208
492
|
const target = await createVerifiedSession('admin-final-target');
|
|
209
493
|
|
|
210
494
|
await app.inject({
|
|
211
495
|
method: 'POST',
|
|
212
|
-
url: '/admin/
|
|
496
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
213
497
|
headers: authHeaders(owner.token, 'final-bootstrap'),
|
|
214
498
|
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
215
499
|
});
|
|
216
500
|
|
|
217
501
|
const firstGrant = await app.inject({
|
|
218
502
|
method: 'POST',
|
|
219
|
-
url: '/admin/
|
|
503
|
+
url: '/admin/auth/admin-grants',
|
|
220
504
|
headers: authHeaders(owner.token, 'role-key'),
|
|
221
505
|
payload: {user_id: target.userId, role: 'admin-observer', reason: 'Initial review'},
|
|
222
506
|
});
|
|
@@ -224,7 +508,7 @@ describe('Auth administration routes', () => {
|
|
|
224
508
|
|
|
225
509
|
const reusedKey = await app.inject({
|
|
226
510
|
method: 'POST',
|
|
227
|
-
url: '/admin/
|
|
511
|
+
url: '/admin/auth/admin-grants',
|
|
228
512
|
headers: authHeaders(owner.token, 'role-key'),
|
|
229
513
|
payload: {user_id: target.userId, role: 'admin-operator', reason: 'Different command'},
|
|
230
514
|
});
|
|
@@ -233,16 +517,16 @@ describe('Auth administration routes', () => {
|
|
|
233
517
|
|
|
234
518
|
const finalOwnerRevoke = await app.inject({
|
|
235
519
|
method: 'DELETE',
|
|
236
|
-
url: `/admin/
|
|
520
|
+
url: `/admin/auth/admin-grants/${
|
|
237
521
|
(
|
|
238
522
|
await app.inject({
|
|
239
523
|
method: 'GET',
|
|
240
|
-
url: '/admin/
|
|
524
|
+
url: '/admin/auth/admin-grants',
|
|
241
525
|
headers: {authorization: `Bearer ${owner.token}`},
|
|
242
526
|
})
|
|
243
527
|
)
|
|
244
528
|
.json()
|
|
245
|
-
.grants.find((grant: {
|
|
529
|
+
.grants.find((grant: {user: {id: string}}) => grant.user.id === owner.userId).grant_id
|
|
246
530
|
}`,
|
|
247
531
|
headers: authHeaders(owner.token, 'revoke-final-owner'),
|
|
248
532
|
payload: {reason: 'Attempt to remove final owner'},
|
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
import {AUTH_USER} from '@shipfox/api-auth-context';
|
|
2
2
|
import {
|
|
3
|
+
administratorUserLookupQuerySchema,
|
|
4
|
+
administratorUserSummarySchema,
|
|
3
5
|
bootstrapAdminOwnerBodySchema,
|
|
4
6
|
bootstrapAdminOwnerResponseSchema,
|
|
5
7
|
grantAdminRoleBodySchema,
|
|
6
8
|
grantAdminRoleResponseSchema,
|
|
9
|
+
listAdminGrantsQuerySchema,
|
|
7
10
|
listAdminGrantsResponseSchema,
|
|
8
11
|
revokeAdminGrantBodySchema,
|
|
9
12
|
revokeAdminGrantResponseSchema,
|
|
10
13
|
} from '@shipfox/api-auth-dto';
|
|
14
|
+
import {decodeTimestampIdCursor, encodeTimestampIdCursor} from '@shipfox/node-drizzle';
|
|
11
15
|
import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
|
|
12
16
|
import type {FastifyRequest} from 'fastify';
|
|
13
17
|
import {z} from 'zod';
|
|
18
|
+
import {requireAdminRole} from '#core/admin-role.js';
|
|
14
19
|
import {
|
|
15
20
|
bootstrapFirstAdminOwner,
|
|
21
|
+
findAdministratorUserSummary,
|
|
16
22
|
grantAdministratorRole,
|
|
17
|
-
|
|
23
|
+
listAdministratorGrantSummaries,
|
|
18
24
|
revokeAdministratorGrant,
|
|
19
25
|
} from '#core/administration.js';
|
|
20
26
|
import type {AdminGrant} from '#core/entities/admin-grant.js';
|
|
27
|
+
import type {
|
|
28
|
+
AdministratorGrantSummary,
|
|
29
|
+
AdministratorUserSummary,
|
|
30
|
+
} from '#core/entities/administrator-read-model.js';
|
|
21
31
|
import {
|
|
22
32
|
AdminBootstrapClosedError,
|
|
23
33
|
AdminGrantAlreadyExistsError,
|
|
@@ -52,6 +62,10 @@ function requireIdempotencyKey(request: FastifyRequest): string {
|
|
|
52
62
|
return key;
|
|
53
63
|
}
|
|
54
64
|
|
|
65
|
+
async function requireAdministratorObserver(request: FastifyRequest): Promise<void> {
|
|
66
|
+
await requireAdminRole({userId: requireActorId(request), minimumRole: 'admin-observer'});
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
function toAdminGrantDto(grant: AdminGrant) {
|
|
56
70
|
return {
|
|
57
71
|
id: grant.id,
|
|
@@ -63,9 +77,31 @@ function toAdminGrantDto(grant: AdminGrant) {
|
|
|
63
77
|
};
|
|
64
78
|
}
|
|
65
79
|
|
|
80
|
+
function toAdministratorUserSummaryDto(user: AdministratorUserSummary) {
|
|
81
|
+
return {
|
|
82
|
+
id: user.id,
|
|
83
|
+
email: user.email,
|
|
84
|
+
name: user.name,
|
|
85
|
+
status: user.status,
|
|
86
|
+
email_verified_at: user.emailVerifiedAt?.toISOString() ?? null,
|
|
87
|
+
created_at: user.createdAt.toISOString(),
|
|
88
|
+
admin_role: user.adminRole,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toAdministratorGrantSummaryDto(grant: AdministratorGrantSummary) {
|
|
93
|
+
return {
|
|
94
|
+
grant_id: grant.grantId,
|
|
95
|
+
role: grant.role,
|
|
96
|
+
created_at: grant.createdAt.toISOString(),
|
|
97
|
+
revoked_at: grant.revokedAt?.toISOString() ?? null,
|
|
98
|
+
user: grant.user,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
66
102
|
function translateAdministrationError(error: unknown): never {
|
|
67
103
|
if (error instanceof AdminRoleRequiredError) {
|
|
68
|
-
throw new ClientError('Administrator
|
|
104
|
+
throw new ClientError('Administrator role required', 'forbidden', {
|
|
69
105
|
status: 403,
|
|
70
106
|
details: {required_role: error.minimumRole},
|
|
71
107
|
});
|
|
@@ -132,14 +168,53 @@ const bootstrapRoute = defineRoute({
|
|
|
132
168
|
const listRoute = defineRoute({
|
|
133
169
|
method: 'GET',
|
|
134
170
|
path: '/',
|
|
135
|
-
description: 'List local administrator
|
|
136
|
-
schema: {
|
|
171
|
+
description: 'List bounded local administrator grant summaries.',
|
|
172
|
+
schema: {
|
|
173
|
+
querystring: listAdminGrantsQuerySchema,
|
|
174
|
+
response: {200: listAdminGrantsResponseSchema},
|
|
175
|
+
},
|
|
137
176
|
errorHandler: translateAdministrationError,
|
|
138
|
-
handler: async (request) =>
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
142
|
-
|
|
177
|
+
handler: async (request) => {
|
|
178
|
+
const {limit, cursor} = request.query;
|
|
179
|
+
const decodedCursor = decodeTimestampIdCursor(cursor);
|
|
180
|
+
if (cursor && !decodedCursor) {
|
|
181
|
+
throw new ClientError('Invalid cursor', 'invalid-cursor', {status: 400});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const result = await listAdministratorGrantSummaries({
|
|
185
|
+
actorId: requireActorId(request),
|
|
186
|
+
limit,
|
|
187
|
+
...(decodedCursor ? {cursor: decodedCursor} : {}),
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
grants: result.grants.map(toAdministratorGrantSummaryDto),
|
|
191
|
+
next_cursor: result.nextCursor ? encodeTimestampIdCursor(result.nextCursor) : null,
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const userLookupRoute = defineRoute({
|
|
197
|
+
method: 'GET',
|
|
198
|
+
path: '/',
|
|
199
|
+
description: 'Find one administrator-safe user summary by exact ID or email.',
|
|
200
|
+
schema: {
|
|
201
|
+
querystring: administratorUserLookupQuerySchema,
|
|
202
|
+
response: {200: administratorUserSummarySchema},
|
|
203
|
+
},
|
|
204
|
+
preHandler: [requireAdministratorObserver, createAuthIpRateLimitPreHandler('lookup')],
|
|
205
|
+
errorHandler: translateAdministrationError,
|
|
206
|
+
handler: async (request) => {
|
|
207
|
+
const {id, user_id: userId, email} = request.query;
|
|
208
|
+
const lookupId = id ?? userId;
|
|
209
|
+
const actorId = requireActorId(request);
|
|
210
|
+
const user = lookupId
|
|
211
|
+
? await findAdministratorUserSummary({actorId, id: lookupId})
|
|
212
|
+
: email
|
|
213
|
+
? await findAdministratorUserSummary({actorId, email})
|
|
214
|
+
: undefined;
|
|
215
|
+
if (!user) throw new UserNotFoundError(lookupId ?? email ?? 'unknown');
|
|
216
|
+
return toAdministratorUserSummaryDto(user);
|
|
217
|
+
},
|
|
143
218
|
});
|
|
144
219
|
|
|
145
220
|
const grantRoute = defineRoute({
|
|
@@ -189,7 +264,13 @@ const revokeRoute = defineRoute({
|
|
|
189
264
|
});
|
|
190
265
|
|
|
191
266
|
export const administrationRoutes: RouteGroup = {
|
|
192
|
-
prefix: '/admin/
|
|
267
|
+
prefix: '/admin/auth/admin-grants',
|
|
193
268
|
auth: AUTH_USER,
|
|
194
269
|
routes: [bootstrapRoute, listRoute, grantRoute, revokeRoute],
|
|
195
270
|
};
|
|
271
|
+
|
|
272
|
+
export const administrationUserRoutes: RouteGroup = {
|
|
273
|
+
prefix: '/admin/auth/users',
|
|
274
|
+
auth: AUTH_USER,
|
|
275
|
+
routes: [userLookupRoute],
|
|
276
|
+
};
|
package/test/routes.ts
CHANGED
|
@@ -8,7 +8,10 @@ import {and, desc, eq, sql} from 'drizzle-orm';
|
|
|
8
8
|
import {db} from '#db/db.js';
|
|
9
9
|
import {authOutbox} from '#db/schema/outbox.js';
|
|
10
10
|
import {createJwtAuthMethod} from '#presentation/auth/jwt-auth.js';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
administrationRoutes,
|
|
13
|
+
administrationUserRoutes,
|
|
14
|
+
} from '#presentation/routes/administration.js';
|
|
12
15
|
import {buildAuthRoutes} from '#presentation/routes/index.js';
|
|
13
16
|
|
|
14
17
|
const testConfig = vi.hoisted(
|
|
@@ -153,7 +156,7 @@ export async function createAuthTestApp(params?: {
|
|
|
153
156
|
}): Promise<FastifyInstance> {
|
|
154
157
|
const appConfig: AppConfig = {
|
|
155
158
|
auth: [createJwtAuthMethod()],
|
|
156
|
-
routes: [buildAuthRoutes(true, workspaces), administrationRoutes],
|
|
159
|
+
routes: [buildAuthRoutes(true, workspaces), administrationRoutes, administrationUserRoutes],
|
|
157
160
|
swagger: false,
|
|
158
161
|
};
|
|
159
162
|
if (params?.fastifyOptions) appConfig.fastifyOptions = params.fastifyOptions;
|