@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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +20 -0
- package/dist/index.d.ts +31 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +94 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/administration-guard.test.ts +201 -0
- package/src/index.ts +113 -1
- package/src/reject-impersonated-session.test.ts +42 -0
- package/tsconfig.build.tsbuildinfo +1 -1
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 {
|
|
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
|
+
});
|