@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
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. */
|
|
@@ -88,15 +95,50 @@ 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
|
+
const apiNeedsJwt = registry.apis.some(api => api.authType === 'jwt');
|
|
128
|
+
const actionNeedsJwt = registry.actions.some(a => a.exposure?.type === 'api' && a.exposure.auth === 'required');
|
|
129
|
+
const anyRouteRequiresJwt = apiNeedsJwt || actionNeedsJwt;
|
|
130
|
+
if (anyRouteRequiresJwt && !jwtAuth) {
|
|
131
|
+
cdk.Annotations.of(this).addError(`[DomainStack] Domain "${domainId}" declares one or more JWT-protected routes ` +
|
|
132
|
+
`(api.authType='jwt' or action.exposure.auth='required') but no Cognito user pool is ` +
|
|
133
|
+
`configured. Provide userPoolArn/userPoolId AND userPoolClientId to DomainStackProps.`);
|
|
134
|
+
}
|
|
135
|
+
// Issue #4662 Task D — deployment-time security assertions. The aspect
|
|
136
|
+
// walks the synthesised tree after the stack has been assembled and
|
|
137
|
+
// re-validates the same invariants the CLI checks at build time. It
|
|
138
|
+
// catches regressions where a route was silently synthesised without
|
|
139
|
+
// an authorizer (e.g. a feature flag skipped the wiring), or where an
|
|
140
|
+
// action Lambda was attached to a Function URL outside the registry.
|
|
141
|
+
cdk.Aspects.of(this).add(new SecurityAssertionAspect({ registry }));
|
|
100
142
|
// Optional WAF
|
|
101
143
|
if (enableWaf) {
|
|
102
144
|
new WafConstruct(this, 'Waf', { domainId, namePrefix: resourceBaseName, httpApi: this.httpApi });
|
|
@@ -138,12 +180,31 @@ export class DomainStack extends cdk.Stack {
|
|
|
138
180
|
const lambdas = [];
|
|
139
181
|
const byId = new Map();
|
|
140
182
|
for (const group of splitByOutboundAccess(entries)) {
|
|
141
|
-
|
|
183
|
+
// Force dedicated mode for action groups that contain any API-exposed
|
|
184
|
+
// action — the per-entry adapter override only takes effect in
|
|
185
|
+
// dedicated mode (grouped mode uses a single TIB_HANDLER_MAP dispatcher
|
|
186
|
+
// which assumes a uniform adapter across all entries). We detect
|
|
187
|
+
// api-exposed actions by their `adapter` override (set by the caller
|
|
188
|
+
// in the action block below), since the entry shape is intentionally
|
|
189
|
+
// primitive-agnostic.
|
|
190
|
+
const hasApiExposedAction = primitiveType === 'action'
|
|
191
|
+
&& group.entries.some(e => e.adapter === 'createExposedActionApiHandler');
|
|
192
|
+
const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated')
|
|
193
|
+
|| primitiveType === 'api'
|
|
194
|
+
|| hasApiExposedAction;
|
|
142
195
|
const groupLambdas = createGroupedLambdas(this, {
|
|
143
196
|
domainId,
|
|
144
197
|
domainRoot: registry.domainRoot,
|
|
145
198
|
primitiveType,
|
|
146
|
-
handlerEntries: group.entries.map(entry =>
|
|
199
|
+
handlerEntries: group.entries.map(entry => {
|
|
200
|
+
const handlerEntry = {
|
|
201
|
+
id: entry.id,
|
|
202
|
+
handlerFile: entry.handlerFile,
|
|
203
|
+
};
|
|
204
|
+
if (entry.adapter)
|
|
205
|
+
handlerEntry.adapter = entry.adapter;
|
|
206
|
+
return handlerEntry;
|
|
207
|
+
}),
|
|
147
208
|
environment,
|
|
148
209
|
eventBusArn,
|
|
149
210
|
dedicated,
|
|
@@ -234,23 +295,11 @@ export class DomainStack extends cdk.Stack {
|
|
|
234
295
|
// Add routes for each API entry
|
|
235
296
|
for (const api of registry.apis) {
|
|
236
297
|
const fn = apiLambdaById.get(api.id);
|
|
237
|
-
|
|
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') {
|
|
298
|
+
if (api.authType === 'api-key') {
|
|
247
299
|
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
300
|
}
|
|
253
|
-
|
|
301
|
+
const routeAuth = api.authType === 'jwt' ? jwtAuth : undefined;
|
|
302
|
+
addRouteToApi(this, fn, `/${domainId}${api.path}`, [toHttpMethod(api.method)], this.httpApi.httpApiId, routeAuth);
|
|
254
303
|
}
|
|
255
304
|
}
|
|
256
305
|
// Deploy webhooks as grouped Lambdas
|
|
@@ -420,7 +469,27 @@ export class DomainStack extends cdk.Stack {
|
|
|
420
469
|
}
|
|
421
470
|
// Deploy callable actions as grouped Lambdas
|
|
422
471
|
if (registry.actions.length > 0) {
|
|
423
|
-
|
|
472
|
+
// Map action entries to NetworkedRegistryEntry so we can attach a per-entry
|
|
473
|
+
// adapter override. API-exposed actions must use `createExposedActionApiHandler`
|
|
474
|
+
// because they receive raw HTTP API v2 events; internal actions use the
|
|
475
|
+
// default `createActionLambdaHandler` (envelope-based invocation through
|
|
476
|
+
// `ctx.actions`).
|
|
477
|
+
const actionEntries = registry.actions.map((action) => {
|
|
478
|
+
const entry = {
|
|
479
|
+
id: action.id,
|
|
480
|
+
handlerFile: action.handlerFile,
|
|
481
|
+
};
|
|
482
|
+
if (action.outboundAccess !== undefined)
|
|
483
|
+
entry.outboundAccess = action.outboundAccess;
|
|
484
|
+
if (action.deployment !== undefined)
|
|
485
|
+
entry.deployment = action.deployment;
|
|
486
|
+
if (action.exposure?.type === 'api') {
|
|
487
|
+
entry.adapter = 'createExposedActionApiHandler';
|
|
488
|
+
}
|
|
489
|
+
return entry;
|
|
490
|
+
});
|
|
491
|
+
const actionHandlers = createPrimitiveHandlers(actionEntries, 'action');
|
|
492
|
+
const actionLambdaById = actionHandlers.byId;
|
|
424
493
|
actionLambdas = actionHandlers.lambdas;
|
|
425
494
|
this.lambdaArns[`${domainId}-action`] = actionLambdas[0].functionArn;
|
|
426
495
|
const iamPolicies = iamBuilder.forAction([
|
|
@@ -436,6 +505,18 @@ export class DomainStack extends cdk.Stack {
|
|
|
436
505
|
resources: [dbSecretArn],
|
|
437
506
|
})));
|
|
438
507
|
}
|
|
508
|
+
// Generate API Gateway routes for actions whose exposure is `api`.
|
|
509
|
+
//
|
|
510
|
+
// `exposure.type === 'internal'` actions are intentionally NOT routed —
|
|
511
|
+
// they are reachable only through `ctx.actions` and exposing them would
|
|
512
|
+
// silently bypass the `backendAccess` permission gate.
|
|
513
|
+
for (const action of registry.actions) {
|
|
514
|
+
if (action.exposure?.type !== 'api')
|
|
515
|
+
continue;
|
|
516
|
+
const fn = actionLambdaById.get(action.id);
|
|
517
|
+
const routeAuth = action.exposure.auth === 'required' ? jwtAuth : undefined;
|
|
518
|
+
addRouteToApi(this, fn, `/${domainId}${action.exposure.path}`, [toHttpMethod(action.exposure.method)], this.httpApi.httpApiId, routeAuth);
|
|
519
|
+
}
|
|
439
520
|
}
|
|
440
521
|
// Grant all domain Lambdas read/write access to the per-domain table and bucket
|
|
441
522
|
const allDomainLambdas = [
|
|
@@ -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
|
+
apis: [], 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
|
+
});
|
|
@@ -5,6 +5,19 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { DomainStack } from '../DomainStack.js';
|
|
7
7
|
const DOMAIN_ROOT = path.join(process.cwd(), '.test-domain-cdk-packer');
|
|
8
|
+
// Wave 7 Task 7.3 (#4619): removed the runtime-export probe that used to
|
|
9
|
+
// gate the action-exposure block via `describe.skipIf(...)`. The probe
|
|
10
|
+
// returned `false` whenever the workspace-resolved `@mettlecast/domain-runtime`
|
|
11
|
+
// package lagged behind the integration branch, which silently masked
|
|
12
|
+
// regressions in the route/wrapper wiring that this block exists to catch.
|
|
13
|
+
//
|
|
14
|
+
// Coverage for the adapter-selection logic itself moved to
|
|
15
|
+
// `grouped-lambda-factory.test.ts` (pure, no CDK / esbuild, no dependency
|
|
16
|
+
// on the published runtime state). This file still exercises the
|
|
17
|
+
// end-to-end CDK wiring — route creation, JWT authorizer attachment, and
|
|
18
|
+
// the "no route for internal actions" guard — so the integration-level
|
|
19
|
+
// regressions the probe used to hide are now caught by the narrow unit
|
|
20
|
+
// tests instead.
|
|
8
21
|
const minimalRegistry = {
|
|
9
22
|
schemaVersion: '1',
|
|
10
23
|
domainRoot: DOMAIN_ROOT,
|
|
@@ -29,7 +42,12 @@ const minimalRegistry = {
|
|
|
29
42
|
};
|
|
30
43
|
const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
|
|
31
44
|
fs.mkdirSync(handlerDir, { recursive: true });
|
|
32
|
-
fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), 'export const getUsers = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n'
|
|
45
|
+
fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), 'export const getUsers = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
|
|
46
|
+
'export const createUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
|
|
47
|
+
'export const replaceUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
|
|
48
|
+
'export const patchUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
|
|
49
|
+
'export const deleteUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
|
|
50
|
+
'export const listThings = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n');
|
|
33
51
|
afterAll(() => {
|
|
34
52
|
fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
|
|
35
53
|
});
|
|
@@ -169,4 +187,219 @@ describe('DomainStack', () => {
|
|
|
169
187
|
template.hasOutput('DomainTenantScopedRoleArn', {});
|
|
170
188
|
});
|
|
171
189
|
});
|
|
190
|
+
/**
|
|
191
|
+
* Wave 4 Task 4.1 — fixes the route key bug where every non-GET method was
|
|
192
|
+
* silently coerced to "POST" and the JWT authorizer was never attached
|
|
193
|
+
* because the HttpJwtAuthorizer was never bound to a route.
|
|
194
|
+
*/
|
|
195
|
+
describe('route keys and JWT authorizer wiring (#4619)', () => {
|
|
196
|
+
const userPoolArn = 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz';
|
|
197
|
+
const userPoolClientId = 'test-client-id';
|
|
198
|
+
/**
|
|
199
|
+
* Build a registry whose API entries cover all HTTP method variants the
|
|
200
|
+
* route key bug previously broke. Each entry uses `authType: 'jwt'` so
|
|
201
|
+
* every method exercises the new JWT-wiring path through `addRouteToApi`.
|
|
202
|
+
*/
|
|
203
|
+
const multiMethodRegistry = {
|
|
204
|
+
...minimalRegistry,
|
|
205
|
+
apis: [
|
|
206
|
+
{ ...minimalRegistry.apis[0], id: 'get-users', path: '/users', method: 'GET', authType: 'jwt' },
|
|
207
|
+
{ ...minimalRegistry.apis[0], id: 'create-user', path: '/users', method: 'POST', authType: 'jwt' },
|
|
208
|
+
{ ...minimalRegistry.apis[0], id: 'replace-user', path: '/users/{id}', method: 'PUT', authType: 'jwt' },
|
|
209
|
+
{ ...minimalRegistry.apis[0], id: 'patch-user', path: '/users/{id}', method: 'PATCH', authType: 'jwt' },
|
|
210
|
+
{ ...minimalRegistry.apis[0], id: 'delete-user', path: '/users/{id}', method: 'DELETE', authType: 'jwt' },
|
|
211
|
+
{ ...minimalRegistry.apis[0], id: 'list-things', path: '/things', method: 'GET', authType: 'none' },
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
it('uses the actual HTTP method for each route key (GET/POST/PUT/PATCH/DELETE)', () => {
|
|
215
|
+
const app = new cdk.App();
|
|
216
|
+
const stack = new DomainStack(app, 'TestDomainStackMethods', {
|
|
217
|
+
registry: multiMethodRegistry,
|
|
218
|
+
eventBusArn,
|
|
219
|
+
userPoolArn,
|
|
220
|
+
userPoolClientId,
|
|
221
|
+
});
|
|
222
|
+
const template = Template.fromStack(stack);
|
|
223
|
+
const routes = template.findResources('AWS::ApiGatewayV2::Route');
|
|
224
|
+
const routeKeys = Object.values(routes).map(r => r.Properties.RouteKey);
|
|
225
|
+
// Every method must round-trip — the previous implementation coerced all
|
|
226
|
+
// non-GET methods to POST, so PUT/PATCH/DELETE were broken.
|
|
227
|
+
expect(routeKeys).toContain('GET /test-domain/users');
|
|
228
|
+
expect(routeKeys).toContain('POST /test-domain/users');
|
|
229
|
+
expect(routeKeys).toContain('PUT /test-domain/users/{id}');
|
|
230
|
+
expect(routeKeys).toContain('PATCH /test-domain/users/{id}');
|
|
231
|
+
expect(routeKeys).toContain('DELETE /test-domain/users/{id}');
|
|
232
|
+
});
|
|
233
|
+
it('sets AuthorizationType=JWT and AuthorizerId on jwt-auth routes', () => {
|
|
234
|
+
const app = new cdk.App();
|
|
235
|
+
const stack = new DomainStack(app, 'TestDomainStackRouteAuth', {
|
|
236
|
+
registry: multiMethodRegistry,
|
|
237
|
+
eventBusArn,
|
|
238
|
+
userPoolArn,
|
|
239
|
+
userPoolClientId,
|
|
240
|
+
});
|
|
241
|
+
const template = Template.fromStack(stack);
|
|
242
|
+
const routes = template.findResources('AWS::ApiGatewayV2::Route');
|
|
243
|
+
const jwtRoutes = Object.values(routes).filter(r => {
|
|
244
|
+
const key = r.Properties.RouteKey;
|
|
245
|
+
return key.endsWith('/users') || key.endsWith('/users/{id}');
|
|
246
|
+
});
|
|
247
|
+
// Every jwt-auth route must carry AuthorizationType and AuthorizerId.
|
|
248
|
+
expect(jwtRoutes.length).toBeGreaterThan(0);
|
|
249
|
+
for (const r of jwtRoutes) {
|
|
250
|
+
expect(r.Properties.AuthorizationType).toBe('JWT');
|
|
251
|
+
const authorizerId = r.Properties.AuthorizerId;
|
|
252
|
+
expect(authorizerId).toBeDefined();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
it('does not attach authorizer to non-jwt routes (authType=none)', () => {
|
|
256
|
+
const app = new cdk.App();
|
|
257
|
+
const stack = new DomainStack(app, 'TestDomainStackUnauthRoute', {
|
|
258
|
+
registry: multiMethodRegistry,
|
|
259
|
+
eventBusArn,
|
|
260
|
+
userPoolArn,
|
|
261
|
+
userPoolClientId,
|
|
262
|
+
});
|
|
263
|
+
const template = Template.fromStack(stack);
|
|
264
|
+
const routes = template.findResources('AWS::ApiGatewayV2::Route');
|
|
265
|
+
const unauthRoute = Object.values(routes).find(r => r.Properties.RouteKey === 'GET /test-domain/things');
|
|
266
|
+
expect(unauthRoute).toBeDefined();
|
|
267
|
+
expect(unauthRoute.Properties.AuthorizationType).toBeUndefined();
|
|
268
|
+
expect(unauthRoute.Properties.AuthorizerId).toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
it('emits a synth-time error annotation when JWT route is declared without Cognito config', () => {
|
|
271
|
+
const app = new cdk.App();
|
|
272
|
+
const stack = new DomainStack(app, 'TestDomainStackMissingPool', {
|
|
273
|
+
registry: multiMethodRegistry,
|
|
274
|
+
eventBusArn,
|
|
275
|
+
// No userPoolArn / userPoolId / userPoolClientId provided
|
|
276
|
+
});
|
|
277
|
+
// `Annotations.of(stack).errors` is not exposed; read the construct's
|
|
278
|
+
// metadata directly and filter for error-level entries.
|
|
279
|
+
const metadata = stack.node.metadata;
|
|
280
|
+
const errorMessages = metadata
|
|
281
|
+
.filter(m => m.type === 'aws:cdk:error')
|
|
282
|
+
.map(m => (typeof m.data === 'string' ? m.data : JSON.stringify(m.data)));
|
|
283
|
+
expect(errorMessages.some(a => a.includes('JWT-protected routes') && a.includes('Cognito'))).toBe(true);
|
|
284
|
+
});
|
|
285
|
+
it('does NOT emit the missing-pool error when authType=none', () => {
|
|
286
|
+
const app = new cdk.App();
|
|
287
|
+
const noJwtRegistry = {
|
|
288
|
+
...minimalRegistry,
|
|
289
|
+
apis: [{ ...minimalRegistry.apis[0], authType: 'none' }],
|
|
290
|
+
};
|
|
291
|
+
const stack = new DomainStack(app, 'TestDomainStackNoJwtNeeded', {
|
|
292
|
+
registry: noJwtRegistry,
|
|
293
|
+
eventBusArn,
|
|
294
|
+
});
|
|
295
|
+
const metadata = stack.node.metadata;
|
|
296
|
+
const errorMessages = metadata
|
|
297
|
+
.filter(m => m.type === 'aws:cdk:error')
|
|
298
|
+
.map(m => (typeof m.data === 'string' ? m.data : JSON.stringify(m.data)));
|
|
299
|
+
expect(errorMessages.some(a => a.includes('JWT-protected routes'))).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
/**
|
|
303
|
+
* Wave 4 Task 4.1 — generate API Gateway routes for actions whose
|
|
304
|
+
* `exposure.type === 'api'` and skip `exposure.type === 'internal'`.
|
|
305
|
+
*
|
|
306
|
+
* Wave 7 Task 7.3 (#4619) removed the runtime-export probe and the
|
|
307
|
+
* `describe.skipIf(...)` wrapper. The route-level assertions still need
|
|
308
|
+
* a full CDK synth so the L1 `CfnRoute` resources can be inspected, but
|
|
309
|
+
* adapter-selection coverage (the part most likely to regress when the
|
|
310
|
+
* runtime barrel changes) lives in `grouped-lambda-factory.test.ts`
|
|
311
|
+
* where it runs as a pure unit test in milliseconds. If the runtime
|
|
312
|
+
* barrel ever drops an export we expect to see this block fail with a
|
|
313
|
+
* bundling error rather than be silently skipped.
|
|
314
|
+
*/
|
|
315
|
+
describe('action exposure routing (#4619)', () => {
|
|
316
|
+
const actionHandlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
|
|
317
|
+
fs.mkdirSync(actionHandlerDir, { recursive: true });
|
|
318
|
+
fs.writeFileSync(path.join(actionHandlerDir, 'list-invoices.ts'), 'export const listInvoices = { id: "list-invoices", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/invoices", method: "GET", auth: "required", tenancy: "required" }, input: { parse: (x: unknown) => x }, output: { parse: (x: unknown) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n');
|
|
319
|
+
fs.writeFileSync(path.join(actionHandlerDir, 'charge-card.ts'), 'export const chargeCard = { id: "charge-card", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/charge", method: "POST", auth: "required", tenancy: "required" }, input: { parse: (x: unknown) => x }, output: { parse: (x: unknown) => x }, idempotent: true, handler: async () => ({ ok: true }) };\n');
|
|
320
|
+
fs.writeFileSync(path.join(actionHandlerDir, 'internal-helper.ts'), 'export const internalHelper = { id: "internal-helper", backendAccess: "private", exposure: { type: "internal" }, input: { parse: (x: unknown) => x }, output: { parse: (x: unknown) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n');
|
|
321
|
+
const registryWithActions = {
|
|
322
|
+
...minimalRegistry,
|
|
323
|
+
actions: [
|
|
324
|
+
{
|
|
325
|
+
id: 'list-invoices',
|
|
326
|
+
kind: 'action',
|
|
327
|
+
handlerFile: 'src/handlers/list-invoices.ts',
|
|
328
|
+
backendAccess: 'domain',
|
|
329
|
+
exposure: {
|
|
330
|
+
type: 'api',
|
|
331
|
+
path: '/v1/tenants/{tenantId}/invoices',
|
|
332
|
+
method: 'GET',
|
|
333
|
+
auth: 'required',
|
|
334
|
+
tenancy: 'required',
|
|
335
|
+
},
|
|
336
|
+
idempotent: false,
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
id: 'charge-card',
|
|
340
|
+
kind: 'action',
|
|
341
|
+
handlerFile: 'src/handlers/charge-card.ts',
|
|
342
|
+
backendAccess: 'domain',
|
|
343
|
+
exposure: {
|
|
344
|
+
type: 'api',
|
|
345
|
+
path: '/v1/tenants/{tenantId}/charge',
|
|
346
|
+
method: 'POST',
|
|
347
|
+
auth: 'required',
|
|
348
|
+
tenancy: 'required',
|
|
349
|
+
},
|
|
350
|
+
idempotent: true,
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
id: 'internal-helper',
|
|
354
|
+
kind: 'action',
|
|
355
|
+
handlerFile: 'src/handlers/internal-helper.ts',
|
|
356
|
+
backendAccess: 'private',
|
|
357
|
+
exposure: { type: 'internal' },
|
|
358
|
+
idempotent: false,
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
};
|
|
362
|
+
it('synthesises routes for API-exposed actions', () => {
|
|
363
|
+
const app = new cdk.App();
|
|
364
|
+
const stack = new DomainStack(app, 'TestDomainStackActionRoutes', {
|
|
365
|
+
registry: registryWithActions,
|
|
366
|
+
eventBusArn,
|
|
367
|
+
userPoolArn: 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz',
|
|
368
|
+
userPoolClientId: 'test-client-id',
|
|
369
|
+
});
|
|
370
|
+
const template = Template.fromStack(stack);
|
|
371
|
+
const routes = template.findResources('AWS::ApiGatewayV2::Route');
|
|
372
|
+
const routeKeys = Object.values(routes).map(r => r.Properties.RouteKey);
|
|
373
|
+
expect(routeKeys).toContain('GET /test-domain/v1/tenants/{tenantId}/invoices');
|
|
374
|
+
expect(routeKeys).toContain('POST /test-domain/v1/tenants/{tenantId}/charge');
|
|
375
|
+
});
|
|
376
|
+
it('does NOT create a route for internal exposure actions', () => {
|
|
377
|
+
const app = new cdk.App();
|
|
378
|
+
const stack = new DomainStack(app, 'TestDomainStackInternalOnly', {
|
|
379
|
+
registry: registryWithActions,
|
|
380
|
+
eventBusArn,
|
|
381
|
+
userPoolArn: 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz',
|
|
382
|
+
userPoolClientId: 'test-client-id',
|
|
383
|
+
});
|
|
384
|
+
const template = Template.fromStack(stack);
|
|
385
|
+
const routes = template.findResources('AWS::ApiGatewayV2::Route');
|
|
386
|
+
const routeKeys = Object.values(routes).map(r => r.Properties.RouteKey);
|
|
387
|
+
expect(routeKeys.some(k => k.includes('internal-helper'))).toBe(false);
|
|
388
|
+
});
|
|
389
|
+
it('attaches JWT authorizer to API-exposed action routes when auth=required', () => {
|
|
390
|
+
const app = new cdk.App();
|
|
391
|
+
const stack = new DomainStack(app, 'TestDomainStackActionJwt', {
|
|
392
|
+
registry: registryWithActions,
|
|
393
|
+
eventBusArn,
|
|
394
|
+
userPoolArn: 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz',
|
|
395
|
+
userPoolClientId: 'test-client-id',
|
|
396
|
+
});
|
|
397
|
+
const template = Template.fromStack(stack);
|
|
398
|
+
const routes = template.findResources('AWS::ApiGatewayV2::Route');
|
|
399
|
+
const actionRoute = Object.values(routes).find(r => r.Properties.RouteKey === 'POST /test-domain/v1/tenants/{tenantId}/charge');
|
|
400
|
+
expect(actionRoute).toBeDefined();
|
|
401
|
+
expect(actionRoute.Properties.AuthorizationType).toBe('JWT');
|
|
402
|
+
expect(actionRoute.Properties.AuthorizerId).toBeDefined();
|
|
403
|
+
});
|
|
404
|
+
});
|
|
172
405
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave 7 Task 7.1 (#4619) — generated dedicated Lambda wrapper tests.
|
|
3
|
+
*
|
|
4
|
+
* The dedicated-mode bundler writes an entry module that wires a domain action
|
|
5
|
+
* export into a runtime adapter. For action adapters the wiring is non-trivial:
|
|
6
|
+
* `createActionLambdaHandler` takes `(registry, options)` and
|
|
7
|
+
* `createExposedActionApiHandler` takes `(action, options)`. These assertions
|
|
8
|
+
* use the pure entry-content helper so they do not race with other tests that
|
|
9
|
+
* also write `.tib-domain-entries` artifacts.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|