From 6cdddd0e68be88290f4e35f79041a8dfeefae51d Mon Sep 17 00:00:00 2001 From: Alexander Amiri Date: Thu, 26 Mar 2026 20:36:43 +0100 Subject: [PATCH 1/2] Harden ABAC with two-tier boundary and team isolation Two-tier permission boundary: - Org boundary (human-applied): structural denies for all roles - Developer boundary (CI-applied): org denies + team ABAC + naming enforcement Developer boundary adds: DenyXTeam (cross-team tag isolation), DenyShared, DenyTagStrip, DenyPassRole, DenyXLogs, DenyModCI, DenyBadName (naming), DenyNoTag (SG tag enforcement), DenySecSvcs (security + Bedrock), DenyPlatSSM, DenyPlatInfra CI role changes: - Team roles use developer boundary, AllowAll + backend (boundary does denies) - Deploy roles trust via broker only (not direct OIDC) - ECR scoped to {team}-*, PassRole to {team}-*, logs to /ecs/{team}/* - Removed redundant inline deny policies (boundary handles it) --- .github/workflows/expand-terraform.yml | 32 +- scripts/generate-modules.py | 360 ++++++++++++++++ scripts/invoke-apply-gate.sh | 6 +- scripts/invoke-ci-broker.sh | 2 +- scripts/migrate-to-modules.sh | 69 +++ scripts/registry.py | 402 ++---------------- terraform/lambda-src/apply_gate/handler.py | 15 +- terraform/lambda-src/ci_broker/handler.py | 13 +- .../lambda-src/compliance_reporter/handler.py | 4 +- terraform/lambda-src/slack_alert/handler.py | 16 + terraform/modules/platform-data/main.tf | 3 +- terraform/modules/platform-data/outputs.tf | 2 +- terraform/org/boundary.tf | 227 +++------- terraform/org/main.tf | 14 +- terraform/org/providers.tf | 2 +- terraform/org/variables.tf | 11 +- terraform/platform/iam/boundary.tf | 259 ++++++++++- terraform/platform/iam/main.tf | 242 +---------- terraform/platform/iam/outputs.tf | 7 +- terraform/platform/lambdas/main.tf | 10 +- terraform/platform/lambdas/variables.tf | 10 + terraform/platform/main.tf | 2 + terraform/platform/providers.tf | 2 +- 23 files changed, 882 insertions(+), 828 deletions(-) create mode 100755 scripts/generate-modules.py create mode 100755 scripts/migrate-to-modules.sh diff --git a/.github/workflows/expand-terraform.yml b/.github/workflows/expand-terraform.yml index 548e320..f8a0661 100644 --- a/.github/workflows/expand-terraform.yml +++ b/.github/workflows/expand-terraform.yml @@ -1,8 +1,7 @@ -name: Expand Terraform +name: Generate Terraform -# Expands app.yaml into raw Terraform resource definitions using the module -# expander. Commits the generated .tf files back to the repo so that -# tf-plan and tf-apply work on committed files — no repeated expansion. +# Generates Terraform module blocks from app.yaml. Commits the generated .tf +# files back to the repo so tf-plan and tf-apply work on committed files. on: workflow_call: @@ -19,13 +18,17 @@ on: description: "Terraform root directory" type: string default: "terraform" + platform_ref: + description: "Platform repo git ref for module sources" + type: string + default: "main" permissions: contents: write jobs: - expand: - name: Expand Terraform + generate: + name: Generate Terraform runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -46,25 +49,20 @@ jobs: repository: javaBin/platform token: ${{ steps.app-token.outputs.token }} path: .platform - sparse-checkout: | - scripts - terraform/modules - - - uses: hashicorp/setup-terraform@v4 - with: - terraform_version: "1.7" - terraform_wrapper: false + sparse-checkout: scripts - - name: Expand modules from app.yaml + - name: Generate modules from app.yaml env: APP_SERVICE: ${{ github.event.repository.name }} AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} AWS_REGION: ${{ inputs.aws_region }} TF_ROOT: ${{ inputs.tf_root }} + PLATFORM_REF: ${{ inputs.platform_ref }} PLATFORM_ROOT: .platform - run: python3 .platform/scripts/expand-modules.py + run: python3 .platform/scripts/generate-modules.py - name: Commit and push generated files env: BRANCH: ${{ github.head_ref || github.ref_name }} - run: sh .platform/scripts/commit-generated-tf.sh "${{ inputs.tf_root }}" "$BRANCH" + TF_ROOT: ${{ inputs.tf_root }} + run: sh .platform/scripts/commit-generated-tf.sh "$TF_ROOT" "$BRANCH" diff --git a/scripts/generate-modules.py b/scripts/generate-modules.py new file mode 100755 index 0000000..0db4596 --- /dev/null +++ b/scripts/generate-modules.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""Generate Terraform module blocks from app.yaml. + +Reads app.yaml and produces .tf files with module {} blocks. Terraform handles +variable resolution, cross-module references, and output wiring natively. + +Usage: + python3 generate-modules.py + +Required env vars: APP_SERVICE, AWS_ACCOUNT_ID, AWS_REGION, TF_ROOT +Optional env vars: PLATFORM_REF (git ref for module source, default: main) +""" + +import json +import os +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from registry import ( + PROJECT, DOMAIN, MODULE_SOURCES, + BACKEND_TEMPLATE, PROVIDERS_TEMPLATE, OUTPUTS_TEMPLATE, GITIGNORE_TEMPLATE, +) + +GENERATED = "# GENERATED FROM app.yaml — do not edit, changes will be overwritten\n" + +# --------------------------------------------------------------------------- +# YAML loading +# --------------------------------------------------------------------------- + +def load_yaml(path): + result = subprocess.run(["yq", "-o", "json", path], capture_output=True, text=True) + if result.returncode != 0: + try: + import yaml + with open(path) as f: + return yaml.safe_load(f) + except ImportError: + raise RuntimeError(f"Failed to parse {path}: yq not found and pyyaml not installed") + return json.loads(result.stdout) + + +def yaml_get(data, dot_path, default=None): + keys = dot_path.split(".") + val = data + for k in keys: + if isinstance(val, dict) and k in val: + val = val[k] + else: + return default + return val + + +# --------------------------------------------------------------------------- +# HCL generation helpers +# --------------------------------------------------------------------------- + +def _safe_key(k): + """Make a string safe for use as an HCL map key (quote if needed).""" + if all(c.isalnum() or c == "_" for c in k): + return k + return f'"{k}"' + + +def hcl_value(v): + """Format a Python value as HCL.""" + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, str): + if v.startswith("module.") or v.startswith("data.") or v.startswith("["): + return v # reference or list — no quoting + return f'"{v}"' + if isinstance(v, list): + items = ", ".join(hcl_value(i) for i in v) + return f"[{items}]" + if isinstance(v, dict): + if not v: + return "{}" + lines = [] + for k, val in v.items(): + lines.append(f' {_safe_key(k)} = {hcl_value(val)}') + return "{\n" + "\n".join(lines) + "\n }" + return str(v) + + +def module_block(name, source, attrs): + """Generate a module {} block.""" + lines = [f'module "{name}" {{', f' source = "{source}"', ""] + for k, v in attrs.items(): + lines.append(f" {k:30s} = {hcl_value(v)}") + lines.append("}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Main generation +# --------------------------------------------------------------------------- + +def generate(app, account_id, region, platform_ref): + """Generate all .tf file contents from app.yaml data.""" + name = app["name"] + team = app["team"] + full_name = f"{team}-{name}" + repo = os.environ.get("GITHUB_REPOSITORY", f"javaBin/{name}") + + compute = app.get("compute", {}) + routing = app.get("routing", {}) + resources = app.get("resources", {}) + alarms_cfg = app.get("alarms", {}) + + def src(mod): + path = MODULE_SOURCES[mod] + return f"git::https://github.com/javaBin/platform//{path}?ref={platform_ref}" + + files = {} + + # -- backend.tf -- + files["backend.tf"] = BACKEND_TEMPLATE.format( + project=PROJECT, account_id=account_id, team=team, + service=name, region=region, + ) + + # -- providers.tf -- + files["providers.tf"] = PROVIDERS_TEMPLATE.format( + region=region, team=team, service=name, repo=repo, + ) + + # -- .gitignore -- + files[".gitignore"] = GITIGNORE_TEMPLATE + + # -- main.tf -- + blocks = [] + + # Platform data (always) + blocks.append(module_block("platform", src("platform-data"), { + "project": PROJECT, + "domain": DOMAIN, + })) + + # ECR (always) + blocks.append(module_block("ecr", src("ecr-repo"), { + "name": name, + "team": team, + })) + + # Routing (if host specified) + if routing.get("host"): + blocks.append(module_block("routing", src("service-routing"), { + "name": name, + "team": team, + "vpc_id": "module.platform.vpc_id", + "port": compute.get("port", 8000), + "health_check_path": compute.get("health_check", "/health"), + "health_check_matcher": compute.get("health_check_matcher", "200"), + "https_listener_arn": "module.platform.https_listener_arn", + "listener_rule_priority": routing["priority"], + "host_header": routing["host"], + "route53_zone_id": "module.platform.route53_zone_id", + "alb_dns_name": "module.platform.alb_dns_name", + "alb_zone_id": "module.platform.alb_zone_id", + "deregistration_delay": routing.get("deregistration_delay", 30), + })) + + # Collection modules (buckets, databases, secrets, queues) + policy_map = {} # name -> module output ref for access_policy_json + env_map = {} # env var name -> module output ref + secret_map = {} # env var name -> module output ref + + for bucket in resources.get("buckets", []): + bname = bucket["name"] + mod_name = f"bucket_{bname}" + blocks.append(module_block(mod_name, src("service-bucket"), { + "name": bname, + "team": team, + "aws_account_id": account_id, + "service": name, + "versioning": bucket.get("versioning", True), + "expire_days": bucket.get("expire_days", 0), + })) + policy_map[mod_name] = f"module.{mod_name}.access_policy_json" + if bucket.get("env"): + env_map[bucket["env"]] = f"module.{mod_name}.bucket_name" + + for db in resources.get("databases", []): + dname = db["name"] + engine = (db.get("engine") or "dynamodb").lower() + if engine in ("postgres", "postgresql"): + mod_name = f"rds_{dname}" + blocks.append(module_block(mod_name, src("service-rds"), { + "name": dname, + "team": team, + "project": PROJECT, + "engine_version": db.get("engine_version", "16"), + "instance_class": db.get("instance_class", "db.t3.micro"), + "allocated_storage": db.get("allocated_storage", 20), + "subnet_ids": "module.platform.private_subnet_ids", + "vpc_id": "module.platform.vpc_id", + "allowed_security_group_ids": "[module.platform.ecs_tasks_security_group_id]", + "backup_retention_period": db.get("backup_retention_period", 7), + "multi_az": db.get("multi_az", False), + "deletion_protection": db.get("deletion_protection", True), + })) + policy_map[mod_name] = f"module.{mod_name}.access_policy_json" + if db.get("env"): + env_map[db["env"]] = f"module.{mod_name}.endpoint" + else: + mod_name = f"database_{dname}" + attrs = { + "name": dname, + "team": team, + "service": name, + "hash_key": db.get("hash_key", "id"), + "hash_key_type": db.get("hash_key_type", "S"), + } + if db.get("range_key"): + attrs["range_key"] = db["range_key"] + attrs["range_key_type"] = db.get("range_key_type", "S") + if db.get("ttl_attribute"): + attrs["ttl_attribute"] = db["ttl_attribute"] + blocks.append(module_block(mod_name, src("service-database"), attrs)) + policy_map[mod_name] = f"module.{mod_name}.access_policy_json" + if db.get("env"): + env_map[db["env"]] = f"module.{mod_name}.table_name" + + for secret in resources.get("secrets", []): + sname = secret["name"] + mod_name = f"secret_{sname}" + blocks.append(module_block(mod_name, src("service-secret"), { + "name": sname, + "team": team, + "project": PROJECT, + "service": name, + "description": secret.get("description", ""), + })) + policy_map[mod_name] = f"module.{mod_name}.access_policy_json" + if secret.get("env"): + secret_map[secret["env"]] = f"module.{mod_name}.parameter_arn" + + for queue in resources.get("queues", []): + qname = queue["name"] + mod_name = f"queue_{qname}" + blocks.append(module_block(mod_name, src("service-queue"), { + "name": qname, + "team": team, + "service": name, + "visibility_timeout_seconds": queue.get("visibility_timeout", 30), + "retention_seconds": queue.get("retention_seconds", 345600), + "max_receive_count": queue.get("max_receive_count", 3), + })) + policy_map[mod_name] = f"module.{mod_name}.access_policy_json" + if queue.get("env"): + env_map[queue["env"]] = f"module.{mod_name}.queue_url" + + # Task role (always) + blocks.append(module_block("task_role", src("service-role"), { + "name": name, + "team": team, + "region": region, + "aws_account_id": account_id, + "permissions_boundary_arn": "module.platform.developer_boundary_arn", + "additional_policy_jsons": policy_map if policy_map else {}, + })) + + # Merge static env vars with resource-derived env vars + static_env = app.get("environment", {}) or {} + all_env = {**static_env} + for k, v in env_map.items(): + all_env[k] = v + + # ECS service (always) + service_attrs = { + "name": name, + "team": team, + "cluster_id": "module.platform.ecs_cluster_id", + "image": 'module.ecr.repository_url', + "cpu": compute.get("cpu", 512), + "memory": compute.get("memory", 1024), + "port": compute.get("port", 8000), + "desired_count": compute.get("desired_count", 1), + "execution_role_arn": "module.platform.execution_role_arn", + "task_role_arn": "module.task_role.role_arn", + "subnet_ids": "module.platform.private_subnet_ids", + "security_group_ids": "[module.platform.ecs_tasks_security_group_id]", + "region": region, + "container_user": str(compute.get("user", "1000")), + } + if routing.get("host"): + service_attrs["target_group_arn"] = "module.routing.target_group_arn" + if all_env: + service_attrs["environment"] = all_env + if secret_map: + service_attrs["secrets"] = secret_map + + blocks.append(module_block("service", src("ecs-service"), service_attrs)) + + # Alarms (conditional) + alarms_enabled = alarms_cfg.get("enabled", True) if alarms_cfg else True + if alarms_enabled and routing.get("host"): + blocks.append(module_block("alarms", src("service-alarm"), { + "name": name, + "team": team, + "service": name, + "cluster_name": "module.platform.ecs_cluster_name", + "sns_topic_arns": f'[data.aws_sns_topic.alerts.arn]', + "cpu_threshold": alarms_cfg.get("cpu_threshold", 80), + "memory_threshold": alarms_cfg.get("memory_threshold", 80), + "error_5xx_threshold": alarms_cfg.get("error_5xx_threshold", 10), + "target_group_arn_suffix": "module.routing.target_group_arn_suffix", + "alb_arn_suffix": "module.platform.alb_arn_suffix", + })) + # Add the data source for SNS topic + blocks.append(f""" +data "aws_sns_topic" "alerts" {{ + name = "{PROJECT}-alerts" +}}""") + + files["main.tf"] = GENERATED + "\n\n".join(blocks) + "\n" + + # -- outputs.tf -- + host = routing.get("host", f"{name}.{DOMAIN}") + files["outputs.tf"] = OUTPUTS_TEMPLATE.format(host=host) + + return files + + +def main(): + service = os.environ["APP_SERVICE"] + account_id = os.environ["AWS_ACCOUNT_ID"] + region = os.environ.get("AWS_REGION", "eu-central-1") + tf_root = os.environ.get("TF_ROOT", "terraform") + platform_ref = os.environ.get("PLATFORM_REF", "main") + + app_yaml_path = os.path.join(os.environ.get("GITHUB_WORKSPACE", "."), "app.yaml") + if not os.path.exists(app_yaml_path): + print(f"No app.yaml found at {app_yaml_path}") + sys.exit(1) + + app = load_yaml(app_yaml_path) + if not app.get("name"): + app["name"] = service + if not app.get("team"): + print("ERROR: app.yaml must have a 'team' field") + sys.exit(1) + + files = generate(app, account_id, region, platform_ref) + + os.makedirs(tf_root, exist_ok=True) + for filename, content in files.items(): + path = os.path.join(tf_root, filename) + with open(path, "w") as f: + f.write(content) + print(f" wrote {path}") + + print(f"Generated {len(files)} files in {tf_root}/") + + +if __name__ == "__main__": + main() diff --git a/scripts/invoke-apply-gate.sh b/scripts/invoke-apply-gate.sh index 6d2a2de..8e9f8d3 100644 --- a/scripts/invoke-apply-gate.sh +++ b/scripts/invoke-apply-gate.sh @@ -21,11 +21,15 @@ fi echo "Requesting apply credentials from gate Lambda..." +# Build session name from GitHub context for alert attribution +GATE_SESSION="${GITHUB_ACTOR:-unknown}-$(echo "${GITHUB_SHA:-00000000}" | cut -c1-8)-${GITHUB_RUN_ID:-0}" + PAYLOAD=$(jq -n \ --arg action "check" \ --arg plan_key "$PLAN_KEY" \ --arg repo_name "$REPO_NAME" \ - '{action: $action, plan_key: $plan_key, repo_name: $repo_name}') + --arg session_name "$GATE_SESSION" \ + '{action: $action, plan_key: $plan_key, repo_name: $repo_name, session_name: $session_name}') RESPONSE=$(aws lambda invoke \ --function-name "$LAMBDA_NAME" \ diff --git a/scripts/invoke-ci-broker.sh b/scripts/invoke-ci-broker.sh index 048aa6f..279c32e 100755 --- a/scripts/invoke-ci-broker.sh +++ b/scripts/invoke-ci-broker.sh @@ -17,7 +17,7 @@ echo "Requesting ${ACTION} credentials for ${REPO}..." RESPONSE=$(aws lambda invoke \ --function-name "${PROJECT:-javabin}-ci-broker" \ - --payload "$(printf '{"repo":"%s","action":"%s"}' "$REPO" "$ACTION")" \ + --payload "$(printf '{"repo":"%s","action":"%s","session_name":"%s"}' "$REPO" "$ACTION" "${SESSION_NAME:-}")" \ --cli-binary-format raw-in-base64-out \ /tmp/broker-response.json 2>&1) diff --git a/scripts/migrate-to-modules.sh b/scripts/migrate-to-modules.sh new file mode 100755 index 0000000..6216300 --- /dev/null +++ b/scripts/migrate-to-modules.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# Migrate Terraform state from expanded raw resources to module blocks. +# +# Run from the app's terraform/ directory AFTER generating module blocks +# with generate-modules.py. Creates a state backup first. +# +# Usage: +# cd myapp/terraform +# sh /path/to/migrate-to-modules.sh +# +# After migration, run `terraform plan` to verify zero changes. + +set -e + +echo "=== Creating state backup ===" +terraform state pull > terraform.tfstate.backup.json +echo " Backed up to terraform.tfstate.backup.json" + +echo "" +echo "=== Migrating state ===" + +# Helper: move state if source exists +move() { + if terraform state show "$1" >/dev/null 2>&1; then + echo " $1 -> $2" + terraform state mv "$1" "$2" + else + echo " skip $1 (not in state)" + fi +} + +# Platform data sources +move "data.aws_vpc.platform_main" "module.platform.data.aws_vpc.main" +move "data.aws_subnets.platform_private" "module.platform.data.aws_subnets.private" +move "data.aws_subnets.platform_public" "module.platform.data.aws_subnets.public" +move "data.aws_security_group.platform_ecs_tasks" "module.platform.data.aws_security_group.ecs_tasks" +move "data.aws_lb.platform_main" "module.platform.data.aws_lb.main" +move "data.aws_lb_listener.platform_https" "module.platform.data.aws_lb_listener.https" +move "data.aws_ecs_cluster.platform_main" "module.platform.data.aws_ecs_cluster.main" +move "data.aws_iam_role.platform_ecs_execution" "module.platform.data.aws_iam_role.ecs_execution" +move "data.aws_route53_zone.platform_main" "module.platform.data.aws_route53_zone.main" + +# ECR +move "aws_ecr_repository.ecr" "module.ecr.aws_ecr_repository.this" +move "aws_ecr_lifecycle_policy.ecr" "module.ecr.aws_ecr_lifecycle_policy.this" + +# Routing +move "aws_lb_target_group.routing" "module.routing.aws_lb_target_group.this" +move "aws_lb_listener_rule.routing" "module.routing.aws_lb_listener_rule.this" +move "aws_route53_record.routing" "module.routing.aws_route53_record.this" + +# Task role +move "aws_iam_role.task" "module.task_role.aws_iam_role.this" +move "aws_iam_role_policy.task_logs" "module.task_role.aws_iam_role_policy.logs" + +# ECS service +move "aws_cloudwatch_log_group.main" "module.service.aws_cloudwatch_log_group.this" +move "aws_ecs_task_definition.main" "module.service.aws_ecs_task_definition.this" +move "aws_ecs_service.main" "module.service.aws_ecs_service.this" + +# Alarms (if present) +move "aws_cloudwatch_metric_alarm.alarm_cpu" "module.alarms.aws_cloudwatch_metric_alarm.cpu" +move "aws_cloudwatch_metric_alarm.alarm_memory" "module.alarms.aws_cloudwatch_metric_alarm.memory" +move "aws_cloudwatch_metric_alarm.alarm_unhealthy_targets" "module.alarms.aws_cloudwatch_metric_alarm.unhealthy_targets" +move "aws_cloudwatch_metric_alarm.alarm_target_5xx" "module.alarms.aws_cloudwatch_metric_alarm.target_5xx" + +echo "" +echo "=== Migration complete ===" +echo "Run 'terraform plan' to verify zero changes." diff --git a/scripts/registry.py b/scripts/registry.py index 46d21f9..a69f9d4 100644 --- a/scripts/registry.py +++ b/scripts/registry.py @@ -1,8 +1,9 @@ -"""Module registry — maps app.yaml sections to platform Terraform modules. +"""Module registry — constants and templates for Terraform generation. -This is the ONLY place modules are defined. Adding a new resource type to the -platform means adding a registry entry here. The expander (expand-modules.py) -reads this registry and the module source files to generate expanded Terraform. +Used by generate-modules.py to produce module {} blocks from app.yaml. +The old expand-modules.py used the REGISTRY list and DSL expressions — that +approach is deprecated. Module blocks let Terraform handle variable resolution +and cross-module references natively. """ # --------------------------------------------------------------------------- @@ -11,382 +12,27 @@ PROJECT = "javabin" DOMAIN = "javazone.no" -MODULE_ROOT = "terraform/modules" # --------------------------------------------------------------------------- -# Registry entries -# -# Each entry describes one module and how it maps to app.yaml: -# -# id Unique key, used for resource renaming and cross-references -# module_path Path to module source (relative to platform repo root) -# output_file Generated .tf file name in the app repo -# cardinality "singleton" (one per app) or "collection" (N from YAML list) -# condition If set, a DSL expression that must be truthy to emit this module -# vars Dict of module_variable -> value expression (DSL) -# rename Resource name to replace "this" with (singletons) -# output_map Maps module output names -> expanded resource attribute paths -# constructs Hints for complex HCL constructs (for_each, dynamic, count) -# yaml_list (collections) Dot-path to the YAML array -# instance_key (collections) YAML field used as instance name -# exports (collections) What this module contributes to cross-wiring +# Module source paths (relative to platform repo root) # --------------------------------------------------------------------------- -REGISTRY = [ - # ------------------------------------------------------------------ - # platform-data: shared infrastructure data sources (always emitted) - # ------------------------------------------------------------------ - { - "id": "platform", - "module_path": f"{MODULE_ROOT}/platform-data", - "output_file": "platform.tf", - "cardinality": "singleton", - "vars": { - "project": f"const:{PROJECT}", - "domain": f"const:{DOMAIN}", - }, - "rename": "platform", - "output_map": { - "vpc_id": "data.aws_vpc.platform_main.id", - "private_subnet_ids": "data.aws_subnets.platform_private.ids", - "public_subnet_ids": "data.aws_subnets.platform_public.ids", - "ecs_tasks_security_group_id": "data.aws_security_group.platform_ecs_tasks.id", - "alb_arn": "data.aws_lb.platform_main.arn", - "alb_dns_name": "data.aws_lb.platform_main.dns_name", - "alb_zone_id": "data.aws_lb.platform_main.zone_id", - "alb_arn_suffix": "data.aws_lb.platform_main.arn_suffix", - "https_listener_arn": "data.aws_lb_listener.platform_https.arn", - "ecs_cluster_id": "data.aws_ecs_cluster.platform_main.id", - "ecs_cluster_name": "data.aws_ecs_cluster.platform_main.cluster_name", - "execution_role_arn": "data.aws_iam_role.platform_ecs_execution.arn", - "route53_zone_id": "data.aws_route53_zone.platform_main.zone_id", - # Boundary ARN constructed from account ID — no data source needed. - # Avoids iam:GetPolicy permission requirement on the boundary policy. - "developer_boundary_arn": "NOT_USED", - }, - }, - - # ------------------------------------------------------------------ - # ecr-repo: container registry - # ------------------------------------------------------------------ - { - "id": "ecr", - "module_path": f"{MODULE_ROOT}/ecr-repo", - "output_file": "ecr.tf", - "cardinality": "singleton", - "vars": { - "name": "yaml:name", - "team": "yaml:team", - }, - "rename": "ecr", - "output_map": { - "repository_url": "aws_ecr_repository.ecr.repository_url", - "repository_arn": "aws_ecr_repository.ecr.arn", - "repository_name": "aws_ecr_repository.ecr.name", - }, - }, - - # ------------------------------------------------------------------ - # service-routing: ALB target group + listener rule + DNS - # ------------------------------------------------------------------ - { - "id": "routing", - "module_path": f"{MODULE_ROOT}/service-routing", - "output_file": "routing.tf", - "cardinality": "singleton", - "vars": { - "name": "yaml:name", - "team": "yaml:team", - "vpc_id": "ref:platform.vpc_id", - "port": "yaml:compute.port|default:8000", - "health_check_path": "yaml:compute.health_check|default:/health", - "health_check_matcher": "yaml:compute.health_check_matcher|default:200", - "https_listener_arn": "ref:platform.https_listener_arn", - "listener_rule_priority": "yaml:routing.priority", - "host_header": "yaml:routing.host", - "route53_zone_id": "ref:platform.route53_zone_id", - "alb_dns_name": "ref:platform.alb_dns_name", - "alb_zone_id": "ref:platform.alb_zone_id", - "deregistration_delay": "yaml:routing.deregistration_delay|default:30", - }, - "rename": "routing", - "output_map": { - "target_group_arn": "aws_lb_target_group.routing.arn", - "target_group_arn_suffix": "aws_lb_target_group.routing.arn_suffix", - "dns_name": "aws_route53_record.routing.fqdn", - }, - }, - - # ------------------------------------------------------------------ - # service-role: task IAM role + policies (ECS, Lambda, or EC2) - # ------------------------------------------------------------------ - { - "id": "task_role", - "module_path": f"{MODULE_ROOT}/service-role", - "output_file": "iam.tf", - "cardinality": "singleton", - "vars": { - "name": "yaml:name", - "team": "yaml:team", - "region": "env:AWS_REGION", - "aws_account_id": "env:AWS_ACCOUNT_ID", - "permissions_boundary_arn": f"expr:arn:aws:iam::${{env:AWS_ACCOUNT_ID}}:policy/{PROJECT}-developer-boundary", - "trusted_services": "list:yaml:compute.trusted_service|default:ecs-tasks.amazonaws.com", - "additional_policy_jsons": "collect:access_policy_json", - }, - "rename": "task", - "output_map": { - "role_arn": "aws_iam_role.task.arn", - "role_id": "aws_iam_role.task.id", - "role_name": "aws_iam_role.task.name", - }, - "constructs": { - # for_each on additional_policy_jsons: expand into N named resources - "for_each_expand": { - "resource": "aws_iam_role_policy.additional", - "variable": "additional_policy_jsons", - }, - }, - }, - - # ------------------------------------------------------------------ - # ecs-service: log group + task definition + ECS service - # ------------------------------------------------------------------ - { - "id": "service", - "module_path": f"{MODULE_ROOT}/ecs-service", - "output_file": "service.tf", - "cardinality": "singleton", - "vars": { - "name": "yaml:name", - "team": "yaml:team", - "cluster_id": "ref:platform.ecs_cluster_id", - "image": "expr:${ref:ecr.repository_url}:latest", - "cpu": "yaml:compute.cpu|default:512", - "memory": "yaml:compute.memory|default:1024", - "port": "yaml:compute.port|default:8000", - "desired_count": "yaml:compute.desired_count|default:1", - "execution_role_arn": "ref:platform.execution_role_arn", - "task_role_arn": "ref:task_role.role_arn", - "subnet_ids": "ref:platform.private_subnet_ids", - "security_group_ids": "list:ref:platform.ecs_tasks_security_group_id", - "target_group_arn": "ref:routing.target_group_arn", - "region": "env:AWS_REGION", - "container_user": "yaml:compute.user|default:1000", - "environment": "collect:env_vars", - "secrets": "collect:secret_vars", - }, - "rename": "main", - "output_map": { - "service_name": "aws_ecs_service.main.name", - "task_definition_arn": "aws_ecs_task_definition.main.arn", - "log_group_name": "aws_cloudwatch_log_group.main.name", - }, - }, - - # ------------------------------------------------------------------ - # service-alarm: CloudWatch alarms (conditional) - # ------------------------------------------------------------------ - { - "id": "alarms", - "module_path": f"{MODULE_ROOT}/service-alarm", - "output_file": "alarms.tf", - "cardinality": "singleton", - "condition": "yaml:alarms.enabled|default:true", - "vars": { - "name": "yaml:name", - "team": "yaml:team", - "service": "yaml:name", - "cluster_name": "ref:platform.ecs_cluster_name", - "sns_topic_arns": "list:data.aws_sns_topic.alerts.arn", - "cpu_threshold": "yaml:alarms.cpu_threshold|default:80", - "memory_threshold": "yaml:alarms.memory_threshold|default:80", - "error_5xx_threshold": "yaml:alarms.error_5xx_threshold|default:10", - "enable_alb_alarms": "const:true", - "target_group_arn_suffix": "ref:routing.target_group_arn_suffix", - "alb_arn_suffix": "ref:platform.alb_arn_suffix", - }, - "rename": "alarm", - "extra_data_sources": [ - { - "type": "aws_sns_topic", - "name": "alerts", - "body": f'name = "{PROJECT}-alerts"', - }, - ], - "constructs": { - # count-based conditional resources: resolve at gen time - "count_resolve": { - "variable": "enable_alb_alarms", - "resources": ["aws_cloudwatch_metric_alarm.unhealthy_targets", - "aws_cloudwatch_metric_alarm.target_5xx"], - }, - }, - }, - - # ------------------------------------------------------------------ - # Collection modules: one set of resources per YAML list item - # ------------------------------------------------------------------ - { - "id": "bucket", - "module_path": f"{MODULE_ROOT}/service-bucket", - "output_file": "buckets.tf", - "cardinality": "collection", - "yaml_list": "resources.buckets", - "instance_key": "name", - "vars": { - "name": "item:name", - "team": "yaml:team", - "aws_account_id": "env:AWS_ACCOUNT_ID", - "service": "yaml:name", - "versioning": "item:versioning|default:true", - "expire_days": "item:expire_days|default:0", - }, - "rename": "bucket", - "output_map": { - "bucket_name": "aws_s3_bucket.{instance}.id", - "bucket_arn": "aws_s3_bucket.{instance}.arn", - "access_policy_json": "data.aws_iam_policy_document.{instance}_access.json", - }, - "exports": { - "access_policy_json": True, - "env_var": {"output": "bucket_name", "yaml_field": "env", "target": "environment"}, - }, - "constructs": { - "count_resolve": { - "variable": "expire_days", - "condition": "greater_than_zero", - "resources": ["aws_s3_bucket_lifecycle_configuration.this"], - }, - }, - }, - { - "id": "database", - "module_path": f"{MODULE_ROOT}/service-database", - "output_file": "databases.tf", - "cardinality": "collection", - "yaml_list": "resources.databases", - "instance_key": "name", - "engine_filter": "dynamodb", - "vars": { - "name": "item:name", - "team": "yaml:team", - "service": "yaml:name", - "hash_key": "item:hash_key|default:id", - "hash_key_type": "item:hash_key_type|default:S", - "range_key": "item:range_key|default:null", - "range_key_type": "item:range_key_type|default:S", - "ttl_attribute": "item:ttl_attribute|default:", - }, - "rename": "db", - "output_map": { - "table_name": "aws_dynamodb_table.{instance}.name", - "table_arn": "aws_dynamodb_table.{instance}.arn", - "access_policy_json": "data.aws_iam_policy_document.{instance}_access.json", - }, - "exports": { - "access_policy_json": True, - "env_var": {"output": "table_name", "yaml_field": "env", "target": "environment"}, - }, - "constructs": { - "dynamic_resolve": { - "variable": "range_key", - "block_name": "attribute", - "condition": "not_null", - }, - }, - }, - { - "id": "rds", - "module_path": f"{MODULE_ROOT}/service-rds", - "output_file": "databases.tf", - "cardinality": "collection", - "yaml_list": "resources.databases", - "instance_key": "name", - "engine_filter": "postgres", - "vars": { - "name": "item:name", - "team": "yaml:team", - "project": f"const:{PROJECT}", - "engine_version": "item:engine_version|default:16", - "instance_class": "item:instance_class|default:db.t3.micro", - "allocated_storage": "item:allocated_storage|default:20", - "subnet_ids": "ref:platform.private_subnet_ids", - "vpc_id": "ref:platform.vpc_id", - "allowed_security_group_ids": "list:ref:platform.ecs_tasks_security_group_id", - "backup_retention_period": "item:backup_retention_period|default:7", - "multi_az": "item:multi_az|default:false", - "deletion_protection": "item:deletion_protection|default:true", - }, - "rename": "rds", - "output_map": { - "endpoint": "aws_db_instance.{instance}.endpoint", - "port": "aws_db_instance.{instance}.port", - "db_name": "aws_db_instance.{instance}.db_name", - "access_policy_json": "data.aws_iam_policy_document.{instance}_access.json", - "security_group_id": "aws_security_group.{instance}.id", - }, - "exports": { - "access_policy_json": True, - "env_var": {"output": "endpoint", "yaml_field": "env", "target": "environment"}, - }, - }, - { - "id": "secret", - "module_path": f"{MODULE_ROOT}/service-secret", - "output_file": "secrets.tf", - "cardinality": "collection", - "yaml_list": "resources.secrets", - "instance_key": "name", - "vars": { - "name": "item:name", - "team": "yaml:team", - "project": f"const:{PROJECT}", - "service": "yaml:name", - "description": "item:description|default:", - }, - "rename": "secret", - "output_map": { - "parameter_arn": "aws_ssm_parameter.{instance}.arn", - "access_policy_json": "data.aws_iam_policy_document.{instance}_access.json", - }, - "exports": { - "access_policy_json": True, - "env_var": {"output": "parameter_arn", "yaml_field": "env", "target": "secrets"}, - }, - }, - { - "id": "queue", - "module_path": f"{MODULE_ROOT}/service-queue", - "output_file": "queues.tf", - "cardinality": "collection", - "yaml_list": "resources.queues", - "instance_key": "name", - "vars": { - "name": "item:name", - "team": "yaml:team", - "service": "yaml:name", - "visibility_timeout_seconds": "item:visibility_timeout|default:30", - "retention_seconds": "item:retention_seconds|default:345600", - "max_receive_count": "item:max_receive_count|default:3", - }, - "rename": "queue", - "output_map": { - "queue_url": "aws_sqs_queue.{instance}.url", - "queue_arn": "aws_sqs_queue.{instance}.arn", - "dlq_url": "aws_sqs_queue.{instance}_dlq.url", - "access_policy_json": "data.aws_iam_policy_document.{instance}_access.json", - }, - "exports": { - "access_policy_json": True, - "env_var": {"output": "queue_url", "yaml_field": "env", "target": "environment"}, - }, - }, -] - +MODULE_SOURCES = { + "platform-data": "terraform/modules/platform-data", + "ecr-repo": "terraform/modules/ecr-repo", + "service-routing": "terraform/modules/service-routing", + "service-role": "terraform/modules/service-role", + "ecs-service": "terraform/modules/ecs-service", + "service-alarm": "terraform/modules/service-alarm", + "service-bucket": "terraform/modules/service-bucket", + "service-database": "terraform/modules/service-database", + "service-rds": "terraform/modules/service-rds", + "service-secret": "terraform/modules/service-secret", + "service-queue": "terraform/modules/service-queue", +} # --------------------------------------------------------------------------- -# Boilerplate templates (not module-derived) +# Boilerplate templates # --------------------------------------------------------------------------- BACKEND_TEMPLATE = """\ @@ -448,18 +94,18 @@ }} output "ecr_url" {{ - value = aws_ecr_repository.ecr.repository_url + value = module.ecr.repository_url }} output "service_name" {{ - value = aws_ecs_service.main.name + value = module.service.service_name }} output "task_role_arn" {{ - value = aws_iam_role.task.arn + value = module.task_role.role_arn }} output "log_group" {{ - value = aws_cloudwatch_log_group.main.name + value = module.service.log_group_name }} """ diff --git a/terraform/lambda-src/apply_gate/handler.py b/terraform/lambda-src/apply_gate/handler.py index f929954..c83717b 100644 --- a/terraform/lambda-src/apply_gate/handler.py +++ b/terraform/lambda-src/apply_gate/handler.py @@ -88,6 +88,7 @@ def action_check(event): """ plan_key = event["plan_key"] repo_name = event["repo_name"] + session_name = event.get("session_name") prefix = _plan_prefix(plan_key) # Read risk assessment @@ -103,7 +104,7 @@ def action_check(event): # LOW/MEDIUM — auto-approve if risk_level in ("LOW", "MEDIUM"): - credentials = _issue_credentials(repo_name) + credentials = _issue_credentials(repo_name, session_name) return { "approved": True, "risk_level": risk_level, @@ -204,7 +205,14 @@ def action_status(event): } -def _issue_credentials(repo_name): +def _sanitize_session_name(name): + """Sanitize a string for use as STS RoleSessionName (max 64 chars, [\\w+=,.@-]).""" + import re + sanitized = re.sub(r"[^\w+=,.@-]", "-", name) + return sanitized[:64] if sanitized else "apply-gate" + + +def _issue_credentials(repo_name, session_name=None): """Resolve team from GitHub and assume the team's CI role.""" account_id = os.environ.get("ACCOUNT_ID", "") @@ -218,9 +226,10 @@ def _issue_credentials(repo_name): role_arn = f"arn:aws:iam::{account_id}:role/{PROJECT}-ci-team-{team}" logger.info("Assuming team role %s for repo %s", role_arn, repo_name) + role_session = _sanitize_session_name(session_name) if session_name else f"apply-gate-{repo_name}" resp = sts.assume_role( RoleArn=role_arn, - RoleSessionName=f"apply-gate-{repo_name}", + RoleSessionName=role_session, DurationSeconds=CREDENTIAL_DURATION, ) diff --git a/terraform/lambda-src/ci_broker/handler.py b/terraform/lambda-src/ci_broker/handler.py index 04712da..2629d66 100644 --- a/terraform/lambda-src/ci_broker/handler.py +++ b/terraform/lambda-src/ci_broker/handler.py @@ -35,11 +35,18 @@ } +def _sanitize_session_name(name): + """Sanitize a string for use as STS RoleSessionName (max 64 chars, [\w+=,.@-]).""" + import re + sanitized = re.sub(r"[^\w+=,.@-]", "-", name) + return sanitized[:64] if sanitized else "ci-broker" + + def _assume_role(role_arn, session_name, duration): """Assume an IAM role and return temporary credentials.""" resp = sts.assume_role( RoleArn=role_arn, - RoleSessionName=session_name, + RoleSessionName=_sanitize_session_name(session_name), DurationSeconds=duration, ) creds = resp["Credentials"] @@ -75,8 +82,10 @@ def handler(event, context): role_arn = f"arn:aws:iam::{ACCOUNT_ID}:role/{role_prefix}{team}" duration = PLAN_DURATION if action == "plan" else DEPLOY_DURATION + session_name = event.get("session_name") or f"ci-broker-{repo}" + try: - credentials = _assume_role(role_arn, f"ci-broker-{repo}", duration) + credentials = _assume_role(role_arn, session_name, duration) except Exception as e: logger.error("Failed to assume %s: %s", role_arn, e) return { diff --git a/terraform/lambda-src/compliance_reporter/handler.py b/terraform/lambda-src/compliance_reporter/handler.py index 98828f7..ab00b04 100644 --- a/terraform/lambda-src/compliance_reporter/handler.py +++ b/terraform/lambda-src/compliance_reporter/handler.py @@ -28,7 +28,7 @@ if s.strip() ] -REQUIRED_TAGS = {"project", "team", "managed-by"} +REQUIRED_TAGS = {"team", "service", "managed-by"} # AWS clients ec2 = boto3.client("ec2") @@ -226,7 +226,7 @@ def report_to_slack(untagged_resources, event_name, caller_arn): {"type": "section", "text": {"type": "mrkdwn", "text": f"*{len(untagged_resources)}* resource(s) created by `{event_name}` " - f"are missing required tags (`project`, `team`, `managed-by`)."}}, + f"are missing required tags (`team`, `service`, `managed-by`)."}}, {"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(resource_lines)}}, diff --git a/terraform/lambda-src/slack_alert/handler.py b/terraform/lambda-src/slack_alert/handler.py index e468600..9b70bb8 100644 --- a/terraform/lambda-src/slack_alert/handler.py +++ b/terraform/lambda-src/slack_alert/handler.py @@ -380,6 +380,22 @@ def extract_resource_name(event_name, detail): response = detail.get("responseElements") or {} request = detail.get("requestParameters") or {} + # Event-specific extractors (before generic field loop) + if event_name == "CreateTags": + resources = request.get("resourcesSet", {}).get("items", []) + if resources: + return resources[0].get("resourceId") + + if event_name == "UpdateService": + svc = request.get("service", "") + if svc: + return svc.split("/")[-1] if "/" in svc else svc + + if event_name == "ModifyTargetGroupAttributes": + tg_arn = request.get("targetGroupArn", "") + if "targetgroup/" in tg_arn: + return tg_arn.split("targetgroup/")[1].split("/")[0] + name_fields = [ "bucketName", "functionName", "clusterName", "serviceName", "dBInstanceIdentifier", "tableName", "loadBalancerName", diff --git a/terraform/modules/platform-data/main.tf b/terraform/modules/platform-data/main.tf index 7d62ff4..9228dc2 100644 --- a/terraform/modules/platform-data/main.tf +++ b/terraform/modules/platform-data/main.tf @@ -68,5 +68,4 @@ data "aws_route53_zone" "main" { } -# Note: the developer boundary ARN is constructed directly by expand-modules.py -# instead of using a data source, to avoid needing iam:GetPolicy permission. +data "aws_caller_identity" "current" {} diff --git a/terraform/modules/platform-data/outputs.tf b/terraform/modules/platform-data/outputs.tf index 5f9580f..068bc7f 100644 --- a/terraform/modules/platform-data/outputs.tf +++ b/terraform/modules/platform-data/outputs.tf @@ -80,5 +80,5 @@ output "project" { output "developer_boundary_arn" { description = "Permission boundary ARN for service roles" - value = data.aws_iam_policy.developer_boundary.arn + value = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/${var.project}-developer-boundary" } diff --git a/terraform/org/boundary.tf b/terraform/org/boundary.tf index 36cbf55..3a0b015 100644 --- a/terraform/org/boundary.tf +++ b/terraform/org/boundary.tf @@ -1,22 +1,21 @@ ################################################################################ -# Permission Boundary: javabin-developer-boundary +# Permission Boundary: javabin-org-boundary # -# This is the cornerstone of the IAM security model. Any role carrying this -# boundary can only create roles that also carry it (self-replicating). +# Structural safety net for ALL roles in the account. Carried by platform +# roles (infra, plan, override-approver, registry, apply-gate) and cross-team +# Lambda roles (resource-tagger, budget-enforcer, apply-gate, ci-broker). # -# The boundary defines the maximum possible permissions for any non-platform -# role. Hard denies here cannot be overridden by any policy attached to -# roles that carry this boundary. +# Contains ONLY account-level structural denies — things that are dangerous +# regardless of team scope. Team ABAC isolation lives in the developer +# boundary (terraform/platform/iam/boundary.tf), which is a strict superset. # -# IMPORTANT: This resource lives in terraform/org/ (human-applied) because -# the boundary's self-protection denies iam:CreatePolicyVersion on itself. -# The CI pipeline (which carries the boundary) cannot modify it. +# IMPORTANT: Human-applied. The self-protection deny prevents any role +# carrying this boundary from modifying it. ################################################################################ -resource "aws_iam_policy" "developer_boundary" { - name = "${var.project}-developer-boundary" - description = "Permission boundary for all non-platform roles. Self-replicating: roles with this boundary can only create roles that also carry it." - +resource "aws_iam_policy" "org_boundary" { + name = "${var.project}-org-boundary" + description = "Structural permission boundary for platform roles. Denies account-level dangerous operations." policy = jsonencode({ Version = "2012-10-17" @@ -32,10 +31,27 @@ resource "aws_iam_policy" "developer_boundary" { }, ######################################################################## - # Self-replicating: deny creating/modifying roles without this boundary + # Self-protection: deny modifying this boundary policy ######################################################################## { - Sid = "DenyRolesWithoutBoundary" + Sid = "DenyOrgBoundaryTamper" + Effect = "Deny" + Action = [ + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:CreatePolicyVersion", + "iam:SetDefaultPolicyVersion" + ] + Resource = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-org-boundary" + }, + + ######################################################################## + # Self-replicating: roles can only create roles with one of the two + # boundaries. Infra role needs to create team roles (developer boundary) + # and platform roles (org boundary). + ######################################################################## + { + Sid = "DenyNoBoundary" Effect = "Deny" Action = [ "iam:CreateRole", @@ -44,16 +60,29 @@ resource "aws_iam_policy" "developer_boundary" { Resource = "*" Condition = { StringNotEquals = { - "iam:PermissionsBoundary" = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-developer-boundary" + "iam:PermissionsBoundary" = [ + "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-org-boundary", + "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-developer-boundary", + ] } } }, ######################################################################## - # Deny creating IAM users and access keys (console/programmatic) + # Deny removing the boundary from any role ######################################################################## { - Sid = "DenyIAMUserCreation" + Sid = "DenyDeleteBoundary" + Effect = "Deny" + Action = "iam:DeleteRolePermissionsBoundary" + Resource = "*" + }, + + ######################################################################## + # Deny creating IAM users and access keys + ######################################################################## + { + Sid = "DenyIAMUsers" Effect = "Deny" Action = [ "iam:CreateUser", @@ -67,34 +96,11 @@ resource "aws_iam_policy" "developer_boundary" { Resource = "*" }, - ######################################################################## - # Deny modifying or deleting this boundary policy itself - ######################################################################## - { - Sid = "DenyBoundaryTampering" - Effect = "Deny" - Action = [ - "iam:DeletePolicy", - "iam:DeletePolicyVersion", - "iam:CreatePolicyVersion", - "iam:SetDefaultPolicyVersion" - ] - Resource = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-developer-boundary" - }, - { - Sid = "DenyDeleteRoleBoundary" - Effect = "Deny" - Action = [ - "iam:DeleteRolePermissionsBoundary" - ] - Resource = "*" - }, - ######################################################################## # Deny IAM Identity Center, Organizations, SCPs ######################################################################## { - Sid = "DenyIdentityCenterAndOrgs" + Sid = "DenyOrgAndSSO" Effect = "Deny" Action = [ "organizations:*", @@ -108,9 +114,13 @@ resource "aws_iam_policy" "developer_boundary" { ######################################################################## # Deny disabling/deleting protective services + # + # Specific destructive actions only — NOT wildcards. The infra role + # carries this boundary and needs to manage these services (create + # detectors, recorders, etc.). ######################################################################## { - Sid = "DenyProtectiveServicesTampering" + Sid = "DenyProtectiveSvcs" Effect = "Deny" Action = [ "guardduty:DeleteDetector", @@ -136,7 +146,7 @@ resource "aws_iam_policy" "developer_boundary" { # Deny platform networking: VPC, subnets, IGW, NAT gateway ######################################################################## { - Sid = "DenyPlatformNetworking" + Sid = "DenyNetworking" Effect = "Deny" Action = [ "ec2:CreateVpc", @@ -155,137 +165,10 @@ resource "aws_iam_policy" "developer_boundary" { ] Resource = "*" }, - - ######################################################################## - # Protect platform security groups - # - # Teams CAN create security groups (needed for RDS, custom services). - # Teams CANNOT modify or delete platform-owned security groups. - # Platform SGs are named javabin-* (e.g., javabin-alb-sg, javabin-ecs-tasks-sg). - # App SGs use app-name prefix (e.g., moresleep-rds-sg). - ######################################################################## - { - Sid = "DenyPlatformSecurityGroups" - Effect = "Deny" - Action = [ - "ec2:DeleteSecurityGroup", - "ec2:AuthorizeSecurityGroupIngress", - "ec2:RevokeSecurityGroupIngress", - "ec2:AuthorizeSecurityGroupEgress", - "ec2:RevokeSecurityGroupEgress", - "ec2:ModifySecurityGroupRules", - ] - Resource = "arn:aws:ec2:${var.region}:${var.aws_account_id}:security-group/*" - Condition = { - StringLike = { - "ec2:ResourceTag/Name" = "${var.project}-*" - } - } - }, - - ######################################################################## - # Deny platform ECS cluster, ALB, ACM certs - ######################################################################## - { - Sid = "DenyPlatformECSCluster" - Effect = "Deny" - Action = [ - "ecs:DeleteCluster", - "ecs:UpdateCluster" - ] - Resource = "arn:aws:ecs:${var.region}:${var.aws_account_id}:cluster/${var.project}-platform" - }, - { - Sid = "DenyPlatformALB" - Effect = "Deny" - Action = [ - "elasticloadbalancing:DeleteLoadBalancer", - "elasticloadbalancing:ModifyLoadBalancerAttributes" - ] - Resource = "arn:aws:elasticloadbalancing:${var.region}:${var.aws_account_id}:loadbalancer/app/${var.project}-*" - }, - { - Sid = "DenyPlatformACM" - Effect = "Deny" - Action = [ - "acm:DeleteCertificate" - ] - Resource = "arn:aws:acm:${var.region}:${var.aws_account_id}:certificate/*" - }, - - ######################################################################## - # Deny access to state and CI artifact buckets/tables - ######################################################################## - { - Sid = "DenyStateBuckets" - Effect = "Deny" - Action = [ - "s3:DeleteBucket", - "s3:PutBucketPolicy", - "s3:DeleteBucketPolicy", - "s3:PutBucketVersioning", - "s3:PutEncryptionConfiguration" - ] - Resource = [ - "arn:aws:s3:::${var.project}-terraform-*", - "arn:aws:s3:::${var.project}-ci-*" - ] - }, - { - Sid = "DenyStateTables" - Effect = "Deny" - Action = [ - "dynamodb:DeleteTable", - "dynamodb:UpdateTable" - ] - Resource = "arn:aws:dynamodb:${var.region}:${var.aws_account_id}:table/${var.project}-terraform-*" - }, - - ######################################################################## - # Enforce team-prefixed naming on resource creation - # - # Teams can only create resources with names matching their team prefix. - # Uses ${aws:PrincipalTag/team} policy variable in Resource ARNs. - # The AllowAll above permits all actions; this deny blocks creates - # that don't match the team naming convention. - # - # Not enforced for: security groups (ARN uses ID, not name), - # EC2 networking (already denied above), CloudWatch metrics (not resources). - ######################################################################## - { - Sid = "DenyNonTeamPrefixedCreation" - Effect = "Deny" - Action = [ - "s3:CreateBucket", - "dynamodb:CreateTable", - "sqs:CreateQueue", - "sns:CreateTopic", - "rds:CreateDBInstance", - "rds:CreateDBSubnetGroup", - "ecs:CreateService", - "ecs:RegisterTaskDefinition", - "ecr:CreateRepository", - "logs:CreateLogGroup", - "ssm:PutParameter", - ] - NotResource = [ - "arn:aws:s3:::$${aws:PrincipalTag/team}-*", - "arn:aws:dynamodb:${var.region}:${var.aws_account_id}:table/$${aws:PrincipalTag/team}-*", - "arn:aws:sqs:${var.region}:${var.aws_account_id}:$${aws:PrincipalTag/team}-*", - "arn:aws:sns:${var.region}:${var.aws_account_id}:$${aws:PrincipalTag/team}-*", - "arn:aws:rds:${var.region}:${var.aws_account_id}:db:$${aws:PrincipalTag/team}-*", - "arn:aws:rds:${var.region}:${var.aws_account_id}:subgrp:$${aws:PrincipalTag/team}-*", - "arn:aws:ecs:${var.region}:${var.aws_account_id}:service/*/$${aws:PrincipalTag/team}-*", - "arn:aws:ecs:${var.region}:${var.aws_account_id}:task-definition/$${aws:PrincipalTag/team}-*:*", - "arn:aws:ecr:${var.region}:${var.aws_account_id}:repository/$${aws:PrincipalTag/team}-*", - "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:/ecs/$${aws:PrincipalTag/team}/*", - "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/$${aws:PrincipalTag/team}/*", - ] - } ] }) tags = { - Name = "${var.project}-developer-boundary" + Name = "${var.project}-org-boundary" } } diff --git a/terraform/org/main.tf b/terraform/org/main.tf index 1daf886..3b838e0 100644 --- a/terraform/org/main.tf +++ b/terraform/org/main.tf @@ -7,8 +7,8 @@ # See docs/org-runbook.md for instructions. # # IMPORTANT: Do NOT apply the SCP until: -# 1. The javabin-developer-boundary IAM policy exists -# 2. The javabin-ci-infra role exists and references the boundary +# 1. Both javabin-org-boundary and javabin-developer-boundary exist +# 2. The javabin-ci-infra role exists and references the org boundary # 3. You have verified the exempt_roles variable is correct ################################################################################ @@ -29,14 +29,14 @@ resource "aws_organizations_organization" "main" { ################################################################################ # SCP: Require permission boundary on role creation # -# Any iam:CreateRole or iam:PutRolePermissionsBoundary call must attach the -# javabin-developer-boundary policy. Exempt roles (platform CI, root) are -# excluded via condition on the caller's ARN. +# Any iam:CreateRole or iam:PutRolePermissionsBoundary call must attach one +# of the two permitted boundaries (org or developer). Exempt roles (platform +# CI, root) are excluded via condition on the caller's ARN. ################################################################################ resource "aws_organizations_policy" "require_boundary" { name = "${var.project}-require-permission-boundary" - description = "Deny role creation unless javabin-developer-boundary is attached. Exempts platform CI and root." + description = "Deny role creation unless a permitted boundary is attached. Exempts platform CI and root." type = "SERVICE_CONTROL_POLICY" content = jsonencode({ @@ -52,7 +52,7 @@ resource "aws_organizations_policy" "require_boundary" { Resource = "*" Condition = { StringNotEquals = { - "iam:PermissionsBoundary" = var.boundary_policy_arn + "iam:PermissionsBoundary" = var.boundary_policy_arns } # Exempt platform CI role and root from this requirement ArnNotLike = { diff --git a/terraform/org/providers.tf b/terraform/org/providers.tf index 5e25c5e..842d14f 100644 --- a/terraform/org/providers.tf +++ b/terraform/org/providers.tf @@ -11,7 +11,7 @@ terraform { locals { required_tags = { - team = "javabin" + team = "platform" service = "org" repo = "javaBin/platform" environment = var.environment diff --git a/terraform/org/variables.tf b/terraform/org/variables.tf index 351fb10..55cc3ff 100644 --- a/terraform/org/variables.tf +++ b/terraform/org/variables.tf @@ -22,10 +22,13 @@ variable "environment" { default = "production" } -variable "boundary_policy_arn" { - description = "ARN of the javabin-developer-boundary IAM policy (must exist before SCP is applied)" - type = string - default = "arn:aws:iam::553637109631:policy/javabin-developer-boundary" +variable "boundary_policy_arns" { + description = "ARNs of the permitted permission boundary policies (org + developer)" + type = list(string) + default = [ + "arn:aws:iam::553637109631:policy/javabin-org-boundary", + "arn:aws:iam::553637109631:policy/javabin-developer-boundary", + ] } variable "exempt_roles" { diff --git a/terraform/platform/iam/boundary.tf b/terraform/platform/iam/boundary.tf index 9f1cc98..ea1947b 100644 --- a/terraform/platform/iam/boundary.tf +++ b/terraform/platform/iam/boundary.tf @@ -1,12 +1,257 @@ ################################################################################ -# Permission Boundary: javabin-developer-boundary (data source) +# Permission Boundary: javabin-developer-boundary # -# The boundary resource lives in terraform/org/boundary.tf (human-applied) -# because the boundary's self-protection prevents CI from modifying it. -# This data source reads the existing policy so platform resources can -# reference its ARN. +# Superset of the org boundary. Carried by team CI roles, deploy roles, +# app-broker, and app-created roles. +# +# Contains ALL org structural denies PLUS team ABAC isolation, naming +# enforcement, and platform resource protection. +# +# Self-protecting: roles carrying this boundary cannot modify it. +# The infra role (which carries the org boundary, NOT this boundary) +# CAN modify it — enabling CI to update ABAC rules without human apply. +# +# Uses ${aws:PrincipalTag/team} ABAC variables throughout, so a single +# policy works for all teams. +# +# AWS managed policy limit: 6,144 bytes. Keep statements compact. ################################################################################ -data "aws_iam_policy" "developer_boundary" { - name = "${var.project}-developer-boundary" +data "aws_iam_policy" "org_boundary" { + name = "${var.project}-org-boundary" +} + +resource "aws_iam_policy" "developer_boundary" { + name = "${var.project}-developer-boundary" + description = "Team permission boundary: org denies + ABAC isolation + naming enforcement." + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + # -- BASELINE -- + { + Sid = "Allow" + Effect = "Allow" + Action = "*" + Resource = "*" + }, + # -- STRUCTURAL (superset of org boundary) -- + { + Sid = "DenyBndMod" + Effect = "Deny" + Action = [ + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:CreatePolicyVersion", + "iam:SetDefaultPolicyVersion" + ] + Resource = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-developer-boundary" + }, + { + Sid = "DenyNoBnd" + Effect = "Deny" + Action = ["iam:CreateRole", "iam:PutRolePermissionsBoundary"] + Resource = "*" + Condition = { + StringNotEquals = { + "iam:PermissionsBoundary" = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-developer-boundary" + } + } + }, + { + Sid = "DenyStripBnd" + Effect = "Deny" + Action = "iam:DeleteRolePermissionsBoundary" + Resource = "*" + }, + { + Sid = "DenyIAMUsers" + Effect = "Deny" + Action = [ + "iam:CreateUser", "iam:CreateLoginProfile", "iam:UpdateLoginProfile", + "iam:CreateAccessKey", "iam:DeactivateMFADevice" + ] + Resource = "*" + }, + { + Sid = "DenyOrgSSO" + Effect = "Deny" + Action = ["organizations:*", "sso:*", "sso-directory:*", "identitystore:*", "account:*"] + Resource = "*" + }, + { + Sid = "DenyNetwork" + Effect = "Deny" + Action = [ + "ec2:CreateVpc", "ec2:DeleteVpc", "ec2:ModifyVpcAttribute", + "ec2:CreateSubnet", "ec2:DeleteSubnet", + "ec2:CreateInternetGateway", "ec2:DeleteInternetGateway", + "ec2:AttachInternetGateway", "ec2:DetachInternetGateway", + "ec2:CreateNatGateway", "ec2:DeleteNatGateway", + "ec2:CreateRouteTable", "ec2:DeleteRouteTable" + ] + Resource = "*" + }, + # -- PLATFORM PROTECTION -- + # Platform SGs: covered by DenyMutateShared (team=shared) + DenyTagStrip + # Platform ACM: covered by DenyCrossTeam (team=platform) + { + Sid = "DenyPlatInfra" + Effect = "Deny" + Action = [ + "ecs:DeleteCluster", "ecs:UpdateCluster", + "elasticloadbalancing:DeleteLoadBalancer", "elasticloadbalancing:ModifyLoadBalancerAttributes", + "s3:DeleteBucket", "s3:PutBucketPolicy", "s3:DeleteBucketPolicy", + "s3:PutBucketVersioning", "s3:PutEncryptionConfiguration", + "dynamodb:DeleteTable", "dynamodb:UpdateTable" + ] + Resource = [ + "arn:aws:ecs:${var.region}:${var.aws_account_id}:cluster/${var.project}-platform", + "arn:aws:elasticloadbalancing:${var.region}:${var.aws_account_id}:loadbalancer/app/${var.project}-*", + "arn:aws:s3:::${var.project}-terraform-*", + "arn:aws:s3:::${var.project}-ci-*", + "arn:aws:dynamodb:${var.region}:${var.aws_account_id}:table/${var.project}-terraform-*", + ] + }, + { + # Security services + Bedrock (platform-only for plan review/cost narratives) + Sid = "DenySecSvcs" + Effect = "Deny" + Action = ["guardduty:*", "securityhub:*", "config:*", "cloudtrail:*", "bedrock:Invoke*", "bedrock:Converse*"] + Resource = "*" + }, + # -- TEAM ABAC -- + { + Sid = "DenyXTeam" + Effect = "Deny" + Action = "*" + NotResource = [ + "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", + "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 = { + StringNotEquals = { + "aws:ResourceTag/team" = ["$${aws:PrincipalTag/team}", "shared"] + } + "Null" = { "aws:ResourceTag/team" = "false" } + } + }, + { + Sid = "DenyShared" + 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" } } + }, + { + # Only services with shared/platform-tagged resources teams can access. + # Other services (lambda, sqs, dynamodb, etc.) already blocked by DenyCrossTeam. + Sid = "DenyTagStrip" + Effect = "Deny" + Action = [ + "ec2:DeleteTags", "ec2:CreateTags", + "ecs:TagResource", "ecs:UntagResource", + "elasticloadbalancing:AddTags", "elasticloadbalancing:RemoveTags", + "iam:TagRole", "iam:UntagRole", + "sns:TagResource", "sns:UntagResource", + ] + Resource = "*" + Condition = { StringEquals = { "aws:ResourceTag/team" = ["shared", "platform"] } } + }, + { + Sid = "DenyPlatSSM" + Effect = "Deny" + Action = ["ssm:GetParameter*", "ssm:GetParametersByPath"] + Resource = [ + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/platform/*", + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/slack/*", + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/platform-overrides/*", + ] + }, + { + Sid = "DenyPassRole" + Effect = "Deny" + Action = "iam:PassRole" + NotResource = [ + "arn:aws:iam::${var.aws_account_id}:role/$${aws:PrincipalTag/team}-*", + "arn:aws:iam::${var.aws_account_id}:role/${var.project}-ecs-execution", + ] + }, + { + Sid = "DenyXLogs" + Effect = "Deny" + Action = ["logs:GetLogEvents", "logs:FilterLogEvents", "logs:StartQuery", "logs:GetQueryResults"] + NotResource = [ + "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:/ecs/$${aws:PrincipalTag/team}/*", + "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:/ecs/$${aws:PrincipalTag/team}/*:*", + ] + }, + { + Sid = "DenyModCI" + Effect = "Deny" + Action = [ + "iam:PutRolePolicy", "iam:DeleteRolePolicy", + "iam:AttachRolePolicy", "iam:DetachRolePolicy", + "iam:DeleteRole", "iam:UpdateRole", "iam:UpdateAssumeRolePolicy" + ] + Resource = "arn:aws:iam::${var.aws_account_id}:role/${var.project}-ci-*" + }, + # -- REQUEST TAG ENFORCEMENT -- + # SGs use IDs in ARNs so DenyBadName can't enforce naming. + # Require team tag on create — Terraform default_tags handles this. + { + Sid = "DenyNoTag" + Effect = "Deny" + Action = "ec2:CreateSecurityGroup" + Resource = "*" + Condition = { + StringNotEqualsIfExists = { + "aws:RequestTag/team" = "$${aws:PrincipalTag/team}" + } + } + }, + # -- NAMING ENFORCEMENT -- + { + Sid = "DenyBadName" + Effect = "Deny" + 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", + ] + NotResource = [ + "arn:aws:s3:::$${aws:PrincipalTag/team}-*", + "arn:aws:dynamodb:${var.region}:${var.aws_account_id}:table/$${aws:PrincipalTag/team}-*", + "arn:aws:sqs:${var.region}:${var.aws_account_id}:$${aws:PrincipalTag/team}-*", + "arn:aws:sns:${var.region}:${var.aws_account_id}:$${aws:PrincipalTag/team}-*", + "arn:aws:rds:${var.region}:${var.aws_account_id}:db:$${aws:PrincipalTag/team}-*", + "arn:aws:rds:${var.region}:${var.aws_account_id}:subgrp:$${aws:PrincipalTag/team}-*", + "arn:aws:ecs:${var.region}:${var.aws_account_id}:service/*/$${aws:PrincipalTag/team}-*", + "arn:aws:ecs:${var.region}:${var.aws_account_id}:task-definition/$${aws:PrincipalTag/team}-*:*", + "arn:aws:ecr:${var.region}:${var.aws_account_id}:repository/$${aws:PrincipalTag/team}-*", + "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:/ecs/$${aws:PrincipalTag/team}/*", + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/$${aws:PrincipalTag/team}/*", + "arn:aws:lambda:${var.region}:${var.aws_account_id}:function:$${aws:PrincipalTag/team}-*", + "arn:aws:events:${var.region}:${var.aws_account_id}:rule/$${aws:PrincipalTag/team}-*", + "arn:aws:states:${var.region}:${var.aws_account_id}:stateMachine:$${aws:PrincipalTag/team}-*", + ] + }, + ] + }) + + tags = { + Name = "${var.project}-developer-boundary" + } } diff --git a/terraform/platform/iam/main.tf b/terraform/platform/iam/main.tf index 1a7a727..c3b39de 100644 --- a/terraform/platform/iam/main.tf +++ b/terraform/platform/iam/main.tf @@ -34,7 +34,7 @@ data "aws_iam_openid_connect_provider" "github" { resource "aws_iam_role" "ci_infra_plan" { name = "${var.project}-ci-infra-plan" - permissions_boundary = data.aws_iam_policy.developer_boundary.arn + permissions_boundary = data.aws_iam_policy.org_boundary.arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -146,7 +146,7 @@ resource "aws_iam_role_policy" "ci_infra_plan_extras" { resource "aws_iam_role" "ci_infra" { name = "${var.project}-ci-infra" - permissions_boundary = data.aws_iam_policy.developer_boundary.arn + permissions_boundary = data.aws_iam_policy.org_boundary.arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -265,7 +265,7 @@ resource "aws_iam_role_policy" "ci_infra_deny" { resource "aws_iam_role" "ci_app_broker" { name = "${var.project}-ci-app-broker" - permissions_boundary = data.aws_iam_policy.developer_boundary.arn + permissions_boundary = aws_iam_policy.developer_boundary.arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -321,7 +321,7 @@ resource "aws_iam_role" "ci_team" { for_each = toset(var.registered_teams) name = "${var.project}-ci-team-${each.key}" - permissions_boundary = data.aws_iam_policy.developer_boundary.arn + permissions_boundary = aws_iam_policy.developer_boundary.arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -357,20 +357,8 @@ 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. + # AllowAll + backend access only. All deny rules are in the developer + # boundary (iam/boundary.tf) using ABAC variables — one policy for all teams. policy = jsonencode({ Version = "2012-10-17" Statement = [ @@ -380,56 +368,6 @@ resource "aws_iam_role_policy" "ci_team_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 = { - 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" @@ -465,148 +403,6 @@ resource "aws_iam_role_policy" "ci_team_allow" { }) } -resource "aws_iam_role_policy" "ci_team_deny" { - for_each = toset(var.registered_teams) - - name = "deny-platform-operations" - role = aws_iam_role.ci_team[each.key].id - - # Deny policy protects infrastructure that can't be scoped by tags. - # - # 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 = [ - # ---------------------------------------------------------------- - # EC2 networking — MUST be explicit deny - # - # AWS EC2 does NOT support aws:RequestTag conditions on CreateVpc, - # CreateSubnet, CreateInternetGateway, or CreateNatGateway. - # Resource doesn't exist at creation time so aws:ResourceTag is N/A. - # Cross-service dependencies (subnet→VPC) can't be scoped by tag. - # See: docs.aws.amazon.com/AWSEC2/latest/UserGuide/supported-iam-actions-tagging.html - # ---------------------------------------------------------------- - { - Sid = "DenyNetworkInfra" - Effect = "Deny" - 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", - ] - Resource = "*" - }, - # ---------------------------------------------------------------- - # Security services — MUST be explicit deny - # - # GuardDuty, Security Hub, AWS Config, and CloudTrail do NOT - # support aws:ResourceTag or aws:RequestTag conditions in IAM. - # There is no tag-based mechanism to protect these services. - # ---------------------------------------------------------------- - { - Sid = "DenySecurityServices" - Effect = "Deny" - Action = [ - "guardduty:*", - "securityhub:*", - "config:*", - "cloudtrail:*", - ] - Resource = "*" - }, - # ---------------------------------------------------------------- - # Organizations + Account — MUST be explicit deny - # - # These are account-level services with no resource tagging. - # ---------------------------------------------------------------- - { - Sid = "DenyOrgAndAccount" - Effect = "Deny" - Action = [ - "organizations:*", - "account:*", - ] - Resource = "*" - }, - # ---------------------------------------------------------------- - # IAM user creation — MUST be explicit deny - # - # IAM users are global resources, not taggable per-team. - # Prevents creation of long-lived credentials. - # ---------------------------------------------------------------- - { - Sid = "DenyDangerousIAM" - Effect = "Deny" - Action = [ - "iam:CreateUser", - "iam:CreateAccessKey", - "iam:CreateLoginProfile", - ] - Resource = "*" - }, - # ---------------------------------------------------------------- - # Platform SNS topics — scoped to platform ARNs (ABAC-friendly) - # - # SNS supports full tag-based ABAC. Teams CAN create their own - # topics (gated by aws:RequestTag/team in the allow policy). - # This deny only protects platform-owned alert topics by ARN. - # ---------------------------------------------------------------- - { - Sid = "DenyPlatformSNS" - Effect = "Deny" - 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", - "arn:aws:sns:${var.region}:${var.aws_account_id}:${var.project}-budget-enforcement", - ] - }, - # ---------------------------------------------------------------- - # Platform S3 buckets — scoped to platform ARNs (ABAC-friendly) - # - # S3 supports full tag-based ABAC. Teams CAN create/delete their - # own buckets (gated by aws:RequestTag/team). This protects only - # the Terraform state and CI artifact buckets. - # ---------------------------------------------------------------- - { - Sid = "DenyPlatformS3" - Effect = "Deny" - Action = ["s3:DeleteBucket", "s3:PutBucketPolicy", "s3:DeleteBucketPolicy"] - Resource = [ - "arn:aws:s3:::${var.project}-terraform-state-${var.aws_account_id}", - "arn:aws:s3:::${var.project}-ci-plan-artifacts-${var.aws_account_id}", - ] - }, - # ---------------------------------------------------------------- - # Platform ECS cluster + ALB — scoped to platform ARNs (ABAC-friendly) - # - # ECS and ELBv2 support tag-based ABAC. Teams CAN manage their - # own services (gated by aws:ResourceTag/team). This protects - # only the shared platform cluster and load balancer. - # ---------------------------------------------------------------- - { - Sid = "DenyPlatformCompute" - Effect = "Deny" - Action = [ - "ecs:DeleteCluster", "ecs:UpdateCluster", - "elasticloadbalancingv2:DeleteLoadBalancer", - "elasticloadbalancingv2:ModifyLoadBalancerAttributes", - ] - Resource = [ - "arn:aws:ecs:${var.region}:${var.aws_account_id}:cluster/${var.project}-platform", - "arn:aws:elasticloadbalancingv2:${var.region}:${var.aws_account_id}:loadbalancer/app/${var.project}-*", - ] - }, - ] - }) -} - ################################################################################ # 3. javabin-ci-deploy-{team} — Per-team deploy role (build + push + deploy) # @@ -619,7 +415,7 @@ resource "aws_iam_role" "ci_deploy" { for_each = toset(var.registered_teams) name = "${var.project}-ci-deploy-${each.key}" - permissions_boundary = data.aws_iam_policy.developer_boundary.arn + permissions_boundary = aws_iam_policy.developer_boundary.arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -670,11 +466,8 @@ resource "aws_iam_role_policy" "ci_deploy_ecr" { "ecr:CreateRepository", "ecr:DescribeRepositories", ] - # ECR push actions don't support tag-based authorization. - # Blast radius is contained: ci-broker limits which team role you get, - # and each team can only push to repos they've registered. - # Wildcard is required because app ECR repos use plain names (not project-prefixed). - Resource = "arn:aws:ecr:${var.region}:${var.aws_account_id}:repository/*" + # Scoped to team's repos. ECR repos use {team}-{service} naming. + Resource = "arn:aws:ecr:${var.region}:${var.aws_account_id}:repository/${each.key}-*" } ] }) @@ -720,7 +513,7 @@ resource "aws_iam_role_policy" "ci_deploy_ecs" { Action = ["iam:PassRole"] Resource = [ aws_iam_role.ecs_execution.arn, - "arn:aws:iam::${var.aws_account_id}:role/${var.project}-*", + "arn:aws:iam::${var.aws_account_id}:role/${each.key}-*", ] } ] @@ -736,9 +529,12 @@ resource "aws_iam_role_policy" "ci_deploy_logs" { policy = jsonencode({ Version = "2012-10-17" Statement = [{ - Effect = "Allow" - Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"] - Resource = "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:/ecs/${var.project}/*" + Effect = "Allow" + Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"] + Resource = [ + "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:/ecs/${each.key}/*", + "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:/ecs/${each.key}/*:*", + ] }] }) } @@ -770,7 +566,7 @@ resource "aws_iam_role_policy" "ci_deploy_ssm" { resource "aws_iam_role" "ci_override_approver" { name = "${var.project}-ci-override-approver" - permissions_boundary = data.aws_iam_policy.developer_boundary.arn + permissions_boundary = data.aws_iam_policy.org_boundary.arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -825,7 +621,7 @@ resource "aws_iam_role_policy" "ci_override_approver" { resource "aws_iam_role" "ci_registry" { name = "${var.project}-ci-registry" - permissions_boundary = data.aws_iam_policy.developer_boundary.arn + permissions_boundary = data.aws_iam_policy.org_boundary.arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -889,7 +685,7 @@ resource "aws_iam_role_policy" "ci_registry" { resource "aws_iam_role" "ci_apply_gate" { name = "${var.project}-ci-apply-gate" - permissions_boundary = data.aws_iam_policy.developer_boundary.arn + permissions_boundary = data.aws_iam_policy.org_boundary.arn assume_role_policy = jsonencode({ Version = "2012-10-17" diff --git a/terraform/platform/iam/outputs.tf b/terraform/platform/iam/outputs.tf index 7ab1a15..a2bfce2 100644 --- a/terraform/platform/iam/outputs.tf +++ b/terraform/platform/iam/outputs.tf @@ -35,5 +35,10 @@ output "github_oidc_provider_arn" { output "developer_boundary_arn" { description = "ARN of the javabin-developer-boundary IAM policy" - value = data.aws_iam_policy.developer_boundary.arn + value = aws_iam_policy.developer_boundary.arn +} + +output "org_boundary_arn" { + description = "ARN of the javabin-org-boundary IAM policy" + value = data.aws_iam_policy.org_boundary.arn } diff --git a/terraform/platform/lambdas/main.tf b/terraform/platform/lambdas/main.tf index 516f380..2156008 100644 --- a/terraform/platform/lambdas/main.tf +++ b/terraform/platform/lambdas/main.tf @@ -416,7 +416,7 @@ resource "aws_iam_role_policy_attachment" "compliance_reporter_logs" { # --- resource-tagger role --- resource "aws_iam_role" "resource_tagger" { name = "${var.project}-resource-tagger" - permissions_boundary = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-developer-boundary" + permissions_boundary = var.org_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -612,7 +612,7 @@ resource "aws_iam_role_policy_attachment" "team_provisioner_logs" { # --- password-set role --- resource "aws_iam_role" "password_set" { name = "${var.project}-password-set" - permissions_boundary = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-developer-boundary" + permissions_boundary = var.org_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -946,7 +946,7 @@ resource "aws_sns_topic_policy" "budget_enforcement" { # --- budget-enforcer role --- resource "aws_iam_role" "budget_enforcer" { name = "${var.project}-budget-enforcer" - permissions_boundary = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-developer-boundary" + permissions_boundary = var.org_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -1283,7 +1283,7 @@ data "archive_file" "apply_gate" { resource "aws_iam_role" "apply_gate" { name = "${var.project}-apply-gate" - permissions_boundary = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-developer-boundary" + permissions_boundary = var.org_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -1387,7 +1387,7 @@ data "archive_file" "ci_broker" { resource "aws_iam_role" "ci_broker" { name = "${var.project}-ci-broker" - permissions_boundary = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-developer-boundary" + permissions_boundary = var.org_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" diff --git a/terraform/platform/lambdas/variables.tf b/terraform/platform/lambdas/variables.tf index 272e6d5..9e3d348 100644 --- a/terraform/platform/lambdas/variables.tf +++ b/terraform/platform/lambdas/variables.tf @@ -85,3 +85,13 @@ variable "domain" { type = string } +variable "org_boundary_arn" { + description = "ARN of the org permission boundary (for cross-team Lambda roles)" + type = string +} + +variable "developer_boundary_arn" { + description = "ARN of the developer permission boundary (for team-scoped Lambda roles)" + type = string +} + diff --git a/terraform/platform/main.tf b/terraform/platform/main.tf index 5705f71..6f7a931 100644 --- a/terraform/platform/main.tf +++ b/terraform/platform/main.tf @@ -72,6 +72,8 @@ module "lambdas" { alb_dns_name = module.ingress.alb_dns_name alb_zone_id = module.ingress.alb_zone_id route53_zone_id = module.ingress.route53_zone_id + org_boundary_arn = module.iam.org_boundary_arn + developer_boundary_arn = module.iam.developer_boundary_arn } module "identity" { diff --git a/terraform/platform/providers.tf b/terraform/platform/providers.tf index b6c6198..3579d68 100644 --- a/terraform/platform/providers.tf +++ b/terraform/platform/providers.tf @@ -11,7 +11,7 @@ terraform { locals { required_tags = { - team = "javabin" + team = "platform" service = "platform" repo = "javaBin/platform" environment = var.environment From 405d15e0e226f576851c515b360da368151649be Mon Sep 17 00:00:00 2001 From: Alexander Amiri Date: Thu, 26 Mar 2026 20:46:22 +0100 Subject: [PATCH 2/2] Construct org boundary ARN instead of data source lookup The org boundary doesn't exist yet (human-applied), so the data source fails during plan. Construct the ARN from variables instead. --- terraform/platform/iam/boundary.tf | 4 ++-- terraform/platform/iam/main.tf | 10 +++++----- terraform/platform/iam/outputs.tf | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/terraform/platform/iam/boundary.tf b/terraform/platform/iam/boundary.tf index ea1947b..eaa2d79 100644 --- a/terraform/platform/iam/boundary.tf +++ b/terraform/platform/iam/boundary.tf @@ -17,8 +17,8 @@ # AWS managed policy limit: 6,144 bytes. Keep statements compact. ################################################################################ -data "aws_iam_policy" "org_boundary" { - name = "${var.project}-org-boundary" +locals { + org_boundary_arn = "arn:aws:iam::${var.aws_account_id}:policy/${var.project}-org-boundary" } resource "aws_iam_policy" "developer_boundary" { diff --git a/terraform/platform/iam/main.tf b/terraform/platform/iam/main.tf index c3b39de..cdca0db 100644 --- a/terraform/platform/iam/main.tf +++ b/terraform/platform/iam/main.tf @@ -34,7 +34,7 @@ data "aws_iam_openid_connect_provider" "github" { resource "aws_iam_role" "ci_infra_plan" { name = "${var.project}-ci-infra-plan" - permissions_boundary = data.aws_iam_policy.org_boundary.arn + permissions_boundary = local.org_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -146,7 +146,7 @@ resource "aws_iam_role_policy" "ci_infra_plan_extras" { resource "aws_iam_role" "ci_infra" { name = "${var.project}-ci-infra" - permissions_boundary = data.aws_iam_policy.org_boundary.arn + permissions_boundary = local.org_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -566,7 +566,7 @@ resource "aws_iam_role_policy" "ci_deploy_ssm" { resource "aws_iam_role" "ci_override_approver" { name = "${var.project}-ci-override-approver" - permissions_boundary = data.aws_iam_policy.org_boundary.arn + permissions_boundary = local.org_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -621,7 +621,7 @@ resource "aws_iam_role_policy" "ci_override_approver" { resource "aws_iam_role" "ci_registry" { name = "${var.project}-ci-registry" - permissions_boundary = data.aws_iam_policy.org_boundary.arn + permissions_boundary = local.org_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -685,7 +685,7 @@ resource "aws_iam_role_policy" "ci_registry" { resource "aws_iam_role" "ci_apply_gate" { name = "${var.project}-ci-apply-gate" - permissions_boundary = data.aws_iam_policy.org_boundary.arn + permissions_boundary = local.org_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" diff --git a/terraform/platform/iam/outputs.tf b/terraform/platform/iam/outputs.tf index a2bfce2..8fba31a 100644 --- a/terraform/platform/iam/outputs.tf +++ b/terraform/platform/iam/outputs.tf @@ -40,5 +40,5 @@ output "developer_boundary_arn" { output "org_boundary_arn" { description = "ARN of the javabin-org-boundary IAM policy" - value = data.aws_iam_policy.org_boundary.arn + value = local.org_boundary_arn }