@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.
Files changed (106) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +30 -0
  3. package/README.md +41 -0
  4. package/dist/config.d.ts +1 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +4 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/administration.d.ts +24 -2
  9. package/dist/core/administration.d.ts.map +1 -1
  10. package/dist/core/administration.js +126 -3
  11. package/dist/core/administration.js.map +1 -1
  12. package/dist/core/auth.d.ts +31 -0
  13. package/dist/core/auth.d.ts.map +1 -1
  14. package/dist/core/auth.js +70 -2
  15. package/dist/core/auth.js.map +1 -1
  16. package/dist/core/errors.d.ts +15 -0
  17. package/dist/core/errors.d.ts.map +1 -1
  18. package/dist/core/errors.js +30 -0
  19. package/dist/core/errors.js.map +1 -1
  20. package/dist/core/jwt.d.ts +2 -0
  21. package/dist/core/jwt.d.ts.map +1 -1
  22. package/dist/core/jwt.js +22 -1
  23. package/dist/core/jwt.js.map +1 -1
  24. package/dist/core/runner-session-token.d.ts.map +1 -1
  25. package/dist/core/runner-session-token.js +2 -1
  26. package/dist/core/runner-session-token.js.map +1 -1
  27. package/dist/db/admin-command.d.ts +12 -4
  28. package/dist/db/admin-command.d.ts.map +1 -1
  29. package/dist/db/admin-command.js +10 -1
  30. package/dist/db/admin-command.js.map +1 -1
  31. package/dist/db/admin-grants.d.ts.map +1 -1
  32. package/dist/db/admin-grants.js +4 -1
  33. package/dist/db/admin-grants.js.map +1 -1
  34. package/dist/db/admin-user-moderation.d.ts +3 -0
  35. package/dist/db/admin-user-moderation.d.ts.map +1 -1
  36. package/dist/db/admin-user-moderation.js +6 -2
  37. package/dist/db/admin-user-moderation.js.map +1 -1
  38. package/dist/db/impersonation.d.ts +55 -0
  39. package/dist/db/impersonation.d.ts.map +1 -0
  40. package/dist/db/impersonation.js +212 -0
  41. package/dist/db/impersonation.js.map +1 -0
  42. package/dist/db/schema/admin-command-results.d.ts +16 -0
  43. package/dist/db/schema/admin-command-results.d.ts.map +1 -1
  44. package/dist/db/schema/admin-command-results.js.map +1 -1
  45. package/dist/index.d.ts +5 -4
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +5 -5
  48. package/dist/index.js.map +1 -1
  49. package/dist/metrics/index.d.ts +1 -1
  50. package/dist/metrics/index.d.ts.map +1 -1
  51. package/dist/metrics/index.js +1 -1
  52. package/dist/metrics/index.js.map +1 -1
  53. package/dist/metrics/instance.d.ts +4 -2
  54. package/dist/metrics/instance.d.ts.map +1 -1
  55. package/dist/metrics/instance.js +8 -0
  56. package/dist/metrics/instance.js.map +1 -1
  57. package/dist/presentation/auth/bearer-token-auth.d.ts +2 -2
  58. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
  59. package/dist/presentation/auth/bearer-token-auth.js +2 -2
  60. package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
  61. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  62. package/dist/presentation/auth/jwt-auth.js +26 -2
  63. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  64. package/dist/presentation/routes/administration.d.ts +2 -1
  65. package/dist/presentation/routes/administration.d.ts.map +1 -1
  66. package/dist/presentation/routes/administration.js +127 -19
  67. package/dist/presentation/routes/administration.js.map +1 -1
  68. package/dist/presentation/routes/rate-limit.d.ts +8 -0
  69. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  70. package/dist/presentation/routes/rate-limit.js +44 -0
  71. package/dist/presentation/routes/rate-limit.js.map +1 -1
  72. package/dist/presentation/routes/session/me.d.ts.map +1 -1
  73. package/dist/presentation/routes/session/me.js +2 -1
  74. package/dist/presentation/routes/session/me.js.map +1 -1
  75. package/dist/tsconfig.test.tsbuildinfo +1 -1
  76. package/package.json +6 -6
  77. package/src/config.ts +4 -0
  78. package/src/core/administration.ts +172 -1
  79. package/src/core/auth.test.ts +79 -0
  80. package/src/core/auth.ts +98 -1
  81. package/src/core/errors.ts +35 -0
  82. package/src/core/jwt.test.ts +92 -0
  83. package/src/core/jwt.ts +38 -9
  84. package/src/core/runner-session-token.test.ts +2 -0
  85. package/src/core/runner-session-token.ts +1 -0
  86. package/src/db/admin-command.ts +35 -6
  87. package/src/db/admin-grants.ts +5 -1
  88. package/src/db/admin-user-moderation.ts +8 -2
  89. package/src/db/impersonation.ts +323 -0
  90. package/src/db/schema/admin-command-results.ts +18 -0
  91. package/src/index.test.ts +1 -1
  92. package/src/index.ts +12 -3
  93. package/src/metrics/index.ts +2 -0
  94. package/src/metrics/instance.ts +13 -2
  95. package/src/presentation/auth/bearer-token-auth.ts +4 -4
  96. package/src/presentation/auth/jwt-auth.test.ts +191 -0
  97. package/src/presentation/auth/jwt-auth.ts +19 -1
  98. package/src/presentation/routes/administration.test.ts +828 -3
  99. package/src/presentation/routes/administration.ts +130 -11
  100. package/src/presentation/routes/rate-limit.ts +40 -0
  101. package/src/presentation/routes/session/login.test.ts +5 -0
  102. package/src/presentation/routes/session/me.test.ts +24 -0
  103. package/src/presentation/routes/session/me.ts +1 -0
  104. package/src/presentation/routes/session/refresh.test.ts +11 -0
  105. package/test/routes.ts +43 -16
  106. 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
- export const userTokenClaimsSchema = z.object({
15
- sub: z.string().uuid(),
16
- refreshSessionId: z.string().uuid().optional(),
17
- email: z.string().email(),
18
- name: z.string().nullable().optional(),
19
- memberships: z.array(tokenMembershipSchema),
20
- iat: z.number().int(),
21
- exp: z.number().int(),
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,
@@ -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: Pick<
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: AdminCommandTransactionParams,
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.event.command,
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',
@@ -218,7 +218,11 @@ async function storeCommandResult(
218
218
  params: AuditedAdminCommandParams,
219
219
  grant: AdminGrant,
220
220
  ): Promise<void> {
221
- await storeAdminCommandResult(tx, params, {grant: toStoredAdminGrant(grant)});
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(tx, params, {userModeration: result});
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<void> {
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(4);
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
- administrationUserRoutes,
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
- administrationUserRoutes,
132
+ ...createAdministrationUserRoutes(workspaces),
124
133
  ],
125
134
  e2eRoutes: [createAuthE2eRoutes(workspaces)],
126
135
  publishers: [{name: 'auth', table: authOutbox, db, eventSchemas: authPublisherEventSchemas}],
@@ -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,