@mettlecast/domain-cdk-packer 0.2.105 → 0.2.107

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.
@@ -820,14 +820,16 @@ export class DomainStack extends cdk.Stack {
820
820
  }
821
821
  }
822
822
  // Grant SES SendEmail permission when a verified sending address is configured.
823
- // Scoped to the SES identity ARN derived from SES_FROM_EMAIL (e.g. an
824
- // address or domain identity) — least-privilege. Falls back to 'arn:aws:ses:*:*:*'
825
- // only when the address is not address-shaped (tests/local).
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.
826
829
  if (process.env['SES_FROM_EMAIL']) {
827
830
  const sesFrom = process.env['SES_FROM_EMAIL'];
828
- const sesIdentity = sesFrom.includes('@')
829
- ? `arn:aws:ses:${this.region}:${this.account}:identity/${sesFrom}`
830
- : `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}`;
831
833
  for (const fn of allDomainLambdas) {
832
834
  fn.addToRolePolicy(new iam.PolicyStatement({
833
835
  actions: ['ses:SendEmail', 'ses:SendRawEmail'],
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cdk-packer",
3
- "version": "0.2.105",
3
+ "version": "0.2.107",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",