@mettlecast/domain-cdk-packer 0.2.103 → 0.2.105
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 +172 -17
- package/dist/__tests__/bootstrap-invite-registration.test.js +8 -0
- package/dist/__tests__/domain-stack.test.js +65 -0
- package/dist/__tests__/jobs-outbox-cmk.test.d.ts +1 -0
- package/dist/__tests__/jobs-outbox-cmk.test.js +108 -0
- package/dist/__tests__/jobs-outbox-wiring.test.d.ts +1 -0
- package/dist/__tests__/jobs-outbox-wiring.test.js +228 -0
- package/dist/constructs/job-construct.d.ts +12 -0
- package/dist/constructs/job-construct.js +27 -3
- package/dist/grouped-lambda-factory.d.ts +7 -0
- package/dist/grouped-lambda-factory.js +12 -0
- package/dist/iam/iam-policy-builder.d.ts +29 -0
- package/dist/iam/iam-policy-builder.js +54 -0
- package/dist/registry.d.ts +7 -0
- package/package.json +1 -1
package/dist/DomainStack.js
CHANGED
|
@@ -9,6 +9,9 @@ import * as iam from 'aws-cdk-lib/aws-iam';
|
|
|
9
9
|
import * as scheduler from 'aws-cdk-lib/aws-scheduler';
|
|
10
10
|
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
11
11
|
import * as s3 from 'aws-cdk-lib/aws-s3';
|
|
12
|
+
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
|
|
13
|
+
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
|
|
14
|
+
import * as sns from 'aws-cdk-lib/aws-sns';
|
|
12
15
|
import { createGroupedLambdas, createGroupedApiActionLambda } from './grouped-lambda-factory.js';
|
|
13
16
|
import { IamPolicyBuilder } from './iam/iam-policy-builder.js';
|
|
14
17
|
import { HealthConstruct } from './constructs/health-construct.js';
|
|
@@ -175,6 +178,11 @@ export class DomainStack extends cdk.Stack {
|
|
|
175
178
|
const environment = {
|
|
176
179
|
TIB_DOMAIN_ID: domainId,
|
|
177
180
|
TIB_EVENT_BUS_ARN: eventBusArn,
|
|
181
|
+
// #5282: the runtime resolves the `ctx.publish` bus from
|
|
182
|
+
// EVENT_BUS_NAME (falling back to 'default'). Inject the shared bus
|
|
183
|
+
// name so published domain events land on the project-scoped bus.
|
|
184
|
+
// Prefer the supplied name; derive from the ARN when it is omitted.
|
|
185
|
+
EVENT_BUS_NAME: eventBusName ?? cdk.Fn.select(1, cdk.Fn.split('/', eventBusArn)),
|
|
178
186
|
};
|
|
179
187
|
if (databaseUrl) {
|
|
180
188
|
environment.DATABASE_URL = databaseUrl;
|
|
@@ -199,6 +207,56 @@ export class DomainStack extends cdk.Stack {
|
|
|
199
207
|
if (Object.keys(crossDomainActionArns).length > 0) {
|
|
200
208
|
environment.TIB_ACTION_LAMBDA_ARNS = JSON.stringify(crossDomainActionArns);
|
|
201
209
|
}
|
|
210
|
+
// Transactional outbox (issue #5294): when the domain declares an outbox
|
|
211
|
+
// table in defineDomain, the runtime hydrates a real ctx.outbox backed by
|
|
212
|
+
// Postgres + SQS. The table itself is created by the domain's
|
|
213
|
+
// `outbox-table` migration.
|
|
214
|
+
if (registry.domain.outboxTableName) {
|
|
215
|
+
environment.TIB_OUTBOX_TABLE = registry.domain.outboxTableName;
|
|
216
|
+
}
|
|
217
|
+
// ── Job queues (issue #5294) ────────────────────────────────────────────
|
|
218
|
+
// Pre-create every declared job's SQS queue + DLQ BEFORE any producer
|
|
219
|
+
// Lambda so TIB_JOB_QUEUE_URLS / TIB_JOB_DLQ_ARNS can be injected into
|
|
220
|
+
// the shared environment. The runtime's ctx.jobs.enqueue resolves queue
|
|
221
|
+
// URLs from that map at hydrate time, so every handler that receives ctx
|
|
222
|
+
// can enqueue declared jobs. Queues are encrypted (KMS when enableCmk,
|
|
223
|
+
// else SQS-managed), redrive honors defineJob.maxRetries as RETRY
|
|
224
|
+
// ATTEMPTS AFTER THE FIRST DELIVERY (maxReceiveCount = maxRetries + 1,
|
|
225
|
+
// minimum 1 so zero retries is valid), and the configured visibility
|
|
226
|
+
// timeout is preserved.
|
|
227
|
+
const jobQueues = new Map();
|
|
228
|
+
const jobDlqs = new Map();
|
|
229
|
+
const jobQueueUrlMap = {};
|
|
230
|
+
const jobDlqArnMap = {};
|
|
231
|
+
for (const job of registry.jobs) {
|
|
232
|
+
const pascalId = toPascalCase(job.id);
|
|
233
|
+
const dlq = new sqs.Queue(this, `${pascalId}JobDlq`, {
|
|
234
|
+
retentionPeriod: cdk.Duration.days(14),
|
|
235
|
+
encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
|
|
236
|
+
encryptionMasterKey: enableCmk ? cmkKey : undefined,
|
|
237
|
+
});
|
|
238
|
+
jobDlqs.set(job.id, dlq);
|
|
239
|
+
const queue = new sqs.Queue(this, `${pascalId}JobQueue`, {
|
|
240
|
+
visibilityTimeout: cdk.Duration.seconds(job.visibilityTimeoutSeconds ?? 30),
|
|
241
|
+
deadLetterQueue: {
|
|
242
|
+
queue: dlq,
|
|
243
|
+
maxReceiveCount: maxReceiveCountForJob(job.maxRetries),
|
|
244
|
+
},
|
|
245
|
+
encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
|
|
246
|
+
encryptionMasterKey: enableCmk ? cmkKey : undefined,
|
|
247
|
+
});
|
|
248
|
+
jobQueues.set(job.id, queue);
|
|
249
|
+
jobQueueUrlMap[job.id] = queue.queueUrl;
|
|
250
|
+
jobDlqArnMap[job.id] = dlq.queueArn;
|
|
251
|
+
}
|
|
252
|
+
if (Object.keys(jobQueueUrlMap).length > 0) {
|
|
253
|
+
// Fn::ToJsonString resolves the queue URL tokens at deploy time so the
|
|
254
|
+
// Lambda env var carries the real per-job URLs.
|
|
255
|
+
environment.TIB_JOB_QUEUE_URLS = cdk.Fn.toJsonString(jobQueueUrlMap);
|
|
256
|
+
// Per-job DLQ ARNs back ctx.jobs.replayDlq (operator DLQ redrive via
|
|
257
|
+
// SQS StartMessageMoveTask). Scoped IAM is granted below.
|
|
258
|
+
environment.TIB_JOB_DLQ_ARNS = cdk.Fn.toJsonString(jobDlqArnMap);
|
|
259
|
+
}
|
|
202
260
|
// Deterministic physical name for THIS domain's internal-action grouped
|
|
203
261
|
// Lambda so other stacks can construct its ARN for cross-domain calls.
|
|
204
262
|
const actionLambdaName = `${resourceBaseName}-action`;
|
|
@@ -248,6 +306,7 @@ export class DomainStack extends cdk.Stack {
|
|
|
248
306
|
apiEntries,
|
|
249
307
|
environment,
|
|
250
308
|
eventBusArn,
|
|
309
|
+
eventBusName,
|
|
251
310
|
reservedConcurrency,
|
|
252
311
|
logRetentionDays: logRetentionDays ?? 30,
|
|
253
312
|
lambdaGroupId: group.outboundAccess,
|
|
@@ -278,6 +337,7 @@ export class DomainStack extends cdk.Stack {
|
|
|
278
337
|
}),
|
|
279
338
|
environment,
|
|
280
339
|
eventBusArn,
|
|
340
|
+
eventBusName,
|
|
281
341
|
dedicated: internalDedicated,
|
|
282
342
|
// Deterministic physical name so OTHER domain stacks can resolve
|
|
283
343
|
// this Lambda's ARN for cross-domain `ctx.actions` dispatch.
|
|
@@ -319,6 +379,7 @@ export class DomainStack extends cdk.Stack {
|
|
|
319
379
|
}),
|
|
320
380
|
environment,
|
|
321
381
|
eventBusArn,
|
|
382
|
+
eventBusName,
|
|
322
383
|
dedicated,
|
|
323
384
|
reservedConcurrency,
|
|
324
385
|
logRetentionDays: logRetentionDays ?? 30,
|
|
@@ -547,30 +608,55 @@ export class DomainStack extends cdk.Stack {
|
|
|
547
608
|
const jobLambdaById = jobHandlers.byId;
|
|
548
609
|
jobLambdas = jobHandlers.lambdas;
|
|
549
610
|
this.lambdaArns[`${domainId}-job`] = jobLambdas[0].functionArn;
|
|
550
|
-
// Wire SQS → Lambda for each job
|
|
611
|
+
// Wire SQS → Lambda for each job. Queues/DLQs were pre-created above so
|
|
612
|
+
// TIB_JOB_QUEUE_URLS could be injected before any Lambda existed.
|
|
613
|
+
const alarmTopic = alarmSnsTopicArn
|
|
614
|
+
? sns.Topic.fromTopicArn(this, 'JobDlqAlarmTopic', alarmSnsTopicArn)
|
|
615
|
+
: undefined;
|
|
616
|
+
const alarmAction = alarmTopic ? [new cloudwatchActions.SnsAction(alarmTopic)] : [];
|
|
551
617
|
for (const job of registry.jobs) {
|
|
552
618
|
const pascalId = toPascalCase(job.id);
|
|
553
|
-
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
619
|
+
const dlq = jobDlqs.get(job.id);
|
|
620
|
+
const queue = jobQueues.get(job.id);
|
|
621
|
+
// Standard per-job DLQ alarms (issue #5294): fire when any message
|
|
622
|
+
// lands in the DLQ, and when the oldest message ages past 15 minutes.
|
|
623
|
+
// Both are wired to the project SNS topic when one is configured.
|
|
624
|
+
const dlqDepthAlarm = new cloudwatch.Alarm(this, `${pascalId}JobDlqDepthAlarm`, {
|
|
625
|
+
alarmName: `${resourceBaseName}-job-${job.id}-dlq-depth`,
|
|
626
|
+
alarmDescription: `Job ${job.id} DLQ has visible messages — investigate failed job processing`,
|
|
627
|
+
metric: dlq.metricApproximateNumberOfMessagesVisible({
|
|
628
|
+
period: cdk.Duration.minutes(1),
|
|
629
|
+
statistic: 'Maximum',
|
|
630
|
+
}),
|
|
631
|
+
threshold: 0,
|
|
632
|
+
evaluationPeriods: 1,
|
|
633
|
+
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
|
634
|
+
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
558
635
|
});
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
636
|
+
if (alarmAction.length > 0)
|
|
637
|
+
dlqDepthAlarm.addAlarmAction(...alarmAction);
|
|
638
|
+
const dlqAgeAlarm = new cloudwatch.Alarm(this, `${pascalId}JobDlqAgeAlarm`, {
|
|
639
|
+
alarmName: `${resourceBaseName}-job-${job.id}-dlq-oldest-age`,
|
|
640
|
+
alarmDescription: `Job ${job.id} DLQ oldest message exceeded 15 minutes — investigate failed job processing`,
|
|
641
|
+
metric: dlq.metricApproximateAgeOfOldestMessage({
|
|
642
|
+
period: cdk.Duration.minutes(5),
|
|
643
|
+
statistic: 'Maximum',
|
|
644
|
+
}),
|
|
645
|
+
threshold: 900,
|
|
646
|
+
evaluationPeriods: 1,
|
|
647
|
+
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
|
648
|
+
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
569
649
|
});
|
|
570
|
-
|
|
650
|
+
if (alarmAction.length > 0)
|
|
651
|
+
dlqAgeAlarm.addAlarmAction(...alarmAction);
|
|
652
|
+
// Add SQS as event source for the first grouped Lambda. Batch size
|
|
653
|
+
// stays 1 (one message per invocation), but partial-batch failure
|
|
654
|
+
// reporting is enabled so a future batch-size increase degrades
|
|
655
|
+
// safely: only failed messages are retried/redriven (issue #5294).
|
|
571
656
|
const fn = jobLambdaById.get(job.id);
|
|
572
657
|
fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
|
|
573
658
|
batchSize: 1,
|
|
659
|
+
reportBatchItemFailures: true,
|
|
574
660
|
}));
|
|
575
661
|
// Grant IAM permissions for this queue
|
|
576
662
|
const queuePolicies = iamBuilder.forJob({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
|
|
@@ -620,6 +706,12 @@ export class DomainStack extends cdk.Stack {
|
|
|
620
706
|
actionLambdas.forEach((fn) => {
|
|
621
707
|
iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
622
708
|
});
|
|
709
|
+
// #5282: actions publish domain events via `ctx.publish`. The runtime
|
|
710
|
+
// targets the bus named by EVENT_BUS_NAME, so the execution role needs
|
|
711
|
+
// events:PutEvents scoped to the shared bus ARN.
|
|
712
|
+
actionLambdas.forEach((fn) => {
|
|
713
|
+
eventBus.grantPutEventsTo(fn);
|
|
714
|
+
});
|
|
623
715
|
// Add dbSecretArn grant if provided
|
|
624
716
|
if (dbSecretArn) {
|
|
625
717
|
actionLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
@@ -640,6 +732,44 @@ export class DomainStack extends cdk.Stack {
|
|
|
640
732
|
addRouteToApi(this, fn, `/${domainId}${action.exposure.path}`, [toHttpMethod(action.exposure.method)], this.httpApi.httpApiId, routeAuth);
|
|
641
733
|
}
|
|
642
734
|
}
|
|
735
|
+
// Producer SQS grant (issue #5294): every producer handler — actions,
|
|
736
|
+
// subscribers, schedules, jobs, and webhooks — receives ctx.jobs, so each
|
|
737
|
+
// gets sqs:SendMessage scoped to THIS domain's declared job queue ARNs.
|
|
738
|
+
// Never a wildcard; producers cannot touch other domains' queues.
|
|
739
|
+
if (jobQueues.size > 0) {
|
|
740
|
+
const producerQueueArns = [...jobQueues.values()].map(q => q.queueArn);
|
|
741
|
+
const producerStatements = iamBuilder.forJobProducer(producerQueueArns);
|
|
742
|
+
const producerLambdas = [
|
|
743
|
+
...actionLambdas,
|
|
744
|
+
...subscriberLambdas,
|
|
745
|
+
...scheduleLambdas,
|
|
746
|
+
...jobLambdas,
|
|
747
|
+
...webhookLambdas,
|
|
748
|
+
];
|
|
749
|
+
for (const fn of producerLambdas) {
|
|
750
|
+
producerStatements.forEach(statement => fn.addToRolePolicy(statement));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// DLQ redrive grant (issue #5294): the runtime's ctx.jobs.replayDlq
|
|
754
|
+
// starts an SQS StartMessageMoveTask on a declared job's DLQ. Every
|
|
755
|
+
// domain Lambda that receives ctx.jobs can invoke it, so grant
|
|
756
|
+
// sqs:StartMessageMoveTask scoped to THIS domain's exact DLQ ARNs — the
|
|
757
|
+
// destination is derived by SQS from the DLQ's redrive policy, so no
|
|
758
|
+
// broader resource is needed.
|
|
759
|
+
if (jobDlqs.size > 0) {
|
|
760
|
+
const dlqArns = [...jobDlqs.values()].map(q => q.queueArn);
|
|
761
|
+
const replayStatements = iamBuilder.forDlqReplay(dlqArns);
|
|
762
|
+
const replayLambdas = [
|
|
763
|
+
...actionLambdas,
|
|
764
|
+
...subscriberLambdas,
|
|
765
|
+
...scheduleLambdas,
|
|
766
|
+
...jobLambdas,
|
|
767
|
+
...webhookLambdas,
|
|
768
|
+
];
|
|
769
|
+
for (const fn of replayLambdas) {
|
|
770
|
+
replayStatements.forEach(statement => fn.addToRolePolicy(statement));
|
|
771
|
+
}
|
|
772
|
+
}
|
|
643
773
|
// Grant all domain Lambdas read/write access to the per-domain table and bucket
|
|
644
774
|
const allDomainLambdas = [
|
|
645
775
|
...webhookLambdas, ...subscriberLambdas,
|
|
@@ -649,6 +779,18 @@ export class DomainStack extends cdk.Stack {
|
|
|
649
779
|
domainTable.grantReadWriteData(fn);
|
|
650
780
|
domainBucket.grantReadWrite(fn);
|
|
651
781
|
}
|
|
782
|
+
// SQS KMS grants (issue #5294): with enableCmk every queue/DLQ — job
|
|
783
|
+
// queues, subscriber queues — is encrypted with the domain CMK. SQS
|
|
784
|
+
// producers need kms:GenerateDataKey (envelope encrypt) and consumers
|
|
785
|
+
// need kms:Decrypt + kms:GenerateDataKey (envelope decrypt). Grant the
|
|
786
|
+
// exact operations on the exact queue key to every Lambda that touches
|
|
787
|
+
// SQS (producers enqueue, subscribers/jobs consume).
|
|
788
|
+
if (enableCmk && cmkKey) {
|
|
789
|
+
const sqsKmsStatements = iamBuilder.forSqsKmsEncryption(cmkKey.keyArn);
|
|
790
|
+
for (const fn of allDomainLambdas) {
|
|
791
|
+
sqsKmsStatements.forEach(statement => fn.addToRolePolicy(statement));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
652
794
|
// Grant Cognito Admin permissions when a user pool is configured.
|
|
653
795
|
// The auth domain's provision-cognito-user internal action needs
|
|
654
796
|
// AdminCreateUser, AdminSetUserPassword, and AdminGetUser to
|
|
@@ -809,6 +951,19 @@ function toPascalCase(s) {
|
|
|
809
951
|
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
810
952
|
.join('');
|
|
811
953
|
}
|
|
954
|
+
/**
|
|
955
|
+
* Map `defineJob.maxRetries` to the SQS redrive policy `maxReceiveCount`.
|
|
956
|
+
*
|
|
957
|
+
* `maxRetries` is documented as the number of RETRY ATTEMPTS AFTER THE FIRST
|
|
958
|
+
* DELIVERY, so the receive count is `maxRetries + 1`. Zero retries is valid
|
|
959
|
+
* (exactly one delivery) and the receive count is clamped to a minimum of 1
|
|
960
|
+
* (SQS rejects 0). This mirrors the domain-runtime `defineJob` contract
|
|
961
|
+
* (issue #5294).
|
|
962
|
+
*/
|
|
963
|
+
function maxReceiveCountForJob(maxRetries) {
|
|
964
|
+
const retries = Number.isFinite(maxRetries) && maxRetries !== undefined ? maxRetries : 3;
|
|
965
|
+
return Math.max(1, retries + 1);
|
|
966
|
+
}
|
|
812
967
|
/**
|
|
813
968
|
* Convert HTTP method string to ApiGatewayV2 HttpMethod enum.
|
|
814
969
|
* @param method - HTTP method string (e.g., 'GET', 'POST').
|
|
@@ -91,6 +91,14 @@ describe('auth registry — bootstrap-invite registration (#5226)', () => {
|
|
|
91
91
|
expect(bootstrapInviteSource).toContain("VALUES ($1, 'system', NULL, $2, 'sys_admin')");
|
|
92
92
|
expect(bootstrapInviteSource).not.toContain("00000000-0000-0000-0000-000000000000");
|
|
93
93
|
});
|
|
94
|
+
it('runs SQL through the raw pg client (ctx.db.client.query), not the Drizzle builder', () => {
|
|
95
|
+
// Regression for #5277: `DbContext.query` is the Drizzle query builder —
|
|
96
|
+
// an object, not a function. Calling it (`ctx.db.query(sql, params)`)
|
|
97
|
+
// throws `TypeError: e.db.query is not a function` at runtime. Raw SQL
|
|
98
|
+
// must go through the raw pg client exposed as `DbContext.client`.
|
|
99
|
+
expect(bootstrapInviteSource).toContain('ctx.db.client.query(');
|
|
100
|
+
expect(bootstrapInviteSource).not.toContain('ctx.db.query(');
|
|
101
|
+
});
|
|
94
102
|
});
|
|
95
103
|
describe('full documented build process — all current domains', () => {
|
|
96
104
|
it('generates a registry file for EVERY domain under domains/ (auth, data-management, email, orgs)', () => {
|
|
@@ -568,4 +568,69 @@ describe('DomainStack', () => {
|
|
|
568
568
|
expect(actionRoute.Properties.AuthorizerId).toBeDefined();
|
|
569
569
|
});
|
|
570
570
|
});
|
|
571
|
+
/**
|
|
572
|
+
* Issue #5282 — API action event publishing.
|
|
573
|
+
*
|
|
574
|
+
* Production evidence: the auth API-action Lambda inserted a bootstrap
|
|
575
|
+
* invitation but `ctx.publish('auth.member.invited')` failed with
|
|
576
|
+
* `AccessDeniedException: events:PutEvents` on the EventBridge default bus.
|
|
577
|
+
* The runtime resolves the `ctx.publish` bus from the `EVENT_BUS_NAME` env
|
|
578
|
+
* var (falling back to 'default') and the grouped API-action Lambda's
|
|
579
|
+
* execution role previously had no `events:PutEvents`. These synth tests
|
|
580
|
+
* pin both halves of the packer fix.
|
|
581
|
+
*/
|
|
582
|
+
describe('action event publishing (#5282)', () => {
|
|
583
|
+
const busName = 'tib-event-bus';
|
|
584
|
+
it('injects EVENT_BUS_NAME into the grouped API-action Lambda', () => {
|
|
585
|
+
const app = new cdk.App();
|
|
586
|
+
const stack = new DomainStack(app, 'TestDomainStackEventBusName', {
|
|
587
|
+
registry: minimalRegistry,
|
|
588
|
+
eventBusArn,
|
|
589
|
+
eventBusName: busName,
|
|
590
|
+
projectId: 'Test',
|
|
591
|
+
envCode: 'Dev',
|
|
592
|
+
});
|
|
593
|
+
const template = Template.fromStack(stack);
|
|
594
|
+
const lambdas = template.findResources('AWS::Lambda::Function');
|
|
595
|
+
const actionApiLambdas = Object.values(lambdas).filter(l => l.Properties.Environment?.Variables?.POWERTOOLS_SERVICE_NAME === 'test-domain-action-api');
|
|
596
|
+
expect(actionApiLambdas.length).toBeGreaterThan(0);
|
|
597
|
+
for (const fn of actionApiLambdas) {
|
|
598
|
+
expect(fn.Properties.Environment?.Variables?.EVENT_BUS_NAME).toBe(busName);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
it('derives EVENT_BUS_NAME from the bus ARN when eventBusName is omitted', () => {
|
|
602
|
+
const app = new cdk.App();
|
|
603
|
+
const stack = new DomainStack(app, 'TestDomainStackEventBusDerived', {
|
|
604
|
+
registry: minimalRegistry,
|
|
605
|
+
eventBusArn,
|
|
606
|
+
projectId: 'Test',
|
|
607
|
+
envCode: 'Dev',
|
|
608
|
+
});
|
|
609
|
+
const template = Template.fromStack(stack);
|
|
610
|
+
const lambdas = template.findResources('AWS::Lambda::Function');
|
|
611
|
+
const actionApiLambdas = Object.values(lambdas).filter(l => l.Properties.Environment?.Variables?.POWERTOOLS_SERVICE_NAME === 'test-domain-action-api');
|
|
612
|
+
expect(actionApiLambdas.length).toBeGreaterThan(0);
|
|
613
|
+
for (const fn of actionApiLambdas) {
|
|
614
|
+
// Without an explicit name the packer derives it from the supplied
|
|
615
|
+
// bus ARN tail (`...:event-bus/tib-event-bus` → `tib-event-bus`) so
|
|
616
|
+
// published events target the shared bus, not the 'default' one.
|
|
617
|
+
expect(fn.Properties.Environment?.Variables?.EVENT_BUS_NAME).toBe('tib-event-bus');
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
it('grants action Lambda roles events:PutEvents scoped to the shared bus', () => {
|
|
621
|
+
const app = new cdk.App();
|
|
622
|
+
const stack = new DomainStack(app, 'TestDomainStackPutEvents', {
|
|
623
|
+
registry: minimalRegistry,
|
|
624
|
+
eventBusArn,
|
|
625
|
+
eventBusName: busName,
|
|
626
|
+
projectId: 'Test',
|
|
627
|
+
envCode: 'Dev',
|
|
628
|
+
});
|
|
629
|
+
const template = Template.fromStack(stack);
|
|
630
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
631
|
+
const hasScopedPutEvents = policies.some(resource => resource.Properties.PolicyDocument.Statement.some(statement => statement.Action?.includes('events:PutEvents')
|
|
632
|
+
&& JSON.stringify(statement.Resource).includes('event-bus/tib-event-bus')));
|
|
633
|
+
expect(hasScopedPutEvents).toBe(true);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
571
636
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect, 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 { DomainStack } from '../DomainStack.js';
|
|
7
|
+
const DOMAIN_ROOT = path.join(process.cwd(), '.test-domain-cdk-packer-cmk');
|
|
8
|
+
const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
|
|
9
|
+
fs.mkdirSync(handlerDir, { recursive: true });
|
|
10
|
+
fs.writeFileSync(path.join(handlerDir, 'send-email.ts'), `export const sendEmail = { id: "send-email", _kind: "job", maxRetries: 2, visibilityTimeoutSeconds: 60, handler: async () => undefined };\n`);
|
|
11
|
+
fs.writeFileSync(path.join(handlerDir, 'on-user-created.ts'), `export const onUserCreated = { id: "on-user-created", _kind: "subscriber", event: "auth.user.created", semverRange: ">=1", handler: async () => undefined };\n`);
|
|
12
|
+
// Grouped Lambdas need stub asset dirs to synth.
|
|
13
|
+
const assetDirs = ['subscriber', 'job'].map(type => path.join(process.cwd(), 'dist', 'domains', 'email', type));
|
|
14
|
+
for (const dir of assetDirs) {
|
|
15
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
16
|
+
fs.writeFileSync(path.join(dir, 'index.js'), 'exports.handler = async () => ({});\n');
|
|
17
|
+
}
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
|
|
20
|
+
for (const dir of assetDirs) {
|
|
21
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
const eventBusArn = 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus';
|
|
25
|
+
const cmkRegistry = {
|
|
26
|
+
schemaVersion: '1',
|
|
27
|
+
domainRoot: DOMAIN_ROOT,
|
|
28
|
+
domain: { id: 'email', kind: 'domain', name: 'Email', tenancy: 'required', outboxTableName: 'email.outbox' },
|
|
29
|
+
webhooks: [],
|
|
30
|
+
subscribers: [
|
|
31
|
+
{
|
|
32
|
+
id: 'on-user-created', kind: 'subscriber', handlerFile: 'src/handlers/on-user-created.ts',
|
|
33
|
+
event: 'auth.user.created', semverRange: '>=1',
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
schedules: [],
|
|
37
|
+
jobs: [
|
|
38
|
+
{ id: 'send-email', kind: 'job', handlerFile: 'src/handlers/send-email.ts', maxRetries: 2, visibilityTimeoutSeconds: 60 },
|
|
39
|
+
],
|
|
40
|
+
actions: [],
|
|
41
|
+
integrations: [],
|
|
42
|
+
events: [],
|
|
43
|
+
};
|
|
44
|
+
describe('DomainStack enableCmk SQS KMS grants (#5294)', () => {
|
|
45
|
+
const app = new cdk.App();
|
|
46
|
+
const stack = new DomainStack(app, 'CmkTestDomainStack', {
|
|
47
|
+
registry: cmkRegistry,
|
|
48
|
+
eventBusArn,
|
|
49
|
+
projectId: 'Test',
|
|
50
|
+
envCode: 'Dev',
|
|
51
|
+
enableCmk: true,
|
|
52
|
+
});
|
|
53
|
+
const template = Template.fromStack(stack);
|
|
54
|
+
it('encrypts job queues and DLQs with the customer-managed key', () => {
|
|
55
|
+
const queues = Object.values(template.findResources('AWS::SQS::Queue'));
|
|
56
|
+
expect(queues.length).toBeGreaterThan(0);
|
|
57
|
+
for (const q of queues) {
|
|
58
|
+
expect(q.Properties.KmsMasterKeyId).toBeDefined();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
it('grants the exact KMS operations (kms:Decrypt + kms:GenerateDataKey) on the queue key to SQS producers and consumers', () => {
|
|
62
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
63
|
+
const kmsStatements = policies.flatMap(p => p.Properties.PolicyDocument.Statement
|
|
64
|
+
.filter(s => {
|
|
65
|
+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
|
|
66
|
+
return actions.some(a => a === 'kms:Decrypt' || a === 'kms:GenerateDataKey');
|
|
67
|
+
}));
|
|
68
|
+
expect(kmsStatements.length).toBeGreaterThan(0);
|
|
69
|
+
// The SQS grant is the statement that carries BOTH exact operations on
|
|
70
|
+
// the domain CMK (S3/DDB grants carry narrower single-operation grants on
|
|
71
|
+
// the same key — do not conflate them).
|
|
72
|
+
const sqsKmsStatement = kmsStatements.find(s => {
|
|
73
|
+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
|
|
74
|
+
return actions.includes('kms:Decrypt') && actions.includes('kms:GenerateDataKey');
|
|
75
|
+
});
|
|
76
|
+
expect(sqsKmsStatement).toBeDefined();
|
|
77
|
+
// Scoped to the domain CMK ARN (references the DomainKey resource).
|
|
78
|
+
expect(JSON.stringify(sqsKmsStatement.Resource ?? '')).toContain('DomainKey');
|
|
79
|
+
// No broader kms:* wildcard anywhere.
|
|
80
|
+
for (const s of kmsStatements) {
|
|
81
|
+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
|
|
82
|
+
for (const a of actions) {
|
|
83
|
+
expect(String(a)).not.toBe('kms:*');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
it('grants the KMS operations to both job consumers and subscriber consumers (SQS receive paths)', () => {
|
|
88
|
+
// The subscriber and job lambdas consume from KMS-encrypted queues; at
|
|
89
|
+
// least one policy must be attached to a role used by each group. The
|
|
90
|
+
// check is intentionally coarse: every domain Lambda policy that carries
|
|
91
|
+
// SQS actions also carries the KMS decrypt grant.
|
|
92
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
93
|
+
const sqsKmsPolicies = policies.filter(p => p.Properties.PolicyDocument.Statement
|
|
94
|
+
.some(s => {
|
|
95
|
+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
|
|
96
|
+
return actions.includes('sqs:ReceiveMessage') || actions.includes('sqs:SendMessage');
|
|
97
|
+
}));
|
|
98
|
+
expect(sqsKmsPolicies.length).toBeGreaterThan(0);
|
|
99
|
+
for (const p of sqsKmsPolicies) {
|
|
100
|
+
const statements = p.Properties.PolicyDocument.Statement;
|
|
101
|
+
const hasKms = statements.some(s => {
|
|
102
|
+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
|
|
103
|
+
return actions.includes('kms:Decrypt') || actions.includes('kms:GenerateDataKey');
|
|
104
|
+
});
|
|
105
|
+
expect(hasKms).toBe(true);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, it, expect, 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 { DomainStack } from '../DomainStack.js';
|
|
7
|
+
const DOMAIN_ROOT = path.join(process.cwd(), '.test-domain-cdk-packer-jobs');
|
|
8
|
+
const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
|
|
9
|
+
fs.mkdirSync(handlerDir, { recursive: true });
|
|
10
|
+
fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), `export const getUsers = { id: "get-users", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/users", method: "GET", auth: "required", tenancy: "required" }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n`);
|
|
11
|
+
fs.writeFileSync(path.join(handlerDir, 'send-email.ts'), `export const sendEmail = { id: "send-email", _kind: "job", maxRetries: 4, visibilityTimeoutSeconds: 120, handler: async () => undefined };\n`);
|
|
12
|
+
fs.writeFileSync(path.join(handlerDir, 'sync-users.ts'), `export const syncUsers = { id: "sync-users", _kind: "job", handler: async () => undefined };\n`);
|
|
13
|
+
fs.writeFileSync(path.join(handlerDir, 'no-retry.ts'), `export const noRetry = { id: "no-retry", _kind: "job", maxRetries: 0, handler: async () => undefined };\n`);
|
|
14
|
+
fs.writeFileSync(path.join(handlerDir, 'daily-digest.ts'), `export const dailyDigest = { id: "daily-digest", _kind: "schedule", cron: "rate(1 day)", enabled: true, handler: async () => undefined };\n`);
|
|
15
|
+
fs.writeFileSync(path.join(handlerDir, 'on-user-created.ts'), `export const onUserCreated = { id: "on-user-created", _kind: "subscriber", event: "auth.user.created", semverRange: ">=1", handler: async () => undefined };\n`);
|
|
16
|
+
fs.writeFileSync(path.join(handlerDir, 'github-push.ts'), `export const githubPush = { id: "github-push", _kind: "webhook", path: "/github/push", provider: "github", handler: async () => undefined };\n`);
|
|
17
|
+
// Grouped (non-dedicated) Lambdas use Code.fromAsset('dist/domains/{domain}/{type}').
|
|
18
|
+
// Create stub asset dirs so `Template.fromStack` can synth without a real build.
|
|
19
|
+
const assetDirs = ['webhook', 'subscriber', 'schedule', 'job'].map(type => path.join(process.cwd(), 'dist', 'domains', 'email', type));
|
|
20
|
+
for (const dir of assetDirs) {
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
fs.writeFileSync(path.join(dir, 'index.js'), 'exports.handler = async () => ({});\n');
|
|
23
|
+
}
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
|
|
26
|
+
for (const dir of assetDirs) {
|
|
27
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
const eventBusArn = 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus';
|
|
31
|
+
const jobsRegistry = {
|
|
32
|
+
schemaVersion: '1',
|
|
33
|
+
domainRoot: DOMAIN_ROOT,
|
|
34
|
+
domain: {
|
|
35
|
+
id: 'email',
|
|
36
|
+
kind: 'domain',
|
|
37
|
+
name: 'Email',
|
|
38
|
+
tenancy: 'required',
|
|
39
|
+
outboxTableName: 'email.outbox',
|
|
40
|
+
},
|
|
41
|
+
webhooks: [
|
|
42
|
+
{
|
|
43
|
+
id: 'github-push', kind: 'webhook', handlerFile: 'src/handlers/github-push.ts',
|
|
44
|
+
path: '/github/push', provider: 'github',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
subscribers: [
|
|
48
|
+
{
|
|
49
|
+
id: 'on-user-created', kind: 'subscriber', handlerFile: 'src/handlers/on-user-created.ts',
|
|
50
|
+
event: 'auth.user.created', semverRange: '>=1',
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
schedules: [
|
|
54
|
+
{
|
|
55
|
+
id: 'daily-digest', kind: 'schedule', handlerFile: 'src/handlers/daily-digest.ts',
|
|
56
|
+
cron: 'rate(1 day)', enabled: true,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
jobs: [
|
|
60
|
+
{
|
|
61
|
+
id: 'send-email', kind: 'job', handlerFile: 'src/handlers/send-email.ts',
|
|
62
|
+
maxRetries: 4, visibilityTimeoutSeconds: 120,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'sync-users', kind: 'job', handlerFile: 'src/handlers/sync-users.ts',
|
|
66
|
+
maxRetries: 3, visibilityTimeoutSeconds: 30,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'no-retry', kind: 'job', handlerFile: 'src/handlers/no-retry.ts',
|
|
70
|
+
maxRetries: 0, visibilityTimeoutSeconds: 30,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
actions: [
|
|
74
|
+
{
|
|
75
|
+
id: 'get-users', kind: 'action', handlerFile: 'src/handlers/get-users.ts',
|
|
76
|
+
backendAccess: 'domain',
|
|
77
|
+
exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
|
|
78
|
+
idempotent: false,
|
|
79
|
+
inputSchema: { type: 'object', properties: {} },
|
|
80
|
+
outputSchema: { type: 'object', properties: {} },
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
integrations: [],
|
|
84
|
+
events: [],
|
|
85
|
+
};
|
|
86
|
+
describe('DomainStack jobs/outbox wiring (#5294)', () => {
|
|
87
|
+
const app = new cdk.App();
|
|
88
|
+
const stack = new DomainStack(app, 'JobsTestDomainStack', {
|
|
89
|
+
registry: jobsRegistry,
|
|
90
|
+
eventBusArn,
|
|
91
|
+
projectId: 'Test',
|
|
92
|
+
envCode: 'Dev',
|
|
93
|
+
alarmSnsTopicArn: 'arn:aws:sns:eu-north-1:123456789012:alerts',
|
|
94
|
+
});
|
|
95
|
+
const template = Template.fromStack(stack);
|
|
96
|
+
function domainLambdaEnvVars() {
|
|
97
|
+
const lambdas = template.findResources('AWS::Lambda::Function');
|
|
98
|
+
return Object.entries(lambdas)
|
|
99
|
+
.filter(([id]) => !id.includes('Health') && !id.includes('Ready') && !id.includes('LogRetention'))
|
|
100
|
+
.map(([, res]) => res.Properties.Environment?.Variables ?? {});
|
|
101
|
+
}
|
|
102
|
+
it('injects TIB_JOB_QUEUE_URLS into every producer Lambda environment', () => {
|
|
103
|
+
const envs = domainLambdaEnvVars();
|
|
104
|
+
expect(envs.length).toBeGreaterThan(0);
|
|
105
|
+
for (const env of envs) {
|
|
106
|
+
expect(env.TIB_JOB_QUEUE_URLS).toBeDefined();
|
|
107
|
+
expect(JSON.stringify(env.TIB_JOB_QUEUE_URLS)).toContain('send-email');
|
|
108
|
+
expect(JSON.stringify(env.TIB_JOB_QUEUE_URLS)).toContain('sync-users');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
it('injects TIB_OUTBOX_TABLE when the domain declares an outbox', () => {
|
|
112
|
+
const envs = domainLambdaEnvVars();
|
|
113
|
+
for (const env of envs) {
|
|
114
|
+
expect(env.TIB_OUTBOX_TABLE).toBe('email.outbox');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
it('maps defineJob.maxRetries to maxReceiveCount = maxRetries + 1 (retries AFTER first delivery)', () => {
|
|
118
|
+
const queues = template.findResources('AWS::SQS::Queue');
|
|
119
|
+
const redriveCounts = Object.values(queues)
|
|
120
|
+
.map(q => q.Properties.RedrivePolicy?.maxReceiveCount)
|
|
121
|
+
.filter((v) => v !== undefined)
|
|
122
|
+
.map(v => String(v));
|
|
123
|
+
// send-email maxRetries: 4 → 5 deliveries total
|
|
124
|
+
expect(redriveCounts).toContain('5');
|
|
125
|
+
// sync-users default maxRetries: 3 → 4 deliveries total
|
|
126
|
+
expect(redriveCounts).toContain('4');
|
|
127
|
+
// no-retry maxRetries: 0 → exactly 1 delivery (zero retries is valid)
|
|
128
|
+
expect(redriveCounts).toContain('1');
|
|
129
|
+
// No queue can carry a receive count of 0 (SQS rejects it)
|
|
130
|
+
expect(redriveCounts).not.toContain('0');
|
|
131
|
+
});
|
|
132
|
+
it('enables SQS partial-batch failure reporting on JOB event-source mappings (batch stays 1)', () => {
|
|
133
|
+
const mappings = Object.values(template.findResources('AWS::Lambda::EventSourceMapping'));
|
|
134
|
+
// Filter to JOB mappings only (the subscriber mapping legitimately uses
|
|
135
|
+
// batchSize 10 — job mappings are the #5294 contract).
|
|
136
|
+
const jobMappings = mappings.filter(m => JSON.stringify(m.Properties.EventSourceArn ?? '').includes('JobQueue'));
|
|
137
|
+
expect(jobMappings.length).toBeGreaterThan(0);
|
|
138
|
+
for (const m of jobMappings) {
|
|
139
|
+
const props = m.Properties;
|
|
140
|
+
expect(props.BatchSize).toBe(1);
|
|
141
|
+
// FunctionResponseTypes 'ReportBatchItemFailures' — safe future batch growth
|
|
142
|
+
expect(props.FunctionResponseTypes).toContain('ReportBatchItemFailures');
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
it('injects TIB_JOB_DLQ_ARNS (jobId → DLQ ARN) for the ctx.jobs.replayDlq operator path', () => {
|
|
146
|
+
const envs = domainLambdaEnvVars();
|
|
147
|
+
for (const env of envs) {
|
|
148
|
+
expect(env.TIB_JOB_DLQ_ARNS).toBeDefined();
|
|
149
|
+
const raw = JSON.stringify(env.TIB_JOB_DLQ_ARNS);
|
|
150
|
+
// Fn::ToJsonString synthesizes to an Fn::Join token — assert the
|
|
151
|
+
// resolved literals (job ids + the DLQ logical-id references) are present.
|
|
152
|
+
expect(raw).toContain('no-retry');
|
|
153
|
+
expect(raw).toContain('send-email');
|
|
154
|
+
expect(raw).toContain('sync-users');
|
|
155
|
+
expect(raw).toContain('JobDlq');
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
it('grants sqs:StartMessageMoveTask scoped to the domain DLQ ARNs (operator replay, no wildcard)', () => {
|
|
159
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
160
|
+
const replayStatements = policies.flatMap(p => p.Properties.PolicyDocument.Statement
|
|
161
|
+
.filter(s => (Array.isArray(s.Action) ? s.Action : [s.Action]).includes('sqs:StartMessageMoveTask')));
|
|
162
|
+
expect(replayStatements.length).toBeGreaterThan(0);
|
|
163
|
+
for (const s of replayStatements) {
|
|
164
|
+
const res = JSON.stringify(s.Resource ?? '');
|
|
165
|
+
expect(res).not.toBe('"*"');
|
|
166
|
+
expect(res).toContain('JobDlq'); // scoped to the job DLQ logical ids
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
it('includes webhook handlers in the producer sqs:SendMessage grant (ctx.jobs is hydrated for webhooks)', () => {
|
|
170
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
171
|
+
const webhookPolicy = policies.find(p => p.Properties.PolicyDocument.Statement
|
|
172
|
+
.some(s => (Array.isArray(s.Action) ? s.Action : [s.Action]).includes('sqs:SendMessage')));
|
|
173
|
+
expect(webhookPolicy).toBeDefined();
|
|
174
|
+
});
|
|
175
|
+
it('preserves the configured visibility timeout and keeps encryption enabled', () => {
|
|
176
|
+
const queues = template.findResources('AWS::SQS::Queue');
|
|
177
|
+
const mainQueues = Object.values(queues).filter(q => !String(q.Properties.QueueName ?? '').includes('dlq'));
|
|
178
|
+
expect(mainQueues.some(q => q.Properties.VisibilityTimeout === 120)).toBe(true);
|
|
179
|
+
// SQS-managed encryption is the default — every queue must declare it (no plaintext queues).
|
|
180
|
+
for (const q of Object.values(queues)) {
|
|
181
|
+
expect(q.Properties.SqsManagedSseEnabled).toBe(true);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
it('grants producers sqs:SendMessage scoped to the domain queue ARNs (no wildcard SQS)', () => {
|
|
185
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
186
|
+
const sendStatements = policies.flatMap(p => p.Properties.PolicyDocument.Statement
|
|
187
|
+
.filter(s => (Array.isArray(s.Action) ? s.Action : [s.Action]).includes('sqs:SendMessage')));
|
|
188
|
+
expect(sendStatements.length).toBeGreaterThan(0);
|
|
189
|
+
// The producer grant targets THIS domain's job queue ARNs (logical ids
|
|
190
|
+
// contain "JobQueue"), not the per-handler DLQ grants.
|
|
191
|
+
const producerStatement = sendStatements.find(s => JSON.stringify(s.Resource ?? '').includes('JobQueue'));
|
|
192
|
+
expect(producerStatement).toBeDefined();
|
|
193
|
+
// Never a wildcard — every sqs:SendMessage resource is an exact queue ARN.
|
|
194
|
+
for (const s of sendStatements) {
|
|
195
|
+
const res = JSON.stringify(s.Resource ?? '');
|
|
196
|
+
expect(res).not.toBe('"*"');
|
|
197
|
+
expect(res).not.toContain('*');
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
it('creates standard DLQ depth + oldest-age alarms for each job', () => {
|
|
201
|
+
const alarms = Object.values(template.findResources('AWS::CloudWatch::Alarm'))
|
|
202
|
+
.map(a => a.Properties.AlarmName);
|
|
203
|
+
expect(alarms.some(name => name.includes('-job-send-email-dlq-depth'))).toBe(true);
|
|
204
|
+
expect(alarms.some(name => name.includes('-job-send-email-dlq-oldest-age'))).toBe(true);
|
|
205
|
+
expect(alarms.some(name => name.includes('-job-sync-users-dlq-depth'))).toBe(true);
|
|
206
|
+
expect(alarms.some(name => name.includes('-job-sync-users-dlq-oldest-age'))).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
it('wires DLQ alarms to the SNS topic when one is provided', () => {
|
|
209
|
+
const alarms = template.findResources('AWS::CloudWatch::Alarm');
|
|
210
|
+
const depthAlarm = Object.entries(alarms).find(([, a]) => a.Properties.AlarmName.includes('-job-send-email-dlq-depth'));
|
|
211
|
+
expect(depthAlarm).toBeDefined();
|
|
212
|
+
expect(JSON.stringify(depthAlarm[1].Properties.AlarmActions)).toContain('alerts');
|
|
213
|
+
});
|
|
214
|
+
it('does not expose any wildcard SQS policy action', () => {
|
|
215
|
+
const policies = Object.values(template.findResources('AWS::IAM::Policy'));
|
|
216
|
+
const sqsStatements = policies.flatMap(p => p.Properties.PolicyDocument.Statement
|
|
217
|
+
.filter(s => {
|
|
218
|
+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
|
|
219
|
+
return actions.some(a => typeof a === 'string' && a.startsWith('sqs:'));
|
|
220
|
+
}));
|
|
221
|
+
for (const s of sqsStatements) {
|
|
222
|
+
const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
|
|
223
|
+
for (const action of actions) {
|
|
224
|
+
expect(action).not.toBe('sqs:*');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as cdk from 'aws-cdk-lib';
|
|
1
2
|
import * as sqs from 'aws-cdk-lib/aws-sqs';
|
|
2
3
|
import { Construct } from 'constructs';
|
|
3
4
|
import { LambdaFactory } from '../lambda-factory.js';
|
|
@@ -13,10 +14,21 @@ export interface JobConstructProps {
|
|
|
13
14
|
lambdaFactory: LambdaFactory;
|
|
14
15
|
/** Builder for generating IAM policies. */
|
|
15
16
|
iamBuilder: IamPolicyBuilder;
|
|
17
|
+
/** Whether queues/DLQs use a customer-managed KMS key. Defaults to SQS-managed encryption. */
|
|
18
|
+
enableCmk?: boolean;
|
|
19
|
+
/** KMS key used when enableCmk is true. */
|
|
20
|
+
encryptionKey?: cdk.aws_kms.IKey;
|
|
16
21
|
}
|
|
17
22
|
/**
|
|
18
23
|
* CDK Construct that synthesizes Lambda functions with SQS queues, dead-letter queues,
|
|
19
24
|
* and event source mappings for background jobs defined in the domain registry.
|
|
25
|
+
*
|
|
26
|
+
* Issue #5294: each job's SQS redrive policy honors `defineJob.maxRetries` as
|
|
27
|
+
* RETRY ATTEMPTS AFTER THE FIRST DELIVERY (`maxReceiveCount = maxRetries + 1`,
|
|
28
|
+
* minimum 1 so zero retries is valid), the configured `visibilityTimeoutSeconds`
|
|
29
|
+
* is preserved, queue/DLQ encryption is always enabled (KMS when `enableCmk`,
|
|
30
|
+
* otherwise SQS-managed), and job event-source mappings report partial batch
|
|
31
|
+
* failures so a future batch-size increase stays safe.
|
|
20
32
|
*/
|
|
21
33
|
export declare class JobConstruct extends Construct {
|
|
22
34
|
/**
|
|
@@ -5,6 +5,13 @@ import { Construct } from 'constructs';
|
|
|
5
5
|
/**
|
|
6
6
|
* CDK Construct that synthesizes Lambda functions with SQS queues, dead-letter queues,
|
|
7
7
|
* and event source mappings for background jobs defined in the domain registry.
|
|
8
|
+
*
|
|
9
|
+
* Issue #5294: each job's SQS redrive policy honors `defineJob.maxRetries` as
|
|
10
|
+
* RETRY ATTEMPTS AFTER THE FIRST DELIVERY (`maxReceiveCount = maxRetries + 1`,
|
|
11
|
+
* minimum 1 so zero retries is valid), the configured `visibilityTimeoutSeconds`
|
|
12
|
+
* is preserved, queue/DLQ encryption is always enabled (KMS when `enableCmk`,
|
|
13
|
+
* otherwise SQS-managed), and job event-source mappings report partial batch
|
|
14
|
+
* failures so a future batch-size increase stays safe.
|
|
8
15
|
*/
|
|
9
16
|
export class JobConstruct extends Construct {
|
|
10
17
|
/**
|
|
@@ -24,23 +31,40 @@ export class JobConstruct extends Construct {
|
|
|
24
31
|
const pascalId = toPascalCase(entry.id);
|
|
25
32
|
const dlq = new sqs.Queue(this, `${pascalId}Dlq`, {
|
|
26
33
|
retentionPeriod: cdk.Duration.days(14),
|
|
34
|
+
encryption: props.enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
|
|
35
|
+
encryptionMasterKey: props.enableCmk ? props.encryptionKey : undefined,
|
|
27
36
|
});
|
|
28
37
|
const queue = new sqs.Queue(this, `${pascalId}Queue`, {
|
|
29
|
-
visibilityTimeout: cdk.Duration.seconds(entry.visibilityTimeoutSeconds),
|
|
38
|
+
visibilityTimeout: cdk.Duration.seconds(entry.visibilityTimeoutSeconds ?? 30),
|
|
30
39
|
deadLetterQueue: {
|
|
31
40
|
queue: dlq,
|
|
32
|
-
maxReceiveCount: entry.maxRetries,
|
|
41
|
+
maxReceiveCount: maxReceiveCountForJob(entry.maxRetries),
|
|
33
42
|
},
|
|
43
|
+
encryption: props.enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
|
|
44
|
+
encryptionMasterKey: props.enableCmk ? props.encryptionKey : undefined,
|
|
34
45
|
});
|
|
35
46
|
this.queues.set(entry.id, queue);
|
|
36
47
|
const fn = props.lambdaFactory.createFunction(entry, entry.deployment);
|
|
37
48
|
props.iamBuilder
|
|
38
49
|
.forJob({ queueArn: queue.queueArn, dlqArn: dlq.queueArn })
|
|
39
50
|
.forEach((s) => fn.addToRolePolicy(s));
|
|
40
|
-
fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
|
|
51
|
+
fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
|
|
52
|
+
batchSize: 1,
|
|
53
|
+
reportBatchItemFailures: true,
|
|
54
|
+
}));
|
|
41
55
|
}
|
|
42
56
|
}
|
|
43
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Map `defineJob.maxRetries` to the SQS redrive policy `maxReceiveCount`.
|
|
60
|
+
*
|
|
61
|
+
* `maxRetries` is the number of retry attempts AFTER the first delivery, so
|
|
62
|
+
* the receive count is `maxRetries + 1` (minimum 1 — zero retries is valid).
|
|
63
|
+
*/
|
|
64
|
+
function maxReceiveCountForJob(maxRetries) {
|
|
65
|
+
const retries = Number.isFinite(maxRetries) && maxRetries !== undefined ? maxRetries : 3;
|
|
66
|
+
return Math.max(1, retries + 1);
|
|
67
|
+
}
|
|
44
68
|
/**
|
|
45
69
|
* Convert a kebab-case or snake_case string to PascalCase.
|
|
46
70
|
* @param s - Input string to convert
|
|
@@ -45,6 +45,13 @@ export interface GroupedLambdaProps {
|
|
|
45
45
|
environment: Record<string, string>;
|
|
46
46
|
/** ARN of the project-scoped EventBridge bus. */
|
|
47
47
|
eventBusArn: string;
|
|
48
|
+
/**
|
|
49
|
+
* Name of the project-scoped EventBridge bus. Injected as `EVENT_BUS_NAME`
|
|
50
|
+
* so the runtime's `ctx.publish` targets the shared bus instead of falling
|
|
51
|
+
* back to the AWS 'default' bus (#5282). When omitted, the name is derived
|
|
52
|
+
* from `eventBusArn` (e.g. `arn:...:event-bus/my-bus` → `my-bus`).
|
|
53
|
+
*/
|
|
54
|
+
eventBusName?: string;
|
|
48
55
|
/** VPC to place Lambdas in — required for Aurora connectivity. */
|
|
49
56
|
vpc?: ec2.IVpc;
|
|
50
57
|
/** Security groups to attach to the Lambda(s). */
|
|
@@ -200,6 +200,7 @@ export function createGroupedLambdas(scope, props) {
|
|
|
200
200
|
POWERTOOLS_LOG_LEVEL: 'INFO',
|
|
201
201
|
TIB_HANDLER_ID: entry.id,
|
|
202
202
|
TIB_EVENT_BUS_ARN: props.eventBusArn,
|
|
203
|
+
EVENT_BUS_NAME: resolveEventBusName(props.eventBusName, props.eventBusArn),
|
|
203
204
|
},
|
|
204
205
|
timeout: cdk.Duration.seconds(30),
|
|
205
206
|
memorySize: 256,
|
|
@@ -226,6 +227,7 @@ export function createGroupedLambdas(scope, props) {
|
|
|
226
227
|
POWERTOOLS_LOG_LEVEL: 'INFO',
|
|
227
228
|
TIB_HANDLER_MAP: JSON.stringify(props.handlerEntries.map(e => e.id)),
|
|
228
229
|
TIB_EVENT_BUS_ARN: props.eventBusArn,
|
|
230
|
+
EVENT_BUS_NAME: resolveEventBusName(props.eventBusName, props.eventBusArn),
|
|
229
231
|
},
|
|
230
232
|
timeout: cdk.Duration.seconds(30),
|
|
231
233
|
memorySize: 256,
|
|
@@ -235,6 +237,15 @@ export function createGroupedLambdas(scope, props) {
|
|
|
235
237
|
}),
|
|
236
238
|
];
|
|
237
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Resolves the EventBridge bus name injected as `EVENT_BUS_NAME`.
|
|
242
|
+
* Prefers the explicitly supplied name (avoids ARN parsing when the ARN is a
|
|
243
|
+
* dynamic reference); falls back to deriving the name from the ARN tail
|
|
244
|
+
* (`arn:...:event-bus/{name}` → `{name}`).
|
|
245
|
+
*/
|
|
246
|
+
function resolveEventBusName(eventBusName, eventBusArn) {
|
|
247
|
+
return eventBusName ?? cdk.Fn.select(1, cdk.Fn.split('/', eventBusArn));
|
|
248
|
+
}
|
|
238
249
|
function toLogRetention(days) {
|
|
239
250
|
const map = {
|
|
240
251
|
30: logs.RetentionDays.ONE_MONTH,
|
|
@@ -329,6 +340,7 @@ export function createGroupedApiActionLambda(scope, props) {
|
|
|
329
340
|
POWERTOOLS_SERVICE_NAME: serviceName,
|
|
330
341
|
POWERTOOLS_LOG_LEVEL: 'INFO',
|
|
331
342
|
TIB_EVENT_BUS_ARN: props.eventBusArn,
|
|
343
|
+
EVENT_BUS_NAME: resolveEventBusName(props.eventBusName, props.eventBusArn),
|
|
332
344
|
},
|
|
333
345
|
timeout: cdk.Duration.seconds(30),
|
|
334
346
|
memorySize: 256,
|
|
@@ -58,6 +58,35 @@ export declare class IamPolicyBuilder {
|
|
|
58
58
|
* @returns Array of policy statements for SQS message receive/delete and DLQ send access.
|
|
59
59
|
*/
|
|
60
60
|
forJob(params: QueuePolicyParams): iam.PolicyStatement[];
|
|
61
|
+
/**
|
|
62
|
+
* Returns IAM policy statements for PRODUCER handlers that enqueue jobs via
|
|
63
|
+
* `ctx.jobs.enqueue(...)`. Grants `sqs:SendMessage` scoped to the exact
|
|
64
|
+
* queue ARNs of THIS domain's declared `defineJob` queues — never a
|
|
65
|
+
* wildcard. Applies to action, subscriber, schedule, job, and webhook roles
|
|
66
|
+
* because the runtime exposes `ctx.jobs` to all of them.
|
|
67
|
+
* @param queueArns - Exact ARNs of the domain's declared job queues.
|
|
68
|
+
* @returns Array of policy statements (empty when there are no queues).
|
|
69
|
+
*/
|
|
70
|
+
forJobProducer(queueArns: string[]): iam.PolicyStatement[];
|
|
71
|
+
/**
|
|
72
|
+
* Returns IAM policy statements for the operator DLQ redrive operation
|
|
73
|
+
* (`ctx.jobs.replayDlq`, SQS `StartMessageMoveTask`). Scoped to the exact
|
|
74
|
+
* DLQ ARNs of THIS domain's declared `defineJob` queues — never a wildcard.
|
|
75
|
+
* The move-task destination is derived by SQS from the DLQ's redrive
|
|
76
|
+
* policy, so no destination grant is needed.
|
|
77
|
+
* @param dlqArns - Exact ARNs of the domain's job DLQs.
|
|
78
|
+
* @returns Array of policy statements (empty when there are no DLQs).
|
|
79
|
+
*/
|
|
80
|
+
forDlqReplay(dlqArns: string[]): iam.PolicyStatement[];
|
|
81
|
+
/**
|
|
82
|
+
* Returns IAM policy statements for SQS KMS envelope encryption on the
|
|
83
|
+
* domain CMK (enableCmk mode). Producers need `kms:GenerateDataKey` to
|
|
84
|
+
* encrypt a message; consumers need `kms:Decrypt` + `kms:GenerateDataKey`
|
|
85
|
+
* to decrypt it. Both are granted on the exact queue key ARN.
|
|
86
|
+
* @param keyArn - ARN of the domain CMK used to encrypt the queues.
|
|
87
|
+
* @returns Array of policy statements (exact KMS operations on the key).
|
|
88
|
+
*/
|
|
89
|
+
forSqsKmsEncryption(keyArn: string): iam.PolicyStatement[];
|
|
61
90
|
/**
|
|
62
91
|
* Returns IAM policy statements for callable action handlers.
|
|
63
92
|
* @param crossDomainActionArns - Optional ARNs of other domain action Lambdas to invoke.
|
|
@@ -91,6 +91,60 @@ export class IamPolicyBuilder {
|
|
|
91
91
|
}),
|
|
92
92
|
];
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Returns IAM policy statements for PRODUCER handlers that enqueue jobs via
|
|
96
|
+
* `ctx.jobs.enqueue(...)`. Grants `sqs:SendMessage` scoped to the exact
|
|
97
|
+
* queue ARNs of THIS domain's declared `defineJob` queues — never a
|
|
98
|
+
* wildcard. Applies to action, subscriber, schedule, job, and webhook roles
|
|
99
|
+
* because the runtime exposes `ctx.jobs` to all of them.
|
|
100
|
+
* @param queueArns - Exact ARNs of the domain's declared job queues.
|
|
101
|
+
* @returns Array of policy statements (empty when there are no queues).
|
|
102
|
+
*/
|
|
103
|
+
forJobProducer(queueArns) {
|
|
104
|
+
if (queueArns.length === 0)
|
|
105
|
+
return [];
|
|
106
|
+
return [
|
|
107
|
+
new iam.PolicyStatement({
|
|
108
|
+
actions: ['sqs:SendMessage'],
|
|
109
|
+
resources: queueArns,
|
|
110
|
+
}),
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Returns IAM policy statements for the operator DLQ redrive operation
|
|
115
|
+
* (`ctx.jobs.replayDlq`, SQS `StartMessageMoveTask`). Scoped to the exact
|
|
116
|
+
* DLQ ARNs of THIS domain's declared `defineJob` queues — never a wildcard.
|
|
117
|
+
* The move-task destination is derived by SQS from the DLQ's redrive
|
|
118
|
+
* policy, so no destination grant is needed.
|
|
119
|
+
* @param dlqArns - Exact ARNs of the domain's job DLQs.
|
|
120
|
+
* @returns Array of policy statements (empty when there are no DLQs).
|
|
121
|
+
*/
|
|
122
|
+
forDlqReplay(dlqArns) {
|
|
123
|
+
if (dlqArns.length === 0)
|
|
124
|
+
return [];
|
|
125
|
+
return [
|
|
126
|
+
new iam.PolicyStatement({
|
|
127
|
+
actions: ['sqs:StartMessageMoveTask'],
|
|
128
|
+
resources: dlqArns,
|
|
129
|
+
}),
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Returns IAM policy statements for SQS KMS envelope encryption on the
|
|
134
|
+
* domain CMK (enableCmk mode). Producers need `kms:GenerateDataKey` to
|
|
135
|
+
* encrypt a message; consumers need `kms:Decrypt` + `kms:GenerateDataKey`
|
|
136
|
+
* to decrypt it. Both are granted on the exact queue key ARN.
|
|
137
|
+
* @param keyArn - ARN of the domain CMK used to encrypt the queues.
|
|
138
|
+
* @returns Array of policy statements (exact KMS operations on the key).
|
|
139
|
+
*/
|
|
140
|
+
forSqsKmsEncryption(keyArn) {
|
|
141
|
+
return [
|
|
142
|
+
new iam.PolicyStatement({
|
|
143
|
+
actions: ['kms:Decrypt', 'kms:GenerateDataKey'],
|
|
144
|
+
resources: [keyArn],
|
|
145
|
+
}),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
94
148
|
/**
|
|
95
149
|
* Returns IAM policy statements for callable action handlers.
|
|
96
150
|
* @param crossDomainActionArns - Optional ARNs of other domain action Lambdas to invoke.
|
package/dist/registry.d.ts
CHANGED
|
@@ -262,6 +262,13 @@ export interface DomainRegistryEntry extends BaseRegistryEntry {
|
|
|
262
262
|
tenancy: string;
|
|
263
263
|
/** Optional default deployment configuration for the domain. */
|
|
264
264
|
defaultDeployment?: SerialDeploymentConfig;
|
|
265
|
+
/**
|
|
266
|
+
* Optional transactional outbox table (schema-qualified, e.g. `email.outbox`).
|
|
267
|
+
* When declared, the CDK packer injects `TIB_OUTBOX_TABLE` so the runtime
|
|
268
|
+
* hydrates a real `ctx.outbox` (append/dispatch) for this domain (#5294).
|
|
269
|
+
* The table must be created by the domain's `outbox-table` migration.
|
|
270
|
+
*/
|
|
271
|
+
outboxTableName?: string;
|
|
265
272
|
}
|
|
266
273
|
/**
|
|
267
274
|
* Union type of all registry entry kinds.
|