@mettlecast/domain-cdk-packer 0.2.59 → 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.
- package/dist/DomainStack.js +114 -33
- package/dist/__tests__/action-construct.test.d.ts +1 -0
- package/dist/__tests__/action-construct.test.js +159 -0
- package/dist/__tests__/domain-stack.test.js +234 -1
- package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.d.ts +11 -0
- package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.js +47 -0
- package/dist/__tests__/grouped-lambda-factory.test.d.ts +1 -0
- package/dist/__tests__/grouped-lambda-factory.test.js +147 -0
- package/dist/__tests__/registry.test.js +108 -0
- package/dist/__tests__/security-assertion-aspect.test.d.ts +1 -0
- package/dist/__tests__/security-assertion-aspect.test.js +200 -0
- package/dist/aspects/index.d.ts +4 -0
- package/dist/aspects/index.js +2 -0
- package/dist/aspects/security-assertion-aspect.d.ts +191 -0
- package/dist/aspects/security-assertion-aspect.js +297 -0
- package/dist/constructs/action-construct.d.ts +16 -6
- package/dist/constructs/action-construct.js +10 -11
- package/dist/grouped-lambda-factory.d.ts +69 -0
- package/dist/grouped-lambda-factory.js +99 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/registry.d.ts +110 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
*
|