@mettlecast/domain-cdk-packer 0.2.53 → 0.2.54
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 +4 -0
- package/dist/DomainStack.js +67 -93
- package/dist/__tests__/domain-stack.test.js +26 -77
- package/dist/grouped-lambda-factory.d.ts +4 -0
- package/dist/grouped-lambda-factory.js +28 -18
- package/dist/pack-domain.d.ts +4 -0
- package/dist/pack-domain.js +2 -0
- package/dist/registry.d.ts +14 -0
- package/package.json +1 -1
package/dist/DomainStack.d.ts
CHANGED
|
@@ -27,6 +27,10 @@ export interface DomainStackProps extends cdk.StackProps {
|
|
|
27
27
|
vpc?: ec2.IVpc;
|
|
28
28
|
/** Security group for domain Lambdas — must allow egress to Aurora SG. */
|
|
29
29
|
lambdaSg?: ec2.ISecurityGroup;
|
|
30
|
+
/** Subnet selection for VPC-attached handlers that should remain internal-only. */
|
|
31
|
+
internalSubnetSelection?: ec2.SubnetSelection;
|
|
32
|
+
/** Subnet selection for VPC-attached handlers that need NAT-backed internet egress. */
|
|
33
|
+
internetSubnetSelection?: ec2.SubnetSelection;
|
|
30
34
|
/** Shared HTTP API Gateway — when provided, domain routes are added here instead of creating a separate API. */
|
|
31
35
|
httpApi?: apigwv2.IHttpApi;
|
|
32
36
|
/** Path to domain source root � defaults to ../../domains/{domainId}. */
|
package/dist/DomainStack.js
CHANGED
|
@@ -55,10 +55,8 @@ export class DomainStack extends cdk.Stack {
|
|
|
55
55
|
*/
|
|
56
56
|
constructor(scope, id, props) {
|
|
57
57
|
super(scope, id, props);
|
|
58
|
-
const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, userPoolArn, userPoolId, userPoolClientId, vpc, lambdaSg, httpApi, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials } = props;
|
|
58
|
+
const { registry, eventBusArn, eventBusName, databaseUrl, dbSecretArn, userPoolArn, userPoolId, userPoolClientId, vpc, lambdaSg, internalSubnetSelection, internetSubnetSelection, httpApi, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials } = props;
|
|
59
59
|
const domainId = registry.domain.id;
|
|
60
|
-
const domainRoot = props.domainRoot ?? "../../../../domains/" + domainId;
|
|
61
|
-
const vpcProps = vpc ? { vpc, securityGroups: lambdaSg ? [lambdaSg] : undefined } : {};
|
|
62
60
|
const allowedOrigins = corsAllowedOrigins ?? ['*'];
|
|
63
61
|
this.httpApi = httpApi ?? new apigwv2.HttpApi(this, 'HttpApi', {
|
|
64
62
|
apiName: `${domainId}-api`,
|
|
@@ -103,6 +101,50 @@ export class DomainStack extends cdk.Stack {
|
|
|
103
101
|
if (dbSecretArn) {
|
|
104
102
|
environment.DB_SECRET_ARN = dbSecretArn;
|
|
105
103
|
}
|
|
104
|
+
const resolveSubnetSelection = (access) => {
|
|
105
|
+
if (access === 'internet')
|
|
106
|
+
return internetSubnetSelection ?? internalSubnetSelection;
|
|
107
|
+
return internalSubnetSelection ?? internetSubnetSelection;
|
|
108
|
+
};
|
|
109
|
+
const normaliseOutboundAccess = (access) => access === 'internet' ? 'internet' : 'internal';
|
|
110
|
+
const splitByOutboundAccess = (entries) => {
|
|
111
|
+
const buckets = { internal: [], internet: [] };
|
|
112
|
+
for (const entry of entries)
|
|
113
|
+
buckets[normaliseOutboundAccess(entry.outboundAccess)].push(entry);
|
|
114
|
+
return Object.entries(buckets).filter(([, grouped]) => grouped.length > 0).map(([outboundAccess, grouped]) => ({ outboundAccess, entries: grouped }));
|
|
115
|
+
};
|
|
116
|
+
const createPrimitiveHandlers = (entries, primitiveType) => {
|
|
117
|
+
const lambdas = [];
|
|
118
|
+
const byId = new Map();
|
|
119
|
+
for (const group of splitByOutboundAccess(entries)) {
|
|
120
|
+
const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated') || primitiveType === 'api';
|
|
121
|
+
const groupLambdas = createGroupedLambdas(this, {
|
|
122
|
+
domainId,
|
|
123
|
+
domainRoot: registry.domainRoot,
|
|
124
|
+
primitiveType,
|
|
125
|
+
handlerEntries: group.entries.map(entry => ({ id: entry.id, handlerFile: entry.handlerFile })),
|
|
126
|
+
environment,
|
|
127
|
+
eventBusArn,
|
|
128
|
+
dedicated,
|
|
129
|
+
reservedConcurrency,
|
|
130
|
+
logRetentionDays: logRetentionDays ?? 30,
|
|
131
|
+
lambdaGroupId: group.outboundAccess,
|
|
132
|
+
...(vpc ? {
|
|
133
|
+
vpc,
|
|
134
|
+
securityGroups: lambdaSg ? [lambdaSg] : undefined,
|
|
135
|
+
subnetSelection: resolveSubnetSelection(group.outboundAccess),
|
|
136
|
+
} : {}),
|
|
137
|
+
});
|
|
138
|
+
lambdas.push(...groupLambdas);
|
|
139
|
+
if (dedicated) {
|
|
140
|
+
group.entries.forEach((entry, index) => byId.set(entry.id, groupLambdas[index]));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
group.entries.forEach(entry => byId.set(entry.id, groupLambdas[0]));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { lambdas, byId };
|
|
147
|
+
};
|
|
106
148
|
// Per-domain DynamoDB table — PK=tenantId (tenant is primary partition), SK=entity key
|
|
107
149
|
const domainTable = new dynamodb.Table(this, 'DomainTable', {
|
|
108
150
|
tableName: `tib-${domainId}`,
|
|
@@ -152,21 +194,9 @@ export class DomainStack extends cdk.Stack {
|
|
|
152
194
|
const allDlqs = [];
|
|
153
195
|
// Deploy API endpoints as grouped Lambdas
|
|
154
196
|
if (registry.apis.length > 0) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
handlerEntries: registry.apis.map(api => ({
|
|
159
|
-
id: api.id,
|
|
160
|
-
handlerFile: api.handlerFile,
|
|
161
|
-
})),
|
|
162
|
-
environment,
|
|
163
|
-
eventBusArn,
|
|
164
|
-
dedicated: true,
|
|
165
|
-
domainRoot,
|
|
166
|
-
reservedConcurrency,
|
|
167
|
-
logRetentionDays: logRetentionDays ?? 30,
|
|
168
|
-
...vpcProps,
|
|
169
|
-
});
|
|
197
|
+
const apiHandlers = createPrimitiveHandlers(registry.apis, 'api');
|
|
198
|
+
const apiLambdaById = apiHandlers.byId;
|
|
199
|
+
apiLambdas = apiHandlers.lambdas;
|
|
170
200
|
this.lambdaArns[`${domainId}-api`] = apiLambdas[0].functionArn;
|
|
171
201
|
// Wire each API Lambda to the HttpApi
|
|
172
202
|
const iamPolicies = iamBuilder.forApi();
|
|
@@ -182,7 +212,7 @@ export class DomainStack extends cdk.Stack {
|
|
|
182
212
|
}
|
|
183
213
|
// Add routes for each API entry
|
|
184
214
|
for (const api of registry.apis) {
|
|
185
|
-
const fn =
|
|
215
|
+
const fn = apiLambdaById.get(api.id);
|
|
186
216
|
const apiRouteBase = {
|
|
187
217
|
path: api.path,
|
|
188
218
|
methods: [toHttpMethod(api.method)],
|
|
@@ -211,20 +241,9 @@ export class DomainStack extends cdk.Stack {
|
|
|
211
241
|
timeToLiveAttribute: 'expiresAt',
|
|
212
242
|
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
213
243
|
});
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
handlerEntries: registry.webhooks.map(webhook => ({
|
|
218
|
-
id: webhook.id,
|
|
219
|
-
handlerFile: webhook.handlerFile,
|
|
220
|
-
})),
|
|
221
|
-
environment,
|
|
222
|
-
eventBusArn,
|
|
223
|
-
dedicated: registry.webhooks.some(webhook => webhook.deployment?.isolation === 'dedicated'),
|
|
224
|
-
reservedConcurrency,
|
|
225
|
-
logRetentionDays: logRetentionDays ?? 30,
|
|
226
|
-
...vpcProps,
|
|
227
|
-
});
|
|
244
|
+
const webhookHandlers = createPrimitiveHandlers(registry.webhooks, 'webhook');
|
|
245
|
+
const webhookLambdaById = webhookHandlers.byId;
|
|
246
|
+
webhookLambdas = webhookHandlers.lambdas;
|
|
228
247
|
this.lambdaArns[`${domainId}-webhook`] = webhookLambdas[0].functionArn;
|
|
229
248
|
// Wire each webhook Lambda to the HttpApi
|
|
230
249
|
const iamPolicies = iamBuilder.forWebhook({ dedupeTableArn: dedupeTable.tableArn });
|
|
@@ -241,26 +260,15 @@ export class DomainStack extends cdk.Stack {
|
|
|
241
260
|
}
|
|
242
261
|
// Add routes for each webhook entry
|
|
243
262
|
for (const webhook of registry.webhooks) {
|
|
244
|
-
const fn =
|
|
263
|
+
const fn = webhookLambdaById.get(webhook.id);
|
|
245
264
|
addRouteToApi(this, fn, `/${domainId}${webhook.path}`, [apigwv2.HttpMethod.POST], this.httpApi.httpApiId);
|
|
246
265
|
}
|
|
247
266
|
}
|
|
248
267
|
// Deploy event subscribers as grouped Lambdas
|
|
249
268
|
if (registry.subscribers.length > 0) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
handlerEntries: registry.subscribers.map(subscriber => ({
|
|
254
|
-
id: subscriber.id,
|
|
255
|
-
handlerFile: subscriber.handlerFile,
|
|
256
|
-
})),
|
|
257
|
-
environment,
|
|
258
|
-
eventBusArn,
|
|
259
|
-
dedicated: registry.subscribers.some(subscriber => subscriber.deployment?.isolation === 'dedicated'),
|
|
260
|
-
reservedConcurrency,
|
|
261
|
-
logRetentionDays: logRetentionDays ?? 30,
|
|
262
|
-
...vpcProps,
|
|
263
|
-
});
|
|
269
|
+
const subscriberHandlers = createPrimitiveHandlers(registry.subscribers, 'subscriber');
|
|
270
|
+
const subscriberLambdaById = subscriberHandlers.byId;
|
|
271
|
+
subscriberLambdas = subscriberHandlers.lambdas;
|
|
264
272
|
this.lambdaArns[`${domainId}-subscriber`] = subscriberLambdas[0].functionArn;
|
|
265
273
|
// Wire EventBridge → SQS → Lambda for each subscriber
|
|
266
274
|
for (const subscriber of registry.subscribers) {
|
|
@@ -283,7 +291,7 @@ export class DomainStack extends cdk.Stack {
|
|
|
283
291
|
encryptionMasterKey: enableCmk ? cmkKey : undefined,
|
|
284
292
|
});
|
|
285
293
|
// Add SQS as event source for the first grouped Lambda
|
|
286
|
-
const fn =
|
|
294
|
+
const fn = subscriberLambdaById.get(subscriber.id);
|
|
287
295
|
fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
|
|
288
296
|
batchSize: 10,
|
|
289
297
|
maxConcurrency: subscriber.concurrency ?? 5,
|
|
@@ -308,20 +316,9 @@ export class DomainStack extends cdk.Stack {
|
|
|
308
316
|
}
|
|
309
317
|
// Deploy scheduled tasks as grouped Lambdas
|
|
310
318
|
if (registry.schedules.length > 0) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
handlerEntries: registry.schedules.map(schedule => ({
|
|
315
|
-
id: schedule.id,
|
|
316
|
-
handlerFile: schedule.handlerFile,
|
|
317
|
-
})),
|
|
318
|
-
environment,
|
|
319
|
-
eventBusArn,
|
|
320
|
-
dedicated: registry.schedules.some(schedule => schedule.deployment?.isolation === 'dedicated'),
|
|
321
|
-
reservedConcurrency,
|
|
322
|
-
logRetentionDays: logRetentionDays ?? 30,
|
|
323
|
-
...vpcProps,
|
|
324
|
-
});
|
|
319
|
+
const scheduleHandlers = createPrimitiveHandlers(registry.schedules, 'schedule');
|
|
320
|
+
const scheduleLambdaById = scheduleHandlers.byId;
|
|
321
|
+
scheduleLambdas = scheduleHandlers.lambdas;
|
|
325
322
|
this.lambdaArns[`${domainId}-schedule`] = scheduleLambdas[0].functionArn;
|
|
326
323
|
const iamPolicies = iamBuilder.forSchedule();
|
|
327
324
|
scheduleLambdas.forEach((fn) => {
|
|
@@ -337,7 +334,7 @@ export class DomainStack extends cdk.Stack {
|
|
|
337
334
|
// Wire EventBridge Scheduler rules to Lambda
|
|
338
335
|
for (const schedule of registry.schedules) {
|
|
339
336
|
const pascalId = toPascalCase(schedule.id);
|
|
340
|
-
const fn =
|
|
337
|
+
const fn = scheduleLambdaById.get(schedule.id);
|
|
341
338
|
// Create a dedicated IAM role for the EventBridge Scheduler to assume
|
|
342
339
|
const schedulerRole = new iam.Role(this, `${pascalId}SchedulerRole`, {
|
|
343
340
|
assumedBy: new iam.ServicePrincipal('scheduler.amazonaws.com'),
|
|
@@ -359,20 +356,9 @@ export class DomainStack extends cdk.Stack {
|
|
|
359
356
|
}
|
|
360
357
|
// Deploy background jobs as grouped Lambdas
|
|
361
358
|
if (registry.jobs.length > 0) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
handlerEntries: registry.jobs.map(job => ({
|
|
366
|
-
id: job.id,
|
|
367
|
-
handlerFile: job.handlerFile,
|
|
368
|
-
})),
|
|
369
|
-
environment,
|
|
370
|
-
eventBusArn,
|
|
371
|
-
dedicated: registry.jobs.some(job => job.deployment?.isolation === 'dedicated'),
|
|
372
|
-
reservedConcurrency,
|
|
373
|
-
logRetentionDays: logRetentionDays ?? 30,
|
|
374
|
-
...vpcProps,
|
|
375
|
-
});
|
|
359
|
+
const jobHandlers = createPrimitiveHandlers(registry.jobs, 'job');
|
|
360
|
+
const jobLambdaById = jobHandlers.byId;
|
|
361
|
+
jobLambdas = jobHandlers.lambdas;
|
|
376
362
|
this.lambdaArns[`${domainId}-job`] = jobLambdas[0].functionArn;
|
|
377
363
|
// Wire SQS → Lambda for each job
|
|
378
364
|
for (const job of registry.jobs) {
|
|
@@ -395,7 +381,7 @@ export class DomainStack extends cdk.Stack {
|
|
|
395
381
|
encryptionMasterKey: enableCmk ? cmkKey : undefined,
|
|
396
382
|
});
|
|
397
383
|
// Add SQS as event source for the first grouped Lambda
|
|
398
|
-
const fn =
|
|
384
|
+
const fn = jobLambdaById.get(job.id);
|
|
399
385
|
fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
|
|
400
386
|
batchSize: 1,
|
|
401
387
|
}));
|
|
@@ -413,20 +399,8 @@ export class DomainStack extends cdk.Stack {
|
|
|
413
399
|
}
|
|
414
400
|
// Deploy callable actions as grouped Lambdas
|
|
415
401
|
if (registry.actions.length > 0) {
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
primitiveType: 'action',
|
|
419
|
-
handlerEntries: registry.actions.map(action => ({
|
|
420
|
-
id: action.id,
|
|
421
|
-
handlerFile: action.handlerFile,
|
|
422
|
-
})),
|
|
423
|
-
environment,
|
|
424
|
-
eventBusArn,
|
|
425
|
-
dedicated: registry.actions.some(action => action.deployment?.isolation === 'dedicated'),
|
|
426
|
-
reservedConcurrency,
|
|
427
|
-
logRetentionDays: logRetentionDays ?? 30,
|
|
428
|
-
...vpcProps,
|
|
429
|
-
});
|
|
402
|
+
const actionHandlers = createPrimitiveHandlers(registry.actions, 'action');
|
|
403
|
+
actionLambdas = actionHandlers.lambdas;
|
|
430
404
|
this.lambdaArns[`${domainId}-action`] = actionLambdas[0].functionArn;
|
|
431
405
|
const iamPolicies = iamBuilder.forAction([
|
|
432
406
|
`arn:aws:lambda:${this.region}:${this.account}:function:TIB-*-domain-*-action-*`,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { describe, it, expect,
|
|
1
|
+
import { describe, it, expect, afterAll } from 'vitest';
|
|
2
2
|
import * as cdk from 'aws-cdk-lib';
|
|
3
3
|
import { Template } from 'aws-cdk-lib/assertions';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { DomainStack } from '../DomainStack.js';
|
|
7
|
-
const DOMAIN_ROOT = '
|
|
7
|
+
const DOMAIN_ROOT = path.join(process.cwd(), '.test-domain-cdk-packer');
|
|
8
8
|
const minimalRegistry = {
|
|
9
9
|
schemaVersion: '1',
|
|
10
10
|
domainRoot: DOMAIN_ROOT,
|
|
@@ -27,11 +27,9 @@ const minimalRegistry = {
|
|
|
27
27
|
integrations: [],
|
|
28
28
|
events: [],
|
|
29
29
|
};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), 'export const handler = async () => ({ statusCode: 200 });\n');
|
|
34
|
-
});
|
|
30
|
+
const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
|
|
31
|
+
fs.mkdirSync(handlerDir, { recursive: true });
|
|
32
|
+
fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), 'export const getUsers = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n');
|
|
35
33
|
afterAll(() => {
|
|
36
34
|
fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
|
|
37
35
|
});
|
|
@@ -95,17 +93,9 @@ describe('DomainStack', () => {
|
|
|
95
93
|
dbSecretArn,
|
|
96
94
|
});
|
|
97
95
|
const template = Template.fromStack(stack);
|
|
98
|
-
template.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
Match: {
|
|
102
|
-
stringLike: {
|
|
103
|
-
DB_SECRET_ARN: dbSecretArn,
|
|
104
|
-
},
|
|
105
|
-
},
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
});
|
|
96
|
+
const lambdas = template.findResources('AWS::Lambda::Function');
|
|
97
|
+
const domainLambdas = Object.entries(lambdas).filter(([id]) => !id.includes('Health') && !id.includes('Ready') && !id.includes('LogRetention'));
|
|
98
|
+
expect(domainLambdas.some(([, resource]) => resource.Properties.Environment?.Variables?.DB_SECRET_ARN === dbSecretArn)).toBe(true);
|
|
109
99
|
});
|
|
110
100
|
it('secretsmanager:GetSecretValue grant added when dbSecretArn provided', () => {
|
|
111
101
|
const app = new cdk.App();
|
|
@@ -116,24 +106,9 @@ describe('DomainStack', () => {
|
|
|
116
106
|
dbSecretArn,
|
|
117
107
|
});
|
|
118
108
|
const template = Template.fromStack(stack);
|
|
119
|
-
template.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
objectLike: {
|
|
123
|
-
Statement: [
|
|
124
|
-
{
|
|
125
|
-
Match: {
|
|
126
|
-
objectLike: {
|
|
127
|
-
Action: ['secretsmanager:GetSecretValue'],
|
|
128
|
-
Resource: [dbSecretArn],
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
},
|
|
132
|
-
],
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
},
|
|
136
|
-
});
|
|
109
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
110
|
+
const hasSecretGrant = policies.some(resource => resource.Properties.PolicyDocument.Statement.some(statement => statement.Action?.includes('secretsmanager:GetSecretValue') && statement.Resource?.includes(dbSecretArn)));
|
|
111
|
+
expect(hasSecretGrant).toBe(true);
|
|
137
112
|
});
|
|
138
113
|
describe('domain S3 bucket', () => {
|
|
139
114
|
const app = new cdk.App();
|
|
@@ -166,55 +141,29 @@ describe('DomainStack', () => {
|
|
|
166
141
|
const lambdas = template.findResources('AWS::Lambda::Function');
|
|
167
142
|
for (const [id, res] of Object.entries(lambdas)) {
|
|
168
143
|
// Skip health/readiness Lambdas from HealthConstruct
|
|
169
|
-
if (id.includes('Health') || id.includes('Ready'))
|
|
144
|
+
if (id.includes('Health') || id.includes('Ready') || id.includes('LogRetention'))
|
|
170
145
|
continue;
|
|
171
|
-
expect(res.Properties.Environment
|
|
146
|
+
expect(res.Properties.Environment?.Variables).toHaveProperty('DOMAIN_TENANT_ROLE_ARN');
|
|
172
147
|
}
|
|
173
148
|
});
|
|
174
149
|
it('grants Lambda execution roles sts:AssumeRole + sts:TagSession on the tenant-scoped role', () => {
|
|
175
|
-
template.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
Effect: 'Allow',
|
|
181
|
-
}),
|
|
182
|
-
]),
|
|
183
|
-
},
|
|
184
|
-
});
|
|
150
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
151
|
+
const hasAssumeRoleGrant = policies.some(resource => resource.Properties.PolicyDocument.Statement.some(statement => statement.Effect === 'Allow'
|
|
152
|
+
&& statement.Action?.includes('sts:AssumeRole')
|
|
153
|
+
&& statement.Action?.includes('sts:TagSession')));
|
|
154
|
+
expect(hasAssumeRoleGrant).toBe(true);
|
|
185
155
|
});
|
|
186
156
|
it('tenant-scoped role has trust policy with request-tag condition', () => {
|
|
187
|
-
template.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
Effect: 'Allow',
|
|
193
|
-
Condition: expect.objectContaining({
|
|
194
|
-
StringLike: expect.objectContaining({
|
|
195
|
-
'aws:RequestTag/tenantId': '*',
|
|
196
|
-
}),
|
|
197
|
-
}),
|
|
198
|
-
}),
|
|
199
|
-
]),
|
|
200
|
-
},
|
|
201
|
-
});
|
|
157
|
+
const roles = Object.values(template.findResources('AWS::IAM::Role'));
|
|
158
|
+
const hasTrustCondition = roles.some(resource => resource.Properties.AssumeRolePolicyDocument.Statement.some(statement => statement.Action?.includes('sts:AssumeRole')
|
|
159
|
+
&& statement.Action?.includes('sts:TagSession')
|
|
160
|
+
&& statement.Condition?.StringLike?.['aws:RequestTag/tenantId'] === '*'));
|
|
161
|
+
expect(hasTrustCondition).toBe(true);
|
|
202
162
|
});
|
|
203
163
|
it('tenant-scoped role policy uses PrincipalTag conditions', () => {
|
|
204
|
-
template.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
Statement: expect.arrayContaining([
|
|
208
|
-
expect.objectContaining({
|
|
209
|
-
Condition: expect.objectContaining({
|
|
210
|
-
'ForAllValues:StringEquals': expect.objectContaining({
|
|
211
|
-
'dynamodb:LeadingKeys': ['${aws:PrincipalTag/tenantId}'],
|
|
212
|
-
}),
|
|
213
|
-
}),
|
|
214
|
-
}),
|
|
215
|
-
]),
|
|
216
|
-
},
|
|
217
|
-
});
|
|
164
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
165
|
+
const hasPrincipalTagCondition = policies.some(resource => resource.Properties.PolicyDocument.Statement.some(statement => statement.Condition?.['ForAllValues:StringEquals']?.['dynamodb:LeadingKeys']?.includes('${aws:PrincipalTag/tenantId}')));
|
|
166
|
+
expect(hasPrincipalTagCondition).toBe(true);
|
|
218
167
|
});
|
|
219
168
|
it('has CfnOutput for tenant-scoped role ARN', () => {
|
|
220
169
|
template.hasOutput('DomainTenantScopedRoleArn', {});
|
|
@@ -28,6 +28,10 @@ export interface GroupedLambdaProps {
|
|
|
28
28
|
vpc?: ec2.IVpc;
|
|
29
29
|
/** Security groups to attach to the Lambda(s). */
|
|
30
30
|
securityGroups?: ec2.ISecurityGroup[];
|
|
31
|
+
/** Subnet selection for the Lambda(s) when attached to a VPC. */
|
|
32
|
+
subnetSelection?: ec2.SubnetSelection;
|
|
33
|
+
/** Optional suffix used when multiple Lambda groups share the same primitive type. */
|
|
34
|
+
lambdaGroupId?: string;
|
|
31
35
|
/** When true, creates one dedicated Lambda per handler instead of a single grouped Lambda. */
|
|
32
36
|
dedicated?: boolean;
|
|
33
37
|
/** Reserved concurrency for all Lambdas in this group. If omitted, no limit. */
|
|
@@ -3,19 +3,19 @@ import * as lambdaNode from 'aws-cdk-lib/aws-lambda-nodejs';
|
|
|
3
3
|
import * as logs from 'aws-cdk-lib/aws-logs';
|
|
4
4
|
import * as cdk from 'aws-cdk-lib';
|
|
5
5
|
import { writeFileSync, mkdirSync } from 'fs';
|
|
6
|
-
import { join } from 'path';
|
|
6
|
+
import { join, relative } from 'path';
|
|
7
7
|
function camelCase(str) {
|
|
8
8
|
return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
9
9
|
}
|
|
10
10
|
function toPascalCase(str) {
|
|
11
11
|
return str.charAt(0).toUpperCase() + camelCase(str).slice(1);
|
|
12
12
|
}
|
|
13
|
-
function generateDedicatedEntry(
|
|
13
|
+
function generateDedicatedEntry(handlerImportPath, entry, primitiveType) {
|
|
14
14
|
const exportName = camelCase(entry.id);
|
|
15
15
|
if (primitiveType === 'schedule') {
|
|
16
16
|
return [
|
|
17
17
|
`import { hydrateCtx } from '@mettlecast/domain-runtime';`,
|
|
18
|
-
`import { ${exportName} } from '${
|
|
18
|
+
`import { ${exportName} } from '${handlerImportPath}';`,
|
|
19
19
|
``,
|
|
20
20
|
`export const handler = async (event: any) => {`,
|
|
21
21
|
` const ctx = await hydrateCtx(event, {`,
|
|
@@ -36,17 +36,15 @@ function generateDedicatedEntry(domainRoot, entry, primitiveType) {
|
|
|
36
36
|
const adapter = adapterMap[primitiveType] ?? 'createApiLambdaHandler';
|
|
37
37
|
return [
|
|
38
38
|
`import { ${adapter} } from '@mettlecast/domain-runtime';`,
|
|
39
|
-
`import { ${exportName} } from '${
|
|
39
|
+
`import { ${exportName} } from '${handlerImportPath}';`,
|
|
40
40
|
'',
|
|
41
41
|
`export const handler = ${adapter}(${exportName});`,
|
|
42
42
|
].join('\n');
|
|
43
43
|
}
|
|
44
|
-
function
|
|
45
|
-
const dir = join('.tib',
|
|
44
|
+
function createTempEntryDir() {
|
|
45
|
+
const dir = join(process.cwd(), '.tib-domain-entries', `${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
46
46
|
mkdirSync(dir, { recursive: true });
|
|
47
|
-
|
|
48
|
-
writeFileSync(filePath, content, 'utf8');
|
|
49
|
-
return filePath;
|
|
47
|
+
return dir;
|
|
50
48
|
}
|
|
51
49
|
const adapterCache = new Map();
|
|
52
50
|
/**
|
|
@@ -57,18 +55,30 @@ const adapterCache = new Map();
|
|
|
57
55
|
* Grouped mode retains Code.fromAsset for backward compatibility.
|
|
58
56
|
*/
|
|
59
57
|
export function createGroupedLambdas(scope, props) {
|
|
60
|
-
const vpcConfig = props.vpc
|
|
58
|
+
const vpcConfig = props.vpc
|
|
59
|
+
? {
|
|
60
|
+
vpc: props.vpc,
|
|
61
|
+
securityGroups: props.securityGroups,
|
|
62
|
+
...(props.subnetSelection ? { vpcSubnets: props.subnetSelection } : {}),
|
|
63
|
+
}
|
|
64
|
+
: {};
|
|
61
65
|
const logRetention = toLogRetention(props.logRetentionDays ?? 30);
|
|
62
66
|
const powertoolsLayerArn = `arn:aws:lambda:${cdk.Stack.of(scope).region}:094274105915:layer:AWSLambdaPowertoolsTypeScriptV2:26`;
|
|
67
|
+
const groupSuffix = props.lambdaGroupId ? `-${props.lambdaGroupId}` : '';
|
|
68
|
+
const groupSuffixPascal = props.lambdaGroupId ? toPascalCase(props.lambdaGroupId) : '';
|
|
69
|
+
const serviceName = `${props.domainId}-${props.primitiveType}${groupSuffix}`;
|
|
63
70
|
if (props.dedicated) {
|
|
64
71
|
if (!props.domainRoot)
|
|
65
72
|
throw new Error('domainRoot is required when dedicated=true');
|
|
66
73
|
const domainRoot = props.domainRoot;
|
|
67
74
|
const fns = props.handlerEntries.map(entry => {
|
|
68
|
-
const powertoolsLayer = lambda.LayerVersion.fromLayerVersionArn(scope, `PowertoolsLayer${props.domainId}-${props.primitiveType}-${entry.id}`, powertoolsLayerArn);
|
|
69
|
-
const
|
|
70
|
-
const entryPath =
|
|
71
|
-
|
|
75
|
+
const powertoolsLayer = lambda.LayerVersion.fromLayerVersionArn(scope, `PowertoolsLayer${props.domainId}-${props.primitiveType}${groupSuffixPascal}-${entry.id}`, powertoolsLayerArn);
|
|
76
|
+
const entryDir = createTempEntryDir();
|
|
77
|
+
const entryPath = join(entryDir, 'entry.ts');
|
|
78
|
+
const handlerImportPath = relative(entryDir, join(domainRoot, entry.handlerFile)).replace(/\\/g, '/');
|
|
79
|
+
const entryContent = generateDedicatedEntry(handlerImportPath.startsWith('.') ? handlerImportPath : `./${handlerImportPath}`, entry, props.primitiveType);
|
|
80
|
+
writeFileSync(entryPath, entryContent, 'utf8');
|
|
81
|
+
return new lambdaNode.NodejsFunction(scope, `${props.domainId}-${props.primitiveType}${groupSuffix}-${entry.id}`, {
|
|
72
82
|
runtime: lambda.Runtime.NODEJS_22_X,
|
|
73
83
|
architecture: lambda.Architecture.ARM_64,
|
|
74
84
|
entry: entryPath,
|
|
@@ -81,7 +91,7 @@ export function createGroupedLambdas(scope, props) {
|
|
|
81
91
|
},
|
|
82
92
|
environment: {
|
|
83
93
|
...props.environment,
|
|
84
|
-
POWERTOOLS_SERVICE_NAME:
|
|
94
|
+
POWERTOOLS_SERVICE_NAME: serviceName,
|
|
85
95
|
POWERTOOLS_LOG_LEVEL: 'INFO',
|
|
86
96
|
TIB_HANDLER_ID: entry.id,
|
|
87
97
|
TIB_EVENT_BUS_ARN: props.eventBusArn,
|
|
@@ -96,9 +106,9 @@ export function createGroupedLambdas(scope, props) {
|
|
|
96
106
|
return fns;
|
|
97
107
|
}
|
|
98
108
|
// Single grouped Lambda — all handlers for this primitive type bundled together
|
|
99
|
-
const powertoolsLayer = lambda.LayerVersion.fromLayerVersionArn(scope, `PowertoolsLayer${props.domainId}-${props.primitiveType}`, powertoolsLayerArn);
|
|
109
|
+
const powertoolsLayer = lambda.LayerVersion.fromLayerVersionArn(scope, `PowertoolsLayer${props.domainId}-${props.primitiveType}${groupSuffixPascal}`, powertoolsLayerArn);
|
|
100
110
|
return [
|
|
101
|
-
new lambda.Function(scope, `${props.domainId}-${props.primitiveType}`, {
|
|
111
|
+
new lambda.Function(scope, `${props.domainId}-${props.primitiveType}${groupSuffix}`, {
|
|
102
112
|
runtime: lambda.Runtime.NODEJS_22_X,
|
|
103
113
|
architecture: lambda.Architecture.ARM_64,
|
|
104
114
|
handler: 'index.handler',
|
|
@@ -106,7 +116,7 @@ export function createGroupedLambdas(scope, props) {
|
|
|
106
116
|
layers: [powertoolsLayer],
|
|
107
117
|
environment: {
|
|
108
118
|
...props.environment,
|
|
109
|
-
POWERTOOLS_SERVICE_NAME:
|
|
119
|
+
POWERTOOLS_SERVICE_NAME: serviceName,
|
|
110
120
|
POWERTOOLS_LOG_LEVEL: 'INFO',
|
|
111
121
|
TIB_HANDLER_MAP: JSON.stringify(props.handlerEntries.map(e => e.id)),
|
|
112
122
|
TIB_EVENT_BUS_ARN: props.eventBusArn,
|
package/dist/pack-domain.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface PackDomainOptions {
|
|
|
13
13
|
vpc?: ec2.IVpc;
|
|
14
14
|
/** Security group for domain Lambdas. */
|
|
15
15
|
lambdaSg?: ec2.ISecurityGroup;
|
|
16
|
+
/** Subnet selection for VPC-attached handlers that should remain internal-only. */
|
|
17
|
+
internalSubnetSelection?: ec2.SubnetSelection;
|
|
18
|
+
/** Subnet selection for VPC-attached handlers that need NAT-backed internet egress. */
|
|
19
|
+
internetSubnetSelection?: ec2.SubnetSelection;
|
|
16
20
|
/** Shared HTTP API Gateway — when provided, domain routes use this instead of creating a separate API. */
|
|
17
21
|
httpApi?: apigwv2.HttpApi;
|
|
18
22
|
/** Cognito User Pool ARN — when provided, HTTP API routes are JWT-protected. */
|
package/dist/pack-domain.js
CHANGED
|
@@ -24,6 +24,8 @@ export function packDomain(registry, app, stackId, eventBusArn, options) {
|
|
|
24
24
|
env: opts.env,
|
|
25
25
|
vpc: opts.vpc,
|
|
26
26
|
lambdaSg: opts.lambdaSg,
|
|
27
|
+
internalSubnetSelection: opts.internalSubnetSelection,
|
|
28
|
+
internetSubnetSelection: opts.internetSubnetSelection,
|
|
27
29
|
httpApi: opts.httpApi,
|
|
28
30
|
userPoolArn: opts.userPoolArn,
|
|
29
31
|
userPoolId: opts.userPoolId,
|
package/dist/registry.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type SchemaSnapshot = Record<string, unknown>;
|
|
|
6
6
|
* Kind discriminant for registry entries.
|
|
7
7
|
*/
|
|
8
8
|
export type RegistryEntryKind = 'api' | 'webhook' | 'subscriber' | 'schedule' | 'job' | 'action' | 'integration' | 'event' | 'domain';
|
|
9
|
+
/** Controls whether a VPC-attached handler may use NAT-backed public internet egress. */
|
|
10
|
+
export type RegistryOutboundAccess = 'internal' | 'internet';
|
|
9
11
|
/**
|
|
10
12
|
* Plain-value subset of DeploymentConfig for serialization.
|
|
11
13
|
*/
|
|
@@ -63,6 +65,8 @@ export interface ApiRegistryEntry extends BaseRegistryEntry {
|
|
|
63
65
|
authType: 'jwt' | 'api-key' | 'none';
|
|
64
66
|
/** Optional deployment overrides. */
|
|
65
67
|
deployment?: SerialDeploymentConfig;
|
|
68
|
+
/** Whether this handler may use NAT-backed public internet egress. */
|
|
69
|
+
outboundAccess?: RegistryOutboundAccess;
|
|
66
70
|
/** JSON Schema snapshot for the HTTP request body. */
|
|
67
71
|
requestSchema?: SchemaSnapshot;
|
|
68
72
|
/** JSON Schema snapshot for the HTTP response body. */
|
|
@@ -93,6 +97,8 @@ export interface WebhookRegistryEntry extends BaseRegistryEntry {
|
|
|
93
97
|
hmacSecretRef?: string;
|
|
94
98
|
/** Optional deployment overrides. */
|
|
95
99
|
deployment?: SerialDeploymentConfig;
|
|
100
|
+
/** Whether this handler may use NAT-backed public internet egress. */
|
|
101
|
+
outboundAccess?: RegistryOutboundAccess;
|
|
96
102
|
}
|
|
97
103
|
/**
|
|
98
104
|
* Event subscriber registry entry.
|
|
@@ -110,6 +116,8 @@ export interface SubscriberRegistryEntry extends BaseRegistryEntry {
|
|
|
110
116
|
concurrency?: number;
|
|
111
117
|
/** Optional deployment overrides. */
|
|
112
118
|
deployment?: SerialDeploymentConfig;
|
|
119
|
+
/** Whether this handler may use NAT-backed public internet egress. */
|
|
120
|
+
outboundAccess?: RegistryOutboundAccess;
|
|
113
121
|
}
|
|
114
122
|
/**
|
|
115
123
|
* Schedule (cron) registry entry.
|
|
@@ -125,6 +133,8 @@ export interface ScheduleRegistryEntry extends BaseRegistryEntry {
|
|
|
125
133
|
enabled: boolean;
|
|
126
134
|
/** Optional deployment overrides. */
|
|
127
135
|
deployment?: SerialDeploymentConfig;
|
|
136
|
+
/** Whether this handler may use NAT-backed public internet egress. */
|
|
137
|
+
outboundAccess?: RegistryOutboundAccess;
|
|
128
138
|
}
|
|
129
139
|
/**
|
|
130
140
|
* Background job registry entry.
|
|
@@ -140,6 +150,8 @@ export interface JobRegistryEntry extends BaseRegistryEntry {
|
|
|
140
150
|
visibilityTimeoutSeconds: number;
|
|
141
151
|
/** Optional deployment overrides. */
|
|
142
152
|
deployment?: SerialDeploymentConfig;
|
|
153
|
+
/** Whether this handler may use NAT-backed public internet egress. */
|
|
154
|
+
outboundAccess?: RegistryOutboundAccess;
|
|
143
155
|
}
|
|
144
156
|
/**
|
|
145
157
|
* Action registry entry for callable domain actions.
|
|
@@ -155,6 +167,8 @@ export interface ActionRegistryEntry extends BaseRegistryEntry {
|
|
|
155
167
|
idempotent: boolean;
|
|
156
168
|
/** Optional deployment overrides. */
|
|
157
169
|
deployment?: SerialDeploymentConfig;
|
|
170
|
+
/** Whether this handler may use NAT-backed public internet egress. */
|
|
171
|
+
outboundAccess?: RegistryOutboundAccess;
|
|
158
172
|
/** JSON Schema snapshot for the action input. */
|
|
159
173
|
inputSchema?: SchemaSnapshot;
|
|
160
174
|
/** JSON Schema snapshot for the action output. */
|