Skip to content

Restructure team IAM to tag-based isolation model#93

Merged
Alexanderamiri merged 1 commit into
mainfrom
fix/tag-based-team-isolation
Mar 17, 2026
Merged

Restructure team IAM to tag-based isolation model#93
Alexanderamiri merged 1 commit into
mainfrom
fix/tag-based-team-isolation

Conversation

@Alexanderamiri
Copy link
Copy Markdown
Member

Summary

Replace hardcoded service action lists with pure tag-based ABAC. Three statements handle all isolation:

Statement What it does
AllowAll Unrestricted allow — deny policies are the gates
DenyCrossTeamAccess Blocks any action where aws:ResourceTag/team exists and isn't the team's or "shared"
DenyMutateSharedInfra Blocks Delete*/Modify* on team=shared resources

Tag model

  • team=shared → any team can use (read + create children), but not delete/modify
  • team=teamX → only that team can access
  • team=platform → no team can access
  • No tag → deny doesn't fire (new resources during create)

Shared resources tagged

VPC, subnets, ECS tasks SG, ALB, HTTPS listener, ECS cluster, SNS topics, ECS execution role.

Cross-team isolation

Teams cannot touch each other's resources — the deny fires when aws:ResourceTag/team doesn't match. Teams also cannot delete/modify shared infra (ALB, cluster, zone) — only create children (listener rules, ECS services, DNS records) which get the team's own tag.

Test plan

  • Merge and wait for apply (tag changes are safe — just adds/changes tags)
  • Re-run test app CI — should pass plan and apply end-to-end

Replace hardcoded service action lists with pure tag-based ABAC:

- AllowAll: unrestricted allow (deny policies are the real gates)
- DenyCrossTeamAccess: blocks any action on resources where
  aws:ResourceTag/team exists and isn't the team's or "shared".
  Creates pass through (no tags on new resources).
- DenyMutateSharedInfra: protects shared resources from
  deletion/modification while allowing child creation (listener
  rules, ECS services get team's tag, not parent's "shared" tag).

Tag shared platform resources with team=shared:
- VPC, subnets, ECS tasks SG (networking)
- ALB, HTTPS listener (ingress)
- ECS cluster (compute)
- SNS alert topics (monitoring)
- ECS execution role (iam)

Resources tagged team=platform remain inaccessible to teams.
New shared resources just need team=shared to be automatically usable.
@Alexanderamiri Alexanderamiri requested a review from a team as a code owner March 17, 2026 23:50
@Alexanderamiri Alexanderamiri enabled auto-merge (squash) March 17, 2026 23:51
@github-actions
Copy link
Copy Markdown

Terraform Plan

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

Plan output
Acquiring state lock. This may take a few moments...

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.compute.aws_ecs_cluster.main will be updated in-place
  ~ resource "aws_ecs_cluster" "main" {
        id       = "arn:aws:ecs:eu-central-1:553637109631:cluster/javabin-platform"
        name     = "javabin-platform"
      ~ tags     = {
            "Name" = "javabin-platform"
          + "team" = "shared"
        }
      ~ tags_all = {
          ~ "team"        = "javabin" -> "shared"
            # (5 unchanged elements hidden)
        }
        # (1 unchanged attribute hidden)

        # (1 unchanged block hidden)
    }

  # module.iam.aws_iam_role.ecs_execution will be updated in-place
  ~ resource "aws_iam_role" "ecs_execution" {
        id                    = "javabin-ecs-execution"
        name                  = "javabin-ecs-execution"
      ~ tags                  = {
            "Name" = "javabin-ecs-execution"
          + "team" = "shared"
        }
      ~ tags_all              = {
          ~ "team"        = "javabin" -> "shared"
            # (5 unchanged elements hidden)
        }
        # (8 unchanged attributes hidden)

        # (1 unchanged block hidden)
    }

  # module.iam.aws_iam_role_policy.ci_team_allow["testteam"] will be updated in-place
  ~ resource "aws_iam_role_policy" "ci_team_allow" {
        id     = "javabin-ci-team-testteam:team-management"
        name   = "team-management"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      - Condition = {
                          - StringEqualsIfExists = {
                              - "aws:RequestTag/team"  = "testteam"
                              - "aws:ResourceTag/team" = "testteam"
                            }
                        }
                      ~ Sid       = "AllowWithTeamTagIsolation" -> "AllowAll"
                        # (3 unchanged attributes hidden)
                    },
                  + {
                      + Action      = "*"
                      + Condition   = {
                          + Null            = {
                              + "aws:ResourceTag/team" = "false"
                            }
                          + StringNotEquals = {
                              + "aws:ResourceTag/team" = [
                                  + "testteam",
                                  + "shared",
                                ]
                            }
                        }
                      + Effect      = "Deny"
                      + NotResource = [
                          + "arn:aws:s3:::javabin-terraform-state-553637109631",
                          + "arn:aws:s3:::javabin-terraform-state-553637109631/*",
                          + "arn:aws:dynamodb:eu-central-1:553637109631:table/javabin-terraform-app-locks",
                          + "arn:aws:s3:::javabin-ci-plan-artifacts-553637109631",
                          + "arn:aws:s3:::javabin-ci-plan-artifacts-553637109631/*",
                        ]
                      + Sid         = "DenyCrossTeamAccess"
                    },
                  + {
                      + Action    = [
                          + "ec2:Delete*",
                          + "ec2:Modify*",
                          + "elasticloadbalancing:Delete*",
                          + "elasticloadbalancing:Modify*",
                          + "ecs:DeleteCluster",
                          + "ecs:UpdateCluster",
                          + "ecs:UpdateClusterSettings",
                          + "iam:DeleteRole",
                          + "iam:UpdateRole",
                          + "iam:DeleteRolePolicy",
                          + "iam:PutRolePolicy",
                          + "iam:AttachRolePolicy",
                          + "iam:DetachRolePolicy",
                          + "route53:DeleteHostedZone",
                          + "sns:DeleteTopic",
                          + "sns:SetTopicAttributes",
                        ]
                      + Condition = {
                          + StringEquals = {
                              + "aws:ResourceTag/team" = "shared"
                            }
                        }
                      + Effect    = "Deny"
                      + Resource  = "*"
                      + Sid       = "DenyMutateSharedInfra"
                    },
                    {
                        Action   = [
                            "s3:GetObject",
                            "s3:PutObject",
                            "s3:DeleteObject",
                            "s3:ListBucket",
                        ]
                        Effect   = "Allow"
                        Resource = [
                            "arn:aws:s3:::javabin-terraform-state-553637109631",
                            "arn:aws:s3:::javabin-terraform-state-553637109631/apps/testteam/*",
                        ]
                        Sid      = "AllowTerraformBackend"
                    },
                    {
                        Action    = [
                            "dynamodb:GetItem",
                            "dynamodb:PutItem",
                            "dynamodb:DeleteItem",
                        ]
                        Condition = {
                            "ForAllValues:StringLike" = {
                                "dynamodb:LeadingKeys" = "javabin-terraform-state-553637109631/apps/testteam/*"
                            }
                        }
                        Effect    = "Allow"
                        Resource  = "arn:aws:dynamodb:eu-central-1:553637109631:table/javabin-terraform-app-locks"
                        Sid       = "AllowTerraformLocking"
                    },
                  - {
                      - Action   = [
                          - "ec2:Describe*",
                          - "elasticloadbalancing:Describe*",
                          - "ecs:DescribeClusters",
                          - "ecs:ListServices",
                          - "iam:GetRole",
                          - "iam:GetPolicy",
                          - "iam:ListAttachedRolePolicies",
                          - "sns:GetTopicAttributes",
                          - "sns:ListTagsForResource",
                          - "route53:ListHostedZones",
                          - "route53:GetHostedZone",
                          - "route53:ListResourceRecordSets",
                          - "acm:ListCertificates",
                          - "acm:DescribeCertificate",
                          - "logs:DescribeLogGroups",
                          - "logs:CreateLogGroup",
                          - "logs:PutRetentionPolicy",
                          - "ecr:GetAuthorizationToken",
                        ]
                      - Effect   = "Allow"
                      - Resource = "*"
                      - Sid      = "AllowPlatformDataSourceReads"
                    },
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

  # module.iam.aws_iam_role_policy.ci_team_deny["testteam"] will be updated in-place
  ~ resource "aws_iam_role_policy" "ci_team_deny" {
        id     = "javabin-ci-team-testteam:deny-platform-operations"
        name   = "deny-platform-operations"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                    # (3 unchanged elements hidden)
                    {
                        Action   = [
                            "iam:CreateUser",
                            "iam:CreateAccessKey",
                            "iam:CreateLoginProfile",
                        ]
                        Effect   = "Deny"
                        Resource = "*"
                        Sid      = "DenyDangerousIAM"
                    },
                  ~ {
                      ~ Action   = [
                            # (1 unchanged element hidden)
                            "sns:SetTopicAttributes",
                          - "sns:Subscribe",
                          - "sns:Unsubscribe",
                        ]
                        # (3 unchanged attributes hidden)
                    },
                    {
                        Action   = [
                            "s3:DeleteBucket",
                            "s3:PutBucketPolicy",
                            "s3:DeleteBucketPolicy",
                        ]
                        Effect   = "Deny"
                        Resource = [
                            "arn:aws:s3:::javabin-terraform-state-553637109631",
                            "arn:aws:s3:::javabin-ci-plan-artifacts-553637109631",
                        ]
                        Sid      = "DenyPlatformS3"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

  # module.ingress.aws_lb.main will be updated in-place
  ~ resource "aws_lb" "main" {
        id                                          = "arn:aws:elasticloadbalancing:eu-central-1:553637109631:loadbalancer/app/javabin-platform-alb/bec1dd43ab8341b9"
        name                                        = "javabin-platform-alb"
      ~ tags                                        = {
            "Name" = "javabin-platform-alb"
          + "team" = "shared"
        }
      ~ tags_all                                    = {
          ~ "team"        = "javabin" -> "shared"
            # (5 unchanged elements hidden)
        }
        # (23 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

  # module.ingress.aws_lb_listener.https will be updated in-place
  ~ resource "aws_lb_listener" "https" {
        id                                   = "arn:aws:elasticloadbalancing:eu-central-1:553637109631:listener/app/javabin-platform-alb/bec1dd43ab8341b9/500c9c2b4186bf45"
      ~ tags                                 = {
          + "team" = "shared"
        }
      ~ tags_all                             = {
          ~ "team"        = "javabin" -> "shared"
            # (4 unchanged elements hidden)
        }
        # (7 unchanged attributes hidden)

        # (2 unchanged blocks hidden)
    }

  # module.monitoring.aws_sns_topic.alerts will be updated in-place
  ~ resource "aws_sns_topic" "alerts" {
        id                                       = "arn:aws:sns:eu-central-1:553637109631:javabin-alerts"
        name                                     = "javabin-alerts"
      ~ tags                                     = {
            "Name" = "javabin-alerts"
          + "team" = "shared"
        }
      ~ tags_all                                 = {
          ~ "team"        = "javabin" -> "shared"
            # (5 unchanged elements hidden)
        }
        # (11 unchanged attributes hidden)
    }

  # module.monitoring.aws_sns_topic.security will be updated in-place
  ~ resource "aws_sns_topic" "security" {
        id                                       = "arn:aws:sns:eu-central-1:553637109631:javabin-security"
        name                                     = "javabin-security"
      ~ tags                                     = {
            "Name" = "javabin-security"
          + "team" = "shared"
        }
      ~ tags_all                                 = {
          ~ "team"        = "javabin" -> "shared"
            # (5 unchanged elements hidden)
        }
        # (11 unchanged attributes hidden)
    }

  # module.networking.aws_security_group.ecs_tasks will be updated in-place
  ~ resource "aws_security_group" "ecs_tasks" {
        id                     = "sg-0df9a0a3a22548c62"
        name                   = "javabin-ecs-tasks-sg"
      ~ tags                   = {
            "Name" = "javabin-ecs-tasks-sg"
          + "team" = "shared"
        }
      ~ tags_all               = {
          ~ "team"        = "javabin" -> "shared"
            # (5 unchanged elements hidden)
        }
        # (7 unchanged attributes hidden)
    }

  # module.networking.aws_subnet.private_a will be updated in-place
  ~ resource "aws_subnet" "private_a" {
        id                                             = "subnet-0329ad20dc025c693"
      ~ tags                                           = {
            "Name" = "javabin-private-a"
          + "team" = "shared"
            "tier" = "private"
        }
      ~ tags_all                                       = {
          ~ "team"        = "javabin" -> "shared"
            # (6 unchanged elements hidden)
        }
        # (15 unchanged attributes hidden)
    }

  # module.networking.aws_subnet.private_b will be updated in-place
  ~ resource "aws_subnet" "private_b" {
        id                                             = "subnet-09ee21336f809f3c9"
      ~ tags                                           = {
            "Name" = "javabin-private-b"
          + "team" = "shared"
            "tier" = "private"
        }
      ~ tags_all                                       = {
          ~ "team"        = "javabin" -> "shared"
            # (6 unchanged elements hidden)
        }
        # (15 unchanged attributes hidden)
    }

  # module.networking.aws_subnet.public_a will be updated in-place
  ~ resource "aws_subnet" "public_a" {
        id                                             = "subnet-0f6bfec917146b856"
      ~ tags                                           = {
            "Name" = "javabin-public-a"
          + "team" = "shared"
            "tier" = "public"
        }
      ~ tags_all                                       = {
          ~ "team"        = "javabin" -> "shared"
            # (6 unchanged elements hidden)
        }
        # (15 unchanged attributes hidden)
    }

  # module.networking.aws_subnet.public_b will be updated in-place
  ~ resource "aws_subnet" "public_b" {
        id                                             = "subnet-0eb818326ee94a266"
      ~ tags                                           = {
            "Name" = "javabin-public-b"
          + "team" = "shared"
            "tier" = "public"
        }
      ~ tags_all                                       = {
          ~ "team"        = "javabin" -> "shared"
            # (6 unchanged elements hidden)
        }
        # (15 unchanged attributes hidden)
    }

  # module.networking.aws_vpc.main will be updated in-place
  ~ resource "aws_vpc" "main" {
        id                                   = "vpc-0cd3502de2527a310"
      ~ tags                                 = {
            "Name" = "javabin-vpc"
          + "team" = "shared"
        }
      ~ tags_all                             = {
          ~ "team"        = "javabin" -> "shared"
            # (5 unchanged elements hidden)
        }
        # (14 unchanged attributes hidden)
    }

Plan: 0 to add, 14 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: 🟢 LOW

Routine tag updates changing team ownership from 'javabin' to 'shared' across 14 infrastructure resources with no destructive or security-impacting changes.

  • [routine] Tag updates on 14 resources (VPC, subnets, security groups, load balancer, SNS topics, IAM roles, ECS cluster) changing 'team' tag from 'javabin' to 'shared' for cost allocation and resource organization
  • [routine] IAM policy updates for ci_team_allow[testteam] restructuring permission statements: removing team tag isolation condition, adding cross-team access denial, and adding shared infrastructure mutation protection
  • [routine] IAM policy updates for ci_team_deny[testteam] removing sns:Subscribe and sns:Unsubscribe from denied actions, allowing teams to manage SNS subscriptions
  • [routine] All changes are in-place updates with no resource destruction, no new billable resources, and no security group modifications affecting network access
  • [routine] No Lambda function code changes, no database modifications, no infrastructure deletions - purely metadata and permission policy adjustments

@Alexanderamiri Alexanderamiri merged commit 5bf92c3 into main Mar 17, 2026
3 checks passed
@Alexanderamiri Alexanderamiri deleted the fix/tag-based-team-isolation branch March 17, 2026 23:51
Alexanderamiri added a commit that referenced this pull request May 9, 2026
## Summary

Replace hardcoded service action lists with pure tag-based ABAC. Three
statements handle all isolation:

| Statement | What it does |
|-----------|-------------|
| `AllowAll` | Unrestricted allow — deny policies are the gates |
| `DenyCrossTeamAccess` | Blocks any action where `aws:ResourceTag/team`
exists and isn't the team's or `"shared"` |
| `DenyMutateSharedInfra` | Blocks Delete*/Modify* on `team=shared`
resources |

### Tag model
- `team=shared` → any team can use (read + create children), but not
delete/modify
- `team=teamX` → only that team can access
- `team=platform` → no team can access
- No tag → deny doesn't fire (new resources during create)

### Shared resources tagged
VPC, subnets, ECS tasks SG, ALB, HTTPS listener, ECS cluster, SNS
topics, ECS execution role.

### Cross-team isolation
Teams **cannot** touch each other's resources — the deny fires when
`aws:ResourceTag/team` doesn't match. Teams also **cannot**
delete/modify shared infra (ALB, cluster, zone) — only create children
(listener rules, ECS services, DNS records) which get the team's own
tag.

## Test plan
- [ ] Merge and wait for apply (tag changes are safe — just adds/changes
tags)
- [ ] Re-run test app CI — should pass plan and apply end-to-end
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