@mettlecast/domain-cdk-packer 0.2.60 → 0.2.61

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.
@@ -0,0 +1,191 @@
1
+ import * as cdk from 'aws-cdk-lib';
2
+ import { IConstruct } from 'constructs';
3
+ import type { DomainRegistry } from '../registry.js';
4
+ /**
5
+ * Properties for {@link SecurityAssertionAspect}.
6
+ *
7
+ * The aspect reads the `DomainRegistry` exactly once at synthesis time and
8
+ * then walks every CDK construct for violations. All checks are read-only —
9
+ * the aspect never modifies the synthesized graph, only emits annotations.
10
+ *
11
+ * Issue #4662 Task D — deployment-time assertions. The CLI validator at
12
+ * `packages/domain-cli` already enforces the same rules against the
13
+ * `DomainRegistry` before synth, but the aspect is the last line of defence:
14
+ * it runs even if the CLI was skipped, and it can catch regressions in the
15
+ * synthesized graph itself (e.g. a route accidentally wired without an
16
+ * authorizer, or a Lambda exposed via Function URL).
17
+ */
18
+ export interface SecurityAssertionAspectProps {
19
+ /** The compiled domain registry — source of truth for what was declared. */
20
+ registry: DomainRegistry;
21
+ /**
22
+ * When true, missing JWT authorizer on a JWT-required route is reported
23
+ * as a synth-blocking error rather than a warning. Default: true.
24
+ *
25
+ * Set to false during the Wave 4 Task 4.1 roll-out so we could ship
26
+ * fix-ups one stack at a time. New deployments should leave it on.
27
+ */
28
+ failOnMissingAuthorizer?: boolean;
29
+ /**
30
+ * When true, an API-exposed action whose handler Lambda is reachable
31
+ * through a `Lambda::Url` resource is a synth-blocking error rather
32
+ * than a warning. Default: true — Function URLs create public, IAM-
33
+ * or NONE-authenticated endpoints that bypass API Gateway entirely.
34
+ */
35
+ failOnFunctionUrl?: boolean;
36
+ }
37
+ /**
38
+ * Result codes emitted by the aspect. Mirrored in the CLI validator so
39
+ * downstream tooling can correlate synth-time annotations with build-time
40
+ * validation errors. Each code corresponds to a single, narrowly-scoped
41
+ * invariant — see the per-method docstrings for the exact contract.
42
+ */
43
+ export declare const SECURITY_ASSERTION_CODES: {
44
+ /**
45
+ * A JWT-required route (api.authType='jwt' or action.exposure.auth='required')
46
+ * was synthesised without an attached `CfnAuthorizer` ID. Synth blocks.
47
+ */
48
+ readonly MISSING_JWT_AUTHORIZER: "SECURITY_MISSING_JWT_AUTHORIZER";
49
+ /**
50
+ * A tenancy-required route (api requiring a tenant, or action.exposure.tenancy='required')
51
+ * does not include the canonical tenant placeholder
52
+ * (`/v1/tenants/{tenantId}/`) in its path. The runtime cannot bind
53
+ * `ctx.tenant.id` from a path that does not carry the placeholder, so
54
+ * this would silently produce a tenant-less route. Synth blocks.
55
+ */
56
+ readonly MISSING_TENANT_PATH: "SECURITY_MISSING_TENANT_PATH";
57
+ /**
58
+ * An action Lambda is reachable through a `Lambda::Url` resource. This
59
+ * creates a public-or-IAM endpoint that bypasses API Gateway, the JWT
60
+ * authorizer, and the registry's exposure metadata. Synth blocks.
61
+ */
62
+ readonly ACTION_FUNCTION_URL: "SECURITY_ACTION_FUNCTION_URL";
63
+ /**
64
+ * An API route is declared `auth: 'none'` or `authType: 'none'` without
65
+ * a `securityException.reason`. Public routes must document the
66
+ * exception so security reviewers can audit the relaxation. Synth
67
+ * blocks — anonymous routes are the exception, not the default.
68
+ */
69
+ readonly MISSING_SECURITY_EXCEPTION: "SECURITY_MISSING_SECURITY_EXCEPTION";
70
+ };
71
+ export type SecurityAssertionCode = typeof SECURITY_ASSERTION_CODES[keyof typeof SECURITY_ASSERTION_CODES];
72
+ /**
73
+ * CDK Aspect that performs deployment-time security assertions against
74
+ * the synthesised {@link DomainStack}.
75
+ *
76
+ * The aspect complements the CLI validator at `packages/domain-cli`. Both
77
+ * layers run from the same source of truth (`DomainRegistry`), but they
78
+ * fail at different points in the pipeline:
79
+ *
80
+ * - The CLI validator fails the build BEFORE synth, so the developer
81
+ * gets a structured error code in their terminal and CI fails on
82
+ * `mc-domain-module validate`.
83
+ * - The aspect fails synth AFTER the stack has been assembled. It can
84
+ * detect runtime regressions (e.g. a route accidentally synthesised
85
+ * without an authorizer because a feature flag silently skipped the
86
+ * wiring) that the CLI cannot see.
87
+ *
88
+ * Adding the aspect to a stack is a one-liner:
89
+ *
90
+ * ```ts
91
+ * cdk.Aspects.of(stack).add(new SecurityAssertionAspect({ registry }));
92
+ * ```
93
+ *
94
+ * The {@link DomainStack} constructor already wires this aspect in for
95
+ * every domain, so most code does not need to interact with the class
96
+ * directly.
97
+ */
98
+ export declare class SecurityAssertionAspect implements cdk.IAspect {
99
+ private readonly props;
100
+ constructor(props: SecurityAssertionAspectProps);
101
+ /**
102
+ * CDK calls this for every node in the construct tree. The aspect is
103
+ * intentionally narrow: it inspects the synthesised graph for the
104
+ * specific invariants listed above. It does NOT validate the registry
105
+ * itself — the CLI owns that responsibility and emitting duplicate
106
+ * errors here would be noisy.
107
+ *
108
+ * @param node - The CDK construct being visited.
109
+ */
110
+ visit(node: IConstruct): void;
111
+ /**
112
+ * Walk every `CfnRoute` in the construct tree and verify the security
113
+ * invariants in one pass:
114
+ *
115
+ * 1. Routes declared as JWT-protected actually carry an `AuthorizerId`.
116
+ * Covers both legacy `defineApi` entries (`authType: 'jwt'`) and
117
+ * new-style action API exposures (`exposure.auth === 'required'`).
118
+ * 2. Routes declared as tenant-required include the canonical
119
+ * `/v1/tenants/{tenantId}/` placeholder in their path.
120
+ *
121
+ * Both checks fail synth by default; the authorizer check can be
122
+ * downgraded to a warning via {@link SecurityAssertionAspectProps.failOnMissingAuthorizer}
123
+ * for incremental rollouts.
124
+ */
125
+ private assertRouteSecurity;
126
+ /**
127
+ * Walk every `CfnUrl` in the construct tree and verify no action
128
+ * Lambda is reachable through a `Lambda::Url`. Wave 4 Task 4.2 (#4619)
129
+ * explicitly removed Function URL exposure because the resulting
130
+ * endpoints bypass API Gateway, the JWT authorizer, and the
131
+ * `backendAccess` / `exposure` policy gates.
132
+ *
133
+ * Health and readiness probes are explicitly exempted — they use
134
+ * `Lambda::Url` for cheap internal polling and the runtime never
135
+ * routes tenant traffic through them.
136
+ */
137
+ private assertNoFunctionUrls;
138
+ /**
139
+ * Walk every `CfnRoute` whose registry counterpart declares
140
+ * `auth: 'none'` or `authType: 'none'` and verify that the registry
141
+ * entry carries a `securityException.reason`. Public routes must
142
+ * document the exception so security reviewers can audit the
143
+ * relaxation.
144
+ */
145
+ private assertAnonymousRoutesHaveException;
146
+ /**
147
+ * Find the registry API entry that corresponds to a synthesised route.
148
+ * DomainStack prefixes every route with `/<domainId>`, so we strip that
149
+ * prefix before comparing paths.
150
+ *
151
+ * @param path - The route path including the domain prefix.
152
+ * @param method - Upper-case HTTP method.
153
+ * @returns The matching {@link ApiRegistryEntry} or `undefined`.
154
+ */
155
+ private findApiRegistryEntryForRoute;
156
+ /**
157
+ * Find the registry action whose `exposure.path` matches a synthesised
158
+ * route. Mirrors {@link findApiRegistryEntryForRoute} but for action API
159
+ * exposures.
160
+ */
161
+ private findActionRegistryEntryForRoute;
162
+ /**
163
+ * Predicate — does the registry entry imply that the route must
164
+ * include `/v1/tenants/{tenantId}/`?
165
+ *
166
+ * The legacy `defineApi` surface does not carry an explicit tenancy
167
+ * marker; we treat any non-anonymous API as tenant-required because
168
+ * the only legitimate JWT-protected routes are tenant-scoped. Routes
169
+ * with `authType: 'none'` are exempted — they are explicitly public.
170
+ */
171
+ private registryEntryRequiresTenant;
172
+ /**
173
+ * Predicate — does the API registry entry carry a securityException?
174
+ * Legacy `defineApi` rows do not have this field; we accept the
175
+ * registry absence and rely on the CLI validator to catch it before
176
+ * synth.
177
+ */
178
+ private hasSecurityException;
179
+ /**
180
+ * Predicate — does the action registry entry carry a securityException
181
+ * on its exposure block?
182
+ */
183
+ private hasActionSecurityException;
184
+ /**
185
+ * Predicate — does the path contain `/v1/tenants/{tenantId}/`?
186
+ *
187
+ * We do not use a regex because the placeholder is a literal string
188
+ * and the runtime extractor performs an identical literal match.
189
+ */
190
+ private hasTenantPlaceholder;
191
+ }
@@ -0,0 +1,297 @@
1
+ import * as cdk from 'aws-cdk-lib';
2
+ import { CfnRoute as Apigwv2CfnRoute } from 'aws-cdk-lib/aws-apigatewayv2';
3
+ import { CfnUrl as LambdaCfnUrl } from 'aws-cdk-lib/aws-lambda';
4
+ /**
5
+ * Result codes emitted by the aspect. Mirrored in the CLI validator so
6
+ * downstream tooling can correlate synth-time annotations with build-time
7
+ * validation errors. Each code corresponds to a single, narrowly-scoped
8
+ * invariant — see the per-method docstrings for the exact contract.
9
+ */
10
+ export const SECURITY_ASSERTION_CODES = {
11
+ /**
12
+ * A JWT-required route (api.authType='jwt' or action.exposure.auth='required')
13
+ * was synthesised without an attached `CfnAuthorizer` ID. Synth blocks.
14
+ */
15
+ MISSING_JWT_AUTHORIZER: 'SECURITY_MISSING_JWT_AUTHORIZER',
16
+ /**
17
+ * A tenancy-required route (api requiring a tenant, or action.exposure.tenancy='required')
18
+ * does not include the canonical tenant placeholder
19
+ * (`/v1/tenants/{tenantId}/`) in its path. The runtime cannot bind
20
+ * `ctx.tenant.id` from a path that does not carry the placeholder, so
21
+ * this would silently produce a tenant-less route. Synth blocks.
22
+ */
23
+ MISSING_TENANT_PATH: 'SECURITY_MISSING_TENANT_PATH',
24
+ /**
25
+ * An action Lambda is reachable through a `Lambda::Url` resource. This
26
+ * creates a public-or-IAM endpoint that bypasses API Gateway, the JWT
27
+ * authorizer, and the registry's exposure metadata. Synth blocks.
28
+ */
29
+ ACTION_FUNCTION_URL: 'SECURITY_ACTION_FUNCTION_URL',
30
+ /**
31
+ * An API route is declared `auth: 'none'` or `authType: 'none'` without
32
+ * a `securityException.reason`. Public routes must document the
33
+ * exception so security reviewers can audit the relaxation. Synth
34
+ * blocks — anonymous routes are the exception, not the default.
35
+ */
36
+ MISSING_SECURITY_EXCEPTION: 'SECURITY_MISSING_SECURITY_EXCEPTION',
37
+ };
38
+ /**
39
+ * CDK Aspect that performs deployment-time security assertions against
40
+ * the synthesised {@link DomainStack}.
41
+ *
42
+ * The aspect complements the CLI validator at `packages/domain-cli`. Both
43
+ * layers run from the same source of truth (`DomainRegistry`), but they
44
+ * fail at different points in the pipeline:
45
+ *
46
+ * - The CLI validator fails the build BEFORE synth, so the developer
47
+ * gets a structured error code in their terminal and CI fails on
48
+ * `mc-domain-module validate`.
49
+ * - The aspect fails synth AFTER the stack has been assembled. It can
50
+ * detect runtime regressions (e.g. a route accidentally synthesised
51
+ * without an authorizer because a feature flag silently skipped the
52
+ * wiring) that the CLI cannot see.
53
+ *
54
+ * Adding the aspect to a stack is a one-liner:
55
+ *
56
+ * ```ts
57
+ * cdk.Aspects.of(stack).add(new SecurityAssertionAspect({ registry }));
58
+ * ```
59
+ *
60
+ * The {@link DomainStack} constructor already wires this aspect in for
61
+ * every domain, so most code does not need to interact with the class
62
+ * directly.
63
+ */
64
+ export class SecurityAssertionAspect {
65
+ props;
66
+ constructor(props) {
67
+ this.props = {
68
+ registry: props.registry,
69
+ failOnMissingAuthorizer: props.failOnMissingAuthorizer ?? true,
70
+ failOnFunctionUrl: props.failOnFunctionUrl ?? true,
71
+ };
72
+ }
73
+ /**
74
+ * CDK calls this for every node in the construct tree. The aspect is
75
+ * intentionally narrow: it inspects the synthesised graph for the
76
+ * specific invariants listed above. It does NOT validate the registry
77
+ * itself — the CLI owns that responsibility and emitting duplicate
78
+ * errors here would be noisy.
79
+ *
80
+ * @param node - The CDK construct being visited.
81
+ */
82
+ visit(node) {
83
+ this.assertNoFunctionUrls(node);
84
+ this.assertRouteSecurity(node);
85
+ this.assertAnonymousRoutesHaveException(node);
86
+ }
87
+ /**
88
+ * Walk every `CfnRoute` in the construct tree and verify the security
89
+ * invariants in one pass:
90
+ *
91
+ * 1. Routes declared as JWT-protected actually carry an `AuthorizerId`.
92
+ * Covers both legacy `defineApi` entries (`authType: 'jwt'`) and
93
+ * new-style action API exposures (`exposure.auth === 'required'`).
94
+ * 2. Routes declared as tenant-required include the canonical
95
+ * `/v1/tenants/{tenantId}/` placeholder in their path.
96
+ *
97
+ * Both checks fail synth by default; the authorizer check can be
98
+ * downgraded to a warning via {@link SecurityAssertionAspectProps.failOnMissingAuthorizer}
99
+ * for incremental rollouts.
100
+ */
101
+ assertRouteSecurity(node) {
102
+ if (!(node instanceof Apigwv2CfnRoute))
103
+ return;
104
+ // CDK's generated L1 `CfnRoute` keeps the constructor-supplied `routeKey`
105
+ // on a private field that is not exposed via the public `.d.ts`. We
106
+ // reach it through a typed bracket cast so we get strong typing on
107
+ // everything else while still being able to inspect the synthesised
108
+ // route key during the aspect's visit.
109
+ const routeKey = node._routeKey ?? '';
110
+ const spaceIndex = routeKey.indexOf(' ');
111
+ if (spaceIndex === -1)
112
+ return;
113
+ const path = routeKey.slice(spaceIndex + 1);
114
+ const method = routeKey.slice(0, spaceIndex).toUpperCase();
115
+ const authorizerId = node.authorizerId;
116
+ const api = this.findApiRegistryEntryForRoute(path, method);
117
+ if (api) {
118
+ // Invariant 1a: JWT-required API has an authorizer
119
+ if (api.authType === 'jwt' && !authorizerId) {
120
+ const message = `[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_JWT_AUTHORIZER}: ` +
121
+ `API '${api.id}' declares authType='jwt' but the synthesised route ${routeKey} ` +
122
+ `has no AuthorizerId. Provide a Cognito user pool via DomainStackProps.userPoolArn + userPoolClientId.`;
123
+ if (this.props.failOnMissingAuthorizer) {
124
+ cdk.Annotations.of(node).addError(message);
125
+ }
126
+ else {
127
+ cdk.Annotations.of(node).addWarning(message);
128
+ }
129
+ }
130
+ // Invariant 2: tenant-required API path includes placeholder
131
+ if (this.registryEntryRequiresTenant(api) && !this.hasTenantPlaceholder(path)) {
132
+ cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_TENANT_PATH}: ` +
133
+ `API '${api.id}' (${method} ${path}) is tenant-required but its path does not include ` +
134
+ `the canonical '/v1/tenants/{tenantId}/' placeholder. Synth will fail.`);
135
+ }
136
+ }
137
+ const action = this.findActionRegistryEntryForRoute(path, method);
138
+ if (action && action.exposure.type === 'api') {
139
+ const exposure = action.exposure;
140
+ // Invariant 1b: action with required auth has an authorizer
141
+ if (exposure.auth === 'required' && !authorizerId) {
142
+ cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_JWT_AUTHORIZER}: ` +
143
+ `Action '${action.id}' declares exposure.auth='required' but the synthesised route ${routeKey} ` +
144
+ `has no AuthorizerId. Provide a Cognito user pool via DomainStackProps.userPoolArn + userPoolClientId.`);
145
+ }
146
+ // Invariant 2b: action with required tenancy has tenant placeholder
147
+ if (exposure.tenancy === 'required' && !this.hasTenantPlaceholder(path)) {
148
+ cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_TENANT_PATH}: ` +
149
+ `Action '${action.id}' declares exposure.tenancy='required' but its path (${path}) ` +
150
+ `does not include '/v1/tenants/{tenantId}/'. Synth will fail.`);
151
+ }
152
+ }
153
+ }
154
+ /**
155
+ * Walk every `CfnUrl` in the construct tree and verify no action
156
+ * Lambda is reachable through a `Lambda::Url`. Wave 4 Task 4.2 (#4619)
157
+ * explicitly removed Function URL exposure because the resulting
158
+ * endpoints bypass API Gateway, the JWT authorizer, and the
159
+ * `backendAccess` / `exposure` policy gates.
160
+ *
161
+ * Health and readiness probes are explicitly exempted — they use
162
+ * `Lambda::Url` for cheap internal polling and the runtime never
163
+ * routes tenant traffic through them.
164
+ */
165
+ assertNoFunctionUrls(node) {
166
+ if (!(node instanceof LambdaCfnUrl))
167
+ return;
168
+ const targetFn = node.targetFunctionArn
169
+ ?? node.props?.targetFunction?.functionArn;
170
+ if (!targetFn)
171
+ return;
172
+ // Health & readiness probes have a function name suffix; if the
173
+ // synthesised target is a recognised health/ready function, skip.
174
+ // The aspect's intent is to block *tenant-facing* endpoints.
175
+ const fnName = String(targetFn).split(':').pop() ?? '';
176
+ if (/health|ready/i.test(fnName))
177
+ return;
178
+ cdk.Annotations.of(node).addWarning(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.ACTION_FUNCTION_URL}: ` +
179
+ `Lambda::Url attached to '${fnName}'. Function URLs bypass API Gateway and the registry's ` +
180
+ `exposure policy. Only health/ready probes are permitted to use them; verify the target ` +
181
+ `function name explicitly.`);
182
+ if (this.props.failOnFunctionUrl) {
183
+ cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.ACTION_FUNCTION_URL}: ` +
184
+ `Lambda::Url attached to '${fnName}'. Synth will fail. See registry's action.exposure for the canonical route surface.`);
185
+ }
186
+ }
187
+ /**
188
+ * Walk every `CfnRoute` whose registry counterpart declares
189
+ * `auth: 'none'` or `authType: 'none'` and verify that the registry
190
+ * entry carries a `securityException.reason`. Public routes must
191
+ * document the exception so security reviewers can audit the
192
+ * relaxation.
193
+ */
194
+ assertAnonymousRoutesHaveException(node) {
195
+ if (!(node instanceof Apigwv2CfnRoute))
196
+ return;
197
+ const routeKey = node._routeKey ?? '';
198
+ const spaceIndex = routeKey.indexOf(' ');
199
+ if (spaceIndex === -1)
200
+ return;
201
+ const path = routeKey.slice(spaceIndex + 1);
202
+ const method = routeKey.slice(0, spaceIndex).toUpperCase();
203
+ const api = this.findApiRegistryEntryForRoute(path, method);
204
+ if (api && api.authType === 'none' && !this.hasSecurityException(api)) {
205
+ cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_SECURITY_EXCEPTION}: ` +
206
+ `API '${api.id}' (${method} ${path}) is anonymous (authType='none') but does not declare ` +
207
+ `a securityException.reason. Public routes must document the exception.`);
208
+ }
209
+ const action = this.findActionRegistryEntryForRoute(path, method);
210
+ if (action
211
+ && action.exposure.type === 'api'
212
+ && action.exposure.auth === 'none'
213
+ && !this.hasActionSecurityException(action)) {
214
+ cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_SECURITY_EXCEPTION}: ` +
215
+ `Action '${action.id}' declares exposure.auth='none' but does not declare ` +
216
+ `exposure.securityException.reason. Public routes must document the exception.`);
217
+ }
218
+ }
219
+ /**
220
+ * Find the registry API entry that corresponds to a synthesised route.
221
+ * DomainStack prefixes every route with `/<domainId>`, so we strip that
222
+ * prefix before comparing paths.
223
+ *
224
+ * @param path - The route path including the domain prefix.
225
+ * @param method - Upper-case HTTP method.
226
+ * @returns The matching {@link ApiRegistryEntry} or `undefined`.
227
+ */
228
+ findApiRegistryEntryForRoute(path, method) {
229
+ const domainId = this.props.registry.domain.id;
230
+ if (!path.startsWith(`/${domainId}`))
231
+ return undefined;
232
+ const trimmed = path.slice(`/${domainId}`.length) || '/';
233
+ return this.props.registry.apis.find(api => api.path === trimmed && api.method.toUpperCase() === method);
234
+ }
235
+ /**
236
+ * Find the registry action whose `exposure.path` matches a synthesised
237
+ * route. Mirrors {@link findApiRegistryEntryForRoute} but for action API
238
+ * exposures.
239
+ */
240
+ findActionRegistryEntryForRoute(path, method) {
241
+ const domainId = this.props.registry.domain.id;
242
+ if (!path.startsWith(`/${domainId}`))
243
+ return undefined;
244
+ const trimmed = path.slice(`/${domainId}`.length) || '/';
245
+ return this.props.registry.actions.find(action => {
246
+ if (action.exposure.type !== 'api')
247
+ return false;
248
+ const exposure = action.exposure;
249
+ return exposure.path === trimmed && exposure.method.toUpperCase() === method;
250
+ });
251
+ }
252
+ /**
253
+ * Predicate — does the registry entry imply that the route must
254
+ * include `/v1/tenants/{tenantId}/`?
255
+ *
256
+ * The legacy `defineApi` surface does not carry an explicit tenancy
257
+ * marker; we treat any non-anonymous API as tenant-required because
258
+ * the only legitimate JWT-protected routes are tenant-scoped. Routes
259
+ * with `authType: 'none'` are exempted — they are explicitly public.
260
+ */
261
+ registryEntryRequiresTenant(api) {
262
+ if (api.authType === 'none')
263
+ return false;
264
+ return true;
265
+ }
266
+ /**
267
+ * Predicate — does the API registry entry carry a securityException?
268
+ * Legacy `defineApi` rows do not have this field; we accept the
269
+ * registry absence and rely on the CLI validator to catch it before
270
+ * synth.
271
+ */
272
+ hasSecurityException(api) {
273
+ const entry = api;
274
+ const reason = entry.securityException?.reason;
275
+ return typeof reason === 'string' && reason.trim().length > 0;
276
+ }
277
+ /**
278
+ * Predicate — does the action registry entry carry a securityException
279
+ * on its exposure block?
280
+ */
281
+ hasActionSecurityException(action) {
282
+ if (action.exposure.type !== 'api')
283
+ return false;
284
+ const exposure = action.exposure;
285
+ const reason = exposure.securityException?.reason;
286
+ return typeof reason === 'string' && reason.trim().length > 0;
287
+ }
288
+ /**
289
+ * Predicate — does the path contain `/v1/tenants/{tenantId}/`?
290
+ *
291
+ * We do not use a regex because the placeholder is a literal string
292
+ * and the runtime extractor performs an identical literal match.
293
+ */
294
+ hasTenantPlaceholder(path) {
295
+ return path.includes('/v1/tenants/{tenantId}/');
296
+ }
297
+ }
@@ -5,6 +5,12 @@ import { IamPolicyBuilder } from '../iam/iam-policy-builder.js';
5
5
  import type { DomainRegistry } from '../registry.js';
6
6
  /**
7
7
  * Configuration properties for ActionConstruct.
8
+ *
9
+ * NOTE: Action Lambdas are NEVER exposed directly via Lambda Function URL.
10
+ * Per Wave 4 Task 4.2 (#4619), `backendAccess` / `exposure` are the canonical
11
+ * permissions model. External reachability for actions is granted only via
12
+ * API Gateway routes synthesized from the `exposure` field — never through
13
+ * an unauthenticated Function URL.
8
14
  */
9
15
  export interface ActionConstructProps {
10
16
  /** Domain registry containing all action entries. */
@@ -13,14 +19,18 @@ export interface ActionConstructProps {
13
19
  lambdaFactory: LambdaFactory;
14
20
  /** IAM policy builder for granting permissions to Lambda roles. */
15
21
  iamBuilder: IamPolicyBuilder;
16
- /**
17
- * Allowed origins for Function URL CORS on workspace-visible actions.
18
- * Defaults to ['*']. Set to specific dashboard URLs + localhost for production.
19
- */
20
- corsAllowedOrigins?: string[];
21
22
  }
22
23
  /**
23
- * CDK Construct that synthesises one Lambda per registered action, with optional Function URL for workspace-visible actions.
24
+ * CDK Construct that synthesises one Lambda per registered action.
25
+ *
26
+ * Action Lambdas are intentionally internal-only. They are invoked by:
27
+ * - `ctx.actions` (same-domain or cross-domain) — backed by `lambda:InvokeFunction`
28
+ * - API Gateway routes — synthesized by DomainStack from the action's
29
+ * `exposure.type === 'api'` metadata.
30
+ *
31
+ * They MUST NOT be exposed through Lambda Function URL with `authType: NONE`
32
+ * because that would create a public, unauthenticated endpoint bypassing
33
+ * API Gateway auth (JWT / IAM / Lambda authorizer) and tenant scoping.
24
34
  */
25
35
  export declare class ActionConstruct extends Construct {
26
36
  /** Lambda function per action id. */
@@ -1,7 +1,15 @@
1
- import * as lambda from 'aws-cdk-lib/aws-lambda';
2
1
  import { Construct } from 'constructs';
3
2
  /**
4
- * CDK Construct that synthesises one Lambda per registered action, with optional Function URL for workspace-visible actions.
3
+ * CDK Construct that synthesises one Lambda per registered action.
4
+ *
5
+ * Action Lambdas are intentionally internal-only. They are invoked by:
6
+ * - `ctx.actions` (same-domain or cross-domain) — backed by `lambda:InvokeFunction`
7
+ * - API Gateway routes — synthesized by DomainStack from the action's
8
+ * `exposure.type === 'api'` metadata.
9
+ *
10
+ * They MUST NOT be exposed through Lambda Function URL with `authType: NONE`
11
+ * because that would create a public, unauthenticated endpoint bypassing
12
+ * API Gateway auth (JWT / IAM / Lambda authorizer) and tenant scoping.
5
13
  */
6
14
  export class ActionConstruct extends Construct {
7
15
  /** Lambda function per action id. */
@@ -21,15 +29,6 @@ export class ActionConstruct extends Construct {
21
29
  props.iamBuilder.forAction().forEach((statement) => {
22
30
  fn.addToRolePolicy(statement);
23
31
  });
24
- if (entry.visibility === 'workspace') {
25
- fn.addFunctionUrl({
26
- authType: lambda.FunctionUrlAuthType.NONE,
27
- cors: {
28
- allowedOrigins: props.corsAllowedOrigins ?? ['*'],
29
- allowedMethods: [lambda.HttpMethod.ALL],
30
- },
31
- });
32
- }
33
32
  }
34
33
  }
35
34
  }
@@ -9,6 +9,27 @@ export interface HandlerEntry {
9
9
  id: string;
10
10
  /** Path to the handler file relative to the domain's dist directory. */
11
11
  handlerFile: string;
12
+ /**
13
+ * Optional override for the runtime adapter exported from
14
+ * `@mettlecast/domain-runtime` that wraps this handler's default export
15
+ * into a Lambda handler.
16
+ *
17
+ * When omitted, the adapter is inferred from `primitiveType`:
18
+ * - `api` → `createApiLambdaHandler`
19
+ * - `subscriber` → `createSubscriberLambdaHandler`
20
+ * - `job` → `createJobLambdaHandler`
21
+ * - `webhook` → `createWebhookLambdaHandler`
22
+ * - `action` → `createActionLambdaHandler`
23
+ * - `schedule` → uses the inline `hydrateCtx` envelope (no adapter factory).
24
+ *
25
+ * Only honoured in `dedicated: true` mode — grouped mode bundles all
26
+ * entries behind a single dispatcher and assumes a uniform adapter.
27
+ *
28
+ * Wave 4 Task 4.1: API-exposed actions set this to
29
+ * `'createExposedActionApiHandler'` so their dedicated Lambda receives raw
30
+ * HTTP API v2 events instead of the action envelope.
31
+ */
32
+ adapter?: string;
12
33
  }
13
34
  /** Props for creating grouped or dedicated Lambdas for a primitive type. */
14
35
  export interface GroupedLambdaProps {
@@ -39,6 +60,54 @@ export interface GroupedLambdaProps {
39
60
  /** Log retention in days. Default: 30. */
40
61
  logRetentionDays?: number;
41
62
  }
63
+ /**
64
+ * Per-primitive-type default adapter exported from `@mettlecast/domain-runtime`.
65
+ *
66
+ * Exported (Wave 7 Task 7.3, #4619) so unit tests can pin the adapter
67
+ * selection logic without instantiating CDK or esbuild. The mapping here
68
+ * is the source of truth for the default wrap used by
69
+ * `buildDedicatedEntryContent`; it does NOT cover the per-entry override
70
+ * used by API-exposed actions (see `EXPOSED_ACTION_ADAPTER`).
71
+ */
72
+ export declare const ADAPTER_BY_PRIMITIVE: Record<Exclude<PrimitiveType, 'schedule'>, string>;
73
+ /**
74
+ * Adapter used for API-exposed actions (Wave 4 Task 4.1, #4619).
75
+ *
76
+ * API-exposed actions receive raw HTTP API v2 events, so they must wrap
77
+ * with `createExposedActionApiHandler` instead of the envelope-based
78
+ * `createActionLambdaHandler` that internal actions use.
79
+ *
80
+ * Exported (Wave 7 Task 7.3) so tests can assert the override behaviour
81
+ * without depending on the published runtime's barrel.
82
+ */
83
+ export declare const EXPOSED_ACTION_ADAPTER = "createExposedActionApiHandler";
84
+ /**
85
+ * Pure, side-effect-free entry-content generator for a single dedicated-mode
86
+ * Lambda handler.
87
+ *
88
+ * Returns the TypeScript source string that `NodejsFunction` will bundle.
89
+ * Exported (Wave 7 Task 7.3, #4619) so unit tests can assert adapter
90
+ * selection and import wiring without paying the cost of esbuild bundling
91
+ * on every `DomainStack` synth.
92
+ *
93
+ * Note: Wave 7 Task 7.1 may extend this signature with `domainId` (and
94
+ * rewrite the action-adapter wrappers) so the runtime can plumb
95
+ * `definingDomain` / `domainId` into the dedicated Lambda. The narrow
96
+ * tests in `grouped-lambda-factory.test.ts` deliberately target only the
97
+ * parts that are stable across that change so they keep passing through
98
+ * the merge.
99
+ *
100
+ * @param handlerImportPath Relative path from the temp entry directory
101
+ * to the handler file. Must already be normalised
102
+ * to start with `./` or `../`.
103
+ * @param entry The handler entry whose `id` and `adapter`
104
+ * drive the export name and adapter choice.
105
+ * @param primitiveType The primitive type — selects the default
106
+ * adapter when `entry.adapter` is unset, and
107
+ * switches to the `hydrateCtx` envelope for
108
+ * schedules.
109
+ */
110
+ export declare function buildDedicatedEntryContent(handlerImportPath: string, entry: HandlerEntry, primitiveType: PrimitiveType, domainId: string): string;
42
111
  /**
43
112
  * Creates either a single grouped Lambda (default) or per-handler dedicated Lambdas.
44
113
  *