mirror of
https://github.com/Sea-Haven-Industries/seahaven-account-baseline.git
synced 2026-08-04 16:56:14 +00:00
refactor(iam): reduce boundary to the fleet-wide floor, defer per-workload scope
Adam's call after review: the security win of INFRA-186 comes from DELETING the account-wide wildcards, not from enumerating replacements. Per-workload prefixes add no security -- they only keep a workload functional -- and widening a boundary is the safe direction (adding a resource never breaks a running Lambda; only tightening does). So the per-workload scope moves to each migration PR, which has the stack's real template open in front of it. Removed all nine per-workload data-plane statements (DynamoDB, S3 x3, Secrets Manager, SSM, SQS, Lambda invoke, SES, scheduler x2, KMS). Kept the fleet-wide floor: CloudWatchLogsWrite (/aws/lambda*), CloudWatchLogsDescribe, XRay, Ec2Eni -- the statements every Lambda needs regardless of workload, and also the silent-failure classes, which is why they belong in the floor. KMS dropped entirely: both accounts have ZERO CMK-encrypted log groups (verified). A workload bringing a CMK adds the statement plus the matching kms:ViaService principal in its own PR. Why not keep the enumeration: it required predicting five stacks' needs from this file's own permission-source comment block, and /sh-security-review found SIX errors in the result -- three silent. The block is a secondary record, not an authority. Deriving scope per-migration from the owning template removes the whole error class. Effect on the security objective: unchanged. secret:*, table/*, function:*, sqs:* and the s3:::*-<acct> name-suffix filter are gone either way, so the amplifier is closed identically. Size: 5,457 chars / 16 statements -> 703 / 4. Headroom 687 -> 5,441, so the cap stops being a forcing function. Header, SCOPING RULE and WIDENING PATH all updated to match; widening path now leads with 'read the stack's own template', names the silent-failure classes to check, and moves the version-budget check to a precondition instead of a trailing step. Verified unchanged: logical id and ManagedPolicyName, so all eight pinning conditions across both guardrail policies still resolve. Both accounts synth identically at 703 chars.
This commit is contained in:
parent
59852eff34
commit
32f06e74eb
1 changed files with 108 additions and 397 deletions
|
|
@ -53,17 +53,21 @@ Description: >-
|
|||
# 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 scopable Resource pattern in LambdaExecutionBoundary
|
||||
# was re-scoped from account-wide wildcards (table/*, table/*/index/*,
|
||||
# secret:*, parameter/*, sqs :*, function:*, ses identity/* +
|
||||
# configuration-set/*, and an s3:::*-<accountid> pattern that was a bare
|
||||
# name-suffix filter rather than an ownership check) to per-workload
|
||||
# prefixes drawn from the permission-source block above. The three genuinely
|
||||
# unscopable statements (CloudWatchLogsDescribe, XRay, Ec2Eni) keep
|
||||
# Resource "*" with written justification on each. CloudWatch Logs was split
|
||||
# into a scoped write half and a wildcard describe half. KMS keeps key/* and
|
||||
# is scoped by a kms:ViaService condition instead, because key ARNs carry
|
||||
# UUID key ids that cannot be prefix-scoped.
|
||||
# 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
|
||||
|
|
@ -114,19 +118,21 @@ Description: >-
|
|||
# correctly left untouched.
|
||||
#
|
||||
# SIZE BUDGET: an attached managed policy document is capped at 6,144 characters
|
||||
# (whitespace excluded). LambdaExecutionBoundary measures 5,457 characters across
|
||||
# 16 statements as of 2026-07-30 (was 2,507 across 11 before this scoping pass —
|
||||
# TIGHTENING COSTS CHARACTERS, and the review corrections cost more). Measure
|
||||
# before widening — len(json.dumps(doc,separators=(',',':'))) on the synthesized
|
||||
# (whitespace excluded). LambdaExecutionBoundary measures 703 characters across
|
||||
# 4 statements as of 2026-07-30 — the fleet-wide floor only. Measure before
|
||||
# widening — len(json.dumps(doc,separators=(',',':'))) on the synthesized
|
||||
# PolicyDocument with ${AWS::AccountId} resolved, and UPDATE THESE TWO NUMBERS in
|
||||
# the same edit (they went stale once already inside a single branch).
|
||||
# the same edit (they went stale twice inside this branch alone).
|
||||
#
|
||||
# ⚠ HEADROOM IS 687 CHARACTERS — roughly ONE more workload at ~450 each, NOT the
|
||||
# "five" an earlier revision of this header claimed. The next stack to migrate is
|
||||
# likely to exhaust it. Read the note below before assuming there is room: the
|
||||
# realistic next move is per-workload boundaries
|
||||
# (seahaven-lambda-execution-boundary-<workload>), which is also the durable fix
|
||||
# for the shared-ceiling residual documented in the SCOPING RULE.
|
||||
# Headroom is 5,441 characters, roughly TWELVE workloads at ~450 each. That is a
|
||||
# deliberate outcome, not luck: an earlier revision of this branch pre-loaded
|
||||
# per-workload prefixes for all five mgmt SAM stacks and reached 5,457 characters
|
||||
# with 687 left — about one workload of room — before any stack had actually
|
||||
# migrated. Deferring per-workload scope to each migration PR (see the note on
|
||||
# the boundary itself) removed that pressure entirely. If the budget tightens
|
||||
# again as workloads land, the end-state fix is per-workload boundaries
|
||||
# (seahaven-lambda-execution-boundary-<workload>), which also resolves the
|
||||
# shared-ceiling residual — tracked as its own ticket, do not improvise it.
|
||||
# CRITICAL: unlike the 2026-07-27 inline-limit incident,
|
||||
# there is NO restructure available when this cap is reached — a role has exactly
|
||||
# ONE permissions boundary, so statements cannot be spilled into a second attached
|
||||
|
|
@ -181,8 +187,11 @@ Resources:
|
|||
# intersection of the role's own policies and this boundary, so a misconfigured
|
||||
# SAM role can never exceed what is listed here.
|
||||
#
|
||||
# SCOPING RULE (INFRA-186, 2026-07-30). This boundary is a CLOSED ENUMERATION
|
||||
# of per-workload resource prefixes. It was previously a deliberate SUPERSET
|
||||
# SCOPING RULE (INFRA-186, 2026-07-30). This boundary is the FLEET-WIDE FLOOR
|
||||
# ONLY: what every Lambda execution role needs regardless of workload. It
|
||||
# carries NO per-workload data-plane statements — each migrating stack adds its
|
||||
# own, from its own template, in its own PR (see the note on the boundary
|
||||
# resource and the WIDENING PATH below). It was previously a deliberate SUPERSET
|
||||
# with account-wide wildcards (table/*, secret:*, sqs :*, function:*,
|
||||
# parameter/*, and an s3:::*-<accountid> pattern that was a name-suffix filter,
|
||||
# not an ownership check). That trade-off was made when the only account
|
||||
|
|
@ -209,21 +218,30 @@ Resources:
|
|||
# The boundary is never widened by the person who hits the AccessDenied. It is
|
||||
# widened by the migrating stack's owner, in THIS repo, BEFORE the workload's
|
||||
# first deploy into the target account:
|
||||
# 1. Add the workload's prefixes to the relevant per-service Resource lists
|
||||
# above — never add a new per-workload statement (a duplicated action list
|
||||
# costs ~250 characters for zero new actions; an extra ARN costs ~60).
|
||||
# Add its entry to the permission-source block below in the same edit: that
|
||||
# block is the sanctioned scope source, and a prefix added without one will
|
||||
# be "reconciled" away later.
|
||||
# 2. Measure. See SIZE BUDGET in the header. There is NO escape hatch.
|
||||
# 3. 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.
|
||||
# 4. Merge and let CI deploy deploy-substrate-prod / deploy-substrate-dev to
|
||||
# UPDATE_COMPLETE, THEN deploy the workload stack.
|
||||
# 5. Check the managed-policy VERSION budget first: max 5 versions, both
|
||||
# 0. PRECONDITION — check the managed-policy VERSION budget BEFORE merging:
|
||||
# max 5 versions, both
|
||||
# accounts are on v1 today. Every widening — and every Description-only
|
||||
# edit — burns one. Delete the oldest non-default version if at 5.
|
||||
# 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. Group by service so a second workload can
|
||||
# extend a Resource list rather than duplicate an action list (~250
|
||||
# characters for zero new actions; an extra ARN costs ~60). Check for the
|
||||
# SILENT classes specifically: 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; and any CMK-encrypted resource needs the
|
||||
# matching kms:ViaService principal, not just the kms action.
|
||||
# 3. Measure. See SIZE BUDGET in the header. There is NO escape hatch.
|
||||
# 4. 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 workload stack.
|
||||
# ORDERING IS NOT ENFORCED BY CLOUDFORMATION AND THIS IS THE MOST IMPORTANT
|
||||
# SENTENCE HERE: the workload's deploy SUCCEEDS even against a stale boundary,
|
||||
# because seahaven-cfn-exec-iam-management's gate checks that the boundary ARN
|
||||
|
|
@ -428,366 +446,59 @@ Resources:
|
|||
- ec2:DescribeVpcs
|
||||
Resource: "*"
|
||||
|
||||
# ── DynamoDB — one prefix per enumerated workload ───────────────────
|
||||
# Replaces table/* + table/*/index/*, which reached every table in the
|
||||
# account. A trailing * on each workload prefix covers the base table
|
||||
# AND its /index/* GSI ARNs in a single entry (IAM wildcards match "/"),
|
||||
# verified 2026-07-30 with iam simulate-custom-policy against
|
||||
# table/afterhours-shifts/index/gsi1 — so the separate table/*/index/*
|
||||
# line is deleted, not replaced. Prod's CDK-owned tables (WorkOrders,
|
||||
# WorkOrderComments, purchase-orders, verified-sites, pending-site-review)
|
||||
# match none of these prefixes, which is the point.
|
||||
# PaymentsDashboard is a legacy PascalCase table exempt from renaming
|
||||
# under handbook naming-conventions.md; the kebab-case prefix is listed
|
||||
# alongside it so a future rename cannot silently lock the stack out.
|
||||
# Table names are NOT stack names (afterhours-shifts, front-sla-alerts) —
|
||||
# scoping naively off stack name would deny at runtime.
|
||||
- Sid: DynamoDB
|
||||
Effect: Allow
|
||||
Action:
|
||||
- dynamodb:GetItem
|
||||
- dynamodb:PutItem
|
||||
- dynamodb:UpdateItem
|
||||
- dynamodb:DeleteItem
|
||||
- dynamodb:Query
|
||||
- dynamodb:Scan
|
||||
- dynamodb:BatchGetItem
|
||||
- dynamodb:BatchWriteItem
|
||||
- dynamodb:DescribeTable
|
||||
- dynamodb:ConditionCheckItem
|
||||
Resource:
|
||||
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/afterhours-*"
|
||||
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/front-*"
|
||||
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/meal-order-manager-*"
|
||||
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/PaymentsDashboard*"
|
||||
- !Sub "arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/payments-dashboard-*"
|
||||
|
||||
# ── S3 — write access (meal-order-manager only) ─────────────────────
|
||||
# THE LARGEST SECURITY WIN IN INFRA-186. The removed
|
||||
# arn:aws:s3:::*-${AWS::AccountId} was not a per-workload scope at all:
|
||||
# S3 ARNs carry no account field, so it was a bare NAME-SUFFIX FILTER
|
||||
# matching every bucket in the account whose name ends in the account id.
|
||||
# In seahaven-prod today that was 8 of 9 buckets — all four
|
||||
# proposal-system-*, both ingest-email buckets, AND the org's own
|
||||
# seahaven-prod-config-* and seahaven-prod-vpc-flow-logs-* — with
|
||||
# PutObject and DeleteObject. That is anti-forensics capability over the
|
||||
# org's own security telemetry, handed to the SAM fleet's ceiling.
|
||||
# Verified 2026-07-30 with iam simulate-custom-policy: the patterns below
|
||||
# deny seahaven-prod-config-*, seahaven-prod-vpc-flow-logs-* and
|
||||
# proposal-system-uploads-*, and allow meal-order-manager-reports-*.
|
||||
# The four meal-order-manager-*-${AWS::AccountId} entries previously
|
||||
# listed here were strictly redundant — every one ends in -<accountid> and
|
||||
# was already matched by the wildcard above them; their comment claiming a
|
||||
# "non-AccountId suffix pattern" was contradicted by the ARNs beneath it.
|
||||
# Split by DIRECTION of access: meal-order-manager is the only stack the
|
||||
# permission-source block gives write intent to (ReportsBucket /
|
||||
# FormBucket CRUD). Both the bucket and object ARN forms are listed in
|
||||
# each statement because s3:ListBucket authorises against the bucket ARN
|
||||
# and s3:GetObject against the object ARN.
|
||||
- Sid: S3WorkloadReadWrite
|
||||
Effect: Allow
|
||||
Action:
|
||||
- s3:GetObject
|
||||
- s3:PutObject
|
||||
- s3:DeleteObject
|
||||
- s3:ListBucket
|
||||
- s3:GetBucketLocation
|
||||
- s3:GetObjectVersion
|
||||
- s3:GetObjectTagging
|
||||
- s3:PutObjectTagging
|
||||
Resource:
|
||||
- !Sub "arn:aws:s3:::meal-order-manager-*-${AWS::AccountId}"
|
||||
- !Sub "arn:aws:s3:::meal-order-manager-*-${AWS::AccountId}/*"
|
||||
|
||||
# ── S3 — payments-dashboard ─────────────────────────────────────────
|
||||
# Split by direction, but note the source of truth: the template
|
||||
# comment above enumerates "S3 GetObject" for payments-dashboard, and
|
||||
# that enumeration is INCOMPLETE. Verified against the real stack
|
||||
# 2026-07-30 — payments-dashboard/template.yaml grants s3:PutObject on
|
||||
# BoaRawBucket (lines 272 and 1098-1099), so the fetchBoaTransactions
|
||||
# path writes raw BoA payloads. A read-only grant here would deny that
|
||||
# write at migration time. Write is therefore allowed on the boa-raw
|
||||
# bucket ONLY; payroll-emails and payments-csv stay read-only, so a
|
||||
# compromised payments function still cannot delete payroll evidence.
|
||||
# Buckets carry the org-wide seahaven- prefix rather than a payments-
|
||||
# one, which is why bucket names cannot be derived from stack names.
|
||||
- Sid: S3PaymentsBoaRawWrite
|
||||
Effect: Allow
|
||||
Action:
|
||||
- s3:GetObject
|
||||
- s3:PutObject
|
||||
- s3:GetObjectVersion
|
||||
- s3:ListBucket
|
||||
- s3:GetBucketLocation
|
||||
Resource:
|
||||
- !Sub "arn:aws:s3:::seahaven-payments-boa-raw-${AWS::AccountId}"
|
||||
- !Sub "arn:aws:s3:::seahaven-payments-boa-raw-${AWS::AccountId}/*"
|
||||
|
||||
- Sid: S3WorkloadReadOnly
|
||||
Effect: Allow
|
||||
Action:
|
||||
- s3:GetObject
|
||||
- s3:GetObjectVersion
|
||||
- s3:ListBucket
|
||||
- s3:GetBucketLocation
|
||||
Resource:
|
||||
- !Sub "arn:aws:s3:::seahaven-payments-csv-${AWS::AccountId}"
|
||||
- !Sub "arn:aws:s3:::seahaven-payments-csv-${AWS::AccountId}/*"
|
||||
- !Sub "arn:aws:s3:::seahaven-payroll-emails-${AWS::AccountId}"
|
||||
- !Sub "arn:aws:s3:::seahaven-payroll-emails-${AWS::AccountId}/*"
|
||||
|
||||
# ── Secrets Manager — one <stack>/ prefix per workload ──────────────
|
||||
# Replaces secret:*, which in seahaven-prod today reads
|
||||
# proposal-system/db-credentials, proposal-system/bedrock-user,
|
||||
# procurement-ingest/web-ui-auth-token and workorder-ingest/shoc-webhook-hmac
|
||||
# — the last of which is an HMAC SIGNING key, so the wildcard was a
|
||||
# webhook-forgery primitive against the SHOC integration.
|
||||
# The trailing * after each / is MANDATORY, not decorative: Secrets
|
||||
# Manager appends a random 6-character suffix to every ARN, so an
|
||||
# exact-name ARN never matches. Verified 2026-07-30 with
|
||||
# iam simulate-custom-policy against a suffixed ARN.
|
||||
# afi-api-key is a bare, unprefixed, account-root secret that predates the
|
||||
# <stack>/<name> convention (afi-backup-monitor's OTHER secret,
|
||||
# afi-backup-monitor/slack-webhook-url, is correctly prefixed). It is
|
||||
# listed explicitly because omitting it would encode a KNOWN-WRONG scope
|
||||
# that fails silently months from now at migration. MIGRATION OBLIGATION:
|
||||
# afi-backup-monitor's migration PR renames it to
|
||||
# afi-backup-monitor/api-key and deletes this line.
|
||||
# secretsmanager:ListSecrets and BatchGetSecretValue are deliberately
|
||||
# ABSENT and must stay absent — AWS authorises both against "*" regardless
|
||||
# of any resource list, so adding either would silently reinstate
|
||||
# account-wide read. (Precedent: BatchGetSecretValue synthed clean, passed
|
||||
# review, then AccessDenied in production and crash-looped open-swe.)
|
||||
- Sid: SecretsManager
|
||||
Effect: Allow
|
||||
Action:
|
||||
- secretsmanager:GetSecretValue
|
||||
- secretsmanager:DescribeSecret
|
||||
Resource:
|
||||
- !Sub "arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:afterhours-shift-manager/*"
|
||||
- !Sub "arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:afi-backup-monitor/*"
|
||||
- !Sub "arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:afi-api-key-*"
|
||||
# CORRECTION (2026-07-30 review): the permission-source block
|
||||
# named this secret "afi-backup-monitor/slack-webhook-url", which
|
||||
# does not exist. afi-backup-monitor/template.yaml takes both
|
||||
# secret ARNs as deploy PARAMETERS, so no name is discoverable
|
||||
# from the template; the real names are in that repo's README
|
||||
# (afi-api-key and afi-slack-webhook, both bare/unprefixed). Same
|
||||
# rename-at-migration obligation as afi-api-key applies.
|
||||
- !Sub "arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:afi-slack-webhook-*"
|
||||
- !Sub "arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:front-integrations/*"
|
||||
- !Sub "arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:meal-order-manager/*"
|
||||
- !Sub "arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:payments-dashboard/*"
|
||||
|
||||
# ── SSM Parameter Store (meal-order-manager, afterhours) ────────────
|
||||
# Replaces parameter/*, which in seahaven-prod reads
|
||||
# /procurement-api/custom-domain/certificate-arn and
|
||||
# /seahaven/dynamodb/cmk-arn. These are exactly the two hierarchies the
|
||||
# statement's own heading already claimed to serve.
|
||||
# The bare-path entries alongside the /* entries are REQUIRED, not
|
||||
# duplicates: ssm:GetParametersByPath authorises against the PATH ARN,
|
||||
# not the leaf, so a /*-only grant can deny the recursive read.
|
||||
# Note the ARN form drops the parameter's leading slash.
|
||||
# /3cx-scheduler/* is deliberately EXCLUDED: ownership between
|
||||
# afterhours-shift-manager and the retired standalone 3CX ring-group
|
||||
# scheduler is unresolved, and inventing a scope is not permitted.
|
||||
# Resolve at afterhours' migration and widen then if it is genuinely ours.
|
||||
- Sid: SSMParameterRead
|
||||
Effect: Allow
|
||||
Action:
|
||||
- ssm:GetParameter
|
||||
- ssm:GetParameters
|
||||
- ssm:GetParametersByPath
|
||||
Resource:
|
||||
- !Sub "arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/afterhours-shift-manager"
|
||||
- !Sub "arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/afterhours-shift-manager/*"
|
||||
- !Sub "arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/meal-order-manager"
|
||||
- !Sub "arn:aws:ssm:us-east-1:${AWS::AccountId}:parameter/meal-order-manager/*"
|
||||
|
||||
# ── SQS (payments-dashboard batch queues + async DLQs) ──────────────
|
||||
# payments-dashboard is the only enumerated stack with a queue
|
||||
# dependency, and all of its live queues are payments-prefixed. Replacing
|
||||
# :* costs four characters and removes sqs:ReceiveMessage /
|
||||
# sqs:DeleteMessage on proposal-system-jobs, workorder-shoc-emitter-failures
|
||||
# and workorder-shoc-emitter-rejected, where the wildcard was a silent
|
||||
# message-drain (data-loss) primitive against another tenant's pipeline.
|
||||
# Verified denied 2026-07-30 with iam simulate-custom-policy.
|
||||
# A prefix rather than four literals is deliberate, BUT the original
|
||||
# justification for it was wrong and is corrected here: SAM does NOT
|
||||
# auto-create or auto-name async DLQs — all four payments queues are
|
||||
# hand-written AWS::SQS::Queue resources with explicit QueueNames, and
|
||||
# OnFailure destinations take an explicit ARN. The real invariant is
|
||||
# therefore a naming rule, not a framework behaviour: ANY queue a
|
||||
# boundary-carrying function sends to — including async OnFailure
|
||||
# destinations and DeadLetterQueue targets — must be named payments-*,
|
||||
# or the boundary must be widened in the SAME PR. A denied destination
|
||||
# write is SILENT: the async event is discarded with no caller to
|
||||
# error, no Errors datapoint and no DLQ contents.
|
||||
# sqs:ListQueues is absent and must stay absent (authorised against "*").
|
||||
- Sid: SQS
|
||||
Effect: Allow
|
||||
Action:
|
||||
- sqs:SendMessage
|
||||
- sqs:ReceiveMessage
|
||||
- sqs:DeleteMessage
|
||||
- sqs:GetQueueAttributes
|
||||
- sqs:GetQueueUrl
|
||||
- sqs:ChangeMessageVisibility
|
||||
Resource:
|
||||
- !Sub "arn:aws:sqs:us-east-1:${AWS::AccountId}:payments-*"
|
||||
|
||||
# ── Lambda invocation — HIGHEST-LEVERAGE FIX IN THIS CHANGE ─────────
|
||||
# function:* was a BOUNDARY-ESCAPE primitive, not merely lateral
|
||||
# movement: an invoked function executes under ITS OWN execution role,
|
||||
# and every non-SAM function in seahaven-prod (proposal-system-*,
|
||||
# procurement-api, workorder-*, po-*) is CDK-deployed and carries NO
|
||||
# permissions boundary at all. A bounded SAM Lambda could therefore reach,
|
||||
# by proxy, capability this ceiling exists to deny. Verified 2026-07-30
|
||||
# with iam simulate-custom-policy: proposal-system-api is now denied,
|
||||
# payments-expenseProcessor still allowed.
|
||||
# Scoped to the three workloads with enumerated inter-function calls:
|
||||
# payments-dashboard (ExpenseReceiver -> ExpenseProcessor),
|
||||
# meal-order-manager (submit-order -> slack-notifier, close-form ->
|
||||
# aggregate-orders, plus AdminAuthorizerInvokeRole), and
|
||||
# afterhours-shift-manager, whose boundary-carrying ReleaseNotifyInvokeRole
|
||||
# and HolidaySchedulerExecutionRole exist solely to invoke.
|
||||
# front-integrations and afi-backup-monitor have no enumerated invoke need
|
||||
# and are deliberately absent. Prefixes (not literal function ARNs) are
|
||||
# used because they also match the :<alias> / :<version> qualified form.
|
||||
- Sid: LambdaInvoke
|
||||
Effect: Allow
|
||||
Action:
|
||||
- lambda:InvokeFunction
|
||||
Resource:
|
||||
- !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:function:afterhours-*"
|
||||
- !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:function:meal-order-manager-*"
|
||||
- !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:function:payments-*"
|
||||
|
||||
# ── SES — pinned to account inventory, NOT per-workload scoping ─────
|
||||
# Labelled honestly: an SES identity is a shared DOMAIN, so no
|
||||
# per-workload prefix exists to scope to. These are seahaven-prod's two
|
||||
# verified identities (re-verified live 2026-07-30); seahaven-dev has
|
||||
# none, so both entries are simply inert there. The win is bounded but
|
||||
# real: identity/* would let the SAM fleet send as ANY identity ever added
|
||||
# to the account, including a customer or partner domain, from a
|
||||
# legitimately-authenticated sender. This cannot break anything that would
|
||||
# otherwise work — sending from an unverified identity fails with
|
||||
# MessageRejected regardless of IAM — so the failure mode is LOUD.
|
||||
# configuration-set/* is DROPPED: zero configuration sets exist in prod or
|
||||
# dev and no enumerated stack uses one.
|
||||
# MIGRATION-BLOCKING QUESTION: mgmt additionally has seahavenind.com,
|
||||
# apfacilities.org, adam@seahaven.com and payroll@seahaven.com verified;
|
||||
# prod does NOT. Each migrating stack must confirm its actual Source
|
||||
# address, verify that domain in the target account, and add the identity
|
||||
# ARN here in the same PR.
|
||||
# A ses:FromAddress condition would be stronger but is not available: the
|
||||
# permission-source block names payroll@seahavenind.com, which is not a
|
||||
# verified identity in prod — writing that condition would invent a scope.
|
||||
# CORRECTION (2026-07-30 review): an earlier revision DROPPED
|
||||
# configuration-set/* on the reasoning that zero configuration sets
|
||||
# exist in prod or dev today. That test was the wrong one. SES
|
||||
# authorizes SendEmail against the CONFIGURATION-SET resource in
|
||||
# addition to the identity whenever the identity has a default
|
||||
# configuration set, and afterhours-shift-manager/template.yaml:178-181
|
||||
# grants exactly that ARN with an in-repo comment recording that
|
||||
# omitting it DENIES the send. The correct question is not "does the
|
||||
# resource exist in the target account yet" but "does an enumerated
|
||||
# stack's own IAM policy name it". seahavenind.com is listed because
|
||||
# meal-order-manager's SenderEmail parameter defaults to
|
||||
# adam@seahavenind.com (meal-order-manager/template.yaml:20-22) and its
|
||||
# email_report handler sends with that Source; the earlier "sending
|
||||
# from an unverified identity fails loudly anyway" argument only holds
|
||||
# until the migration verifies the domain, which the migration
|
||||
# procedure itself requires.
|
||||
- Sid: SES
|
||||
Effect: Allow
|
||||
Action:
|
||||
- ses:SendEmail
|
||||
- ses:SendRawEmail
|
||||
Resource:
|
||||
- !Sub "arn:aws:ses:us-east-1:${AWS::AccountId}:identity/int.seahaven.com"
|
||||
- !Sub "arn:aws:ses:us-east-1:${AWS::AccountId}:identity/seahaven.com"
|
||||
- !Sub "arn:aws:ses:us-east-1:${AWS::AccountId}:identity/seahavenind.com"
|
||||
- !Sub "arn:aws:ses:us-east-1:${AWS::AccountId}:configuration-set/seahaven-email-events"
|
||||
|
||||
# ── EventBridge Scheduler (afterhours-shift-manager holiday routing) ─
|
||||
# Omitted from the permission-source block entirely, which the block's
|
||||
# "verified live" header did not catch. afterhours-shift-manager
|
||||
# template.yaml:110-120 grants these three scheduler actions plus
|
||||
# iam:PassRole on its holiday-scheduler execution role. Without them
|
||||
# the failure is SILENT: src/slack-bot/app.py wraps create_schedule in
|
||||
# a bare `except Exception`, so the Slack command returns success, the
|
||||
# holiday record is written with no schedule, and no alarm fires.
|
||||
- Sid: EventBridgeScheduler
|
||||
Effect: Allow
|
||||
Action:
|
||||
- scheduler:CreateSchedule
|
||||
- scheduler:DeleteSchedule
|
||||
- scheduler:GetSchedule
|
||||
Resource:
|
||||
- !Sub "arn:aws:scheduler:us-east-1:${AWS::AccountId}:schedule/default/holiday-*"
|
||||
|
||||
# PassRole is confined to the scheduler service principal, so this
|
||||
# cannot be used to hand a role to Lambda or any other service.
|
||||
- Sid: SchedulerPassRole
|
||||
Effect: Allow
|
||||
Action:
|
||||
- iam:PassRole
|
||||
Resource:
|
||||
- !Sub "arn:aws:iam::${AWS::AccountId}:role/afterhours-shift-manager-*"
|
||||
Condition:
|
||||
StringEquals:
|
||||
"iam:PassedToService": "scheduler.amazonaws.com"
|
||||
|
||||
# ── KMS — key/* RETAINED, scoped by condition instead ───────────────
|
||||
# KMS is the one high-value data plane that CANNOT be scoped by resource
|
||||
# name: key ARNs carry UUID key ids, not workload names, and alias ARNs
|
||||
# are not valid in a Resource for these actions (alias scoping needs a
|
||||
# kms:RequestAlias condition). The two key ids named in this statement's
|
||||
# previous comment (key/0b660af3, key/b748750c) are MGMT keys that do not
|
||||
# exist in seahaven-prod or seahaven-dev; hardcoding them — or prod's
|
||||
# three live CMKs — would encode one account's inventory into a template
|
||||
# shared by two, and would break on any key replacement.
|
||||
# So Resource stays key/* and the scope is derived from kms:ViaService:
|
||||
# the fleet may use a CMK ONLY as part of a request one of these services
|
||||
# makes on its behalf. Decrypting a DynamoDB item or an S3 object still
|
||||
# works; a direct kms:Decrypt on arbitrary ciphertext lifted from anywhere
|
||||
# in the account is denied — the actual escalation path key/* opened.
|
||||
# Because those services are themselves prefix-scoped above, the effective
|
||||
# KMS scope INHERITS the per-workload scoping for free, with no key ids
|
||||
# and no per-account parameterisation.
|
||||
# logs.us-east-1.amazonaws.com is included as belt-and-braces: CloudWatch
|
||||
# Logs is believed to decrypt log-group CMKs under its own service grant
|
||||
# rather than the execution role's credentials, which would make this entry
|
||||
# a no-op — but a KMS denial on the logging path would be SILENT, and the
|
||||
# entry cannot grant anything meaningful on its own, so the insurance is
|
||||
# bought deliberately.
|
||||
# If a specific key must ever be named, use a kms:RequestAlias condition —
|
||||
# never a literal key id.
|
||||
- Sid: KMS
|
||||
Effect: Allow
|
||||
Action:
|
||||
- kms:Decrypt
|
||||
- kms:GenerateDataKey
|
||||
- kms:DescribeKey
|
||||
Resource:
|
||||
- !Sub "arn:aws:kms:us-east-1:${AWS::AccountId}:key/*"
|
||||
Condition:
|
||||
StringEquals:
|
||||
kms:ViaService:
|
||||
- dynamodb.us-east-1.amazonaws.com
|
||||
- logs.us-east-1.amazonaws.com
|
||||
- s3.us-east-1.amazonaws.com
|
||||
- secretsmanager.us-east-1.amazonaws.com
|
||||
# REQUIRED for parity with SSMParameterRead above: a
|
||||
# SecureString parameter decrypts via the SSM service
|
||||
# principal, so omitting this denies reads that this same
|
||||
# policy grants — a self-inconsistency caught by the
|
||||
# 2026-07-30 review. Any ssm:GetParameter* grant in this
|
||||
# boundary must keep this entry.
|
||||
- ssm.us-east-1.amazonaws.com
|
||||
- sqs.us-east-1.amazonaws.com
|
||||
|
||||
# ── NO PER-WORKLOAD DATA-PLANE STATEMENTS — BY DESIGN ───────────────
|
||||
# The statements above are the FLEET-WIDE FLOOR: what every Lambda
|
||||
# execution role needs regardless of which workload it belongs to.
|
||||
# There are deliberately NO DynamoDB, S3, Secrets Manager, SSM, SQS,
|
||||
# SES, KMS, lambda:InvokeFunction or scheduler statements here.
|
||||
#
|
||||
# WHY (decided 2026-07-30, Adam):
|
||||
# The security win of INFRA-186 comes from DELETION, not enumeration.
|
||||
# Removing the account-wide secret:*, table/*, function:* and sqs:*
|
||||
# wildcards is what closes the amplifier — the ability of a principal
|
||||
# who can write an inline policy onto a boundary-carrying role to read
|
||||
# every secret in the account. Per-workload prefixes add no security;
|
||||
# they exist only to keep a workload FUNCTIONAL once it arrives.
|
||||
#
|
||||
# An earlier revision of this branch PRE-LOADED prefixes for all five
|
||||
# mgmt SAM stacks before any of them had migrated. That required
|
||||
# predicting five stacks' permission needs from the permission-source
|
||||
# comment block above, and the /sh-security-review pass found SIX
|
||||
# errors in the result — three of which would have failed SILENTLY at
|
||||
# first migration (afterhours' SES config-set, its holiday scheduler
|
||||
# behind a bare except, and afi's webhook secret under an invented
|
||||
# name). The block is a secondary record and is not a substitute for
|
||||
# reading the owning repo's template.
|
||||
#
|
||||
# WIDENING IS THE SAFE DIRECTION. Adding a resource to a boundary can
|
||||
# never break a running Lambda; only tightening can. So there is no
|
||||
# cost to deferring per-workload scope to the migration PR that has
|
||||
# the real template open in front of it — and a large cost to
|
||||
# guessing it years ahead of the migration.
|
||||
#
|
||||
# CONSEQUENCE FOR EVERY MIGRATION PR (mandatory, see WIDENING PATH in
|
||||
# the header): a stack landing in prod or dev MUST add its own
|
||||
# data-plane statements here, derived from ITS OWN template, in the
|
||||
# same PR that deploys it. Without them its Lambdas get AccessDenied
|
||||
# at first invoke. The permission-source block above is the starting
|
||||
# point, NOT the authority — verify every entry against the stack.
|
||||
#
|
||||
# Prod/dev boundary usage is 0 (verified 2026-07-30), so this floor
|
||||
# currently constrains nothing that exists. Fleet-wide statements that
|
||||
# genuinely cannot be scoped (Logs, X-Ray, ENI) stay above with their
|
||||
# justifications; they are also the SILENT-failure classes, which is
|
||||
# why they belong in the floor rather than in per-workload widenings.
|
||||
#
|
||||
# KMS is absent deliberately: both accounts have ZERO CMK-encrypted
|
||||
# log groups today (verified 2026-07-30). A workload bringing a
|
||||
# CMK-encrypted resource adds a KMS statement with the matching
|
||||
# kms:ViaService principal in its own migration PR — a missing
|
||||
# ViaService entry denies, and for env-var encryption it fails at
|
||||
# cold-start INIT.
|
||||
#
|
||||
# The end-state fix for the shared-ceiling residual (one boundary =
|
||||
# every SAM workload reaches every other's data plane once they land)
|
||||
# is per-workload boundaries — tracked separately, see the header.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared CloudFormation execution role (SAM stacks) — INFRA-97 scoped
|
||||
#
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue