@shipfox/api-auth 10.0.0 → 10.2.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 (82) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +20 -1
  4. package/dist/core/administration.d.ts +18 -1
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +111 -15
  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 +11 -2
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/db/admin-command.d.ts +18 -0
  12. package/dist/db/admin-command.d.ts.map +1 -0
  13. package/dist/db/admin-command.js +37 -0
  14. package/dist/db/admin-command.js.map +1 -0
  15. package/dist/db/admin-grants.d.ts +9 -0
  16. package/dist/db/admin-grants.d.ts.map +1 -1
  17. package/dist/db/admin-grants.js +31 -40
  18. package/dist/db/admin-grants.js.map +1 -1
  19. package/dist/db/admin-user-moderation.d.ts +29 -0
  20. package/dist/db/admin-user-moderation.d.ts.map +1 -0
  21. package/dist/db/admin-user-moderation.js +156 -0
  22. package/dist/db/admin-user-moderation.js.map +1 -0
  23. package/dist/db/admin-user-summary.d.ts +14 -0
  24. package/dist/db/admin-user-summary.d.ts.map +1 -0
  25. package/dist/db/admin-user-summary.js +31 -0
  26. package/dist/db/admin-user-summary.js.map +1 -0
  27. package/dist/db/admin-users.d.ts +2 -11
  28. package/dist/db/admin-users.d.ts.map +1 -1
  29. package/dist/db/admin-users.js +2 -27
  30. package/dist/db/admin-users.js.map +1 -1
  31. package/dist/db/refresh-tokens.d.ts +6 -0
  32. package/dist/db/refresh-tokens.d.ts.map +1 -1
  33. package/dist/db/refresh-tokens.js +31 -0
  34. package/dist/db/refresh-tokens.js.map +1 -1
  35. package/dist/db/schema/admin-command-results.d.ts +19 -2
  36. package/dist/db/schema/admin-command-results.d.ts.map +1 -1
  37. package/dist/db/schema/admin-command-results.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/metrics/instance.d.ts +1 -1
  43. package/dist/metrics/instance.d.ts.map +1 -1
  44. package/dist/metrics/instance.js.map +1 -1
  45. package/dist/presentation/auth/bearer-token-auth.d.ts +1 -0
  46. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
  47. package/dist/presentation/auth/bearer-token-auth.js +4 -3
  48. package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
  49. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  50. package/dist/presentation/auth/jwt-auth.js +32 -4
  51. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  52. package/dist/presentation/routes/administration.d.ts +1 -0
  53. package/dist/presentation/routes/administration.d.ts.map +1 -1
  54. package/dist/presentation/routes/administration.js +116 -3
  55. package/dist/presentation/routes/administration.js.map +1 -1
  56. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  57. package/dist/presentation/routes/rate-limit.js +6 -0
  58. package/dist/presentation/routes/rate-limit.js.map +1 -1
  59. package/dist/tsconfig.test.tsbuildinfo +1 -1
  60. package/package.json +4 -4
  61. package/src/core/administration.ts +142 -15
  62. package/src/core/auth.ts +9 -2
  63. package/src/db/admin-command.ts +85 -0
  64. package/src/db/admin-grants.test.ts +17 -1
  65. package/src/db/admin-grants.ts +52 -92
  66. package/src/db/admin-user-moderation.ts +230 -0
  67. package/src/db/admin-user-summary.ts +51 -0
  68. package/src/db/admin-users.ts +4 -49
  69. package/src/db/refresh-tokens.ts +54 -0
  70. package/src/db/schema/admin-command-results.ts +23 -2
  71. package/src/index.test.ts +4 -1
  72. package/src/index.ts +5 -0
  73. package/src/metrics/instance.ts +6 -1
  74. package/src/presentation/auth/bearer-token-auth.test.ts +17 -0
  75. package/src/presentation/auth/bearer-token-auth.ts +9 -2
  76. package/src/presentation/auth/jwt-auth.test.ts +1 -1
  77. package/src/presentation/auth/jwt-auth.ts +30 -1
  78. package/src/presentation/routes/administration.test.ts +373 -2
  79. package/src/presentation/routes/administration.ts +106 -1
  80. package/src/presentation/routes/rate-limit.ts +3 -0
  81. package/test/routes.ts +7 -1
  82. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,9 +1,20 @@
1
1
  import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
2
- import {sql} from 'drizzle-orm';
2
+ import {eq, sql} from 'drizzle-orm';
3
3
  import type {FastifyInstance} from 'fastify';
4
+ import {type AuthRateLimitAction, hashAuthRateLimitIdentifier} from '#core/rate-limit.js';
4
5
  import {db} from '#db/db.js';
5
6
  import {authOutbox} from '#db/schema/outbox.js';
6
- import {createAuthTestApp, createVerifiedSession, resetCapturedMail} from '#test/routes.js';
7
+ import {authRateLimits} from '#db/schema/rate-limits.js';
8
+ import {refreshTokens} from '#db/schema/refresh-tokens.js';
9
+ import {users} from '#db/schema/users.js';
10
+ import {
11
+ cookieHeader,
12
+ createAuthTestApp,
13
+ createVerifiedSession,
14
+ getSetCookie,
15
+ login,
16
+ resetCapturedMail,
17
+ } from '#test/routes.js';
7
18
 
8
19
  const BOOTSTRAP_TOKEN = 'test-bootstrap-token';
9
20
 
@@ -20,6 +31,30 @@ function authHeaders(token: string, idempotencyKey: string) {
20
31
  };
21
32
  }
22
33
 
34
+ async function seedExhaustedIpBucket(params: {
35
+ action: AuthRateLimitAction;
36
+ identifier: string;
37
+ limit: number;
38
+ windowSeconds: number;
39
+ }): Promise<void> {
40
+ const windowMs = params.windowSeconds * 1000;
41
+ const windowStart = new Date(Math.floor(Date.now() / windowMs) * windowMs);
42
+ await db()
43
+ .insert(authRateLimits)
44
+ .values({
45
+ action: params.action,
46
+ scope: 'ip',
47
+ identifierHmac: hashAuthRateLimitIdentifier({
48
+ action: params.action,
49
+ scope: 'ip',
50
+ identifier: params.identifier,
51
+ }),
52
+ windowStart,
53
+ count: params.limit,
54
+ expiresAt: new Date(windowStart.getTime() + windowMs),
55
+ });
56
+ }
57
+
23
58
  describe('Auth administration routes', () => {
24
59
  let app: FastifyInstance;
25
60
 
@@ -48,6 +83,78 @@ describe('Auth administration routes', () => {
48
83
  expect(response.statusCode).toBe(401);
49
84
  });
50
85
 
86
+ test('requires an authenticated session for bootstrap state', async () => {
87
+ const response = await app.inject({
88
+ method: 'GET',
89
+ url: '/admin/auth/bootstrap-state',
90
+ });
91
+
92
+ expect(response.statusCode).toBe(401);
93
+ });
94
+
95
+ test('reports only available or closed bootstrap state', async () => {
96
+ const account = await createVerifiedSession('admin-bootstrap-state');
97
+
98
+ const available = await app.inject({
99
+ method: 'GET',
100
+ url: '/admin/auth/bootstrap-state',
101
+ headers: {authorization: `Bearer ${account.token}`},
102
+ });
103
+
104
+ expect(available.statusCode).toBe(200);
105
+ expect(available.json()).toEqual({state: 'available'});
106
+
107
+ const bootstrap = await app.inject({
108
+ method: 'POST',
109
+ url: '/admin/auth/admin-grants/bootstrap',
110
+ headers: authHeaders(account.token, 'bootstrap-state-bootstrap'),
111
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
112
+ });
113
+ expect(bootstrap.statusCode).toBe(201);
114
+
115
+ const closed = await app.inject({
116
+ method: 'GET',
117
+ url: '/admin/auth/bootstrap-state',
118
+ headers: {authorization: `Bearer ${account.token}`},
119
+ });
120
+
121
+ expect(closed.statusCode).toBe(200);
122
+ expect(closed.json()).toEqual({state: 'closed'});
123
+ expect(JSON.stringify(closed.json())).not.toContain(BOOTSTRAP_TOKEN);
124
+ });
125
+
126
+ test('keeps bootstrap-state reads separate from the bootstrap write limit', async () => {
127
+ const account = await createVerifiedSession('admin-bootstrap-state-rate-limit');
128
+ const ip = '127.0.0.1';
129
+
130
+ await seedExhaustedIpBucket({
131
+ action: 'bootstrap',
132
+ identifier: ip,
133
+ limit: 5,
134
+ windowSeconds: 15 * 60,
135
+ });
136
+ const state = await app.inject({
137
+ method: 'GET',
138
+ url: '/admin/auth/bootstrap-state',
139
+ headers: {authorization: `Bearer ${account.token}`},
140
+ });
141
+ expect(state.statusCode).toBe(200);
142
+
143
+ await resetAdministrationState();
144
+ await seedExhaustedIpBucket({
145
+ action: 'bootstrap-state',
146
+ identifier: ip,
147
+ limit: 60,
148
+ windowSeconds: 5 * 60,
149
+ });
150
+ const blocked = await app.inject({
151
+ method: 'GET',
152
+ url: '/admin/auth/bootstrap-state',
153
+ headers: {authorization: `Bearer ${account.token}`},
154
+ });
155
+ expect(blocked.statusCode).toBe(429);
156
+ });
157
+
51
158
  test('does not register the removed versioned administration namespace', async () => {
52
159
  const response = await app.inject({
53
160
  method: 'GET',
@@ -140,6 +247,36 @@ describe('Auth administration routes', () => {
140
247
  expect(JSON.stringify(events[0]?.payload)).not.toContain(BOOTSTRAP_TOKEN);
141
248
  });
142
249
 
250
+ test('serializes concurrent bootstrap attempts to one active first owner', async () => {
251
+ const first = await createVerifiedSession('admin-bootstrap-race-first');
252
+ const second = await createVerifiedSession('admin-bootstrap-race-second');
253
+
254
+ const responses = await Promise.all(
255
+ [first, second].map((account, index) =>
256
+ app.inject({
257
+ method: 'POST',
258
+ url: '/admin/auth/admin-grants/bootstrap',
259
+ headers: authHeaders(account.token, `bootstrap-race-${index}`),
260
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
261
+ }),
262
+ ),
263
+ );
264
+
265
+ expect(responses.map((response) => response.statusCode).sort()).toEqual([201, 409]);
266
+ const activeOwnerRoles = await Promise.all(
267
+ [first, second].map(async (account) => {
268
+ const response = await app.inject({
269
+ method: 'GET',
270
+ url: `/admin/auth/users?user_id=${account.userId}`,
271
+ headers: {authorization: `Bearer ${account.token}`},
272
+ });
273
+ return response.statusCode === 200 && response.json().admin_role === 'admin-owner';
274
+ }),
275
+ );
276
+
277
+ expect(activeOwnerRoles.filter(Boolean)).toHaveLength(1);
278
+ });
279
+
143
280
  test('lets an owner list, grant, and revoke roles with idempotent audited mutations', async () => {
144
281
  const owner = await createVerifiedSession('admin-grant-owner');
145
282
  const target = await createVerifiedSession('admin-grant-target');
@@ -534,4 +671,238 @@ describe('Auth administration routes', () => {
534
671
  expect(finalOwnerRevoke.statusCode).toBe(409);
535
672
  expect(finalOwnerRevoke.json().code).toBe('last-owner');
536
673
  });
674
+
675
+ test('suspends users, invalidates sessions, and reactivates without restoring them', async () => {
676
+ const owner = await createVerifiedSession('admin-user-moderation-owner');
677
+ const target = await createVerifiedSession('admin-user-moderation-target');
678
+
679
+ await app.inject({
680
+ method: 'POST',
681
+ url: '/admin/auth/admin-grants/bootstrap',
682
+ headers: authHeaders(owner.token, 'moderation-bootstrap'),
683
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
684
+ });
685
+ await app.inject({
686
+ method: 'POST',
687
+ url: '/admin/auth/admin-grants',
688
+ headers: authHeaders(owner.token, 'moderation-grant'),
689
+ payload: {
690
+ user_id: owner.userId,
691
+ role: 'admin-operator',
692
+ reason: 'User moderation access',
693
+ },
694
+ });
695
+
696
+ const rotatedBeforeSuspension = await app.inject({
697
+ method: 'POST',
698
+ url: '/auth/refresh',
699
+ headers: {cookie: target.refreshCookie},
700
+ payload: {},
701
+ });
702
+ expect(rotatedBeforeSuspension.statusCode).toBe(200);
703
+ const rotatedRefreshCookie = cookieHeader(getSetCookie(rotatedBeforeSuspension));
704
+
705
+ const suspended = await app.inject({
706
+ method: 'POST',
707
+ url: `/admin/auth/users/${target.userId}/suspend`,
708
+ headers: authHeaders(owner.token, 'suspend-user'),
709
+ payload: {reason: 'Account security review'},
710
+ });
711
+
712
+ expect(suspended.statusCode).toBe(200);
713
+ expect(suspended.json()).toMatchObject({
714
+ id: target.userId,
715
+ status: 'suspended',
716
+ admin_role: null,
717
+ correlation_id: expect.any(String),
718
+ });
719
+
720
+ const suspendedMe = await app.inject({
721
+ method: 'GET',
722
+ url: '/auth/me',
723
+ headers: {authorization: `Bearer ${target.token}`},
724
+ });
725
+ const suspendedLogin = await login(app, {email: target.email, password: target.password});
726
+ expect(suspendedMe.statusCode).toBe(401);
727
+ expect(suspendedLogin.statusCode).toBe(401);
728
+
729
+ const suspendedDbUser = (
730
+ await db().select({status: users.status}).from(users).where(eq(users.id, target.userId))
731
+ )[0];
732
+ const targetSessions = await db()
733
+ .select({sessionId: refreshTokens.sessionId, revokedAt: refreshTokens.revokedAt})
734
+ .from(refreshTokens)
735
+ .where(eq(refreshTokens.userId, target.userId));
736
+ expect(suspendedDbUser?.status).toBe('suspended');
737
+ expect(targetSessions).toHaveLength(2);
738
+ expect(targetSessions.every(({revokedAt}) => revokedAt instanceof Date)).toBe(true);
739
+
740
+ const suspendedRefresh = await app.inject({
741
+ method: 'POST',
742
+ url: '/auth/refresh',
743
+ headers: {cookie: rotatedRefreshCookie},
744
+ payload: {},
745
+ });
746
+ expect(suspendedRefresh.statusCode).toBe(401);
747
+
748
+ const suspensionEvent = (await db().select().from(authOutbox)).find(
749
+ (event) =>
750
+ event.eventType === ADMINISTRATION_ACTION_PERFORMED &&
751
+ (event.payload as {command?: string}).command === 'auth.user.suspend',
752
+ );
753
+ expect(suspensionEvent?.payload).toMatchObject({
754
+ actorId: owner.userId,
755
+ actorRole: 'admin-owner',
756
+ requiredRole: 'admin-operator',
757
+ targetId: target.userId,
758
+ reason: 'Account security review',
759
+ result: 'succeeded',
760
+ });
761
+ expect(JSON.stringify(suspensionEvent?.payload)).not.toContain('suspend-user');
762
+ expect(JSON.stringify(suspensionEvent?.payload)).not.toContain(target.password);
763
+
764
+ const repeatedSuspension = await app.inject({
765
+ method: 'POST',
766
+ url: `/admin/auth/users/${target.userId}/suspend`,
767
+ headers: authHeaders(owner.token, 'suspend-user'),
768
+ payload: {reason: 'Account security review'},
769
+ });
770
+ expect(repeatedSuspension.statusCode).toBe(200);
771
+ expect(repeatedSuspension.json().correlation_id).toBe(suspended.json().correlation_id);
772
+
773
+ const reusedSuspensionKey = await app.inject({
774
+ method: 'POST',
775
+ url: `/admin/auth/users/${target.userId}/suspend`,
776
+ headers: authHeaders(owner.token, 'suspend-user'),
777
+ payload: {reason: 'A different reason'},
778
+ });
779
+ expect(reusedSuspensionKey.statusCode).toBe(409);
780
+ expect(reusedSuspensionKey.json().code).toBe('idempotency-key-reused');
781
+
782
+ const reactivated = await app.inject({
783
+ method: 'POST',
784
+ url: `/admin/auth/users/${target.userId}/reactivate`,
785
+ headers: authHeaders(owner.token, 'reactivate-user'),
786
+ payload: {},
787
+ });
788
+ expect(reactivated.statusCode).toBe(200);
789
+ expect(reactivated.json()).toMatchObject({
790
+ id: target.userId,
791
+ status: 'active',
792
+ correlation_id: expect.any(String),
793
+ });
794
+
795
+ const oldRefreshAfterReactivation = await app.inject({
796
+ method: 'POST',
797
+ url: '/auth/refresh',
798
+ headers: {cookie: target.refreshCookie},
799
+ payload: {},
800
+ });
801
+ expect(oldRefreshAfterReactivation.statusCode).toBe(401);
802
+ const rotatedRefreshAfterReactivation = await app.inject({
803
+ method: 'POST',
804
+ url: '/auth/refresh',
805
+ headers: {cookie: rotatedRefreshCookie},
806
+ payload: {},
807
+ });
808
+ expect(rotatedRefreshAfterReactivation.statusCode).toBe(401);
809
+
810
+ const newLogin = await login(app, {email: target.email, password: target.password});
811
+ expect(newLogin.statusCode).toBe(200);
812
+ const secondLogin = await login(app, {email: target.email, password: target.password});
813
+ expect(secondLogin.statusCode).toBe(200);
814
+
815
+ const revoked = await app.inject({
816
+ method: 'POST',
817
+ url: `/admin/auth/users/${target.userId}/revoke-sessions`,
818
+ headers: authHeaders(owner.token, 'revoke-user-sessions'),
819
+ payload: {reason: 'End all active sessions'},
820
+ });
821
+ expect(revoked.statusCode).toBe(200);
822
+ expect(revoked.json()).toMatchObject({
823
+ id: target.userId,
824
+ status: 'active',
825
+ sessions_revoked: 2,
826
+ correlation_id: expect.any(String),
827
+ });
828
+
829
+ const newRefresh = await app.inject({
830
+ method: 'POST',
831
+ url: '/auth/refresh',
832
+ headers: {cookie: cookieHeader(getSetCookie(newLogin))},
833
+ payload: {},
834
+ });
835
+ expect(newRefresh.statusCode).toBe(401);
836
+ const secondRefresh = await app.inject({
837
+ method: 'POST',
838
+ url: '/auth/refresh',
839
+ headers: {cookie: cookieHeader(getSetCookie(secondLogin))},
840
+ payload: {},
841
+ });
842
+ expect(secondRefresh.statusCode).toBe(401);
843
+
844
+ const repeatedRevoke = await app.inject({
845
+ method: 'POST',
846
+ url: `/admin/auth/users/${target.userId}/revoke-sessions`,
847
+ headers: authHeaders(owner.token, 'revoke-user-sessions'),
848
+ payload: {reason: 'End all active sessions'},
849
+ });
850
+ expect(repeatedRevoke.statusCode).toBe(200);
851
+ expect(repeatedRevoke.json().sessions_revoked).toBe(2);
852
+ expect(repeatedRevoke.json().correlation_id).toBe(revoked.json().correlation_id);
853
+ });
854
+
855
+ test('protects the final active owner from suspension', async () => {
856
+ const owner = await createVerifiedSession('admin-suspend-final-owner');
857
+
858
+ await app.inject({
859
+ method: 'POST',
860
+ url: '/admin/auth/admin-grants/bootstrap',
861
+ headers: authHeaders(owner.token, 'suspend-final-bootstrap'),
862
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
863
+ });
864
+
865
+ const suspended = await app.inject({
866
+ method: 'POST',
867
+ url: `/admin/auth/users/${owner.userId}/suspend`,
868
+ headers: authHeaders(owner.token, 'suspend-final-owner'),
869
+ payload: {reason: 'Attempt to suspend final owner'},
870
+ });
871
+
872
+ expect(suspended.statusCode).toBe(409);
873
+ expect(suspended.json().code).toBe('last-owner');
874
+ });
875
+
876
+ test('rejects user moderation by an observer', async () => {
877
+ const owner = await createVerifiedSession('admin-moderation-role-owner');
878
+ const observer = await createVerifiedSession('admin-moderation-role-observer');
879
+ const target = await createVerifiedSession('admin-moderation-role-target');
880
+
881
+ await app.inject({
882
+ method: 'POST',
883
+ url: '/admin/auth/admin-grants/bootstrap',
884
+ headers: authHeaders(owner.token, 'moderation-role-bootstrap'),
885
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
886
+ });
887
+ await app.inject({
888
+ method: 'POST',
889
+ url: '/admin/auth/admin-grants',
890
+ headers: authHeaders(owner.token, 'moderation-role-grant'),
891
+ payload: {
892
+ user_id: observer.userId,
893
+ role: 'admin-observer',
894
+ reason: 'Read-only administration access',
895
+ },
896
+ });
897
+
898
+ const response = await app.inject({
899
+ method: 'POST',
900
+ url: `/admin/auth/users/${target.userId}/suspend`,
901
+ headers: authHeaders(observer.token, 'moderation-role-suspend'),
902
+ payload: {reason: 'Observer should not mutate users'},
903
+ });
904
+
905
+ expect(response.statusCode).toBe(403);
906
+ expect(response.json().code).toBe('forbidden');
907
+ });
537
908
  });
@@ -1,6 +1,8 @@
1
1
  import {AUTH_USER} from '@shipfox/api-auth-context';
2
2
  import {
3
+ adminBootstrapStateSchema,
3
4
  administratorUserLookupQuerySchema,
5
+ administratorUserMutationResponseSchema,
4
6
  administratorUserSummarySchema,
5
7
  bootstrapAdminOwnerBodySchema,
6
8
  bootstrapAdminOwnerResponseSchema,
@@ -8,8 +10,12 @@ import {
8
10
  grantAdminRoleResponseSchema,
9
11
  listAdminGrantsQuerySchema,
10
12
  listAdminGrantsResponseSchema,
13
+ reactivateAdministratorUserBodySchema,
11
14
  revokeAdminGrantBodySchema,
12
15
  revokeAdminGrantResponseSchema,
16
+ revokeAdministratorUserSessionsBodySchema,
17
+ revokeAdministratorUserSessionsResponseSchema,
18
+ suspendAdministratorUserBodySchema,
13
19
  } from '@shipfox/api-auth-dto';
14
20
  import {decodeTimestampIdCursor, encodeTimestampIdCursor} from '@shipfox/node-drizzle';
15
21
  import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
@@ -19,9 +25,13 @@ import {requireAdminRole} from '#core/admin-role.js';
19
25
  import {
20
26
  bootstrapFirstAdminOwner,
21
27
  findAdministratorUserSummary,
28
+ getAdminBootstrapState,
22
29
  grantAdministratorRole,
23
30
  listAdministratorGrantSummaries,
31
+ reactivateAdministratorUser,
24
32
  revokeAdministratorGrant,
33
+ revokeAdministratorUserSessions,
34
+ suspendAdministratorUser,
25
35
  } from '#core/administration.js';
26
36
  import type {AdminGrant} from '#core/entities/admin-grant.js';
27
37
  import type {
@@ -99,6 +109,16 @@ function toAdministratorGrantSummaryDto(grant: AdministratorGrantSummary) {
99
109
  };
100
110
  }
101
111
 
112
+ function toAdministratorUserMutationDto(result: {
113
+ user: AdministratorUserSummary;
114
+ correlationId: string;
115
+ }) {
116
+ return {
117
+ ...toAdministratorUserSummaryDto(result.user),
118
+ correlation_id: result.correlationId,
119
+ };
120
+ }
121
+
102
122
  function translateAdministrationError(error: unknown): never {
103
123
  if (error instanceof AdminRoleRequiredError) {
104
124
  throw new ClientError('Administrator role required', 'forbidden', {
@@ -165,6 +185,16 @@ const bootstrapRoute = defineRoute({
165
185
  },
166
186
  });
167
187
 
188
+ const bootstrapStateRoute = defineRoute({
189
+ method: 'GET',
190
+ path: '/bootstrap-state',
191
+ description: 'Read whether first administrator owner bootstrap is available.',
192
+ schema: {response: {200: adminBootstrapStateSchema}},
193
+ preHandler: createAuthIpRateLimitPreHandler('bootstrap-state'),
194
+ errorHandler: translateAdministrationError,
195
+ handler: async () => ({state: await getAdminBootstrapState()}),
196
+ });
197
+
168
198
  const listRoute = defineRoute({
169
199
  method: 'GET',
170
200
  path: '/',
@@ -217,6 +247,75 @@ const userLookupRoute = defineRoute({
217
247
  },
218
248
  });
219
249
 
250
+ const suspendUserRoute = defineRoute({
251
+ method: 'POST',
252
+ path: '/:userId/suspend',
253
+ description: 'Suspend a user and revoke all of its active sessions.',
254
+ schema: {
255
+ params: z.object({userId: z.string().uuid()}),
256
+ body: suspendAdministratorUserBodySchema,
257
+ response: {200: administratorUserMutationResponseSchema},
258
+ },
259
+ errorHandler: translateAdministrationError,
260
+ handler: async (request) => {
261
+ const result = await suspendAdministratorUser({
262
+ actorId: requireActorId(request),
263
+ userId: request.params.userId,
264
+ reason: request.body.reason,
265
+ idempotencyKey: requireIdempotencyKey(request),
266
+ correlationId: request.id,
267
+ });
268
+ return toAdministratorUserMutationDto(result);
269
+ },
270
+ });
271
+
272
+ const reactivateUserRoute = defineRoute({
273
+ method: 'POST',
274
+ path: '/:userId/reactivate',
275
+ description: 'Reactivate a suspended user without restoring revoked sessions.',
276
+ schema: {
277
+ params: z.object({userId: z.string().uuid()}),
278
+ body: reactivateAdministratorUserBodySchema,
279
+ response: {200: administratorUserMutationResponseSchema},
280
+ },
281
+ errorHandler: translateAdministrationError,
282
+ handler: async (request) => {
283
+ const result = await reactivateAdministratorUser({
284
+ actorId: requireActorId(request),
285
+ userId: request.params.userId,
286
+ ...(request.body.reason ? {reason: request.body.reason} : {}),
287
+ idempotencyKey: requireIdempotencyKey(request),
288
+ correlationId: request.id,
289
+ });
290
+ return toAdministratorUserMutationDto(result);
291
+ },
292
+ });
293
+
294
+ const revokeUserSessionsRoute = defineRoute({
295
+ method: 'POST',
296
+ path: '/:userId/revoke-sessions',
297
+ description: 'Revoke all active sessions for a user without changing account status.',
298
+ schema: {
299
+ params: z.object({userId: z.string().uuid()}),
300
+ body: revokeAdministratorUserSessionsBodySchema,
301
+ response: {200: revokeAdministratorUserSessionsResponseSchema},
302
+ },
303
+ errorHandler: translateAdministrationError,
304
+ handler: async (request) => {
305
+ const result = await revokeAdministratorUserSessions({
306
+ actorId: requireActorId(request),
307
+ userId: request.params.userId,
308
+ ...(request.body.reason ? {reason: request.body.reason} : {}),
309
+ idempotencyKey: requireIdempotencyKey(request),
310
+ correlationId: request.id,
311
+ });
312
+ return {
313
+ ...toAdministratorUserMutationDto(result),
314
+ sessions_revoked: result.sessionsRevoked,
315
+ };
316
+ },
317
+ });
318
+
220
319
  const grantRoute = defineRoute({
221
320
  method: 'POST',
222
321
  path: '/',
@@ -269,8 +368,14 @@ export const administrationRoutes: RouteGroup = {
269
368
  routes: [bootstrapRoute, listRoute, grantRoute, revokeRoute],
270
369
  };
271
370
 
371
+ export const administrationBootstrapRoutes: RouteGroup = {
372
+ prefix: '/admin/auth',
373
+ auth: AUTH_USER,
374
+ routes: [bootstrapStateRoute],
375
+ };
376
+
272
377
  export const administrationUserRoutes: RouteGroup = {
273
378
  prefix: '/admin/auth/users',
274
379
  auth: AUTH_USER,
275
- routes: [userLookupRoute],
380
+ routes: [userLookupRoute, suspendUserRoute, reactivateUserRoute, revokeUserSessionsRoute],
276
381
  };
@@ -23,6 +23,9 @@ const policies: Record<
23
23
  bootstrap: {
24
24
  ip: {limit: 5, windowSeconds: 15 * 60},
25
25
  },
26
+ 'bootstrap-state': {
27
+ ip: {limit: 60, windowSeconds: 5 * 60},
28
+ },
26
29
  lookup: {
27
30
  ip: {limit: 60, windowSeconds: 5 * 60},
28
31
  },
package/test/routes.ts CHANGED
@@ -9,6 +9,7 @@ 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
11
  import {
12
+ administrationBootstrapRoutes,
12
13
  administrationRoutes,
13
14
  administrationUserRoutes,
14
15
  } from '#presentation/routes/administration.js';
@@ -156,7 +157,12 @@ export async function createAuthTestApp(params?: {
156
157
  }): Promise<FastifyInstance> {
157
158
  const appConfig: AppConfig = {
158
159
  auth: [createJwtAuthMethod()],
159
- routes: [buildAuthRoutes(true, workspaces), administrationRoutes, administrationUserRoutes],
160
+ routes: [
161
+ buildAuthRoutes(true, workspaces),
162
+ administrationBootstrapRoutes,
163
+ administrationRoutes,
164
+ administrationUserRoutes,
165
+ ],
160
166
  swagger: false,
161
167
  };
162
168
  if (params?.fastifyOptions) appConfig.fastifyOptions = params.fastifyOptions;