@shipfox/api-auth 17.0.0 → 19.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 +44 -0
- package/README.md +24 -9
- package/dist/core/administration.d.ts +15 -0
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +44 -2
- package/dist/core/administration.js.map +1 -1
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +5 -3
- package/dist/core/auth.js.map +1 -1
- package/dist/core/entities/agent-access.d.ts +75 -0
- package/dist/core/entities/agent-access.d.ts.map +1 -0
- package/dist/core/entities/agent-access.js +3 -0
- package/dist/core/entities/agent-access.js.map +1 -0
- package/dist/core/errors.d.ts +3 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +6 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/runner-session-token.d.ts.map +1 -1
- package/dist/core/runner-session-token.js +2 -1
- package/dist/core/runner-session-token.js.map +1 -1
- package/dist/db/admin-user-moderation.d.ts.map +1 -1
- package/dist/db/admin-user-moderation.js +30 -26
- package/dist/db/admin-user-moderation.js.map +1 -1
- package/dist/db/admin-user-summary.d.ts +22 -0
- package/dist/db/admin-user-summary.d.ts.map +1 -1
- package/dist/db/admin-user-summary.js +75 -1
- package/dist/db/admin-user-summary.js.map +1 -1
- package/dist/db/admin-users.d.ts +11 -0
- package/dist/db/admin-users.d.ts.map +1 -1
- package/dist/db/admin-users.js +4 -1
- package/dist/db/admin-users.js.map +1 -1
- package/dist/db/agent-access.d.ts +159 -0
- package/dist/db/agent-access.d.ts.map +1 -0
- package/dist/db/agent-access.js +267 -0
- package/dist/db/agent-access.js.map +1 -0
- package/dist/db/db.d.ts +2604 -238
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +7 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/schema/agent-access.d.ts +1204 -0
- package/dist/db/schema/agent-access.d.ts.map +1 -0
- package/dist/db/schema/agent-access.js +267 -0
- package/dist/db/schema/agent-access.js.map +1 -0
- package/dist/db/schema/users.d.ts.map +1 -1
- package/dist/db/schema/users.js +8 -3
- package/dist/db/schema/users.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- 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.map +1 -1
- package/dist/presentation/routes/administration.js +123 -20
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +10 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
- package/dist/presentation/routes/registration/signup.js +59 -50
- package/dist/presentation/routes/registration/signup.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0003_brainy_luckman.sql +6 -0
- package/drizzle/0004_yummy_runaways.sql +110 -0
- package/drizzle/meta/0003_snapshot.json +844 -0
- package/drizzle/meta/0004_snapshot.json +1751 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +7 -7
- package/src/core/admin-role.test.ts +70 -0
- package/src/core/administration.ts +78 -1
- package/src/core/auth.ts +3 -5
- package/src/core/entities/agent-access.ts +80 -0
- package/src/core/errors.ts +7 -0
- package/src/core/runner-session-token.test.ts +2 -0
- package/src/core/runner-session-token.ts +1 -0
- package/src/db/admin-user-moderation.ts +53 -45
- package/src/db/admin-user-summary.test.ts +228 -0
- package/src/db/admin-user-summary.ts +143 -1
- package/src/db/admin-users.ts +21 -1
- package/src/db/agent-access.test.ts +370 -0
- package/src/db/agent-access.ts +629 -0
- package/src/db/db.ts +14 -0
- package/src/db/schema/agent-access.ts +273 -0
- package/src/db/schema/users.ts +9 -3
- package/src/index.ts +6 -0
- package/src/metrics/instance.ts +1 -0
- package/src/presentation/routes/administration.test.ts +359 -2
- package/src/presentation/routes/administration.ts +142 -20
- package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
- package/src/presentation/routes/rate-limit.ts +4 -0
- package/src/presentation/routes/registration/signup.ts +52 -41
- package/test/globalSetup.ts +1 -1
- package/test/routes.ts +3 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {impersonateResponseSchema} from '@shipfox/api-auth-dto';
|
|
2
2
|
import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
|
|
3
3
|
import {userAccessTokenKey} from '@shipfox/node-auth-root-key';
|
|
4
|
+
import type {AppConfig, FastifyInstance} from '@shipfox/node-fastify';
|
|
4
5
|
import {hashOpaqueToken} from '@shipfox/node-tokens';
|
|
5
6
|
import {asc, eq, sql} from 'drizzle-orm';
|
|
6
|
-
import type {FastifyInstance} from 'fastify';
|
|
7
7
|
import {signUserToken, verifyUserToken} from '#core/jwt.js';
|
|
8
8
|
import {type AuthRateLimitAction, hashAuthRateLimitIdentifier} from '#core/rate-limit.js';
|
|
9
9
|
import {db} from '#db/db.js';
|
|
@@ -78,14 +78,48 @@ async function seedExhaustedIpBucket(params: {
|
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
type LoggerInstance = NonNullable<NonNullable<AppConfig['fastifyOptions']>['loggerInstance']>;
|
|
82
|
+
|
|
83
|
+
interface CapturedLog {
|
|
84
|
+
level: string;
|
|
85
|
+
args: unknown[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createCapturingLogger(logs: CapturedLog[]): LoggerInstance {
|
|
89
|
+
const logger = {
|
|
90
|
+
child: () => logger,
|
|
91
|
+
level: 'info',
|
|
92
|
+
silent: (...args: unknown[]) => logs.push({level: 'silent', args}),
|
|
93
|
+
fatal: (...args: unknown[]) => logs.push({level: 'fatal', args}),
|
|
94
|
+
error: (...args: unknown[]) => logs.push({level: 'error', args}),
|
|
95
|
+
warn: (...args: unknown[]) => logs.push({level: 'warn', args}),
|
|
96
|
+
info: (...args: unknown[]) => logs.push({level: 'info', args}),
|
|
97
|
+
debug: (...args: unknown[]) => logs.push({level: 'debug', args}),
|
|
98
|
+
trace: (...args: unknown[]) => logs.push({level: 'trace', args}),
|
|
99
|
+
};
|
|
100
|
+
return logger as unknown as LoggerInstance;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function directoryLogContexts(logs: CapturedLog[]): unknown[] {
|
|
104
|
+
return logs
|
|
105
|
+
.filter(
|
|
106
|
+
({level, args}) => level === 'info' && args[1] === 'Listed administrator user directory',
|
|
107
|
+
)
|
|
108
|
+
.map(({args}) => args[0]);
|
|
109
|
+
}
|
|
110
|
+
|
|
81
111
|
describe('Auth administration routes', () => {
|
|
82
112
|
let app: FastifyInstance;
|
|
113
|
+
const directoryLogs: CapturedLog[] = [];
|
|
83
114
|
|
|
84
115
|
beforeAll(async () => {
|
|
85
|
-
app = await createAuthTestApp(
|
|
116
|
+
app = await createAuthTestApp({
|
|
117
|
+
fastifyOptions: {loggerInstance: createCapturingLogger(directoryLogs)},
|
|
118
|
+
});
|
|
86
119
|
});
|
|
87
120
|
|
|
88
121
|
beforeEach(async () => {
|
|
122
|
+
directoryLogs.length = 0;
|
|
89
123
|
resetCapturedMail();
|
|
90
124
|
setImpersonationEnabled(true);
|
|
91
125
|
setAuthJwtExpiresIn('15m');
|
|
@@ -273,6 +307,12 @@ describe('Auth administration routes', () => {
|
|
|
273
307
|
});
|
|
274
308
|
|
|
275
309
|
expect(response.statusCode).toBe(404);
|
|
310
|
+
|
|
311
|
+
const directoryResponse = await app.inject({
|
|
312
|
+
method: 'GET',
|
|
313
|
+
url: '/admin/v1/auth/users/directory',
|
|
314
|
+
});
|
|
315
|
+
expect(directoryResponse.statusCode).toBe(404);
|
|
276
316
|
});
|
|
277
317
|
|
|
278
318
|
test('rejects an invalid bootstrap token without writing a grant or event', async () => {
|
|
@@ -514,6 +554,323 @@ describe('Auth administration routes', () => {
|
|
|
514
554
|
expect(byEmail.json().email).toBe(observer.email);
|
|
515
555
|
});
|
|
516
556
|
|
|
557
|
+
test('lists safe user summaries for owners, operators, and observers with cursor pagination', async () => {
|
|
558
|
+
const marker = `admin-user-directory-${crypto.randomUUID()}`;
|
|
559
|
+
const owner = await createVerifiedSession(`${marker}-owner`);
|
|
560
|
+
const operator = await createVerifiedSession(`${marker}-operator`);
|
|
561
|
+
const observer = await createVerifiedSession(`${marker}-observer`);
|
|
562
|
+
const target = await createVerifiedSession(`${marker}-target`);
|
|
563
|
+
const eligibilityMarker = `admin-user-directory-eligibility-${crypto.randomUUID()}`;
|
|
564
|
+
const eligibleTarget = await createVerifiedSession(`${eligibilityMarker}-eligible`);
|
|
565
|
+
const ineligibleTarget = await createVerifiedSession(`${eligibilityMarker}-ineligible`);
|
|
566
|
+
await db()
|
|
567
|
+
.update(users)
|
|
568
|
+
.set({status: 'suspended'})
|
|
569
|
+
.where(eq(users.id, ineligibleTarget.userId));
|
|
570
|
+
|
|
571
|
+
const bootstrap = await app.inject({
|
|
572
|
+
method: 'POST',
|
|
573
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
574
|
+
headers: authHeaders(owner.token, 'directory-bootstrap'),
|
|
575
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
576
|
+
});
|
|
577
|
+
expect(bootstrap.statusCode).toBe(201);
|
|
578
|
+
|
|
579
|
+
const observerGrant = await app.inject({
|
|
580
|
+
method: 'POST',
|
|
581
|
+
url: '/admin/auth/admin-grants',
|
|
582
|
+
headers: authHeaders(owner.token, 'directory-observer-grant'),
|
|
583
|
+
payload: {
|
|
584
|
+
user_id: observer.userId,
|
|
585
|
+
role: 'admin-observer',
|
|
586
|
+
reason: 'Directory read access',
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
expect(observerGrant.statusCode).toBe(201);
|
|
590
|
+
|
|
591
|
+
const operatorGrant = await app.inject({
|
|
592
|
+
method: 'POST',
|
|
593
|
+
url: '/admin/auth/admin-grants',
|
|
594
|
+
headers: authHeaders(owner.token, 'directory-operator-grant'),
|
|
595
|
+
payload: {
|
|
596
|
+
user_id: operator.userId,
|
|
597
|
+
role: 'admin-operator',
|
|
598
|
+
reason: 'Directory read access',
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
expect(operatorGrant.statusCode).toBe(201);
|
|
602
|
+
|
|
603
|
+
const firstPage = await app.inject({
|
|
604
|
+
method: 'GET',
|
|
605
|
+
url: `/admin/auth/users/directory?search=${encodeURIComponent(` ${marker} `)}&limit=2`,
|
|
606
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
607
|
+
});
|
|
608
|
+
expect(firstPage.statusCode).toBe(200);
|
|
609
|
+
expect(firstPage.json().users).toHaveLength(2);
|
|
610
|
+
expect(firstPage.json().next_cursor).toEqual(expect.any(String));
|
|
611
|
+
const firstPageLog = directoryLogContexts(directoryLogs).at(-1);
|
|
612
|
+
expect(firstPageLog).toMatchObject({
|
|
613
|
+
actorId: observer.userId,
|
|
614
|
+
requiredRole: 'admin-observer',
|
|
615
|
+
targetType: 'user-directory',
|
|
616
|
+
requestId: expect.any(String),
|
|
617
|
+
result: 'succeeded',
|
|
618
|
+
outcome: 'succeeded',
|
|
619
|
+
durationMs: expect.any(Number),
|
|
620
|
+
resultCountBucket: '1-10',
|
|
621
|
+
filterPresence: {search: true, status: false, impersonationEligible: false},
|
|
622
|
+
nextPagePresent: true,
|
|
623
|
+
});
|
|
624
|
+
expect(JSON.stringify(firstPageLog)).not.toContain(marker);
|
|
625
|
+
expect(JSON.stringify(firstPageLog)).not.toContain(firstPage.json().next_cursor);
|
|
626
|
+
|
|
627
|
+
const secondPage = await app.inject({
|
|
628
|
+
method: 'GET',
|
|
629
|
+
url: `/admin/auth/users/directory?search=${encodeURIComponent(marker)}&limit=2&cursor=${encodeURIComponent(firstPage.json().next_cursor)}`,
|
|
630
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
631
|
+
});
|
|
632
|
+
expect(secondPage.statusCode).toBe(200);
|
|
633
|
+
expect(secondPage.json().users).toHaveLength(2);
|
|
634
|
+
expect(secondPage.json().next_cursor).toBeNull();
|
|
635
|
+
|
|
636
|
+
const listedUsers = [...firstPage.json().users, ...secondPage.json().users];
|
|
637
|
+
expect(new Set(listedUsers.map((user: {id: string}) => user.id)).size).toBe(4);
|
|
638
|
+
expect(listedUsers).toEqual(
|
|
639
|
+
expect.arrayContaining([
|
|
640
|
+
expect.objectContaining({
|
|
641
|
+
id: owner.userId,
|
|
642
|
+
email: owner.email,
|
|
643
|
+
name: `${marker}-owner`,
|
|
644
|
+
status: 'active',
|
|
645
|
+
admin_role: 'admin-owner',
|
|
646
|
+
}),
|
|
647
|
+
expect.objectContaining({
|
|
648
|
+
id: operator.userId,
|
|
649
|
+
email: operator.email,
|
|
650
|
+
name: `${marker}-operator`,
|
|
651
|
+
status: 'active',
|
|
652
|
+
admin_role: 'admin-operator',
|
|
653
|
+
}),
|
|
654
|
+
expect.objectContaining({
|
|
655
|
+
id: observer.userId,
|
|
656
|
+
email: observer.email,
|
|
657
|
+
name: `${marker}-observer`,
|
|
658
|
+
status: 'active',
|
|
659
|
+
admin_role: 'admin-observer',
|
|
660
|
+
}),
|
|
661
|
+
expect.objectContaining({
|
|
662
|
+
id: target.userId,
|
|
663
|
+
email: target.email,
|
|
664
|
+
name: `${marker}-target`,
|
|
665
|
+
status: 'active',
|
|
666
|
+
admin_role: null,
|
|
667
|
+
}),
|
|
668
|
+
]),
|
|
669
|
+
);
|
|
670
|
+
for (const user of listedUsers) {
|
|
671
|
+
expect(user).not.toHaveProperty('hashed_password');
|
|
672
|
+
expect(user).not.toHaveProperty('sessions');
|
|
673
|
+
expect(user).not.toHaveProperty('provider_payload');
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const eligibleUsers = await app.inject({
|
|
677
|
+
method: 'GET',
|
|
678
|
+
url: `/admin/auth/users/directory?search=${encodeURIComponent(marker)}&status=active&impersonation_eligible=true`,
|
|
679
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
680
|
+
});
|
|
681
|
+
expect(eligibleUsers.statusCode).toBe(200);
|
|
682
|
+
expect(eligibleUsers.json().users).toEqual([
|
|
683
|
+
expect.objectContaining({id: target.userId, admin_role: null}),
|
|
684
|
+
]);
|
|
685
|
+
expect(eligibleUsers.json().next_cursor).toBeNull();
|
|
686
|
+
|
|
687
|
+
const ineligibleUsers = await app.inject({
|
|
688
|
+
method: 'GET',
|
|
689
|
+
url: `/admin/auth/users/directory?search=${encodeURIComponent(eligibilityMarker)}&impersonation_eligible=false`,
|
|
690
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
691
|
+
});
|
|
692
|
+
expect(ineligibleUsers.statusCode).toBe(200);
|
|
693
|
+
expect(ineligibleUsers.json().users).toEqual([
|
|
694
|
+
expect.objectContaining({id: ineligibleTarget.userId, status: 'suspended'}),
|
|
695
|
+
]);
|
|
696
|
+
expect(ineligibleUsers.json().users).not.toEqual(
|
|
697
|
+
expect.arrayContaining([expect.objectContaining({id: eligibleTarget.userId})]),
|
|
698
|
+
);
|
|
699
|
+
expect(ineligibleUsers.json().next_cursor).toBeNull();
|
|
700
|
+
|
|
701
|
+
const emptyPage = await app.inject({
|
|
702
|
+
method: 'GET',
|
|
703
|
+
url: `/admin/auth/users/directory?search=${encodeURIComponent(`missing-${marker}`)}`,
|
|
704
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
705
|
+
});
|
|
706
|
+
expect(emptyPage.statusCode).toBe(200);
|
|
707
|
+
expect(emptyPage.json()).toEqual({users: [], next_cursor: null});
|
|
708
|
+
|
|
709
|
+
for (const actor of [owner, operator]) {
|
|
710
|
+
const response = await app.inject({
|
|
711
|
+
method: 'GET',
|
|
712
|
+
url: `/admin/auth/users/directory?search=${encodeURIComponent(marker)}&limit=100`,
|
|
713
|
+
headers: {authorization: `Bearer ${actor.token}`},
|
|
714
|
+
});
|
|
715
|
+
expect(response.statusCode).toBe(200);
|
|
716
|
+
expect(response.json().users).toHaveLength(4);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const observerActionEvents = await db()
|
|
720
|
+
.select()
|
|
721
|
+
.from(authOutbox)
|
|
722
|
+
.where(sql`${authOutbox.payload}->>'actorId' = ${observer.userId}`);
|
|
723
|
+
expect(observerActionEvents).toHaveLength(0);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test('translates directory filter errors and rejects ordinary, suspended, and impersonated sessions', async () => {
|
|
727
|
+
const marker = `admin-user-directory-authz-${crypto.randomUUID()}`;
|
|
728
|
+
const owner = await createVerifiedSession(`${marker}-owner`);
|
|
729
|
+
const observer = await createVerifiedSession(`${marker}-observer`);
|
|
730
|
+
const ordinary = await createVerifiedSession(`${marker}-ordinary`);
|
|
731
|
+
const suspended = await createVerifiedSession(`${marker}-suspended`);
|
|
732
|
+
|
|
733
|
+
const bootstrap = await app.inject({
|
|
734
|
+
method: 'POST',
|
|
735
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
736
|
+
headers: authHeaders(owner.token, 'directory-authz-bootstrap'),
|
|
737
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
738
|
+
});
|
|
739
|
+
expect(bootstrap.statusCode).toBe(201);
|
|
740
|
+
|
|
741
|
+
for (const [userId, role, idempotencyKey] of [
|
|
742
|
+
[observer.userId, 'admin-observer', 'directory-authz-observer-grant'],
|
|
743
|
+
[suspended.userId, 'admin-observer', 'directory-authz-suspended-grant'],
|
|
744
|
+
] as const) {
|
|
745
|
+
const grant = await app.inject({
|
|
746
|
+
method: 'POST',
|
|
747
|
+
url: '/admin/auth/admin-grants',
|
|
748
|
+
headers: authHeaders(owner.token, idempotencyKey),
|
|
749
|
+
payload: {user_id: userId, role, reason: 'Directory read access'},
|
|
750
|
+
});
|
|
751
|
+
expect(grant.statusCode).toBe(201);
|
|
752
|
+
}
|
|
753
|
+
await db().update(users).set({status: 'suspended'}).where(eq(users.id, suspended.userId));
|
|
754
|
+
|
|
755
|
+
const ordinaryResponse = await app.inject({
|
|
756
|
+
method: 'GET',
|
|
757
|
+
url: '/admin/auth/users/directory',
|
|
758
|
+
headers: {authorization: `Bearer ${ordinary.token}`},
|
|
759
|
+
});
|
|
760
|
+
expect(ordinaryResponse.statusCode).toBe(403);
|
|
761
|
+
expect(ordinaryResponse.json()).toEqual({
|
|
762
|
+
code: 'forbidden',
|
|
763
|
+
details: {required_role: 'admin-observer'},
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const suspendedResponse = await app.inject({
|
|
767
|
+
method: 'GET',
|
|
768
|
+
url: '/admin/auth/users/directory',
|
|
769
|
+
headers: {authorization: `Bearer ${suspended.token}`},
|
|
770
|
+
});
|
|
771
|
+
expect(suspendedResponse.statusCode).toBe(403);
|
|
772
|
+
expect(suspendedResponse.json().code).toBe('forbidden');
|
|
773
|
+
|
|
774
|
+
const impersonatedResponse = await app.inject({
|
|
775
|
+
method: 'GET',
|
|
776
|
+
url: '/admin/auth/users/directory',
|
|
777
|
+
headers: {authorization: `Bearer ${await impersonatedToken(owner.userId, owner.email)}`},
|
|
778
|
+
});
|
|
779
|
+
expect(impersonatedResponse.statusCode).toBe(403);
|
|
780
|
+
expect(impersonatedResponse.json()).toEqual({code: 'admin-role-required'});
|
|
781
|
+
|
|
782
|
+
const invalidFilter = await app.inject({
|
|
783
|
+
method: 'GET',
|
|
784
|
+
url: '/admin/auth/users/directory?status=suspended&impersonation_eligible=true',
|
|
785
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
786
|
+
});
|
|
787
|
+
expect(invalidFilter.statusCode).toBe(400);
|
|
788
|
+
expect(invalidFilter.json().code).toBe('validation-error');
|
|
789
|
+
|
|
790
|
+
const invalidCursor = await app.inject({
|
|
791
|
+
method: 'GET',
|
|
792
|
+
url: '/admin/auth/users/directory?cursor=not-a-real-cursor',
|
|
793
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
794
|
+
});
|
|
795
|
+
expect(invalidCursor.statusCode).toBe(400);
|
|
796
|
+
expect(invalidCursor.json().code).toBe('invalid-cursor');
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test('uses independent source-IP and actor buckets for directory reads', async () => {
|
|
800
|
+
const owner = await createVerifiedSession('admin-directory-rate-limit-owner');
|
|
801
|
+
const observer = await createVerifiedSession('admin-directory-rate-limit-observer');
|
|
802
|
+
|
|
803
|
+
const bootstrap = await app.inject({
|
|
804
|
+
method: 'POST',
|
|
805
|
+
url: '/admin/auth/admin-grants/bootstrap',
|
|
806
|
+
headers: authHeaders(owner.token, 'directory-rate-limit-bootstrap'),
|
|
807
|
+
payload: {bootstrap_token: BOOTSTRAP_TOKEN},
|
|
808
|
+
});
|
|
809
|
+
expect(bootstrap.statusCode).toBe(201);
|
|
810
|
+
|
|
811
|
+
const grant = await app.inject({
|
|
812
|
+
method: 'POST',
|
|
813
|
+
url: '/admin/auth/admin-grants',
|
|
814
|
+
headers: authHeaders(owner.token, 'directory-rate-limit-grant'),
|
|
815
|
+
payload: {
|
|
816
|
+
user_id: observer.userId,
|
|
817
|
+
role: 'admin-observer',
|
|
818
|
+
reason: 'Directory read access',
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
expect(grant.statusCode).toBe(201);
|
|
822
|
+
|
|
823
|
+
await seedExhaustedIpBucket({
|
|
824
|
+
action: 'directory',
|
|
825
|
+
identifier: '127.0.0.1',
|
|
826
|
+
limit: 60,
|
|
827
|
+
windowSeconds: 5 * 60,
|
|
828
|
+
});
|
|
829
|
+
const ipBlocked = await app.inject({
|
|
830
|
+
method: 'GET',
|
|
831
|
+
url: '/admin/auth/users/directory',
|
|
832
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
833
|
+
});
|
|
834
|
+
expect(ipBlocked.statusCode).toBe(429);
|
|
835
|
+
expect(ipBlocked.json().code).toBe('rate-limited');
|
|
836
|
+
|
|
837
|
+
await seedExhaustedIpBucket({
|
|
838
|
+
action: 'directory',
|
|
839
|
+
scope: 'actor',
|
|
840
|
+
identifier: observer.userId,
|
|
841
|
+
limit: 60,
|
|
842
|
+
windowSeconds: 5 * 60,
|
|
843
|
+
});
|
|
844
|
+
const actorBlocked = await app.inject({
|
|
845
|
+
method: 'GET',
|
|
846
|
+
url: '/admin/auth/users/directory',
|
|
847
|
+
remoteAddress: '10.0.0.2',
|
|
848
|
+
headers: {authorization: `Bearer ${observer.token}`},
|
|
849
|
+
});
|
|
850
|
+
expect(actorBlocked.statusCode).toBe(429);
|
|
851
|
+
expect(actorBlocked.json().code).toBe('rate-limited');
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test('rate-limits directory requests before checking the observer role', async () => {
|
|
855
|
+
const ordinary = await createVerifiedSession('admin-directory-rate-limit-ordinary');
|
|
856
|
+
|
|
857
|
+
await seedExhaustedIpBucket({
|
|
858
|
+
action: 'directory',
|
|
859
|
+
identifier: '127.0.0.1',
|
|
860
|
+
limit: 60,
|
|
861
|
+
windowSeconds: 5 * 60,
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
const response = await app.inject({
|
|
865
|
+
method: 'GET',
|
|
866
|
+
url: '/admin/auth/users/directory',
|
|
867
|
+
headers: {authorization: `Bearer ${ordinary.token}`},
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
expect(response.statusCode).toBe(429);
|
|
871
|
+
expect(response.json().code).toBe('rate-limited');
|
|
872
|
+
});
|
|
873
|
+
|
|
517
874
|
test('bounds and deterministically paginates administrator grant summaries for observers', async () => {
|
|
518
875
|
const owner = await createVerifiedSession('admin-summary-owner');
|
|
519
876
|
const observer = await createVerifiedSession('admin-summary-observer');
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
} from '@shipfox/api-auth-context';
|
|
7
7
|
import {
|
|
8
8
|
adminBootstrapStateSchema,
|
|
9
|
+
administratorUserDirectoryQuerySchema,
|
|
10
|
+
administratorUserDirectoryResponseSchema,
|
|
9
11
|
administratorUserLookupQuerySchema,
|
|
10
12
|
administratorUserMutationResponseSchema,
|
|
11
13
|
administratorUserSummarySchema,
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
grantAdministratorRole,
|
|
38
40
|
impersonateUser,
|
|
39
41
|
listAdministratorGrantSummaries,
|
|
42
|
+
listAdministratorUsers,
|
|
40
43
|
reactivateAdministratorUser,
|
|
41
44
|
revokeAdministratorGrant,
|
|
42
45
|
revokeAdministratorUserSessions,
|
|
@@ -60,6 +63,7 @@ import {
|
|
|
60
63
|
ImpersonationExpiredError,
|
|
61
64
|
ImpersonationTargetNotActiveError,
|
|
62
65
|
InvalidAdminBootstrapTokenError,
|
|
66
|
+
InvalidAdministratorUserDirectoryFilterError,
|
|
63
67
|
InvalidCredentialsError,
|
|
64
68
|
LastAdminOwnerError,
|
|
65
69
|
UserNotFoundError,
|
|
@@ -136,13 +140,77 @@ function toAdministratorUserMutationDto(result: {
|
|
|
136
140
|
};
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
function directoryResultCountBucket(count: number): '0' | '1-10' | '11-50' | '51-100' {
|
|
144
|
+
if (count === 0) return '0';
|
|
145
|
+
if (count <= 10) return '1-10';
|
|
146
|
+
if (count <= 50) return '11-50';
|
|
147
|
+
return '51-100';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function logAdministratorUserDirectoryRead(params: {
|
|
151
|
+
request: FastifyRequest;
|
|
152
|
+
actorId: string;
|
|
153
|
+
outcome: 'succeeded' | 'failed';
|
|
154
|
+
durationMs: number;
|
|
155
|
+
resultCount: number;
|
|
156
|
+
filterPresence: {
|
|
157
|
+
search: boolean;
|
|
158
|
+
status: boolean;
|
|
159
|
+
impersonationEligible: boolean;
|
|
160
|
+
};
|
|
161
|
+
nextPagePresent: boolean;
|
|
162
|
+
}): void {
|
|
163
|
+
try {
|
|
164
|
+
params.request.log.info(
|
|
165
|
+
{
|
|
166
|
+
actorId: params.actorId,
|
|
167
|
+
requiredRole: 'admin-observer',
|
|
168
|
+
targetType: 'user-directory',
|
|
169
|
+
requestId: params.request.id,
|
|
170
|
+
result: params.outcome,
|
|
171
|
+
outcome: params.outcome,
|
|
172
|
+
durationMs: params.durationMs,
|
|
173
|
+
resultCountBucket: directoryResultCountBucket(params.resultCount),
|
|
174
|
+
filterPresence: params.filterPresence,
|
|
175
|
+
nextPagePresent: params.nextPagePresent,
|
|
176
|
+
},
|
|
177
|
+
'Listed administrator user directory',
|
|
178
|
+
);
|
|
179
|
+
} catch {
|
|
180
|
+
// Logging must not change the directory read outcome.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function translateImpersonationEligibilityError(error: unknown): ClientError | undefined {
|
|
185
|
+
// The mint primitive re-checks login eligibility on the row it reads, and a
|
|
186
|
+
// concurrent suspension or unverification between the in-transaction ladder
|
|
187
|
+
// read and that read surfaces these errors. Map them to the same documented
|
|
188
|
+
// client error instead of a generic 500.
|
|
189
|
+
if (error instanceof EmailNotVerifiedError || error instanceof InvalidCredentialsError) {
|
|
190
|
+
return new ClientError('User cannot be impersonated', 'impersonation-target-not-active', {
|
|
191
|
+
status: 403,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (error instanceof ImpersonationExpiredError) {
|
|
195
|
+
return new ClientError('Impersonation session has expired', 'impersonation-expired', {
|
|
196
|
+
status: 410,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
139
202
|
function translateAdministrationError(error: unknown): never {
|
|
203
|
+
const impersonationEligibilityError = translateImpersonationEligibilityError(error);
|
|
204
|
+
if (impersonationEligibilityError) throw impersonationEligibilityError;
|
|
140
205
|
if (error instanceof AdminRoleRequiredError) {
|
|
141
206
|
throw new ClientError('Administrator role required', 'forbidden', {
|
|
142
207
|
status: 403,
|
|
143
208
|
details: {required_role: error.minimumRole},
|
|
144
209
|
});
|
|
145
210
|
}
|
|
211
|
+
if (error instanceof InvalidAdministratorUserDirectoryFilterError) {
|
|
212
|
+
throw new ClientError(error.message, 'validation-error', {status: 400});
|
|
213
|
+
}
|
|
146
214
|
if (error instanceof InvalidAdminBootstrapTokenError) {
|
|
147
215
|
throw new ClientError('Bootstrap token is invalid', 'bootstrap-token-invalid', {
|
|
148
216
|
status: 403,
|
|
@@ -196,20 +264,6 @@ function translateAdministrationError(error: unknown): never {
|
|
|
196
264
|
status: 403,
|
|
197
265
|
});
|
|
198
266
|
}
|
|
199
|
-
// The mint primitive re-checks login eligibility (active, verified) on the
|
|
200
|
-
// row it reads, and a concurrent suspension or unverification between the
|
|
201
|
-
// in-transaction ladder read and that read surfaces these errors. Map them
|
|
202
|
-
// to the same documented client error instead of a generic 500.
|
|
203
|
-
if (error instanceof EmailNotVerifiedError || error instanceof InvalidCredentialsError) {
|
|
204
|
-
throw new ClientError('User cannot be impersonated', 'impersonation-target-not-active', {
|
|
205
|
-
status: 403,
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
if (error instanceof ImpersonationExpiredError) {
|
|
209
|
-
throw new ClientError('Impersonation session has expired', 'impersonation-expired', {
|
|
210
|
-
status: 410,
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
267
|
throw error;
|
|
214
268
|
}
|
|
215
269
|
|
|
@@ -288,16 +342,78 @@ const userLookupRoute = defineRoute({
|
|
|
288
342
|
const {id, user_id: userId, email} = request.query;
|
|
289
343
|
const lookupId = id ?? userId;
|
|
290
344
|
const actorId = requireActorId(request);
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
? await findAdministratorUserSummary({actorId, email})
|
|
295
|
-
: undefined;
|
|
345
|
+
let user: Awaited<ReturnType<typeof findAdministratorUserSummary>>;
|
|
346
|
+
if (lookupId) user = await findAdministratorUserSummary({actorId, id: lookupId});
|
|
347
|
+
else if (email) user = await findAdministratorUserSummary({actorId, email});
|
|
296
348
|
if (!user) throw new UserNotFoundError(lookupId ?? email ?? 'unknown');
|
|
297
349
|
return toAdministratorUserSummaryDto(user);
|
|
298
350
|
},
|
|
299
351
|
});
|
|
300
352
|
|
|
353
|
+
const userDirectoryRoute = defineRoute({
|
|
354
|
+
method: 'GET',
|
|
355
|
+
path: '/directory',
|
|
356
|
+
description: 'List bounded administrator-safe user summaries for directory browsing.',
|
|
357
|
+
schema: {
|
|
358
|
+
querystring: administratorUserDirectoryQuerySchema,
|
|
359
|
+
response: {200: administratorUserDirectoryResponseSchema},
|
|
360
|
+
},
|
|
361
|
+
preHandler: createAuthActorRateLimitPreHandler('directory'),
|
|
362
|
+
errorHandler: translateAdministrationError,
|
|
363
|
+
handler: async (request) => {
|
|
364
|
+
const actorId = requireActorId(request);
|
|
365
|
+
const startedAt = performance.now();
|
|
366
|
+
const {
|
|
367
|
+
search,
|
|
368
|
+
status,
|
|
369
|
+
impersonation_eligible: impersonationEligible,
|
|
370
|
+
limit,
|
|
371
|
+
cursor,
|
|
372
|
+
} = request.query;
|
|
373
|
+
let resultCount = 0;
|
|
374
|
+
let nextPagePresent = false;
|
|
375
|
+
let outcome: 'succeeded' | 'failed' = 'failed';
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const decodedCursor = decodeTimestampIdCursor(cursor);
|
|
379
|
+
if (cursor && !decodedCursor) {
|
|
380
|
+
throw new ClientError('Invalid cursor', 'invalid-cursor', {status: 400});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const result = await listAdministratorUsers({
|
|
384
|
+
actorId,
|
|
385
|
+
limit,
|
|
386
|
+
...(decodedCursor ? {cursor: decodedCursor} : {}),
|
|
387
|
+
...(search !== undefined ? {search} : {}),
|
|
388
|
+
...(status !== undefined ? {status} : {}),
|
|
389
|
+
...(impersonationEligible !== undefined ? {eligible: impersonationEligible} : {}),
|
|
390
|
+
});
|
|
391
|
+
resultCount = result.users.length;
|
|
392
|
+
nextPagePresent = result.nextCursor !== null;
|
|
393
|
+
outcome = 'succeeded';
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
users: result.users.map(toAdministratorUserSummaryDto),
|
|
397
|
+
next_cursor: result.nextCursor ? encodeTimestampIdCursor(result.nextCursor) : null,
|
|
398
|
+
};
|
|
399
|
+
} finally {
|
|
400
|
+
logAdministratorUserDirectoryRead({
|
|
401
|
+
request,
|
|
402
|
+
actorId,
|
|
403
|
+
outcome,
|
|
404
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
405
|
+
resultCount,
|
|
406
|
+
filterPresence: {
|
|
407
|
+
search: search !== undefined,
|
|
408
|
+
status: status !== undefined,
|
|
409
|
+
impersonationEligible: impersonationEligible !== undefined,
|
|
410
|
+
},
|
|
411
|
+
nextPagePresent,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
|
|
301
417
|
const suspendUserRoute = defineRoute({
|
|
302
418
|
method: 'POST',
|
|
303
419
|
path: '/:userId/suspend',
|
|
@@ -485,7 +601,13 @@ export function createAdministrationUserRoutes(
|
|
|
485
601
|
adoptAdministrationActorGuard({
|
|
486
602
|
prefix: '/admin/auth/users',
|
|
487
603
|
auth: AUTH_USER,
|
|
488
|
-
routes: [
|
|
604
|
+
routes: [
|
|
605
|
+
userDirectoryRoute,
|
|
606
|
+
userLookupRoute,
|
|
607
|
+
suspendUserRoute,
|
|
608
|
+
reactivateUserRoute,
|
|
609
|
+
revokeUserSessionsRoute,
|
|
610
|
+
],
|
|
489
611
|
}),
|
|
490
612
|
// The impersonate route mounts outside the adopted guard on purpose: its
|
|
491
613
|
// limiter must run before the impersonated-session rejection (see
|
|
@@ -4,6 +4,12 @@ import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
|
4
4
|
import {resendEmailVerification} from '#core/auth.js';
|
|
5
5
|
import {createAuthRateLimitPreHandler} from '#presentation/routes/rate-limit.js';
|
|
6
6
|
|
|
7
|
+
function emailChallengeStatus(code: EmailChallengeError['code']): 400 | 410 | 429 {
|
|
8
|
+
if (code === 'limited') return 429;
|
|
9
|
+
if (code === 'expired') return 410;
|
|
10
|
+
return 400;
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
export const verifyEmailResendRoute = defineRoute({
|
|
8
14
|
method: 'POST',
|
|
9
15
|
path: '/verify-email/resend',
|
|
@@ -18,7 +24,7 @@ export const verifyEmailResendRoute = defineRoute({
|
|
|
18
24
|
errorHandler: (error) => {
|
|
19
25
|
if (error instanceof EmailChallengeError) {
|
|
20
26
|
throw new ClientError(error.message, `email-challenge-${error.code}`, {
|
|
21
|
-
status: error.code
|
|
27
|
+
status: emailChallengeStatus(error.code),
|
|
22
28
|
...(error.retryAt ? {details: {retry_at: error.retryAt.toISOString()}} : {}),
|
|
23
29
|
});
|
|
24
30
|
}
|
|
@@ -29,6 +29,10 @@ const policies: Record<
|
|
|
29
29
|
lookup: {
|
|
30
30
|
ip: {limit: 60, windowSeconds: 5 * 60},
|
|
31
31
|
},
|
|
32
|
+
directory: {
|
|
33
|
+
ip: {limit: 60, windowSeconds: 5 * 60},
|
|
34
|
+
actor: {limit: 60, windowSeconds: 5 * 60},
|
|
35
|
+
},
|
|
32
36
|
impersonate: {
|
|
33
37
|
// Bounds mint and probing attempts: the IP bucket bounds aggregate source
|
|
34
38
|
// traffic, and the actor bucket is a per-actor cap that prevents an actor
|