Skip to content

Replace per-repo IAM roles with per-team roles#73

Merged
Alexanderamiri merged 1 commit into
mainfrom
fix/team-based-iam-roles
Mar 16, 2026
Merged

Replace per-repo IAM roles with per-team roles#73
Alexanderamiri merged 1 commit into
mainfrom
fix/team-based-iam-roles

Conversation

@Alexanderamiri
Copy link
Copy Markdown
Member

Summary

Replaces per-repo IAM roles (javabin-ci-app-{repo}, javabin-ci-deploy-{repo}) with per-team roles (javabin-ci-team-{team}, javabin-ci-deploy-{team}).

Before: Every app repo needed to be in a registered_app_repos list → IAM role created per repo → sync mechanism needed to discover repos.

After: IAM roles are per-team. Repos are discovered at CI runtime via GitHub API (/repos/{repo}/teams). Adding a repo to a GitHub team is all that's needed — no Terraform changes, no sync.

Security model

  • OIDC trust: repo:javaBin/*:* (any org repo) BUT job_workflow_ref pinned to our platform workflows on main
  • Only code running through our controlled tf-plan.yml, docker-build.yml, ecs-deploy.yml can assume these roles
  • ABAC: resources must be tagged with team={team-slug} — teams can only touch their own resources
  • Permission boundary still applied to all roles

Flow

  1. Team registers in registry → GitHub team created
  2. Dev adds repo to GitHub team (GitHub UI)
  3. Dev pushes → workflow resolves team → assumes javabin-ci-team-{team} → plans/applies
  4. No manual registration. No repo lists. No sync delays.

Test plan

  • terraform plan — verify old per-repo roles destroyed, new per-team roles created
  • Test app repo CI: verify team resolution works and role assumption succeeds
  • Verify repo not in any team gets clear error message
  • Verify ABAC: team role can only touch team-tagged resources

IAM:
- Replace javabin-ci-app-{repo} with javabin-ci-team-{team}
- Replace javabin-ci-deploy-{repo} with javabin-ci-deploy-{team}
- Trust any javaBin/* repo via OIDC, pinned to platform workflow refs
  (only our controlled workflows can assume these roles)
- ABAC scopes access by team tag instead of project tag
- registered_app_repos → registered_teams (synced from GitHub teams API)

Workflows:
- tf-plan, docker-build, ecs-deploy: resolve team from GitHub API
  at runtime, then assume team-scoped role
- Repos not in any team get a clear error with setup instructions
- Platform CI: sync-registered-teams.py replaces sync-registered-repos.py

Scripts:
- Add resolve-team.sh: queries /repos/{repo}/teams API
- Add sync-registered-teams.py: lists org teams for tfvars
- Remove sync-registered-repos.py: no longer needed

No manual registration needed. Teams register in registry, add repos
to their GitHub team, and CI works automatically.
@github-actions
Copy link
Copy Markdown

Terraform Plan

🚧 Changes detected — Plan: 8 to add, 1 to change, 8 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:
  + create
  ~ update in-place
  - destroy

Terraform will perform the following actions:

  # module.iam.aws_iam_role.ci_app["platform-test-app"] will be destroyed
  # (because aws_iam_role.ci_app is not in configuration)
  - resource "aws_iam_role" "ci_app" {
      - arn                   = "arn:aws:iam::553637109631:role/javabin-ci-app-platform-test-app" -> null
      - assume_role_policy    = jsonencode(
            {
              - Statement = [
                  - {
                      - Action    = "sts:AssumeRoleWithWebIdentity"
                      - Condition = {
                          - StringEquals = {
                              - "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
                            }
                          - StringLike   = {
                              - "token.actions.githubusercontent.com:job_workflow_ref" = [
                                  - "javaBin/platform/.github/workflows/tf-plan.yml@refs/heads/main",
                                ]
                              - "token.actions.githubusercontent.com:sub"              = "repo:javaBin/platform-test-app:*"
                            }
                        }
                      - Effect    = "Allow"
                      - Principal = {
                          - Federated = "arn:aws:iam::553637109631:oidc-provider/token.actions.githubusercontent.com"
                        }
                      - Sid       = "AllowPlanAndReviewViaOIDC"
                    },
                  - {
                      - Action    = "sts:AssumeRole"
                      - Effect    = "Allow"
                      - Principal = {
                          - AWS = "arn:aws:iam::553637109631:role/javabin-apply-gate"
                        }
                      - Sid       = "AllowApplyViaGateLambda"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> null
      - create_date           = "2026-03-07T18:03:35Z" -> null
      - force_detach_policies = false -> null
      - id                    = "javabin-ci-app-platform-test-app" -> null
      - managed_policy_arns   = [] -> null
      - max_session_duration  = 3600 -> null
      - name                  = "javabin-ci-app-platform-test-app" -> null
      - path                  = "/" -> null
      - permissions_boundary  = "arn:aws:iam::553637109631:policy/javabin-developer-boundary" -> null
      - tags                  = {
          - "Name" = "javabin-ci-app-platform-test-app"
        } -> null
      - tags_all              = {
          - "Name"        = "javabin-ci-app-platform-test-app"
          - "environment" = "production"
          - "managed-by"  = "terraform"
          - "project"     = "javabin"
          - "team"        = "javabin"
        } -> null
      - unique_id             = "AROAYBZ2X35763HSYKSYL" -> null

      - inline_policy {
          - name   = "app-management" -> null
          - policy = jsonencode(
                {
                  - Statement = [
                      - {
                          - Action    = "*"
                          - Condition = {
                              - StringEqualsIfExists = {
                                  - "aws:RequestTag/project"  = "${aws:PrincipalTag/project}"
                                  - "aws:ResourceTag/project" = "${aws:PrincipalTag/project}"
                                }
                            }
                          - Effect    = "Allow"
                          - Resource  = "*"
                          - Sid       = "AllowWithTagIsolation"
                        },
                    ]
                  - Version   = "2012-10-17"
                }
            ) -> null
        }
      - inline_policy {
          - name   = "deny-platform-operations" -> null
          - policy = jsonencode(
                {
                  - Statement = [
                      - {
                          - Action   = [
                              - "ec2:CreateVpc",
                              - "ec2:DeleteVpc",
                              - "ec2:ModifyVpcAttribute",
                              - "ec2:CreateSubnet",
                              - "ec2:DeleteSubnet",
                              - "ec2:CreateNatGateway",
                              - "ec2:DeleteNatGateway",
                              - "ec2:CreateInternetGateway",
                              - "ec2:DeleteInternetGateway",
                              - "ec2:AttachInternetGateway",
                              - "ec2:DetachInternetGateway",
                              - "ec2:CreateRouteTable",
                              - "ec2:DeleteRouteTable",
                              - "ec2:CreateSecurityGroup",
                              - "ec2:DeleteSecurityGroup",
                              - "ec2:AuthorizeSecurityGroupIngress",
                              - "ec2:RevokeSecurityGroupIngress",
                              - "ec2:AuthorizeSecurityGroupEgress",
                              - "ec2:RevokeSecurityGroupEgress",
                            ]
                          - Effect   = "Deny"
                          - Resource = "*"
                          - Sid      = "DenyNetworkInfra"
                        },
                      - {
                          - Action   = [
                              - "elasticloadbalancingv2:CreateLoadBalancer",
                              - "elasticloadbalancingv2:DeleteLoadBalancer",
                              - "ecs:CreateCluster",
                              - "ecs:DeleteCluster",
                            ]
                          - Effect   = "Deny"
                          - Resource = "*"
                          - Sid      = "DenyLoadBalancerAndCluster"
                        },
                      - {
                          - Action   = [
                              - "guardduty:*",
                              - "securityhub:*",
                              - "config:*",
                              - "cloudtrail:*",
                            ]
                          - Effect   = "Deny"
                          - Resource = "*"
                          - Sid      = "DenySecurityServices"
                        },
                      - {
                          - Action   = [
                              - "organizations:*",
                              - "account:*",
                            ]
                          - Effect   = "Deny"
                          - Resource = "*"
                          - Sid      = "DenyOrgAndAccount"
                        },
                      - {
                          - Action   = [
                              - "iam:CreateUser",
                              - "iam:CreateAccessKey",
                              - "iam:CreateLoginProfile",
                            ]
                          - Effect   = "Deny"
                          - Resource = "*"
                          - Sid      = "DenyDangerousIAM"
                        },
                      - {
                          - Action   = [
                              - "sns:CreateTopic",
                              - "sns:DeleteTopic",
                            ]
                          - Effect   = "Deny"
                          - Resource = "*"
                          - Sid      = "DenyPlatformSNS"
                        },
                      - {
                          - Action   = "s3:DeleteBucket"
                          - Effect   = "Deny"
                          - Resource = "*"
                          - Sid      = "DenyStateBucketDeletion"
                        },
                    ]
                  - Version   = "2012-10-17"
                }
            ) -> null
        }
    }

  # module.iam.aws_iam_role.ci_deploy["platform-test-app"] will be destroyed
  # (because key ["platform-test-app"] is not in for_each map)
  - resource "aws_iam_role" "ci_deploy" {
      - arn                   = "arn:aws:iam::553637109631:role/javabin-ci-deploy-platform-test-app" -> null
      - assume_role_policy    = jsonencode(
            {
              - Statement = [
                  - {
                      - Action    = "sts:AssumeRoleWithWebIdentity"
                      - Condition = {
                          - StringEquals = {
                              - "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
                            }
                          - StringLike   = {
                              - "token.actions.githubusercontent.com:job_workflow_ref" = [
                                  - "javaBin/platform/.github/workflows/docker-build.yml@refs/heads/main",
                                  - "javaBin/platform/.github/workflows/ecs-deploy.yml@refs/heads/main",
                                ]
                              - "token.actions.githubusercontent.com:sub"              = "repo:javaBin/platform-test-app:*"
                            }
                        }
                      - Effect    = "Allow"
                      - Principal = {
                          - Federated = "arn:aws:iam::553637109631:oidc-provider/token.actions.githubusercontent.com"
                        }
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> null
      - create_date           = "2026-03-07T18:03:35Z" -> null
      - force_detach_policies = false -> null
      - id                    = "javabin-ci-deploy-platform-test-app" -> null
      - managed_policy_arns   = [] -> null
      - max_session_duration  = 3600 -> null
      - name                  = "javabin-ci-deploy-platform-test-app" -> null
      - path                  = "/" -> null
      - permissions_boundary  = "arn:aws:iam::553637109631:policy/javabin-developer-boundary" -> null
      - tags                  = {
          - "Name" = "javabin-ci-deploy-platform-test-app"
        } -> null
      - tags_all              = {
          - "Name"        = "javabin-ci-deploy-platform-test-app"
          - "environment" = "production"
          - "managed-by"  = "terraform"
          - "project"     = "javabin"
          - "team"        = "javabin"
        } -> null
      - unique_id             = "AROAYBZ2X357SWLXFMXQK" -> null

      - inline_policy {
          - name   = "cloudwatch-logs" -> null
          - policy = jsonencode(
                {
                  - Statement = [
                      - {
                          - Action   = [
                              - "logs:CreateLogGroup",
                              - "logs:CreateLogStream",
                              - "logs:PutLogEvents",
                            ]
                          - Effect   = "Allow"
                          - Resource = "arn:aws:logs:eu-central-1:553637109631:log-group:/ecs/javabin/*"
                        },
                    ]
                  - Version   = "2012-10-17"
                }
            ) -> null
        }
      - inline_policy {
          - name   = "ecr-push" -> null
          - policy = jsonencode(
                {
                  - Statement = [
                      - {
                          - Action   = [
                              - "ecr:GetAuthorizationToken",
                            ]
                          - Effect   = "Allow"
                          - Resource = "*"
                          - Sid      = "EcrAuth"
                        },
                      - {
                          - Action   = [
                              - "ecr:BatchCheckLayerAvailability",
                              - "ecr:PutImage",
                              - "ecr:InitiateLayerUpload",
                              - "ecr:UploadLayerPart",
                              - "ecr:CompleteLayerUpload",
                              - "ecr:GetDownloadUrlForLayer",
                              - "ecr:BatchGetImage",
                            ]
                          - Effect   = "Allow"
                          - Resource = "arn:aws:ecr:eu-central-1:553637109631:repository/platform-test-app"
                          - Sid      = "EcrPush"
                        },
                    ]
                  - Version   = "2012-10-17"
                }
            ) -> null
        }
      - inline_policy {
          - name   = "ecs-deploy" -> null
          - policy = jsonencode(
                {
                  - Statement = [
                      - {
                          - Action    = [
                              - "ecs:UpdateService",
                              - "ecs:DescribeServices",
                            ]
                          - Condition = {
                              - StringEquals = {
                                  - "aws:ResourceTag/project" = "javabin"
                                }
                            }
                          - Effect    = "Allow"
                          - Resource  = "*"
                          - Sid       = "EcsServiceManagement"
                        },
                      - {
                          - Action   = [
                              - "ecs:DescribeTaskDefinition",
                              - "ecs:RegisterTaskDefinition",
                              - "ecs:DeregisterTaskDefinition",
                            ]
                          - Effect   = "Allow"
                          - Resource = "*"
                          - Sid      = "EcsTaskDefinitionManagement"
                        },
                      - {
                          - Action   = [
                              - "iam:PassRole",
                            ]
                          - Effect   = "Allow"
                          - Resource = [
                              - "arn:aws:iam::553637109631:role/javabin-ecs-execution",
                              - "arn:aws:iam::553637109631:role/javabin-*",
                            ]
                          - Sid      = "PassRole"
                        },
                    ]
                  - Version   = "2012-10-17"
                }
            ) -> null
        }
      - inline_policy {
          - name   = "ssm-read-overrides" -> null
          - policy = jsonencode(
                {
                  - Statement = [
                      - {
                          - Action   = [
                              - "ssm:GetParameter",
                              - "ssm:GetParameters",
                            ]
                          - Effect   = "Allow"
                          - Resource = "arn:aws:ssm:eu-central-1:553637109631:parameter/javabin/platform-overrides/*"
                        },
                    ]
                  - Version   = "2012-10-17"
                }
            ) -> null
        }
    }

  # module.iam.aws_iam_role.ci_deploy["platform-test-team"] will be created
  + resource "aws_iam_role" "ci_deploy" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRoleWithWebIdentity"
                      + Condition = {
                          + StringEquals = {
                              + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
                            }
                          + StringLike   = {
                              + "token.actions.githubusercontent.com:job_workflow_ref" = [
                                  + "javaBin/platform/.github/workflows/docker-build.yml@refs/heads/main",
                                  + "javaBin/platform/.github/workflows/ecs-deploy.yml@refs/heads/main",
                                ]
                              + "token.actions.githubusercontent.com:sub"              = "repo:javaBin/*:*"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Federated = "arn:aws:iam::553637109631:oidc-provider/token.actions.githubusercontent.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "javabin-ci-deploy-platform-test-team"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::553637109631:policy/javabin-developer-boundary"
      + tags                  = {
          + "Name" = "javabin-ci-deploy-platform-test-team"
          + "team" = "platform-test-team"
        }
      + tags_all              = {
          + "Name"        = "javabin-ci-deploy-platform-test-team"
          + "environment" = "production"
          + "managed-by"  = "terraform"
          + "project"     = "javabin"
          + "team"        = "platform-test-team"
        }
      + unique_id             = (known after apply)
    }

  # module.iam.aws_iam_role.ci_team["platform-test-team"] will be created
  + resource "aws_iam_role" "ci_team" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRoleWithWebIdentity"
                      + Condition = {
                          + StringEquals = {
                              + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
                            }
                          + StringLike   = {
                              + "token.actions.githubusercontent.com:job_workflow_ref" = [
                                  + "javaBin/platform/.github/workflows/tf-plan.yml@refs/heads/main",
                                ]
                              + "token.actions.githubusercontent.com:sub"              = "repo:javaBin/*:*"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Federated = "arn:aws:iam::553637109631:oidc-provider/token.actions.githubusercontent.com"
                        }
                      + Sid       = "AllowPlanViaOIDC"
                    },
                  + {
                      + Action    = "sts:AssumeRole"
                      + Effect    = "Allow"
                      + Principal = {
                          + AWS = "arn:aws:iam::553637109631:role/javabin-apply-gate"
                        }
                      + Sid       = "AllowApplyViaGateLambda"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "javabin-ci-team-platform-test-team"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::553637109631:policy/javabin-developer-boundary"
      + tags                  = {
          + "Name" = "javabin-ci-team-platform-test-team"
          + "team" = "platform-test-team"
        }
      + tags_all              = {
          + "Name"        = "javabin-ci-team-platform-test-team"
          + "environment" = "production"
          + "managed-by"  = "terraform"
          + "project"     = "javabin"
          + "team"        = "platform-test-team"
        }
      + unique_id             = (known after apply)
    }

  # module.iam.aws_iam_role_policy.ci_app_allow["platform-test-app"] will be destroyed
  # (because aws_iam_role_policy.ci_app_allow is not in configuration)
  - resource "aws_iam_role_policy" "ci_app_allow" {
      - id     = "javabin-ci-app-platform-test-app:app-management" -> null
      - name   = "app-management" -> null
      - policy = jsonencode(
            {
              - Statement = [
                  - {
                      - Action    = "*"
                      - Condition = {
                          - StringEqualsIfExists = {
                              - "aws:RequestTag/project"  = "${aws:PrincipalTag/project}"
                              - "aws:ResourceTag/project" = "${aws:PrincipalTag/project}"
                            }
                        }
                      - Effect    = "Allow"
                      - Resource  = "*"
                      - Sid       = "AllowWithTagIsolation"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> null
      - role   = "javabin-ci-app-platform-test-app" -> null
    }

  # module.iam.aws_iam_role_policy.ci_app_deny["platform-test-app"] will be destroyed
  # (because aws_iam_role_policy.ci_app_deny is not in configuration)
  - resource "aws_iam_role_policy" "ci_app_deny" {
      - id     = "javabin-ci-app-platform-test-app:deny-platform-operations" -> null
      - name   = "deny-platform-operations" -> null
      - policy = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "ec2:CreateVpc",
                          - "ec2:DeleteVpc",
                          - "ec2:ModifyVpcAttribute",
                          - "ec2:CreateSubnet",
                          - "ec2:DeleteSubnet",
                          - "ec2:CreateNatGateway",
                          - "ec2:DeleteNatGateway",
                          - "ec2:CreateInternetGateway",
                          - "ec2:DeleteInternetGateway",
                          - "ec2:AttachInternetGateway",
                          - "ec2:DetachInternetGateway",
                          - "ec2:CreateRouteTable",
                          - "ec2:DeleteRouteTable",
                          - "ec2:CreateSecurityGroup",
                          - "ec2:DeleteSecurityGroup",
                          - "ec2:AuthorizeSecurityGroupIngress",
                          - "ec2:RevokeSecurityGroupIngress",
                          - "ec2:AuthorizeSecurityGroupEgress",
                          - "ec2:RevokeSecurityGroupEgress",
                        ]
                      - Effect   = "Deny"
                      - Resource = "*"
                      - Sid      = "DenyNetworkInfra"
                    },
                  - {
                      - Action   = [
                          - "elasticloadbalancingv2:CreateLoadBalancer",
                          - "elasticloadbalancingv2:DeleteLoadBalancer",
                          - "ecs:CreateCluster",
                          - "ecs:DeleteCluster",
                        ]
                      - Effect   = "Deny"
                      - Resource = "*"
                      - Sid      = "DenyLoadBalancerAndCluster"
                    },
                  - {
                      - Action   = [
                          - "guardduty:*",
                          - "securityhub:*",
                          - "config:*",
                          - "cloudtrail:*",
                        ]
                      - Effect   = "Deny"
                      - Resource = "*"
                      - Sid      = "DenySecurityServices"
                    },
                  - {
                      - Action   = [
                          - "organizations:*",
                          - "account:*",
                        ]
                      - Effect   = "Deny"
                      - Resource = "*"
                      - Sid      = "DenyOrgAndAccount"
                    },
                  - {
                      - Action   = [
                          - "iam:CreateUser",
                          - "iam:CreateAccessKey",
                          - "iam:CreateLoginProfile",
                        ]
                      - Effect   = "Deny"
                      - Resource = "*"
                      - Sid      = "DenyDangerousIAM"
                    },
                  - {
                      - Action   = [
                          - "sns:CreateTopic",
                          - "sns:DeleteTopic",
                        ]
                      - Effect   = "Deny"
                      - Resource = "*"
                      - Sid      = "DenyPlatformSNS"
                    },
                  - {
                      - Action   = "s3:DeleteBucket"
                      - Effect   = "Deny"
                      - Resource = "*"
                      - Sid      = "DenyStateBucketDeletion"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> null
      - role   = "javabin-ci-app-platform-test-app" -> null
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ecr["platform-test-app"] will be destroyed
  # (because key ["platform-test-app"] is not in for_each map)
  - resource "aws_iam_role_policy" "ci_deploy_ecr" {
      - id     = "javabin-ci-deploy-platform-test-app:ecr-push" -> null
      - name   = "ecr-push" -> null
      - policy = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "ecr:GetAuthorizationToken",
                        ]
                      - Effect   = "Allow"
                      - Resource = "*"
                      - Sid      = "EcrAuth"
                    },
                  - {
                      - Action   = [
                          - "ecr:BatchCheckLayerAvailability",
                          - "ecr:PutImage",
                          - "ecr:InitiateLayerUpload",
                          - "ecr:UploadLayerPart",
                          - "ecr:CompleteLayerUpload",
                          - "ecr:GetDownloadUrlForLayer",
                          - "ecr:BatchGetImage",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:ecr:eu-central-1:553637109631:repository/platform-test-app"
                      - Sid      = "EcrPush"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> null
      - role   = "javabin-ci-deploy-platform-test-app" -> null
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ecr["platform-test-team"] will be created
  + resource "aws_iam_role_policy" "ci_deploy_ecr" {
      + id          = (known after apply)
      + name        = "ecr-push"
      + name_prefix = (known after apply)
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = [
                          + "ecr:GetAuthorizationToken",
                        ]
                      + Effect   = "Allow"
                      + Resource = "*"
                      + Sid      = "EcrAuth"
                    },
                  + {
                      + Action   = [
                          + "ecr:BatchCheckLayerAvailability",
                          + "ecr:PutImage",
                          + "ecr:InitiateLayerUpload",
                          + "ecr:UploadLayerPart",
                          + "ecr:CompleteLayerUpload",
                          + "ecr:GetDownloadUrlForLayer",
                          + "ecr:BatchGetImage",
                          + "ecr:CreateRepository",
                          + "ecr:DescribeRepositories",
                        ]
                      + Effect   = "Allow"
                      + Resource = "arn:aws:ecr:eu-central-1:553637109631:repository/*"
                      + Sid      = "EcrPush"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + role        = (known after apply)
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ecs["platform-test-app"] will be destroyed
  # (because key ["platform-test-app"] is not in for_each map)
  - resource "aws_iam_role_policy" "ci_deploy_ecs" {
      - id     = "javabin-ci-deploy-platform-test-app:ecs-deploy" -> null
      - name   = "ecs-deploy" -> null
      - policy = jsonencode(
            {
              - Statement = [
                  - {
                      - Action    = [
                          - "ecs:UpdateService",
                          - "ecs:DescribeServices",
                        ]
                      - Condition = {
                          - StringEquals = {
                              - "aws:ResourceTag/project" = "javabin"
                            }
                        }
                      - Effect    = "Allow"
                      - Resource  = "*"
                      - Sid       = "EcsServiceManagement"
                    },
                  - {
                      - Action   = [
                          - "ecs:DescribeTaskDefinition",
                          - "ecs:RegisterTaskDefinition",
                          - "ecs:DeregisterTaskDefinition",
                        ]
                      - Effect   = "Allow"
                      - Resource = "*"
                      - Sid      = "EcsTaskDefinitionManagement"
                    },
                  - {
                      - Action   = [
                          - "iam:PassRole",
                        ]
                      - Effect   = "Allow"
                      - Resource = [
                          - "arn:aws:iam::553637109631:role/javabin-ecs-execution",
                          - "arn:aws:iam::553637109631:role/javabin-*",
                        ]
                      - Sid      = "PassRole"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> null
      - role   = "javabin-ci-deploy-platform-test-app" -> null
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ecs["platform-test-team"] will be created
  + resource "aws_iam_role_policy" "ci_deploy_ecs" {
      + id          = (known after apply)
      + name        = "ecs-deploy"
      + name_prefix = (known after apply)
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = [
                          + "ecs:UpdateService",
                          + "ecs:DescribeServices",
                        ]
                      + Condition = {
                          + StringEquals = {
                              + "aws:ResourceTag/team" = "platform-test-team"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsServiceManagement"
                    },
                  + {
                      + Action   = [
                          + "ecs:DescribeTaskDefinition",
                          + "ecs:RegisterTaskDefinition",
                          + "ecs:DeregisterTaskDefinition",
                        ]
                      + Effect   = "Allow"
                      + Resource = "*"
                      + Sid      = "EcsTaskDefinitionManagement"
                    },
                  + {
                      + Action   = [
                          + "iam:PassRole",
                        ]
                      + Effect   = "Allow"
                      + Resource = [
                          + "arn:aws:iam::553637109631:role/javabin-ecs-execution",
                          + "arn:aws:iam::553637109631:role/javabin-*",
                        ]
                      + Sid      = "PassRole"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + role        = (known after apply)
    }

  # module.iam.aws_iam_role_policy.ci_deploy_logs["platform-test-app"] will be destroyed
  # (because key ["platform-test-app"] is not in for_each map)
  - resource "aws_iam_role_policy" "ci_deploy_logs" {
      - id     = "javabin-ci-deploy-platform-test-app:cloudwatch-logs" -> null
      - name   = "cloudwatch-logs" -> null
      - policy = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "logs:CreateLogGroup",
                          - "logs:CreateLogStream",
                          - "logs:PutLogEvents",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:logs:eu-central-1:553637109631:log-group:/ecs/javabin/*"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> null
      - role   = "javabin-ci-deploy-platform-test-app" -> null
    }

  # module.iam.aws_iam_role_policy.ci_deploy_logs["platform-test-team"] will be created
  + resource "aws_iam_role_policy" "ci_deploy_logs" {
      + id          = (known after apply)
      + name        = "cloudwatch-logs"
      + name_prefix = (known after apply)
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = [
                          + "logs:CreateLogGroup",
                          + "logs:CreateLogStream",
                          + "logs:PutLogEvents",
                        ]
                      + Effect   = "Allow"
                      + Resource = "arn:aws:logs:eu-central-1:553637109631:log-group:/ecs/javabin/*"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + role        = (known after apply)
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ssm["platform-test-app"] will be destroyed
  # (because key ["platform-test-app"] is not in for_each map)
  - resource "aws_iam_role_policy" "ci_deploy_ssm" {
      - id     = "javabin-ci-deploy-platform-test-app:ssm-read-overrides" -> null
      - name   = "ssm-read-overrides" -> null
      - policy = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "ssm:GetParameter",
                          - "ssm:GetParameters",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:ssm:eu-central-1:553637109631:parameter/javabin/platform-overrides/*"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> null
      - role   = "javabin-ci-deploy-platform-test-app" -> null
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ssm["platform-test-team"] will be created
  + resource "aws_iam_role_policy" "ci_deploy_ssm" {
      + id          = (known after apply)
      + name        = "ssm-read-overrides"
      + name_prefix = (known after apply)
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = [
                          + "ssm:GetParameter",
                          + "ssm:GetParameters",
                        ]
                      + Effect   = "Allow"
                      + Resource = "arn:aws:ssm:eu-central-1:553637109631:parameter/javabin/platform-overrides/*"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + role        = (known after apply)
    }

  # module.iam.aws_iam_role_policy.ci_team_allow["platform-test-team"] will be created
  + resource "aws_iam_role_policy" "ci_team_allow" {
      + id          = (known after apply)
      + name        = "team-management"
      + name_prefix = (known after apply)
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "*"
                      + Condition = {
                          + StringEqualsIfExists = {
                              + "aws:RequestTag/team"  = "platform-test-team"
                              + "aws:ResourceTag/team" = "platform-test-team"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "AllowWithTeamTagIsolation"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + role        = (known after apply)
    }

  # module.iam.aws_iam_role_policy.ci_team_deny["platform-test-team"] will be created
  + resource "aws_iam_role_policy" "ci_team_deny" {
      + id          = (known after apply)
      + name        = "deny-platform-operations"
      + name_prefix = (known after apply)
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = [
                          + "ec2:CreateVpc",
                          + "ec2:DeleteVpc",
                          + "ec2:ModifyVpcAttribute",
                          + "ec2:CreateSubnet",
                          + "ec2:DeleteSubnet",
                          + "ec2:CreateNatGateway",
                          + "ec2:DeleteNatGateway",
                          + "ec2:CreateInternetGateway",
                          + "ec2:DeleteInternetGateway",
                          + "ec2:AttachInternetGateway",
                          + "ec2:DetachInternetGateway",
                          + "ec2:CreateRouteTable",
                          + "ec2:DeleteRouteTable",
                          + "ec2:CreateSecurityGroup",
                          + "ec2:DeleteSecurityGroup",
                          + "ec2:AuthorizeSecurityGroupIngress",
                          + "ec2:RevokeSecurityGroupIngress",
                          + "ec2:AuthorizeSecurityGroupEgress",
                          + "ec2:RevokeSecurityGroupEgress",
                        ]
                      + Effect   = "Deny"
                      + Resource = "*"
                      + Sid      = "DenyNetworkInfra"
                    },
                  + {
                      + Action   = [
                          + "elasticloadbalancingv2:CreateLoadBalancer",
                          + "elasticloadbalancingv2:DeleteLoadBalancer",
                          + "ecs:CreateCluster",
                          + "ecs:DeleteCluster",
                        ]
                      + Effect   = "Deny"
                      + Resource = "*"
                      + Sid      = "DenyLoadBalancerAndCluster"
                    },
                  + {
                      + Action   = [
                          + "guardduty:*",
                          + "securityhub:*",
                          + "config:*",
                          + "cloudtrail:*",
                        ]
                      + Effect   = "Deny"
                      + Resource = "*"
                      + Sid      = "DenySecurityServices"
                    },
                  + {
                      + Action   = [
                          + "organizations:*",
                          + "account:*",
                        ]
                      + Effect   = "Deny"
                      + Resource = "*"
                      + Sid      = "DenyOrgAndAccount"
                    },
                  + {
                      + Action   = [
                          + "iam:CreateUser",
                          + "iam:CreateAccessKey",
                          + "iam:CreateLoginProfile",
                        ]
                      + Effect   = "Deny"
                      + Resource = "*"
                      + Sid      = "DenyDangerousIAM"
                    },
                  + {
                      + Action   = [
                          + "sns:CreateTopic",
                          + "sns:DeleteTopic",
                        ]
                      + Effect   = "Deny"
                      + Resource = "*"
                      + Sid      = "DenyPlatformSNS"
                    },
                  + {
                      + Action   = "s3:DeleteBucket"
                      + Effect   = "Deny"
                      + Resource = "*"
                      + Sid      = "DenyStateBucketDeletion"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + role        = (known after apply)
    }

  # module.lambdas.aws_lambda_function.compliance_reporter will be updated in-place
  ~ resource "aws_lambda_function" "compliance_reporter" {
        id                             = "javabin-compliance-reporter"
        tags                           = {}
        # (23 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              ~ "ALLOWED_IDENTITIES"  = "javabin-ci-infra,javabin-ci-app-,javabin-ci-deploy-,javabin-,alexander.amiri" -> "javabin-ci-infra,javabin-ci-team-,javabin-ci-deploy-,javabin-,alexander.amiri"
                # (2 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

Plan: 8 to add, 1 to change, 8 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

Terraform plan refactors CI/CD IAM roles from app-specific to team-based structure, removing platform-test-app roles and creating new platform-test-team roles with broader ECR permissions.

  • 🔒 [security] New ci_deploy role for platform-test-team grants ecr:CreateRepository and ecr:DescribeRepositories on all repositories (arn:aws:ecr:eu-central-1:553637109631:repository/*), expanding permissions beyond the previous app-specific role which was limited to platform-test-app repository only.
  • 🔒 [security] New ci_team role uses broader OIDC condition (repo:javaBin/:) instead of app-specific (repo:javaBin/platform-test-app:*), allowing any javaBin repository to assume this role. Verify this is intentional for the team-based model.
  • [routine] Compliance reporter Lambda environment variable updated to use ci_team- prefix instead of ci-app- prefix, reflecting the migration from app-based to team-based IAM structure.
  • 💥 [destruction] Destroying ci_app role for platform-test-app and all associated policies (app-management, deny-platform-operations). Ensure no active GitHub Actions workflows depend on this role before applying.
  • [routine] ECS service management permissions changed from project-based tag isolation (aws:ResourceTag/project = javabin) to team-based tag isolation (aws:ResourceTag/team = platform-test-team), aligning with new team-scoped access model.

@Alexanderamiri Alexanderamiri merged commit cba894e into main Mar 16, 2026
3 checks passed
@Alexanderamiri Alexanderamiri deleted the fix/team-based-iam-roles branch March 16, 2026 22:01
Alexanderamiri added a commit that referenced this pull request May 9, 2026
## Summary

Replaces per-repo IAM roles (`javabin-ci-app-{repo}`,
`javabin-ci-deploy-{repo}`) with per-team roles
(`javabin-ci-team-{team}`, `javabin-ci-deploy-{team}`).

**Before:** Every app repo needed to be in a `registered_app_repos` list
→ IAM role created per repo → sync mechanism needed to discover repos.

**After:** IAM roles are per-team. Repos are discovered at CI runtime
via GitHub API (`/repos/{repo}/teams`). Adding a repo to a GitHub team
is all that's needed — no Terraform changes, no sync.

### Security model
- OIDC trust: `repo:javaBin/*:*` (any org repo) BUT `job_workflow_ref`
pinned to our platform workflows on main
- Only code running through our controlled `tf-plan.yml`,
`docker-build.yml`, `ecs-deploy.yml` can assume these roles
- ABAC: resources must be tagged with `team={team-slug}` — teams can
only touch their own resources
- Permission boundary still applied to all roles

### Flow
1. Team registers in registry → GitHub team created
2. Dev adds repo to GitHub team (GitHub UI)
3. Dev pushes → workflow resolves team → assumes
`javabin-ci-team-{team}` → plans/applies
4. No manual registration. No repo lists. No sync delays.

## Test plan
- [ ] `terraform plan` — verify old per-repo roles destroyed, new
per-team roles created
- [ ] Test app repo CI: verify team resolution works and role assumption
succeeds
- [ ] Verify repo not in any team gets clear error message
- [ ] Verify ABAC: team role can only touch team-tagged resources
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