@shipfox/api-auth-context 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/src/index.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import type {JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';
2
2
  import type {WorkspaceRole} from '@shipfox/api-workspaces-dto';
3
- import {ClientError} from '@shipfox/node-fastify';
3
+ import {
4
+ ClientError,
5
+ isRouteGroup,
6
+ type RouteExport,
7
+ type RoutePreHandler,
8
+ } from '@shipfox/node-fastify';
4
9
 
5
10
  export const AUTH_USER = 'user';
6
11
  export const AUTH_RUNNER_REGISTRATION_TOKEN = 'runner-registration-token';
@@ -22,6 +27,7 @@ export interface UserContext {
22
27
  email: string;
23
28
  name: string | null;
24
29
  memberships: ReadonlyArray<UserContextMembership>;
30
+ impersonatorId?: string | undefined;
25
31
  canAccess(workspaceId: string): boolean;
26
32
  hasRole(workspaceId: string, role: WorkspaceRole): boolean;
27
33
  }
@@ -31,6 +37,7 @@ export interface BuildUserContextParams {
31
37
  email: string;
32
38
  name?: string | null | undefined;
33
39
  memberships?: ReadonlyArray<UserContextMembership> | undefined;
40
+ impersonatorId?: string | undefined;
34
41
  }
35
42
 
36
43
  export function buildUserContext(params: BuildUserContextParams): UserContext {
@@ -40,6 +47,7 @@ export function buildUserContext(params: BuildUserContextParams): UserContext {
40
47
  email: params.email,
41
48
  name: params.name ?? null,
42
49
  memberships,
50
+ impersonatorId: params.impersonatorId,
43
51
  canAccess: (workspaceId) =>
44
52
  memberships.some((m) => m.workspaceId === workspaceId && m.workspaceStatus === 'active'),
45
53
  hasRole: (workspaceId, role) =>
@@ -89,6 +97,24 @@ export function requireUserContext(request: RequestWithContext): UserContext {
89
97
  return context;
90
98
  }
91
99
 
100
+ /**
101
+ * Rejects a request whose user context carries an impersonator (the
102
+ * durable-artefact deny-list). Routes that issue a credential or create a
103
+ * durable grant call this so an impersonated session cannot leave anything
104
+ * behind that outlives its bounded token window. Requires a user context:
105
+ * callers must run it on routes whose auth method has set one.
106
+ */
107
+ export function rejectImpersonatedSession(request: RequestWithContext): void {
108
+ const context = requireUserContext(request);
109
+ if (context.impersonatorId) {
110
+ throw new ClientError(
111
+ 'Impersonated sessions cannot issue credentials or create durable grants',
112
+ 'impersonation-not-permitted',
113
+ {status: 403},
114
+ );
115
+ }
116
+ }
117
+
92
118
  export interface RequireWorkspaceAccessParams {
93
119
  request: RequestWithContext;
94
120
  workspaceId: string;
@@ -128,6 +154,92 @@ export function requireWorkspaceAccess(
128
154
  return {workspaceId: params.workspaceId, userId: context.userId, role: membership.role};
129
155
  }
130
156
 
157
+ /**
158
+ * Rejects an impersonated session before any administrator authority is
159
+ * consulted. Every route under an `/admin` prefix runs this guard: a request
160
+ * whose context carries `impersonatorId` receives the same `admin-role-required`
161
+ * failure a role-less actor would, before roles are read, so a target that
162
+ * gains a grant inside the token window still cannot act on it. The check is
163
+ * request-scoped on purpose: the inter-module `requireAdminRole` contract only
164
+ * carries `{userId, minimumRole}` and cannot transport the mark.
165
+ */
166
+ export function requireAdministrationActor(request: RequestWithContext): void {
167
+ const context = getUserContext(request);
168
+ if (context?.impersonatorId != null) {
169
+ throw new ClientError('Administrator role required', 'admin-role-required', {status: 403});
170
+ }
171
+ }
172
+
173
+ const ADMINISTRATION_PREFIX = '/admin';
174
+ const TRAILING_SLASHES = /\/+$/;
175
+ const LEADING_SLASHES = /^\/+/;
176
+
177
+ function isAdministrationPrefix(prefix: string): boolean {
178
+ return prefix === ADMINISTRATION_PREFIX || prefix.startsWith(`${ADMINISTRATION_PREFIX}/`);
179
+ }
180
+
181
+ /**
182
+ * Joins a parent prefix and a child prefix or route path the way Fastify
183
+ * resolves the mounted URL: surrounding slashes are trimmed and segments are
184
+ * joined with a single `/`, and a leading slash is added when the joined path
185
+ * is non-empty. Raw concatenation would diverge from Fastify's resolution for
186
+ * slash-less prefixes (`/admin` + `things` -> `/adminthings` while Fastify
187
+ * mounts `/admin/things`) and let a route escape the guard while still
188
+ * mounting under `/admin`.
189
+ */
190
+ function joinRoutePath(parent: string, child: string): string {
191
+ if (parent === '') {
192
+ return child.startsWith('/') ? child : `/${child}`;
193
+ }
194
+ if (child === '') {
195
+ return parent;
196
+ }
197
+ return `${parent.replace(TRAILING_SLASHES, '')}/${child.replace(LEADING_SLASHES, '')}`;
198
+ }
199
+
200
+ function adoptAdministrationGuardIn(route: RouteExport, parentPrefix: string): RouteExport {
201
+ if (isRouteGroup(route)) {
202
+ return {
203
+ ...route,
204
+ routes: route.routes.map((child) =>
205
+ adoptAdministrationGuardIn(child, joinRoutePath(parentPrefix, route.prefix)),
206
+ ),
207
+ };
208
+ }
209
+ const effectivePath = joinRoutePath(parentPrefix, route.path);
210
+ if (!isAdministrationPrefix(parentPrefix) && !isAdministrationPrefix(effectivePath)) {
211
+ return route;
212
+ }
213
+ const preHandler: RoutePreHandler[] = [
214
+ (request) => {
215
+ requireAdministrationActor(request);
216
+ return undefined;
217
+ },
218
+ ...(route.preHandler === undefined
219
+ ? []
220
+ : Array.isArray(route.preHandler)
221
+ ? route.preHandler
222
+ : [route.preHandler]),
223
+ ];
224
+ return {...route, preHandler};
225
+ }
226
+
227
+ /**
228
+ * Positionally adopts the impersonated-session rejection for every route under
229
+ * an `/admin` prefix in the given route tree: the rule is "every `/admin`
230
+ * route", so an administration surface added later under the prefix inherits
231
+ * the guard without anyone remembering to attach it. The guard runs before any
232
+ * existing preHandler, so roles are never consulted for an impersonated
233
+ * context — including on role-check-free routes such as the first-owner
234
+ * bootstrap.
235
+ */
236
+ export function adoptAdministrationActorGuard<T extends RouteExport | RouteExport[]>(routes: T): T {
237
+ if (Array.isArray(routes)) {
238
+ return routes.map((route) => adoptAdministrationGuardIn(route, '')) as T;
239
+ }
240
+ return adoptAdministrationGuardIn(routes, '') as T;
241
+ }
242
+
131
243
  /**
132
244
  * Applies the workspace lifecycle gate to a resource that has already been loaded. Missing
133
245
  * membership remains resource-shaped 404 to avoid leaking the resource's existence, while
@@ -0,0 +1,42 @@
1
+ import {ClientError} from '@shipfox/node-fastify';
2
+ import {buildUserContext, rejectImpersonatedSession, setUserContext} from './index.js';
3
+
4
+ describe('rejectImpersonatedSession', () => {
5
+ test('does nothing for an ordinary session', () => {
6
+ const request = {};
7
+ setUserContext(
8
+ request,
9
+ buildUserContext({
10
+ userId: crypto.randomUUID(),
11
+ email: 'user@example.com',
12
+ }),
13
+ );
14
+
15
+ expect(() => rejectImpersonatedSession(request)).not.toThrow();
16
+ });
17
+
18
+ test('throws when no user context is set', () => {
19
+ expect(() => rejectImpersonatedSession({})).toThrow(
20
+ 'User context is not available on this request',
21
+ );
22
+ });
23
+
24
+ test('throws impersonation-not-permitted for an impersonated session', () => {
25
+ const request = {};
26
+ setUserContext(
27
+ request,
28
+ buildUserContext({
29
+ userId: crypto.randomUUID(),
30
+ email: 'user@example.com',
31
+ impersonatorId: crypto.randomUUID(),
32
+ }),
33
+ );
34
+
35
+ const act = () => rejectImpersonatedSession(request);
36
+
37
+ expect(act).toThrow(ClientError);
38
+ expect(act).toThrow(
39
+ expect.objectContaining({code: 'impersonation-not-permitted', status: 403}),
40
+ );
41
+ });
42
+ });