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, # - the boundary-gated IAM block moved from an INLINE role policy into an # attached managed policy (SamCfnIamManagementPolicy). Forced by IAM's # 10,240-byte per-role inline limit: mgmt's inline set is ~10.1 KB, i.e. # ~94 bytes from the cap, so the added Deny statements did not fit and the # first deploy failed with ServiceLimitExceeded (2026-07-27). Effective # permissions are unchanged — verified by comparing the full 27-statement # set before and after the move (identical), since identity policies are # unioned and an explicit Deny still wins. mgmt received this same # restructure in Phase B (.github PR #98), so this is no longer a # divergence, # - SECURITY FIX (now in BOTH copies): iam:DeleteRolePermissionsBoundary # removed from Sid IAMPutPermissionsBoundary and explicit Deny statements # (DenyBoundaryTampering / DenyBoundaryPolicyEdit / DenySelfMutation) # added. The mgmt copy was remediated 2026-07-27 (.github PRs #95 Phase A # + #98 Phase B); DenySelfMutation and the widened policy/seahaven-* # DenyBoundaryPolicyEdit scope were then ported back here, so the two # copies' GUARDRAIL statement sets were reconciled as of that date. # SamCfnIamManagementPolicy and SamCfnExecutionRole remain at parity on # their IAM STATEMENT SETS across the two files EXCEPT the # iam:PermissionsBoundary StringEquals VALUE. Prod/dev (this file) now # enumerates the shared ARN plus each seahaven-lambda-execution-boundary- # ARN (PLAT-52). Mgmt's .github copy keeps the unsuffixed single ARN — # those per-workload policies do not exist in mgmt, and adding them to # mgmt's allow-list would be a no-op that reads as false parity. Do not # weaken mgmt to ArnLike. Sid names, Deny statements, and every other # Action/Resource still change in both files together. Parity covers # statements, not surrounding comments — a comment may diverge where it # describes boundary content, which now differs between the files. The # only other functional delta is the DependsOn line above, which is # ordering, not permission. LambdaExecutionBoundary is NO LONGER # byte-identical — see DELIBERATE DIVERGENCE below. # # DELIBERATE DIVERGENCE — LambdaExecutionBoundary (INFRA-186, 2026-07-30) # The parity rule above is SCOPED, not global. LambdaExecutionBoundary in THIS # file is DELIBERATELY STRICTER than the mgmt copy in # Sea-Haven-Industries/.github/oidc-deploy-roles.yaml. Do not "reconcile" the two # by copying mgmt's statements back over these, or vice versa; the divergence is # load-bearing. A future mechanical drift check WILL read it as drift — it is not. # # 1. WHAT DIVERGED. Every per-workload data-plane statement was REMOVED from # LambdaExecutionBoundary in this file, leaving only the fleet-wide floor: # CloudWatchLogsWrite (scoped to /aws/lambda*), CloudWatchLogsDescribe, # XRay and Ec2Eni. The account-wide wildcards mgmt still carries — table/*, # table/*/index/*, secret:*, parameter/*, sqs :*, function:*, ses # identity/* + configuration-set/*, kms key/*, and an s3:::*- # pattern that was a bare name-suffix filter rather than an ownership # check — are simply GONE here rather than re-scoped. # The security win is the deletion: it is what closes the amplifier whereby # a principal able to write an inline policy onto a boundary-carrying role # could read every secret in the account. Per-workload prefixes add no # security — they only keep a workload functional — so they are added by # each migration PR, from that stack's own template, when the stack # actually lands. See the note on the boundary resource for the full # rationale and the six errors that the pre-loaded approach produced. # # 2. WHY MGMT'S RATIONALE IS LEGITIMATE THERE. The superset framing this file # used to carry ("being slightly broad is the correct trade-off; a boundary # that is too tight will break Lambda functions at runtime AFTER deploy") is # a real constraint in the management account: 328440206208 has 26 LIVE # roles carrying seahaven-lambda-execution-boundary, across all five SAM # stacks. Tightening there is a production change to running workloads with # a silent, deploy-time-invisible failure mode. # # 3. WHY IT DOES NOT TRANSFER HERE. This file deploys ONLY to seahaven-prod # (011934824531) and seahaven-dev (710827005802), where # PermissionsBoundaryUsageCount is 0 and 0 respectively (aws iam get-policy, # verified 2026-07-30; corroborated by list-roles returning no role carrying # any permissions boundary in either account). No live Lambda can break, so # the risk that justifies mgmt's breadth is absent — while the exposure is # strictly WORSE here than in mgmt, because prod is multi-tenant: the old # wildcards reached proposal-system's, procurement-ingest's and # workorder-ingest's CDK-owned tables, buckets, secrets and queues, the org's # own Config and VPC-flow-log buckets, and — via function:* — CDK Lambdas # that carry no boundary at all. # # 4. RECONCILIATION OBLIGATION, RESTATED. For LambdaExecutionBoundary the two # copies are now INTENTIONALLY DIFFERENT and must NOT be synchronised: # - A change to the per-workload Resource patterns in THIS file does NOT # propagate to mgmt. # - A change to mgmt's boundary does NOT propagate here. # - Any change to the ACTION lists, or any new statement, is a substrate # semantic change and DOES still require the same review in both copies. # For SamCfnIamManagementPolicy and SamCfnExecutionRole the original rule is # unchanged: change BOTH files in the same piece of work. # # KNOWN OPEN ITEM (deferred, not closed by INFRA-186): mgmt 328440206208 still # carries the account-wide patterns. Tightening it needs its own validated # rollout — enumerate what the 26 live roles actually call, stage it, and be # ready to roll back — and is explicitly OUT OF SCOPE of INFRA-186. Until that # lands, the two copies stay divergent and that is the intended state. # # COUPLING (PLAT-52, frozen for HCP by PLAT-143): the SHARED policy's # ManagedPolicyName and ARN (seahaven-lambda-execution-boundary) stay unchanged # until PermissionsBoundaryUsageCount is 0 in the account. Four Conditions in # SamCfnIamManagementPolicy below pin an enumerated StringEquals list of # acceptable boundary ARNs: the shared ARN plus each # seahaven-lambda-execution-boundary- ARN. Do not use ArnLike on # seahaven-lambda-execution-boundary-*: githubdeploy-seahaven-org-baseline # can CreatePolicy via CFN, so a conforming-name policy would become an # acceptable ceiling without touching the pin sites. New HCP stacks do not # append here (PLAT-150); their ceilings are policy/tf-managed/ created # by hcptf-bootstrap. The matching four Sids in seahaven-hcptf-iam-management # stay frozen until that policy is deleted with the prod/dev terraform-substrate # stacks (PLAT-147). Adding a remaining SAM workload is still a NEW named # ManagedPolicy in this file AND one ARN appended to the SAM allow-list only. # # SIZE BUDGET: an attached managed policy document is capped at 6,144 characters # (whitespace excluded). Measure with len(json.dumps(doc, separators=(',',':'))) # on the synthesized PolicyDocument with ${AWS::AccountId} resolved, and UPDATE # THE NUMBERS in the same edit. # # Shared LambdaExecutionBoundary (legacy ceiling; do not widen): 5986 # characters / 15 statements as of 2026-08-10 (PLAT-100). Headroom 158. # Leave it unchanged until live roles retarget (PLAT-52 phase 2). # # Per-workload policies (floor + own data plane). Compact sizes recorded # after synth (prod, ${AWS::AccountId}=011934824531): # seahaven-lambda-execution-boundary-afi-backup-monitor: 952 / 5 statements # seahaven-lambda-execution-boundary-front-integrations: 1532 / 6 statements # seahaven-lambda-execution-boundary-procurement-ingest: 3977 / 11 statements # seahaven-lambda-execution-boundary-meal-order-manager: 2530 / 11 statements (PLAT-135) # seahaven-lambda-execution-boundary-seahaven-site: 1159 / 6 statements # seahaven-lambda-execution-boundary-seahaven-door-unlock-api: measure after deploy (PLAT-76) # seahaven-lambda-execution-boundary-paychex-integrations: 3250 / 10 statements (PLAT-228) # Dev copies are floor-only (691 / 4) via IsProdAccount, except # meal-order-manager (PLAT-210): DynamoDB/S3/SSM/invoke plus the # seahaven-dev slack-bot-token ARN. SNS, SQS (Paychex), and SES stay prod. # # Guardrail PolicyDocuments also cap at 6,144. Each extra allow-list ARN # is copied into four Sids in EACH of SamCfnIamManagementPolicy and # HcptfIamManagementPolicy. Measured after PLAT-76 (7 ARNs): # seahaven-cfn-exec-iam-management: 4571 / 10 statements (1573 headroom) # seahaven-hcptf-iam-management: 4693 / 10 statements (1451 headroom) # PLAT-120 adds an 8th ARN (paychex-integrations). Re-measure after deploy. # # CRITICAL: a role has exactly ONE permissions boundary, so statements cannot # be spilled into a second attached managed policy. Do not introduce # dynamodb:* / s3:* / ses:Send* to reclaim space. Do not add new data-plane # to the shared document; create seahaven-lambda-execution-boundary-. # # 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 (currently seahaven-prod 011934824531 and # seahaven-dev 710827005802 via bin/app.ts instances deploy-substrate-prod / # deploy-substrate-dev; NEVER mgmt — 328440206208 is served by the .github copy # named above until its stacks migrate out). 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"] # Exact prod secret ARNs for afi-backup-monitor (PLAT-56) must only widen the # seahaven-prod boundary. The same template deploys to seahaven-dev. IsProdAccount: !Equals [!Ref "AWS::AccountId", "011934824531"] 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, re-scoped by INFRA-186) # # 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. # # SCOPING RULE (PLAT-52 phase 1, 2026-08-13). New workloads get their own # ManagedPolicy seahaven-lambda-execution-boundary-: the fleet-wide # floor (CloudWatchLogsWrite / CloudWatchLogsDescribe / XRay / Ec2Eni) plus # that workload's data plane, derived from ITS OWN template. Do not add new # data-plane statements to the shared seahaven-lambda-execution-boundary # document — it is the legacy ceiling for roles not yet retargeted and stays # unchanged until PermissionsBoundaryUsageCount is 0. INFRA-186 reduced this # copy to a floor and later migrations packed data plane back into it under # the 6,144-character cap (PLAT-93 / PLAT-100). Per-workload policies are # the escape hatch from that cap and from the shared-ceiling residual. # # A resource pattern that genuinely CANNOT be scoped keeps its wildcard WITH a # written justification on the statement: CloudWatchLogsDescribe, XRay and # Ec2Eni name runtime-created resources or use actions AWS authorises against # "*" regardless of the ARN supplied. Do not "tighten" those. Copy them into # every per-workload policy (a role takes exactly one boundary). # # Every "verified " annotation in this file is a POINT-IN-TIME # observation, not live state. Re-validate (usage counts, log-group CMK state, # per-stack permission sources) before citing one as justification for a # future change. # # WIDENING PATH — read this before migrating a stack into prod or dev. # The ceiling is never widened by the person who hits the AccessDenied. It is # created by the migrating stack's owner, in THIS repo, BEFORE the workload's # first deploy into the target account: # 0. Create AWS::IAM::ManagedPolicy seahaven-lambda-execution-boundary- # in this file (floor via the YAML anchors on AfiBackupMonitorBoundary, # plus that stack's data plane). Do NOT edit the shared # LambdaExecutionBoundary PolicyDocument. Description, ManagedPolicyName # and Path on an existing named policy are REPLACEMENT properties — # CloudFormation cannot replace a custom-named policy, so a # Description-only edit FAILS the stack update, and the error's suggested # remedy (rename) is the forbidden rename in the COUPLING note. Never # edit those three properties on a policy that already exists. New # policies start at v1. Per-policy version budget (max 5) applies when # later editing that workload's document: # aws iam list-policy-versions --policy-arn \ # arn:aws:iam:::policy/seahaven-lambda-execution-boundary- # aws iam delete-policy-version --version-id v ... # 1. Derive the workload's needs from ITS OWN TEMPLATE — open the stack's # template.yaml and read the actual IAM policy statements. The # permission-source block below is a STARTING POINT, NOT THE AUTHORITY: # the /sh-security-review pass on 2026-07-30 found SIX places where it was # incomplete or simply invented a resource name, three of which would have # failed silently. Update that block in the same edit with what you find. # 2. Add the workload's statements to ITS policy. Do not pack them into # another workload's Resource lists. Check for the SILENT classes: # a denied SQS destination/DLQ write discards the async event with no # error and no alarm; a denied scheduler call may sit behind a bare # except; a denied KMS decrypt for env-var encryption fails at cold-start # INIT; any CMK-encrypted resource needs the matching kms:ViaService # principal, not just the kms action; and a function using LoggingConfig # with a custom log-group name outside /aws/lambda* silently loses ALL # logs — add a scoped logs statement for the custom group or keep the # default group name. # 3. Measure THAT policy against 6144 (SIZE BUDGET). Also measure the SAM # guardrail PolicyDocument after step 4 — each new ARN is copied into # four Sids. # 4. Remaining SAM workloads: append the new ARN to SamCfnIamManagementPolicy # only. Do not use ArnLike. Do not append to seahaven-hcptf-iam-management # (frozen; prod/dev HCP IAM is leaving that policy). New HCP stacks create # policy/tf-managed/ via hcptf-bootstrap instead of a named policy # here. Both review gates run and neither discharges the other: the # GPT-4.1 cross-family review against the real diff, and /sh-security-review # (IaC/IAM is on the mandatory surface). CLI down = review outstanding. # 5. Merge and let CI deploy deploy-substrate-prod / deploy-substrate-dev # to UPDATE_COMPLETE, THEN deploy the SAM workload with # PermissionsBoundary set to THIS stack's ARN (not the shared name). # ORDERING IS NOT ENFORCED BY CLOUDFORMATION AND THIS IS THE MOST IMPORTANT # SENTENCE HERE: the workload's deploy SUCCEEDS even against a stale or # missing-content boundary, because the guardrail gates check that a listed # boundary ARN is attached, never its contents. The failure surfaces later, # at first invoke, as AccessDenied. A stale boundary is a silent deploy-time # pass and a loud production failure. # # CONSIDERED AND REJECTED: a Deny statement reserving the seahaven-* namespace. # With the Allow set reduced to the fleet-wide floor it is fully redundant (verified # 2026-07-30: seahaven-prod-config-* and seahaven-prod-vpc-flow-logs-* are # already denied by the Allow set alone), and a Deny inside a BOUNDARY is the # hardest failure mode in the estate to debug — it beats every Allow in every # policy with no synth-time signal. Revisit only if a widening ever has to # re-broaden a per-service Resource list back toward a wildcard. # # NOTE ON WHAT THESE PREFIXES ARE. All five stacks below currently live in the # MANAGEMENT account and none of their resources exists in seahaven-prod or # seahaven-dev yet. These are MIGRATION-CANDIDATE prefixes for the accounts this # template deploys to, not an inventory of what is deployed there. They are a # SECONDARY RECORD and a starting point for widening PRs — the authority is # each stack's own template (WIDENING PATH step 1). No Resource pattern in # the floor above derives from this block. # # Permission sources per stack (verified live 2026-07-30 IN MGMT — these # stacks and resources do not exist in prod/dev yet, so nothing below is a # prod/dev observation; starting point only — verify every entry against # the owning repo before use. PARAMETERIZED resources (ARNs passed as # deploy parameters) must be re-derived from the live stack configuration # at migration time, as exact ARNs — never inferred from these names into # broad patterns like secret:afi-*): # # afterhours-shift-manager (functions: afterhours-*, 6 live) # - DynamoDB CRUD (afterhours-shifts table) # - secretsmanager:GetSecretValue (afterhours-shift-manager/*) # - ses:SendEmail on the identity AND on # configuration-set/seahaven-email-events (template.yaml:178-181 — the # send is DENIED without the config-set ARN when the identity has a # default configuration set) # - scheduler:CreateSchedule/DeleteSchedule/GetSchedule on # schedule/default/holiday-* + iam:PassRole to scheduler.amazonaws.com # (template.yaml:110-120). CORRECTED 2026-07-30: this block previously # omitted both, and the omission is SILENT at runtime (bare except). # - NO ssm. CORRECTED 2026-07-30: this block previously credited # ssm:GetParameter to this stack; `grep -c 'ssm:' template.yaml` = 0. # Its slack tokens come from Secrets Manager and the channel id from a # CloudFormation parameter. # - lambda:InvokeFunction (ReleaseNotifyInvokeRole, # HolidaySchedulerExecutionRole — these two carry the boundary and need # ONLY this action) # - CloudWatch Logs (all functions) # - UNRESOLVED: /3cx-scheduler/* ownership (this stack vs. the retired # standalone 3CX scheduler). Deliberately NOT granted. Resolve at # migration in that stack's own repo — do NOT add any 3cx-scheduler # resource here until ownership is resolved. # # payments-dashboard (functions: payments-*) # - DynamoDB CRUD / Read (PaymentsDashboard table — legacy PascalCase) # - S3 GetObject on seahaven-payments-csv-* and seahaven-payroll-emails-*; # GetObject + PutObject on seahaven-payments-boa-raw-* # (template.yaml:272 and 1097-1099, fetchBoaTransactions raw archive). # CORRECTED 2026-07-30: this block previously said "GetObject ONLY — # no write intent enumerated", which was false and would have denied # the raw-archive write at migration. # - secretsmanager:GetSecretValue (payments-dashboard/*) # - sqs Send/Receive/Delete etc. (payments-payroll-batch + DLQs) # - lambda:InvokeFunction (ExpenseReceiver -> ExpenseProcessor) # - ec2 ENI lifecycle (VPC-attached functions) # - KMS via dynamodb (table CMK) — no SSM, no SES # - CloudWatch Logs # # meal-order-manager (functions: meal-order-manager-*, 7 live) # - DynamoDB CRUD / Read (meal-order-manager-orders table) # - S3 CRUD (meal-order-manager-reports-*, meal-order-manager-form-*) # - secretsmanager:GetSecretValue (meal-order-manager/*) # - ssm:GetParameter (/meal-order-manager/*) # - lambda:InvokeFunction (submit-order -> slack-notifier, # close-form -> aggregate-orders, plus AdminAuthorizerInvokeRole) # - ses:SendRawEmail # - CloudWatch Logs # # front-integrations (functions: front-*) # - DynamoDB CRUD (front-sla-alerts table) # - secretsmanager:GetSecretValue (front-integrations/*) # - CloudWatch Logs # - no IAM permissions for S3 / SQS / SSM / SES / KMS / VPC in this # stack's template as of 2026-07-30 # # paychex-integrations (functions: paychex-*, PLAT-122) # - Authority: Sea-Haven-Industries/paychex-integrations terraform/ # (placeholder Lambda). GetSecretValue on six exact prod secret ARNs # minted by first HCP apply on 2026-08-27. No name-prefix wildcards. # - CloudWatch Logs (floor) # - no DynamoDB / EventBridge / S3 data plane / SES / KMS / VPC in the # scaffold Terraform # - lambda:InvokeFunction on function:paychex-payroll-schedule only # (PLAT-228). No other function ARN. # - sqs:SendMessage on paychex-checkcomponents so the schedule role can # enqueue the Monday flush. Identity policies still name the queue. # # afi-backup-monitor (functions: afi-*) # - secretsmanager:GetSecretValue on TWO exact prod secret ARNs # (PLAT-56, created 2026-08-05 in seahaven-prod; no name patterns): # arn:aws:secretsmanager:us-east-1:011934824531:secret:afi-api-key-w0E02a # arn:aws:secretsmanager:us-east-1:011934824531:secret:afi-slack-webhook-T4oR3G # LIVE MGMT (328440206208) still uses afi-api-key-BD122x and # afi-backup-monitor/slack-webhook-url-NtYGf1 — the 2026-07-30 note # that the webhook name "does not exist" was wrong for mgmt; prod # intentionally uses the ticket names afi-api-key / afi-slack-webhook. # - CloudWatch Logs (covered by fleet floor) # - nothing else # # --------------------------------------------------------------------------- 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 — write (every Lambda) ────────────────────────── # Scoped to the Lambda log-group namespace. Every SAM function's group # is /aws/lambda/, and the trailing * also covers the # :log-stream: suffix PutLogEvents authorises against, so one ARN # serves CreateLogGroup, CreateLogStream, PutLogEvents and # DescribeLogStreams. The * is deliberately NOT after a trailing slash: # /aws/lambda* also matches the /aws/lambda-insights groups the Lambda # Insights extension writes to, which /aws/lambda/* would have denied. # VERIFICATION PROVENANCE, stated precisely (2026-07-30). What # iam simulate-custom-policy DOES confirm: this pattern allows # logs:CreateLogGroup / logs:PutLogEvents on the bare group ARN # log-group:/aws/lambda/ (and /aws/lambda//), and DENIES # log-group:seahaven-prod-vpc-flow-logs — the latter re-checked against # an Allow */* positive control, which allows it, so the deny is real # policy behaviour and not a simulator artifact. # What the simulator CANNOT evaluate, so do NOT claim it was verified: # log-stream-qualified ARNs (log-group::log-stream:) and the bare # /aws/lambda-insights group both return implicitDeny EVEN UNDER an # Allow */* policy. That is a simulator resource-parsing limitation, not # a denial. Coverage of those two rests on documented IAM wildcard # semantics — "*" matches any sequence of characters including ":" and # "/" — which is why the * is deliberately NOT placed after a trailing # slash. If this ever needs true end-to-end proof, it must come from a # real invoke in dev, not from the simulator. # # NOT scoped per workload, deliberately. A per-stack prefix # (/aws/lambda/payments-* etc.) was considered and rejected: a Lambda # denied PutLogEvents does not fail — it keeps running and silently # produces no logs. Log denial is the one failure class in this policy # that is NOT loud, so it must not depend on function-name discipline. # ACCEPTED RESIDUAL RISK: a SAM Lambda can write into another tenant's # /aws/lambda/* group (log poisoning). No read action is granted here, so # this is not an exfiltration path. Tighten only once every # boundary-carrying function is confirmed to set an explicit FunctionName. # REGION IS PINNED TO us-east-1 DELIBERATELY: every Sea Haven workload # deploys to us-east-1, and this template itself only ever deploys there. # ${AWS::Region} would resolve to the identical string, so it would # document nothing. A future stack in another region carries this # boundary but CANNOT write its logs (the silent class above) — so a # cross-region migration MUST add region-scoped statements in its # widening PR, same as any other data-plane need. - Sid: CloudWatchLogsWrite Effect: Allow Action: - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents - logs:DescribeLogStreams Resource: - !Sub "arn:aws:logs:us-east-1:${AWS::AccountId}:log-group:/aws/lambda*" # ── CloudWatch Logs — describe (UNSCOPABLE, kept "*" deliberately) ── # logs:DescribeLogGroups is a COLLECTION action: AWS authorises it # against "*" regardless of any resource ARN supplied. Scoping it would # produce a policy that reads tighter and denies at runtime, so it is # split into its own statement and keeps the wildcard. Read-only # metadata; it cannot mutate anything or return log content. - Sid: CloudWatchLogsDescribe Effect: Allow Action: - logs:DescribeLogGroups Resource: "*" # ── X-Ray tracing (UNSCOPABLE, kept "*" deliberately) ─────────────── # xray:PutTraceSegments / PutTelemetryRecords support no resource-level # permissions — X-Ray exposes no ARN for them, which is why the AWS # managed AWSXRayDaemonWriteAccess also uses "*". Any ARN written here # would be inert and would falsely imply a control exists. Write-only # into this account's own trace store; no cross-tenant read is # expressible with this action set. - Sid: XRay Effect: Allow Action: - xray:PutTraceSegments - xray:PutTelemetryRecords Resource: "*" # ── VPC / ENI management (UNSCOPABLE, kept "*" deliberately) ──────── # Derived from AWSLambdaVPCAccessExecutionRole, NOT an exact match: # DescribeSecurityGroups and DescribeVpcs exceed that managed policy # (kept for CFN/SAM VpcConfig validation; read-only). The four # ec2:Describe* actions do not support resource-level # permissions AT ALL — an ARN in Resource is ignored and the call is # authorised against "*" — so narrowing them is cosmetic. The ENI in # CreateNetworkInterface / DeleteNetworkInterface is created by the # Lambda service at attach time with an id that cannot exist when this # policy is written. Nothing here is scopable by resource name. # AssignPrivateIpAddresses / UnassignPrivateIpAddresses are for EFA and # secondary IPs — not part of the Lambda ENI lifecycle — omitted. # # KNOWN OPEN ITEM (pre-existing, NOT introduced by INFRA-186; tracked # as INFRA-200): # ec2:DeleteNetworkInterface on "*" lets a bounded Lambda delete any ENI # in the account, including NAT / VPC-endpoint / RDS ENIs — a # denial-of-service primitive inherited from the AWS managed policy. The # durable fix is a Condition on ec2:Subnet / ec2:Vpc naming THE SET OF # VPCs that boundary-carrying Lambdas attach to — not a single VPC id; # the list must be extended whenever a workload introduces a new VPC # (tag-based conditions are the alternative if the set churns). No such # VPC exists in seahaven-prod or seahaven-dev today (payments-dashboard's # 10.20.0.0/16 VPC is in mgmt), so writing the condition now would encode # an mgmt resource into a prod/dev template. Whoever brings the first VPC # across in payments-dashboard's migration PR adds the condition in the # same PR. - Sid: Ec2Eni Effect: Allow Action: - ec2:CreateNetworkInterface - ec2:DescribeNetworkInterfaces - ec2:DeleteNetworkInterface - ec2:DescribeSubnets - ec2:DescribeSecurityGroups - ec2:DescribeVpcs Resource: "*" # ── Shared workload data-plane (PLAT-93 PolicySize consolidation) ─── # Per-workload secret/DDB/S3 SIDs were merged so meal-order-manager # (PLAT-70) can fit under the 6,144-character managed-policy cap # without introducing new action wildcards. Exact secret ARNs only. # End-state isolation remains PLAT-52 / INFRA-187. # # WorkloadSecrets covers: afi-backup-monitor (PLAT-56), # front-integrations (PLAT-72), procurement-ingest (PLAT-86), # meal-order-manager (PLAT-70). - !If - IsProdAccount - Sid: WorkloadSecrets Effect: Allow Action: - secretsmanager:GetSecretValue Resource: - arn:aws:secretsmanager:us-east-1:011934824531:secret:afi-api-key-w0E02a - arn:aws:secretsmanager:us-east-1:011934824531:secret:afi-slack-webhook-T4oR3G - arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/front-api-token-UXKv0U - arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/slack-bot-token-giGfA7 - arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/google-service-account-dqv3Bo - arn:aws:secretsmanager:us-east-1:011934824531:secret:procurement-ingest/web-ui-auth-token-ApAMmr - arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB - arn:aws:secretsmanager:us-east-1:011934824531:secret:meal-order-manager/slack-bot-token-ZGmSGw - !Ref AWS::NoValue # WorkloadDynamoDB: enumerated union of front-integrations CRUD + # procurement-ingest CRUD/stream actions. Meal-order table ARNs appended. # Stream actions on non-stream tables are inert at the ceiling. - !If - IsProdAccount - Sid: WorkloadDynamoDB Effect: Allow Action: - dynamodb:GetItem - dynamodb:PutItem - dynamodb:UpdateItem - dynamodb:DeleteItem - dynamodb:Query - dynamodb:Scan - dynamodb:BatchGetItem - dynamodb:BatchWriteItem - dynamodb:DescribeTable - dynamodb:ConditionCheckItem - dynamodb:GetRecords - dynamodb:GetShardIterator - dynamodb:DescribeStream # ListStreams is a collection API (Resource "*"); ARN-scoping # it is a silent no-op. Runtime stream consumers use the # stream ARN via DescribeStream/GetRecords above. Resource: - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/front-sla-alerts" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/front-sla-alerts/index/*" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/purchase-orders" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/purchase-orders/*" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/verified-sites" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/verified-sites/*" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/pending-site-review" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/pending-site-review/*" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-orders" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-orders/*" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-order-comments" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-order-comments/*" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/meal-order-manager-orders" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/meal-order-manager-orders/index/*" - !Ref AWS::NoValue # ── procurement-ingest remaining data plane + meal-order S3 (PLAT-86/70) ─ # WorkloadS3 keeps the prior ProcurementIngestS3 action list and appends # meal-order form/reports bucket ARNs (same object CRUD shape). - !If - IsProdAccount - Sid: WorkloadS3 Effect: Allow Action: - s3:GetObject* - s3:GetBucket* - s3:List* - s3:PutObject* - s3:DeleteObject* - s3:AbortMultipartUpload Resource: - !Sub "arn:aws:s3:::po-ingest-emails-${AWS::AccountId}" - !Sub "arn:aws:s3:::po-ingest-emails-${AWS::AccountId}/*" - !Sub "arn:aws:s3:::workorder-ingest-emails-${AWS::AccountId}" - !Sub "arn:aws:s3:::workorder-ingest-emails-${AWS::AccountId}/*" - !Sub "arn:aws:s3:::meal-order-manager-form-${AWS::AccountId}" - !Sub "arn:aws:s3:::meal-order-manager-form-${AWS::AccountId}/*" - !Sub "arn:aws:s3:::meal-order-manager-reports-${AWS::AccountId}" - !Sub "arn:aws:s3:::meal-order-manager-reports-${AWS::AccountId}/*" - !Ref AWS::NoValue - !If - IsProdAccount - Sid: ProcurementIngestSqs 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}:po-ingest-*" - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:WorkorderIngestStack-*" - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:workorder-shoc-emitter-*" - !Ref AWS::NoValue - !If - IsProdAccount - Sid: ProcurementIngestKms Effect: Allow Action: - kms:Decrypt - kms:DescribeKey - kms:Encrypt - kms:GenerateDataKey* - kms:ReEncrypt* Resource: - arn:aws:kms:us-east-1:011934824531:key/be5fa4cb-c546-40fe-a13d-c7bec79f5d12 - arn:aws:kms:us-east-1:011934824531:key/d10fd1f0-a61a-4405-8568-85e9fd11ba18 - !Ref AWS::NoValue - !If - IsProdAccount - Sid: ProcurementIngestBedrock Effect: Allow Action: - bedrock:InvokeModel - bedrock:InvokeModelWithResponseStream Resource: - !Sub "arn:aws:bedrock:us-east-1:${AWS::AccountId}:inference-profile/us.anthropic.claude-haiku-4-5-20251001-v1:0" - arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0 - arn:aws:bedrock:us-east-2::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0 - arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0 - !Ref AWS::NoValue # site-alerts publish shared by procurement-ingest alarms and # meal-order-manager (PLAT-70); no separate MealOrder SNS statement. - !If - IsProdAccount - Sid: ProcurementIngestSns Effect: Allow Action: - sns:Publish Resource: - !Sub "arn:aws:sns:us-east-1:${AWS::AccountId}:site-alerts" - !Ref AWS::NoValue # ── seahaven-site (PLAT-91) — content-deploy role data plane ──────── # TF creates githubdeploy-seahaven-site under /tf-managed/ with this # boundary as ceiling. Role policy is S3 sync + CloudFront invalidate # only; no Lambda. Exact origin bucket + distribution-scoped invalidate. - !If - IsProdAccount - Sid: SeahavenSiteOriginS3 Effect: Allow Action: - s3:GetObject - s3:PutObject - s3:DeleteObject - s3:GetObjectTagging - s3:PutObjectTagging - s3:ListBucket - s3:GetBucketLocation Resource: - arn:aws:s3:::seahaven-site-prod - arn:aws:s3:::seahaven-site-prod/* - !Ref AWS::NoValue - !If - IsProdAccount - Sid: SeahavenSiteCloudFrontInvalidate Effect: Allow Action: - cloudfront:CreateInvalidation - cloudfront:GetInvalidation Resource: - !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/*" - !Ref AWS::NoValue # ── meal-order-manager (PLAT-70 / PLAT-100) — not covered above ───── # Secrets → WorkloadSecrets; DynamoDB → WorkloadDynamoDB; S3 → WorkloadS3; # SNS → ProcurementIngestSns. SSM + Lambda Invoke + execute-api share # one Sid (scoped Resources only) so PolicySize stays under 6,144 — # a standalone execute-api Sid is ~243 chars against 241 headroom and # would fail UPDATE with LimitExceeded. SES stays in its own Sid: # Resource:"*" must not share a statement with execute-api:Invoke # (that would allow Invoke on every API in the account). # Weekly-menu OIDC identity policy pins the API id; boundary pins # method/path only. - !If - IsProdAccount - Sid: MealOrderManager Effect: Allow Action: - ssm:GetParameter - lambda:InvokeFunction - execute-api:Invoke Resource: - !Sub "arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/meal-order-manager/*" - !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:function:meal-order-manager-*" - !Sub "arn:aws:execute-api:us-east-1:${AWS::AccountId}:*/*/GET/api/publish/settings" - !Sub "arn:aws:execute-api:us-east-1:${AWS::AccountId}:*/*/POST/api/publish/menu" - !Ref AWS::NoValue - !If - IsProdAccount - Sid: MealOrderManagerSes Effect: Allow Action: - ses:SendRawEmail Resource: "*" - !Ref AWS::NoValue # ── FURTHER PER-WORKLOAD DATA-PLANE ──────────────────────────────── # Do not add statements here. Remaining SAM stacks: create # seahaven-lambda-execution-boundary- below and append its ARN # to SamCfnIamManagementPolicy only (WIDENING PATH). New HCP stacks # do not append here. This shared document stays unchanged until live # roles retarget (PLAT-52 phase 2) and PermissionsBoundaryUsageCount # reaches 0. # --------------------------------------------------------------------------- # Per-workload Lambda execution boundaries (PLAT-52 phase 1) # # Each policy is the fleet floor plus that workload's data plane, split from # the shared document above without editing it. Live roles keep the shared # ARN until app-repo retargets. Guardrail StringEquals lists include both. # Floor statements are YAML-anchored on AfiBackupMonitorBoundary; later # policies alias them. Floor rationale lives on LambdaExecutionBoundary. # Prod-only data plane stays behind IsProdAccount (same as the shared copy). # --------------------------------------------------------------------------- AfiBackupMonitorBoundary: Type: AWS::IAM::ManagedPolicy Properties: ManagedPolicyName: seahaven-lambda-execution-boundary-afi-backup-monitor Description: >- Per-workload permissions boundary for afi-backup-monitor (PLAT-52). Floor plus exact prod secret ARNs. Shared seahaven-lambda-execution-boundary remains the live-role ceiling until app retarget. PolicyDocument: Version: "2012-10-17" Statement: - &lambdaBoundaryFloorLogsWrite Sid: CloudWatchLogsWrite Effect: Allow Action: - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents - logs:DescribeLogStreams Resource: - !Sub "arn:aws:logs:us-east-1:${AWS::AccountId}:log-group:/aws/lambda*" - &lambdaBoundaryFloorLogsDescribe Sid: CloudWatchLogsDescribe Effect: Allow Action: - logs:DescribeLogGroups Resource: "*" - &lambdaBoundaryFloorXRay Sid: XRay Effect: Allow Action: - xray:PutTraceSegments - xray:PutTelemetryRecords Resource: "*" - &lambdaBoundaryFloorEc2Eni Sid: Ec2Eni Effect: Allow Action: - ec2:CreateNetworkInterface - ec2:DescribeNetworkInterfaces - ec2:DeleteNetworkInterface - ec2:DescribeSubnets - ec2:DescribeSecurityGroups - ec2:DescribeVpcs Resource: "*" - !If - IsProdAccount - Sid: AfiBackupMonitorSecrets Effect: Allow Action: - secretsmanager:GetSecretValue Resource: - arn:aws:secretsmanager:us-east-1:011934824531:secret:afi-api-key-w0E02a - arn:aws:secretsmanager:us-east-1:011934824531:secret:afi-slack-webhook-T4oR3G - !Ref AWS::NoValue FrontIntegrationsBoundary: Type: AWS::IAM::ManagedPolicy Properties: ManagedPolicyName: seahaven-lambda-execution-boundary-front-integrations Description: >- Per-workload permissions boundary for front-integrations (PLAT-52). Floor plus exact prod secrets and front-sla-alerts. Shared policy remains the live-role ceiling until app retarget. PolicyDocument: Version: "2012-10-17" Statement: # Floor aliases — edit the &lambdaBoundaryFloor* anchors on # AfiBackupMonitorBoundary only; do not inline a divergent copy. - *lambdaBoundaryFloorLogsWrite - *lambdaBoundaryFloorLogsDescribe - *lambdaBoundaryFloorXRay - *lambdaBoundaryFloorEc2Eni - !If - IsProdAccount - Sid: FrontIntegrationsSecrets Effect: Allow Action: - secretsmanager:GetSecretValue Resource: - arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/front-api-token-UXKv0U - arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/slack-bot-token-giGfA7 - arn:aws:secretsmanager:us-east-1:011934824531:secret:front-integrations/google-service-account-dqv3Bo - !Ref AWS::NoValue - !If - IsProdAccount - Sid: FrontIntegrationsDynamoDB 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/front-sla-alerts" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/front-sla-alerts/index/*" - !Ref AWS::NoValue PaychexIntegrationsBoundary: Type: AWS::IAM::ManagedPolicy Properties: ManagedPolicyName: seahaven-lambda-execution-boundary-paychex-integrations Description: >- Per-workload permissions boundary for paychex-integrations (PLAT-120). Floor only until first HCP apply mints secret suffixes. PolicyDocument: Version: "2012-10-17" Statement: # Floor aliases — edit the &lambdaBoundaryFloor* anchors on # AfiBackupMonitorBoundary only; do not inline a divergent copy. - *lambdaBoundaryFloorLogsWrite - *lambdaBoundaryFloorLogsDescribe - *lambdaBoundaryFloorXRay - *lambdaBoundaryFloorEc2Eni # Unconditional (not !If): adding Fn::If to this named managed policy # made CloudFormation replace it (409 duplicate ManagedPolicyName) on # seahaven-deploy-substrate. Prod ARNs are a no-op in other accounts. - Sid: PaychexIntegrationsSecrets Effect: Allow Action: - secretsmanager:GetSecretValue Resource: - arn:aws:secretsmanager:us-east-1:011934824531:secret:paychex-integrations/oauth-client-2WfF5w - arn:aws:secretsmanager:us-east-1:011934824531:secret:paychex-integrations/webhook-api-key-44b0jB - arn:aws:secretsmanager:us-east-1:011934824531:secret:paychex-integrations/google-service-account-PcUeJD - arn:aws:secretsmanager:us-east-1:011934824531:secret:paychex-integrations/slack-bot-token-L8DntD - arn:aws:secretsmanager:us-east-1:011934824531:secret:paychex-integrations/slack-admin-token-LHr2VD - arn:aws:secretsmanager:us-east-1:011934824531:secret:paychex-integrations/front-inboxes-write-v6niDC - arn:aws:secretsmanager:us-east-1:011934824531:secret:paychex-integrations/3cx-system-admin-PcUeJD - arn:aws:secretsmanager:us-east-1:011934824531:secret:paychex-integrations/afterhours-roster-token-j3yCh7 - Sid: PaychexIntegrationsDynamoDB Effect: Allow Action: - dynamodb:GetItem - dynamodb:PutItem - dynamodb:UpdateItem - dynamodb:DeleteItem - dynamodb:ConditionCheckItem - dynamodb:DescribeTable - dynamodb:Query Resource: - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/paychex-worker-ledger" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/paychex-worker-ledger/index/*" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/paychex-webhook-notifications" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/paychex-payroll-notices" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/paychex-checkcomponents-posted" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/paychex-checkcomponents-period" - Sid: PaychexIntegrationsSqsConsume Effect: Allow Action: - sqs:ReceiveMessage - sqs:DeleteMessage - sqs:GetQueueAttributes - sqs:ChangeMessageVisibility Resource: - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:paychex-webhook-events" - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:paychex-login-delay" - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:paychex-checkcomponents" - Sid: PaychexIntegrationsSqsSend Effect: Allow Action: - sqs:SendMessage Resource: - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:paychex-webhook-events" - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:paychex-login-delay" - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:paychex-checkcomponents" - Sid: PaychexIntegrationsSns Effect: Allow Action: - sns:Publish Resource: - !Sub "arn:aws:sns:us-east-1:${AWS::AccountId}:site-alerts" # Scheduler invoke role only. The identity policy names this one # function; the boundary must not open any other function ARN. - Sid: PaychexIntegrationsInvokeSchedule Effect: Allow Action: - lambda:InvokeFunction Resource: - !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:function:paychex-payroll-schedule" ProcurementIngestBoundary: Type: AWS::IAM::ManagedPolicy Properties: ManagedPolicyName: seahaven-lambda-execution-boundary-procurement-ingest Description: >- Per-workload permissions boundary for procurement-ingest including bundled workorder-ingest data plane (PLAT-86 grouping, PLAT-52 split). Shared policy remains the live-role ceiling until app retarget. PolicyDocument: Version: "2012-10-17" Statement: # Floor aliases — edit the &lambdaBoundaryFloor* anchors on # AfiBackupMonitorBoundary only; do not inline a divergent copy. - *lambdaBoundaryFloorLogsWrite - *lambdaBoundaryFloorLogsDescribe - *lambdaBoundaryFloorXRay - *lambdaBoundaryFloorEc2Eni - !If - IsProdAccount - Sid: ProcurementIngestSecrets Effect: Allow Action: - secretsmanager:GetSecretValue Resource: - arn:aws:secretsmanager:us-east-1:011934824531:secret:procurement-ingest/web-ui-auth-token-ApAMmr - arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB - !Ref AWS::NoValue - !If - IsProdAccount - Sid: ProcurementIngestDynamoDB Effect: Allow Action: - dynamodb:GetItem - dynamodb:PutItem - dynamodb:UpdateItem - dynamodb:DeleteItem - dynamodb:Query - dynamodb:Scan - dynamodb:BatchGetItem - dynamodb:BatchWriteItem - dynamodb:DescribeTable - dynamodb:ConditionCheckItem - dynamodb:GetRecords - dynamodb:GetShardIterator - dynamodb:DescribeStream Resource: - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/purchase-orders" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/purchase-orders/*" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/verified-sites" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/verified-sites/*" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/pending-site-review" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/pending-site-review/*" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-orders" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-orders/*" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-order-comments" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/work-order-comments/*" - !Ref AWS::NoValue - !If - IsProdAccount - Sid: ProcurementIngestS3 Effect: Allow Action: - s3:GetObject* - s3:GetBucket* - s3:List* - s3:PutObject* - s3:DeleteObject* - s3:AbortMultipartUpload Resource: - !Sub "arn:aws:s3:::po-ingest-emails-${AWS::AccountId}" - !Sub "arn:aws:s3:::po-ingest-emails-${AWS::AccountId}/*" - !Sub "arn:aws:s3:::workorder-ingest-emails-${AWS::AccountId}" - !Sub "arn:aws:s3:::workorder-ingest-emails-${AWS::AccountId}/*" - !Ref AWS::NoValue - !If - IsProdAccount - Sid: ProcurementIngestSqs 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}:po-ingest-*" - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:WorkorderIngestStack-*" - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:workorder-shoc-emitter-*" - !Ref AWS::NoValue - !If - IsProdAccount - Sid: ProcurementIngestKms Effect: Allow Action: - kms:Decrypt - kms:DescribeKey - kms:Encrypt - kms:GenerateDataKey* - kms:ReEncrypt* Resource: - arn:aws:kms:us-east-1:011934824531:key/be5fa4cb-c546-40fe-a13d-c7bec79f5d12 - arn:aws:kms:us-east-1:011934824531:key/d10fd1f0-a61a-4405-8568-85e9fd11ba18 - !Ref AWS::NoValue - !If - IsProdAccount - Sid: ProcurementIngestBedrock Effect: Allow Action: - bedrock:InvokeModel - bedrock:InvokeModelWithResponseStream Resource: - !Sub "arn:aws:bedrock:us-east-1:${AWS::AccountId}:inference-profile/us.anthropic.claude-haiku-4-5-20251001-v1:0" - arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0 - arn:aws:bedrock:us-east-2::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0 - arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0 - !Ref AWS::NoValue - !If - IsProdAccount - Sid: ProcurementIngestSns Effect: Allow Action: - sns:Publish Resource: - !Sub "arn:aws:sns:us-east-1:${AWS::AccountId}:site-alerts" - !Ref AWS::NoValue MealOrderManagerBoundary: Type: AWS::IAM::ManagedPolicy Properties: ManagedPolicyName: seahaven-lambda-execution-boundary-meal-order-manager Description: >- Per-workload permissions boundary for meal-order-manager (PLAT-52). Floor plus secrets, orders table, form/reports buckets, site-alerts, SSM/invoke/execute-api, and SES. Shared policy remains the live-role ceiling until app retarget. PolicyDocument: Version: "2012-10-17" Statement: # Floor aliases — edit the &lambdaBoundaryFloor* anchors on # AfiBackupMonitorBoundary only; do not inline a divergent copy. - *lambdaBoundaryFloorLogsWrite - *lambdaBoundaryFloorLogsDescribe - *lambdaBoundaryFloorXRay - *lambdaBoundaryFloorEc2Eni - !If - IsProdAccount - Sid: MealOrderManagerSecrets Effect: Allow Action: - secretsmanager:GetSecretValue Resource: - arn:aws:secretsmanager:us-east-1:011934824531:secret:meal-order-manager/slack-bot-token-ZGmSGw - Sid: MealOrderManagerSecrets Effect: Allow Action: - secretsmanager:GetSecretValue Resource: - arn:aws:secretsmanager:us-east-1:710827005802:secret:meal-order-manager/slack-bot-token-y37snU - Sid: MealOrderManagerDynamoDB 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/meal-order-manager-orders" - !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/meal-order-manager-orders/index/*" - Sid: MealOrderManagerS3 Effect: Allow Action: - s3:GetObject* - s3:GetBucket* - s3:List* - s3:PutObject* - s3:DeleteObject* - s3:AbortMultipartUpload Resource: - !Sub "arn:aws:s3:::meal-order-manager-form-${AWS::AccountId}" - !Sub "arn:aws:s3:::meal-order-manager-form-${AWS::AccountId}/*" - !Sub "arn:aws:s3:::meal-order-manager-reports-${AWS::AccountId}" - !Sub "arn:aws:s3:::meal-order-manager-reports-${AWS::AccountId}/*" - !If - IsProdAccount - Sid: MealOrderManagerSns Effect: Allow Action: - sns:Publish Resource: - !Sub "arn:aws:sns:us-east-1:${AWS::AccountId}:site-alerts" - !Ref AWS::NoValue # aggregate-orders enqueues the weekly meal-deduction payload onto # paychex-integrations' checkcomponents queue (PLAT-135). Send only; # the paychex processor owns receive/delete. Not in seahaven-dev # (PLAT-210: Paychex queue URLs stay empty). - !If - IsProdAccount - Sid: MealOrderManagerSqs Effect: Allow Action: - sqs:SendMessage Resource: - !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:paychex-checkcomponents" - !Ref AWS::NoValue - Sid: MealOrderManager Effect: Allow Action: - ssm:GetParameter - lambda:InvokeFunction - execute-api:Invoke Resource: - !Sub "arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/meal-order-manager/*" - !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:function:meal-order-manager-*" - !Sub "arn:aws:execute-api:us-east-1:${AWS::AccountId}:*/*/GET/api/publish/settings" - !Sub "arn:aws:execute-api:us-east-1:${AWS::AccountId}:*/*/POST/api/publish/menu" - !If - IsProdAccount - Sid: MealOrderManagerSes Effect: Allow Action: - ses:SendRawEmail Resource: "*" - !Ref AWS::NoValue SeahavenSiteBoundary: Type: AWS::IAM::ManagedPolicy Properties: ManagedPolicyName: seahaven-lambda-execution-boundary-seahaven-site Description: >- Per-workload permissions boundary for githubdeploy-seahaven-site (PLAT-91 / PLAT-52). Floor plus origin S3 and CloudFront invalidate. Not a Lambda; hcptf guardrail still requires a listed boundary on /tf-managed/ roles. Shared policy remains the live-role ceiling until app retarget. PolicyDocument: Version: "2012-10-17" Statement: # Floor aliases — edit the &lambdaBoundaryFloor* anchors on # AfiBackupMonitorBoundary only; do not inline a divergent copy. - *lambdaBoundaryFloorLogsWrite - *lambdaBoundaryFloorLogsDescribe - *lambdaBoundaryFloorXRay - *lambdaBoundaryFloorEc2Eni - !If - IsProdAccount - Sid: SeahavenSiteOriginS3 Effect: Allow Action: - s3:GetObject - s3:PutObject - s3:DeleteObject - s3:GetObjectTagging - s3:PutObjectTagging - s3:ListBucket - s3:GetBucketLocation Resource: - arn:aws:s3:::seahaven-site-prod - arn:aws:s3:::seahaven-site-prod/* - !Ref AWS::NoValue - !If - IsProdAccount - Sid: SeahavenSiteCloudFrontInvalidate Effect: Allow Action: - cloudfront:CreateInvalidation - cloudfront:GetInvalidation Resource: - !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/*" - !Ref AWS::NoValue DoorUnlockApiBoundary: Type: AWS::IAM::ManagedPolicy Properties: ManagedPolicyName: seahaven-lambda-execution-boundary-seahaven-door-unlock-api Description: >- Per-workload permissions boundary for seahaven-door-unlock-api (PLAT-76 / PLAT-52). Floor plus exact prod SSM parameter ARNs and 3CX secret ARNs. Shared policy remains the live-role ceiling until app retarget. PolicyDocument: Version: "2012-10-17" Statement: # Floor aliases — edit the &lambdaBoundaryFloor* anchors on # AfiBackupMonitorBoundary only; do not inline a divergent copy. - *lambdaBoundaryFloorLogsWrite - *lambdaBoundaryFloorLogsDescribe - *lambdaBoundaryFloorXRay - *lambdaBoundaryFloorEc2Eni - !If - IsProdAccount - Sid: DoorUnlockApiSsm Effect: Allow Action: - ssm:GetParameter Resource: - arn:aws:ssm:us-east-1:011934824531:parameter/seahaven/door-unlock/elements-api-key - arn:aws:ssm:us-east-1:011934824531:parameter/seahaven/door-unlock/auth-token - arn:aws:ssm:us-east-1:011934824531:parameter/seahaven/door-unlock/door-id - !Ref AWS::NoValue - !If - IsProdAccount - Sid: DoorUnlockApiSecrets Effect: Allow Action: - secretsmanager:GetSecretValue Resource: - arn:aws:secretsmanager:us-east-1:011934824531:secret:afterhours-shift-manager/3cx-domain-TPwqWP - arn:aws:secretsmanager:us-east-1:011934824531:secret:afterhours-shift-manager/3cx-client-id-jzyQXb - arn:aws:secretsmanager:us-east-1:011934824531:secret:afterhours-shift-manager/3cx-client-secret-jpO476 - !Ref AWS::NoValue # --------------------------------------------------------------------------- # 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 an enumerated list # of acceptable boundary ARNs (shared seahaven-lambda-execution-boundary # plus each seahaven-lambda-execution-boundary-, PLAT-52). # 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 ManagedPolicyArns: - !Ref SamCfnIamManagementPolicy 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 # Ported from the mgmt copy (Phase A): afterhours-shift-manager # creates an AWS::Logs::MetricFilter through this role, so a # SAM stack migrating here fails mid-deploy without these. - logs:PutMetricFilter - logs:DeleteMetricFilter - logs:DescribeMetricFilters 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 (attached managed policy) # # Lives in a MANAGED policy, not inline on the role, because the role's # inline policies total ~10.1 KB against IAM's hard 10,240-byte per-role # inline limit - adding the Deny statements below inline exceeds it and # fails the deploy (ServiceLimitExceeded, hit live 2026-07-27). Attached # managed policies have their own separate 6,144-byte budget, so moving this # block out both fits the Denies and leaves ~1.9 KB of inline headroom for # future statements. Identity policies are unioned and an explicit Deny still # wins, so effective permissions are unchanged by the relocation. # # This is the PRIMARY escalation control for INFRA-97. # # iam:CreateRole / iam:AttachRolePolicy / iam:PutRolePolicy are # conditioned on iam:PermissionsBoundary StringEquals an enumerated # list: the shared seahaven-lambda-execution-boundary ARN plus each # seahaven-lambda-execution-boundary- ARN (PLAT-52). That # condition means any role this execution role creates must have a # listed boundary applied, so it can never exceed what that boundary # allows. Mgmt's .github copy still pins the unsuffixed ARN only. # # 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. # --------------------------------------------------------------------------- SamCfnIamManagementPolicy: Type: AWS::IAM::ManagedPolicy Properties: # Fixed name: changing it makes CloudFormation create a replacement policy # and detach this one, which briefly drops the role's IAM permissions # mid-update. Treat a rename as a coordinated migration, not an edit. This # is the role's FIRST attached managed policy (per-role quota is 10). ManagedPolicyName: seahaven-cfn-exec-iam-management Description: >- Boundary-gated IAM role lifecycle for github-cfn-execution-role, plus the explicit Deny backstops that keep the permissions boundary from being detached or rewritten. Separated from the role's inline policies to stay under IAM's 10,240-byte inline limit. 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": &acceptableLambdaBoundaries - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary-afi-backup-monitor" - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary-front-integrations" - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary-procurement-ingest" - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary-meal-order-manager" - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary-seahaven-site" - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary-seahaven-door-unlock-api" - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary-paychex-integrations" # 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": *acceptableLambdaBoundaries # 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": *acceptableLambdaBoundaries # 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": *acceptableLambdaBoundaries # 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/*" # Scoped to the whole seahaven-* policy family, not just the boundary: # this policy carries the Deny statements, so it is now a # higher-value target than the boundary it protects. Safe to scope # broadly — the role holds no iam:CreatePolicy anywhere and no SAM # stack manages a managed policy through it (both verified # 2026-07-27), so nothing legitimate writes policy versions here. - Sid: DenyBoundaryPolicyEdit Effect: Deny Action: - iam:CreatePolicyVersion - iam:SetDefaultPolicyVersion - iam:DeletePolicyVersion - iam:DeletePolicy Resource: - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-*" # Self-protection. Without this the whole control is one API call # from being undone: IAMRoleReadAndDelete below grants # iam:DetachRolePolicy on Resource "*" with no condition, so this # role could detach the very policy carrying these Denies from # itself and reinstate the escalation. Verified live 2026-07-27: # simulate-principal-policy returned "allowed" for DetachRolePolicy, # DeleteRolePolicy and DeleteRole against this role's own ARN and # against githubdeploy-* roles. # # Also closes a denial-of-service and a self-elevation precondition: # iam:PutRolePermissionsBoundary is condition-pinned to the Lambda # boundary ARN but NOT scoped by target, so this role could apply # that runtime boundary to itself or to a githubdeploy-* role — # bricking the pipelines, unrecoverable without an admin because # removing a boundary is denied above, and making the otherwise-inert # AttachRolePolicy/PutRolePolicy self-elevation conditions start # matching. # # Costs nothing operationally: this role is only ever passed to # CloudFormation for SAM application stacks. The substrate's own # roles are managed by THIS stack (deployed through the CDK # bootstrap execution role), and per-repo githubdeploy-* roles are # provisioned at onboarding time outside any stack this role # executes — so CloudFormation never exercises these actions # against them as this role. SAM-generated roles are named # -Role- and are unaffected. - Sid: DenySelfMutation Effect: Deny Action: - iam:AttachRolePolicy - iam:DeleteRole - iam:DeleteRolePolicy - iam:DeleteRolePermissionsBoundary - iam:DetachRolePolicy - iam:PutRolePolicy - iam:PutRolePermissionsBoundary - iam:UpdateAssumeRolePolicy - iam:UpdateRole - iam:UpdateRoleDescription Resource: - !Sub "arn:aws:iam::${AWS::AccountId}:role/github-cfn-execution-role" - !Sub "arn:aws:iam::${AWS::AccountId}:role/githubdeploy-*" # 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" # API Gateway assumes SAM authorizer invocation roles. Keep this # separate from Lambda PassRole so each target service and role # pattern remains independently constrained. - Sid: IAMPassAuthorizerRole Effect: Allow Action: - iam:PassRole Resource: - !Sub "arn:aws:iam::${AWS::AccountId}:role/cfn-managed/*AuthorizerInvokeRole-*" Condition: StringEquals: "iam:PassedToService": "apigateway.amazonaws.com"