fix(infra): send proposals from seahaven.com domain identity (primary SES domain) (#230)

Switch the API Lambda's SES sender from the email identity proposals@seahavenind.com
to proposals@seahaven.com under the seahaven.com DOMAIN identity — the org's primary
sending domain for all workload stacks. The domain identity is account-shared and
managed out-of-band in seahaven-prod (Easy-DKIM verified in the seahaven.com Route53
zone; production-access request submitted), so this stack references its ARN instead
of creating an EmailIdentity (avoids per-stack duplication + cross-account DNS at
deploy). Send scoped to identity/seahaven.com with a required ses:FromAddress condition.
GPT-4.1 cross-review APPROVE. Drops the unused aws-ses import.

Follow-up: codify the seahaven.com identity in the seahaven-prod account baseline.
This commit is contained in:
Adam Moussa 2026-07-15 18:54:10 -04:00 • committed by GitHub
parent 590f0c075c
commit c19c7fda72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -5,7 +5,6 @@ import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as apigatewayv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; import * as apigatewayv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as iam from 'aws-cdk-lib/aws-iam'; import * as iam from 'aws-cdk-lib/aws-iam';
import * as ses from 'aws-cdk-lib/aws-ses';
import * as s3 from 'aws-cdk-lib/aws-s3'; import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sqs from 'aws-cdk-lib/aws-sqs'; import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as sns from 'aws-cdk-lib/aws-sns'; import * as sns from 'aws-cdk-lib/aws-sns';
@ -216,7 +215,7 @@ export class ComputeStack extends cdk.Stack {
Auth__CognitoDomain: `${config.cognitoDomainPrefix}.auth.${this.region}.amazoncognito.com`, Auth__CognitoDomain: `${config.cognitoDomainPrefix}.auth.${this.region}.amazoncognito.com`,
COGNITO_WEB_CLIENT_ID: props.webClientId, COGNITO_WEB_CLIENT_ID: props.webClientId,
COGNITO_MOBILE_CLIENT_ID: props.mobileClientId, COGNITO_MOBILE_CLIENT_ID: props.mobileClientId,
SES_FROM_ADDRESS: 'proposals@seahavenind.com', SES_FROM_ADDRESS: 'proposals@seahaven.com',
}, },
tracing: lambda.Tracing.ACTIVE, tracing: lambda.Tracing.ACTIVE,
logRetention: logs.RetentionDays.TWO_MONTHS, logRetention: logs.RetentionDays.TWO_MONTHS,
@ -234,31 +233,27 @@ export class ComputeStack extends cdk.Stack {
resources: [props.userPool.userPoolArn], resources: [props.userPool.userPoolArn],
})); }));
// PR4: SES email identity for proposal delivery. // SES sender: proposals@seahaven.com. seahaven.com is the org's PRIMARY sending
// NOTE: New SES identities start in the **sandbox**. While in sandbox mode the // domain for all workload stacks. The domain identity is an ACCOUNT-SHARED resource
// account can only send email to *verified* recipient addresses. To send to // managed OUT OF BAND in seahaven-prod (verified via Easy DKIM in the seahaven.com
// arbitrary recipients, submit a production-access request via the AWS SES console // Route53 zone; production-access request submitted 2026-07-15) — this stack
// (Support Center → "SES Sending Limits Increase"). The email identity itself // references it rather than owning it, so it is not re-created per stack and does not
// must also be verified — AWS sends a click-link email to the address below after // depend on cross-account DNS at deploy time. Do NOT create an EmailIdentity here
// deployment; someone with access to the mailbox must click it. // (CloudFormation would fail "already exists" against the out-of-band identity).
const sesFromAddress = 'proposals@seahavenind.com'; // TODO: codify the seahaven.com identity in the seahaven-prod account baseline.
const sesSenderIdentity = new ses.EmailIdentity(this, 'SesSenderIdentity', { const sesFromAddress = 'proposals@seahaven.com';
identity: ses.Identity.email(sesFromAddress),
});
// Grant the API Lambda least-privilege SES send permission, scoped to the // Least-privilege SES send: scope to the seahaven.com domain identity ARN. A domain
// sender identity ARN only (not ses:* / resource:*). // identity authorizes sending as ANY user@seahaven.com, so the ses:FromAddress
const senderIdentityArn = `arn:aws:ses:${this.region}:${this.account}:identity/${sesFromAddress}`; // condition is REQUIRED (not optional) to pin the Lambda to proposals@seahaven.com.
const senderIdentityArn = `arn:aws:ses:${this.region}:${this.account}:identity/seahaven.com`;
apiFunction.addToRolePolicy(new iam.PolicyStatement({ apiFunction.addToRolePolicy(new iam.PolicyStatement({
actions: ['ses:SendEmail', 'ses:SendRawEmail'], actions: ['ses:SendEmail', 'ses:SendRawEmail'],
resources: [senderIdentityArn], resources: [senderIdentityArn],
// Defense-in-depth (cross-review): only send AS this address, even if the
// identity scope is later widened.
conditions: { conditions: {
StringEquals: { 'ses:FromAddress': sesFromAddress }, StringEquals: { 'ses:FromAddress': sesFromAddress },
}, },
})); }));
void sesSenderIdentity;
// Fix: LAM-C1/INF-H1 — require IAM auth on Function URL (was authType NONE). // Fix: LAM-C1/INF-H1 — require IAM auth on Function URL (was authType NONE).
// NOTE: Lambda HTTP clients (suggestions, pdf-extract, pdf-generate, library-ingest) // NOTE: Lambda HTTP clients (suggestions, pdf-extract, pdf-generate, library-ingest)