From 62f6a76e6cf941551655133a0c12485b8a4da6b3 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 27 Jul 2026 18:37:39 -0400 Subject: [PATCH 1/2] fix(iam): port DenySelfMutation self-protection into the prod/dev deploy substrate The seahaven-cfn-exec-iam-management policy in prod and dev carried only DenyBoundaryTampering + DenyBoundaryPolicyEdit: the mgmt Phase A review later showed a Deny-in-a-managed-policy control is self-detachable (iam:DetachRolePolicy on * is unconditioned), so without DenySelfMutation the exec role can detach the very policy carrying the Denies and reinstate the boundary-removal escalation. Latent today (no PassRole grants, zero SAM stacks in prod/dev) but must be closed before the first SAM workload migrates. Ports verbatim from .github/oidc-deploy-roles.yaml (mgmt, PRs #95/#98): - DenySelfMutation over role/github-cfn-execution-role + githubdeploy-* - DenyBoundaryPolicyEdit widened to policy/seahaven-* Statement set verified byte-identical to the mgmt copy (9 sids); provenance header updated - the two copies are reconciled. --- .../deploy-substrate.template.yaml | 67 ++++++++++++++++--- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/lib/deploy-substrate/deploy-substrate.template.yaml b/lib/deploy-substrate/deploy-substrate.template.yaml index df7cdc8..29f48d2 100644 --- a/lib/deploy-substrate/deploy-substrate.template.yaml +++ b/lib/deploy-substrate/deploy-substrate.template.yaml @@ -32,16 +32,14 @@ Description: >- # set before and after the move (identical), since identity policies are # unioned and an explicit Deny still wins. NOTE for the mgmt remediation: # mgmt needs this same restructure before its Deny statements can be added, -# - SECURITY FIX, deliberate divergence: iam:DeleteRolePermissionsBoundary +# - SECURITY FIX (now in BOTH copies): iam:DeleteRolePermissionsBoundary # removed from Sid IAMPutPermissionsBoundary and explicit Deny statements -# (DenyBoundaryTampering / DenyBoundaryPolicyEdit) added. The mgmt copy -# still carries the hole — verified live 2026-07-27 via -# simulate-principal-policy on the deployed mgmt role -# (iam:DeleteRolePermissionsBoundary = ALLOWED). New accounts must not be -# born with it. Remediating mgmt means changing a role that is actively -# executing production deploys, so it is tracked as a separate change -# with its own review gates rather than folded in here. Reconcile the two -# copies when that lands. +# (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' statement sets are reconciled as of that date. If a substrate +# statement changes again, change BOTH files in the same piece of work. # # This template is deployed via lib/deploy-substrate-stack.ts # (cloudformation-include) as stack seahaven-deploy-substrate, once per @@ -880,6 +878,12 @@ Resources: - !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: @@ -888,7 +892,50 @@ Resources: - iam:DeletePolicyVersion - iam:DeletePolicy Resource: - - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-*" + + # Self-protection. Without this the whole control is one API call + # from being undone: IAMRoleReadAndDelete below grants + # iam:DetachRolePolicy on Resource "*" with no condition, so this + # role could detach the very policy carrying these Denies from + # itself and reinstate the escalation. Verified live 2026-07-27: + # simulate-principal-policy returned "allowed" for DetachRolePolicy, + # DeleteRolePolicy and DeleteRole against this role's own ARN and + # against githubdeploy-* roles. + # + # Also closes a denial-of-service and a self-elevation precondition: + # iam:PutRolePermissionsBoundary is condition-pinned to the Lambda + # boundary ARN but NOT scoped by target, so this role could apply + # that runtime boundary to itself or to a githubdeploy-* role — + # bricking the pipelines, unrecoverable without an admin because + # removing a boundary is denied above, and making the otherwise-inert + # AttachRolePolicy/PutRolePolicy self-elevation conditions start + # matching. + # + # Costs nothing operationally: this role is only ever passed to + # CloudFormation for SAM application stacks. The substrate's own + # roles are managed by THIS stack (deployed through the CDK + # bootstrap execution role), and per-repo githubdeploy-* roles are + # provisioned at onboarding time outside any stack this role + # executes — so CloudFormation never exercises these actions + # against them as this role. SAM-generated roles are named + # -Role- and are unaffected. + - Sid: DenySelfMutation + Effect: Deny + Action: + - iam:AttachRolePolicy + - iam:DeleteRole + - iam:DeleteRolePolicy + - iam:DeleteRolePermissionsBoundary + - iam:DetachRolePolicy + - iam:PutRolePolicy + - iam:PutRolePermissionsBoundary + - iam:UpdateAssumeRolePolicy + - iam:UpdateRole + - iam:UpdateRoleDescription + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:role/github-cfn-execution-role" + - !Sub "arn:aws:iam::${AWS::AccountId}:role/githubdeploy-*" # Read / tag / delete role and policy — no boundary condition needed - Sid: IAMRoleReadAndDelete From 61a94da4fc7152f22e683f035b5f161c236c37c8 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 27 Jul 2026 18:55:10 -0400 Subject: [PATCH 2/2] fix(iam): reconcile the remaining substrate divergences from the mgmt copy Review of the DenySelfMutation port found the header's 'reconciled' claim was not yet true: mgmt Phase A also added the CloudWatch Logs metric-filter actions (afterhours-shift-manager creates an AWS::Logs::MetricFilter through this role), and without them a migrating SAM stack fails mid-deploy with AccessDenied. Ports those three actions and corrects two stale header notes. Every IAM statement in the three shared resources is now byte-identical across both files, verified programmatically; the only delta left is the DependsOn ordering line. --- .../deploy-substrate.template.yaml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/deploy-substrate/deploy-substrate.template.yaml b/lib/deploy-substrate/deploy-substrate.template.yaml index 29f48d2..ce16245 100644 --- a/lib/deploy-substrate/deploy-substrate.template.yaml +++ b/lib/deploy-substrate/deploy-substrate.template.yaml @@ -30,16 +30,21 @@ Description: >- # 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. NOTE for the mgmt remediation: -# mgmt needs this same restructure before its Deny statements can be added, +# 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' statement sets are reconciled as of that date. If a substrate -# statement changes again, change BOTH files in the same piece of work. +# copies' statement sets are reconciled as of that date — every IAM +# statement in LambdaExecutionBoundary, SamCfnIamManagementPolicy and +# SamCfnExecutionRole is byte-identical across the two files; the only +# remaining delta is the DependsOn line above, which is ordering, not +# permission. If a substrate statement changes again, change BOTH files +# in the same piece of work. # # This template is deployed via lib/deploy-substrate-stack.ts # (cloudformation-include) as stack seahaven-deploy-substrate, once per @@ -525,6 +530,12 @@ Resources: - 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) ───────────