@shipfox/api-auth 18.0.0 → 20.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.
Files changed (151) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +24 -9
  4. package/dist/core/administration.d.ts +15 -0
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +44 -2
  7. package/dist/core/administration.js.map +1 -1
  8. package/dist/core/auth.d.ts.map +1 -1
  9. package/dist/core/auth.js +5 -3
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/core/cimd.d.ts +39 -0
  12. package/dist/core/cimd.d.ts.map +1 -0
  13. package/dist/core/cimd.js +392 -0
  14. package/dist/core/cimd.js.map +1 -0
  15. package/dist/core/entities/agent-access.d.ts +75 -0
  16. package/dist/core/entities/agent-access.d.ts.map +1 -0
  17. package/dist/core/entities/agent-access.js +3 -0
  18. package/dist/core/entities/agent-access.js.map +1 -0
  19. package/dist/core/errors.d.ts +17 -0
  20. package/dist/core/errors.d.ts.map +1 -1
  21. package/dist/core/errors.js +32 -0
  22. package/dist/core/errors.js.map +1 -1
  23. package/dist/core/oauth-client-resolver.d.ts +54 -0
  24. package/dist/core/oauth-client-resolver.d.ts.map +1 -0
  25. package/dist/core/oauth-client-resolver.js +154 -0
  26. package/dist/core/oauth-client-resolver.js.map +1 -0
  27. package/dist/core/oauth-client.d.ts +39 -0
  28. package/dist/core/oauth-client.d.ts.map +1 -0
  29. package/dist/core/oauth-client.js +236 -0
  30. package/dist/core/oauth-client.js.map +1 -0
  31. package/dist/db/admin-user-moderation.d.ts.map +1 -1
  32. package/dist/db/admin-user-moderation.js +30 -26
  33. package/dist/db/admin-user-moderation.js.map +1 -1
  34. package/dist/db/admin-user-summary.d.ts +22 -0
  35. package/dist/db/admin-user-summary.d.ts.map +1 -1
  36. package/dist/db/admin-user-summary.js +75 -1
  37. package/dist/db/admin-user-summary.js.map +1 -1
  38. package/dist/db/admin-users.d.ts +11 -0
  39. package/dist/db/admin-users.d.ts.map +1 -1
  40. package/dist/db/admin-users.js +4 -1
  41. package/dist/db/admin-users.js.map +1 -1
  42. package/dist/db/agent-access-retention.d.ts +10 -0
  43. package/dist/db/agent-access-retention.d.ts.map +1 -0
  44. package/dist/db/agent-access-retention.js +11 -0
  45. package/dist/db/agent-access-retention.js.map +1 -0
  46. package/dist/db/agent-access.d.ts +255 -0
  47. package/dist/db/agent-access.d.ts.map +1 -0
  48. package/dist/db/agent-access.js +705 -0
  49. package/dist/db/agent-access.js.map +1 -0
  50. package/dist/db/db.d.ts +2604 -238
  51. package/dist/db/db.d.ts.map +1 -1
  52. package/dist/db/db.js +7 -0
  53. package/dist/db/db.js.map +1 -1
  54. package/dist/db/schema/agent-access.d.ts +1204 -0
  55. package/dist/db/schema/agent-access.d.ts.map +1 -0
  56. package/dist/db/schema/agent-access.js +267 -0
  57. package/dist/db/schema/agent-access.js.map +1 -0
  58. package/dist/db/schema/users.d.ts.map +1 -1
  59. package/dist/db/schema/users.js +8 -3
  60. package/dist/db/schema/users.js.map +1 -1
  61. package/dist/index.d.ts +11 -2
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +26 -2
  64. package/dist/index.js.map +1 -1
  65. package/dist/metrics/instance.d.ts +1 -1
  66. package/dist/metrics/instance.d.ts.map +1 -1
  67. package/dist/metrics/instance.js.map +1 -1
  68. package/dist/presentation/routes/administration.d.ts.map +1 -1
  69. package/dist/presentation/routes/administration.js +123 -20
  70. package/dist/presentation/routes/administration.js.map +1 -1
  71. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
  72. package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
  73. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
  74. package/dist/presentation/routes/oauth.d.ts +9 -0
  75. package/dist/presentation/routes/oauth.d.ts.map +1 -0
  76. package/dist/presentation/routes/oauth.js +150 -0
  77. package/dist/presentation/routes/oauth.js.map +1 -0
  78. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  79. package/dist/presentation/routes/rate-limit.js +34 -0
  80. package/dist/presentation/routes/rate-limit.js.map +1 -1
  81. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  82. package/dist/presentation/routes/registration/signup.js +59 -50
  83. package/dist/presentation/routes/registration/signup.js.map +1 -1
  84. package/dist/temporal/activities/agent-access-retention.d.ts +13 -0
  85. package/dist/temporal/activities/agent-access-retention.d.ts.map +1 -0
  86. package/dist/temporal/activities/agent-access-retention.js +58 -0
  87. package/dist/temporal/activities/agent-access-retention.js.map +1 -0
  88. package/dist/temporal/activities/index.d.ts +5 -0
  89. package/dist/temporal/activities/index.d.ts.map +1 -0
  90. package/dist/temporal/activities/index.js +8 -0
  91. package/dist/temporal/activities/index.js.map +1 -0
  92. package/dist/temporal/constants.d.ts +2 -0
  93. package/dist/temporal/constants.d.ts.map +1 -0
  94. package/dist/temporal/constants.js +3 -0
  95. package/dist/temporal/constants.js.map +1 -0
  96. package/dist/temporal/workflows/agent-access-retention-cron.d.ts +2 -0
  97. package/dist/temporal/workflows/agent-access-retention-cron.d.ts.map +1 -0
  98. package/dist/temporal/workflows/agent-access-retention-cron.js +22 -0
  99. package/dist/temporal/workflows/agent-access-retention-cron.js.map +1 -0
  100. package/dist/temporal/workflows/index.bundle.js +25227 -0
  101. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  102. package/dist/temporal/workflows/index.d.ts +2 -0
  103. package/dist/temporal/workflows/index.d.ts.map +1 -0
  104. package/dist/temporal/workflows/index.js +3 -0
  105. package/dist/temporal/workflows/index.js.map +1 -0
  106. package/dist/tsconfig.test.tsbuildinfo +1 -1
  107. package/drizzle/0003_brainy_luckman.sql +6 -0
  108. package/drizzle/0004_yummy_runaways.sql +110 -0
  109. package/drizzle/meta/0003_snapshot.json +844 -0
  110. package/drizzle/meta/0004_snapshot.json +1751 -0
  111. package/drizzle/meta/_journal.json +14 -0
  112. package/package.json +11 -8
  113. package/src/core/admin-role.test.ts +70 -0
  114. package/src/core/administration.ts +78 -1
  115. package/src/core/auth.ts +3 -5
  116. package/src/core/cimd.test.ts +213 -0
  117. package/src/core/cimd.ts +532 -0
  118. package/src/core/entities/agent-access.ts +80 -0
  119. package/src/core/errors.ts +48 -0
  120. package/src/core/oauth-client-resolver.test.ts +198 -0
  121. package/src/core/oauth-client-resolver.ts +253 -0
  122. package/src/core/oauth-client.test.ts +157 -0
  123. package/src/core/oauth-client.ts +331 -0
  124. package/src/db/admin-user-moderation.ts +53 -45
  125. package/src/db/admin-user-summary.test.ts +228 -0
  126. package/src/db/admin-user-summary.ts +143 -1
  127. package/src/db/admin-users.ts +21 -1
  128. package/src/db/agent-access-retention.ts +10 -0
  129. package/src/db/agent-access.test.ts +1064 -0
  130. package/src/db/agent-access.ts +1491 -0
  131. package/src/db/db.ts +14 -0
  132. package/src/db/schema/agent-access.ts +273 -0
  133. package/src/db/schema/users.ts +9 -3
  134. package/src/index.test.ts +27 -0
  135. package/src/index.ts +82 -0
  136. package/src/metrics/instance.ts +6 -1
  137. package/src/presentation/routes/administration.test.ts +359 -2
  138. package/src/presentation/routes/administration.ts +142 -20
  139. package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
  140. package/src/presentation/routes/oauth.test.ts +133 -0
  141. package/src/presentation/routes/oauth.ts +144 -0
  142. package/src/presentation/routes/rate-limit.ts +16 -0
  143. package/src/presentation/routes/registration/signup.ts +52 -41
  144. package/src/temporal/activities/agent-access-retention.ts +76 -0
  145. package/src/temporal/activities/index.ts +5 -0
  146. package/src/temporal/constants.ts +1 -0
  147. package/src/temporal/workflows/agent-access-retention-cron.ts +23 -0
  148. package/src/temporal/workflows/index.ts +1 -0
  149. package/test/globalSetup.ts +1 -1
  150. package/test/routes.ts +3 -1
  151. 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
- const user = lookupId
292
- ? await findAdministratorUserSummary({actorId, id: lookupId})
293
- : email
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: [userLookupRoute, suspendUserRoute, reactivateUserRoute, revokeUserSessionsRoute],
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 === 'limited' ? 429 : error.code === 'expired' ? 410 : 400,
27
+ status: emailChallengeStatus(error.code),
22
28
  ...(error.retryAt ? {details: {retry_at: error.retryAt.toISOString()}} : {}),
23
29
  });
24
30
  }