From 2cfc122269258ed557ca109bb0e02f1336e5e021 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 27 Jul 2026 16:43:15 -0400 Subject: [PATCH] fix(deploy-substrate): move boundary-gated IAM policy off the role's inline budget The first deploy of seahaven-deploy-substrate failed in both prod and dev with ServiceLimitExceeded: 'Maximum policy size of 10240 bytes exceeded for role github-cfn-execution-role'. The role's inline policies already sat ~94 bytes under IAM's hard 10,240-byte per-role limit, so the two Deny statements added to close the boundary-removal escalation did not fit (10,656 total). Moves the whole boundary-gated IAM block (6 Allow + 2 Deny statements) into an attached managed policy, which carries its own separate 6,144-byte budget. Inline drops to 8,285 with ~1.9 KB of headroom; the managed policy sits at 2,371. Effective permissions are unchanged: the union of role statements (inline + attached) is byte-identical as a sorted set before and after the move (27 statements both sides), identity policies are unioned, and an explicit Deny still wins. Boundary and trust policy untouched. Both failed stacks rolled back cleanly with zero orphaned resources and were deleted before this retry. --- lib/deploy-substrate-stack.ts | 5 +- .../deploy-substrate.template.yaml | 297 ++++++++++-------- 2 files changed, 170 insertions(+), 132 deletions(-) diff --git a/lib/deploy-substrate-stack.ts b/lib/deploy-substrate-stack.ts index e8a3530..814d9a9 100644 --- a/lib/deploy-substrate-stack.ts +++ b/lib/deploy-substrate-stack.ts @@ -21,7 +21,10 @@ export interface DeploySubstrateStackProps extends cdk.StackProps { * - `seahaven-lambda-execution-boundary` permissions boundary (the ceiling * applied to every SAM-generated Lambda execution role), * - `github-cfn-execution-role` (the shared CloudFormation execution role - * that cd-sam callers pass as cfn-role-arn). + * that cd-sam callers pass as cfn-role-arn), plus + * `seahaven-cfn-exec-iam-management`, the attached managed policy holding + * that role's boundary-gated IAM statements (separated from the inline + * policies to stay under IAM's 10,240-byte per-role inline limit). * * Deliberately NOT here: per-repo githubdeploy-* roles. Those are provisioned * per repo at migration/onboarding time (deploy-role-first playbook) so an diff --git a/lib/deploy-substrate/deploy-substrate.template.yaml b/lib/deploy-substrate/deploy-substrate.template.yaml index 2ce4f0a..df7cdc8 100644 --- a/lib/deploy-substrate/deploy-substrate.template.yaml +++ b/lib/deploy-substrate/deploy-substrate.template.yaml @@ -23,6 +23,15 @@ Description: >- # 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. NOTE for the mgmt remediation: +# mgmt needs this same restructure before its Deny statements can be added, # - SECURITY FIX, deliberate divergence: iam:DeleteRolePermissionsBoundary # removed from Sid IAMPutPermissionsBoundary and explicit Deny statements # (DenyBoundaryTampering / DenyBoundaryPolicyEdit) added. The mgmt copy @@ -319,6 +328,8 @@ Resources: DependsOn: LambdaExecutionBoundary Properties: RoleName: github-cfn-execution-role + ManagedPolicyArns: + - !Ref SamCfnIamManagementPolicy AssumeRolePolicyDocument: Version: "2012-10-17" Statement: @@ -751,142 +762,166 @@ Resources: - wafv2:ListResourcesForWebACL Resource: "*" - # ── IAM role lifecycle — BOUNDARY-GATED ────────────────────────── - # This is the PRIMARY escalation control for INFRA-97. - # - # iam:CreateRole / iam:AttachRolePolicy / iam:PutRolePolicy are - # conditioned on iam:PermissionsBoundary StringEquals the - # seahaven-lambda-execution-boundary ARN. That condition means - # any role this execution role creates must have the boundary - # applied, so it can never exceed what the boundary allows - # (which is scoped to the services the five stacks actually use). - # - # 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. - - PolicyName: iam-role-management-boundary-gated - 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": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + # --------------------------------------------------------------------------- + # 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 the + # seahaven-lambda-execution-boundary ARN. That condition means + # any role this execution role creates must have the boundary + # applied, so it can never exceed what the boundary allows + # (which is scoped to the services the five stacks actually use). + # + # 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": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" - # 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": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + # 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": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" - # 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": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + # 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": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" - # 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": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + # 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": !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" - # 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/*" + # 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/*" - - Sid: DenyBoundaryPolicyEdit - Effect: Deny - Action: - - iam:CreatePolicyVersion - - iam:SetDefaultPolicyVersion - - iam:DeletePolicyVersion - - iam:DeletePolicy - Resource: - - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" + - Sid: DenyBoundaryPolicyEdit + Effect: Deny + Action: + - iam:CreatePolicyVersion + - iam:SetDefaultPolicyVersion + - iam:DeletePolicyVersion + - iam:DeletePolicy + Resource: + - !Sub "arn:aws:iam::${AWS::AccountId}:policy/seahaven-lambda-execution-boundary" - # 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: "*" + # 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" + # 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"