@shipfox/api-auth 10.1.0 → 11.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +42 -11
- 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 +27 -10
- package/dist/core/auth.js.map +1 -1
- package/dist/core/jwt.d.ts +10 -0
- package/dist/core/jwt.d.ts.map +1 -1
- package/dist/core/jwt.js +3 -2
- package/dist/core/jwt.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 -4
- package/dist/db/refresh-tokens.d.ts.map +1 -1
- package/dist/db/refresh-tokens.js +31 -6
- 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 +1 -4
- package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
- package/dist/presentation/auth/jwt-auth.js +23 -19
- 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.test.ts +29 -10
- package/src/core/auth.ts +36 -14
- package/src/core/jwt.test.ts +20 -4
- package/src/core/jwt.ts +2 -1
- 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.test.ts +0 -19
- package/src/db/refresh-tokens.ts +54 -23
- 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 +34 -9
- package/src/presentation/auth/jwt-auth.ts +19 -13
- package/src/presentation/routes/administration.test.ts +253 -2
- package/src/presentation/routes/administration.ts +88 -1
- package/src/presentation/routes/password/change-password.test.ts +6 -0
- package/src/presentation/routes/password/password-reset-confirm.test.ts +14 -0
- package/src/presentation/routes/registration/signup.test.ts +2 -2
- package/src/presentation/routes/session/logout.test.ts +6 -0
- package/src/presentation/routes/session/refresh.test.ts +11 -0
- package/tsconfig.build.tsbuildinfo +1 -1
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,
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type {AdministrationActionEvent} from '@shipfox/api-common-dto';
|
|
2
|
+
import {and, eq, gt, isNull, sql} from 'drizzle-orm';
|
|
3
|
+
import {hasMinimumAdminRole, highestAdminRole} from '#core/admin-role-model.js';
|
|
4
|
+
import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
|
|
5
|
+
import {AdminRoleRequiredError, LastAdminOwnerError, UserNotFoundError} from '#core/errors.js';
|
|
6
|
+
import {
|
|
7
|
+
findAdminCommandResult,
|
|
8
|
+
lockAdminCommand,
|
|
9
|
+
lockAdminOwnerGrants,
|
|
10
|
+
storeAdminCommandResult,
|
|
11
|
+
type Tx,
|
|
12
|
+
writeAdminAction,
|
|
13
|
+
} from './admin-command.js';
|
|
14
|
+
import {findAdministratorUserSummary} from './admin-user-summary.js';
|
|
15
|
+
import {db} from './db.js';
|
|
16
|
+
import {lockUserSessionMutations} from './refresh-tokens.js';
|
|
17
|
+
import type {
|
|
18
|
+
StoredAdministratorUserSummary,
|
|
19
|
+
StoredAdminUserModerationResult,
|
|
20
|
+
} from './schema/admin-command-results.js';
|
|
21
|
+
import {adminGrants} from './schema/admin-grants.js';
|
|
22
|
+
import {refreshTokens} from './schema/refresh-tokens.js';
|
|
23
|
+
import {users} from './schema/users.js';
|
|
24
|
+
|
|
25
|
+
interface UserModerationCommandParams {
|
|
26
|
+
actorId: string;
|
|
27
|
+
userId: string;
|
|
28
|
+
idempotencyKeyFingerprint: string;
|
|
29
|
+
requestFingerprint: string;
|
|
30
|
+
event: AdministrationActionEvent;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toStoredUserSummary(user: AdministratorUserSummary): StoredAdministratorUserSummary {
|
|
34
|
+
return {
|
|
35
|
+
id: user.id,
|
|
36
|
+
email: user.email,
|
|
37
|
+
name: user.name,
|
|
38
|
+
emailVerifiedAt: user.emailVerifiedAt?.toISOString() ?? null,
|
|
39
|
+
status: user.status,
|
|
40
|
+
createdAt: user.createdAt.toISOString(),
|
|
41
|
+
adminRole: user.adminRole,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function findUserSummary(tx: Tx, userId: string): Promise<StoredAdministratorUserSummary> {
|
|
46
|
+
const user = await findAdministratorUserSummary(tx, {id: userId});
|
|
47
|
+
if (!user) throw new Error('User summary query returned no rows');
|
|
48
|
+
return toStoredUserSummary(user);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fromStoredResult(result: StoredAdminUserModerationResult) {
|
|
52
|
+
return {
|
|
53
|
+
user: {
|
|
54
|
+
id: result.user.id,
|
|
55
|
+
email: result.user.email,
|
|
56
|
+
name: result.user.name,
|
|
57
|
+
emailVerifiedAt: result.user.emailVerifiedAt ? new Date(result.user.emailVerifiedAt) : null,
|
|
58
|
+
status: result.user.status,
|
|
59
|
+
createdAt: new Date(result.user.createdAt),
|
|
60
|
+
adminRole: result.user.adminRole,
|
|
61
|
+
},
|
|
62
|
+
correlationId: result.correlationId,
|
|
63
|
+
sessionsRevoked: result.sessionsRevoked,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function findCommandResult(
|
|
68
|
+
tx: Tx,
|
|
69
|
+
params: Pick<
|
|
70
|
+
UserModerationCommandParams,
|
|
71
|
+
'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
|
|
72
|
+
> & {command: string},
|
|
73
|
+
) {
|
|
74
|
+
const result = await findAdminCommandResult(tx, params);
|
|
75
|
+
if (!result) return undefined;
|
|
76
|
+
if (!('userModeration' in result.result)) {
|
|
77
|
+
throw new Error('Administrator command result has an unexpected shape');
|
|
78
|
+
}
|
|
79
|
+
return fromStoredResult(result.result.userModeration);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function storeCommandResult(
|
|
83
|
+
tx: Tx,
|
|
84
|
+
params: UserModerationCommandParams,
|
|
85
|
+
result: StoredAdminUserModerationResult,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
await storeAdminCommandResult(tx, params, {userModeration: result});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readTargetUserForUpdate(tx: Tx, userId: string) {
|
|
91
|
+
const rows = await tx
|
|
92
|
+
.select({id: users.id, status: users.status})
|
|
93
|
+
.from(users)
|
|
94
|
+
.where(eq(users.id, userId))
|
|
95
|
+
.limit(1)
|
|
96
|
+
.for('update');
|
|
97
|
+
const user = rows[0];
|
|
98
|
+
if (!user || user.status === 'deleted') throw new UserNotFoundError(userId);
|
|
99
|
+
return user;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function requireActiveAdminOperator(tx: Tx, actorId: string): Promise<void> {
|
|
103
|
+
const actorRows = await tx
|
|
104
|
+
.select({status: users.status})
|
|
105
|
+
.from(users)
|
|
106
|
+
.where(eq(users.id, actorId))
|
|
107
|
+
.limit(1);
|
|
108
|
+
const actor = actorRows[0];
|
|
109
|
+
const grants = await tx
|
|
110
|
+
.select({role: adminGrants.role})
|
|
111
|
+
.from(adminGrants)
|
|
112
|
+
.where(and(eq(adminGrants.userId, actorId), isNull(adminGrants.revokedAt)));
|
|
113
|
+
const role = highestAdminRole(grants.map(({role}) => role));
|
|
114
|
+
|
|
115
|
+
if (actor?.status !== 'active' || !role || !hasMinimumAdminRole(role, 'admin-operator')) {
|
|
116
|
+
throw new AdminRoleRequiredError('admin-operator');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function revokeActiveSessions(tx: Tx, userId: string): Promise<number> {
|
|
121
|
+
const revoked = await tx
|
|
122
|
+
.update(refreshTokens)
|
|
123
|
+
.set({revokedAt: sql`now()`, updatedAt: sql`now()`})
|
|
124
|
+
.where(
|
|
125
|
+
and(
|
|
126
|
+
eq(refreshTokens.userId, userId),
|
|
127
|
+
isNull(refreshTokens.revokedAt),
|
|
128
|
+
gt(refreshTokens.expiresAt, sql`now()`),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
.returning({sessionId: refreshTokens.sessionId});
|
|
132
|
+
|
|
133
|
+
return new Set(revoked.map(({sessionId}) => sessionId)).size;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function executeUserModerationCommand(
|
|
137
|
+
params: UserModerationCommandParams & {
|
|
138
|
+
operation: 'suspend' | 'reactivate' | 'revoke-sessions';
|
|
139
|
+
},
|
|
140
|
+
) {
|
|
141
|
+
return await db().transaction(async (tx) => {
|
|
142
|
+
await lockAdminCommand(tx, params);
|
|
143
|
+
if (params.operation === 'suspend') await lockAdminOwnerGrants(tx);
|
|
144
|
+
|
|
145
|
+
const existing = await findCommandResult(tx, {
|
|
146
|
+
...params,
|
|
147
|
+
command: params.event.command,
|
|
148
|
+
});
|
|
149
|
+
if (existing) return existing;
|
|
150
|
+
|
|
151
|
+
await lockUserSessionMutations(tx, params.userId);
|
|
152
|
+
const user = await readTargetUserForUpdate(tx, params.userId);
|
|
153
|
+
await requireActiveAdminOperator(tx, params.actorId);
|
|
154
|
+
let sessionsRevoked = 0;
|
|
155
|
+
|
|
156
|
+
if (params.operation === 'suspend') {
|
|
157
|
+
if (user.status === 'active') {
|
|
158
|
+
const activeOwners = await tx
|
|
159
|
+
.select({id: adminGrants.id})
|
|
160
|
+
.from(adminGrants)
|
|
161
|
+
.innerJoin(users, eq(adminGrants.userId, users.id))
|
|
162
|
+
.where(
|
|
163
|
+
and(
|
|
164
|
+
eq(adminGrants.role, 'admin-owner'),
|
|
165
|
+
isNull(adminGrants.revokedAt),
|
|
166
|
+
eq(users.status, 'active'),
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
.limit(2);
|
|
170
|
+
const targetIsActiveOwner = await tx
|
|
171
|
+
.select({id: adminGrants.id})
|
|
172
|
+
.from(adminGrants)
|
|
173
|
+
.where(
|
|
174
|
+
and(
|
|
175
|
+
eq(adminGrants.userId, user.id),
|
|
176
|
+
eq(adminGrants.role, 'admin-owner'),
|
|
177
|
+
isNull(adminGrants.revokedAt),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
.limit(1);
|
|
181
|
+
if (targetIsActiveOwner.length > 0 && activeOwners.length <= 1) {
|
|
182
|
+
throw new LastAdminOwnerError();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await tx
|
|
186
|
+
.update(users)
|
|
187
|
+
.set({status: 'suspended', updatedAt: sql`now()`})
|
|
188
|
+
.where(eq(users.id, user.id));
|
|
189
|
+
}
|
|
190
|
+
sessionsRevoked = await revokeActiveSessions(tx, user.id);
|
|
191
|
+
} else if (params.operation === 'reactivate' && user.status === 'suspended') {
|
|
192
|
+
await tx
|
|
193
|
+
.update(users)
|
|
194
|
+
.set({status: 'active', updatedAt: sql`now()`})
|
|
195
|
+
.where(eq(users.id, user.id));
|
|
196
|
+
} else if (params.operation === 'revoke-sessions') {
|
|
197
|
+
sessionsRevoked = await revokeActiveSessions(tx, user.id);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const summary = await findUserSummary(tx, user.id);
|
|
201
|
+
const result: StoredAdminUserModerationResult = {
|
|
202
|
+
user: summary,
|
|
203
|
+
correlationId: params.event.correlationId,
|
|
204
|
+
sessionsRevoked,
|
|
205
|
+
};
|
|
206
|
+
await writeAdminAction(tx, params.event);
|
|
207
|
+
await storeCommandResult(tx, params, result);
|
|
208
|
+
return fromStoredResult(result);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type UserModerationResult = Awaited<ReturnType<typeof executeUserModerationCommand>>;
|
|
213
|
+
|
|
214
|
+
export async function suspendUserWithAudit(
|
|
215
|
+
params: UserModerationCommandParams,
|
|
216
|
+
): Promise<UserModerationResult> {
|
|
217
|
+
return await executeUserModerationCommand({...params, operation: 'suspend'});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function reactivateUserWithAudit(
|
|
221
|
+
params: UserModerationCommandParams,
|
|
222
|
+
): Promise<UserModerationResult> {
|
|
223
|
+
return await executeUserModerationCommand({...params, operation: 'reactivate'});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function revokeUserSessionsWithAudit(
|
|
227
|
+
params: UserModerationCommandParams,
|
|
228
|
+
): Promise<UserModerationResult> {
|
|
229
|
+
return await executeUserModerationCommand({...params, operation: 'revoke-sessions'});
|
|
230
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {and, eq, isNull} from 'drizzle-orm';
|
|
2
|
+
import {highestAdminRole} from '#core/admin-role-model.js';
|
|
3
|
+
import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
|
|
4
|
+
import type {db} from './db.js';
|
|
5
|
+
import {adminGrants} from './schema/admin-grants.js';
|
|
6
|
+
import {users} from './schema/users.js';
|
|
7
|
+
|
|
8
|
+
type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
|
|
9
|
+
export type AdministratorUserSummaryExecutor = ReturnType<typeof db> | Tx;
|
|
10
|
+
|
|
11
|
+
type AdministratorUserLookup = {id: string; email?: never} | {email: string; id?: never};
|
|
12
|
+
|
|
13
|
+
export async function findAdministratorUserSummary(
|
|
14
|
+
executor: AdministratorUserSummaryExecutor,
|
|
15
|
+
params: AdministratorUserLookup,
|
|
16
|
+
): Promise<AdministratorUserSummary | undefined> {
|
|
17
|
+
const identifier = 'id' in params ? eq(users.id, params.id) : eq(users.email, params.email);
|
|
18
|
+
const rows = await executor
|
|
19
|
+
.select({
|
|
20
|
+
id: users.id,
|
|
21
|
+
email: users.email,
|
|
22
|
+
name: users.name,
|
|
23
|
+
emailVerifiedAt: users.emailVerifiedAt,
|
|
24
|
+
status: users.status,
|
|
25
|
+
createdAt: users.createdAt,
|
|
26
|
+
adminRole: adminGrants.role,
|
|
27
|
+
})
|
|
28
|
+
.from(users)
|
|
29
|
+
.leftJoin(
|
|
30
|
+
adminGrants,
|
|
31
|
+
and(
|
|
32
|
+
eq(adminGrants.userId, users.id),
|
|
33
|
+
isNull(adminGrants.revokedAt),
|
|
34
|
+
eq(users.status, 'active'),
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
.where(identifier);
|
|
38
|
+
|
|
39
|
+
const first = rows[0];
|
|
40
|
+
if (!first) return undefined;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
id: first.id,
|
|
44
|
+
email: first.email,
|
|
45
|
+
name: first.name,
|
|
46
|
+
emailVerifiedAt: first.emailVerifiedAt,
|
|
47
|
+
status: first.status,
|
|
48
|
+
createdAt: first.createdAt,
|
|
49
|
+
adminRole: highestAdminRole(rows.flatMap(({adminRole}) => (adminRole ? [adminRole] : []))),
|
|
50
|
+
} satisfies AdministratorUserSummary;
|
|
51
|
+
}
|
package/src/db/admin-users.ts
CHANGED
|
@@ -1,58 +1,13 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
import {highestAdminRole} from '#core/admin-role-model.js';
|
|
4
|
-
import type {UserStatus} from '#core/entities/user.js';
|
|
1
|
+
import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
|
|
2
|
+
import {findAdministratorUserSummary} from './admin-user-summary.js';
|
|
5
3
|
import {db} from './db.js';
|
|
6
|
-
import {adminGrants} from './schema/admin-grants.js';
|
|
7
|
-
import {users} from './schema/users.js';
|
|
8
4
|
|
|
9
|
-
export
|
|
10
|
-
id: string;
|
|
11
|
-
email: string;
|
|
12
|
-
name: string | null;
|
|
13
|
-
emailVerifiedAt: Date | null;
|
|
14
|
-
status: UserStatus;
|
|
15
|
-
createdAt: Date;
|
|
16
|
-
adminRole: AdminRole | null;
|
|
17
|
-
}
|
|
5
|
+
export type AdministratorUserRecord = AdministratorUserSummary;
|
|
18
6
|
|
|
19
7
|
type AdministratorUserLookup = {id: string; email?: never} | {email: string; id?: never};
|
|
20
8
|
|
|
21
9
|
export async function findAdministratorUser(
|
|
22
10
|
params: AdministratorUserLookup,
|
|
23
11
|
): Promise<AdministratorUserRecord | undefined> {
|
|
24
|
-
|
|
25
|
-
const rows = await db()
|
|
26
|
-
.select({
|
|
27
|
-
id: users.id,
|
|
28
|
-
email: users.email,
|
|
29
|
-
name: users.name,
|
|
30
|
-
emailVerifiedAt: users.emailVerifiedAt,
|
|
31
|
-
status: users.status,
|
|
32
|
-
createdAt: users.createdAt,
|
|
33
|
-
adminRole: adminGrants.role,
|
|
34
|
-
})
|
|
35
|
-
.from(users)
|
|
36
|
-
.leftJoin(
|
|
37
|
-
adminGrants,
|
|
38
|
-
and(
|
|
39
|
-
eq(adminGrants.userId, users.id),
|
|
40
|
-
isNull(adminGrants.revokedAt),
|
|
41
|
-
eq(users.status, 'active'),
|
|
42
|
-
),
|
|
43
|
-
)
|
|
44
|
-
.where(identifier);
|
|
45
|
-
|
|
46
|
-
const first = rows[0];
|
|
47
|
-
if (!first) return undefined;
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
id: first.id,
|
|
51
|
-
email: first.email,
|
|
52
|
-
name: first.name,
|
|
53
|
-
emailVerifiedAt: first.emailVerifiedAt,
|
|
54
|
-
status: first.status,
|
|
55
|
-
createdAt: first.createdAt,
|
|
56
|
-
adminRole: highestAdminRole(rows.flatMap(({adminRole}) => (adminRole ? [adminRole] : []))),
|
|
57
|
-
};
|
|
12
|
+
return await findAdministratorUserSummary(db(), params);
|
|
58
13
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {hashOpaqueToken} from '@shipfox/node-tokens';
|
|
2
2
|
import {
|
|
3
3
|
createRefreshToken,
|
|
4
|
-
findActiveRefreshSession,
|
|
5
4
|
findActiveRefreshTokenByHash,
|
|
6
5
|
findRefreshTokenByHash,
|
|
7
6
|
revokeRefreshSession,
|
|
@@ -45,24 +44,6 @@ describe('refresh-tokens db', () => {
|
|
|
45
44
|
expect(found).toBeUndefined();
|
|
46
45
|
});
|
|
47
46
|
|
|
48
|
-
test('finds only the active session owned by the requested user', async () => {
|
|
49
|
-
const user = await createUser({email: emailFor('rt-session'), hashedPassword: 'h'});
|
|
50
|
-
const otherUser = await createUser({email: emailFor('rt-session-other'), hashedPassword: 'h'});
|
|
51
|
-
const sessionId = crypto.randomUUID();
|
|
52
|
-
await createRefreshToken({
|
|
53
|
-
userId: user.id,
|
|
54
|
-
sessionId,
|
|
55
|
-
hashedToken: hashOpaqueToken(`active-${crypto.randomUUID()}`),
|
|
56
|
-
expiresAt: new Date(Date.now() + 60_000),
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const found = await findActiveRefreshSession({sessionId, userId: user.id});
|
|
60
|
-
const otherUserFound = await findActiveRefreshSession({sessionId, userId: otherUser.id});
|
|
61
|
-
|
|
62
|
-
expect(found?.sessionId).toBe(sessionId);
|
|
63
|
-
expect(otherUserFound).toBeUndefined();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
47
|
test('rotates a token once, creates the successor, and only the first caller wins', async () => {
|
|
67
48
|
const user = await createUser({email: emailFor('rt-rotate'), hashedPassword: 'h'});
|
|
68
49
|
const currentHashedToken = hashOpaqueToken(`current-${crypto.randomUUID()}`);
|
package/src/db/refresh-tokens.ts
CHANGED
|
@@ -2,6 +2,13 @@ import {and, eq, gt, isNull, ne, sql} from 'drizzle-orm';
|
|
|
2
2
|
import type {RefreshToken} from '#core/entities/refresh-token.js';
|
|
3
3
|
import {db} from './db.js';
|
|
4
4
|
import {refreshTokens, toRefreshToken} from './schema/refresh-tokens.js';
|
|
5
|
+
import {users} from './schema/users.js';
|
|
6
|
+
|
|
7
|
+
type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
|
|
8
|
+
|
|
9
|
+
export async function lockUserSessionMutations(tx: Tx, userId: string): Promise<void> {
|
|
10
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`auth_user_sessions:${userId}`}))`);
|
|
11
|
+
}
|
|
5
12
|
|
|
6
13
|
export interface CreateRefreshTokenParams {
|
|
7
14
|
sessionId?: string | undefined;
|
|
@@ -26,6 +33,33 @@ export async function createRefreshToken(params: CreateRefreshTokenParams): Prom
|
|
|
26
33
|
return toRefreshToken(row);
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
export async function createRefreshTokenForActiveUser(
|
|
37
|
+
params: CreateRefreshTokenParams,
|
|
38
|
+
): Promise<RefreshToken | undefined> {
|
|
39
|
+
return await db().transaction(async (tx) => {
|
|
40
|
+
await lockUserSessionMutations(tx, params.userId);
|
|
41
|
+
const userRows = await tx
|
|
42
|
+
.select({status: users.status})
|
|
43
|
+
.from(users)
|
|
44
|
+
.where(eq(users.id, params.userId))
|
|
45
|
+
.limit(1);
|
|
46
|
+
if (userRows[0]?.status !== 'active') return undefined;
|
|
47
|
+
|
|
48
|
+
const rows = await tx
|
|
49
|
+
.insert(refreshTokens)
|
|
50
|
+
.values({
|
|
51
|
+
sessionId: params.sessionId,
|
|
52
|
+
userId: params.userId,
|
|
53
|
+
hashedToken: params.hashedToken,
|
|
54
|
+
expiresAt: params.expiresAt,
|
|
55
|
+
})
|
|
56
|
+
.returning();
|
|
57
|
+
const row = rows[0];
|
|
58
|
+
if (!row) throw new Error('Insert returned no rows');
|
|
59
|
+
return toRefreshToken(row);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
29
63
|
/**
|
|
30
64
|
* Looks up the live session token by hash — one that can still authenticate as
|
|
31
65
|
* the current session. Returns `undefined` for revoked, expired, or already
|
|
@@ -53,29 +87,6 @@ export async function findActiveRefreshTokenByHash(params: {
|
|
|
53
87
|
return toRefreshToken(row);
|
|
54
88
|
}
|
|
55
89
|
|
|
56
|
-
export async function findActiveRefreshSession(params: {
|
|
57
|
-
sessionId: string;
|
|
58
|
-
userId: string;
|
|
59
|
-
}): Promise<RefreshToken | undefined> {
|
|
60
|
-
const rows = await db()
|
|
61
|
-
.select()
|
|
62
|
-
.from(refreshTokens)
|
|
63
|
-
.where(
|
|
64
|
-
and(
|
|
65
|
-
eq(refreshTokens.sessionId, params.sessionId),
|
|
66
|
-
eq(refreshTokens.userId, params.userId),
|
|
67
|
-
isNull(refreshTokens.revokedAt),
|
|
68
|
-
isNull(refreshTokens.rotatedAt),
|
|
69
|
-
gt(refreshTokens.expiresAt, sql`now()`),
|
|
70
|
-
),
|
|
71
|
-
)
|
|
72
|
-
.limit(1);
|
|
73
|
-
|
|
74
|
-
const row = rows[0];
|
|
75
|
-
if (!row) return undefined;
|
|
76
|
-
return toRefreshToken(row);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
90
|
/**
|
|
80
91
|
* Looks up a non-revoked, non-expired token by hash, including ones already
|
|
81
92
|
* rotated. Rotation reads {@link RefreshToken.rotatedAt} to decide whether to
|
|
@@ -108,12 +119,32 @@ export async function findRefreshTokenByHash(params: {
|
|
|
108
119
|
* If the successor insert fails, the predecessor rotation rolls back.
|
|
109
120
|
*/
|
|
110
121
|
export async function rotateRefreshToken(params: {
|
|
122
|
+
userId?: string | undefined;
|
|
111
123
|
id: string;
|
|
112
124
|
currentHashedToken: string;
|
|
113
125
|
nextHashedToken: string;
|
|
114
126
|
expiresAt: Date;
|
|
115
127
|
}): Promise<RefreshToken | undefined> {
|
|
116
128
|
return await db().transaction(async (tx) => {
|
|
129
|
+
const userId =
|
|
130
|
+
params.userId ??
|
|
131
|
+
(
|
|
132
|
+
await tx
|
|
133
|
+
.select({userId: refreshTokens.userId})
|
|
134
|
+
.from(refreshTokens)
|
|
135
|
+
.where(eq(refreshTokens.id, params.id))
|
|
136
|
+
.limit(1)
|
|
137
|
+
)[0]?.userId;
|
|
138
|
+
if (!userId) return undefined;
|
|
139
|
+
|
|
140
|
+
await lockUserSessionMutations(tx, userId);
|
|
141
|
+
const userRows = await tx
|
|
142
|
+
.select({status: users.status})
|
|
143
|
+
.from(users)
|
|
144
|
+
.where(eq(users.id, userId))
|
|
145
|
+
.limit(1);
|
|
146
|
+
if (userRows[0]?.status !== 'active') return undefined;
|
|
147
|
+
|
|
117
148
|
const rotatedRows = await tx
|
|
118
149
|
.update(refreshTokens)
|
|
119
150
|
.set({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
2
2
|
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
3
3
|
import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
4
|
+
import type {UserStatus} from '#core/entities/user.js';
|
|
4
5
|
import {pgTable} from './common.js';
|
|
5
6
|
import {users} from './users.js';
|
|
6
7
|
|
|
@@ -13,10 +14,30 @@ export interface StoredAdminGrant {
|
|
|
13
14
|
updatedAt: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export interface
|
|
17
|
-
|
|
17
|
+
export interface StoredAdministratorUserSummary {
|
|
18
|
+
id: string;
|
|
19
|
+
email: string;
|
|
20
|
+
name: string | null;
|
|
21
|
+
emailVerifiedAt: string | null;
|
|
22
|
+
status: UserStatus;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
adminRole: AdminRole | null;
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
export interface StoredAdminUserModerationResult {
|
|
28
|
+
user: StoredAdministratorUserSummary;
|
|
29
|
+
correlationId: string;
|
|
30
|
+
sessionsRevoked: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type StoredAdminCommandResult =
|
|
34
|
+
| {
|
|
35
|
+
grant: StoredAdminGrant;
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
userModeration: StoredAdminUserModerationResult;
|
|
39
|
+
};
|
|
40
|
+
|
|
20
41
|
export const adminCommandResults = pgTable(
|
|
21
42
|
'admin_command_results',
|
|
22
43
|
{
|
package/src/index.ts
CHANGED
|
@@ -40,7 +40,10 @@ export {
|
|
|
40
40
|
export {
|
|
41
41
|
bootstrapFirstAdminOwner,
|
|
42
42
|
grantAdministratorRole,
|
|
43
|
+
reactivateAdministratorUser,
|
|
43
44
|
revokeAdministratorGrant,
|
|
45
|
+
revokeAdministratorUserSessions,
|
|
46
|
+
suspendAdministratorUser,
|
|
44
47
|
} from '#core/administration.js';
|
|
45
48
|
export type {
|
|
46
49
|
CreateSessionForUserError,
|