@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +29 -0
- package/README.md +20 -1
- package/dist/core/administration.d.ts +18 -1
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +111 -15
- package/dist/core/administration.js.map +1 -1
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +11 -2
- package/dist/core/auth.js.map +1 -1
- package/dist/db/admin-command.d.ts +18 -0
- package/dist/db/admin-command.d.ts.map +1 -0
- package/dist/db/admin-command.js +37 -0
- package/dist/db/admin-command.js.map +1 -0
- package/dist/db/admin-grants.d.ts +9 -0
- package/dist/db/admin-grants.d.ts.map +1 -1
- package/dist/db/admin-grants.js +31 -40
- package/dist/db/admin-grants.js.map +1 -1
- package/dist/db/admin-user-moderation.d.ts +29 -0
- package/dist/db/admin-user-moderation.d.ts.map +1 -0
- package/dist/db/admin-user-moderation.js +156 -0
- package/dist/db/admin-user-moderation.js.map +1 -0
- package/dist/db/admin-user-summary.d.ts +14 -0
- package/dist/db/admin-user-summary.d.ts.map +1 -0
- package/dist/db/admin-user-summary.js +31 -0
- package/dist/db/admin-user-summary.js.map +1 -0
- package/dist/db/admin-users.d.ts +2 -11
- package/dist/db/admin-users.d.ts.map +1 -1
- package/dist/db/admin-users.js +2 -27
- package/dist/db/admin-users.js.map +1 -1
- package/dist/db/refresh-tokens.d.ts +6 -0
- package/dist/db/refresh-tokens.d.ts.map +1 -1
- package/dist/db/refresh-tokens.js +31 -0
- package/dist/db/refresh-tokens.js.map +1 -1
- package/dist/db/schema/admin-command-results.d.ts +19 -2
- package/dist/db/schema/admin-command-results.d.ts.map +1 -1
- package/dist/db/schema/admin-command-results.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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/auth/bearer-token-auth.d.ts +1 -0
- package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
- package/dist/presentation/auth/bearer-token-auth.js +4 -3
- package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
- package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
- package/dist/presentation/auth/jwt-auth.js +32 -4
- package/dist/presentation/auth/jwt-auth.js.map +1 -1
- package/dist/presentation/routes/administration.d.ts +1 -0
- package/dist/presentation/routes/administration.d.ts.map +1 -1
- package/dist/presentation/routes/administration.js +116 -3
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +6 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/core/administration.ts +142 -15
- package/src/core/auth.ts +9 -2
- package/src/db/admin-command.ts +85 -0
- package/src/db/admin-grants.test.ts +17 -1
- package/src/db/admin-grants.ts +52 -92
- package/src/db/admin-user-moderation.ts +230 -0
- package/src/db/admin-user-summary.ts +51 -0
- package/src/db/admin-users.ts +4 -49
- package/src/db/refresh-tokens.ts +54 -0
- package/src/db/schema/admin-command-results.ts +23 -2
- package/src/index.test.ts +4 -1
- package/src/index.ts +5 -0
- package/src/metrics/instance.ts +6 -1
- package/src/presentation/auth/bearer-token-auth.test.ts +17 -0
- package/src/presentation/auth/bearer-token-auth.ts +9 -2
- package/src/presentation/auth/jwt-auth.test.ts +1 -1
- package/src/presentation/auth/jwt-auth.ts +30 -1
- package/src/presentation/routes/administration.test.ts +373 -2
- package/src/presentation/routes/administration.ts +106 -1
- package/src/presentation/routes/rate-limit.ts +3 -0
- package/test/routes.ts +7 -1
- 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 {
|
|
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
|
};
|
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: [
|
|
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;
|