From 8595e1d01a7f4efd85587bf2ae1160abc76b5a7f Mon Sep 17 00:00:00 2001 From: Jonathan Alvarez Delgado Date: Tue, 3 Feb 2026 19:23:46 +0100 Subject: [PATCH 1/2] security: Separate ALB and container security groups --- infra/pulumi/__main__.py | 10 ++++++++-- infra/pulumi/config.stage.yaml | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/infra/pulumi/__main__.py b/infra/pulumi/__main__.py index 546af77d46a3..eac646272ebc 100755 --- a/infra/pulumi/__main__.py +++ b/infra/pulumi/__main__.py @@ -248,22 +248,28 @@ def main(): if subnets: # Determine which security groups to apply + # Container SG: controls what can reach the task + # ALB SG: controls what can reach the load balancer (public-facing) if service_name == "web": container_sgs = [security_groups.get("web-sg")] + alb_sgs = [security_groups.get("alb-sg")] elif service_name == "worker": container_sgs = [security_groups.get("worker-sg")] + alb_sgs = [] # Workers don't have ALB else: container_sgs = [security_groups.get("web-sg")] # Default + alb_sgs = [security_groups.get("alb-sg")] - # Filter out None values + # Filter out None values and extract SG IDs container_sg_ids = [sg.resources["sg"].id for sg in container_sgs if sg is not None] + alb_sg_ids = [sg.resources["sg"].id for sg in alb_sgs if sg is not None] fargate_services[service_name] = tb_pulumi.fargate.FargateClusterWithLogging( name=f"{project.name_prefix}-{service_name}", project=project, subnets=[s.id for s in subnets] if subnets else [], container_security_groups=container_sg_ids, - load_balancer_security_groups=container_sg_ids if not is_internal else [], + load_balancer_security_groups=alb_sg_ids if not is_internal else [], **service_config, ) diff --git a/infra/pulumi/config.stage.yaml b/infra/pulumi/config.stage.yaml index 8b6b0d957695..092344c98f48 100644 --- a/infra/pulumi/config.stage.yaml +++ b/infra/pulumi/config.stage.yaml @@ -314,17 +314,46 @@ resources: # ============================================================================= # Security Groups # ============================================================================= + # Architecture: Internet -> ALB (alb-sg) -> Container (web-sg) + # ALB SG allows public HTTPS; container SG allows traffic only from ALB SG tb:network:SecurityGroupWithRules: + alb-sg: + description: Security group for public-facing ALB + rules: + ingress: + - description: HTTPS from internet + from_port: 443 + to_port: 443 + protocol: tcp + cidr_blocks: + - 0.0.0.0/0 + - description: HTTP redirect (optional) + from_port: 80 + to_port: 80 + protocol: tcp + cidr_blocks: + - 0.0.0.0/0 + egress: + - description: To container tasks + from_port: 8000 + to_port: 8000 + protocol: tcp + cidr_blocks: + - 10.100.0.0/16 # VPC CIDR or could be tightened to private subnets + web-sg: description: Security group for web Fargate tasks + # NOTE: Ideally ingress would use source_security_group_id: alb-sg + # but tb_pulumi.network.SecurityGroupWithRules may not support SG references + # Using VPC CIDR as interim; traffic only comes from ALB in practice rules: ingress: - - description: HTTPS from ALB + - description: From ALB only from_port: 8000 to_port: 8000 protocol: tcp cidr_blocks: - - 10.100.0.0/16 + - 10.100.0.0/16 # TODO: Replace with alb-sg reference instead when supported egress: - description: Allow all outbound from_port: 0 From 11809cf4c7cde1f0918810ceef5c308e08d88a83 Mon Sep 17 00:00:00 2001 From: Jonathan Alvarez Delgado Date: Tue, 3 Feb 2026 20:02:06 +0100 Subject: [PATCH 2/2] security: Align SG pattern with thunderbird-accounts repo --- infra/pulumi/__main__.py | 80 ++++++++++++------ infra/pulumi/config.stage.yaml | 144 ++++++++++++++++++++------------- 2 files changed, 143 insertions(+), 81 deletions(-) diff --git a/infra/pulumi/__main__.py b/infra/pulumi/__main__.py index eac646272ebc..bb87c9cb4d05 100755 --- a/infra/pulumi/__main__.py +++ b/infra/pulumi/__main__.py @@ -220,18 +220,53 @@ def main(): pulumi.export("gha_ecr_publish_role_arn", gha_ecr_publish_role.arn) # ========================================================================= - # Security Groups + # Security Groups (accounts-repo pattern) # ========================================================================= + # Pattern: separate load_balancers and containers sections + # For each service, matching entries in both. Workers with no LB set to null. + # Code dynamically wires source_security_group_id from LB SG to container ingress. sg_configs = resources.get("tb:network:SecurityGroupWithRules", {}) - security_groups = {} - - for sg_name, sg_config in sg_configs.items(): + lb_sg_configs = sg_configs.get("load_balancers", {}) + container_sg_configs = sg_configs.get("containers", {}) + + # Build security groups for load balancers + lb_sgs = {} + for service, sg_config in lb_sg_configs.items(): + if sg_config is None: + lb_sgs[service] = None + continue if vpc_resource: sg_config["vpc_id"] = vpc_resource.id + lb_sgs[service] = tb_pulumi.network.SecurityGroupWithRules( + name=f"{project.name_prefix}-sg-lb-{service}", + project=project, + opts=pulumi.ResourceOptions(depends_on=[vpc] if vpc_config else None), + **sg_config, + ) - security_groups[sg_name] = tb_pulumi.network.SecurityGroupWithRules( - name=f"{project.name_prefix}-{sg_name}", + # Build security groups for containers + # Wire source_security_group_id from LB SG to container ingress rules + container_sgs = {} + for service, sg_config in container_sg_configs.items(): + if service not in lb_sgs: + pulumi.log.warn(f"Container SG '{service}' has no matching load_balancers entry") + # Dynamically set source_security_group_id for ingress rules + if lb_sgs.get(service) is not None: + for rule in sg_config.get("rules", {}).get("ingress", []): + if "self" not in rule or not rule.get("self"): + # Set source SG to the matching LB SG + rule["source_security_group_id"] = lb_sgs[service].resources["sg"].id + if vpc_resource: + sg_config["vpc_id"] = vpc_resource.id + depends_on = [] + if lb_sgs.get(service): + depends_on.append(lb_sgs[service].resources["sg"]) + if vpc_config: + depends_on.append(vpc) + container_sgs[service] = tb_pulumi.network.SecurityGroupWithRules( + name=f"{project.name_prefix}-sg-cont-{service}", project=project, + opts=pulumi.ResourceOptions(depends_on=depends_on) if depends_on else None, **sg_config, ) @@ -247,29 +282,28 @@ def main(): subnets = private_subnets if is_internal else public_subnets if subnets: - # Determine which security groups to apply - # Container SG: controls what can reach the task - # ALB SG: controls what can reach the load balancer (public-facing) - if service_name == "web": - container_sgs = [security_groups.get("web-sg")] - alb_sgs = [security_groups.get("alb-sg")] - elif service_name == "worker": - container_sgs = [security_groups.get("worker-sg")] - alb_sgs = [] # Workers don't have ALB - else: - container_sgs = [security_groups.get("web-sg")] # Default - alb_sgs = [security_groups.get("alb-sg")] - - # Filter out None values and extract SG IDs - container_sg_ids = [sg.resources["sg"].id for sg in container_sgs if sg is not None] - alb_sg_ids = [sg.resources["sg"].id for sg in alb_sgs if sg is not None] + # Get security groups for this service + lb_sg = lb_sgs.get(service_name) + container_sg = container_sgs.get(service_name) + + # Extract SG IDs + lb_sg_ids = [lb_sg.resources["sg"].id] if lb_sg else [] + container_sg_ids = [container_sg.resources["sg"].id] if container_sg else [] + + # Build depends_on list + depends_on = [*subnets] + if container_sg: + depends_on.append(container_sg.resources["sg"]) + if lb_sg: + depends_on.append(lb_sg.resources["sg"]) fargate_services[service_name] = tb_pulumi.fargate.FargateClusterWithLogging( name=f"{project.name_prefix}-{service_name}", project=project, subnets=[s.id for s in subnets] if subnets else [], container_security_groups=container_sg_ids, - load_balancer_security_groups=alb_sg_ids if not is_internal else [], + load_balancer_security_groups=lb_sg_ids if not is_internal else [], + opts=pulumi.ResourceOptions(depends_on=depends_on), **service_config, ) diff --git a/infra/pulumi/config.stage.yaml b/infra/pulumi/config.stage.yaml index 092344c98f48..963ff6d5c3a2 100644 --- a/infra/pulumi/config.stage.yaml +++ b/infra/pulumi/config.stage.yaml @@ -314,65 +314,93 @@ resources: # ============================================================================= # Security Groups # ============================================================================= - # Architecture: Internet -> ALB (alb-sg) -> Container (web-sg) - # ALB SG allows public HTTPS; container SG allows traffic only from ALB SG + # Pattern from thunderbird-accounts: separate load_balancers and containers sections + # For each Fargate service, create matching entries in both sections. + # Services without LB (e.g., workers) set load_balancers entry to null. + # Code dynamically wires source_security_group_id from LB SG to container ingress. tb:network:SecurityGroupWithRules: - alb-sg: - description: Security group for public-facing ALB - rules: - ingress: - - description: HTTPS from internet - from_port: 443 - to_port: 443 - protocol: tcp - cidr_blocks: - - 0.0.0.0/0 - - description: HTTP redirect (optional) - from_port: 80 - to_port: 80 - protocol: tcp - cidr_blocks: - - 0.0.0.0/0 - egress: - - description: To container tasks - from_port: 8000 - to_port: 8000 - protocol: tcp - cidr_blocks: - - 10.100.0.0/16 # VPC CIDR or could be tightened to private subnets - - web-sg: - description: Security group for web Fargate tasks - # NOTE: Ideally ingress would use source_security_group_id: alb-sg - # but tb_pulumi.network.SecurityGroupWithRules may not support SG references - # Using VPC CIDR as interim; traffic only comes from ALB in practice - rules: - ingress: - - description: From ALB only - from_port: 8000 - to_port: 8000 - protocol: tcp - cidr_blocks: - - 10.100.0.0/16 # TODO: Replace with alb-sg reference instead when supported - egress: - - description: Allow all outbound - from_port: 0 - to_port: 65535 - protocol: tcp - cidr_blocks: - - 0.0.0.0/0 - - worker-sg: - description: Security group for worker Fargate tasks - rules: - ingress: [] - egress: - - description: Allow all outbound - from_port: 0 - to_port: 65535 - protocol: tcp - cidr_blocks: - - 0.0.0.0/0 + load_balancers: + web: + rules: + ingress: + - description: HTTPS from internet + from_port: 443 + to_port: 443 + protocol: tcp + cidr_blocks: + - 0.0.0.0/0 + - description: HTTP redirect (optional) + from_port: 80 + to_port: 80 + protocol: tcp + cidr_blocks: + - 0.0.0.0/0 + egress: + - description: Allow outbound to containers + from_port: 0 + to_port: 65535 + protocol: tcp + cidr_blocks: + - 0.0.0.0/0 + versioncheck: + rules: + ingress: + - description: HTTPS from internet + from_port: 443 + to_port: 443 + protocol: tcp + cidr_blocks: + - 0.0.0.0/0 + egress: + - description: Allow outbound to containers + from_port: 0 + to_port: 65535 + protocol: tcp + cidr_blocks: + - 0.0.0.0/0 + worker: null # Workers don't have ALB + + containers: + web: + rules: + ingress: + - description: From ALB to container port + from_port: 8000 + to_port: 8000 + protocol: tcp + # source_security_group_id set dynamically in __main__.py + egress: + - description: Allow all outbound + from_port: 0 + to_port: 65535 + protocol: tcp + cidr_blocks: + - 0.0.0.0/0 + versioncheck: + rules: + ingress: + - description: From ALB to container port + from_port: 8000 + to_port: 8000 + protocol: tcp + # source_security_group_id set dynamically in __main__.py + egress: + - description: Allow all outbound + from_port: 0 + to_port: 65535 + protocol: tcp + cidr_blocks: + - 0.0.0.0/0 + worker: + rules: + ingress: [] # Workers have no inbound traffic + egress: + - description: Allow all outbound + from_port: 0 + to_port: 65535 + protocol: tcp + cidr_blocks: + - 0.0.0.0/0 # ============================================================================= # ECS Scheduled Tasks (Cron Jobs)