feat(iam): add hcptf roles/boundary widen - afi-backup-monitor (PLAT-56) (#76)

* feat(iam): add hcptf roles and boundary widen for afi-backup-monitor

Provision plan/apply OIDC roles for workspace afi-backup-monitor-prod
and widen the prod Lambda boundary with the two exact secret ARNs.

* fix(iam): split DescribeLogGroups and allow afi artifact bucket

logs:DescribeLogGroups cannot be resource-scoped; grant it on *. Add
S3 permissions for the HCP Lambda artifact bucket used by PLAT-56.
This commit is contained in:
Adam Moussa 2026-08-05 12:44:52 -04:00 • committed by GitHub
parent 517f41eb7f
commit 4e1cf4c0bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 195 additions and 66 deletions

View file

@ -158,6 +158,9 @@ Parameters:
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:
@ -349,13 +352,15 @@ Resources:
# stack's template as of 2026-07-30
#
# afi-backup-monitor (functions: afi-*)
# - secretsmanager:GetSecretValue on TWO bare, unprefixed secrets:
# afi-api-key and afi-slack-webhook. CORRECTED 2026-07-30: this block
# previously named "afi-backup-monitor/slack-webhook-url", which does
# not exist. Both ARNs are deploy PARAMETERS in that stack
# (AfiApiKeySecretArn / SlackWebhookSecretArn), so no name is
# discoverable from its template — the names are in its README:48-49.
# - CloudWatch Logs
# - 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
#
# ---------------------------------------------------------------------------
@ -487,70 +492,40 @@ Resources:
- ec2:DescribeVpcs
Resource: "*"
# ── 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.
# ── afi-backup-monitor (PLAT-56) — prod-only exact secret ARNs ───────
# Derived from live stack parameters + secrets created in seahaven-prod
# 2026-08-05. No secret:afi-* patterns. Omitted in seahaven-dev via
# IsProdAccount (same template deploys to both accounts).
- !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
# ── FURTHER PER-WORKLOAD DATA-PLANE STATEMENTS ──────────────────────
# Floor above + afi-backup-monitor widening (PLAT-56). Additional
# stacks add their own statements here, derived from THEIR OWN
# template, in their own PR, deployed to UPDATE_COMPLETE before first
# workload deploy. WIDENING PATH in the header still governs.
#
# WHY (decided 2026-07-30, Adam):
# WHY the floor stayed empty of data-plane (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 its
# own PR to THIS repo, deployed to UPDATE_COMPLETE before the
# workload's first deploy from its own repo (the workload PR and the
# widening PR cannot be the same PR — they live in different repos).
# 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: zero boundary-carrying roles exist, so
# nothing bounded decrypts anything yet. (Log-group CMKs are the one
# KMS case that never hits an execution role — CloudWatch Logs
# decrypts via the key policy's grant to the Logs service principal —
# so log-group CMK state is not the gate here. seahaven-prod already
# hosts the seahaven-dynamodb-cmk table key; the first migrating
# stack touching that table must cover it.) 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.
# wildcards is what closes the amplifier. Per-workload prefixes add no
# security; they exist only to keep a workload FUNCTIONAL once it
# arrives. WIDENING IS THE SAFE DIRECTION.
#
# 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 as INFRA-187. Do not improvise
# it: the load-bearing problem there is that both guardrail policies
# pin ONE literal boundary ARN inside StringEquals conditions, and
# loosening that to a wildcard weakens the gate.
# every SAM/Terraform workload reaches every other's data plane once
# they land) is per-workload boundaries — tracked as INFRA-187. Do not
# improvise it: both guardrail policies pin ONE literal boundary ARN
# inside StringEquals conditions, and loosening that to a wildcard
# weakens the gate.
# ---------------------------------------------------------------------------
# Shared CloudFormation execution role (SAM stacks) — INFRA-97 scoped
#

View file

@ -118,6 +118,10 @@ Parameters:
Conditions:
ShouldCreateOIDCProvider: !Equals [!Ref CreateOIDCProvider, "true"]
# Per-workspace hcptf-* roles for afi-backup-monitor-prod (PLAT-56) must only
# exist in seahaven-prod. The same template deploys to seahaven-dev; creating
# prod-workspace trust there would leave dead credentials in the wrong account.
IsProdAccount: !Equals [!Ref "AWS::AccountId", "011934824531"]
Resources:
@ -367,3 +371,153 @@ Resources:
Condition:
StringEquals:
"iam:PassedToService": "lambda.amazonaws.com"
# ---------------------------------------------------------------------------
# Per-workspace roles: afi-backup-monitor-prod (PLAT-56)
#
# First HCP Terraform workload. Trust subs are exact StringEquals on
# organization/project/workspace/run_phase — never StringLike, never a
# wildcarded run_phase. Plan role: ViewOnlyAccess only (never ReadOnlyAccess,
# which grants secretsmanager:GetSecretValue). Apply role: attaches the
# shared guardrail plus stack-scoped Lambda / layer / EventBridge / Logs.
# Prod-only (IsProdAccount): this template also deploys to seahaven-dev.
# ---------------------------------------------------------------------------
HcptfAfiBackupMonitorPlanRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
Properties:
RoleName: hcptf-afi-backup-monitor-plan
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub "arn:aws:iam::${AWS::AccountId}:oidc-provider/app.terraform.io"
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
"app.terraform.io:aud": aws.workload.identity
"app.terraform.io:sub": organization:seahaven:project:seahaven-prod:workspace:afi-backup-monitor-prod:run_phase:plan
ManagedPolicyArns:
- arn:aws:iam::aws:policy/job-function/ViewOnlyAccess
HcptfAfiBackupMonitorApplyRole:
Type: AWS::IAM::Role
Condition: IsProdAccount
Properties:
RoleName: hcptf-afi-backup-monitor
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Federated: !Sub "arn:aws:iam::${AWS::AccountId}:oidc-provider/app.terraform.io"
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
"app.terraform.io:aud": aws.workload.identity
"app.terraform.io:sub": organization:seahaven:project:seahaven-prod:workspace:afi-backup-monitor-prod:run_phase:apply
ManagedPolicyArns:
- !Ref HcptfIamManagementPolicy
Policies:
- PolicyName: afi-backup-monitor-services
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: LambdaFunctions
Effect: Allow
Action:
- lambda:CreateFunction
- lambda:DeleteFunction
- lambda:GetFunction
- lambda:GetFunctionConfiguration
- lambda:UpdateFunctionCode
- lambda:UpdateFunctionConfiguration
- lambda:ListVersionsByFunction
- lambda:PublishVersion
- lambda:TagResource
- lambda:UntagResource
- lambda:ListTags
- lambda:AddPermission
- lambda:RemovePermission
- lambda:GetPolicy
- lambda:InvokeFunction
Resource:
- !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:function:afi-*"
- Sid: LambdaLayers
Effect: Allow
Action:
- lambda:PublishLayerVersion
- lambda:DeleteLayerVersion
- lambda:GetLayerVersion
- lambda:ListLayerVersions
Resource:
- !Sub "arn:aws:lambda:us-east-1:${AWS::AccountId}:layer:afi-shared*"
- Sid: LambdaList
Effect: Allow
Action:
- lambda:ListFunctions
- lambda:ListLayers
- lambda:GetAccountSettings
Resource: "*"
- Sid: EventBridgeRules
Effect: Allow
Action:
- events:PutRule
- events:DeleteRule
- events:DescribeRule
- events:EnableRule
- events:DisableRule
- events:PutTargets
- events:RemoveTargets
- events:ListTargetsByRule
- events:TagResource
- events:UntagResource
- events:ListTagsForResource
Resource:
- !Sub "arn:aws:events:us-east-1:${AWS::AccountId}:rule/afi-*"
- Sid: CloudWatchLogs
Effect: Allow
Action:
- logs:CreateLogGroup
- logs:DeleteLogGroup
- logs:PutRetentionPolicy
- logs:DeleteRetentionPolicy
- logs:TagResource
- logs:UntagResource
- logs:ListTagsForResource
Resource:
- !Sub "arn:aws:logs:us-east-1:${AWS::AccountId}:log-group:/aws/lambda/afi-*"
# logs:DescribeLogGroups is a collection action — AWS authorises it
# against "*" only. Scoping it to a log-group ARN is a silent no-op
# grant (same pitfall documented on LambdaExecutionBoundary).
- Sid: CloudWatchLogsDescribe
Effect: Allow
Action:
- logs:DescribeLogGroups
Resource: "*"
# Artifact bucket for HCP plan/apply split: zip bytes travel in the
# plan via aws_s3_object content_base64 (local archive_file paths
# from the plan worker are not on the apply worker).
- Sid: LambdaArtifactsBucket
Effect: Allow
Action:
- s3:CreateBucket
- s3:DeleteBucket
- s3:GetBucketLocation
- s3:GetBucketPolicy
- s3:PutBucketPolicy
- s3:DeleteBucketPolicy
- s3:GetBucketVersioning
- s3:PutBucketVersioning
- s3:GetBucketPublicAccessBlock
- s3:PutBucketPublicAccessBlock
- s3:GetBucketTagging
- s3:PutBucketTagging
- s3:ListBucket
- s3:GetObject
- s3:PutObject
- s3:DeleteObject
Resource:
- !Sub "arn:aws:s3:::afi-backup-monitor-artifacts-${AWS::AccountId}"
- !Sub "arn:aws:s3:::afi-backup-monitor-artifacts-${AWS::AccountId}/*"