@mettlecast/domain-cdk-packer 0.2.104 → 0.2.106
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 +166 -23
- package/dist/__tests__/domain-stack.test.js +65 -1
- 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/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';
|
|
@@ -204,6 +207,56 @@ export class DomainStack extends cdk.Stack {
|
|
|
204
207
|
if (Object.keys(crossDomainActionArns).length > 0) {
|
|
205
208
|
environment.TIB_ACTION_LAMBDA_ARNS = JSON.stringify(crossDomainActionArns);
|
|
206
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
|
+
}
|
|
207
260
|
// Deterministic physical name for THIS domain's internal-action grouped
|
|
208
261
|
// Lambda so other stacks can construct its ARN for cross-domain calls.
|
|
209
262
|
const actionLambdaName = `${resourceBaseName}-action`;
|
|
@@ -555,30 +608,55 @@ export class DomainStack extends cdk.Stack {
|
|
|
555
608
|
const jobLambdaById = jobHandlers.byId;
|
|
556
609
|
jobLambdas = jobHandlers.lambdas;
|
|
557
610
|
this.lambdaArns[`${domainId}-job`] = jobLambdas[0].functionArn;
|
|
558
|
-
// 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)] : [];
|
|
559
617
|
for (const job of registry.jobs) {
|
|
560
618
|
const pascalId = toPascalCase(job.id);
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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,
|
|
566
635
|
});
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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,
|
|
577
649
|
});
|
|
578
|
-
|
|
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).
|
|
579
656
|
const fn = jobLambdaById.get(job.id);
|
|
580
657
|
fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
|
|
581
658
|
batchSize: 1,
|
|
659
|
+
reportBatchItemFailures: true,
|
|
582
660
|
}));
|
|
583
661
|
// Grant IAM permissions for this queue
|
|
584
662
|
const queuePolicies = iamBuilder.forJob({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
|
|
@@ -654,6 +732,44 @@ export class DomainStack extends cdk.Stack {
|
|
|
654
732
|
addRouteToApi(this, fn, `/${domainId}${action.exposure.path}`, [toHttpMethod(action.exposure.method)], this.httpApi.httpApiId, routeAuth);
|
|
655
733
|
}
|
|
656
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
|
+
}
|
|
657
773
|
// Grant all domain Lambdas read/write access to the per-domain table and bucket
|
|
658
774
|
const allDomainLambdas = [
|
|
659
775
|
...webhookLambdas, ...subscriberLambdas,
|
|
@@ -663,6 +779,18 @@ export class DomainStack extends cdk.Stack {
|
|
|
663
779
|
domainTable.grantReadWriteData(fn);
|
|
664
780
|
domainBucket.grantReadWrite(fn);
|
|
665
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
|
+
}
|
|
666
794
|
// Grant Cognito Admin permissions when a user pool is configured.
|
|
667
795
|
// The auth domain's provision-cognito-user internal action needs
|
|
668
796
|
// AdminCreateUser, AdminSetUserPassword, and AdminGetUser to
|
|
@@ -692,14 +820,16 @@ export class DomainStack extends cdk.Stack {
|
|
|
692
820
|
}
|
|
693
821
|
}
|
|
694
822
|
// Grant SES SendEmail permission when a verified sending address is configured.
|
|
695
|
-
// Scoped to the SES identity ARN derived from SES_FROM_EMAIL
|
|
696
|
-
//
|
|
697
|
-
//
|
|
823
|
+
// Scoped to the SES identity ARN derived from SES_FROM_EMAIL — least-privilege.
|
|
824
|
+
// SES_FROM_EMAIL may be address-shaped (noreply@example.com) or a bare domain
|
|
825
|
+
// (example.com). The Email Domain settings tab always creates a *domain*
|
|
826
|
+
// identity, so an address-shaped value must be reduced to its domain part:
|
|
827
|
+
// scoping to `identity/noreply@example.com` would not match the created
|
|
828
|
+
// `identity/example.com` and ses:SendEmail would be denied at runtime.
|
|
698
829
|
if (process.env['SES_FROM_EMAIL']) {
|
|
699
830
|
const sesFrom = process.env['SES_FROM_EMAIL'];
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
: `arn:aws:ses:${this.region}:${this.account}:identity/${sesFrom}`;
|
|
831
|
+
const sesIdentityDomain = (sesFrom.includes('@') ? sesFrom.split('@')[1] : sesFrom).toLowerCase();
|
|
832
|
+
const sesIdentity = `arn:aws:ses:${this.region}:${this.account}:identity/${sesIdentityDomain}`;
|
|
703
833
|
for (const fn of allDomainLambdas) {
|
|
704
834
|
fn.addToRolePolicy(new iam.PolicyStatement({
|
|
705
835
|
actions: ['ses:SendEmail', 'ses:SendRawEmail'],
|
|
@@ -823,6 +953,19 @@ function toPascalCase(s) {
|
|
|
823
953
|
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
824
954
|
.join('');
|
|
825
955
|
}
|
|
956
|
+
/**
|
|
957
|
+
* Map `defineJob.maxRetries` to the SQS redrive policy `maxReceiveCount`.
|
|
958
|
+
*
|
|
959
|
+
* `maxRetries` is documented as the number of RETRY ATTEMPTS AFTER THE FIRST
|
|
960
|
+
* DELIVERY, so the receive count is `maxRetries + 1`. Zero retries is valid
|
|
961
|
+
* (exactly one delivery) and the receive count is clamped to a minimum of 1
|
|
962
|
+
* (SQS rejects 0). This mirrors the domain-runtime `defineJob` contract
|
|
963
|
+
* (issue #5294).
|
|
964
|
+
*/
|
|
965
|
+
function maxReceiveCountForJob(maxRetries) {
|
|
966
|
+
const retries = Number.isFinite(maxRetries) && maxRetries !== undefined ? maxRetries : 3;
|
|
967
|
+
return Math.max(1, retries + 1);
|
|
968
|
+
}
|
|
826
969
|
/**
|
|
827
970
|
* Convert HTTP method string to ApiGatewayV2 HttpMethod enum.
|
|
828
971
|
* @param method - HTTP method string (e.g., 'GET', 'POST').
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, afterAll } from 'vitest';
|
|
1
|
+
import { describe, it, expect, afterAll, afterEach } 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';
|
|
@@ -633,4 +633,68 @@ describe('DomainStack', () => {
|
|
|
633
633
|
expect(hasScopedPutEvents).toBe(true);
|
|
634
634
|
});
|
|
635
635
|
});
|
|
636
|
+
/**
|
|
637
|
+
* Review finding H1 (#5292): the ses:SendEmail/ses:SendRawEmail grant must
|
|
638
|
+
* scope to the *domain* SES identity that the Email Domain settings tab
|
|
639
|
+
* creates. SES_FROM_EMAIL is address-shaped (`noreply@example.com`) after
|
|
640
|
+
* sender activation — building `identity/noreply@example.com` would never
|
|
641
|
+
* match the created `identity/example.com` and email would be denied.
|
|
642
|
+
*/
|
|
643
|
+
describe('SES send grant scoped to the domain identity (#5292 H1)', () => {
|
|
644
|
+
const originalSesFromEmail = process.env.SES_FROM_EMAIL;
|
|
645
|
+
afterEach(() => {
|
|
646
|
+
if (originalSesFromEmail === undefined)
|
|
647
|
+
delete process.env.SES_FROM_EMAIL;
|
|
648
|
+
else
|
|
649
|
+
process.env.SES_FROM_EMAIL = originalSesFromEmail;
|
|
650
|
+
});
|
|
651
|
+
const sesResourceStrings = (template) => Object.values(template.findResources('AWS::IAM::Policy')).flatMap(p => p.Properties.PolicyDocument.Statement
|
|
652
|
+
.filter(s => s.Action?.includes('ses:SendEmail'))
|
|
653
|
+
.map(s => JSON.stringify(s.Resource)));
|
|
654
|
+
it('scopes an address-shaped SES_FROM_EMAIL to identity/example.com, never identity/noreply@example.com', () => {
|
|
655
|
+
process.env.SES_FROM_EMAIL = 'noreply@example.com';
|
|
656
|
+
const app = new cdk.App();
|
|
657
|
+
const stack = new DomainStack(app, 'TestDomainStackSesAddress', {
|
|
658
|
+
registry: minimalRegistry,
|
|
659
|
+
eventBusArn,
|
|
660
|
+
projectId: 'Test',
|
|
661
|
+
envCode: 'Dev',
|
|
662
|
+
});
|
|
663
|
+
const template = Template.fromStack(stack);
|
|
664
|
+
const resources = sesResourceStrings(template);
|
|
665
|
+
expect(resources.length).toBeGreaterThan(0);
|
|
666
|
+
for (const resource of resources) {
|
|
667
|
+
expect(resource).toContain('identity/example.com');
|
|
668
|
+
expect(resource).not.toContain('identity/noreply@example.com');
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
it('keeps plain-domain behavior: a bare domain SES_FROM_EMAIL scopes to identity/example.com', () => {
|
|
672
|
+
process.env.SES_FROM_EMAIL = 'example.com';
|
|
673
|
+
const app = new cdk.App();
|
|
674
|
+
const stack = new DomainStack(app, 'TestDomainStackSesDomain', {
|
|
675
|
+
registry: minimalRegistry,
|
|
676
|
+
eventBusArn,
|
|
677
|
+
projectId: 'Test',
|
|
678
|
+
envCode: 'Dev',
|
|
679
|
+
});
|
|
680
|
+
const template = Template.fromStack(stack);
|
|
681
|
+
const resources = sesResourceStrings(template);
|
|
682
|
+
expect(resources.length).toBeGreaterThan(0);
|
|
683
|
+
for (const resource of resources) {
|
|
684
|
+
expect(resource).toContain('identity/example.com');
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
it('omits the SES grant when SES_FROM_EMAIL is not set', () => {
|
|
688
|
+
delete process.env.SES_FROM_EMAIL;
|
|
689
|
+
const app = new cdk.App();
|
|
690
|
+
const stack = new DomainStack(app, 'TestDomainStackSesUnset', {
|
|
691
|
+
registry: minimalRegistry,
|
|
692
|
+
eventBusArn,
|
|
693
|
+
projectId: 'Test',
|
|
694
|
+
envCode: 'Dev',
|
|
695
|
+
});
|
|
696
|
+
const template = Template.fromStack(stack);
|
|
697
|
+
expect(sesResourceStrings(template)).toHaveLength(0);
|
|
698
|
+
});
|
|
699
|
+
});
|
|
636
700
|
});
|
|
@@ -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
|
|
@@ -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.
|