@shipfox/api-auth-context 15.0.0 → 17.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 +13 -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/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
$ shipfox-swc
|
|
2
|
-
Successfully compiled: 1 file with swc (
|
|
2
|
+
Successfully compiled: 1 file with swc (275.02ms)
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @shipfox/api-auth-context
|
|
2
2
|
|
|
3
|
+
## 17.0.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a4f56ff: Adds the `requireAdministrationActor` guard to `@shipfox/api-auth-context`. The guard rejects impersonated sessions (`UserContext` carrying `impersonatorId`) with the `admin-role-required` failure on every `/admin` route in the auth, projects, workspaces, and runners modules. It protects the first-owner bootstrap route before the system checks roles.
|
|
8
|
+
- a591e8a: Adds `rejectImpersonatedSession` to `@shipfox/api-auth-context`, which throws `impersonation-not-permitted` when the request's user context carries an `impersonatorId`. The runner manual-registration-token, provisioner-token, and workspace invitation routes (create and accept) now reject impersonated sessions, so an impersonated session cannot use these routes to leave behind a credential or durable grant that outlives its bounded token window.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- Updated dependencies [5ae8b3d]
|
|
13
|
+
- Updated dependencies [918d84a]
|
|
14
|
+
- @shipfox/api-auth-dto@17.0.0
|
|
15
|
+
|
|
3
16
|
## 15.0.0
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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 { ClientError, type RouteExport } from '@shipfox/node-fastify';
|
|
4
4
|
export declare const AUTH_USER = "user";
|
|
5
5
|
export declare const AUTH_RUNNER_REGISTRATION_TOKEN = "runner-registration-token";
|
|
6
6
|
export declare const AUTH_RUNNER_SESSION = "runner-session";
|
|
@@ -18,6 +18,7 @@ export interface UserContext {
|
|
|
18
18
|
email: string;
|
|
19
19
|
name: string | null;
|
|
20
20
|
memberships: ReadonlyArray<UserContextMembership>;
|
|
21
|
+
impersonatorId?: string | undefined;
|
|
21
22
|
canAccess(workspaceId: string): boolean;
|
|
22
23
|
hasRole(workspaceId: string, role: WorkspaceRole): boolean;
|
|
23
24
|
}
|
|
@@ -26,6 +27,7 @@ export interface BuildUserContextParams {
|
|
|
26
27
|
email: string;
|
|
27
28
|
name?: string | null | undefined;
|
|
28
29
|
memberships?: ReadonlyArray<UserContextMembership> | undefined;
|
|
30
|
+
impersonatorId?: string | undefined;
|
|
29
31
|
}
|
|
30
32
|
export declare function buildUserContext(params: BuildUserContextParams): UserContext;
|
|
31
33
|
export type ProvisionerContext = {
|
|
@@ -47,6 +49,14 @@ type RequestWithContext = object;
|
|
|
47
49
|
export declare function setUserContext(request: RequestWithContext, context: UserContext): void;
|
|
48
50
|
export declare function getUserContext(request: RequestWithContext): UserContext | null;
|
|
49
51
|
export declare function requireUserContext(request: RequestWithContext): UserContext;
|
|
52
|
+
/**
|
|
53
|
+
* Rejects a request whose user context carries an impersonator (the
|
|
54
|
+
* durable-artefact deny-list). Routes that issue a credential or create a
|
|
55
|
+
* durable grant call this so an impersonated session cannot leave anything
|
|
56
|
+
* behind that outlives its bounded token window. Requires a user context:
|
|
57
|
+
* callers must run it on routes whose auth method has set one.
|
|
58
|
+
*/
|
|
59
|
+
export declare function rejectImpersonatedSession(request: RequestWithContext): void;
|
|
50
60
|
export interface RequireWorkspaceAccessParams {
|
|
51
61
|
request: RequestWithContext;
|
|
52
62
|
workspaceId: string;
|
|
@@ -62,6 +72,26 @@ export interface RequireWorkspaceAccessResult {
|
|
|
62
72
|
* stateless and does not read the database on the request path.
|
|
63
73
|
*/
|
|
64
74
|
export declare function requireWorkspaceAccess(params: RequireWorkspaceAccessParams): RequireWorkspaceAccessResult;
|
|
75
|
+
/**
|
|
76
|
+
* Rejects an impersonated session before any administrator authority is
|
|
77
|
+
* consulted. Every route under an `/admin` prefix runs this guard: a request
|
|
78
|
+
* whose context carries `impersonatorId` receives the same `admin-role-required`
|
|
79
|
+
* failure a role-less actor would, before roles are read, so a target that
|
|
80
|
+
* gains a grant inside the token window still cannot act on it. The check is
|
|
81
|
+
* request-scoped on purpose: the inter-module `requireAdminRole` contract only
|
|
82
|
+
* carries `{userId, minimumRole}` and cannot transport the mark.
|
|
83
|
+
*/
|
|
84
|
+
export declare function requireAdministrationActor(request: RequestWithContext): void;
|
|
85
|
+
/**
|
|
86
|
+
* Positionally adopts the impersonated-session rejection for every route under
|
|
87
|
+
* an `/admin` prefix in the given route tree: the rule is "every `/admin`
|
|
88
|
+
* route", so an administration surface added later under the prefix inherits
|
|
89
|
+
* the guard without anyone remembering to attach it. The guard runs before any
|
|
90
|
+
* existing preHandler, so roles are never consulted for an impersonated
|
|
91
|
+
* context — including on role-check-free routes such as the first-owner
|
|
92
|
+
* bootstrap.
|
|
93
|
+
*/
|
|
94
|
+
export declare function adoptAdministrationActorGuard<T extends RouteExport | RouteExport[]>(routes: T): T;
|
|
65
95
|
/**
|
|
66
96
|
* Applies the workspace lifecycle gate to a resource that has already been loaded. Missing
|
|
67
97
|
* membership remains resource-shaped 404 to avoid leaking the resource's existence, while
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,mBAAmB,EAAE,wBAAwB,EAAC,MAAM,uBAAuB,CAAC;AACzF,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,6BAA6B,CAAC;AAC/D,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ClientError } from '@shipfox/node-fastify';
|
|
1
|
+
import { ClientError, isRouteGroup } from '@shipfox/node-fastify';
|
|
2
2
|
export const AUTH_USER = 'user';
|
|
3
3
|
export const AUTH_RUNNER_REGISTRATION_TOKEN = 'runner-registration-token';
|
|
4
4
|
export const AUTH_RUNNER_SESSION = 'runner-session';
|
|
@@ -12,6 +12,7 @@ export function buildUserContext(params) {
|
|
|
12
12
|
email: params.email,
|
|
13
13
|
name: params.name ?? null,
|
|
14
14
|
memberships,
|
|
15
|
+
impersonatorId: params.impersonatorId,
|
|
15
16
|
canAccess: (workspaceId)=>memberships.some((m)=>m.workspaceId === workspaceId && m.workspaceStatus === 'active'),
|
|
16
17
|
hasRole: (workspaceId, role)=>memberships.some((m)=>m.workspaceId === workspaceId && m.workspaceStatus === 'active' && m.role === role)
|
|
17
18
|
};
|
|
@@ -34,6 +35,20 @@ export function requireUserContext(request) {
|
|
|
34
35
|
}
|
|
35
36
|
return context;
|
|
36
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Rejects a request whose user context carries an impersonator (the
|
|
40
|
+
* durable-artefact deny-list). Routes that issue a credential or create a
|
|
41
|
+
* durable grant call this so an impersonated session cannot leave anything
|
|
42
|
+
* behind that outlives its bounded token window. Requires a user context:
|
|
43
|
+
* callers must run it on routes whose auth method has set one.
|
|
44
|
+
*/ export function rejectImpersonatedSession(request) {
|
|
45
|
+
const context = requireUserContext(request);
|
|
46
|
+
if (context.impersonatorId) {
|
|
47
|
+
throw new ClientError('Impersonated sessions cannot issue credentials or create durable grants', 'impersonation-not-permitted', {
|
|
48
|
+
status: 403
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
37
52
|
/**
|
|
38
53
|
* Authorizes the request's user for a workspace using only the verified session
|
|
39
54
|
* token. Membership and role come from the token claims, so the check stays
|
|
@@ -67,6 +82,84 @@ export function requireUserContext(request) {
|
|
|
67
82
|
role: membership.role
|
|
68
83
|
};
|
|
69
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Rejects an impersonated session before any administrator authority is
|
|
87
|
+
* consulted. Every route under an `/admin` prefix runs this guard: a request
|
|
88
|
+
* whose context carries `impersonatorId` receives the same `admin-role-required`
|
|
89
|
+
* failure a role-less actor would, before roles are read, so a target that
|
|
90
|
+
* gains a grant inside the token window still cannot act on it. The check is
|
|
91
|
+
* request-scoped on purpose: the inter-module `requireAdminRole` contract only
|
|
92
|
+
* carries `{userId, minimumRole}` and cannot transport the mark.
|
|
93
|
+
*/ export function requireAdministrationActor(request) {
|
|
94
|
+
const context = getUserContext(request);
|
|
95
|
+
if (context?.impersonatorId != null) {
|
|
96
|
+
throw new ClientError('Administrator role required', 'admin-role-required', {
|
|
97
|
+
status: 403
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const ADMINISTRATION_PREFIX = '/admin';
|
|
102
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
103
|
+
const LEADING_SLASHES = /^\/+/;
|
|
104
|
+
function isAdministrationPrefix(prefix) {
|
|
105
|
+
return prefix === ADMINISTRATION_PREFIX || prefix.startsWith(`${ADMINISTRATION_PREFIX}/`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Joins a parent prefix and a child prefix or route path the way Fastify
|
|
109
|
+
* resolves the mounted URL: surrounding slashes are trimmed and segments are
|
|
110
|
+
* joined with a single `/`, and a leading slash is added when the joined path
|
|
111
|
+
* is non-empty. Raw concatenation would diverge from Fastify's resolution for
|
|
112
|
+
* slash-less prefixes (`/admin` + `things` -> `/adminthings` while Fastify
|
|
113
|
+
* mounts `/admin/things`) and let a route escape the guard while still
|
|
114
|
+
* mounting under `/admin`.
|
|
115
|
+
*/ function joinRoutePath(parent, child) {
|
|
116
|
+
if (parent === '') {
|
|
117
|
+
return child.startsWith('/') ? child : `/${child}`;
|
|
118
|
+
}
|
|
119
|
+
if (child === '') {
|
|
120
|
+
return parent;
|
|
121
|
+
}
|
|
122
|
+
return `${parent.replace(TRAILING_SLASHES, '')}/${child.replace(LEADING_SLASHES, '')}`;
|
|
123
|
+
}
|
|
124
|
+
function adoptAdministrationGuardIn(route, parentPrefix) {
|
|
125
|
+
if (isRouteGroup(route)) {
|
|
126
|
+
return {
|
|
127
|
+
...route,
|
|
128
|
+
routes: route.routes.map((child)=>adoptAdministrationGuardIn(child, joinRoutePath(parentPrefix, route.prefix)))
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const effectivePath = joinRoutePath(parentPrefix, route.path);
|
|
132
|
+
if (!isAdministrationPrefix(parentPrefix) && !isAdministrationPrefix(effectivePath)) {
|
|
133
|
+
return route;
|
|
134
|
+
}
|
|
135
|
+
const preHandler = [
|
|
136
|
+
(request)=>{
|
|
137
|
+
requireAdministrationActor(request);
|
|
138
|
+
return undefined;
|
|
139
|
+
},
|
|
140
|
+
...route.preHandler === undefined ? [] : Array.isArray(route.preHandler) ? route.preHandler : [
|
|
141
|
+
route.preHandler
|
|
142
|
+
]
|
|
143
|
+
];
|
|
144
|
+
return {
|
|
145
|
+
...route,
|
|
146
|
+
preHandler
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Positionally adopts the impersonated-session rejection for every route under
|
|
151
|
+
* an `/admin` prefix in the given route tree: the rule is "every `/admin`
|
|
152
|
+
* route", so an administration surface added later under the prefix inherits
|
|
153
|
+
* the guard without anyone remembering to attach it. The guard runs before any
|
|
154
|
+
* existing preHandler, so roles are never consulted for an impersonated
|
|
155
|
+
* context — including on role-check-free routes such as the first-owner
|
|
156
|
+
* bootstrap.
|
|
157
|
+
*/ export function adoptAdministrationActorGuard(routes) {
|
|
158
|
+
if (Array.isArray(routes)) {
|
|
159
|
+
return routes.map((route)=>adoptAdministrationGuardIn(route, ''));
|
|
160
|
+
}
|
|
161
|
+
return adoptAdministrationGuardIn(routes, '');
|
|
162
|
+
}
|
|
70
163
|
/**
|
|
71
164
|
* Applies the workspace lifecycle gate to a resource that has already been loaded. Missing
|
|
72
165
|
* membership remains resource-shaped 404 to avoid leaking the resource's existence, while
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type {JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';\nimport type {WorkspaceRole} from '@shipfox/api-workspaces-dto';\nimport {ClientError} from '@shipfox/node-fastify';\n\nexport const AUTH_USER = 'user';\nexport const AUTH_RUNNER_REGISTRATION_TOKEN = 'runner-registration-token';\nexport const AUTH_RUNNER_SESSION = 'runner-session';\nexport const AUTH_RUNNER_CONTROL_SESSION = 'runner-control-session';\nexport const AUTH_LEASED_JOB = 'leased-job';\nexport const AUTH_PROVISIONER_TOKEN = 'provisioner-token';\n\nexport type WorkspaceStatus = 'active' | 'suspended' | 'deleted';\n\nexport interface UserContextMembership {\n workspaceId: string;\n role: WorkspaceRole;\n workspaceStatus: WorkspaceStatus;\n}\n\nexport interface UserContext {\n userId: string;\n email: string;\n name: string | null;\n memberships: ReadonlyArray<UserContextMembership>;\n canAccess(workspaceId: string): boolean;\n hasRole(workspaceId: string, role: WorkspaceRole): boolean;\n}\n\nexport interface BuildUserContextParams {\n userId: string;\n email: string;\n name?: string | null | undefined;\n memberships?: ReadonlyArray<UserContextMembership> | undefined;\n}\n\nexport function buildUserContext(params: BuildUserContextParams): UserContext {\n const memberships = params.memberships ?? [];\n return {\n userId: params.userId,\n email: params.email,\n name: params.name ?? null,\n memberships,\n canAccess: (workspaceId) =>\n memberships.some((m) => m.workspaceId === workspaceId && m.workspaceStatus === 'active'),\n hasRole: (workspaceId, role) =>\n memberships.some(\n (m) => m.workspaceId === workspaceId && m.workspaceStatus === 'active' && m.role === role,\n ),\n };\n}\n\nexport type ProvisionerContext =\n | {provisionerTokenId: string; scope: 'installation'}\n | {provisionerTokenId: string; scope: 'workspace'; workspaceId: string};\n\nexport type LeasedJobContext = JobLeaseTokenClaims;\nexport type RunnerSessionContext = RunnerSessionTokenClaims;\nexport interface RunnerControlSessionContext {\n runnerControlSessionId: string;\n runnerInstanceId: string;\n provisionerId: string;\n}\n\ntype RequestWithContext = object;\n\nconst USER_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/user');\nconst LEASED_JOB_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/leased-job');\nconst PROVISIONER_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/provisioner');\nconst RUNNER_SESSION_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/runner-session');\nconst RUNNER_CONTROL_SESSION_CONTEXT_KEY = Symbol.for(\n '@shipfox/api-auth-context/runner-control-session',\n);\n\nexport function setUserContext(request: RequestWithContext, context: UserContext): void {\n (request as Record<symbol, unknown>)[USER_CONTEXT_KEY] = context;\n}\n\nexport function getUserContext(request: RequestWithContext): UserContext | null {\n return (\n ((request as Record<symbol, unknown>)[USER_CONTEXT_KEY] as UserContext | undefined) ?? null\n );\n}\n\nexport function requireUserContext(request: RequestWithContext): UserContext {\n const context = getUserContext(request);\n if (!context) {\n throw new Error('User context is not available on this request');\n }\n return context;\n}\n\nexport interface RequireWorkspaceAccessParams {\n request: RequestWithContext;\n workspaceId: string;\n}\n\nexport interface RequireWorkspaceAccessResult {\n workspaceId: string;\n userId: string;\n role: WorkspaceRole;\n}\n\n/**\n * Authorizes the request's user for a workspace using only the verified session\n * token. Membership and role come from the token claims, so the check stays\n * stateless and does not read the database on the request path.\n */\nexport function requireWorkspaceAccess(\n params: RequireWorkspaceAccessParams,\n): RequireWorkspaceAccessResult {\n const context = getUserContext(params.request);\n if (!context) {\n throw new ClientError('Authentication required', 'unauthorized', {status: 401});\n }\n\n const membership = context.memberships.find((m) => m.workspaceId === params.workspaceId);\n if (!membership) {\n throw new ClientError('Not a member of this workspace', 'forbidden', {status: 403});\n }\n\n if (membership.workspaceStatus === 'suspended') {\n throw new ClientError('Workspace is suspended', 'workspace-suspended', {status: 409});\n }\n if (membership.workspaceStatus === 'deleted') {\n throw new ClientError('Workspace is not active', 'workspace-inactive', {status: 403});\n }\n\n return {workspaceId: params.workspaceId, userId: context.userId, role: membership.role};\n}\n\n/**\n * Applies the workspace lifecycle gate to a resource that has already been loaded. Missing\n * membership remains resource-shaped 404 to avoid leaking the resource's existence, while\n * suspended and deleted claims retain their stable lifecycle errors.\n */\nexport function requireWorkspaceResourceAccess(params: {\n request: RequestWithContext;\n workspaceId: string;\n notFoundError: ClientError;\n}): RequireWorkspaceAccessResult {\n try {\n return requireWorkspaceAccess(params);\n } catch (error) {\n if (error instanceof ClientError && error.code === 'forbidden') {\n throw params.notFoundError;\n }\n throw error;\n }\n}\n\nexport function setProvisionerContext(\n request: RequestWithContext,\n context: ProvisionerContext,\n): void {\n (request as Record<symbol, unknown>)[PROVISIONER_CONTEXT_KEY] = context;\n}\n\nexport function getProvisionerContext(request: RequestWithContext): ProvisionerContext | null {\n return (\n ((request as Record<symbol, unknown>)[PROVISIONER_CONTEXT_KEY] as\n | ProvisionerContext\n | undefined) ?? null\n );\n}\n\nexport function requireProvisionerContext(request: RequestWithContext): ProvisionerContext {\n const context = getProvisionerContext(request);\n if (!context) {\n throw new Error('Provisioner context is not available on this request');\n }\n return context;\n}\n\nexport function requireWorkspaceProvisionerContext(\n request: RequestWithContext,\n): Extract<ProvisionerContext, {scope: 'workspace'}> {\n const context = requireProvisionerContext(request);\n if (context.scope !== 'workspace') {\n throw new ClientError('Workspace provisioner credential required', 'forbidden', {status: 403});\n }\n return context;\n}\n\nexport function requireInstallationProvisionerContext(\n request: RequestWithContext,\n): Extract<ProvisionerContext, {scope: 'installation'}> {\n const context = requireProvisionerContext(request);\n if (context.scope !== 'installation') {\n throw new ClientError('Installation provisioner credential required', 'forbidden', {\n status: 403,\n });\n }\n return context;\n}\n\nexport function setLeasedJobContext(request: RequestWithContext, context: LeasedJobContext): void {\n (request as Record<symbol, unknown>)[LEASED_JOB_CONTEXT_KEY] = context;\n}\n\nexport function getLeasedJobContext(request: RequestWithContext): LeasedJobContext | null {\n return (\n ((request as Record<symbol, unknown>)[LEASED_JOB_CONTEXT_KEY] as\n | LeasedJobContext\n | undefined) ?? null\n );\n}\n\nexport function requireLeasedJobContext(request: RequestWithContext): LeasedJobContext {\n const context = getLeasedJobContext(request);\n if (!context) {\n throw new Error('Leased job context is not available on this request');\n }\n return context;\n}\n\nexport function setRunnerSessionContext(\n request: RequestWithContext,\n context: RunnerSessionContext,\n): void {\n (request as Record<symbol, unknown>)[RUNNER_SESSION_CONTEXT_KEY] = context;\n}\n\nexport function getRunnerSessionContext(request: RequestWithContext): RunnerSessionContext | null {\n return (\n ((request as Record<symbol, unknown>)[RUNNER_SESSION_CONTEXT_KEY] as\n | RunnerSessionContext\n | undefined) ?? null\n );\n}\n\nexport function requireRunnerSessionContext(request: RequestWithContext): RunnerSessionContext {\n const context = getRunnerSessionContext(request);\n if (!context) {\n throw new Error('Runner session context is not available on this request');\n }\n return context;\n}\n\nexport function setRunnerControlSessionContext(\n request: RequestWithContext,\n context: RunnerControlSessionContext,\n): void {\n (request as Record<symbol, unknown>)[RUNNER_CONTROL_SESSION_CONTEXT_KEY] = context;\n}\n\nexport function requireRunnerControlSessionContext(\n request: RequestWithContext,\n): RunnerControlSessionContext {\n const context = (request as Record<symbol, unknown>)[RUNNER_CONTROL_SESSION_CONTEXT_KEY] as\n | RunnerControlSessionContext\n | undefined;\n if (!context) throw new Error('Runner control session context is not available on this request');\n return context;\n}\n"],"names":["ClientError","AUTH_USER","AUTH_RUNNER_REGISTRATION_TOKEN","AUTH_RUNNER_SESSION","AUTH_RUNNER_CONTROL_SESSION","AUTH_LEASED_JOB","AUTH_PROVISIONER_TOKEN","buildUserContext","params","memberships","userId","email","name","canAccess","workspaceId","some","m","workspaceStatus","hasRole","role","USER_CONTEXT_KEY","Symbol","for","LEASED_JOB_CONTEXT_KEY","PROVISIONER_CONTEXT_KEY","RUNNER_SESSION_CONTEXT_KEY","RUNNER_CONTROL_SESSION_CONTEXT_KEY","setUserContext","request","context","getUserContext","requireUserContext","Error","requireWorkspaceAccess","status","membership","find","requireWorkspaceResourceAccess","error","code","notFoundError","setProvisionerContext","getProvisionerContext","requireProvisionerContext","requireWorkspaceProvisionerContext","scope","requireInstallationProvisionerContext","setLeasedJobContext","getLeasedJobContext","requireLeasedJobContext","setRunnerSessionContext","getRunnerSessionContext","requireRunnerSessionContext","setRunnerControlSessionContext","requireRunnerControlSessionContext"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type {JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';\nimport type {WorkspaceRole} from '@shipfox/api-workspaces-dto';\nimport {\n ClientError,\n isRouteGroup,\n type RouteExport,\n type RoutePreHandler,\n} from '@shipfox/node-fastify';\n\nexport const AUTH_USER = 'user';\nexport const AUTH_RUNNER_REGISTRATION_TOKEN = 'runner-registration-token';\nexport const AUTH_RUNNER_SESSION = 'runner-session';\nexport const AUTH_RUNNER_CONTROL_SESSION = 'runner-control-session';\nexport const AUTH_LEASED_JOB = 'leased-job';\nexport const AUTH_PROVISIONER_TOKEN = 'provisioner-token';\n\nexport type WorkspaceStatus = 'active' | 'suspended' | 'deleted';\n\nexport interface UserContextMembership {\n workspaceId: string;\n role: WorkspaceRole;\n workspaceStatus: WorkspaceStatus;\n}\n\nexport interface UserContext {\n userId: string;\n email: string;\n name: string | null;\n memberships: ReadonlyArray<UserContextMembership>;\n impersonatorId?: string | undefined;\n canAccess(workspaceId: string): boolean;\n hasRole(workspaceId: string, role: WorkspaceRole): boolean;\n}\n\nexport interface BuildUserContextParams {\n userId: string;\n email: string;\n name?: string | null | undefined;\n memberships?: ReadonlyArray<UserContextMembership> | undefined;\n impersonatorId?: string | undefined;\n}\n\nexport function buildUserContext(params: BuildUserContextParams): UserContext {\n const memberships = params.memberships ?? [];\n return {\n userId: params.userId,\n email: params.email,\n name: params.name ?? null,\n memberships,\n impersonatorId: params.impersonatorId,\n canAccess: (workspaceId) =>\n memberships.some((m) => m.workspaceId === workspaceId && m.workspaceStatus === 'active'),\n hasRole: (workspaceId, role) =>\n memberships.some(\n (m) => m.workspaceId === workspaceId && m.workspaceStatus === 'active' && m.role === role,\n ),\n };\n}\n\nexport type ProvisionerContext =\n | {provisionerTokenId: string; scope: 'installation'}\n | {provisionerTokenId: string; scope: 'workspace'; workspaceId: string};\n\nexport type LeasedJobContext = JobLeaseTokenClaims;\nexport type RunnerSessionContext = RunnerSessionTokenClaims;\nexport interface RunnerControlSessionContext {\n runnerControlSessionId: string;\n runnerInstanceId: string;\n provisionerId: string;\n}\n\ntype RequestWithContext = object;\n\nconst USER_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/user');\nconst LEASED_JOB_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/leased-job');\nconst PROVISIONER_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/provisioner');\nconst RUNNER_SESSION_CONTEXT_KEY = Symbol.for('@shipfox/api-auth-context/runner-session');\nconst RUNNER_CONTROL_SESSION_CONTEXT_KEY = Symbol.for(\n '@shipfox/api-auth-context/runner-control-session',\n);\n\nexport function setUserContext(request: RequestWithContext, context: UserContext): void {\n (request as Record<symbol, unknown>)[USER_CONTEXT_KEY] = context;\n}\n\nexport function getUserContext(request: RequestWithContext): UserContext | null {\n return (\n ((request as Record<symbol, unknown>)[USER_CONTEXT_KEY] as UserContext | undefined) ?? null\n );\n}\n\nexport function requireUserContext(request: RequestWithContext): UserContext {\n const context = getUserContext(request);\n if (!context) {\n throw new Error('User context is not available on this request');\n }\n return context;\n}\n\n/**\n * Rejects a request whose user context carries an impersonator (the\n * durable-artefact deny-list). Routes that issue a credential or create a\n * durable grant call this so an impersonated session cannot leave anything\n * behind that outlives its bounded token window. Requires a user context:\n * callers must run it on routes whose auth method has set one.\n */\nexport function rejectImpersonatedSession(request: RequestWithContext): void {\n const context = requireUserContext(request);\n if (context.impersonatorId) {\n throw new ClientError(\n 'Impersonated sessions cannot issue credentials or create durable grants',\n 'impersonation-not-permitted',\n {status: 403},\n );\n }\n}\n\nexport interface RequireWorkspaceAccessParams {\n request: RequestWithContext;\n workspaceId: string;\n}\n\nexport interface RequireWorkspaceAccessResult {\n workspaceId: string;\n userId: string;\n role: WorkspaceRole;\n}\n\n/**\n * Authorizes the request's user for a workspace using only the verified session\n * token. Membership and role come from the token claims, so the check stays\n * stateless and does not read the database on the request path.\n */\nexport function requireWorkspaceAccess(\n params: RequireWorkspaceAccessParams,\n): RequireWorkspaceAccessResult {\n const context = getUserContext(params.request);\n if (!context) {\n throw new ClientError('Authentication required', 'unauthorized', {status: 401});\n }\n\n const membership = context.memberships.find((m) => m.workspaceId === params.workspaceId);\n if (!membership) {\n throw new ClientError('Not a member of this workspace', 'forbidden', {status: 403});\n }\n\n if (membership.workspaceStatus === 'suspended') {\n throw new ClientError('Workspace is suspended', 'workspace-suspended', {status: 409});\n }\n if (membership.workspaceStatus === 'deleted') {\n throw new ClientError('Workspace is not active', 'workspace-inactive', {status: 403});\n }\n\n return {workspaceId: params.workspaceId, userId: context.userId, role: membership.role};\n}\n\n/**\n * Rejects an impersonated session before any administrator authority is\n * consulted. Every route under an `/admin` prefix runs this guard: a request\n * whose context carries `impersonatorId` receives the same `admin-role-required`\n * failure a role-less actor would, before roles are read, so a target that\n * gains a grant inside the token window still cannot act on it. The check is\n * request-scoped on purpose: the inter-module `requireAdminRole` contract only\n * carries `{userId, minimumRole}` and cannot transport the mark.\n */\nexport function requireAdministrationActor(request: RequestWithContext): void {\n const context = getUserContext(request);\n if (context?.impersonatorId != null) {\n throw new ClientError('Administrator role required', 'admin-role-required', {status: 403});\n }\n}\n\nconst ADMINISTRATION_PREFIX = '/admin';\nconst TRAILING_SLASHES = /\\/+$/;\nconst LEADING_SLASHES = /^\\/+/;\n\nfunction isAdministrationPrefix(prefix: string): boolean {\n return prefix === ADMINISTRATION_PREFIX || prefix.startsWith(`${ADMINISTRATION_PREFIX}/`);\n}\n\n/**\n * Joins a parent prefix and a child prefix or route path the way Fastify\n * resolves the mounted URL: surrounding slashes are trimmed and segments are\n * joined with a single `/`, and a leading slash is added when the joined path\n * is non-empty. Raw concatenation would diverge from Fastify's resolution for\n * slash-less prefixes (`/admin` + `things` -> `/adminthings` while Fastify\n * mounts `/admin/things`) and let a route escape the guard while still\n * mounting under `/admin`.\n */\nfunction joinRoutePath(parent: string, child: string): string {\n if (parent === '') {\n return child.startsWith('/') ? child : `/${child}`;\n }\n if (child === '') {\n return parent;\n }\n return `${parent.replace(TRAILING_SLASHES, '')}/${child.replace(LEADING_SLASHES, '')}`;\n}\n\nfunction adoptAdministrationGuardIn(route: RouteExport, parentPrefix: string): RouteExport {\n if (isRouteGroup(route)) {\n return {\n ...route,\n routes: route.routes.map((child) =>\n adoptAdministrationGuardIn(child, joinRoutePath(parentPrefix, route.prefix)),\n ),\n };\n }\n const effectivePath = joinRoutePath(parentPrefix, route.path);\n if (!isAdministrationPrefix(parentPrefix) && !isAdministrationPrefix(effectivePath)) {\n return route;\n }\n const preHandler: RoutePreHandler[] = [\n (request) => {\n requireAdministrationActor(request);\n return undefined;\n },\n ...(route.preHandler === undefined\n ? []\n : Array.isArray(route.preHandler)\n ? route.preHandler\n : [route.preHandler]),\n ];\n return {...route, preHandler};\n}\n\n/**\n * Positionally adopts the impersonated-session rejection for every route under\n * an `/admin` prefix in the given route tree: the rule is \"every `/admin`\n * route\", so an administration surface added later under the prefix inherits\n * the guard without anyone remembering to attach it. The guard runs before any\n * existing preHandler, so roles are never consulted for an impersonated\n * context — including on role-check-free routes such as the first-owner\n * bootstrap.\n */\nexport function adoptAdministrationActorGuard<T extends RouteExport | RouteExport[]>(routes: T): T {\n if (Array.isArray(routes)) {\n return routes.map((route) => adoptAdministrationGuardIn(route, '')) as T;\n }\n return adoptAdministrationGuardIn(routes, '') as T;\n}\n\n/**\n * Applies the workspace lifecycle gate to a resource that has already been loaded. Missing\n * membership remains resource-shaped 404 to avoid leaking the resource's existence, while\n * suspended and deleted claims retain their stable lifecycle errors.\n */\nexport function requireWorkspaceResourceAccess(params: {\n request: RequestWithContext;\n workspaceId: string;\n notFoundError: ClientError;\n}): RequireWorkspaceAccessResult {\n try {\n return requireWorkspaceAccess(params);\n } catch (error) {\n if (error instanceof ClientError && error.code === 'forbidden') {\n throw params.notFoundError;\n }\n throw error;\n }\n}\n\nexport function setProvisionerContext(\n request: RequestWithContext,\n context: ProvisionerContext,\n): void {\n (request as Record<symbol, unknown>)[PROVISIONER_CONTEXT_KEY] = context;\n}\n\nexport function getProvisionerContext(request: RequestWithContext): ProvisionerContext | null {\n return (\n ((request as Record<symbol, unknown>)[PROVISIONER_CONTEXT_KEY] as\n | ProvisionerContext\n | undefined) ?? null\n );\n}\n\nexport function requireProvisionerContext(request: RequestWithContext): ProvisionerContext {\n const context = getProvisionerContext(request);\n if (!context) {\n throw new Error('Provisioner context is not available on this request');\n }\n return context;\n}\n\nexport function requireWorkspaceProvisionerContext(\n request: RequestWithContext,\n): Extract<ProvisionerContext, {scope: 'workspace'}> {\n const context = requireProvisionerContext(request);\n if (context.scope !== 'workspace') {\n throw new ClientError('Workspace provisioner credential required', 'forbidden', {status: 403});\n }\n return context;\n}\n\nexport function requireInstallationProvisionerContext(\n request: RequestWithContext,\n): Extract<ProvisionerContext, {scope: 'installation'}> {\n const context = requireProvisionerContext(request);\n if (context.scope !== 'installation') {\n throw new ClientError('Installation provisioner credential required', 'forbidden', {\n status: 403,\n });\n }\n return context;\n}\n\nexport function setLeasedJobContext(request: RequestWithContext, context: LeasedJobContext): void {\n (request as Record<symbol, unknown>)[LEASED_JOB_CONTEXT_KEY] = context;\n}\n\nexport function getLeasedJobContext(request: RequestWithContext): LeasedJobContext | null {\n return (\n ((request as Record<symbol, unknown>)[LEASED_JOB_CONTEXT_KEY] as\n | LeasedJobContext\n | undefined) ?? null\n );\n}\n\nexport function requireLeasedJobContext(request: RequestWithContext): LeasedJobContext {\n const context = getLeasedJobContext(request);\n if (!context) {\n throw new Error('Leased job context is not available on this request');\n }\n return context;\n}\n\nexport function setRunnerSessionContext(\n request: RequestWithContext,\n context: RunnerSessionContext,\n): void {\n (request as Record<symbol, unknown>)[RUNNER_SESSION_CONTEXT_KEY] = context;\n}\n\nexport function getRunnerSessionContext(request: RequestWithContext): RunnerSessionContext | null {\n return (\n ((request as Record<symbol, unknown>)[RUNNER_SESSION_CONTEXT_KEY] as\n | RunnerSessionContext\n | undefined) ?? null\n );\n}\n\nexport function requireRunnerSessionContext(request: RequestWithContext): RunnerSessionContext {\n const context = getRunnerSessionContext(request);\n if (!context) {\n throw new Error('Runner session context is not available on this request');\n }\n return context;\n}\n\nexport function setRunnerControlSessionContext(\n request: RequestWithContext,\n context: RunnerControlSessionContext,\n): void {\n (request as Record<symbol, unknown>)[RUNNER_CONTROL_SESSION_CONTEXT_KEY] = context;\n}\n\nexport function requireRunnerControlSessionContext(\n request: RequestWithContext,\n): RunnerControlSessionContext {\n const context = (request as Record<symbol, unknown>)[RUNNER_CONTROL_SESSION_CONTEXT_KEY] as\n | RunnerControlSessionContext\n | undefined;\n if (!context) throw new Error('Runner control session context is not available on this request');\n return context;\n}\n"],"names":["ClientError","isRouteGroup","AUTH_USER","AUTH_RUNNER_REGISTRATION_TOKEN","AUTH_RUNNER_SESSION","AUTH_RUNNER_CONTROL_SESSION","AUTH_LEASED_JOB","AUTH_PROVISIONER_TOKEN","buildUserContext","params","memberships","userId","email","name","impersonatorId","canAccess","workspaceId","some","m","workspaceStatus","hasRole","role","USER_CONTEXT_KEY","Symbol","for","LEASED_JOB_CONTEXT_KEY","PROVISIONER_CONTEXT_KEY","RUNNER_SESSION_CONTEXT_KEY","RUNNER_CONTROL_SESSION_CONTEXT_KEY","setUserContext","request","context","getUserContext","requireUserContext","Error","rejectImpersonatedSession","status","requireWorkspaceAccess","membership","find","requireAdministrationActor","ADMINISTRATION_PREFIX","TRAILING_SLASHES","LEADING_SLASHES","isAdministrationPrefix","prefix","startsWith","joinRoutePath","parent","child","replace","adoptAdministrationGuardIn","route","parentPrefix","routes","map","effectivePath","path","preHandler","undefined","Array","isArray","adoptAdministrationActorGuard","requireWorkspaceResourceAccess","error","code","notFoundError","setProvisionerContext","getProvisionerContext","requireProvisionerContext","requireWorkspaceProvisionerContext","scope","requireInstallationProvisionerContext","setLeasedJobContext","getLeasedJobContext","requireLeasedJobContext","setRunnerSessionContext","getRunnerSessionContext","requireRunnerSessionContext","setRunnerControlSessionContext","requireRunnerControlSessionContext"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-auth-context",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "17.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@shipfox/api-auth-dto": "
|
|
21
|
+
"@shipfox/api-auth-dto": "17.0.0",
|
|
22
22
|
"@shipfox/api-workspaces-dto": "15.0.0",
|
|
23
23
|
"@shipfox/node-fastify": "0.4.3"
|
|
24
24
|
},
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClientError,
|
|
3
|
+
type RouteDefinition,
|
|
4
|
+
type RouteGroup,
|
|
5
|
+
type RoutePreHandler,
|
|
6
|
+
} from '@shipfox/node-fastify';
|
|
7
|
+
import {
|
|
8
|
+
adoptAdministrationActorGuard,
|
|
9
|
+
buildUserContext,
|
|
10
|
+
requireAdministrationActor,
|
|
11
|
+
setUserContext,
|
|
12
|
+
} from './index.js';
|
|
13
|
+
|
|
14
|
+
function route(path: string, preHandler?: RouteDefinition['preHandler']): RouteDefinition {
|
|
15
|
+
return {
|
|
16
|
+
method: 'GET',
|
|
17
|
+
path,
|
|
18
|
+
description: `test route ${path}`,
|
|
19
|
+
handler: () => ({ok: true}),
|
|
20
|
+
...(preHandler === undefined ? {} : {preHandler}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('requireAdministrationActor', () => {
|
|
25
|
+
test('rejects an impersonated context with admin-role-required before roles are read', () => {
|
|
26
|
+
const request = {};
|
|
27
|
+
setUserContext(
|
|
28
|
+
request,
|
|
29
|
+
buildUserContext({
|
|
30
|
+
userId: crypto.randomUUID(),
|
|
31
|
+
email: 'target@example.com',
|
|
32
|
+
memberships: [{workspaceId: crypto.randomUUID(), role: 'admin', workspaceStatus: 'active'}],
|
|
33
|
+
impersonatorId: crypto.randomUUID(),
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const act = () => requireAdministrationActor(request);
|
|
38
|
+
|
|
39
|
+
expect(act).toThrow(ClientError);
|
|
40
|
+
expect(act).toThrow(expect.objectContaining({code: 'admin-role-required', status: 403}));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('passes an ordinary session through', () => {
|
|
44
|
+
const request = {};
|
|
45
|
+
setUserContext(
|
|
46
|
+
request,
|
|
47
|
+
buildUserContext({userId: crypto.randomUUID(), email: 'user@example.com'}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(() => requireAdministrationActor(request)).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('passes when no user context exists', () => {
|
|
54
|
+
expect(() => requireAdministrationActor({})).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('passes a null impersonatorId through so only a present mark rejects', () => {
|
|
58
|
+
const request = {};
|
|
59
|
+
setUserContext(
|
|
60
|
+
request,
|
|
61
|
+
buildUserContext({
|
|
62
|
+
userId: crypto.randomUUID(),
|
|
63
|
+
email: 'user@example.com',
|
|
64
|
+
// A type-violating producer could set null; it must not lock a
|
|
65
|
+
// legitimate administrator out of /admin routes.
|
|
66
|
+
impersonatorId: null as unknown as string | undefined,
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(() => requireAdministrationActor(request)).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('adoptAdministrationActorGuard', () => {
|
|
75
|
+
test('guards every route in an /admin-prefixed group ahead of existing preHandlers', () => {
|
|
76
|
+
const existingPreHandler = () => Promise.resolve();
|
|
77
|
+
const guardedRoute = route('/lookup', existingPreHandler);
|
|
78
|
+
const [adminGroup] = adoptAdministrationActorGuard([
|
|
79
|
+
{prefix: '/admin/things', routes: [guardedRoute, route('/bootstrap')]},
|
|
80
|
+
]) as RouteGroup[];
|
|
81
|
+
|
|
82
|
+
const [lookup, bootstrap] = adminGroup.routes as RouteDefinition[];
|
|
83
|
+
expect(lookup.preHandler).toHaveLength(2);
|
|
84
|
+
expect(typeof lookup.preHandler?.[0]).toBe('function');
|
|
85
|
+
expect(lookup.preHandler?.[1]).toBe(existingPreHandler);
|
|
86
|
+
expect(bootstrap.preHandler).toHaveLength(1);
|
|
87
|
+
expect(typeof bootstrap.preHandler?.[0]).toBe('function');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('guards nested groups under an /admin prefix', () => {
|
|
91
|
+
const leaf = route('/leaf');
|
|
92
|
+
const [group] = adoptAdministrationActorGuard([
|
|
93
|
+
{prefix: '/admin', routes: [{prefix: '/nested', routes: [leaf]}]},
|
|
94
|
+
]) as RouteGroup[];
|
|
95
|
+
|
|
96
|
+
const nested = (group.routes[0] as RouteGroup).routes as RouteDefinition[];
|
|
97
|
+
expect(nested[0]?.preHandler).toHaveLength(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('guards routes whose prefixes Fastify resolves under /admin', () => {
|
|
101
|
+
// Fastify mounts a slash-less child prefix under the parent: `/admin` +
|
|
102
|
+
// `things` -> `/admin/things`. The guard must follow the same resolution
|
|
103
|
+
// rather than raw string concatenation.
|
|
104
|
+
const [nestedGroup] = adoptAdministrationActorGuard([
|
|
105
|
+
{prefix: '/admin', routes: [{prefix: 'things', routes: [route('/leaf')]}]},
|
|
106
|
+
]) as RouteGroup[];
|
|
107
|
+
const nested = (nestedGroup.routes[0] as RouteGroup).routes as RouteDefinition[];
|
|
108
|
+
expect(nested[0]?.preHandler).toHaveLength(1);
|
|
109
|
+
|
|
110
|
+
// Fastify adds the leading slash to a top-level slash-less prefix.
|
|
111
|
+
const [topLevel] = adoptAdministrationActorGuard([
|
|
112
|
+
{prefix: 'admin', routes: [route('/x')]},
|
|
113
|
+
]) as RouteGroup[];
|
|
114
|
+
expect((topLevel.routes as RouteDefinition[])[0]?.preHandler).toHaveLength(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('guards a group whose prefix is exactly /admin', () => {
|
|
118
|
+
const [group] = adoptAdministrationActorGuard([
|
|
119
|
+
{prefix: '/admin', routes: [route('/x')]},
|
|
120
|
+
]) as RouteGroup[];
|
|
121
|
+
const preHandlers = (group.routes as RouteDefinition[])[0]?.preHandler as RoutePreHandler[];
|
|
122
|
+
|
|
123
|
+
const markedRequest = {};
|
|
124
|
+
setUserContext(
|
|
125
|
+
markedRequest,
|
|
126
|
+
buildUserContext({
|
|
127
|
+
userId: crypto.randomUUID(),
|
|
128
|
+
email: 'target@example.com',
|
|
129
|
+
impersonatorId: crypto.randomUUID(),
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const act = () => preHandlers[0]?.(markedRequest as Parameters<RoutePreHandler>[0]);
|
|
134
|
+
expect(act).toThrow(ClientError);
|
|
135
|
+
expect(act).toThrow(expect.objectContaining({code: 'admin-role-required', status: 403}));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('leaves routes under lookalike /admin prefixes untouched', () => {
|
|
139
|
+
const administratorLeaf = route('/x');
|
|
140
|
+
const adminExtraLeaf = route('/x');
|
|
141
|
+
|
|
142
|
+
const [administrator] = adoptAdministrationActorGuard([
|
|
143
|
+
{prefix: '/administrator', routes: [administratorLeaf]},
|
|
144
|
+
]) as RouteGroup[];
|
|
145
|
+
const [adminExtra] = adoptAdministrationActorGuard([
|
|
146
|
+
{prefix: '/admin-extra', routes: [adminExtraLeaf]},
|
|
147
|
+
]) as RouteGroup[];
|
|
148
|
+
|
|
149
|
+
expect((administrator.routes as RouteDefinition[])[0]).toBe(administratorLeaf);
|
|
150
|
+
expect((adminExtra.routes as RouteDefinition[])[0]).toBe(adminExtraLeaf);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('guards a root route whose own path is under /admin', () => {
|
|
154
|
+
const guarded = adoptAdministrationActorGuard(route('/admin/things')) as RouteDefinition;
|
|
155
|
+
|
|
156
|
+
expect(guarded.preHandler).toHaveLength(1);
|
|
157
|
+
expect(typeof guarded.preHandler?.[0]).toBe('function');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('leaves routes outside an /admin prefix untouched', () => {
|
|
161
|
+
const ordinary = route('/things');
|
|
162
|
+
const [group] = adoptAdministrationActorGuard([
|
|
163
|
+
{prefix: '/projects', routes: [ordinary]},
|
|
164
|
+
]) as RouteGroup[];
|
|
165
|
+
|
|
166
|
+
expect((group.routes as RouteDefinition[])[0]).toBe(ordinary);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('the adopted preHandler rejects an impersonated request', () => {
|
|
170
|
+
const [group] = adoptAdministrationActorGuard([
|
|
171
|
+
{prefix: '/admin/things', routes: [route('/')]},
|
|
172
|
+
]) as RouteGroup[];
|
|
173
|
+
const preHandlers = (group.routes as RouteDefinition[])[0]?.preHandler as RoutePreHandler[];
|
|
174
|
+
|
|
175
|
+
const markedRequest = {};
|
|
176
|
+
setUserContext(
|
|
177
|
+
markedRequest,
|
|
178
|
+
buildUserContext({
|
|
179
|
+
userId: crypto.randomUUID(),
|
|
180
|
+
email: 'target@example.com',
|
|
181
|
+
impersonatorId: crypto.randomUUID(),
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const act = () => preHandlers[0]?.(markedRequest as Parameters<RoutePreHandler>[0]);
|
|
186
|
+
expect(act).toThrow(ClientError);
|
|
187
|
+
expect(act).toThrow(expect.objectContaining({code: 'admin-role-required', status: 403}));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('accepts a single group and preserves group auth', () => {
|
|
191
|
+
const guarded = adoptAdministrationActorGuard({
|
|
192
|
+
prefix: '/admin/workspaces',
|
|
193
|
+
auth: 'user',
|
|
194
|
+
routes: [route('/')],
|
|
195
|
+
}) as RouteGroup;
|
|
196
|
+
|
|
197
|
+
expect(guarded.prefix).toBe('/admin/workspaces');
|
|
198
|
+
expect(guarded.auth).toBe('user');
|
|
199
|
+
expect((guarded.routes as RouteDefinition[])[0]?.preHandler).toHaveLength(1);
|
|
200
|
+
});
|
|
201
|
+
});
|