@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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "10.
|
|
4
|
+
"version": "10.2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"drizzle-orm": "^0.45.2",
|
|
28
28
|
"zod": "^4.4.3",
|
|
29
29
|
"@shipfox/api-common-dto": "9.2.0",
|
|
30
|
-
"@shipfox/api-auth-context": "10.
|
|
31
|
-
"@shipfox/api-auth-dto": "10.
|
|
30
|
+
"@shipfox/api-auth-context": "10.2.0",
|
|
31
|
+
"@shipfox/api-auth-dto": "10.2.0",
|
|
32
32
|
"@shipfox/api-email-challenges": "1.1.6",
|
|
33
|
-
"@shipfox/api-workspaces-dto": "10.
|
|
33
|
+
"@shipfox/api-workspaces-dto": "10.2.0",
|
|
34
34
|
"@shipfox/inter-module": "0.2.2",
|
|
35
35
|
"@shipfox/config": "1.2.4",
|
|
36
36
|
"@shipfox/node-drizzle": "0.3.4",
|
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {timingSafeEqual} from 'node:crypto';
|
|
2
2
|
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
3
3
|
import {createAdministrationActionEvent} from '@shipfox/api-common-dto';
|
|
4
4
|
import type {TimestampIdCursor} from '@shipfox/node-drizzle';
|
|
5
|
+
import {hashOpaqueToken} from '@shipfox/node-tokens';
|
|
5
6
|
import {config} from '#config.js';
|
|
6
7
|
import {
|
|
7
8
|
bootstrapFirstAdminOwner as bootstrapFirstAdminOwnerInDb,
|
|
8
9
|
grantAdminRoleWithAudit,
|
|
10
|
+
hasActiveAdminOwner,
|
|
9
11
|
listAdminGrantSummaries,
|
|
10
12
|
revokeAdminGrantWithAudit,
|
|
11
13
|
} from '#db/admin-grants.js';
|
|
14
|
+
import {
|
|
15
|
+
reactivateUserWithAudit,
|
|
16
|
+
revokeUserSessionsWithAudit,
|
|
17
|
+
suspendUserWithAudit,
|
|
18
|
+
type UserModerationResult,
|
|
19
|
+
} from '#db/admin-user-moderation.js';
|
|
12
20
|
import {findAdministratorUser as findAdministratorUserInDb} from '#db/admin-users.js';
|
|
13
21
|
import {requireAdminRole} from './admin-role.js';
|
|
14
22
|
import type {AdminGrant} from './entities/admin-grant.js';
|
|
@@ -28,22 +36,22 @@ import {
|
|
|
28
36
|
|
|
29
37
|
const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
|
|
30
38
|
const ADMIN_OBSERVER_ROLE: AdminRole = 'admin-observer';
|
|
39
|
+
const ADMIN_OPERATOR_ROLE: AdminRole = 'admin-operator';
|
|
31
40
|
const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
|
|
32
41
|
const GRANT_COMMAND = 'auth.admin_grant.grant';
|
|
33
42
|
const REVOKE_COMMAND = 'auth.admin_grant.revoke';
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
43
|
+
const SUSPEND_USER_COMMAND = 'auth.user.suspend';
|
|
44
|
+
const REACTIVATE_USER_COMMAND = 'auth.user.reactivate';
|
|
45
|
+
const REVOKE_USER_SESSIONS_COMMAND = 'auth.user.revoke-sessions';
|
|
38
46
|
|
|
39
47
|
export function administrationCommandFingerprint(command: string, input: unknown): string {
|
|
40
|
-
return
|
|
48
|
+
return hashOpaqueToken(`${command}:${JSON.stringify(input)}`);
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
function bootstrapTokenMatches(candidate: string, expected: string | undefined): boolean {
|
|
44
52
|
if (!expected) return false;
|
|
45
|
-
const candidateHash = Buffer.from(
|
|
46
|
-
const expectedHash = Buffer.from(
|
|
53
|
+
const candidateHash = Buffer.from(hashOpaqueToken(candidate), 'hex');
|
|
54
|
+
const expectedHash = Buffer.from(hashOpaqueToken(expected), 'hex');
|
|
47
55
|
return timingSafeEqual(candidateHash, expectedHash);
|
|
48
56
|
}
|
|
49
57
|
|
|
@@ -54,13 +62,14 @@ function administrationEvent(params: {
|
|
|
54
62
|
targetType: string;
|
|
55
63
|
targetId: string;
|
|
56
64
|
reason: string;
|
|
65
|
+
requiredRole?: AdminRole;
|
|
57
66
|
correlationId: string;
|
|
58
67
|
idempotencyKeyFingerprint: string;
|
|
59
68
|
}) {
|
|
60
69
|
return createAdministrationActionEvent({
|
|
61
70
|
actorId: params.actorId,
|
|
62
71
|
actorRole: params.actorRole,
|
|
63
|
-
requiredRole: ADMIN_OWNER_ROLE,
|
|
72
|
+
requiredRole: params.requiredRole ?? ADMIN_OWNER_ROLE,
|
|
64
73
|
command: params.command,
|
|
65
74
|
targetType: params.targetType,
|
|
66
75
|
targetId: params.targetId,
|
|
@@ -78,6 +87,22 @@ export interface AdministrationMutationContext {
|
|
|
78
87
|
correlationId: string;
|
|
79
88
|
}
|
|
80
89
|
|
|
90
|
+
export interface AdministratorUserMutationResult {
|
|
91
|
+
user: AdministratorUserSummary;
|
|
92
|
+
correlationId: string;
|
|
93
|
+
sessionsRevoked: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toAdministratorUserMutationResult(
|
|
97
|
+
result: UserModerationResult,
|
|
98
|
+
): AdministratorUserMutationResult {
|
|
99
|
+
return {
|
|
100
|
+
user: result.user,
|
|
101
|
+
correlationId: result.correlationId,
|
|
102
|
+
sessionsRevoked: result.sessionsRevoked,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
export async function bootstrapFirstAdminOwner(
|
|
82
107
|
params: AdministrationMutationContext & {bootstrapToken: string},
|
|
83
108
|
): Promise<AdminGrant> {
|
|
@@ -88,7 +113,7 @@ export async function bootstrapFirstAdminOwner(
|
|
|
88
113
|
return await bootstrapFirstAdminOwnerInDb({
|
|
89
114
|
userId: params.actorId,
|
|
90
115
|
actorId: params.actorId,
|
|
91
|
-
idempotencyKeyFingerprint:
|
|
116
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
92
117
|
requestFingerprint: administrationCommandFingerprint(BOOTSTRAP_COMMAND, {
|
|
93
118
|
actorId: params.actorId,
|
|
94
119
|
}),
|
|
@@ -100,11 +125,15 @@ export async function bootstrapFirstAdminOwner(
|
|
|
100
125
|
targetId: params.actorId,
|
|
101
126
|
reason: 'Initial administrator owner bootstrap',
|
|
102
127
|
correlationId: params.correlationId,
|
|
103
|
-
idempotencyKeyFingerprint:
|
|
128
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
104
129
|
}),
|
|
105
130
|
});
|
|
106
131
|
}
|
|
107
132
|
|
|
133
|
+
export async function getAdminBootstrapState(): Promise<'available' | 'closed'> {
|
|
134
|
+
return (await hasActiveAdminOwner()) ? 'closed' : 'available';
|
|
135
|
+
}
|
|
136
|
+
|
|
108
137
|
export async function findAdministratorUserSummary(
|
|
109
138
|
params: {actorId: string} & ({id: string; email?: never} | {email: string; id?: never}),
|
|
110
139
|
): Promise<AdministratorUserSummary | undefined> {
|
|
@@ -155,7 +184,7 @@ export async function grantAdministratorRole(
|
|
|
155
184
|
userId: params.userId,
|
|
156
185
|
role: params.role,
|
|
157
186
|
actorId: params.actorId,
|
|
158
|
-
idempotencyKeyFingerprint:
|
|
187
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
159
188
|
requestFingerprint: administrationCommandFingerprint(GRANT_COMMAND, {
|
|
160
189
|
userId: params.userId,
|
|
161
190
|
role: params.role,
|
|
@@ -169,7 +198,7 @@ export async function grantAdministratorRole(
|
|
|
169
198
|
targetId: params.userId,
|
|
170
199
|
reason: params.reason,
|
|
171
200
|
correlationId: params.correlationId,
|
|
172
|
-
idempotencyKeyFingerprint:
|
|
201
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
173
202
|
}),
|
|
174
203
|
});
|
|
175
204
|
}
|
|
@@ -184,7 +213,7 @@ export async function revokeAdministratorGrant(
|
|
|
184
213
|
return await revokeAdminGrantWithAudit({
|
|
185
214
|
grantId: params.grantId,
|
|
186
215
|
actorId: params.actorId,
|
|
187
|
-
idempotencyKeyFingerprint:
|
|
216
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
188
217
|
requestFingerprint: administrationCommandFingerprint(REVOKE_COMMAND, {
|
|
189
218
|
grantId: params.grantId,
|
|
190
219
|
reason: params.reason,
|
|
@@ -197,11 +226,109 @@ export async function revokeAdministratorGrant(
|
|
|
197
226
|
targetId: params.grantId,
|
|
198
227
|
reason: params.reason,
|
|
199
228
|
correlationId: params.correlationId,
|
|
200
|
-
idempotencyKeyFingerprint:
|
|
229
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
201
230
|
}),
|
|
202
231
|
});
|
|
203
232
|
}
|
|
204
233
|
|
|
234
|
+
export async function suspendAdministratorUser(
|
|
235
|
+
params: AdministrationMutationContext & {userId: string; reason: string},
|
|
236
|
+
): Promise<AdministratorUserMutationResult> {
|
|
237
|
+
const actorRole = await requireAdminRole({
|
|
238
|
+
userId: params.actorId,
|
|
239
|
+
minimumRole: ADMIN_OPERATOR_ROLE,
|
|
240
|
+
});
|
|
241
|
+
const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
|
|
242
|
+
return toAdministratorUserMutationResult(
|
|
243
|
+
await suspendUserWithAudit({
|
|
244
|
+
actorId: params.actorId,
|
|
245
|
+
userId: params.userId,
|
|
246
|
+
idempotencyKeyFingerprint,
|
|
247
|
+
requestFingerprint: administrationCommandFingerprint(SUSPEND_USER_COMMAND, {
|
|
248
|
+
userId: params.userId,
|
|
249
|
+
reason: params.reason,
|
|
250
|
+
}),
|
|
251
|
+
event: administrationEvent({
|
|
252
|
+
actorId: params.actorId,
|
|
253
|
+
actorRole,
|
|
254
|
+
requiredRole: ADMIN_OPERATOR_ROLE,
|
|
255
|
+
command: SUSPEND_USER_COMMAND,
|
|
256
|
+
targetType: 'user',
|
|
257
|
+
targetId: params.userId,
|
|
258
|
+
reason: params.reason,
|
|
259
|
+
correlationId: params.correlationId,
|
|
260
|
+
idempotencyKeyFingerprint,
|
|
261
|
+
}),
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function reactivateAdministratorUser(
|
|
267
|
+
params: AdministrationMutationContext & {userId: string; reason?: string},
|
|
268
|
+
): Promise<AdministratorUserMutationResult> {
|
|
269
|
+
const actorRole = await requireAdminRole({
|
|
270
|
+
userId: params.actorId,
|
|
271
|
+
minimumRole: ADMIN_OPERATOR_ROLE,
|
|
272
|
+
});
|
|
273
|
+
const reason = params.reason ?? 'User reactivation requested by an administrator';
|
|
274
|
+
const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
|
|
275
|
+
return toAdministratorUserMutationResult(
|
|
276
|
+
await reactivateUserWithAudit({
|
|
277
|
+
actorId: params.actorId,
|
|
278
|
+
userId: params.userId,
|
|
279
|
+
idempotencyKeyFingerprint,
|
|
280
|
+
requestFingerprint: administrationCommandFingerprint(REACTIVATE_USER_COMMAND, {
|
|
281
|
+
userId: params.userId,
|
|
282
|
+
reason,
|
|
283
|
+
}),
|
|
284
|
+
event: administrationEvent({
|
|
285
|
+
actorId: params.actorId,
|
|
286
|
+
actorRole,
|
|
287
|
+
requiredRole: ADMIN_OPERATOR_ROLE,
|
|
288
|
+
command: REACTIVATE_USER_COMMAND,
|
|
289
|
+
targetType: 'user',
|
|
290
|
+
targetId: params.userId,
|
|
291
|
+
reason,
|
|
292
|
+
correlationId: params.correlationId,
|
|
293
|
+
idempotencyKeyFingerprint,
|
|
294
|
+
}),
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function revokeAdministratorUserSessions(
|
|
300
|
+
params: AdministrationMutationContext & {userId: string; reason?: string},
|
|
301
|
+
): Promise<AdministratorUserMutationResult> {
|
|
302
|
+
const actorRole = await requireAdminRole({
|
|
303
|
+
userId: params.actorId,
|
|
304
|
+
minimumRole: ADMIN_OPERATOR_ROLE,
|
|
305
|
+
});
|
|
306
|
+
const reason = params.reason ?? 'All active user sessions revoked by an administrator';
|
|
307
|
+
const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
|
|
308
|
+
return toAdministratorUserMutationResult(
|
|
309
|
+
await revokeUserSessionsWithAudit({
|
|
310
|
+
actorId: params.actorId,
|
|
311
|
+
userId: params.userId,
|
|
312
|
+
idempotencyKeyFingerprint,
|
|
313
|
+
requestFingerprint: administrationCommandFingerprint(REVOKE_USER_SESSIONS_COMMAND, {
|
|
314
|
+
userId: params.userId,
|
|
315
|
+
reason,
|
|
316
|
+
}),
|
|
317
|
+
event: administrationEvent({
|
|
318
|
+
actorId: params.actorId,
|
|
319
|
+
actorRole,
|
|
320
|
+
requiredRole: ADMIN_OPERATOR_ROLE,
|
|
321
|
+
command: REVOKE_USER_SESSIONS_COMMAND,
|
|
322
|
+
targetType: 'user',
|
|
323
|
+
targetId: params.userId,
|
|
324
|
+
reason,
|
|
325
|
+
correlationId: params.correlationId,
|
|
326
|
+
idempotencyKeyFingerprint,
|
|
327
|
+
}),
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
205
332
|
export {
|
|
206
333
|
AdminBootstrapClosedError,
|
|
207
334
|
AdminGrantAlreadyExistsError,
|
package/src/core/auth.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
|
|
|
15
15
|
import {config} from '#config.js';
|
|
16
16
|
import {consumePasswordReset, createPasswordReset} from '#db/password-resets.js';
|
|
17
17
|
import {
|
|
18
|
-
|
|
18
|
+
createRefreshTokenForActiveUser,
|
|
19
19
|
findActiveRefreshTokenByHash,
|
|
20
20
|
findRefreshTokenByHash,
|
|
21
21
|
revokeRefreshSession,
|
|
@@ -127,12 +127,13 @@ async function createRefreshSession(
|
|
|
127
127
|
refreshSessionId: string,
|
|
128
128
|
): Promise<{refreshToken: string; refreshSessionId: string}> {
|
|
129
129
|
const refreshToken = generateOpaqueToken('refreshToken');
|
|
130
|
-
const session = await
|
|
130
|
+
const session = await createRefreshTokenForActiveUser({
|
|
131
131
|
sessionId: refreshSessionId,
|
|
132
132
|
userId: user.id,
|
|
133
133
|
hashedToken: hashOpaqueToken(refreshToken),
|
|
134
134
|
expiresAt: daysFromNow(config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS),
|
|
135
135
|
});
|
|
136
|
+
if (!session) throw new InvalidCredentialsError();
|
|
136
137
|
return {refreshToken, refreshSessionId: session.sessionId};
|
|
137
138
|
}
|
|
138
139
|
|
|
@@ -491,12 +492,18 @@ export async function refreshAccessToken(params: {
|
|
|
491
492
|
// revoked, or expired the token before this refresh could claim it.
|
|
492
493
|
const nextRefreshToken = generateOpaqueToken('refreshToken');
|
|
493
494
|
const rotated = await rotateRefreshToken({
|
|
495
|
+
userId: current.userId,
|
|
494
496
|
id: current.id,
|
|
495
497
|
currentHashedToken,
|
|
496
498
|
nextHashedToken: hashOpaqueToken(nextRefreshToken),
|
|
497
499
|
expiresAt: daysFromNow(config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS),
|
|
498
500
|
});
|
|
499
501
|
if (!rotated) {
|
|
502
|
+
const latestUser = await findUserById({id: current.userId});
|
|
503
|
+
if (latestUser?.status !== 'active') {
|
|
504
|
+
recordRefreshOutcome('rejected');
|
|
505
|
+
throw new TokenInvalidError('Refresh token is invalid or expired');
|
|
506
|
+
}
|
|
500
507
|
const latest = await findRefreshTokenByHash({hashedToken: currentHashedToken});
|
|
501
508
|
if (!latest || !isWithinRotationGrace(latest)) {
|
|
502
509
|
recordRefreshOutcome('rejected');
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdministrationActionEvent,
|
|
3
|
+
AdministrationActionEventMap,
|
|
4
|
+
} from '@shipfox/api-common-dto';
|
|
5
|
+
import {writeOutboxEvent} from '@shipfox/node-outbox';
|
|
6
|
+
import {and, eq, sql} from 'drizzle-orm';
|
|
7
|
+
import {AdminIdempotencyKeyReuseError} from '#core/errors.js';
|
|
8
|
+
import type {db} from './db.js';
|
|
9
|
+
import {
|
|
10
|
+
type AdminCommandResultDb,
|
|
11
|
+
adminCommandResults,
|
|
12
|
+
type StoredAdminCommandResult,
|
|
13
|
+
} from './schema/admin-command-results.js';
|
|
14
|
+
import {authOutbox} from './schema/outbox.js';
|
|
15
|
+
|
|
16
|
+
export type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
|
|
17
|
+
|
|
18
|
+
export interface AdminCommandTransactionParams {
|
|
19
|
+
actorId: string;
|
|
20
|
+
idempotencyKeyFingerprint: string;
|
|
21
|
+
requestFingerprint: string;
|
|
22
|
+
event: AdministrationActionEvent;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function lockAdminCommand(
|
|
26
|
+
tx: Tx,
|
|
27
|
+
params: Pick<AdminCommandTransactionParams, 'actorId' | 'idempotencyKeyFingerprint'>,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
await tx.execute(
|
|
30
|
+
sql`select pg_advisory_xact_lock(hashtext(${`auth_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function lockAdminOwnerGrants(tx: Tx): Promise<void> {
|
|
35
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function findAdminCommandResult(
|
|
39
|
+
tx: Tx,
|
|
40
|
+
params: Pick<
|
|
41
|
+
AdminCommandTransactionParams,
|
|
42
|
+
'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
|
|
43
|
+
> & {command: string},
|
|
44
|
+
): Promise<AdminCommandResultDb | undefined> {
|
|
45
|
+
const rows = await tx
|
|
46
|
+
.select()
|
|
47
|
+
.from(adminCommandResults)
|
|
48
|
+
.where(
|
|
49
|
+
and(
|
|
50
|
+
eq(adminCommandResults.actorId, params.actorId),
|
|
51
|
+
eq(adminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
.limit(1);
|
|
55
|
+
const result = rows[0];
|
|
56
|
+
if (!result) return undefined;
|
|
57
|
+
if (
|
|
58
|
+
result.command !== params.command ||
|
|
59
|
+
result.requestFingerprint !== params.requestFingerprint
|
|
60
|
+
) {
|
|
61
|
+
throw new AdminIdempotencyKeyReuseError();
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function storeAdminCommandResult(
|
|
67
|
+
tx: Tx,
|
|
68
|
+
params: AdminCommandTransactionParams,
|
|
69
|
+
result: StoredAdminCommandResult,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
await tx.insert(adminCommandResults).values({
|
|
72
|
+
actorId: params.actorId,
|
|
73
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
74
|
+
command: params.event.command,
|
|
75
|
+
requestFingerprint: params.requestFingerprint,
|
|
76
|
+
result,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function writeAdminAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
|
|
81
|
+
await writeOutboxEvent<AdministrationActionEventMap>(tx, authOutbox, {
|
|
82
|
+
type: 'administration.action.performed',
|
|
83
|
+
payload: event,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import {eq} from 'drizzle-orm';
|
|
2
2
|
import {LastAdminOwnerError} from '#core/errors.js';
|
|
3
3
|
import {userFactory} from '#test/index.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createAdminGrant,
|
|
6
|
+
findCurrentAdminRole,
|
|
7
|
+
hasActiveAdminOwner,
|
|
8
|
+
revokeAdminGrant,
|
|
9
|
+
} from './admin-grants.js';
|
|
5
10
|
import {db} from './db.js';
|
|
6
11
|
import {users} from './schema/users.js';
|
|
7
12
|
|
|
@@ -26,6 +31,17 @@ describe('admin grants db', () => {
|
|
|
26
31
|
expect(await findCurrentAdminRole({userId: user.id})).toBeNull();
|
|
27
32
|
});
|
|
28
33
|
|
|
34
|
+
test('reopens bootstrap when the sole owner user is suspended', async () => {
|
|
35
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
36
|
+
await createAdminGrant({userId: user.id, role: 'admin-owner'});
|
|
37
|
+
|
|
38
|
+
await expect(hasActiveAdminOwner()).resolves.toBe(true);
|
|
39
|
+
|
|
40
|
+
await db().update(users).set({status: 'suspended'}).where(eq(users.id, user.id));
|
|
41
|
+
|
|
42
|
+
await expect(hasActiveAdminOwner()).resolves.toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
29
45
|
test('prevents revoking the final active owner but permits replacement first', async () => {
|
|
30
46
|
const firstOwner = await userFactory.create({emailVerifiedAt: new Date()});
|
|
31
47
|
const secondOwner = await userFactory.create({emailVerifiedAt: new Date()});
|
package/src/db/admin-grants.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
2
|
-
import type {
|
|
3
|
-
AdministrationActionEvent,
|
|
4
|
-
AdministrationActionEventMap,
|
|
5
|
-
} from '@shipfox/api-common-dto';
|
|
2
|
+
import type {AdministrationActionEvent} from '@shipfox/api-common-dto';
|
|
6
3
|
import {
|
|
7
4
|
paginateTimestampIdRows,
|
|
8
5
|
type TimestampIdCursor,
|
|
9
6
|
timestampIdCursorWhere,
|
|
10
7
|
} from '@shipfox/node-drizzle';
|
|
11
|
-
import {
|
|
12
|
-
import {and, desc, eq, isNull, sql} from 'drizzle-orm';
|
|
8
|
+
import {and, desc, eq, isNull} from 'drizzle-orm';
|
|
13
9
|
import {highestAdminRole} from '#core/admin-role-model.js';
|
|
14
10
|
import type {AdminGrant} from '#core/entities/admin-grant.js';
|
|
15
11
|
import type {AdministratorGrantSummary} from '#core/entities/administrator-read-model.js';
|
|
@@ -17,21 +13,40 @@ import {
|
|
|
17
13
|
AdminBootstrapClosedError,
|
|
18
14
|
AdminGrantAlreadyExistsError,
|
|
19
15
|
AdminGrantNotFoundError,
|
|
20
|
-
AdminIdempotencyKeyReuseError,
|
|
21
16
|
LastAdminOwnerError,
|
|
22
17
|
UserNotFoundError,
|
|
23
18
|
} from '#core/errors.js';
|
|
24
|
-
import {db} from './db.js';
|
|
25
19
|
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
findAdminCommandResult,
|
|
21
|
+
lockAdminCommand,
|
|
22
|
+
lockAdminOwnerGrants,
|
|
23
|
+
storeAdminCommandResult,
|
|
24
|
+
type Tx,
|
|
25
|
+
writeAdminAction,
|
|
26
|
+
} from './admin-command.js';
|
|
27
|
+
import {db} from './db.js';
|
|
28
|
+
import type {StoredAdminGrant} from './schema/admin-command-results.js';
|
|
30
29
|
import {adminGrants, toAdminGrant} from './schema/admin-grants.js';
|
|
31
|
-
import {authOutbox} from './schema/outbox.js';
|
|
32
30
|
import {users} from './schema/users.js';
|
|
33
31
|
|
|
34
|
-
type
|
|
32
|
+
type AdminOwnerQueryExecutor = ReturnType<typeof db> | Tx;
|
|
33
|
+
|
|
34
|
+
function activeAdminOwnerWhere() {
|
|
35
|
+
return and(
|
|
36
|
+
eq(adminGrants.role, 'admin-owner'),
|
|
37
|
+
isNull(adminGrants.revokedAt),
|
|
38
|
+
eq(users.status, 'active'),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function listActiveAdminOwners(executor: AdminOwnerQueryExecutor) {
|
|
43
|
+
return await executor
|
|
44
|
+
.select({id: adminGrants.id})
|
|
45
|
+
.from(adminGrants)
|
|
46
|
+
.innerJoin(users, eq(adminGrants.userId, users.id))
|
|
47
|
+
.where(activeAdminOwnerWhere())
|
|
48
|
+
.limit(2);
|
|
49
|
+
}
|
|
35
50
|
|
|
36
51
|
export interface CreateAdminGrantParams {
|
|
37
52
|
userId: string;
|
|
@@ -111,11 +126,23 @@ export async function findCurrentAdminRole(params: {userId: string}): Promise<Ad
|
|
|
111
126
|
return highestAdminRole(rows.map(({role}) => role));
|
|
112
127
|
}
|
|
113
128
|
|
|
129
|
+
/**
|
|
130
|
+
* An owner is active only while both the grant and its user account remain
|
|
131
|
+
* active. A suspended owner's grant is retained, but it intentionally does
|
|
132
|
+
* not prevent deployment-bound bootstrap recovery.
|
|
133
|
+
*/
|
|
134
|
+
export async function hasActiveAdminOwner(
|
|
135
|
+
executor: AdminOwnerQueryExecutor = db(),
|
|
136
|
+
): Promise<boolean> {
|
|
137
|
+
const rows = await listActiveAdminOwners(executor);
|
|
138
|
+
return rows.length > 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
114
141
|
export async function revokeAdminGrant(params: {grantId: string}): Promise<AdminGrant | undefined> {
|
|
115
142
|
return await db().transaction(async (tx) => {
|
|
116
143
|
// All owner grant changes share one lock so concurrent revocations cannot
|
|
117
144
|
// both observe the same final owner and leave the instance ownerless.
|
|
118
|
-
await tx
|
|
145
|
+
await lockAdminOwnerGrants(tx);
|
|
119
146
|
|
|
120
147
|
const rows = await tx
|
|
121
148
|
.select()
|
|
@@ -126,17 +153,7 @@ export async function revokeAdminGrant(params: {grantId: string}): Promise<Admin
|
|
|
126
153
|
if (!grant) return undefined;
|
|
127
154
|
|
|
128
155
|
if (grant.role === 'admin-owner') {
|
|
129
|
-
const activeOwners = await tx
|
|
130
|
-
.select({id: adminGrants.id})
|
|
131
|
-
.from(adminGrants)
|
|
132
|
-
.innerJoin(users, eq(adminGrants.userId, users.id))
|
|
133
|
-
.where(
|
|
134
|
-
and(
|
|
135
|
-
eq(adminGrants.role, 'admin-owner'),
|
|
136
|
-
isNull(adminGrants.revokedAt),
|
|
137
|
-
eq(users.status, 'active'),
|
|
138
|
-
),
|
|
139
|
-
);
|
|
156
|
+
const activeOwners = await listActiveAdminOwners(tx);
|
|
140
157
|
if (activeOwners.length <= 1) throw new LastAdminOwnerError();
|
|
141
158
|
}
|
|
142
159
|
|
|
@@ -188,23 +205,10 @@ async function findCommandResult(
|
|
|
188
205
|
command: string;
|
|
189
206
|
},
|
|
190
207
|
): Promise<AdminGrant | undefined> {
|
|
191
|
-
const
|
|
192
|
-
.select()
|
|
193
|
-
.from(adminCommandResults)
|
|
194
|
-
.where(
|
|
195
|
-
and(
|
|
196
|
-
eq(adminCommandResults.actorId, params.actorId),
|
|
197
|
-
eq(adminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
|
|
198
|
-
),
|
|
199
|
-
)
|
|
200
|
-
.limit(1);
|
|
201
|
-
const result: AdminCommandResultDb | undefined = rows[0];
|
|
208
|
+
const result = await findAdminCommandResult(tx, params);
|
|
202
209
|
if (!result) return undefined;
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
result.requestFingerprint !== params.requestFingerprint
|
|
206
|
-
) {
|
|
207
|
-
throw new AdminIdempotencyKeyReuseError();
|
|
210
|
+
if (!('grant' in result.result)) {
|
|
211
|
+
throw new Error('Administrator command result has an unexpected shape');
|
|
208
212
|
}
|
|
209
213
|
return fromStoredAdminGrant(result.result.grant);
|
|
210
214
|
}
|
|
@@ -214,29 +218,7 @@ async function storeCommandResult(
|
|
|
214
218
|
params: AuditedAdminCommandParams,
|
|
215
219
|
grant: AdminGrant,
|
|
216
220
|
): Promise<void> {
|
|
217
|
-
await tx
|
|
218
|
-
actorId: params.actorId,
|
|
219
|
-
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
220
|
-
command: params.event.command,
|
|
221
|
-
requestFingerprint: params.requestFingerprint,
|
|
222
|
-
result: {grant: toStoredAdminGrant(grant)},
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function writeAdminAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
|
|
227
|
-
await writeOutboxEvent<AdministrationActionEventMap>(tx, authOutbox, {
|
|
228
|
-
type: 'administration.action.performed',
|
|
229
|
-
payload: event,
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function lockAdminCommand(
|
|
234
|
-
tx: Tx,
|
|
235
|
-
params: {actorId: string; idempotencyKeyFingerprint: string},
|
|
236
|
-
) {
|
|
237
|
-
await tx.execute(
|
|
238
|
-
sql`select pg_advisory_xact_lock(hashtext(${`auth_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
|
|
239
|
-
);
|
|
221
|
+
await storeAdminCommandResult(tx, params, {grant: toStoredAdminGrant(grant)});
|
|
240
222
|
}
|
|
241
223
|
|
|
242
224
|
export async function bootstrapFirstAdminOwner(
|
|
@@ -244,7 +226,7 @@ export async function bootstrapFirstAdminOwner(
|
|
|
244
226
|
): Promise<AdminGrant> {
|
|
245
227
|
return await db().transaction(async (tx) => {
|
|
246
228
|
await lockAdminCommand(tx, params);
|
|
247
|
-
await tx
|
|
229
|
+
await lockAdminOwnerGrants(tx);
|
|
248
230
|
|
|
249
231
|
const existing = await findCommandResult(tx, {
|
|
250
232
|
...params,
|
|
@@ -252,19 +234,7 @@ export async function bootstrapFirstAdminOwner(
|
|
|
252
234
|
});
|
|
253
235
|
if (existing) return existing;
|
|
254
236
|
|
|
255
|
-
|
|
256
|
-
.select({id: adminGrants.id})
|
|
257
|
-
.from(adminGrants)
|
|
258
|
-
.innerJoin(users, eq(adminGrants.userId, users.id))
|
|
259
|
-
.where(
|
|
260
|
-
and(
|
|
261
|
-
eq(adminGrants.role, 'admin-owner'),
|
|
262
|
-
isNull(adminGrants.revokedAt),
|
|
263
|
-
eq(users.status, 'active'),
|
|
264
|
-
),
|
|
265
|
-
)
|
|
266
|
-
.limit(1);
|
|
267
|
-
if (activeOwners.length > 0) throw new AdminBootstrapClosedError();
|
|
237
|
+
if (await hasActiveAdminOwner(tx)) throw new AdminBootstrapClosedError();
|
|
268
238
|
|
|
269
239
|
const userRows = await tx
|
|
270
240
|
.select({id: users.id, status: users.status})
|
|
@@ -293,7 +263,7 @@ export async function grantAdminRoleWithAudit(
|
|
|
293
263
|
): Promise<AdminGrant> {
|
|
294
264
|
return await db().transaction(async (tx) => {
|
|
295
265
|
await lockAdminCommand(tx, params);
|
|
296
|
-
await tx
|
|
266
|
+
await lockAdminOwnerGrants(tx);
|
|
297
267
|
|
|
298
268
|
const existing = await findCommandResult(tx, {
|
|
299
269
|
...params,
|
|
@@ -341,7 +311,7 @@ export async function revokeAdminGrantWithAudit(
|
|
|
341
311
|
): Promise<AdminGrant> {
|
|
342
312
|
return await db().transaction(async (tx) => {
|
|
343
313
|
await lockAdminCommand(tx, params);
|
|
344
|
-
await tx
|
|
314
|
+
await lockAdminOwnerGrants(tx);
|
|
345
315
|
|
|
346
316
|
const existing = await findCommandResult(tx, {
|
|
347
317
|
...params,
|
|
@@ -358,17 +328,7 @@ export async function revokeAdminGrantWithAudit(
|
|
|
358
328
|
if (!grant) throw new AdminGrantNotFoundError();
|
|
359
329
|
|
|
360
330
|
if (grant.role === 'admin-owner') {
|
|
361
|
-
const activeOwners = await tx
|
|
362
|
-
.select({id: adminGrants.id})
|
|
363
|
-
.from(adminGrants)
|
|
364
|
-
.innerJoin(users, eq(adminGrants.userId, users.id))
|
|
365
|
-
.where(
|
|
366
|
-
and(
|
|
367
|
-
eq(adminGrants.role, 'admin-owner'),
|
|
368
|
-
isNull(adminGrants.revokedAt),
|
|
369
|
-
eq(users.status, 'active'),
|
|
370
|
-
),
|
|
371
|
-
);
|
|
331
|
+
const activeOwners = await listActiveAdminOwners(tx);
|
|
372
332
|
if (activeOwners.length <= 1) throw new LastAdminOwnerError();
|
|
373
333
|
}
|
|
374
334
|
|