@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.
@@ -1,2 +1,2 @@
1
1
  $ shipfox-swc
2
- Successfully compiled: 1 file with swc (169.01ms)
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
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": "15.0.0",
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": "15.0.0",
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
+ });