seahaven-org-baseline/lib/deploy-substrate/deploy-substrate.template.yaml
Adam Moussa 689ec147a3
Some checks are pending
Deploy / deploy-management (push) Waiting to run
Deploy / deploy-external-dev (push) Waiting to run
Deploy / deploy-security (push) Waiting to run
Deploy / deploy-dev (push) Waiting to run
Deploy / deploy-prod (push) Waiting to run
feat(iam): add door-unlock-api hcptf roles and boundary (PLAT-76) (#123)
* feat(iam): add door-unlock-api hcptf roles and boundary

Give HCP Terraform a prod plan/apply pair, a per-workload Lambda boundary with exact SSM and 3CX ARNs, and API access-log delivery so PLAT-76 can leave the mgmt CDK stack.

* fix(iam): pin door-unlock apigw domain and ssm reads

Stop the apply role from managing every HTTP API custom domain, and keep SecureString door-unlock parameters off HCP plan and apply GetParameter.
2026-08-27 21:26:27 +00:00

1861 lines
92 KiB
YAML

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-<workload>
# 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:::*-<accountid>
# 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): 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, and four more in HcptfIamManagementPolicy
# in lib/terraform-substrate/terraform-substrate.template.yaml, pin an
# enumerated StringEquals list of acceptable boundary ARNs: the shared ARN
# plus each seahaven-lambda-execution-boundary-<workload> ARN. The two files'
# lists MUST match (mechanical sorted-JSON compare). A rename of the SHARED
# policy still fails SILENTLY — an IAM condition naming a non-existent policy
# simply never matches, so the escalation control evaporates rather than
# error — and would additionally force a CloudFormation REPLACEMENT that any
# role carrying the boundary would block. Adding a workload is a NEW named
# ManagedPolicy in this file AND one ARN appended to both allow-lists. 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 eight pin sites.
#
# 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: 2380 / 10 statements
# seahaven-lambda-execution-boundary-seahaven-site: 1159 / 6 statements
# seahaven-lambda-execution-boundary-seahaven-door-unlock-api: measure after deploy (PLAT-76)
# Dev copies are floor-only (691 / 4) via IsProdAccount.
#
# 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 this list (7 ARNs, PLAT-76):
# seahaven-cfn-exec-iam-management: 4571 / 10 statements (1573 headroom)
# seahaven-hcptf-iam-management: 4693 / 10 statements (1451 headroom)
#
# 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-<stack>.
#
# 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-<workload>: 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 <date>" 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-<stack>
# 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::<acct>:policy/seahaven-lambda-execution-boundary-<stack>
# aws iam delete-policy-version --version-id v<oldest-non-default> ...
# 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 both
# guardrail PolicyDocuments after step 4 — each new ARN is copied into
# four Sids in each guardrail.
# 4. Append the new ARN to BOTH allow-lists (SamCfnIamManagementPolicy in
# this file AND HcptfIamManagementPolicy in
# terraform-substrate.template.yaml). The lists must match. Do not use
# ArnLike. 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
# (and terraform-substrate) to UPDATE_COMPLETE, THEN deploy the 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
#
# 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/<function>, and the trailing * also covers the
# :log-stream:<name> 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/<fn> (and /aws/lambda/<a>/<b>), 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:<g>:log-stream:<s>) 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. Create seahaven-lambda-execution-boundary-<stack>
# below and append its ARN to both guardrail allow-lists (WIDENING PATH).
# 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
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
- !Ref AWS::NoValue
- !If
- IsProdAccount
- 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/*"
- !Ref AWS::NoValue
- !If
- IsProdAccount
- 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}/*"
- !Ref AWS::NoValue
- !If
- IsProdAccount
- Sid: MealOrderManagerSns
Effect: Allow
Action:
- sns:Publish
Resource:
- !Sub "arn:aws:sns:us-east-1:${AWS::AccountId}:site-alerts"
- !Ref AWS::NoValue
- !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
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-<workload>, 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-<workload> 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"
# 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
# <stack>-<Function>Role-<hash> 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"