@shipfox/api-auth 10.1.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 +16 -0
- package/README.md +15 -0
- package/dist/core/administration.d.ts +17 -1
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +107 -14
- 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 +1 -1
- package/dist/db/admin-grants.d.ts.map +1 -1
- package/dist/db/admin-grants.js +12 -30
- 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 +1 -1
- package/dist/index.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.map +1 -1
- package/dist/presentation/routes/administration.js +94 -3
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/core/administration.ts +137 -15
- package/src/core/auth.ts +9 -2
- package/src/db/admin-command.ts +85 -0
- package/src/db/admin-grants.ts +19 -57
- 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.ts +3 -0
- 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 +245 -2
- package/src/presentation/routes/administration.ts +88 -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,7 +1,8 @@
|
|
|
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,
|
|
@@ -10,6 +11,12 @@ import {
|
|
|
10
11
|
listAdminGrantSummaries,
|
|
11
12
|
revokeAdminGrantWithAudit,
|
|
12
13
|
} from '#db/admin-grants.js';
|
|
14
|
+
import {
|
|
15
|
+
reactivateUserWithAudit,
|
|
16
|
+
revokeUserSessionsWithAudit,
|
|
17
|
+
suspendUserWithAudit,
|
|
18
|
+
type UserModerationResult,
|
|
19
|
+
} from '#db/admin-user-moderation.js';
|
|
13
20
|
import {findAdministratorUser as findAdministratorUserInDb} from '#db/admin-users.js';
|
|
14
21
|
import {requireAdminRole} from './admin-role.js';
|
|
15
22
|
import type {AdminGrant} from './entities/admin-grant.js';
|
|
@@ -29,22 +36,22 @@ import {
|
|
|
29
36
|
|
|
30
37
|
const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
|
|
31
38
|
const ADMIN_OBSERVER_ROLE: AdminRole = 'admin-observer';
|
|
39
|
+
const ADMIN_OPERATOR_ROLE: AdminRole = 'admin-operator';
|
|
32
40
|
const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
|
|
33
41
|
const GRANT_COMMAND = 'auth.admin_grant.grant';
|
|
34
42
|
const REVOKE_COMMAND = 'auth.admin_grant.revoke';
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
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';
|
|
39
46
|
|
|
40
47
|
export function administrationCommandFingerprint(command: string, input: unknown): string {
|
|
41
|
-
return
|
|
48
|
+
return hashOpaqueToken(`${command}:${JSON.stringify(input)}`);
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
function bootstrapTokenMatches(candidate: string, expected: string | undefined): boolean {
|
|
45
52
|
if (!expected) return false;
|
|
46
|
-
const candidateHash = Buffer.from(
|
|
47
|
-
const expectedHash = Buffer.from(
|
|
53
|
+
const candidateHash = Buffer.from(hashOpaqueToken(candidate), 'hex');
|
|
54
|
+
const expectedHash = Buffer.from(hashOpaqueToken(expected), 'hex');
|
|
48
55
|
return timingSafeEqual(candidateHash, expectedHash);
|
|
49
56
|
}
|
|
50
57
|
|
|
@@ -55,13 +62,14 @@ function administrationEvent(params: {
|
|
|
55
62
|
targetType: string;
|
|
56
63
|
targetId: string;
|
|
57
64
|
reason: string;
|
|
65
|
+
requiredRole?: AdminRole;
|
|
58
66
|
correlationId: string;
|
|
59
67
|
idempotencyKeyFingerprint: string;
|
|
60
68
|
}) {
|
|
61
69
|
return createAdministrationActionEvent({
|
|
62
70
|
actorId: params.actorId,
|
|
63
71
|
actorRole: params.actorRole,
|
|
64
|
-
requiredRole: ADMIN_OWNER_ROLE,
|
|
72
|
+
requiredRole: params.requiredRole ?? ADMIN_OWNER_ROLE,
|
|
65
73
|
command: params.command,
|
|
66
74
|
targetType: params.targetType,
|
|
67
75
|
targetId: params.targetId,
|
|
@@ -79,6 +87,22 @@ export interface AdministrationMutationContext {
|
|
|
79
87
|
correlationId: string;
|
|
80
88
|
}
|
|
81
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
|
+
|
|
82
106
|
export async function bootstrapFirstAdminOwner(
|
|
83
107
|
params: AdministrationMutationContext & {bootstrapToken: string},
|
|
84
108
|
): Promise<AdminGrant> {
|
|
@@ -89,7 +113,7 @@ export async function bootstrapFirstAdminOwner(
|
|
|
89
113
|
return await bootstrapFirstAdminOwnerInDb({
|
|
90
114
|
userId: params.actorId,
|
|
91
115
|
actorId: params.actorId,
|
|
92
|
-
idempotencyKeyFingerprint:
|
|
116
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
93
117
|
requestFingerprint: administrationCommandFingerprint(BOOTSTRAP_COMMAND, {
|
|
94
118
|
actorId: params.actorId,
|
|
95
119
|
}),
|
|
@@ -101,7 +125,7 @@ export async function bootstrapFirstAdminOwner(
|
|
|
101
125
|
targetId: params.actorId,
|
|
102
126
|
reason: 'Initial administrator owner bootstrap',
|
|
103
127
|
correlationId: params.correlationId,
|
|
104
|
-
idempotencyKeyFingerprint:
|
|
128
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
105
129
|
}),
|
|
106
130
|
});
|
|
107
131
|
}
|
|
@@ -160,7 +184,7 @@ export async function grantAdministratorRole(
|
|
|
160
184
|
userId: params.userId,
|
|
161
185
|
role: params.role,
|
|
162
186
|
actorId: params.actorId,
|
|
163
|
-
idempotencyKeyFingerprint:
|
|
187
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
164
188
|
requestFingerprint: administrationCommandFingerprint(GRANT_COMMAND, {
|
|
165
189
|
userId: params.userId,
|
|
166
190
|
role: params.role,
|
|
@@ -174,7 +198,7 @@ export async function grantAdministratorRole(
|
|
|
174
198
|
targetId: params.userId,
|
|
175
199
|
reason: params.reason,
|
|
176
200
|
correlationId: params.correlationId,
|
|
177
|
-
idempotencyKeyFingerprint:
|
|
201
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
178
202
|
}),
|
|
179
203
|
});
|
|
180
204
|
}
|
|
@@ -189,7 +213,7 @@ export async function revokeAdministratorGrant(
|
|
|
189
213
|
return await revokeAdminGrantWithAudit({
|
|
190
214
|
grantId: params.grantId,
|
|
191
215
|
actorId: params.actorId,
|
|
192
|
-
idempotencyKeyFingerprint:
|
|
216
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
193
217
|
requestFingerprint: administrationCommandFingerprint(REVOKE_COMMAND, {
|
|
194
218
|
grantId: params.grantId,
|
|
195
219
|
reason: params.reason,
|
|
@@ -202,11 +226,109 @@ export async function revokeAdministratorGrant(
|
|
|
202
226
|
targetId: params.grantId,
|
|
203
227
|
reason: params.reason,
|
|
204
228
|
correlationId: params.correlationId,
|
|
205
|
-
idempotencyKeyFingerprint:
|
|
229
|
+
idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
|
|
206
230
|
}),
|
|
207
231
|
});
|
|
208
232
|
}
|
|
209
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
|
+
|
|
210
332
|
export {
|
|
211
333
|
AdminBootstrapClosedError,
|
|
212
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
|
+
}
|
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,22 @@ 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 Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
|
|
35
32
|
type AdminOwnerQueryExecutor = ReturnType<typeof db> | Tx;
|
|
36
33
|
|
|
37
34
|
function activeAdminOwnerWhere() {
|
|
@@ -145,7 +142,7 @@ export async function revokeAdminGrant(params: {grantId: string}): Promise<Admin
|
|
|
145
142
|
return await db().transaction(async (tx) => {
|
|
146
143
|
// All owner grant changes share one lock so concurrent revocations cannot
|
|
147
144
|
// both observe the same final owner and leave the instance ownerless.
|
|
148
|
-
await tx
|
|
145
|
+
await lockAdminOwnerGrants(tx);
|
|
149
146
|
|
|
150
147
|
const rows = await tx
|
|
151
148
|
.select()
|
|
@@ -208,23 +205,10 @@ async function findCommandResult(
|
|
|
208
205
|
command: string;
|
|
209
206
|
},
|
|
210
207
|
): Promise<AdminGrant | undefined> {
|
|
211
|
-
const
|
|
212
|
-
.select()
|
|
213
|
-
.from(adminCommandResults)
|
|
214
|
-
.where(
|
|
215
|
-
and(
|
|
216
|
-
eq(adminCommandResults.actorId, params.actorId),
|
|
217
|
-
eq(adminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
|
|
218
|
-
),
|
|
219
|
-
)
|
|
220
|
-
.limit(1);
|
|
221
|
-
const result: AdminCommandResultDb | undefined = rows[0];
|
|
208
|
+
const result = await findAdminCommandResult(tx, params);
|
|
222
209
|
if (!result) return undefined;
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
result.requestFingerprint !== params.requestFingerprint
|
|
226
|
-
) {
|
|
227
|
-
throw new AdminIdempotencyKeyReuseError();
|
|
210
|
+
if (!('grant' in result.result)) {
|
|
211
|
+
throw new Error('Administrator command result has an unexpected shape');
|
|
228
212
|
}
|
|
229
213
|
return fromStoredAdminGrant(result.result.grant);
|
|
230
214
|
}
|
|
@@ -234,29 +218,7 @@ async function storeCommandResult(
|
|
|
234
218
|
params: AuditedAdminCommandParams,
|
|
235
219
|
grant: AdminGrant,
|
|
236
220
|
): Promise<void> {
|
|
237
|
-
await tx
|
|
238
|
-
actorId: params.actorId,
|
|
239
|
-
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
240
|
-
command: params.event.command,
|
|
241
|
-
requestFingerprint: params.requestFingerprint,
|
|
242
|
-
result: {grant: toStoredAdminGrant(grant)},
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
async function writeAdminAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
|
|
247
|
-
await writeOutboxEvent<AdministrationActionEventMap>(tx, authOutbox, {
|
|
248
|
-
type: 'administration.action.performed',
|
|
249
|
-
payload: event,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
async function lockAdminCommand(
|
|
254
|
-
tx: Tx,
|
|
255
|
-
params: {actorId: string; idempotencyKeyFingerprint: string},
|
|
256
|
-
) {
|
|
257
|
-
await tx.execute(
|
|
258
|
-
sql`select pg_advisory_xact_lock(hashtext(${`auth_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
|
|
259
|
-
);
|
|
221
|
+
await storeAdminCommandResult(tx, params, {grant: toStoredAdminGrant(grant)});
|
|
260
222
|
}
|
|
261
223
|
|
|
262
224
|
export async function bootstrapFirstAdminOwner(
|
|
@@ -264,7 +226,7 @@ export async function bootstrapFirstAdminOwner(
|
|
|
264
226
|
): Promise<AdminGrant> {
|
|
265
227
|
return await db().transaction(async (tx) => {
|
|
266
228
|
await lockAdminCommand(tx, params);
|
|
267
|
-
await tx
|
|
229
|
+
await lockAdminOwnerGrants(tx);
|
|
268
230
|
|
|
269
231
|
const existing = await findCommandResult(tx, {
|
|
270
232
|
...params,
|
|
@@ -301,7 +263,7 @@ export async function grantAdminRoleWithAudit(
|
|
|
301
263
|
): Promise<AdminGrant> {
|
|
302
264
|
return await db().transaction(async (tx) => {
|
|
303
265
|
await lockAdminCommand(tx, params);
|
|
304
|
-
await tx
|
|
266
|
+
await lockAdminOwnerGrants(tx);
|
|
305
267
|
|
|
306
268
|
const existing = await findCommandResult(tx, {
|
|
307
269
|
...params,
|
|
@@ -349,7 +311,7 @@ export async function revokeAdminGrantWithAudit(
|
|
|
349
311
|
): Promise<AdminGrant> {
|
|
350
312
|
return await db().transaction(async (tx) => {
|
|
351
313
|
await lockAdminCommand(tx, params);
|
|
352
|
-
await tx
|
|
314
|
+
await lockAdminOwnerGrants(tx);
|
|
353
315
|
|
|
354
316
|
const existing = await findCommandResult(tx, {
|
|
355
317
|
...params,
|