@mettlecast/domain-cdk-packer 0.2.60 → 0.2.62
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.d.ts +5 -0
- package/dist/DomainStack.js +145 -67
- 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 +315 -15
- 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 +146 -0
- package/dist/__tests__/lambda-factory.test.js +7 -7
- package/dist/__tests__/registry.test.js +111 -4
- package/dist/__tests__/security-assertion-aspect.test.d.ts +1 -0
- package/dist/__tests__/security-assertion-aspect.test.js +222 -0
- package/dist/aspects/index.d.ts +4 -0
- package/dist/aspects/index.js +2 -0
- package/dist/aspects/security-assertion-aspect.d.ts +161 -0
- package/dist/aspects/security-assertion-aspect.js +226 -0
- package/dist/constructs/action-construct.d.ts +16 -6
- package/dist/constructs/action-construct.js +10 -11
- package/dist/constructs/api-construct.d.ts +10 -2
- package/dist/constructs/api-construct.js +25 -5
- package/dist/grouped-lambda-factory.d.ts +69 -1
- package/dist/grouped-lambda-factory.js +98 -10
- package/dist/iam/iam-policy-builder.js +0 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/pack-domain.d.ts +2 -0
- package/dist/pack-domain.js +9 -3
- package/dist/registry.d.ts +98 -47
- package/package.json +1 -1
package/dist/DomainStack.d.ts
CHANGED
|
@@ -82,6 +82,11 @@ export interface DomainStackProps extends cdk.StackProps {
|
|
|
82
82
|
* Defaults to the second segment of the stack name; falls back to `dev` when not derivable.
|
|
83
83
|
*/
|
|
84
84
|
envCode?: string;
|
|
85
|
+
/**
|
|
86
|
+
* When true, disables the auto-generated per-domain CloudWatch dashboard.
|
|
87
|
+
* Existing dashboards will be removed on the next CDK deploy.
|
|
88
|
+
*/
|
|
89
|
+
disableCloudWatchDashboards?: boolean;
|
|
85
90
|
}
|
|
86
91
|
export declare class DomainStack extends cdk.Stack {
|
|
87
92
|
/** Shared HTTP API for routing API and webhook requests. */
|
package/dist/DomainStack.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import * as cdk from 'aws-cdk-lib';
|
|
2
2
|
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
|
|
3
|
-
import * as apigwv2integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
|
4
|
-
import * as apigwv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
|
|
5
3
|
import * as events from 'aws-cdk-lib/aws-events';
|
|
6
4
|
import * as eventsTargets from 'aws-cdk-lib/aws-events-targets';
|
|
7
5
|
import * as sqs from 'aws-cdk-lib/aws-sqs';
|
|
@@ -20,12 +18,9 @@ import { WafConstruct } from './constructs/waf-construct.js';
|
|
|
20
18
|
import { AlarmConstruct } from './constructs/alarm-construct.js';
|
|
21
19
|
import { CanaryConstruct } from './constructs/canary-construct.js';
|
|
22
20
|
import { domainResourceName } from './naming.js';
|
|
23
|
-
|
|
24
|
-
* Top-level CDK Stack that composes all domain constructs from a single DomainRegistry input.
|
|
25
|
-
* Uses grouped Lambdas for each primitive type to reduce deployment artifact size.
|
|
26
|
-
*/
|
|
21
|
+
import { SecurityAssertionAspect } from './aspects/security-assertion-aspect.js';
|
|
27
22
|
/** L1 helper: adds a CfnRoute + CfnIntegration to an existing HTTP API. */
|
|
28
|
-
function addRouteToApi(scope, fn, path, methods, apiId) {
|
|
23
|
+
function addRouteToApi(scope, fn, path, methods, apiId, auth) {
|
|
29
24
|
const id = path.replace(/[^a-zA-Z0-9]/g, '_').replace(/^_/, 'api') + '_' + methods[0].toLowerCase();
|
|
30
25
|
const integ = new apigwv2.CfnIntegration(scope, 'Integ_' + id, {
|
|
31
26
|
apiId,
|
|
@@ -37,12 +32,24 @@ function addRouteToApi(scope, fn, path, methods, apiId) {
|
|
|
37
32
|
principal: new iam.ServicePrincipal('apigateway.amazonaws.com'),
|
|
38
33
|
sourceArn: 'arn:aws:execute-api:' + cdk.Aws.REGION + ':' + cdk.Aws.ACCOUNT_ID + ':' + apiId + '/*',
|
|
39
34
|
});
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
35
|
+
// Use the actual HTTP method (supports GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS/ANY).
|
|
36
|
+
// Previous implementation only handled GET vs POST and silently coerced every
|
|
37
|
+
// other method to POST, breaking PUT/PATCH/DELETE routes.
|
|
38
|
+
const routeKey = `${methods[0]} ${path}`;
|
|
39
|
+
const routeProps = auth
|
|
40
|
+
? {
|
|
41
|
+
apiId,
|
|
42
|
+
routeKey,
|
|
43
|
+
target: 'integrations/' + integ.ref,
|
|
44
|
+
authorizationType: auth.authorizationType,
|
|
45
|
+
authorizerId: auth.authorizerId,
|
|
46
|
+
}
|
|
47
|
+
: {
|
|
48
|
+
apiId,
|
|
49
|
+
routeKey,
|
|
50
|
+
target: 'integrations/' + integ.ref,
|
|
51
|
+
};
|
|
52
|
+
new apigwv2.CfnRoute(scope, 'Route_' + id, routeProps);
|
|
46
53
|
}
|
|
47
54
|
export class DomainStack extends cdk.Stack {
|
|
48
55
|
/** Shared HTTP API for routing API and webhook requests. */
|
|
@@ -57,7 +64,7 @@ export class DomainStack extends cdk.Stack {
|
|
|
57
64
|
*/
|
|
58
65
|
constructor(scope, id, props) {
|
|
59
66
|
super(scope, id, props);
|
|
60
|
-
const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, userPoolArn, userPoolId, userPoolClientId, internalSubnetSelection, internetSubnetSelection, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials } = props;
|
|
67
|
+
const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, userPoolArn, userPoolId, userPoolClientId, internalSubnetSelection, internetSubnetSelection, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials, disableCloudWatchDashboards } = props;
|
|
61
68
|
const vpc = props.vpc ?? (props.vpcId
|
|
62
69
|
? ec2.Vpc.fromVpcAttributes(this, 'SharedVpc', {
|
|
63
70
|
vpcId: props.vpcId,
|
|
@@ -88,15 +95,53 @@ export class DomainStack extends cdk.Stack {
|
|
|
88
95
|
...(allowCredentials ? { allowCredentials: true } : {}),
|
|
89
96
|
},
|
|
90
97
|
});
|
|
91
|
-
// Wire Cognito JWT authorizer when user pool is provided
|
|
98
|
+
// Wire Cognito JWT authorizer when user pool is provided.
|
|
99
|
+
//
|
|
100
|
+
// We use the L2 `HttpAuthorizer` (not `HttpJwtAuthorizer`) because it eagerly
|
|
101
|
+
// creates the underlying `CfnAuthorizer` and exposes `authorizerId` as a
|
|
102
|
+
// concrete string. `HttpJwtAuthorizer` lazily binds through `bind()` and
|
|
103
|
+
// throws if `authorizerId` is read before binding — that prevented the
|
|
104
|
+
// existing L1 `addRouteToApi` from ever attaching an authorizer, which is
|
|
105
|
+
// the bug Wave 4 Task 4.1 fixes.
|
|
92
106
|
let jwtAuthorizer;
|
|
93
107
|
if ((userPoolId || userPoolArn) && userPoolClientId) {
|
|
94
108
|
const region = cdk.Stack.of(this).region;
|
|
95
109
|
const resolvedUserPoolId = userPoolId ?? cdk.Fn.select(1, cdk.Fn.split('/', userPoolArn));
|
|
96
|
-
jwtAuthorizer = new
|
|
110
|
+
jwtAuthorizer = new apigwv2.HttpAuthorizer(this, 'CognitoAuthorizer', {
|
|
111
|
+
httpApi: this.httpApi,
|
|
112
|
+
authorizerName: 'CognitoAuthorizer',
|
|
113
|
+
type: apigwv2.HttpAuthorizerType.JWT,
|
|
114
|
+
identitySource: ['$request.header.Authorization'],
|
|
97
115
|
jwtAudience: [userPoolClientId],
|
|
116
|
+
jwtIssuer: `https://cognito-idp.${region}.amazonaws.com/${resolvedUserPoolId}`,
|
|
98
117
|
});
|
|
99
118
|
}
|
|
119
|
+
// Pre-resolve authorizer metadata for `addRouteToApi` — `CfnRoute` consumes
|
|
120
|
+
// a concrete `{ authorizerId, authorizationType }` tuple, not the L2 authorizer.
|
|
121
|
+
const jwtAuth = jwtAuthorizer
|
|
122
|
+
? { authorizerId: jwtAuthorizer.authorizerId, authorizationType: apigwv2.HttpAuthorizerType.JWT }
|
|
123
|
+
: undefined;
|
|
124
|
+
// Determine whether any route in this domain requires a JWT authorizer.
|
|
125
|
+
// If so and `jwtAuth` is undefined, emit a synth-time error so missing Cognito
|
|
126
|
+
// configuration fails fast instead of producing an unauthenticated route.
|
|
127
|
+
//
|
|
128
|
+
// Issue #4689: only API-exposed actions (`exposure.auth === 'required'`)
|
|
129
|
+
// contribute to this check. Legacy `registry.apis` rows are always
|
|
130
|
+
// empty in fresh registries, so checking them would never fire.
|
|
131
|
+
const actionNeedsJwt = registry.actions.some(a => a.exposure?.type === 'api' && a.exposure.auth === 'required');
|
|
132
|
+
const anyRouteRequiresJwt = actionNeedsJwt;
|
|
133
|
+
if (anyRouteRequiresJwt && !jwtAuth) {
|
|
134
|
+
cdk.Annotations.of(this).addError(`[DomainStack] Domain "${domainId}" declares one or more JWT-protected routes ` +
|
|
135
|
+
`(action.exposure.auth='required') but no Cognito user pool is ` +
|
|
136
|
+
`configured. Provide userPoolArn/userPoolId AND userPoolClientId to DomainStackProps.`);
|
|
137
|
+
}
|
|
138
|
+
// Issue #4662 Task D — deployment-time security assertions. The aspect
|
|
139
|
+
// walks the synthesised tree after the stack has been assembled and
|
|
140
|
+
// re-validates the same invariants the CLI checks at build time. It
|
|
141
|
+
// catches regressions where a route was silently synthesised without
|
|
142
|
+
// an authorizer (e.g. a feature flag skipped the wiring), or where an
|
|
143
|
+
// action Lambda was attached to a Function URL outside the registry.
|
|
144
|
+
cdk.Aspects.of(this).add(new SecurityAssertionAspect({ registry }));
|
|
100
145
|
// Optional WAF
|
|
101
146
|
if (enableWaf) {
|
|
102
147
|
new WafConstruct(this, 'Waf', { domainId, namePrefix: resourceBaseName, httpApi: this.httpApi });
|
|
@@ -138,12 +183,30 @@ export class DomainStack extends cdk.Stack {
|
|
|
138
183
|
const lambdas = [];
|
|
139
184
|
const byId = new Map();
|
|
140
185
|
for (const group of splitByOutboundAccess(entries)) {
|
|
141
|
-
|
|
186
|
+
// Force dedicated mode for action groups that contain any API-exposed
|
|
187
|
+
// action — the per-entry adapter override only takes effect in
|
|
188
|
+
// dedicated mode (grouped mode uses a single TIB_HANDLER_MAP dispatcher
|
|
189
|
+
// which assumes a uniform adapter across all entries). We detect
|
|
190
|
+
// api-exposed actions by their `adapter` override (set by the caller
|
|
191
|
+
// in the action block below), since the entry shape is intentionally
|
|
192
|
+
// primitive-agnostic.
|
|
193
|
+
const hasApiExposedAction = primitiveType === 'action'
|
|
194
|
+
&& group.entries.some(e => e.adapter === 'createExposedActionApiHandler');
|
|
195
|
+
const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated')
|
|
196
|
+
|| hasApiExposedAction;
|
|
142
197
|
const groupLambdas = createGroupedLambdas(this, {
|
|
143
198
|
domainId,
|
|
144
199
|
domainRoot: registry.domainRoot,
|
|
145
200
|
primitiveType,
|
|
146
|
-
handlerEntries: group.entries.map(entry =>
|
|
201
|
+
handlerEntries: group.entries.map(entry => {
|
|
202
|
+
const handlerEntry = {
|
|
203
|
+
id: entry.id,
|
|
204
|
+
handlerFile: entry.handlerFile,
|
|
205
|
+
};
|
|
206
|
+
if (entry.adapter)
|
|
207
|
+
handlerEntry.adapter = entry.adapter;
|
|
208
|
+
return handlerEntry;
|
|
209
|
+
}),
|
|
147
210
|
environment,
|
|
148
211
|
eventBusArn,
|
|
149
212
|
dedicated,
|
|
@@ -205,7 +268,6 @@ export class DomainStack extends cdk.Stack {
|
|
|
205
268
|
});
|
|
206
269
|
environment['DOMAIN_TENANT_ROLE_ARN'] = tenantScopedRole.roleArn;
|
|
207
270
|
// Pre-declare lambda group variables
|
|
208
|
-
let apiLambdas = [];
|
|
209
271
|
let webhookLambdas = [];
|
|
210
272
|
let subscriberLambdas = [];
|
|
211
273
|
let scheduleLambdas = [];
|
|
@@ -214,45 +276,18 @@ export class DomainStack extends cdk.Stack {
|
|
|
214
276
|
// Track all DLQs for alarm construct
|
|
215
277
|
const allDlqs = [];
|
|
216
278
|
// Deploy API endpoints as grouped Lambdas
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
apiLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
230
|
-
actions: ['secretsmanager:GetSecretValue'],
|
|
231
|
-
resources: [dbSecretArn],
|
|
232
|
-
})));
|
|
233
|
-
}
|
|
234
|
-
// Add routes for each API entry
|
|
235
|
-
for (const api of registry.apis) {
|
|
236
|
-
const fn = apiLambdaById.get(api.id);
|
|
237
|
-
const apiRouteBase = {
|
|
238
|
-
path: api.path,
|
|
239
|
-
methods: [toHttpMethod(api.method)],
|
|
240
|
-
integration: new apigwv2integrations.HttpLambdaIntegration(`Apis${toPascalCase(api.id)}Integration`, fn),
|
|
241
|
-
};
|
|
242
|
-
let apiRoute;
|
|
243
|
-
if (api.authType === 'jwt' && jwtAuthorizer) {
|
|
244
|
-
apiRoute = { ...apiRouteBase, authorizer: jwtAuthorizer };
|
|
245
|
-
}
|
|
246
|
-
else if (api.authType === 'api-key') {
|
|
247
|
-
cdk.Annotations.of(this).addWarning(`[DomainStack] API "${api.id}" uses authType "api-key" which is not supported on HTTP API v2 — route is UNAUTHENTICATED. Switch to "jwt" or implement a Lambda authorizer.`);
|
|
248
|
-
apiRoute = apiRouteBase;
|
|
249
|
-
}
|
|
250
|
-
else {
|
|
251
|
-
apiRoute = apiRouteBase;
|
|
252
|
-
}
|
|
253
|
-
addRouteToApi(this, fn, `/${domainId}${api.path}`, [toHttpMethod(api.method)], this.httpApi.httpApiId);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
279
|
+
//
|
|
280
|
+
// Issue #4689: the legacy `defineApi` primitive was removed. The
|
|
281
|
+
// canonical HTTP endpoint surface is `actions[]` with
|
|
282
|
+
// `exposure.type === 'api'`. Each API-exposed action is wrapped with
|
|
283
|
+
// `createExposedActionApiHandler` (see grouped-lambda-factory) and
|
|
284
|
+
// mounted on the HTTP API in the action-routing loop below. The
|
|
285
|
+
// legacy `registry.apis` block has been removed because the builder
|
|
286
|
+
// no longer populates that slot in fresh registries.
|
|
287
|
+
//
|
|
288
|
+
// We intentionally do NOT iterate `registry.apis` here; doing so
|
|
289
|
+
// would re-introduce legacy routing that is no longer reachable
|
|
290
|
+
// from the action-first source-of-truth.
|
|
256
291
|
// Deploy webhooks as grouped Lambdas
|
|
257
292
|
if (registry.webhooks.length > 0) {
|
|
258
293
|
// Create shared dedupe table for webhooks
|
|
@@ -420,7 +455,27 @@ export class DomainStack extends cdk.Stack {
|
|
|
420
455
|
}
|
|
421
456
|
// Deploy callable actions as grouped Lambdas
|
|
422
457
|
if (registry.actions.length > 0) {
|
|
423
|
-
|
|
458
|
+
// Map action entries to NetworkedRegistryEntry so we can attach a per-entry
|
|
459
|
+
// adapter override. API-exposed actions must use `createExposedActionApiHandler`
|
|
460
|
+
// because they receive raw HTTP API v2 events; internal actions use the
|
|
461
|
+
// default `createActionLambdaHandler` (envelope-based invocation through
|
|
462
|
+
// `ctx.actions`).
|
|
463
|
+
const actionEntries = registry.actions.map((action) => {
|
|
464
|
+
const entry = {
|
|
465
|
+
id: action.id,
|
|
466
|
+
handlerFile: action.handlerFile,
|
|
467
|
+
};
|
|
468
|
+
if (action.outboundAccess !== undefined)
|
|
469
|
+
entry.outboundAccess = action.outboundAccess;
|
|
470
|
+
if (action.deployment !== undefined)
|
|
471
|
+
entry.deployment = action.deployment;
|
|
472
|
+
if (action.exposure?.type === 'api') {
|
|
473
|
+
entry.adapter = 'createExposedActionApiHandler';
|
|
474
|
+
}
|
|
475
|
+
return entry;
|
|
476
|
+
});
|
|
477
|
+
const actionHandlers = createPrimitiveHandlers(actionEntries, 'action');
|
|
478
|
+
const actionLambdaById = actionHandlers.byId;
|
|
424
479
|
actionLambdas = actionHandlers.lambdas;
|
|
425
480
|
this.lambdaArns[`${domainId}-action`] = actionLambdas[0].functionArn;
|
|
426
481
|
const iamPolicies = iamBuilder.forAction([
|
|
@@ -436,10 +491,22 @@ export class DomainStack extends cdk.Stack {
|
|
|
436
491
|
resources: [dbSecretArn],
|
|
437
492
|
})));
|
|
438
493
|
}
|
|
494
|
+
// Generate API Gateway routes for actions whose exposure is `api`.
|
|
495
|
+
//
|
|
496
|
+
// `exposure.type === 'internal'` actions are intentionally NOT routed —
|
|
497
|
+
// they are reachable only through `ctx.actions` and exposing them would
|
|
498
|
+
// silently bypass the `backendAccess` permission gate.
|
|
499
|
+
for (const action of registry.actions) {
|
|
500
|
+
if (action.exposure?.type !== 'api')
|
|
501
|
+
continue;
|
|
502
|
+
const fn = actionLambdaById.get(action.id);
|
|
503
|
+
const routeAuth = action.exposure.auth === 'required' ? jwtAuth : undefined;
|
|
504
|
+
addRouteToApi(this, fn, `/${domainId}${action.exposure.path}`, [toHttpMethod(action.exposure.method)], this.httpApi.httpApiId, routeAuth);
|
|
505
|
+
}
|
|
439
506
|
}
|
|
440
507
|
// Grant all domain Lambdas read/write access to the per-domain table and bucket
|
|
441
508
|
const allDomainLambdas = [
|
|
442
|
-
...
|
|
509
|
+
...webhookLambdas, ...subscriberLambdas,
|
|
443
510
|
...scheduleLambdas, ...jobLambdas, ...actionLambdas,
|
|
444
511
|
];
|
|
445
512
|
for (const fn of allDomainLambdas) {
|
|
@@ -509,19 +576,30 @@ export class DomainStack extends cdk.Stack {
|
|
|
509
576
|
addRouteToApi(this, healthConstruct.healthFn, `/${domainId}/_health`, [apigwv2.HttpMethod.GET], this.httpApi.httpApiId);
|
|
510
577
|
addRouteToApi(this, healthConstruct.readyFn, `/${domainId}/_ready`, [apigwv2.HttpMethod.GET], this.httpApi.httpApiId);
|
|
511
578
|
// Create CloudWatch dashboard for all primitives
|
|
579
|
+
//
|
|
580
|
+
// Issue #4689: HTTP endpoints are now action entries with
|
|
581
|
+
// `exposure.type === 'api'`. We tag those actions with the
|
|
582
|
+
// `primitiveClass: 'api'` so the dashboard can still group them
|
|
583
|
+
// separately from internal-only actions.
|
|
512
584
|
const allEndpoints = [
|
|
513
|
-
...registry.
|
|
514
|
-
|
|
585
|
+
...registry.actions
|
|
586
|
+
.filter(a => a.exposure?.type === 'api')
|
|
587
|
+
.map(a => ({ id: a.id, primitiveClass: 'api' })),
|
|
588
|
+
...registry.actions
|
|
589
|
+
.filter(a => a.exposure?.type !== 'api')
|
|
590
|
+
.map(a => ({ id: a.id, primitiveClass: 'action' })),
|
|
515
591
|
...registry.subscribers.map(s => ({ id: s.id, primitiveClass: 'subscriber' })),
|
|
516
592
|
...registry.jobs.map(j => ({ id: j.id, primitiveClass: 'job' })),
|
|
517
593
|
...registry.webhooks.map(w => ({ id: w.id, primitiveClass: 'webhook' })),
|
|
518
594
|
...registry.schedules.map(sc => ({ id: sc.id, primitiveClass: 'schedule' })),
|
|
519
595
|
];
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
596
|
+
if (!disableCloudWatchDashboards) {
|
|
597
|
+
new DashboardConstruct(this, 'Dashboard', {
|
|
598
|
+
domainId,
|
|
599
|
+
envCode: this.stackName.split('-')[1] ?? 'dev',
|
|
600
|
+
endpoints: allEndpoints,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
525
603
|
new cdk.CfnOutput(this, 'DomainTableName', {
|
|
526
604
|
value: domainTable.tableName,
|
|
527
605
|
description: 'Per-domain DynamoDB table name',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import * as cdk from 'aws-cdk-lib';
|
|
3
|
+
import { Template } from 'aws-cdk-lib/assertions';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { ActionConstruct } from '../constructs/action-construct.js';
|
|
7
|
+
import { LambdaFactory } from '../lambda-factory.js';
|
|
8
|
+
import { IamPolicyBuilder } from '../iam/iam-policy-builder.js';
|
|
9
|
+
const DOMAIN_ROOT = path.join(process.cwd(), '.test-action-construct');
|
|
10
|
+
const baseRegistry = {
|
|
11
|
+
schemaVersion: '1',
|
|
12
|
+
domainRoot: DOMAIN_ROOT,
|
|
13
|
+
domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
|
|
14
|
+
webhooks: [], subscribers: [], schedules: [], jobs: [],
|
|
15
|
+
actions: [], integrations: [], events: [],
|
|
16
|
+
};
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
const handlerDir = path.join(DOMAIN_ROOT, 'src', 'actions');
|
|
19
|
+
fs.mkdirSync(handlerDir, { recursive: true });
|
|
20
|
+
fs.writeFileSync(path.join(handlerDir, 'noop.ts'), 'export const handler = async () => ({ statusCode: 200 });\n');
|
|
21
|
+
fs.writeFileSync(path.join(handlerDir, 'workspace.ts'), 'export const handler = async () => ({ statusCode: 200 });\n');
|
|
22
|
+
});
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* Wave 4 Task 4.2 (#4619):
|
|
28
|
+
* Action Lambdas must never be exposed directly through a Lambda Function URL.
|
|
29
|
+
* External reachability is granted ONLY via API Gateway routes synthesized from
|
|
30
|
+
* the action's `exposure` field. Function URLs with `authType: NONE` would
|
|
31
|
+
* create public, unauthenticated endpoints that bypass auth and tenancy.
|
|
32
|
+
*/
|
|
33
|
+
describe('ActionConstruct (Wave 4 Task 4.2 — no public Function URLs)', () => {
|
|
34
|
+
const buildStack = (actions) => {
|
|
35
|
+
const app = new cdk.App();
|
|
36
|
+
const stack = new cdk.Stack(app, 'TestActionStack');
|
|
37
|
+
const registry = { ...baseRegistry, actions };
|
|
38
|
+
const factory = new LambdaFactory({ scope: stack, registry, domainRoot: registry.domainRoot });
|
|
39
|
+
const iamBuilder = new IamPolicyBuilder();
|
|
40
|
+
const construct = new ActionConstruct(stack, 'Actions', { registry, lambdaFactory: factory, iamBuilder });
|
|
41
|
+
return { stack, template: Template.fromStack(stack), construct };
|
|
42
|
+
};
|
|
43
|
+
it('synthesises one Lambda per action', () => {
|
|
44
|
+
const { template } = buildStack([
|
|
45
|
+
{ id: 'noop', kind: 'action', handlerFile: 'src/actions/noop.ts', backendAccess: 'domain', exposure: { type: 'internal' }, idempotent: false },
|
|
46
|
+
]);
|
|
47
|
+
const lambdas = Object.entries(template.findResources('AWS::Lambda::Function'))
|
|
48
|
+
.filter(([id]) => !id.startsWith('LogRetention'));
|
|
49
|
+
expect(lambdas).toHaveLength(1);
|
|
50
|
+
expect(lambdas[0][0]).toContain('NoopFn');
|
|
51
|
+
});
|
|
52
|
+
it('does NOT create a Lambda::Url resource for any action', () => {
|
|
53
|
+
const { template } = buildStack([
|
|
54
|
+
{ id: 'noop', kind: 'action', handlerFile: 'src/actions/noop.ts', backendAccess: 'domain', exposure: { type: 'internal' }, idempotent: false },
|
|
55
|
+
]);
|
|
56
|
+
template.resourceCountIs('AWS::Lambda::Url', 0);
|
|
57
|
+
});
|
|
58
|
+
it('does NOT create a Lambda::Url even when legacy visibility === "workspace"', () => {
|
|
59
|
+
// Legacy entries with the deprecated `visibility: 'workspace'` must NOT
|
|
60
|
+
// resurrect Function URL exposure now that the action-first migration is in place.
|
|
61
|
+
const { template } = buildStack([
|
|
62
|
+
{
|
|
63
|
+
id: 'workspace-action',
|
|
64
|
+
kind: 'action',
|
|
65
|
+
handlerFile: 'src/actions/workspace.ts',
|
|
66
|
+
backendAccess: 'domain',
|
|
67
|
+
exposure: { type: 'internal' },
|
|
68
|
+
idempotent: false,
|
|
69
|
+
visibility: 'workspace',
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
template.resourceCountIs('AWS::Lambda::Url', 0);
|
|
73
|
+
const lambdas = Object.entries(template.findResources('AWS::Lambda::Function'))
|
|
74
|
+
.filter(([id]) => !id.startsWith('LogRetention'));
|
|
75
|
+
expect(lambdas).toHaveLength(1);
|
|
76
|
+
expect(lambdas[0][0]).toContain('WorkspaceActionFn');
|
|
77
|
+
});
|
|
78
|
+
it('does NOT create a Lambda::Url when an action exposes via API Gateway (exposure.type === "api")', () => {
|
|
79
|
+
// API-exposed actions are routed by DomainStack via API Gateway, not by Function URL.
|
|
80
|
+
const { template } = buildStack([
|
|
81
|
+
{
|
|
82
|
+
id: 'api-action',
|
|
83
|
+
kind: 'action',
|
|
84
|
+
handlerFile: 'src/actions/noop.ts',
|
|
85
|
+
backendAccess: 'domain',
|
|
86
|
+
exposure: { type: 'api', path: '/v1/example', method: 'POST', auth: 'required', tenancy: 'required' },
|
|
87
|
+
idempotent: false,
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
template.resourceCountIs('AWS::Lambda::Url', 0);
|
|
91
|
+
});
|
|
92
|
+
it('does NOT attach any Lambda::Permission that allows public (apigateway) principal invocation', () => {
|
|
93
|
+
// Defensive: even without a Function URL, the construct must not add
|
|
94
|
+
// resource-based Lambda permissions that would let API Gateway invoke
|
|
95
|
+
// the function without going through the synthesized routes in DomainStack.
|
|
96
|
+
const { template, construct } = buildStack([
|
|
97
|
+
{ id: 'noop', kind: 'action', handlerFile: 'src/actions/noop.ts', backendAccess: 'domain', exposure: { type: 'internal' }, idempotent: false },
|
|
98
|
+
]);
|
|
99
|
+
expect(construct.functions.size).toBe(1);
|
|
100
|
+
template.resourceCountIs('AWS::Lambda::Url', 0);
|
|
101
|
+
const permissions = Object.values(template.findResources('AWS::Lambda::Permission'));
|
|
102
|
+
// No Function URL permission allowed
|
|
103
|
+
const hasUrlPermission = permissions.some(p => p.Properties.FunctionUrlAuthType !== undefined);
|
|
104
|
+
expect(hasUrlPermission).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
/**
|
|
108
|
+
* Tenant-scoped storage IAM (defence-in-depth, Wave 4 Task 4.2).
|
|
109
|
+
*
|
|
110
|
+
* Verifies the IamPolicyBuilder.forTenantScopedStorage output uses
|
|
111
|
+
* PrincipalTag conditions to restrict DynamoDB LeadingKeys and S3 object
|
|
112
|
+
* prefixes to the calling session's tenantId.
|
|
113
|
+
*/
|
|
114
|
+
describe('IamPolicyBuilder.forTenantScopedStorage (defence-in-depth)', () => {
|
|
115
|
+
const params = {
|
|
116
|
+
tableArn: 'arn:aws:dynamodb:eu-north-1:123456789012:table/test-domain',
|
|
117
|
+
bucketArn: 'arn:aws:s3:::test-domain',
|
|
118
|
+
};
|
|
119
|
+
it('restricts DynamoDB LeadingKeys to ${aws:PrincipalTag/tenantId}', () => {
|
|
120
|
+
const builder = new IamPolicyBuilder();
|
|
121
|
+
const statements = builder.forTenantScopedStorage(params);
|
|
122
|
+
const ddb = statements.find(s => (s.actions ?? []).some(a => a.startsWith('dynamodb:')));
|
|
123
|
+
expect(ddb).toBeDefined();
|
|
124
|
+
expect(ddb.resources).toContain(params.tableArn);
|
|
125
|
+
const leadingKeys = ddb.conditions?.['ForAllValues:StringEquals']?.['dynamodb:LeadingKeys'];
|
|
126
|
+
expect(leadingKeys).toEqual(['${aws:PrincipalTag/tenantId}']);
|
|
127
|
+
});
|
|
128
|
+
it('restricts S3 object operations to ${aws:PrincipalTag/tenantId}/* prefix', () => {
|
|
129
|
+
const builder = new IamPolicyBuilder();
|
|
130
|
+
const statements = builder.forTenantScopedStorage(params);
|
|
131
|
+
const s3Objects = statements.find(s => (s.actions ?? []).some(a => a === 's3:GetObject' || a === 's3:PutObject' || a === 's3:DeleteObject'));
|
|
132
|
+
expect(s3Objects).toBeDefined();
|
|
133
|
+
expect(s3Objects.resources).toEqual([
|
|
134
|
+
`${params.bucketArn}/\${aws:PrincipalTag/tenantId}/*`,
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
it('restricts S3:ListBucket to the current tenant prefix via s3:prefix condition', () => {
|
|
138
|
+
const builder = new IamPolicyBuilder();
|
|
139
|
+
const statements = builder.forTenantScopedStorage(params);
|
|
140
|
+
const listBucket = statements.find(s => (s.actions ?? []).includes('s3:ListBucket'));
|
|
141
|
+
expect(listBucket).toBeDefined();
|
|
142
|
+
expect(listBucket.resources).toEqual([params.bucketArn]);
|
|
143
|
+
const prefixCondition = listBucket.conditions
|
|
144
|
+
?.StringLike?.['s3:prefix'];
|
|
145
|
+
expect(prefixCondition).toEqual(['${aws:PrincipalTag/tenantId}/*']);
|
|
146
|
+
});
|
|
147
|
+
it('does NOT grant bucket-wide S3 access (no unconditional ${bucketArn} resource for object ops)', () => {
|
|
148
|
+
const builder = new IamPolicyBuilder();
|
|
149
|
+
const statements = builder.forTenantScopedStorage(params);
|
|
150
|
+
for (const s of statements) {
|
|
151
|
+
const acts = (s.actions ?? []);
|
|
152
|
+
const isObjectOp = acts.some(a => a === 's3:GetObject' || a === 's3:PutObject' || a === 's3:DeleteObject');
|
|
153
|
+
if (isObjectOp) {
|
|
154
|
+
// The only resource granted must be the tenant-scoped prefix, not the bare bucket.
|
|
155
|
+
expect(s.resources).not.toContain(params.bucketArn);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|