Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions terraform/platform/compute/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ resource "aws_ecs_cluster" "main" {

tags = {
Name = "${var.project}-platform"
team = "shared"
}
}

Expand Down
114 changes: 71 additions & 43 deletions terraform/platform/iam/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -357,22 +357,81 @@ resource "aws_iam_role_policy" "ci_team_allow" {
name = "team-management"
role = aws_iam_role.ci_team[each.key].id

# IAM model: AllowAll + DenyCrossTeam + DenyMutateShared
#
# 1. AllowAll: unrestricted allow (the deny policies are the real gates)
# 2. DenyCrossTeamAccess: blocks ANY action on resources tagged with
# another team's name. Uses Null condition so creates (no tags yet)
# pass through. Accepts "shared" for platform infra teams interact with.
# 3. DenyMutateSharedInfra: protects shared resources from deletion/
# modification while allowing teams to create children (listener rules,
# ECS services, DNS records). Works because children inherit the TEAM's
# tag from default_tags, not the parent's "shared" tag.
# 4. Backend access: explicit S3 path + DynamoDB key scoping per team.
#
# The ci_team_deny policy adds a third layer protecting VPC, security
# services, IAM users, and platform-specific ARNs.
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowWithTeamTagIsolation"
Sid = "AllowAll"
Effect = "Allow"
Action = "*"
Resource = "*"
},
{
# Block access to resources owned by other teams or by platform.
# Only fires when aws:ResourceTag/team EXISTS and doesn't match.
# Creates pass through (new resources have no tags yet).
# "shared" is accepted — platform infra that all teams use.
Sid = "DenyCrossTeamAccess"
Effect = "Deny"
Action = "*"
NotResource = [
# State backend excluded — scoped separately by S3 key prefix
# and DynamoDB LeadingKeys (teams can't access each other's state).
"arn:aws:s3:::${var.project}-terraform-state-${var.aws_account_id}",
"arn:aws:s3:::${var.project}-terraform-state-${var.aws_account_id}/*",
"arn:aws:dynamodb:${var.region}:${var.aws_account_id}:table/${var.project}-terraform-app-locks",
# Plan artifacts — teams upload/download their own plans.
"arn:aws:s3:::${var.project}-ci-plan-artifacts-${var.aws_account_id}",
"arn:aws:s3:::${var.project}-ci-plan-artifacts-${var.aws_account_id}/*",
]
Condition = {
StringEqualsIfExists = {
"aws:ResourceTag/team" = each.key
"aws:RequestTag/team" = each.key
StringNotEquals = {
"aws:ResourceTag/team" = [each.key, "shared"]
}
"Null" = {
"aws:ResourceTag/team" = "false"
}
}
},
{
# Protect shared infrastructure from destructive operations.
# Teams can CREATE children (listener rules, ECS services, DNS records)
# because those target the child resource which gets team=teamX.
# But they cannot delete/modify the parent (ALB, cluster, zone).
Sid = "DenyMutateSharedInfra"
Effect = "Deny"
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",
]
Resource = "*"
Condition = {
StringEquals = {
"aws:ResourceTag/team" = "shared"
}
}
},
{
# Terraform state: S3 scoped to team's key prefix.
Sid = "AllowTerraformBackend"
Effect = "Allow"
Action = [
Expand All @@ -387,6 +446,7 @@ resource "aws_iam_role_policy" "ci_team_allow" {
]
},
{
# Terraform locking: DynamoDB scoped to team's state paths.
Sid = "AllowTerraformLocking"
Effect = "Allow"
Action = [
Expand All @@ -401,36 +461,6 @@ resource "aws_iam_role_policy" "ci_team_allow" {
}
}
},
{
# App repos reference shared platform infra via data sources (VPC, ALB,
# ECS cluster, IAM execution role, SNS topics, Route53 zones, etc.).
# These are read-only operations that don't modify resources, but the
# ABAC tag condition blocks them because platform resources have
# team=platform. Allow describe/get/list without tag conditions.
Sid = "AllowPlatformDataSourceReads"
Effect = "Allow"
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",
]
Resource = "*"
},
]
})
}
Expand All @@ -441,15 +471,12 @@ resource "aws_iam_role_policy" "ci_team_deny" {
name = "deny-platform-operations"
role = aws_iam_role.ci_team[each.key].id

# Deny policy protects platform infrastructure from team CI roles.
#
# Design principle: use tag-based ABAC where AWS supports it, explicit
# denies only where the service lacks tag condition support. Each statement
# documents WHY it can't be replaced with ABAC.
# Deny policy protects infrastructure that can't be scoped by tags.
#
# The allow policy (ci_team_allow) already gates all actions behind
# aws:ResourceTag/team + aws:RequestTag/team conditions. These denies
# are a second layer for services that don't honor tag conditions.
# The allow policy uses AllowAll + DenyCrossTeamAccess (tag-based).
# This deny policy adds explicit blocks for services where tags don't
# work: networking, security services, IAM users, and specific
# platform ARNs that need protection beyond tag-based isolation.
policy = jsonencode({
Version = "2012-10-17"
Statement = [
Expand Down Expand Up @@ -533,7 +560,7 @@ resource "aws_iam_role_policy" "ci_team_deny" {
{
Sid = "DenyPlatformSNS"
Effect = "Deny"
Action = ["sns:DeleteTopic", "sns:SetTopicAttributes", "sns:Subscribe", "sns:Unsubscribe"]
Action = ["sns:DeleteTopic", "sns:SetTopicAttributes"]
Resource = [
"arn:aws:sns:${var.region}:${var.aws_account_id}:${var.project}-alerts",
"arn:aws:sns:${var.region}:${var.aws_account_id}:${var.project}-security",
Expand Down Expand Up @@ -939,6 +966,7 @@ resource "aws_iam_role" "ecs_execution" {

tags = {
Name = "${var.project}-ecs-execution"
team = "shared"
}
}

Expand Down
5 changes: 5 additions & 0 deletions terraform/platform/ingress/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ resource "aws_lb" "main" {

tags = {
Name = "${var.project}-platform-alb"
team = "shared"
}
}

Expand All @@ -88,6 +89,10 @@ resource "aws_lb_listener" "https" {
status_code = "404"
}
}

tags = {
team = "shared"
}
}

################################################################################
Expand Down
2 changes: 2 additions & 0 deletions terraform/platform/monitoring/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ resource "aws_sns_topic" "alerts" {

tags = {
Name = "${var.project}-alerts"
team = "shared"
}
}

Expand All @@ -15,6 +16,7 @@ resource "aws_sns_topic" "security" {

tags = {
Name = "${var.project}-security"
team = "shared"
}
}

Expand Down
6 changes: 6 additions & 0 deletions terraform/platform/networking/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ resource "aws_vpc" "main" {

tags = {
Name = "${var.project}-vpc"
team = "shared"
}
}

Expand All @@ -33,6 +34,7 @@ resource "aws_subnet" "public_a" {
tags = {
Name = "${var.project}-public-a"
tier = "public"
team = "shared"
}
}

Expand All @@ -45,6 +47,7 @@ resource "aws_subnet" "public_b" {
tags = {
Name = "${var.project}-public-b"
tier = "public"
team = "shared"
}
}

Expand All @@ -56,6 +59,7 @@ resource "aws_subnet" "private_a" {
tags = {
Name = "${var.project}-private-a"
tier = "private"
team = "shared"
}
}

Expand All @@ -67,6 +71,7 @@ resource "aws_subnet" "private_b" {
tags = {
Name = "${var.project}-private-b"
tier = "private"
team = "shared"
}
}

Expand Down Expand Up @@ -202,6 +207,7 @@ resource "aws_security_group" "ecs_tasks" {

tags = {
Name = "${var.project}-ecs-tasks-sg"
team = "shared"
}
}

Expand Down