Skip to content

fix(iam): scope DenyNoTag to security groups so apps can create their task SG - #177

Merged
Alexanderamiri merged 1 commit into
mainfrom
fix/boundary-sg-request-tag
Oct 30, 2026
Merged

Alexanderamiri merged 1 commit into
mainfrom
fix/boundary-sg-request-tag

Conversation

@Alexanderamiri

Copy link
Copy Markdown
Member

Why

After #156, every app deploy creates its own task security group ({team}-{app}-tasks). The first deploy, frosting run 41034182549, failed:

not authorized to perform: ec2:CreateSecurityGroup on resource: arn:aws:ec2:…:vpc/vpc-0cd3502de2527a310
with an explicit deny in a permissions boundary: javabin-developer-boundary

The decoded message names DenyNoTag. That statement denies ec2:CreateSecurityGroup on Resource = "*" with StringNotEqualsIfExists aws:RequestTag/team. AWS also authorizes the action against the VPC, and request tags are never in that context, so IfExists makes the deny always match. Teams never created SGs before #156, so nobody noticed. Every app's next deploy fails until this is fixed.

What

DenyNoTag is scoped to arn:aws:ec2:*:*:security-group/*. The team tag is still required on the security group itself.

How verified

  • IAM policy simulator (simulate-custom-policy with the boundary as the permissions boundary):
Boundary request tag VPC security group
live team=cupcake allowed allowed
live none denied denied
new team=cupcake allowed allowed
new none allowed denied

The simulator puts request tags on both resources. In real calls the VPC never gets them, which is the "none" row for the VPC.

  • Boundary size: 6,067 → 6,098 bytes (limit 6,144).
  • Unit tests pass and terraform validate succeeds.

Rollout

After apply, re-run the failed frosting deploy. cupcake, jzwatch and testteam pick it up on their next deploy.

🤖 Generated with Claude Code

… task SG

#156 gives every app its own security group, created by the team CI role.
The developer boundary's DenyNoTag denied ec2:CreateSecurityGroup on
Resource "*" unless aws:RequestTag/team matches. AWS also authorizes that
action against the VPC, where request tags are never in the context, so the
IfExists deny always fired (frosting deploy 41034182549: explicit deny,
DenyNoTag, on vpc/...). Teams never created SGs before, so it went unseen.

Scope the statement to security-group resources. The tag is still required
on the SG itself (IAM simulator: SG without team tag is still denied).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alexanderamiri
Alexanderamiri requested a review from a team as a code owner October 30, 2026 09:37
@github-actions

Copy link
Copy Markdown

Terraform Plan

🚧 Changes detected — Plan: 0 to add, 5 to change, 0 to destroy.

Plan output
Acquiring state lock. This may take a few moments...
aws_ssm_parameter.atlassian_api_key: Refreshing state... [id=/javabin/platform/atlassian-api-key]
aws_ssm_parameter.atlassian_org_id: Refreshing state... [id=/javabin/platform/atlassian-org-id]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # module.iam.aws_iam_policy.developer_boundary will be updated in-place
  ~ resource "aws_iam_policy" "developer_boundary" {
        id               = "arn:aws:iam::553637109631:policy/javabin-developer-boundary"
        name             = "javabin-developer-boundary"
      ~ policy           = jsonencode(
          ~ {
              ~ Statement = [
                    # (15 unchanged elements hidden)
                    {
                        Action   = [
                            "iam:PutRolePolicy",
                            "iam:DeleteRolePolicy",
                            "iam:AttachRolePolicy",
                            "iam:DetachRolePolicy",
                            "iam:DeleteRole",
                            "iam:UpdateRole",
                            "iam:UpdateAssumeRolePolicy",
                        ]
                        Effect   = "Deny"
                        Resource = "arn:aws:iam::553637109631:role/javabin-ci-*"
                        Sid      = "DenyModCI"
                    },
                  ~ {
                      ~ Resource  = "*" -> "arn:aws:ec2:*:*:security-group/*"
                        # (4 unchanged attributes hidden)
                    },
                    {
                        Action      = [
                            "s3:CreateBucket",
                            "dynamodb:CreateTable",
                            "sqs:CreateQueue",
                            "sns:CreateTopic",
                            "rds:CreateDBInstance",
                            "rds:CreateDBSubnetGroup",
                            "ecs:CreateService",
                            "ecs:RegisterTaskDefinition",
                            "ecr:CreateRepository",
                            "logs:CreateLogGroup",
                            "ssm:PutParameter",
                            "lambda:CreateFunction",
                            "events:PutRule",
                            "states:CreateStateMachine",
                        ]
                        Effect      = "Deny"
                        NotResource = [
                            "arn:aws:s3:::${aws:PrincipalTag/team}-*",
                            "arn:aws:dynamodb:*:*:table/${aws:PrincipalTag/team}-*",
                            "arn:aws:sqs:*:*:${aws:PrincipalTag/team}-*",
                            "arn:aws:sns:*:*:${aws:PrincipalTag/team}-*",
                            "arn:aws:rds:*:*:db:${aws:PrincipalTag/team}-*",
                            "arn:aws:rds:*:*:subgrp:${aws:PrincipalTag/team}-*",
                            "arn:aws:ecs:*:*:service/*/${aws:PrincipalTag/team}-*",
                            "arn:aws:ecs:*:*:task-definition/${aws:PrincipalTag/team}-*:*",
                            "arn:aws:ecr:*:*:repository/${aws:PrincipalTag/team}-*",
                            "arn:aws:logs:*:*:log-group:/ecs/${aws:PrincipalTag/team}/*",
                            "arn:aws:ssm:*:*:parameter/javabin/apps/${aws:PrincipalTag/team}/*",
                            "arn:aws:lambda:*:*:function:${aws:PrincipalTag/team}-*",
                            "arn:aws:events:*:*:rule/${aws:PrincipalTag/team}-*",
                            "arn:aws:states:*:*:stateMachine:${aws:PrincipalTag/team}-*",
                        ]
                        Sid         = "DenyBadName"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        tags             = {
            "Name" = "javabin-developer-boundary"
        }
        # (6 unchanged attributes hidden)
    }

  # module.iam.aws_iam_policy.team_guardrails["cupcake"] will be updated in-place
  ~ resource "aws_iam_policy" "team_guardrails" {
        id               = "arn:aws:iam::553637109631:policy/javabin-team-cupcake-guardrails"
        name             = "javabin-team-cupcake-guardrails"
      ~ policy           = jsonencode(
          ~ {
              ~ Statement = [
                    # (15 unchanged elements hidden)
                    {
                        Action   = [
                            "iam:PutRolePolicy",
                            "iam:DeleteRolePolicy",
                            "iam:AttachRolePolicy",
                            "iam:DetachRolePolicy",
                            "iam:DeleteRole",
                            "iam:UpdateRole",
                            "iam:UpdateAssumeRolePolicy",
                        ]
                        Effect   = "Deny"
                        Resource = "arn:aws:iam::553637109631:role/javabin-ci-*"
                        Sid      = "DenyModCI"
                    },
                  ~ {
                      ~ Resource  = "*" -> "arn:aws:ec2:*:*:security-group/*"
                        # (4 unchanged attributes hidden)
                    },
                    {
                        Action      = [
                            "s3:CreateBucket",
                            "dynamodb:CreateTable",
                            "sqs:CreateQueue",
                            "sns:CreateTopic",
                            "rds:CreateDBInstance",
                            "rds:CreateDBSubnetGroup",
                            "ecs:CreateService",
                            "ecs:RegisterTaskDefinition",
                            "ecr:CreateRepository",
                            "logs:CreateLogGroup",
                            "ssm:PutParameter",
                            "lambda:CreateFunction",
                            "events:PutRule",
                            "states:CreateStateMachine",
                        ]
                        Effect      = "Deny"
                        NotResource = [
                            "arn:aws:s3:::cupcake-*",
                            "arn:aws:dynamodb:*:*:table/cupcake-*",
                            "arn:aws:sqs:*:*:cupcake-*",
                            "arn:aws:sns:*:*:cupcake-*",
                            "arn:aws:rds:*:*:db:cupcake-*",
                            "arn:aws:rds:*:*:subgrp:cupcake-*",
                            "arn:aws:ecs:*:*:service/*/cupcake-*",
                            "arn:aws:ecs:*:*:task-definition/cupcake-*:*",
                            "arn:aws:ecr:*:*:repository/cupcake-*",
                            "arn:aws:logs:*:*:log-group:/ecs/cupcake/*",
                            "arn:aws:ssm:*:*:parameter/javabin/apps/cupcake/*",
                            "arn:aws:lambda:*:*:function:cupcake-*",
                            "arn:aws:events:*:*:rule/cupcake-*",
                            "arn:aws:states:*:*:stateMachine:cupcake-*",
                        ]
                        Sid         = "DenyBadName"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        tags             = {
            "Name"     = "javabin-team-cupcake-guardrails"
            "for-team" = "cupcake"
            "team"     = "shared"
        }
        # (6 unchanged attributes hidden)
    }

  # module.iam.aws_iam_policy.team_guardrails["javazoneweb"] will be updated in-place
  ~ resource "aws_iam_policy" "team_guardrails" {
        id               = "arn:aws:iam::553637109631:policy/javabin-team-javazoneweb-guardrails"
        name             = "javabin-team-javazoneweb-guardrails"
      ~ policy           = jsonencode(
          ~ {
              ~ Statement = [
                    # (15 unchanged elements hidden)
                    {
                        Action   = [
                            "iam:PutRolePolicy",
                            "iam:DeleteRolePolicy",
                            "iam:AttachRolePolicy",
                            "iam:DetachRolePolicy",
                            "iam:DeleteRole",
                            "iam:UpdateRole",
                            "iam:UpdateAssumeRolePolicy",
                        ]
                        Effect   = "Deny"
                        Resource = "arn:aws:iam::553637109631:role/javabin-ci-*"
                        Sid      = "DenyModCI"
                    },
                  ~ {
                      ~ Resource  = "*" -> "arn:aws:ec2:*:*:security-group/*"
                        # (4 unchanged attributes hidden)
                    },
                    {
                        Action      = [
                            "s3:CreateBucket",
                            "dynamodb:CreateTable",
                            "sqs:CreateQueue",
                            "sns:CreateTopic",
                            "rds:CreateDBInstance",
                            "rds:CreateDBSubnetGroup",
                            "ecs:CreateService",
                            "ecs:RegisterTaskDefinition",
                            "ecr:CreateRepository",
                            "logs:CreateLogGroup",
                            "ssm:PutParameter",
                            "lambda:CreateFunction",
                            "events:PutRule",
                            "states:CreateStateMachine",
                        ]
                        Effect      = "Deny"
                        NotResource = [
                            "arn:aws:s3:::javazoneweb-*",
                            "arn:aws:dynamodb:*:*:table/javazoneweb-*",
                            "arn:aws:sqs:*:*:javazoneweb-*",
                            "arn:aws:sns:*:*:javazoneweb-*",
                            "arn:aws:rds:*:*:db:javazoneweb-*",
                            "arn:aws:rds:*:*:subgrp:javazoneweb-*",
                            "arn:aws:ecs:*:*:service/*/javazoneweb-*",
                            "arn:aws:ecs:*:*:task-definition/javazoneweb-*:*",
                            "arn:aws:ecr:*:*:repository/javazoneweb-*",
                            "arn:aws:logs:*:*:log-group:/ecs/javazoneweb/*",
                            "arn:aws:ssm:*:*:parameter/javabin/apps/javazoneweb/*",
                            "arn:aws:lambda:*:*:function:javazoneweb-*",
                            "arn:aws:events:*:*:rule/javazoneweb-*",
                            "arn:aws:states:*:*:stateMachine:javazoneweb-*",
                        ]
                        Sid         = "DenyBadName"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        tags             = {
            "Name"     = "javabin-team-javazoneweb-guardrails"
            "for-team" = "javazoneweb"
            "team"     = "shared"
        }
        # (6 unchanged attributes hidden)
    }

  # module.iam.aws_iam_policy.team_guardrails["jzwatch"] will be updated in-place
  ~ resource "aws_iam_policy" "team_guardrails" {
        id               = "arn:aws:iam::553637109631:policy/javabin-team-jzwatch-guardrails"
        name             = "javabin-team-jzwatch-guardrails"
      ~ policy           = jsonencode(
          ~ {
              ~ Statement = [
                    # (15 unchanged elements hidden)
                    {
                        Action   = [
                            "iam:PutRolePolicy",
                            "iam:DeleteRolePolicy",
                            "iam:AttachRolePolicy",
                            "iam:DetachRolePolicy",
                            "iam:DeleteRole",
                            "iam:UpdateRole",
                            "iam:UpdateAssumeRolePolicy",
                        ]
                        Effect   = "Deny"
                        Resource = "arn:aws:iam::553637109631:role/javabin-ci-*"
                        Sid      = "DenyModCI"
                    },
                  ~ {
                      ~ Resource  = "*" -> "arn:aws:ec2:*:*:security-group/*"
                        # (4 unchanged attributes hidden)
                    },
                    {
                        Action      = [
                            "s3:CreateBucket",
                            "dynamodb:CreateTable",
                            "sqs:CreateQueue",
                            "sns:CreateTopic",
                            "rds:CreateDBInstance",
                            "rds:CreateDBSubnetGroup",
                            "ecs:CreateService",
                            "ecs:RegisterTaskDefinition",
                            "ecr:CreateRepository",
                            "logs:CreateLogGroup",
                            "ssm:PutParameter",
                            "lambda:CreateFunction",
                            "events:PutRule",
                            "states:CreateStateMachine",
                        ]
                        Effect      = "Deny"
                        NotResource = [
                            "arn:aws:s3:::jzwatch-*",
                            "arn:aws:dynamodb:*:*:table/jzwatch-*",
                            "arn:aws:sqs:*:*:jzwatch-*",
                            "arn:aws:sns:*:*:jzwatch-*",
                            "arn:aws:rds:*:*:db:jzwatch-*",
                            "arn:aws:rds:*:*:subgrp:jzwatch-*",
                            "arn:aws:ecs:*:*:service/*/jzwatch-*",
                            "arn:aws:ecs:*:*:task-definition/jzwatch-*:*",
                            "arn:aws:ecr:*:*:repository/jzwatch-*",
                            "arn:aws:logs:*:*:log-group:/ecs/jzwatch/*",
                            "arn:aws:ssm:*:*:parameter/javabin/apps/jzwatch/*",
                            "arn:aws:lambda:*:*:function:jzwatch-*",
                            "arn:aws:events:*:*:rule/jzwatch-*",
                            "arn:aws:states:*:*:stateMachine:jzwatch-*",
                        ]
                        Sid         = "DenyBadName"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        tags             = {
            "Name"     = "javabin-team-jzwatch-guardrails"
            "for-team" = "jzwatch"
            "team"     = "shared"
        }
        # (6 unchanged attributes hidden)
    }

  # module.iam.aws_iam_policy.team_guardrails["testteam"] will be updated in-place
  ~ resource "aws_iam_policy" "team_guardrails" {
        id               = "arn:aws:iam::553637109631:policy/javabin-team-testteam-guardrails"
        name             = "javabin-team-testteam-guardrails"
      ~ policy           = jsonencode(
          ~ {
              ~ Statement = [
                    # (15 unchanged elements hidden)
                    {
                        Action   = [
                            "iam:PutRolePolicy",
                            "iam:DeleteRolePolicy",
                            "iam:AttachRolePolicy",
                            "iam:DetachRolePolicy",
                            "iam:DeleteRole",
                            "iam:UpdateRole",
                            "iam:UpdateAssumeRolePolicy",
                        ]
                        Effect   = "Deny"
                        Resource = "arn:aws:iam::553637109631:role/javabin-ci-*"
                        Sid      = "DenyModCI"
                    },
                  ~ {
                      ~ Resource  = "*" -> "arn:aws:ec2:*:*:security-group/*"
                        # (4 unchanged attributes hidden)
                    },
                    {
                        Action      = [
                            "s3:CreateBucket",
                            "dynamodb:CreateTable",
                            "sqs:CreateQueue",
                            "sns:CreateTopic",
                            "rds:CreateDBInstance",
                            "rds:CreateDBSubnetGroup",
                            "ecs:CreateService",
                            "ecs:RegisterTaskDefinition",
                            "ecr:CreateRepository",
                            "logs:CreateLogGroup",
                            "ssm:PutParameter",
                            "lambda:CreateFunction",
                            "events:PutRule",
                            "states:CreateStateMachine",
                        ]
                        Effect      = "Deny"
                        NotResource = [
                            "arn:aws:s3:::testteam-*",
                            "arn:aws:dynamodb:*:*:table/testteam-*",
                            "arn:aws:sqs:*:*:testteam-*",
                            "arn:aws:sns:*:*:testteam-*",
                            "arn:aws:rds:*:*:db:testteam-*",
                            "arn:aws:rds:*:*:subgrp:testteam-*",
                            "arn:aws:ecs:*:*:service/*/testteam-*",
                            "arn:aws:ecs:*:*:task-definition/testteam-*:*",
                            "arn:aws:ecr:*:*:repository/testteam-*",
                            "arn:aws:logs:*:*:log-group:/ecs/testteam/*",
                            "arn:aws:ssm:*:*:parameter/javabin/apps/testteam/*",
                            "arn:aws:lambda:*:*:function:testteam-*",
                            "arn:aws:events:*:*:rule/testteam-*",
                            "arn:aws:states:*:*:stateMachine:testteam-*",
                        ]
                        Sid         = "DenyBadName"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        tags             = {
            "Name"     = "javabin-team-testteam-guardrails"
            "for-team" = "testteam"
            "team"     = "shared"
        }
        # (6 unchanged attributes hidden)
    }

Plan: 0 to add, 5 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

LLM Review

Risk: 🟡 MEDIUM

Plan updates 5 IAM managed policies in place (0 add, 0 destroy): the developer boundary and four team guardrail policies, all changing the EC2 security-group Deny statement's resource scope and the DenyBadName naming statement.

  • 🔒 [security] Security-group Deny statement changes Resource from "" to "arn:aws:ec2:::security-group/". The statement's Action list is hidden, so it is unclear whether it is a Deny or Allow-related change; the Deny narrowing could loosen restrictions on other EC2 resources. Confirm the Effect and Action of this statement before applying.
  • 🔒 [security] The DenyBadName statement shows an unchanged Effect of Deny with a NotResource list covering create actions (S3, DynamoDB, SQS, SNS, RDS, ECS, ECR, Lambda, Step Functions, SSM, etc.). These deny lists are unchanged in this diff, so no new permissions are granted by the visible changes. Note the statement is a Deny, so it restricts rather than grants.
  • 🔒 [security] The DenyModCI statement (deny IAM role modification on javabin-ci-* roles) is unchanged. No Allow grants or permission boundary removal is visible, so no HIGH-risk condition is present.
  • 💥 [destruction] 0 resources destroyed and 0 created. All five changes are in-place policy document updates; no databases, networks, or security groups are removed or replaced.
  • 💰 [cost] No new billable resources (no NAT gateways, RDS instances, or load balancers). Cost impact is nil.
  • ✅ [routine] Tag on the cupcake guardrail policy includes team=shared and for-team=cupcake, consistent with the other guardrail policies. Tags are unchanged in substance.

@Alexanderamiri
Alexanderamiri merged commit 15c6d34 into main Oct 30, 2026
4 checks passed
@Alexanderamiri
Alexanderamiri deleted the fix/boundary-sg-request-tag branch October 30, 2026 09:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant