@shipfox/api-auth 15.0.0 → 18.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 +41 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/core/administration.d.ts +24 -2
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +126 -3
- package/dist/core/administration.js.map +1 -1
- package/dist/core/auth.d.ts +31 -0
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +70 -2
- package/dist/core/auth.js.map +1 -1
- package/dist/core/errors.d.ts +15 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +30 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/jwt.d.ts +2 -0
- package/dist/core/jwt.d.ts.map +1 -1
- package/dist/core/jwt.js +22 -1
- package/dist/core/jwt.js.map +1 -1
- package/dist/core/runner-session-token.d.ts.map +1 -1
- package/dist/core/runner-session-token.js +2 -1
- package/dist/core/runner-session-token.js.map +1 -1
- package/dist/db/admin-command.d.ts +12 -4
- package/dist/db/admin-command.d.ts.map +1 -1
- package/dist/db/admin-command.js +10 -1
- package/dist/db/admin-command.js.map +1 -1
- package/dist/db/admin-grants.d.ts.map +1 -1
- package/dist/db/admin-grants.js +4 -1
- package/dist/db/admin-grants.js.map +1 -1
- package/dist/db/admin-user-moderation.d.ts +3 -0
- package/dist/db/admin-user-moderation.d.ts.map +1 -1
- package/dist/db/admin-user-moderation.js +6 -2
- package/dist/db/admin-user-moderation.js.map +1 -1
- package/dist/db/impersonation.d.ts +55 -0
- package/dist/db/impersonation.d.ts.map +1 -0
- package/dist/db/impersonation.js +212 -0
- package/dist/db/impersonation.js.map +1 -0
- package/dist/db/schema/admin-command-results.d.ts +16 -0
- 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 +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/metrics/index.d.ts +1 -1
- package/dist/metrics/index.d.ts.map +1 -1
- package/dist/metrics/index.js +1 -1
- package/dist/metrics/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +4 -2
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +8 -0
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/auth/bearer-token-auth.d.ts +2 -2
- package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
- package/dist/presentation/auth/bearer-token-auth.js +2 -2
- 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 +26 -2
- package/dist/presentation/auth/jwt-auth.js.map +1 -1
- package/dist/presentation/routes/administration.d.ts +2 -1
- package/dist/presentation/routes/administration.d.ts.map +1 -1
- package/dist/presentation/routes/administration.js +127 -19
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts +8 -0
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +44 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/presentation/routes/session/me.d.ts.map +1 -1
- package/dist/presentation/routes/session/me.js +2 -1
- package/dist/presentation/routes/session/me.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/src/config.ts +4 -0
- package/src/core/administration.ts +172 -1
- package/src/core/auth.test.ts +79 -0
- package/src/core/auth.ts +98 -1
- package/src/core/errors.ts +35 -0
- package/src/core/jwt.test.ts +92 -0
- package/src/core/jwt.ts +38 -9
- package/src/core/runner-session-token.test.ts +2 -0
- package/src/core/runner-session-token.ts +1 -0
- package/src/db/admin-command.ts +35 -6
- package/src/db/admin-grants.ts +5 -1
- package/src/db/admin-user-moderation.ts +8 -2
- package/src/db/impersonation.ts +323 -0
- package/src/db/schema/admin-command-results.ts +18 -0
- package/src/index.test.ts +1 -1
- package/src/index.ts +12 -3
- package/src/metrics/index.ts +2 -0
- package/src/metrics/instance.ts +13 -2
- package/src/presentation/auth/bearer-token-auth.ts +4 -4
- package/src/presentation/auth/jwt-auth.test.ts +191 -0
- package/src/presentation/auth/jwt-auth.ts +19 -1
- package/src/presentation/routes/administration.test.ts +828 -3
- package/src/presentation/routes/administration.ts +130 -11
- package/src/presentation/routes/rate-limit.ts +40 -0
- package/src/presentation/routes/session/login.test.ts +5 -0
- package/src/presentation/routes/session/me.test.ts +24 -0
- package/src/presentation/routes/session/me.ts +1 -0
- package/src/presentation/routes/session/refresh.test.ts +11 -0
- package/test/routes.ts +43 -16
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/core/jwt.ts
CHANGED
|
@@ -11,20 +11,39 @@ export const tokenMembershipSchema = z.object({
|
|
|
11
11
|
|
|
12
12
|
export type TokenMembership = z.infer<typeof tokenMembershipSchema>;
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
const impersonatorIdSchema = z.string().uuid();
|
|
15
|
+
|
|
16
|
+
// UUIDs are case-insensitive hex strings: compare normalized values so a
|
|
17
|
+
// re-cased impersonatorId cannot pass off the subject as its own impersonator.
|
|
18
|
+
function isSameUuid(a: string, b: string): boolean {
|
|
19
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Rollback hazard: pre-impersonation verifiers strip unknown claims (zod's
|
|
23
|
+
// default object parsing), so a marked token verified by an old build silently
|
|
24
|
+
// loses the marker. Upgrade every verifier before any issuer mints marked tokens.
|
|
25
|
+
export const userTokenClaimsSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
sub: z.string().uuid(),
|
|
28
|
+
refreshSessionId: z.string().uuid().optional(),
|
|
29
|
+
impersonatorId: impersonatorIdSchema.optional(),
|
|
30
|
+
email: z.string().email(),
|
|
31
|
+
name: z.string().nullable().optional(),
|
|
32
|
+
memberships: z.array(tokenMembershipSchema),
|
|
33
|
+
iat: z.number().int(),
|
|
34
|
+
exp: z.number().int(),
|
|
35
|
+
})
|
|
36
|
+
.refine(
|
|
37
|
+
(claims) =>
|
|
38
|
+
claims.impersonatorId === undefined || !isSameUuid(claims.impersonatorId, claims.sub),
|
|
39
|
+
{message: 'impersonatorId must differ from sub'},
|
|
40
|
+
);
|
|
23
41
|
|
|
24
42
|
export type UserTokenClaims = z.infer<typeof userTokenClaimsSchema>;
|
|
25
43
|
|
|
26
44
|
export interface SignUserTokenParams {
|
|
27
45
|
refreshSessionId?: string | undefined;
|
|
46
|
+
impersonatorId?: string | undefined;
|
|
28
47
|
userId: string;
|
|
29
48
|
email: string;
|
|
30
49
|
name?: string | null | undefined;
|
|
@@ -39,12 +58,22 @@ export interface VerifyUserTokenParams {
|
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
export async function signUserToken(params: SignUserTokenParams): Promise<string> {
|
|
61
|
+
if (params.impersonatorId !== undefined) {
|
|
62
|
+
if (!impersonatorIdSchema.safeParse(params.impersonatorId).success) {
|
|
63
|
+
throw new TypeError('impersonatorId must be a UUID');
|
|
64
|
+
}
|
|
65
|
+
if (isSameUuid(params.impersonatorId, params.userId)) {
|
|
66
|
+
throw new TypeError('impersonatorId must differ from userId');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
const token = await signHs256({
|
|
43
71
|
payload: {
|
|
44
72
|
email: params.email,
|
|
45
73
|
name: params.name ?? null,
|
|
46
74
|
memberships: params.memberships,
|
|
47
75
|
refreshSessionId: params.refreshSessionId,
|
|
76
|
+
impersonatorId: params.impersonatorId,
|
|
48
77
|
},
|
|
49
78
|
secret: params.secret,
|
|
50
79
|
expiresIn: params.expiresIn,
|
|
@@ -14,6 +14,7 @@ function claims() {
|
|
|
14
14
|
scope: 'workspace' as const,
|
|
15
15
|
labels: ['linux', 'x64'],
|
|
16
16
|
maxClaims: null,
|
|
17
|
+
lifecycleCapabilities: ['local_execution_fence_v1'],
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -30,6 +31,7 @@ describe('runner-session-token', () => {
|
|
|
30
31
|
expect(verified?.scope).toBe(input.scope);
|
|
31
32
|
expect(verified?.labels).toEqual(input.labels);
|
|
32
33
|
expect(verified?.maxClaims).toBeNull();
|
|
34
|
+
expect(verified?.lifecycleCapabilities).toEqual(input.lifecycleCapabilities);
|
|
33
35
|
expect(verified?.aud).toBe(RUNNER_SESSION_TOKEN_AUDIENCE);
|
|
34
36
|
expect(verified?.iat).toBeTypeOf('number');
|
|
35
37
|
expect(verified?.exp).toBeGreaterThan(verified?.iat ?? 0);
|
|
@@ -21,6 +21,7 @@ export async function issueRunnerSessionToken(
|
|
|
21
21
|
scope: claims.scope,
|
|
22
22
|
labels: claims.labels,
|
|
23
23
|
maxClaims: claims.maxClaims,
|
|
24
|
+
lifecycleCapabilities: claims.lifecycleCapabilities,
|
|
24
25
|
},
|
|
25
26
|
secret: runnerSessionTokenKey(),
|
|
26
27
|
expiresIn: config.AUTH_RUNNER_SESSION_TOKEN_EXPIRES_IN,
|
package/src/db/admin-command.ts
CHANGED
|
@@ -22,6 +22,16 @@ export interface AdminCommandTransactionParams {
|
|
|
22
22
|
event: AdministrationActionEvent;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export type AdminCommandResultLookup = Pick<
|
|
26
|
+
AdminCommandTransactionParams,
|
|
27
|
+
'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
|
|
28
|
+
> & {command: string};
|
|
29
|
+
|
|
30
|
+
export type AdminCommandResultKey = Pick<
|
|
31
|
+
AdminCommandTransactionParams,
|
|
32
|
+
'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
|
|
33
|
+
>;
|
|
34
|
+
|
|
25
35
|
export async function lockAdminCommand(
|
|
26
36
|
tx: Tx,
|
|
27
37
|
params: Pick<AdminCommandTransactionParams, 'actorId' | 'idempotencyKeyFingerprint'>,
|
|
@@ -37,10 +47,7 @@ export async function lockAdminOwnerGrants(tx: Tx): Promise<void> {
|
|
|
37
47
|
|
|
38
48
|
export async function findAdminCommandResult(
|
|
39
49
|
tx: Tx,
|
|
40
|
-
params:
|
|
41
|
-
AdminCommandTransactionParams,
|
|
42
|
-
'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
|
|
43
|
-
> & {command: string},
|
|
50
|
+
params: AdminCommandResultLookup,
|
|
44
51
|
): Promise<AdminCommandResultDb | undefined> {
|
|
45
52
|
const rows = await tx
|
|
46
53
|
.select()
|
|
@@ -65,18 +72,40 @@ export async function findAdminCommandResult(
|
|
|
65
72
|
|
|
66
73
|
export async function storeAdminCommandResult(
|
|
67
74
|
tx: Tx,
|
|
68
|
-
params:
|
|
75
|
+
params: AdminCommandResultLookup,
|
|
69
76
|
result: StoredAdminCommandResult,
|
|
70
77
|
): Promise<void> {
|
|
71
78
|
await tx.insert(adminCommandResults).values({
|
|
72
79
|
actorId: params.actorId,
|
|
73
80
|
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
74
|
-
command: params.
|
|
81
|
+
command: params.command,
|
|
75
82
|
requestFingerprint: params.requestFingerprint,
|
|
76
83
|
result,
|
|
77
84
|
});
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Replaces the stored result of an already-committed command, used by
|
|
89
|
+
* impersonation replays to append the newly issued token's fingerprint while
|
|
90
|
+
* keeping the original `expires_at`.
|
|
91
|
+
*/
|
|
92
|
+
export async function updateAdminCommandResult(
|
|
93
|
+
tx: Tx,
|
|
94
|
+
params: AdminCommandResultKey,
|
|
95
|
+
result: StoredAdminCommandResult,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
await tx
|
|
98
|
+
.update(adminCommandResults)
|
|
99
|
+
.set({result})
|
|
100
|
+
.where(
|
|
101
|
+
and(
|
|
102
|
+
eq(adminCommandResults.actorId, params.actorId),
|
|
103
|
+
eq(adminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
|
|
104
|
+
eq(adminCommandResults.requestFingerprint, params.requestFingerprint),
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
80
109
|
export async function writeAdminAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
|
|
81
110
|
await writeOutboxEvent<AdministrationActionEventMap>(tx, authOutbox, {
|
|
82
111
|
type: 'administration.action.performed',
|
package/src/db/admin-grants.ts
CHANGED
|
@@ -218,7 +218,11 @@ async function storeCommandResult(
|
|
|
218
218
|
params: AuditedAdminCommandParams,
|
|
219
219
|
grant: AdminGrant,
|
|
220
220
|
): Promise<void> {
|
|
221
|
-
await storeAdminCommandResult(
|
|
221
|
+
await storeAdminCommandResult(
|
|
222
|
+
tx,
|
|
223
|
+
{...params, command: params.event.command},
|
|
224
|
+
{grant: toStoredAdminGrant(grant)},
|
|
225
|
+
);
|
|
222
226
|
}
|
|
223
227
|
|
|
224
228
|
export async function bootstrapFirstAdminOwner(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
1
2
|
import type {AdministrationActionEvent} from '@shipfox/api-common-dto';
|
|
2
3
|
import {and, eq, gt, isNull, sql} from 'drizzle-orm';
|
|
3
4
|
import {hasMinimumAdminRole, highestAdminRole} from '#core/admin-role-model.js';
|
|
@@ -84,7 +85,11 @@ async function storeCommandResult(
|
|
|
84
85
|
params: UserModerationCommandParams,
|
|
85
86
|
result: StoredAdminUserModerationResult,
|
|
86
87
|
): Promise<void> {
|
|
87
|
-
await storeAdminCommandResult(
|
|
88
|
+
await storeAdminCommandResult(
|
|
89
|
+
tx,
|
|
90
|
+
{...params, command: params.event.command},
|
|
91
|
+
{userModeration: result},
|
|
92
|
+
);
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
async function readTargetUserForUpdate(tx: Tx, userId: string) {
|
|
@@ -99,7 +104,7 @@ async function readTargetUserForUpdate(tx: Tx, userId: string) {
|
|
|
99
104
|
return user;
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
async function requireActiveAdminOperator(tx: Tx, actorId: string): Promise<
|
|
107
|
+
export async function requireActiveAdminOperator(tx: Tx, actorId: string): Promise<AdminRole> {
|
|
103
108
|
const actorRows = await tx
|
|
104
109
|
.select({status: users.status})
|
|
105
110
|
.from(users)
|
|
@@ -115,6 +120,7 @@ async function requireActiveAdminOperator(tx: Tx, actorId: string): Promise<void
|
|
|
115
120
|
if (actor?.status !== 'active' || !role || !hasMinimumAdminRole(role, 'admin-operator')) {
|
|
116
121
|
throw new AdminRoleRequiredError('admin-operator');
|
|
117
122
|
}
|
|
123
|
+
return role;
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
async function revokeActiveSessions(tx: Tx, userId: string): Promise<number> {
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {
|
|
3
|
+
ADMINISTRATION_ACTION_PERFORMED,
|
|
4
|
+
type AdministrationActionEvent,
|
|
5
|
+
type AdministrationActionResult,
|
|
6
|
+
createAdministrationActionEvent,
|
|
7
|
+
} from '@shipfox/api-common-dto';
|
|
8
|
+
import type {WorkspacesInterModuleClient} from '@shipfox/api-workspaces-dto/inter-module';
|
|
9
|
+
import {hashOpaqueToken} from '@shipfox/node-tokens';
|
|
10
|
+
import {and, eq, isNull, sql} from 'drizzle-orm';
|
|
11
|
+
import {highestAdminRole} from '#core/admin-role-model.js';
|
|
12
|
+
import {
|
|
13
|
+
type CreateImpersonatedSessionTokenResult,
|
|
14
|
+
createImpersonatedSessionToken,
|
|
15
|
+
} from '#core/auth.js';
|
|
16
|
+
import {
|
|
17
|
+
CannotImpersonateAdministratorError,
|
|
18
|
+
CannotImpersonateSelfError,
|
|
19
|
+
ImpersonationExpiredError,
|
|
20
|
+
ImpersonationTargetNotActiveError,
|
|
21
|
+
UserNotFoundError,
|
|
22
|
+
} from '#core/errors.js';
|
|
23
|
+
import {
|
|
24
|
+
findAdminCommandResult,
|
|
25
|
+
lockAdminCommand,
|
|
26
|
+
lockAdminOwnerGrants,
|
|
27
|
+
storeAdminCommandResult,
|
|
28
|
+
type Tx,
|
|
29
|
+
updateAdminCommandResult,
|
|
30
|
+
writeAdminAction,
|
|
31
|
+
} from './admin-command.js';
|
|
32
|
+
import {requireActiveAdminOperator} from './admin-user-moderation.js';
|
|
33
|
+
import {db} from './db.js';
|
|
34
|
+
import type {StoredImpersonationResult} from './schema/admin-command-results.js';
|
|
35
|
+
import {adminGrants} from './schema/admin-grants.js';
|
|
36
|
+
import {authOutbox} from './schema/outbox.js';
|
|
37
|
+
import {users} from './schema/users.js';
|
|
38
|
+
|
|
39
|
+
export const IMPERSONATE_COMMAND = 'auth.user.impersonate';
|
|
40
|
+
const IMPERSONATE_REQUIRED_ROLE: AdminRole = 'admin-operator';
|
|
41
|
+
|
|
42
|
+
export interface ImpersonationCommandParams {
|
|
43
|
+
actorId: string;
|
|
44
|
+
targetUserId: string;
|
|
45
|
+
reason: string;
|
|
46
|
+
idempotencyKeyFingerprint: string;
|
|
47
|
+
requestFingerprint: string;
|
|
48
|
+
correlationId: string;
|
|
49
|
+
workspaces: WorkspacesInterModuleClient;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ImpersonationResult {
|
|
53
|
+
token: string;
|
|
54
|
+
expiresAt: Date;
|
|
55
|
+
user: CreateImpersonatedSessionTokenResult['user'];
|
|
56
|
+
impersonatorId: string;
|
|
57
|
+
correlationId: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ImpersonationEventFields {
|
|
61
|
+
actorId: string;
|
|
62
|
+
targetUserId: string;
|
|
63
|
+
reason: string;
|
|
64
|
+
actorRole: AdminRole;
|
|
65
|
+
idempotencyKeyFingerprint: string;
|
|
66
|
+
correlationId: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function impersonationEvent(
|
|
70
|
+
params: ImpersonationEventFields,
|
|
71
|
+
result: AdministrationActionResult,
|
|
72
|
+
): AdministrationActionEvent {
|
|
73
|
+
return createAdministrationActionEvent({
|
|
74
|
+
actorId: params.actorId,
|
|
75
|
+
actorRole: params.actorRole,
|
|
76
|
+
requiredRole: IMPERSONATE_REQUIRED_ROLE,
|
|
77
|
+
command: IMPERSONATE_COMMAND,
|
|
78
|
+
targetType: 'user',
|
|
79
|
+
targetId: params.targetUserId,
|
|
80
|
+
reason: params.reason,
|
|
81
|
+
result,
|
|
82
|
+
correlationId: params.correlationId,
|
|
83
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
84
|
+
occurredAt: new Date().toISOString(),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isSameUuid(a: string, b: string): boolean {
|
|
89
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Rule 5: the target exists, is active, and has a verified email. */
|
|
93
|
+
async function requireEligibleImpersonationTarget(tx: Tx, targetUserId: string): Promise<void> {
|
|
94
|
+
const rows = await tx
|
|
95
|
+
.select({emailVerifiedAt: users.emailVerifiedAt, status: users.status})
|
|
96
|
+
.from(users)
|
|
97
|
+
.where(eq(users.id, targetUserId))
|
|
98
|
+
.limit(1);
|
|
99
|
+
const target = rows[0];
|
|
100
|
+
if (!target || target.status === 'deleted') throw new UserNotFoundError(targetUserId);
|
|
101
|
+
if (target.emailVerifiedAt === null) throw new ImpersonationTargetNotActiveError();
|
|
102
|
+
if (target.status !== 'active') throw new ImpersonationTargetNotActiveError();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Rule 6: the target holds no active administrator grant of any role. */
|
|
106
|
+
async function requireNonAdministratorTarget(tx: Tx, targetUserId: string): Promise<void> {
|
|
107
|
+
const grants = await tx
|
|
108
|
+
.select({role: adminGrants.role})
|
|
109
|
+
.from(adminGrants)
|
|
110
|
+
.innerJoin(users, eq(adminGrants.userId, users.id))
|
|
111
|
+
.where(
|
|
112
|
+
and(
|
|
113
|
+
eq(adminGrants.userId, targetUserId),
|
|
114
|
+
isNull(adminGrants.revokedAt),
|
|
115
|
+
eq(users.status, 'active'),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
if (highestAdminRole(grants.map(({role}) => role)) !== null) {
|
|
119
|
+
throw new CannotImpersonateAdministratorError();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The in-transaction authorization and eligibility ladder (rules 2, 4, 5, and
|
|
125
|
+
* 6). Rule 1 (`AUTH_IMPERSONATION_ENABLED`) is a configuration read checked at
|
|
126
|
+
* the command entry and inside the mint primitive, and rule 3 (the actor's own
|
|
127
|
+
* session is not impersonated) is the positional `/admin` route guard; all run
|
|
128
|
+
* on every invocation, replay included. Returns the actor's current role read
|
|
129
|
+
* in this transaction, so the audit event records the role that actually
|
|
130
|
+
* authorized the mint rather than a pre-transaction snapshot.
|
|
131
|
+
*/
|
|
132
|
+
async function runImpersonationLadder(
|
|
133
|
+
tx: Tx,
|
|
134
|
+
params: {actorId: string; targetUserId: string},
|
|
135
|
+
): Promise<AdminRole> {
|
|
136
|
+
const actorRole = await requireActiveAdminOperator(tx, params.actorId);
|
|
137
|
+
if (isSameUuid(params.targetUserId, params.actorId)) throw new CannotImpersonateSelfError();
|
|
138
|
+
await requireEligibleImpersonationTarget(tx, params.targetUserId);
|
|
139
|
+
await requireNonAdministratorTarget(tx, params.targetUserId);
|
|
140
|
+
return actorRole;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toImpersonationResult(
|
|
144
|
+
params: ImpersonationCommandParams,
|
|
145
|
+
minted: CreateImpersonatedSessionTokenResult,
|
|
146
|
+
expiresAt: Date,
|
|
147
|
+
): ImpersonationResult {
|
|
148
|
+
return {
|
|
149
|
+
token: minted.token,
|
|
150
|
+
expiresAt,
|
|
151
|
+
user: minted.user,
|
|
152
|
+
impersonatorId: params.actorId,
|
|
153
|
+
correlationId: params.correlationId,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* True when the current invocation's mint committed: a `succeeded` event
|
|
159
|
+
* carrying this invocation's `correlationId` is durable under the idempotency
|
|
160
|
+
* key. The event is written atomically with the result row, and the
|
|
161
|
+
* correlationId is unique per request, so a result row committed by an earlier
|
|
162
|
+
* mint or replay under the same key can never be mistaken for this
|
|
163
|
+
* invocation's commit. The command entry reconciles against this before
|
|
164
|
+
* publishing a `failed` event for an unexpected error: if the event exists,
|
|
165
|
+
* the mint transaction committed (the driver may still have raised on the
|
|
166
|
+
* COMMIT acknowledgement), so a failure event would contradict the durable
|
|
167
|
+
* `succeeded` trail.
|
|
168
|
+
*/
|
|
169
|
+
export async function impersonationSucceededEventExists(params: {
|
|
170
|
+
actorId: string;
|
|
171
|
+
idempotencyKeyFingerprint: string;
|
|
172
|
+
correlationId: string;
|
|
173
|
+
}): Promise<boolean> {
|
|
174
|
+
const rows = await db()
|
|
175
|
+
.select({id: authOutbox.id})
|
|
176
|
+
.from(authOutbox)
|
|
177
|
+
.where(
|
|
178
|
+
and(
|
|
179
|
+
eq(authOutbox.eventType, ADMINISTRATION_ACTION_PERFORMED),
|
|
180
|
+
sql`${authOutbox.payload}->>'command' = ${IMPERSONATE_COMMAND}`,
|
|
181
|
+
sql`${authOutbox.payload}->>'actorId' = ${params.actorId}`,
|
|
182
|
+
sql`${authOutbox.payload}->>'idempotencyKeyFingerprint' = ${params.idempotencyKeyFingerprint}`,
|
|
183
|
+
sql`${authOutbox.payload}->>'correlationId' = ${params.correlationId}`,
|
|
184
|
+
sql`${authOutbox.payload}->>'result' = 'succeeded'`,
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
.limit(1);
|
|
188
|
+
return rows.length > 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function impersonateUserWithAudit(
|
|
192
|
+
params: ImpersonationCommandParams,
|
|
193
|
+
): Promise<ImpersonationResult> {
|
|
194
|
+
return await db().transaction(async (tx) => {
|
|
195
|
+
await lockAdminCommand(tx, params);
|
|
196
|
+
// Every audited administrator grant mutation (bootstrap, grant, revoke,
|
|
197
|
+
// and suspension) takes this advisory lock, so serializing the ladder and
|
|
198
|
+
// the mint against it closes the race where a concurrent grant mutation
|
|
199
|
+
// changes actor or target eligibility after the ladder read but before
|
|
200
|
+
// the token is signed.
|
|
201
|
+
await lockAdminOwnerGrants(tx);
|
|
202
|
+
|
|
203
|
+
const existing = await findAdminCommandResult(tx, {
|
|
204
|
+
actorId: params.actorId,
|
|
205
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
206
|
+
requestFingerprint: params.requestFingerprint,
|
|
207
|
+
command: IMPERSONATE_COMMAND,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (existing) {
|
|
211
|
+
if (!('impersonation' in existing.result)) {
|
|
212
|
+
throw new Error('Administrator command result has an unexpected shape');
|
|
213
|
+
}
|
|
214
|
+
const stored = existing.result.impersonation as StoredImpersonationResult;
|
|
215
|
+
|
|
216
|
+
// A replay hands back a usable bearer token, so it is an issuance, not a
|
|
217
|
+
// read: it re-runs the full ladder and re-signs instead of returning the
|
|
218
|
+
// stored result, and a replay after expiry is a terminal failure.
|
|
219
|
+
const storedExpiresAt = new Date(stored.expiresAt);
|
|
220
|
+
if (storedExpiresAt.getTime() <= Date.now()) throw new ImpersonationExpiredError();
|
|
221
|
+
const actorRole = await runImpersonationLadder(tx, {
|
|
222
|
+
actorId: params.actorId,
|
|
223
|
+
targetUserId: stored.targetUserId,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// A replay never extends the window: the re-signed token's TTL is the
|
|
227
|
+
// remaining time to the canonical `expiresAt`, so a sub-second remainder
|
|
228
|
+
// is treated as already-expired instead of flooring up to a new token.
|
|
229
|
+
const remainingSeconds = Math.floor((storedExpiresAt.getTime() - Date.now()) / 1000);
|
|
230
|
+
if (remainingSeconds <= 0) throw new ImpersonationExpiredError();
|
|
231
|
+
const minted = await createImpersonatedSessionToken({
|
|
232
|
+
targetUserId: stored.targetUserId,
|
|
233
|
+
impersonatorId: params.actorId,
|
|
234
|
+
workspaces: params.workspaces,
|
|
235
|
+
expiresIn: `${remainingSeconds}s`,
|
|
236
|
+
});
|
|
237
|
+
const fingerprint = hashOpaqueToken(minted.token);
|
|
238
|
+
await updateAdminCommandResult(
|
|
239
|
+
tx,
|
|
240
|
+
{
|
|
241
|
+
actorId: params.actorId,
|
|
242
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
243
|
+
requestFingerprint: params.requestFingerprint,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
impersonation: {
|
|
247
|
+
...stored,
|
|
248
|
+
tokenFingerprints: [...stored.tokenFingerprints, fingerprint],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
// Replays publish their own event with the same idempotency-key
|
|
253
|
+
// fingerprint: the second event under one fingerprint is the replay
|
|
254
|
+
// marker. It commits atomically with the updated command result.
|
|
255
|
+
await writeAdminAction(tx, impersonationEvent({...params, actorRole}, 'succeeded'));
|
|
256
|
+
return toImpersonationResult(params, minted, storedExpiresAt);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const actorRole = await runImpersonationLadder(tx, {
|
|
260
|
+
actorId: params.actorId,
|
|
261
|
+
targetUserId: params.targetUserId,
|
|
262
|
+
});
|
|
263
|
+
const minted = await createImpersonatedSessionToken({
|
|
264
|
+
targetUserId: params.targetUserId,
|
|
265
|
+
impersonatorId: params.actorId,
|
|
266
|
+
workspaces: params.workspaces,
|
|
267
|
+
});
|
|
268
|
+
const stored: StoredImpersonationResult = {
|
|
269
|
+
targetUserId: params.targetUserId,
|
|
270
|
+
expiresAt: minted.expiresAt.toISOString(),
|
|
271
|
+
tokenFingerprints: [hashOpaqueToken(minted.token)],
|
|
272
|
+
};
|
|
273
|
+
await storeAdminCommandResult(
|
|
274
|
+
tx,
|
|
275
|
+
{
|
|
276
|
+
actorId: params.actorId,
|
|
277
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
278
|
+
requestFingerprint: params.requestFingerprint,
|
|
279
|
+
command: IMPERSONATE_COMMAND,
|
|
280
|
+
},
|
|
281
|
+
{impersonation: stored},
|
|
282
|
+
);
|
|
283
|
+
await writeAdminAction(tx, impersonationEvent({...params, actorRole}, 'succeeded'));
|
|
284
|
+
return toImpersonationResult(params, minted, minted.expiresAt);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Publishes a `failed` administration event from its own committed
|
|
290
|
+
* transaction. The main command transaction rolls back on failure and the role
|
|
291
|
+
* check runs before it opens, so an event written inside it would disappear;
|
|
292
|
+
* without this a denied attempt leaves no trace on the one route where failed
|
|
293
|
+
* attempts matter most. The strict event schema requires an actor role, so a
|
|
294
|
+
* role-less actor's denial (nothing but the role gate itself) is not recorded.
|
|
295
|
+
*/
|
|
296
|
+
export async function publishImpersonationFailure(params: {
|
|
297
|
+
actorId: string;
|
|
298
|
+
targetUserId: string;
|
|
299
|
+
reason: string;
|
|
300
|
+
actorRole: AdminRole | null;
|
|
301
|
+
idempotencyKeyFingerprint: string;
|
|
302
|
+
correlationId: string;
|
|
303
|
+
}): Promise<void> {
|
|
304
|
+
if (!params.actorRole) return;
|
|
305
|
+
const event = impersonationEvent(
|
|
306
|
+
{
|
|
307
|
+
actorId: params.actorId,
|
|
308
|
+
targetUserId: params.targetUserId,
|
|
309
|
+
reason: params.reason,
|
|
310
|
+
actorRole: params.actorRole,
|
|
311
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
312
|
+
correlationId: params.correlationId,
|
|
313
|
+
},
|
|
314
|
+
'failed',
|
|
315
|
+
);
|
|
316
|
+
try {
|
|
317
|
+
await db().transaction(async (tx) => {
|
|
318
|
+
await writeAdminAction(tx, event);
|
|
319
|
+
});
|
|
320
|
+
} catch {
|
|
321
|
+
// The failure event must never mask the original command error.
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -30,12 +30,30 @@ export interface StoredAdminUserModerationResult {
|
|
|
30
30
|
sessionsRevoked: number;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The stored impersonation command result: fingerprint-only, never a bearer
|
|
35
|
+
* token or a claims snapshot. Each entry is the SHA-256 of one token issued
|
|
36
|
+
* under the idempotency key, so a token recovered from a log or proxy capture
|
|
37
|
+
* can be matched back to the command, its actor, and its reason. A replay
|
|
38
|
+
* issues a token with different signature bytes and appends its fingerprint.
|
|
39
|
+
* `expiresAt` is canonical for replays: they re-sign with the original
|
|
40
|
+
* expiry and never extend the window.
|
|
41
|
+
*/
|
|
42
|
+
export interface StoredImpersonationResult {
|
|
43
|
+
targetUserId: string;
|
|
44
|
+
expiresAt: string;
|
|
45
|
+
tokenFingerprints: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
export type StoredAdminCommandResult =
|
|
34
49
|
| {
|
|
35
50
|
grant: StoredAdminGrant;
|
|
36
51
|
}
|
|
37
52
|
| {
|
|
38
53
|
userModeration: StoredAdminUserModerationResult;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
impersonation: StoredImpersonationResult;
|
|
39
57
|
};
|
|
40
58
|
|
|
41
59
|
export const adminCommandResults = pgTable(
|
package/src/index.test.ts
CHANGED
|
@@ -78,7 +78,7 @@ describe('authModule', () => {
|
|
|
78
78
|
getWorkspaceOperatingState: vi.fn(),
|
|
79
79
|
},
|
|
80
80
|
});
|
|
81
|
-
expect(module.routes).toHaveLength(
|
|
81
|
+
expect(module.routes).toHaveLength(5);
|
|
82
82
|
expect(module.routes).toEqual(
|
|
83
83
|
expect.arrayContaining([expect.objectContaining({prefix: '/admin/auth'})]),
|
|
84
84
|
);
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {createAuthInterModulePresentation} from '#presentation/inter-module.js';
|
|
|
20
20
|
import {
|
|
21
21
|
administrationBootstrapRoutes,
|
|
22
22
|
administrationRoutes,
|
|
23
|
-
|
|
23
|
+
createAdministrationUserRoutes,
|
|
24
24
|
} from '#presentation/routes/administration.js';
|
|
25
25
|
import {buildAuthRoutes} from '#presentation/routes/index.js';
|
|
26
26
|
import {onPasswordResetSendRequested} from '#presentation/subscribers/index.js';
|
|
@@ -40,18 +40,21 @@ export {
|
|
|
40
40
|
export {
|
|
41
41
|
bootstrapFirstAdminOwner,
|
|
42
42
|
grantAdministratorRole,
|
|
43
|
+
impersonateUser,
|
|
43
44
|
reactivateAdministratorUser,
|
|
44
45
|
revokeAdministratorGrant,
|
|
45
46
|
revokeAdministratorUserSessions,
|
|
46
47
|
suspendAdministratorUser,
|
|
47
48
|
} from '#core/administration.js';
|
|
48
49
|
export type {
|
|
50
|
+
CreateImpersonatedSessionTokenParams,
|
|
51
|
+
CreateImpersonatedSessionTokenResult,
|
|
49
52
|
CreateSessionForUserError,
|
|
50
53
|
CreateSessionForUserParams,
|
|
51
54
|
CreateSessionForUserResult,
|
|
52
55
|
ProvisionUserParams,
|
|
53
56
|
} from '#core/auth.js';
|
|
54
|
-
export {createSessionForUser, provisionUser} from '#core/auth.js';
|
|
57
|
+
export {createImpersonatedSessionToken, createSessionForUser, provisionUser} from '#core/auth.js';
|
|
55
58
|
export type {EmailOwner, FindUserByEmailParams} from '#core/email-owner.js';
|
|
56
59
|
export {findUserByEmail} from '#core/email-owner.js';
|
|
57
60
|
export type {AdminGrant} from '#core/entities/admin-grant.js';
|
|
@@ -63,7 +66,12 @@ export {
|
|
|
63
66
|
AdminIdempotencyKeyReuseError,
|
|
64
67
|
AdminRoleRequiredError,
|
|
65
68
|
AuthDependencyUnavailableError,
|
|
69
|
+
CannotImpersonateAdministratorError,
|
|
70
|
+
CannotImpersonateSelfError,
|
|
66
71
|
EmailNotVerifiedError,
|
|
72
|
+
ImpersonationDisabledError,
|
|
73
|
+
ImpersonationExpiredError,
|
|
74
|
+
ImpersonationTargetNotActiveError,
|
|
67
75
|
InvalidAdminBootstrapTokenError,
|
|
68
76
|
InvalidCredentialsError,
|
|
69
77
|
LastAdminOwnerError,
|
|
@@ -84,6 +92,7 @@ export {
|
|
|
84
92
|
createEnvironmentSignupPolicy,
|
|
85
93
|
DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE,
|
|
86
94
|
} from '#core/signup-policy.js';
|
|
95
|
+
export type {ImpersonationResult} from '#db/impersonation.js';
|
|
87
96
|
export {
|
|
88
97
|
type AuthenticatedSessionContext,
|
|
89
98
|
createJwtAuthMethod,
|
|
@@ -120,7 +129,7 @@ export function createAuthModule({
|
|
|
120
129
|
buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces, signupPolicy),
|
|
121
130
|
administrationBootstrapRoutes,
|
|
122
131
|
administrationRoutes,
|
|
123
|
-
|
|
132
|
+
...createAdministrationUserRoutes(workspaces),
|
|
124
133
|
],
|
|
125
134
|
e2eRoutes: [createAuthE2eRoutes(workspaces)],
|
|
126
135
|
publishers: [{name: 'auth', table: authOutbox, db, eventSchemas: authPublisherEventSchemas}],
|
package/src/metrics/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export {
|
|
2
|
+
type AuthImpersonationOutcome,
|
|
2
3
|
type AuthRateLimitAction,
|
|
3
4
|
type AuthRateLimitOutcome,
|
|
4
5
|
type AuthRateLimitScope,
|
|
@@ -7,6 +8,7 @@ export {
|
|
|
7
8
|
type AuthTokenVerificationOutcome,
|
|
8
9
|
recordAuthRateLimitCheck,
|
|
9
10
|
recordAuthRateLimitPruneFailure,
|
|
11
|
+
recordImpersonationOutcome,
|
|
10
12
|
recordTokenIssued,
|
|
11
13
|
recordTokenRefreshed,
|
|
12
14
|
recordTokenVerified,
|