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"