diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index f956e0c..603eb7e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -46,7 +46,7 @@ jobs: uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@3cde673b9d05c0e68aac4d997d582f2543853d20 # main with: node-version: "24" - stacks: "dev-baseline" + stacks: "dev-baseline deploy-substrate-dev" stack-name: "seahaven-dev-baseline" secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_DEV }} @@ -55,7 +55,7 @@ jobs: uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@3cde673b9d05c0e68aac4d997d582f2543853d20 # main with: node-version: "24" - stacks: "prod-baseline dynamodb-cmk-prod alarm-topic-prod" + stacks: "prod-baseline dynamodb-cmk-prod alarm-topic-prod deploy-substrate-prod" stack-name: "seahaven-prod-baseline" secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_PROD }} diff --git a/README.md b/README.md index c507461..ac223fb 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ the TypeScript source — no separate compile step needed for `cdk synth` / | `tsconfig.json` | TypeScript compiler options (`outDir: cdk.out`) | | `package.json` | Pinned `aws-cdk-lib`, CDK CLI, and the `build` / `synth` / `diff` / `deploy` npm scripts | -`bin/app.ts` synthesizes eleven stacks across three regions and five accounts: +`bin/app.ts` synthesizes fifteen stacks across three regions and five accounts: | Construct id | Stack name | Account | Region | Source | |---|---|---|---|---| @@ -65,6 +65,10 @@ the TypeScript source — no separate compile step needed for `cdk synth` / | `security-baseline` | `seahaven-security-baseline` | 001520130573 | us-east-1 | `lib/member-baseline-stack.ts` | | `dev-baseline` | `seahaven-dev-baseline` | 710827005802 | us-east-1 | `lib/member-baseline-stack.ts` (orgManagedDetection) | | `prod-baseline` | `seahaven-prod-baseline` | 011934824531 | us-east-1 | `lib/member-baseline-stack.ts` (orgManagedDetection) | +| `deploy-substrate-prod` | `seahaven-deploy-substrate` | 011934824531 | us-east-1 | `lib/deploy-substrate-stack.ts` | +| `deploy-substrate-dev` | `seahaven-deploy-substrate` | 710827005802 | us-east-1 | `lib/deploy-substrate-stack.ts` | +| `dynamodb-cmk-prod` | `seahaven-dynamodb-cmk` | 011934824531 | us-east-1 | `lib/dynamodb-cmk-stack.ts` | +| `alarm-topic-prod` | `seahaven-alarm-topic` | 011934824531 | us-east-1 | `lib/alarm-topic-stack.ts` | Member-account stacks deploy with per-account credentials — the CD workflow runs one job per account, each assuming that account's OIDC deploy role. Local @@ -114,6 +118,45 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr ## What it deploys +### GitHub Actions deploy substrate (per account) + +`lib/deploy-substrate-stack.ts` + `lib/deploy-substrate/deploy-substrate.template.yaml` +deploy `seahaven-deploy-substrate` into each member account that hosts SAM +workloads (currently seahaven-prod and seahaven-dev). It contains the shared +account-level deploy plumbing: + +- the `seahaven-lambda-execution-boundary` permissions boundary (ceiling for + every SAM-generated Lambda execution role), +- the `github-cfn-execution-role` CloudFormation execution role that `cd-sam` + callers pass as `cfn-role-arn`, +- optionally the GitHub OIDC identity provider (`createOidcProvider: true`, + only for an account that does not already have one — one provider per URL + per account). + +The template is a verbatim extraction of the substrate section of +`Sea-Haven-Industries/.github/oidc-deploy-roles.yaml` (see the provenance +header in the template — mgmt's copy remains source of truth for 328440206208 +until its stacks migrate out; substrate changes while both are live must edit +both files). Per-repo `githubdeploy-*` deploy roles are deliberately NOT part +of the substrate — they are provisioned per repo at migration/onboarding time +so an account never carries trust relationships for repos that do not deploy +to it. + +**Onboarding a future account as a deploy target:** + +1. CDK-bootstrap the account (`npx cdk bootstrap aws:///us-east-1` + via `OrganizationAccountAccessRole`). +2. Create `githubdeploy-seahaven-org-baseline` in the account (same trust and + policy as the dev/prod copies) and add the repo secret + `AWS_DEPLOY_ROLE_ARN_`. +3. Add a `DeploySubstrateStack` instance in `bin/app.ts` + (`createOidcProvider: true` if the account has no GitHub OIDC provider) + and append its construct id to a new per-account job in + `.github/workflows/deploy.yaml` (explicit `stacks` selector, one job per + account). +4. Merge; the substrate deploys via CD. Per-repo deploy roles and app stacks + follow the cross-account migration playbook from there. + ### CloudTrail (audit finding C-1) | Resource | Logical ID | Notes | diff --git a/bin/app.ts b/bin/app.ts index 18b4194..4b65c4c 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -6,6 +6,7 @@ import { AlarmTopicStack } from "../lib/alarm-topic-stack"; import { BackupOffsiteStack } from "../lib/backup-offsite-stack"; import { BackupStack } from "../lib/backup-stack"; import { RegionalBaselineStack } from "../lib/regional-baseline-stack"; +import { DeploySubstrateStack } from "../lib/deploy-substrate-stack"; import { DynamoDbCmkStack } from "../lib/dynamodb-cmk-stack"; import { MemberBaselineStack } from "../lib/member-baseline-stack"; import { OrgGovernanceStack } from "../lib/org-governance-stack"; @@ -156,6 +157,32 @@ new MemberBaselineStack(app, "prod-baseline", { orgManagedDetection: true, }); +// ── Per-account GitHub Actions deploy substrate ────────────────────────────── +// The shared account-level deploy plumbing for SAM pipelines: permissions +// boundary + github-cfn-execution-role (+ optional OIDC provider). mgmt's +// copy lives in Sea-Haven-Industries/.github/oidc-deploy-roles.yaml and stays +// there until its stacks finish migrating out; these stacks are what let SAM +// repos (payments-dashboard, front-integrations, sh-openswe-traces, ...) +// target prod/dev at all. Per-repo githubdeploy-* roles are provisioned at +// each repo's migration time, never here. createOidcProvider stays false for +// both accounts (provider verified present in each, 2026-07-27); a FUTURE +// member account without one sets it true on its own instance. First-create +// precondition verified 2026-07-27: github-cfn-execution-role and the +// seahaven-lambda-execution-boundary policy both returned NoSuchEntity in +// 011934824531 AND 710827005802, so the named creates cannot collide with +// out-of-band copies. +new DeploySubstrateStack(app, "deploy-substrate-prod", { + stackName: "seahaven-deploy-substrate", + env: { account: PROD_ACCOUNT, region: "us-east-1" }, + createOidcProvider: false, +}); + +new DeploySubstrateStack(app, "deploy-substrate-dev", { + stackName: "seahaven-deploy-substrate", + env: { account: DEV_ACCOUNT, region: "us-east-1" }, + createOidcProvider: false, +}); + // ── Shared DynamoDB CMK (INFRA-95 / M-3) ───────────────────────────────────── // Dedicated, standalone stack so the customer-managed key for sensitive // finance/PII DynamoDB tables is an independent shared dependency for the owning diff --git a/lib/deploy-substrate-stack.ts b/lib/deploy-substrate-stack.ts new file mode 100644 index 0000000..e8a3530 --- /dev/null +++ b/lib/deploy-substrate-stack.ts @@ -0,0 +1,64 @@ +import * as cdk from "aws-cdk-lib"; +import * as cfninc from "aws-cdk-lib/cloudformation-include"; +import * as path from "path"; +import { Construct } from "constructs"; + +export interface DeploySubstrateStackProps extends cdk.StackProps { + /** + * Create the GitHub OIDC identity provider in this account. Leave false + * when the provider already exists (seahaven-prod and seahaven-dev both + * have it from their githubdeploy-* role provisioning) - an account can + * only hold ONE provider per URL, so creating a duplicate fails the deploy. + * Set true only for a brand-new account with no OIDC provider yet. + */ + createOidcProvider?: boolean; +} + +/** + * Per-account GitHub Actions deploy substrate: the shared account-level + * resources every SAM deploy pipeline needs - + * - GitHub OIDC identity provider (conditional, see props), + * - `seahaven-lambda-execution-boundary` permissions boundary (the ceiling + * applied to every SAM-generated Lambda execution role), + * - `github-cfn-execution-role` (the shared CloudFormation execution role + * that cd-sam callers pass as cfn-role-arn). + * + * Deliberately NOT here: per-repo githubdeploy-* roles. Those are provisioned + * per repo at migration/onboarding time (deploy-role-first playbook) so an + * account never accumulates trust relationships for repos that do not deploy + * to it. + * + * The resources come verbatim from the management account's reviewed + * oidc-deploy-roles.yaml substrate section via cloudformation-include, so the + * policy JSON that passed cross-review and security review deploys unchanged. + * See lib/deploy-substrate/deploy-substrate.template.yaml for the provenance + * and drift warning (mgmt's copy stays source of truth for 328440206208 until + * its stacks finish migrating out). + * + * Deploy-order note: the boundary and the execution role live in the SAME + * stack, and the role carries an explicit DependsOn on the boundary (the + * role only names the boundary ARN inside Condition strings, so CFN would + * otherwise infer no creation edge). App stacks (payments-dashboard, + * front-integrations, sh-openswe-traces, ...) can only target this account + * AFTER this stack is deployed there. + */ +export class DeploySubstrateStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: DeploySubstrateStackProps) { + super(scope, id, props); + + new cfninc.CfnInclude(this, "Substrate", { + templateFile: path.join( + __dirname, + "deploy-substrate", + "deploy-substrate.template.yaml", + ), + parameters: { + CreateOIDCProvider: props?.createOidcProvider ? "true" : "false", + }, + }); + + cdk.Tags.of(this).add("Project", "account-baseline"); + cdk.Tags.of(this).add("Owner", "adam@seahavenind.com"); + cdk.Tags.of(this).add("ManagedBy", "cdk"); + } +} diff --git a/lib/deploy-substrate/deploy-substrate.template.yaml b/lib/deploy-substrate/deploy-substrate.template.yaml new file mode 100644 index 0000000..2ce4f0a --- /dev/null +++ b/lib/deploy-substrate/deploy-substrate.template.yaml @@ -0,0 +1,892 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: >- + Per-account GitHub Actions deploy substrate for Sea Haven Industries: + the shared account-level resources every SAM deploy pipeline needs + (GitHub OIDC provider, Lambda execution permissions boundary, and the + shared CloudFormation execution role). Per-repo githubdeploy-* roles + are NOT here — they are provisioned per repo at migration/onboarding + time in the target account. + +# PROVENANCE / DRIFT WARNING +# The Resources below are a VERBATIM extraction of the substrate section +# (OIDC provider + LambdaExecutionBoundary + SamCfnExecutionRole) of +# Sea-Haven-Industries/.github/oidc-deploy-roles.yaml at commit 786dcfe8, +# which remains the deployed source of truth for the management account +# (328440206208) until that account's stacks finish migrating out. If a +# substrate resource must change while both copies are live, change BOTH +# files in the same piece of work. Documented deltas from the source: +# - unused GitHubOrg parameter dropped (only serves the per-repo roles +# left behind), +# - DependsOn: LambdaExecutionBoundary added to SamCfnExecutionRole (the +# role only names the boundary ARN inside Condition strings, so CFN +# infers no edge; first-create needs the boundary to exist first — moot +# for mgmt where both resources already exist, so mgmt's copy is +# deliberately unchanged), +# - DeletionPolicy/UpdateReplacePolicy Retain on the OIDC provider, +# - SECURITY FIX, deliberate divergence: iam:DeleteRolePermissionsBoundary +# removed from Sid IAMPutPermissionsBoundary and explicit Deny statements +# (DenyBoundaryTampering / DenyBoundaryPolicyEdit) added. The mgmt copy +# still carries the hole — verified live 2026-07-27 via +# simulate-principal-policy on the deployed mgmt role +# (iam:DeleteRolePermissionsBoundary = ALLOWED). New accounts must not be +# born with it. Remediating mgmt means changing a role that is actively +# executing production deploys, so it is tracked as a separate change +# with its own review gates rather than folded in here. Reconcile the two +# copies when that lands. +# +# This template is deployed via lib/deploy-substrate-stack.ts +# (cloudformation-include) as stack seahaven-deploy-substrate, once per +# member account that hosts SAM workloads. + +Parameters: + CreateOIDCProvider: + Type: String + Default: "false" + AllowedValues: ["true", "false"] + Description: Set to true only if the GitHub OIDC provider does not already exist in this account + +Conditions: + ShouldCreateOIDCProvider: !Equals [!Ref CreateOIDCProvider, "true"] + +Resources: + + # --------------------------------------------------------------------------- + # OIDC Provider (conditional — most accounts already have it; seahaven-prod + # and seahaven-dev both do, from their githubdeploy-* role provisioning) + # --------------------------------------------------------------------------- + GitHubOIDCProvider: + Type: AWS::IAM::OIDCProvider + Condition: ShouldCreateOIDCProvider + Properties: + Url: https://token.actions.githubusercontent.com + ClientIdList: + - sts.amazonaws.com + ThumbprintList: + - 6938fd4d98bab03faadb97b34396831e3780aea1 + # An account has exactly ONE provider per URL and every githubdeploy-* role + # trusts it. Retain so that flipping createOidcProvider back to false (or + # deleting this stack) can never delete the account's federation anchor and + # break every deploy into it. + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + + # --------------------------------------------------------------------------- + # Lambda execution permissions boundary (INFRA-103) + # + # This managed policy is the CEILING for every Lambda execution role that the + # five SAM stacks auto-generate via AWS::Serverless::Function. Applying it as + # PermissionsBoundary on those roles means the effective permissions are the + # intersection of the role's own policies and this boundary, so a misconfigured + # SAM role can never exceed what is listed here. + # + # The boundary is intentionally a SUPERSET of the union of all runtime + # permissions currently granted across the five stacks. Being slightly broad + # is the correct trade-off at this stage — a boundary that is too tight will + # break Lambda functions at runtime after deploy, which is worse than a slightly + # loose boundary that is tightened in a follow-up. + # + # Permission sources per stack: + # + # afterhours-shift-manager + # - DynamoDB CRUD (afterhours-shifts table) + # - secretsmanager:GetSecretValue (afterhours-shift-manager/*) + # - ses:SendEmail (SES identity) + # - CloudWatch Logs (all functions) + # + # payments-dashboard + # - DynamoDB CRUD / Read (PaymentsDashboard table) + # - S3 GetObject (payroll-emails, payments-csv buckets) + # - secretsmanager:GetSecretValue (payments-dashboard/*) + # - sqs:SendMessage + sqs:ReceiveMessage + sqs:DeleteMessage etc. + # (PayrollBatchQueue + DLQs) + # - lambda:InvokeFunction (ExpenseReceiver → ExpenseProcessor) + # - ec2:CreateNetworkInterface / DescribeNetworkInterfaces / + # DeleteNetworkInterface (VPC-attached functions) + # - CloudWatch Logs + # + # meal-order-manager + # - DynamoDB CRUD / Read (meal-order-manager-orders table) + # - S3 CRUD (ReportsBucket) + s3:GetObject (ReportsBucket presigned URLs) + # - secretsmanager:GetSecretValue (meal-order-manager/*) + # - ssm:GetParameter (/meal-order-manager/*) + # - lambda:InvokeFunction (submit-order → slack-notifier, + # close-form → aggregate-orders) + # - ses:SendRawEmail + # - CloudWatch Logs + # + # front-integrations + # - DynamoDB CRUD (front-sla-alerts table) + # - secretsmanager:GetSecretValue (by ARN, various) + # - CloudWatch Logs + # + # afi-backup-monitor + # - secretsmanager:GetSecretValue (by ARN) + # - CloudWatch Logs + # + # --------------------------------------------------------------------------- + LambdaExecutionBoundary: + Type: AWS::IAM::ManagedPolicy + Properties: + ManagedPolicyName: seahaven-lambda-execution-boundary + Description: >- + Permissions boundary ceiling for all SAM-managed Lambda execution roles. + Applied via PermissionsBoundary on every Globals.Function in the five + SAM stacks (INFRA-103). Effective permissions are the intersection of + this policy and the role's own inline policies. + PolicyDocument: + Version: "2012-10-17" + Statement: + + # ── CloudWatch Logs (every Lambda) ────────────────────────────────── + - Sid: CloudWatchLogs + Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + - logs:DescribeLogGroups + - logs:DescribeLogStreams + Resource: "*" + + # ── X-Ray tracing (standard Lambda execution) ──────────────────── + - Sid: XRay + Effect: Allow + Action: + - xray:PutTraceSegments + - xray:PutTelemetryRecords + Resource: "*" + + # ── VPC / ENI management (payments-dashboard VPC functions) ──────── + # Matches AWSLambdaVPCAccessExecutionRole exactly. + # AssignPrivateIpAddresses / UnassignPrivateIpAddresses are for EFA + # and secondary IPs — not part of the Lambda ENI lifecycle — omitted. + - Sid: Ec2Eni + Effect: Allow + Action: + - ec2:CreateNetworkInterface + - ec2:DescribeNetworkInterfaces + - ec2:DeleteNetworkInterface + - ec2:DescribeSubnets + - ec2:DescribeSecurityGroups + - ec2:DescribeVpcs + Resource: "*" + + # ── DynamoDB (afterhours, payments, meal-order, front-integrations) ─ + # Table/* covers base-table operations; table/*/index/* is required for + # Query/Scan on Global Secondary Indexes. + - Sid: DynamoDB + Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:UpdateItem + - dynamodb:DeleteItem + - dynamodb:Query + - dynamodb:Scan + - dynamodb:BatchGetItem + - dynamodb:BatchWriteItem + - dynamodb:DescribeTable + - dynamodb:ConditionCheckItem + Resource: + - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/*" + - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/*/index/*" + + # ── S3 (payments-dashboard read, meal-order-manager CRUD) ────────── + - Sid: S3 + Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + - s3:DeleteObject + - s3:ListBucket + - s3:GetBucketLocation + - s3:GetObjectVersion + - s3:GetObjectTagging + - s3:PutObjectTagging + Resource: + - !Sub "arn:aws:s3:::*-${AWS::AccountId}" + - !Sub "arn:aws:s3:::*-${AWS::AccountId}/*" + # meal-order-manager ReportsBucket (non-AccountId suffix pattern) + - !Sub "arn:aws:s3:::meal-order-manager-reports-${AWS::AccountId}" + - !Sub "arn:aws:s3:::meal-order-manager-reports-${AWS::AccountId}/*" + - !Sub "arn:aws:s3:::meal-order-manager-form-${AWS::AccountId}" + - !Sub "arn:aws:s3:::meal-order-manager-form-${AWS::AccountId}/*" + + # ── Secrets Manager (all stacks) ────────────────────────────────── + - Sid: SecretsManager + Effect: Allow + Action: + - secretsmanager:GetSecretValue + - secretsmanager:DescribeSecret + Resource: + - !Sub "arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:*" + + # ── SSM Parameter Store (meal-order-manager, afterhours) ────────── + - Sid: SSMParameterRead + Effect: Allow + Action: + - ssm:GetParameter + - ssm:GetParameters + - ssm:GetParametersByPath + Resource: + - !Sub "arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/*" + + # ── SQS (payments-dashboard batch queues) ───────────────────────── + - Sid: SQS + Effect: Allow + Action: + - sqs:SendMessage + - sqs:ReceiveMessage + - sqs:DeleteMessage + - sqs:GetQueueAttributes + - sqs:GetQueueUrl + - sqs:ChangeMessageVisibility + Resource: + - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:*" + + # ── Lambda invocation (payments, meal-order inter-function calls) ── + - Sid: LambdaInvoke + Effect: Allow + Action: + - lambda:InvokeFunction + Resource: + - !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:function:*" + + # ── SES (afterhours weekly-post, meal-order email-report) ────────── + - Sid: SES + Effect: Allow + Action: + - ses:SendEmail + - ses:SendRawEmail + Resource: + - !Sub "arn:aws:ses:us-east-1:${AWS::AccountId}:identity/*" + - !Sub "arn:aws:ses:us-east-1:${AWS::AccountId}:configuration-set/*" + + # ── KMS (CMK-encrypted resources) ───────────────────────────────── + # Required for Lambda functions that read/write CMK-encrypted AWS + # resources. Verified live state: + # - PaymentsDashboard DynamoDB table: CMK key/0b660af3 (KMS:ENABLED) + # - payments-dashboard CloudWatch log groups: CMK key/b748750c + # Secrets Manager + SQS queues in these stacks use AWS-managed keys + # (aws/secretsmanager, aws/sqs) which do not require explicit kms:* + # actions in the execution role policy. The CMK keys are scoped to + # this account to prevent cross-account KMS calls. + - Sid: KMS + Effect: Allow + Action: + - kms:Decrypt + - kms:GenerateDataKey + - kms:DescribeKey + Resource: + - !Sub "arn:aws:kms:us-east-1:${AWS::AccountId}:key/*" + + # --------------------------------------------------------------------------- + # Shared CloudFormation execution role (SAM stacks) — INFRA-97 scoped + # + # Replaces the previous blanket managed-policy set (IAMFullAccess + + # *FullAccess) with per-service inline statements that cover exactly + # what the five SAM stacks need during a CloudFormation deploy/update. + # + # PRIMARY ESCALATION CONTROL + # iam:CreateRole and iam:AttachRolePolicy / iam:PutRolePolicy are + # conditioned on iam:PermissionsBoundary StringEquals the boundary ARN + # (seahaven-lambda-execution-boundary, created in INFRA-103). That + # condition is what prevents the CFN execution role from minting an + # unconstrained admin role. + # + # SAM RolePath deviation note + # The original cross-review suggestion mentioned scoping IAM role + # creation to a specific path (/cfn-managed/). AWS::Serverless::Function + # does NOT support a custom RolePath on auto-generated execution roles — + # the PermissionsBoundary property is supported, but the role always lands + # at path /. Relying on a path condition (iam:ResourceTag or path-prefix) + # would therefore exclude the SAM auto-roles and break every deploy. + # The iam:PermissionsBoundary condition achieves the same security goal + # without requiring a path. For any explicit AWS::IAM::Role resources + # in SAM templates (e.g. AdminAuthorizerInvokeRole in meal-order-manager) + # where we can control the path, path scoping can be added in a follow-up. + # + # DEPLOY ORDER DEPENDENCY + # This role references the boundary ARN only as literal !Sub strings inside + # Condition values, so CloudFormation infers NO creation edge from the + # references alone. The explicit DependsOn below is what guarantees the + # boundary exists before the role on first create (IAM would otherwise + # accept the role, leaving a window where the role exists unbounded-gated + # against a not-yet-existing boundary policy). + # --------------------------------------------------------------------------- + SamCfnExecutionRole: + Type: AWS::IAM::Role + DependsOn: LambdaExecutionBoundary + Properties: + RoleName: github-cfn-execution-role + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Action: sts:AssumeRole + Policies: + + # ── CloudFormation transforms (SAM macro) ───────────────────────── + - PolicyName: cloudformation-transforms + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: AllowSAMTransform + Effect: Allow + Action: + - cloudformation:CreateChangeSet + Resource: + - arn:aws:cloudformation:us-east-1:aws:transform/* + + # ── Lambda management ───────────────────────────────────────────── + # Covers function create/update/delete, aliases, event source + # mappings, and Lambda layers — all needed for SAM deploys. + - PolicyName: lambda-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: LambdaFunctions + Effect: Allow + Action: + - lambda:AddPermission + - lambda:CreateFunction + - lambda:DeleteFunction + - lambda:GetFunction + - lambda:GetFunctionConfiguration + - lambda:ListFunctions + - lambda:RemovePermission + - lambda:UpdateFunctionCode + - lambda:UpdateFunctionConfiguration + - lambda:UpdateFunctionEventInvokeConfig + - lambda:PutFunctionEventInvokeConfig + - lambda:DeleteFunctionEventInvokeConfig + - lambda:GetFunctionEventInvokeConfig + - lambda:ListTags + - lambda:TagResource + - lambda:UntagResource + - lambda:GetPolicy + - lambda:ListVersionsByFunction + - lambda:PublishVersion + - lambda:CreateAlias + - lambda:DeleteAlias + - lambda:UpdateAlias + - lambda:GetAlias + Resource: + - !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:function:*" + - Sid: LambdaLayers + Effect: Allow + Action: + - lambda:PublishLayerVersion + - lambda:DeleteLayerVersion + - lambda:GetLayerVersion + - lambda:ListLayerVersions + - lambda:ListLayers + - lambda:AddLayerVersionPermission + - lambda:RemoveLayerVersionPermission + Resource: + - !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:layer:*" + - Sid: LambdaEventSourceMappings + Effect: Allow + Action: + - lambda:CreateEventSourceMapping + - lambda:DeleteEventSourceMapping + - lambda:GetEventSourceMapping + - lambda:ListEventSourceMappings + - lambda:UpdateEventSourceMapping + Resource: "*" + + # ── API Gateway (HTTP APIs + REST APIs) ─────────────────────────── + - PolicyName: apigateway-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: ApiGateway + Effect: Allow + Action: + - apigateway:GET + - apigateway:POST + - apigateway:PUT + - apigateway:PATCH + - apigateway:DELETE + Resource: + - "arn:aws:apigateway:us-east-1::*" + + # ── DynamoDB ────────────────────────────────────────────────────── + - PolicyName: dynamodb-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: DynamoDBTables + Effect: Allow + Action: + - dynamodb:CreateTable + - dynamodb:DeleteTable + - dynamodb:DescribeTable + - dynamodb:UpdateTable + - dynamodb:ListTables + - dynamodb:TagResource + - dynamodb:UntagResource + - dynamodb:DescribeTimeToLive + - dynamodb:UpdateTimeToLive + - dynamodb:DescribeContinuousBackups + - dynamodb:UpdateContinuousBackups + Resource: + - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/*" + + # ── S3 ──────────────────────────────────────────────────────────── + # Covers bucket create/configure + object operations for SAM + # artifact buckets and application buckets. + - PolicyName: s3-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: S3BucketOps + Effect: Allow + Action: + - s3:CreateBucket + - s3:DeleteBucket + - s3:GetBucketLocation + - s3:GetBucketPolicy + - s3:PutBucketPolicy + - s3:DeleteBucketPolicy + - s3:GetBucketTagging + - s3:PutBucketTagging + - s3:GetBucketVersioning + - s3:PutBucketVersioning + - s3:GetLifecycleConfiguration + - s3:PutLifecycleConfiguration + - s3:GetBucketPublicAccessBlock + - s3:PutBucketPublicAccessBlock + # Explicit BucketEncryption blocks (first: payments-dashboard + # BoaRawBucket, 2026-07-22) need the encryption config pair. + - s3:GetEncryptionConfiguration + - s3:PutEncryptionConfiguration + - s3:GetBucketNotification + - s3:PutBucketNotification + - s3:GetBucketWebsite + - s3:PutBucketWebsite + - s3:DeleteBucketWebsite + - s3:GetBucketAcl + - s3:PutBucketAcl + Resource: + - "arn:aws:s3:::*" + - Sid: S3ObjectOps + Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + - s3:DeleteObject + - s3:ListBucket + - s3:ListBucketVersions + - s3:GetObjectVersion + Resource: + - "arn:aws:s3:::*" + - "arn:aws:s3:::*/*" + + # ── CloudWatch Logs ─────────────────────────────────────────────── + - PolicyName: cloudwatch-logs-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: CWLogs + Effect: Allow + Action: + - logs:CreateLogGroup + - logs:DeleteLogGroup + - logs:DescribeLogGroups + - logs:PutRetentionPolicy + - logs:DeleteRetentionPolicy + - logs:ListTagsLogGroup + - logs:TagLogGroup + - logs:UntagLogGroup + - logs:ListTagsForResource + - logs:TagResource + - logs:UntagResource + - logs:CreateLogDelivery + - logs:GetLogDelivery + - logs:UpdateLogDelivery + - logs:DeleteLogDelivery + - logs:ListLogDeliveries + - logs:PutResourcePolicy + - logs:DescribeResourcePolicies + - logs:PutDestination + - logs:DeleteDestination + - logs:DescribeDestinations + - logs:AssociateKmsKey + - logs:DisassociateKmsKey + Resource: "*" + + # ── EventBridge / CloudWatch Events (scheduled Lambdas) ─────────── + - PolicyName: eventbridge-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: EventBridge + Effect: Allow + Action: + - events:DeleteRule + - events:DescribeRule + - events:EnableRule + - events:DisableRule + - events:ListRules + - events:ListTargetsByRule + - events:PutRule + - events:PutTargets + - events:RemoveTargets + - events:TagResource + - events:UntagResource + - events:ListTagsForResource + - events:PutPermission + - events:RemovePermission + Resource: "*" + + # ── SES (afterhours weekly-post, meal-order email-report) ───────── + - PolicyName: ses-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: SESRules + Effect: Allow + Action: + - ses:CreateReceiptRule + - ses:DeleteReceiptRule + - ses:DescribeReceiptRule + - ses:UpdateReceiptRule + - ses:CreateReceiptRuleSet + - ses:DescribeActiveReceiptRuleSet + - ses:DescribeReceiptRuleSet + - ses:SetActiveReceiptRuleSet + - ses:ReorderReceiptRuleSet + - ses:GetIdentityVerificationAttributes + - ses:ListIdentities + Resource: "*" + + # ── SQS (payments-dashboard queues + DLQs) ──────────────────────── + - PolicyName: sqs-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: SQSQueues + Effect: Allow + Action: + - sqs:CreateQueue + - sqs:DeleteQueue + - sqs:GetQueueAttributes + - sqs:SetQueueAttributes + - sqs:GetQueueUrl + - sqs:ListQueues + - sqs:TagQueue + - sqs:UntagQueue + - sqs:ListQueueTags + - sqs:AddPermission + - sqs:RemovePermission + Resource: + - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:*" + + # ── SNS (validation / alarm notifications) ──────────────────────── + - PolicyName: sns-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: SNS + Effect: Allow + Action: + - sns:CreateTopic + - sns:DeleteTopic + - sns:GetTopicAttributes + - sns:SetTopicAttributes + - sns:Subscribe + - sns:Unsubscribe + - sns:ListSubscriptionsByTopic + - sns:ListTopics + - sns:TagResource + - sns:UntagResource + Resource: + - !Sub "arn:aws:sns:us-east-1:${AWS::AccountId}:*" + + # ── CloudWatch Alarms ───────────────────────────────────────────── + - PolicyName: cloudwatch-alarms-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: CWAlarms + Effect: Allow + Action: + - cloudwatch:PutMetricAlarm + - cloudwatch:DeleteAlarms + - cloudwatch:DescribeAlarms + - cloudwatch:EnableAlarmActions + - cloudwatch:DisableAlarmActions + - cloudwatch:ListTagsForResource + - cloudwatch:TagResource + - cloudwatch:UntagResource + Resource: "*" + + # ── EC2 / VPC / NAT / EIP / Security Groups ─────────────────────── + # payments-dashboard deploys a VPC, NAT gateway, EIP, route tables, + # subnets, security groups, and gateway VPC endpoints. + - PolicyName: ec2-vpc-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: EC2VPC + Effect: Allow + Action: + - ec2:AllocateAddress + - ec2:AssociateRouteTable + - ec2:AttachInternetGateway + - ec2:AuthorizeSecurityGroupEgress + - ec2:AuthorizeSecurityGroupIngress + - ec2:CreateInternetGateway + - ec2:CreateNatGateway + - ec2:CreateRoute + - ec2:CreateRouteTable + - ec2:CreateSecurityGroup + - ec2:CreateSubnet + - ec2:CreateVpc + - ec2:CreateVpcEndpoint + - ec2:CreateTags + - ec2:DeleteInternetGateway + - ec2:DeleteNatGateway + - ec2:DeleteRoute + - ec2:DeleteRouteTable + - ec2:DeleteSecurityGroup + - ec2:DeleteSubnet + - ec2:DeleteVpc + - ec2:DeleteVpcEndpoints + - ec2:DescribeAddresses + - ec2:DescribeAvailabilityZones + - ec2:DescribeInternetGateways + - ec2:DescribeNatGateways + - ec2:DescribeRouteTables + - ec2:DescribeSecurityGroups + - ec2:DescribeSubnets + - ec2:DescribeVpcEndpoints + - ec2:DescribeVpcs + - ec2:DescribePrefixLists + - ec2:DetachInternetGateway + - ec2:DisassociateAddress + - ec2:DisassociateRouteTable + - ec2:ModifySubnetAttribute + - ec2:ModifyVpcAttribute + - ec2:ModifyVpcEndpoint + - ec2:ReleaseAddress + - ec2:RevokeSecurityGroupEgress + - ec2:RevokeSecurityGroupIngress + - ec2:UpdateSecurityGroupRuleDescriptionsEgress + - ec2:UpdateSecurityGroupRuleDescriptionsIngress + Resource: "*" + + # ── CloudFront + OAC (meal-order-manager form distribution) ─────── + - PolicyName: cloudfront-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: CloudFront + Effect: Allow + Action: + - cloudfront:CreateDistribution + - cloudfront:DeleteDistribution + - cloudfront:GetDistribution + - cloudfront:GetDistributionConfig + - cloudfront:UpdateDistribution + - cloudfront:TagResource + - cloudfront:UntagResource + - cloudfront:ListTagsForResource + - cloudfront:CreateOriginAccessControl + - cloudfront:DeleteOriginAccessControl + - cloudfront:GetOriginAccessControl + - cloudfront:GetOriginAccessControlConfig + - cloudfront:UpdateOriginAccessControl + - cloudfront:ListOriginAccessControls + - cloudfront:CreateInvalidation + - cloudfront:GetInvalidation + Resource: "*" + + # ── SSM Parameter Store (meal-order-manager, afterhours) ────────── + # Write is needed because meal-order-manager creates + # /meal-order-manager/slack-channel-id via AWS::SSM::Parameter. + - PolicyName: ssm-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: SSMParameters + Effect: Allow + Action: + - ssm:GetParameter + - ssm:GetParameters + - ssm:GetParametersByPath + - ssm:PutParameter + - ssm:DeleteParameter + - ssm:DeleteParameters + - ssm:DescribeParameters + - ssm:AddTagsToResource + - ssm:RemoveTagsFromResource + - ssm:ListTagsForResource + Resource: + - !Sub "arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/*" + # WAF association needs SSM parameter read at deploy time + # (/seahaven/waf/app-web-acl-arn value lookup) + - Sid: SSMParameterDescribe + Effect: Allow + Action: + - ssm:DescribeParameters + Resource: "*" + + # ── WAF (meal-order-manager CloudFront WebACL association) ──────── + - PolicyName: waf-management + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: WAF + Effect: Allow + Action: + - wafv2:GetWebACL + - wafv2:GetWebACLForResource + - wafv2:ListWebACLs + - wafv2:AssociateWebACL + - wafv2:DisassociateWebACL + - wafv2:ListResourcesForWebACL + Resource: "*" + + # ── IAM role lifecycle — BOUNDARY-GATED ────────────────────────── + # This is the PRIMARY escalation control for INFRA-97. + # + # iam:CreateRole / iam:AttachRolePolicy / iam:PutRolePolicy are + # conditioned on iam:PermissionsBoundary StringEquals the + # seahaven-lambda-execution-boundary ARN. That condition means + # any role this execution role creates must have the boundary + # applied, so it can never exceed what the boundary allows + # (which is scoped to the services the five stacks actually use). + # + # iam:PassRole is also included here so CloudFormation can pass + # the auto-generated Lambda execution role to the Lambda service. + # + # Why not path-scoped (e.g. iam:ResourceTag / path /cfn-managed/)? + # SAM's AWS::Serverless::Function auto-generates execution roles at + # path / — there is no supported way to set a custom RolePath on + # SAM auto-roles. A path condition would therefore exclude the + # SAM auto-roles and break every deploy. The PermissionsBoundary + # condition achieves the same security goal without a path requirement. + - PolicyName: iam-role-management-boundary-gated + PolicyDocument: + Version: "2012-10-17" + Statement: + # Create role — MUST attach boundary + - Sid: IAMCreateRoleWithBoundary + Effect: Allow + Action: + - iam:CreateRole + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + Condition: + StringEquals: + "iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + + # Attach managed policies — MUST have boundary already on role + - Sid: IAMAttachPolicyWithBoundary + Effect: Allow + Action: + - iam:AttachRolePolicy + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + Condition: + StringEquals: + "iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + + # Put inline policy — MUST have boundary already on role + - Sid: IAMPutRolePolicyWithBoundary + Effect: Allow + Action: + - iam:PutRolePolicy + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + Condition: + StringEquals: + "iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + + # Boundary management — SET the boundary only. DELETE is NOT + # granted: for a delete, the iam:PermissionsBoundary condition key + # reflects the boundary CURRENTLY attached to the target role, so + # a StringEquals condition on the boundary ARN MATCHES exactly the + # roles the gate protects. Granting delete under that condition + # lets this role create a boundary-gated role with an inline *:* + # policy, strip the boundary, and pass the now-unbounded role to + # Lambda — defeating the primary escalation control. Verified live + # against the mgmt copy 2026-07-27 (simulate-principal-policy: + # iam:DeleteRolePermissionsBoundary = allowed). SAM never needs + # the delete: it only SETS the boundary on roles it creates, and + # stack teardown calls DeleteRole, not DeleteRolePermissionsBoundary. + - Sid: IAMPutPermissionsBoundary + Effect: Allow + Action: + - iam:PutRolePermissionsBoundary + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + Condition: + StringEquals: + "iam:PermissionsBoundary": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + + # Explicit Deny backstop (AWS's documented NoBoundaryPolicyEdit / + # NoBoundaryDelete delegation pattern). A Deny is required, not + # merely omitting the Allow: without it, any future Allow added to + # this role — or a broader managed policy attached to it — silently + # reopens the escalation. Covers both removing a boundary from a + # role and rewriting the boundary POLICY DOCUMENT itself (the + # latter is only implicitly denied today). + - Sid: DenyBoundaryTampering + Effect: Deny + Action: + - iam:DeleteRolePermissionsBoundary + - iam:DeleteUserPermissionsBoundary + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + - !Sub "arn:aws:iam::${AWS::AccountId}:user/*" + + - Sid: DenyBoundaryPolicyEdit + Effect: Deny + Action: + - iam:CreatePolicyVersion + - iam:SetDefaultPolicyVersion + - iam:DeletePolicyVersion + - iam:DeletePolicy + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + + # Read / tag / delete role and policy — no boundary condition needed + - Sid: IAMRoleReadAndDelete + Effect: Allow + Action: + - iam:DeleteRole + - iam:DeleteRolePolicy + - iam:DetachRolePolicy + - iam:GetRole + - iam:GetRolePolicy + - iam:ListAttachedRolePolicies + - iam:ListRolePolicies + - iam:ListRoles + - iam:TagRole + - iam:UntagRole + - iam:UpdateRole + - iam:UpdateRoleDescription + - iam:UpdateAssumeRolePolicy + - iam:GetPolicy + - iam:GetPolicyVersion + - iam:ListPolicies + - iam:ListPolicyVersions + Resource: "*" + + # PassRole — CloudFormation passes the Lambda execution role + # to the Lambda service. Scoped to SAM-generated role pattern. + - Sid: IAMPassRole + Effect: Allow + Action: + - iam:PassRole + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/*" + Condition: + StringEquals: + "iam:PassedToService": "lambda.amazonaws.com" +