diff --git a/lib/deploy-substrate/deploy-substrate.template.yaml b/lib/deploy-substrate/deploy-substrate.template.yaml index aa91a40..8ba5426 100644 --- a/lib/deploy-substrate/deploy-substrate.template.yaml +++ b/lib/deploy-substrate/deploy-substrate.template.yaml @@ -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 # diff --git a/lib/terraform-substrate/terraform-substrate.template.yaml b/lib/terraform-substrate/terraform-substrate.template.yaml index 4875cd7..ff90bf3 100644 --- a/lib/terraform-substrate/terraform-substrate.template.yaml +++ b/lib/terraform-substrate/terraform-substrate.template.yaml @@ -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}/*"