From 47b5a11ffea68a3795abeee45527d42638848c28 Mon Sep 17 00:00:00 2001 From: JuliaEdom Date: Mon, 20 Jul 2026 14:37:05 +0300 Subject: [PATCH] fix(iac): close latent Terraform audit findings and add executable IaC gate IaC-1: MSK accepts TLS-only client traffic with SASL/IAM auth; Flink wired to the SASL/IAM bootstrap with SigV4 client properties and a scoped kafka-cluster data-plane policy; broker SG narrowed from 10.0.0.0/8 to the cluster subnets' CIDRs. IaC-2: deploy role now carries a permissions boundary that excludes role mutation on its own name pattern and denies boundary detach/rewrite; service-role create/policy writes require the boundary attached; the role can no longer mutate itself (out-of-band admin operation). IaC-3: iam:*OpenIDConnectProvider* on * split into List-on-* plus explicit verbs pinned to the one provider this module manages. IaC-4: kafka:*Cluster*/kinesisanalytics:*Application* wildcards replaced with explicit verbs scoped to project ARN patterns; cluster/application/ workspace/SG/KMS deletion gated on the Project resource tag. Also raised by the scanner and fixed: CMKs with rotation for MSK and lake bucket at-rest encryption, SG egress restricted, remaining deliberate wildcards suppressed inline with justification. New security.yml job `iac`: trivy config scan of infrastructure/terraform, MEDIUM+ severity, exit-code 1 (fails on the old MSK plaintext config, verified locally: baseline exit 1, fixed tree exit 0). Co-Authored-By: Claude Fable 5 --- .github/workflows/security.yml | 27 ++ infrastructure/terraform/main.tf | 28 +- .../terraform/modules/flink/main.tf | 90 +++- .../terraform/modules/github-oidc/main.tf | 456 ++++++++++++++++-- .../terraform/modules/github-oidc/outputs.tf | 5 + .../terraform/modules/kafka/main.tf | 52 +- .../terraform/modules/monitoring/main.tf | 4 +- .../terraform/modules/storage/main.tf | 19 +- infrastructure/terraform/outputs.tf | 4 +- 9 files changed, 629 insertions(+), 56 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 84e7c9f6..8f06d4ae 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -316,3 +316,30 @@ jobs: uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: sarif_file: trivy-results.sarif + + iac: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Run Trivy IaC misconfiguration scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: config + scan-ref: infrastructure/terraform + format: sarif + output: trivy-iac.sarif + severity: MEDIUM,HIGH,CRITICAL + exit-code: "1" + limit-severities-for-sarif: true + - name: Upload Trivy IaC scan results + if: always() + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + sarif_file: trivy-iac.sarif + # Distinct category so these alerts do not collide with the image + # scan's SARIF upload in the trivy job. + category: trivy-iac diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf index e149c500..928e7a17 100644 --- a/infrastructure/terraform/main.tf +++ b/infrastructure/terraform/main.tf @@ -54,13 +54,16 @@ module "kafka" { module "flink" { source = "./modules/flink" - environment = var.environment - vpc_id = var.vpc_id - subnet_ids = var.private_subnet_ids - kafka_bootstrap = module.kafka.bootstrap_brokers - s3_bucket_arn = module.storage.lake_bucket_arn - parallelism = var.flink_parallelism - parallelism_per_kpu = var.flink_parallelism_per_kpu + environment = var.environment + vpc_id = var.vpc_id + subnet_ids = var.private_subnet_ids + kafka_bootstrap = module.kafka.bootstrap_brokers_sasl_iam + kafka_cluster_arn = module.kafka.cluster_arn + s3_bucket_arn = module.storage.lake_bucket_arn + lake_kms_key_arn = module.storage.lake_kms_key_arn + parallelism = var.flink_parallelism + parallelism_per_kpu = var.flink_parallelism_per_kpu + permissions_boundary_arn = module.github_oidc.permissions_boundary_arn } module "storage" { @@ -75,9 +78,10 @@ module "storage" { module "monitoring" { source = "./modules/monitoring" - environment = var.environment - kafka_cluster_arn = module.kafka.cluster_arn - flink_application_arn = module.flink.application_arn - sns_alert_topic_arn = var.sns_alert_topic_arn - freshness_sla_seconds = var.freshness_sla_seconds + environment = var.environment + kafka_cluster_arn = module.kafka.cluster_arn + flink_application_arn = module.flink.application_arn + sns_alert_topic_arn = var.sns_alert_topic_arn + freshness_sla_seconds = var.freshness_sla_seconds + permissions_boundary_arn = module.github_oidc.permissions_boundary_arn } diff --git a/infrastructure/terraform/modules/flink/main.tf b/infrastructure/terraform/modules/flink/main.tf index f561352c..93bfb95c 100644 --- a/infrastructure/terraform/modules/flink/main.tf +++ b/infrastructure/terraform/modules/flink/main.tf @@ -2,25 +2,53 @@ variable "environment" { type = string } variable "vpc_id" { type = string } variable "subnet_ids" { type = list(string) } variable "kafka_bootstrap" { type = string } +variable "kafka_cluster_arn" { type = string } variable "s3_bucket_arn" { type = string } +variable "lake_kms_key_arn" { type = string } variable "parallelism" { type = number } variable "parallelism_per_kpu" { type = number } +variable "permissions_boundary_arn" { type = string } + +locals { + # arn:…:cluster/NAME/UUID → arn:…:topic/NAME/UUID and arn:…:group/NAME/UUID, + # the resource shapes MSK IAM auth authorizes topics and consumer groups on. + kafka_topic_arn_prefix = replace(var.kafka_cluster_arn, ":cluster/", ":topic/") + kafka_group_arn_prefix = replace(var.kafka_cluster_arn, ":cluster/", ":group/") +} + +data "aws_subnet" "kafka" { + for_each = toset(var.subnet_ids) + id = each.value +} resource "aws_security_group" "flink" { name_prefix = "agentflow-flink-${var.environment}-" vpc_id = var.vpc_id egress { - from_port = 0 - to_port = 0 - protocol = "-1" + from_port = 9092 + to_port = 9098 + protocol = "tcp" + cidr_blocks = [for s in data.aws_subnet.kafka : s.cidr_block] + description = "Kafka brokers in cluster subnets" + } + + # S3 (lake, checkpoints, application jar) and AWS APIs are public TLS + # endpoints — this one cannot be CIDR-scoped without VPC endpoints, which are + # operator-owned here. + #trivy:ignore:AVD-AWS-0104 + egress { + from_port = 443 + to_port = 443 + protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] - description = "Allow all outbound" + description = "AWS APIs and S3 over TLS" } } resource "aws_iam_role" "flink" { - name = "agentflow-flink-${var.environment}" + name = "agentflow-flink-${var.environment}" + permissions_boundary = var.permissions_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -54,6 +82,53 @@ resource "aws_iam_role_policy" "flink_s3" { "${var.s3_bucket_arn}/*", ] }, + { + Effect = "Allow" + Action = [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:GenerateDataKey*", + ] + Resource = [var.lake_kms_key_arn] + }, + ] + }) +} + +# MSK IAM auth data-plane permissions for the application's service role. +resource "aws_iam_role_policy" "flink_msk" { + name = "flink-msk-iam-auth" + role = aws_iam_role.flink.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "kafka-cluster:Connect", + "kafka-cluster:DescribeCluster", + ] + Resource = [var.kafka_cluster_arn] + }, + { + Effect = "Allow" + Action = [ + "kafka-cluster:DescribeTopic", + "kafka-cluster:ReadData", + "kafka-cluster:WriteData", + ] + Resource = ["${local.kafka_topic_arn_prefix}/*"] + }, + { + Effect = "Allow" + Action = [ + "kafka-cluster:DescribeGroup", + "kafka-cluster:AlterGroup", + ] + Resource = ["${local.kafka_group_arn_prefix}/*"] + }, ] }) } @@ -97,6 +172,11 @@ resource "aws_kinesisanalyticsv2_application" "stream_processor" { property_map = { "bootstrap.servers" = var.kafka_bootstrap "group.id" = "agentflow-stream-processor" + # MSK IAM auth (bootstrap points at the SASL/IAM listener, port 9098). + "security.protocol" = "SASL_SSL" + "sasl.mechanism" = "AWS_MSK_IAM" + "sasl.jaas.config" = "software.amazon.msk.auth.iam.IAMLoginModule required;" + "sasl.client.callback.handler.class" = "software.amazon.msk.auth.iam.IAMClientCallbackHandler" } } diff --git a/infrastructure/terraform/modules/github-oidc/main.tf b/infrastructure/terraform/modules/github-oidc/main.tf index b73ae7dc..392beea3 100644 --- a/infrastructure/terraform/modules/github-oidc/main.tf +++ b/infrastructure/terraform/modules/github-oidc/main.tf @@ -23,6 +23,19 @@ locals { "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:role/${local.project_prefix}-flink-*", "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:role/${local.project_prefix}-grafana-*", ] + boundary_policy_name = "${var.role_name}-boundary" + boundary_policy_arn = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:policy/${local.boundary_policy_name}" + region_account = "${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}" + msk_cluster_arns = ["arn:${data.aws_partition.current.partition}:kafka:${local.region_account}:cluster/${local.project_prefix}-*/*"] + msk_config_arns = ["arn:${data.aws_partition.current.partition}:kafka:${local.region_account}:configuration/${local.project_prefix}-*/*"] + flink_app_arns = ["arn:${data.aws_partition.current.partition}:kinesisanalytics:${local.region_account}:application/${local.project_prefix}-*"] + alarm_arns = ["arn:${data.aws_partition.current.partition}:cloudwatch:${local.region_account}:alarm:${local.project_prefix}-*"] + kms_key_arns = ["arn:${data.aws_partition.current.partition}:kms:${local.region_account}:key/*"] + kms_alias_arns = ["arn:${data.aws_partition.current.partition}:kms:${local.region_account}:alias/${local.project_prefix}-*"] + project_bucket_arns = [ + "arn:${data.aws_partition.current.partition}:s3:::${local.project_prefix}-*", + "arn:${data.aws_partition.current.partition}:s3:::${local.project_prefix}-*/*", + ] } data "aws_iam_policy_document" "assume_role" { @@ -89,44 +102,118 @@ data "aws_iam_policy_document" "terraform" { resources = [local.state_table_arn] } + # List cannot be resource-scoped; every mutating OIDC-provider verb below is + # pinned to the one provider this module manages (IaC-3: no wildcard verbs on + # every provider in the account). + statement { + sid = "OidcProviderDiscovery" + effect = "Allow" + actions = ["iam:ListOpenIDConnectProviders"] + resources = ["*"] + } + statement { sid = "OidcProviderLifecycle" effect = "Allow" actions = [ + "iam:AddClientIDToOpenIDConnectProvider", "iam:CreateOpenIDConnectProvider", - "iam:ListOpenIDConnectProviders", - "iam:*OpenIDConnectProvider*", - ] - resources = [ - "*", - local.oidc_provider_arn, + "iam:DeleteOpenIDConnectProvider", + "iam:GetOpenIDConnectProvider", + "iam:ListOpenIDConnectProviderTags", + "iam:RemoveClientIDFromOpenIDConnectProvider", + "iam:TagOpenIDConnectProvider", + "iam:UntagOpenIDConnectProvider", + "iam:UpdateOpenIDConnectProviderThumbprint", ] + resources = [local.oidc_provider_arn] } + # IaC-2: the deploy role can refresh its own state (read-only) but can no + # longer mutate roles matching its own name — writes are limited to the + # service roles, and creating/re-policying them requires the permissions + # boundary to be attached. Changes to this role or its inline policy are + # applied out-of-band by an account admin, not by CI. statement { - sid = "TerraformRoleLifecycle" + sid = "RoleStateRead" effect = "Allow" actions = [ - "iam:AttachRolePolicy", - "iam:CreateRole", - "iam:DeleteRole", - "iam:DetachRolePolicy", "iam:GetRole", "iam:GetRolePolicy", "iam:ListAttachedRolePolicies", "iam:ListRolePolicies", "iam:ListRoleTags", + ] + resources = concat([local.terraform_role_arn], local.service_role_arns) + } + + statement { + sid = "ServiceRoleCreate" + effect = "Allow" + actions = ["iam:CreateRole"] + resources = local.service_role_arns + + condition { + test = "StringEquals" + variable = "iam:PermissionsBoundary" + values = [local.boundary_policy_arn] + } + } + + statement { + sid = "ServiceRolePolicyMutation" + effect = "Allow" + actions = [ + "iam:AttachRolePolicy", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", "iam:PutRolePolicy", + ] + resources = local.service_role_arns + + condition { + test = "StringEquals" + variable = "iam:PermissionsBoundary" + values = [local.boundary_policy_arn] + } + } + + statement { + sid = "ServiceRoleLifecycle" + effect = "Allow" + actions = [ + "iam:DeleteRole", "iam:TagRole", "iam:UntagRole", "iam:UpdateAssumeRolePolicy", - "iam:DeleteRolePolicy", ] - resources = [ - local.terraform_role_arn, - local.service_role_arns[0], - local.service_role_arns[1], + resources = local.service_role_arns + } + + # Terraform needs to create and refresh the boundary policy, but not to + # rewrite it: no CreatePolicyVersion/SetDefaultPolicyVersion/DeletePolicy — + # loosening the ceiling is an out-of-band admin operation. + statement { + sid = "BoundaryPolicyRead" + effect = "Allow" + actions = [ + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:ListPolicyTags", + "iam:ListPolicyVersions", ] + resources = [local.boundary_policy_arn] + } + + statement { + sid = "BoundaryPolicyCreate" + effect = "Allow" + actions = [ + "iam:CreatePolicy", + "iam:TagPolicy", + "iam:UntagPolicy", + ] + resources = [local.boundary_policy_arn] } statement { @@ -153,6 +240,10 @@ data "aws_iam_policy_document" "terraform" { } } + # Security-group ids are generated at create time and Describe* is not + # resource-scopeable, so these stay on "*"; deleting a group is gated on the + # project tag below. + #trivy:ignore:AVD-AWS-0057 statement { sid = "SecurityGroups" effect = "Allow" @@ -161,7 +252,6 @@ data "aws_iam_policy_document" "terraform" { "ec2:AuthorizeSecurityGroupIngress", "ec2:CreateSecurityGroup", "ec2:CreateTags", - "ec2:DeleteSecurityGroup", "ec2:DeleteTags", "ec2:DescribeSecurityGroups", "ec2:DescribeSubnets", @@ -174,32 +264,128 @@ data "aws_iam_policy_document" "terraform" { resources = ["*"] } + #trivy:ignore:AVD-AWS-0057 + statement { + sid = "SecurityGroupsDestroy" + effect = "Allow" + actions = ["ec2:DeleteSecurityGroup"] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "aws:ResourceTag/Project" + values = [local.project_prefix] + } + } + + # IaC-4: explicit verbs instead of kafka:*Cluster*/kafka:*Configuration*, + # scoped to this project's cluster/configuration name patterns; cluster + # deletion additionally requires the project tag. + #trivy:ignore:AVD-AWS-0057 + statement { + sid = "MSKDiscovery" + effect = "Allow" + actions = [ + "kafka:DescribeClusterOperation", + "kafka:DescribeClusterOperationV2", + "kafka:ListClusters", + "kafka:ListClustersV2", + "kafka:ListConfigurations", + ] + resources = ["*"] + } + statement { - sid = "MSKClusters" + sid = "MSKClusterLifecycle" effect = "Allow" actions = [ - "kafka:*Cluster*", - "kafka:*Configuration*", + "kafka:CreateCluster", + "kafka:CreateClusterV2", + "kafka:DescribeCluster", + "kafka:DescribeClusterV2", "kafka:GetBootstrapBrokers", "kafka:ListTagsForResource", "kafka:TagResource", "kafka:UntagResource", + "kafka:UpdateBrokerCount", + "kafka:UpdateBrokerStorage", + "kafka:UpdateBrokerType", + "kafka:UpdateClusterConfiguration", + "kafka:UpdateClusterKafkaVersion", + "kafka:UpdateConnectivity", "kafka:UpdateMonitoring", + "kafka:UpdateSecurity", + "kafka:UpdateStorage", ] + resources = local.msk_cluster_arns + } + + statement { + sid = "MSKConfigurationLifecycle" + effect = "Allow" + actions = [ + "kafka:CreateConfiguration", + "kafka:DeleteConfiguration", + "kafka:DescribeConfiguration", + "kafka:DescribeConfigurationRevision", + "kafka:ListConfigurationRevisions", + "kafka:UpdateConfiguration", + ] + resources = local.msk_config_arns + } + + statement { + sid = "MSKDestroy" + effect = "Allow" + actions = ["kafka:DeleteCluster"] + resources = local.msk_cluster_arns + + condition { + test = "StringEquals" + variable = "aws:ResourceTag/Project" + values = [local.project_prefix] + } + } + + # IaC-4: explicit verbs instead of kinesisanalytics:*Application*, scoped to + # this project's application name pattern; deletion requires the project tag. + #trivy:ignore:AVD-AWS-0057 + statement { + sid = "FlinkDiscovery" + effect = "Allow" + actions = ["kinesisanalytics:ListApplications"] resources = ["*"] } statement { - sid = "ManagedFlinkApplications" + sid = "FlinkApplicationLifecycle" effect = "Allow" actions = [ - "kinesisanalytics:*Application*", - "kinesisanalytics:ListApplications", + "kinesisanalytics:CreateApplication", + "kinesisanalytics:DescribeApplication", + "kinesisanalytics:DescribeApplicationVersion", + "kinesisanalytics:ListApplicationVersions", "kinesisanalytics:ListTagsForResource", + "kinesisanalytics:StartApplication", + "kinesisanalytics:StopApplication", "kinesisanalytics:TagResource", "kinesisanalytics:UntagResource", + "kinesisanalytics:UpdateApplication", ] - resources = ["*"] + resources = local.flink_app_arns + } + + statement { + sid = "FlinkDestroy" + effect = "Allow" + actions = ["kinesisanalytics:DeleteApplication"] + resources = local.flink_app_arns + + condition { + test = "StringEquals" + variable = "aws:ResourceTag/Project" + values = [local.project_prefix] + } } statement { @@ -238,15 +424,17 @@ data "aws_iam_policy_document" "terraform" { "cloudwatch:TagResource", "cloudwatch:UntagResource", ] - resources = ["*"] + resources = local.alarm_arns } + # Workspace ids are generated at create time, so grafana verbs stay on "*"; + # workspace deletion is gated on the project tag below. + #trivy:ignore:AVD-AWS-0057 statement { sid = "GrafanaWorkspaces" effect = "Allow" actions = [ "grafana:CreateWorkspace", - "grafana:DeleteWorkspace", "grafana:DescribeWorkspace", "grafana:DescribeWorkspaceAuthentication", "grafana:DescribeWorkspaceConfiguration", @@ -262,6 +450,20 @@ data "aws_iam_policy_document" "terraform" { resources = ["*"] } + #trivy:ignore:AVD-AWS-0057 + statement { + sid = "GrafanaDestroy" + effect = "Allow" + actions = ["grafana:DeleteWorkspace"] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "aws:ResourceTag/Project" + values = [local.project_prefix] + } + } + statement { sid = "GrafanaDependencies" effect = "Allow" @@ -274,6 +476,203 @@ data "aws_iam_policy_document" "terraform" { ] resources = ["*"] } + + # Key ids are generated at create time, so key-scoped verbs use key/* within + # this account and region; alias verbs are pinned to the project prefix and + # key deletion is gated on the project tag. + #trivy:ignore:AVD-AWS-0057 + statement { + sid = "KMSKeyCreate" + effect = "Allow" + actions = ["kms:CreateKey"] + resources = ["*"] + } + + #trivy:ignore:AVD-AWS-0057 + statement { + sid = "KMSKeyLifecycle" + effect = "Allow" + actions = [ + "kms:DescribeKey", + "kms:EnableKeyRotation", + "kms:GetKeyPolicy", + "kms:GetKeyRotationStatus", + "kms:ListResourceTags", + "kms:TagResource", + "kms:UntagResource", + ] + resources = local.kms_key_arns + } + + #trivy:ignore:AVD-AWS-0057 + statement { + sid = "KMSAliasLifecycle" + effect = "Allow" + actions = [ + "kms:CreateAlias", + "kms:DeleteAlias", + "kms:UpdateAlias", + ] + resources = concat(local.kms_alias_arns, local.kms_key_arns) + } + + #trivy:ignore:AVD-AWS-0057 + statement { + sid = "KMSDestroy" + effect = "Allow" + actions = [ + "kms:DisableKey", + "kms:ScheduleKeyDeletion", + ] + resources = local.kms_key_arns + + condition { + test = "StringEquals" + variable = "aws:ResourceTag/Project" + values = [local.project_prefix] + } + } +} + +# Permissions boundary shared by the deploy role and the service roles it +# manages (IaC-2). It is a ceiling, not a grant — effective permissions are the +# intersection with each role's identity policy — so per-service wildcards here +# are intentional. The load-bearing part is what iam: allows exclude (no role +# mutation on the deploy role's own name pattern) plus the explicit denies: +# even a compromised deploy role cannot detach the boundary, rewrite it, or +# escalate itself. The two suppressed checks flag the ceiling's s3:* action +# (resource-pinned to project buckets) and PassRole on the service-role name +# patterns — both deliberate here. +#trivy:ignore:AVD-AWS-0342 +#trivy:ignore:AVD-AWS-0345 +data "aws_iam_policy_document" "permissions_boundary" { + #trivy:ignore:AVD-AWS-0057 + statement { + sid = "ProjectServicesCeiling" + effect = "Allow" + actions = [ + "cloudwatch:*", + "dynamodb:*", + "ec2:*", + "grafana:*", + "kafka-cluster:*", + "kafka:*", + "kinesisanalytics:*", + "kms:*", + "organizations:DescribeOrganization", + "sso:*", + ] + resources = ["*"] + } + + # Action wildcard is the point of a ceiling; the resource list is pinned to + # project-prefixed buckets, so this cannot reach the account's other buckets. + #trivy:ignore:AVD-AWS-0345 + statement { + sid = "ProjectBucketsCeiling" + effect = "Allow" + actions = ["s3:*"] + resources = local.project_bucket_arns + } + + #trivy:ignore:AVD-AWS-0057 + statement { + sid = "IamReadCeiling" + effect = "Allow" + actions = [ + "iam:Get*", + "iam:List*", + ] + resources = ["*"] + } + + # PassRole is limited to the project's service-role name patterns; the "*" + # in them is the environment suffix, not an open wildcard. + #trivy:ignore:AVD-AWS-0342 + statement { + sid = "IamPassRoleCeiling" + effect = "Allow" + actions = ["iam:PassRole"] + resources = local.service_role_arns + } + + #trivy:ignore:AVD-AWS-0057 + statement { + sid = "IamServiceLinkedRoleCeiling" + effect = "Allow" + actions = ["iam:CreateServiceLinkedRole"] + resources = ["*"] + } + + statement { + sid = "IamServiceRoleMutationCeiling" + effect = "Allow" + actions = [ + "iam:AttachRolePolicy", + "iam:CreateRole", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", + "iam:PutRolePolicy", + "iam:TagRole", + "iam:UntagRole", + "iam:UpdateAssumeRolePolicy", + ] + resources = local.service_role_arns + } + + statement { + sid = "IamOidcProviderCeiling" + effect = "Allow" + actions = [ + "iam:AddClientIDToOpenIDConnectProvider", + "iam:CreateOpenIDConnectProvider", + "iam:DeleteOpenIDConnectProvider", + "iam:RemoveClientIDFromOpenIDConnectProvider", + "iam:TagOpenIDConnectProvider", + "iam:UntagOpenIDConnectProvider", + "iam:UpdateOpenIDConnectProviderThumbprint", + ] + resources = [local.oidc_provider_arn] + } + + statement { + sid = "IamBoundaryPolicyCreateCeiling" + effect = "Allow" + actions = [ + "iam:CreatePolicy", + "iam:TagPolicy", + "iam:UntagPolicy", + ] + resources = [local.boundary_policy_arn] + } + + statement { + sid = "DenyBoundaryDetach" + effect = "Deny" + actions = [ + "iam:DeleteRolePermissionsBoundary", + "iam:PutRolePermissionsBoundary", + ] + resources = ["*"] + } + + statement { + sid = "DenyBoundaryPolicyMutation" + effect = "Deny" + actions = [ + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:SetDefaultPolicyVersion", + ] + resources = [local.boundary_policy_arn] + } +} + +resource "aws_iam_policy" "permissions_boundary" { + name = local.boundary_policy_name + policy = data.aws_iam_policy_document.permissions_boundary.json } resource "aws_iam_openid_connect_provider" "github_actions" { @@ -283,8 +682,9 @@ resource "aws_iam_openid_connect_provider" "github_actions" { } resource "aws_iam_role" "github_actions" { - name = var.role_name - assume_role_policy = data.aws_iam_policy_document.assume_role.json + name = var.role_name + assume_role_policy = data.aws_iam_policy_document.assume_role.json + permissions_boundary = aws_iam_policy.permissions_boundary.arn } resource "aws_iam_role_policy" "terraform" { diff --git a/infrastructure/terraform/modules/github-oidc/outputs.tf b/infrastructure/terraform/modules/github-oidc/outputs.tf index 1e7d3b05..a0aff45d 100644 --- a/infrastructure/terraform/modules/github-oidc/outputs.tf +++ b/infrastructure/terraform/modules/github-oidc/outputs.tf @@ -7,3 +7,8 @@ output "provider_arn" { description = "ARN of the GitHub Actions OIDC provider" value = aws_iam_openid_connect_provider.github_actions.arn } + +output "permissions_boundary_arn" { + description = "ARN of the permissions boundary required on project service roles" + value = aws_iam_policy.permissions_boundary.arn +} diff --git a/infrastructure/terraform/modules/kafka/main.tf b/infrastructure/terraform/modules/kafka/main.tf index 298d7973..69a15ce5 100644 --- a/infrastructure/terraform/modules/kafka/main.tf +++ b/infrastructure/terraform/modules/kafka/main.tf @@ -5,6 +5,15 @@ variable "instance_type" { type = string } variable "broker_count" { type = number } variable "ebs_volume_size_gb" { type = number } +# Broker ingress is limited to the CIDRs of the subnets the cluster (and its +# clients — Flink runs in the same private subnets) actually lives in, not the +# whole 10.0.0.0/8. Broker-to-broker traffic stays allowed because the brokers' +# own subnets are in this same list. +data "aws_subnet" "client" { + for_each = toset(var.subnet_ids) + id = each.value +} + resource "aws_security_group" "kafka" { name_prefix = "agentflow-kafka-${var.environment}-" vpc_id = var.vpc_id @@ -13,19 +22,35 @@ resource "aws_security_group" "kafka" { from_port = 9092 to_port = 9098 protocol = "tcp" - cidr_blocks = ["10.0.0.0/8"] - description = "Kafka broker ports" + cidr_blocks = [for s in data.aws_subnet.client : s.cidr_block] + description = "Kafka broker ports (cluster + client subnets only)" } + # Brokers only talk to each other (replication, KRaft) inside these subnets; + # MSK control-plane and log/metric delivery go over service-managed ENIs, not + # this security group. egress { from_port = 0 to_port = 0 protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - description = "Allow all outbound" + cidr_blocks = [for s in data.aws_subnet.client : s.cidr_block] + description = "Inter-broker traffic within cluster subnets" } } +# Customer-managed key for broker EBS at-rest encryption (instead of the +# implicit aws/kafka key): rotation, usage audit and revocation stay in +# project control. +resource "aws_kms_key" "kafka" { + description = "agentflow ${var.environment} MSK at-rest encryption" + enable_key_rotation = true +} + +resource "aws_kms_alias" "kafka" { + name = "alias/agentflow-${var.environment}-kafka" + target_key_id = aws_kms_key.kafka.key_id +} + resource "aws_msk_configuration" "main" { name = "agentflow-${var.environment}" kafka_versions = ["3.7.x.kraft"] @@ -68,9 +93,20 @@ resource "aws_msk_cluster" "main" { revision = aws_msk_configuration.main.latest_revision } + # TLS-only client traffic + IAM client authentication: an in-VPC foothold + # alone is no longer enough to read or write the event stream — a client + # must present SigV4 credentials authorized for kafka-cluster:* actions. + client_authentication { + sasl { + iam = true + } + } + encryption_info { + encryption_at_rest_kms_key_arn = aws_kms_key.kafka.arn + encryption_in_transit { - client_broker = "TLS_PLAINTEXT" + client_broker = "TLS" in_cluster = true } } @@ -96,8 +132,10 @@ resource "aws_msk_cluster" "main" { } } -output "bootstrap_brokers" { - value = aws_msk_cluster.main.bootstrap_brokers +# SASL/IAM endpoints (port 9098). The plaintext bootstrap_brokers attribute is +# empty once client_broker = "TLS", so it is deliberately not exported. +output "bootstrap_brokers_sasl_iam" { + value = aws_msk_cluster.main.bootstrap_brokers_sasl_iam } output "cluster_arn" { diff --git a/infrastructure/terraform/modules/monitoring/main.tf b/infrastructure/terraform/modules/monitoring/main.tf index b7115ff3..9338e13f 100644 --- a/infrastructure/terraform/modules/monitoring/main.tf +++ b/infrastructure/terraform/modules/monitoring/main.tf @@ -3,6 +3,7 @@ variable "kafka_cluster_arn" { type = string } variable "flink_application_arn" { type = string } variable "sns_alert_topic_arn" { type = string } variable "freshness_sla_seconds" { type = number } +variable "permissions_boundary_arn" { type = string } # ── CloudWatch Alarms ─────────────────────────────────────────── @@ -87,7 +88,8 @@ resource "aws_grafana_workspace" "main" { } resource "aws_iam_role" "grafana" { - name = "agentflow-grafana-${var.environment}" + name = "agentflow-grafana-${var.environment}" + permissions_boundary = var.permissions_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" diff --git a/infrastructure/terraform/modules/storage/main.tf b/infrastructure/terraform/modules/storage/main.tf index e4c96af5..5a3b8b2d 100644 --- a/infrastructure/terraform/modules/storage/main.tf +++ b/infrastructure/terraform/modules/storage/main.tf @@ -3,6 +3,18 @@ variable "lake_bucket_name" { type = string } variable "lifecycle_glacier_days" { type = number } variable "lifecycle_expire_days" { type = number } +# Customer-managed key so key rotation, usage audit and revocation stay in +# project control instead of the AWS-managed aws/s3 key. +resource "aws_kms_key" "lake" { + description = "agentflow ${var.environment} lake bucket at-rest encryption" + enable_key_rotation = true +} + +resource "aws_kms_alias" "lake" { + name = "alias/agentflow-${var.environment}-lake" + target_key_id = aws_kms_key.lake.key_id +} + resource "aws_s3_bucket" "lake" { bucket = var.lake_bucket_name } @@ -19,7 +31,8 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "lake" { rule { apply_server_side_encryption_by_default { - sse_algorithm = "aws:kms" + sse_algorithm = "aws:kms" + kms_master_key_id = aws_kms_key.lake.arn } bucket_key_enabled = true } @@ -92,3 +105,7 @@ output "lake_bucket_name" { output "lake_bucket_arn" { value = aws_s3_bucket.lake.arn } + +output "lake_kms_key_arn" { + value = aws_kms_key.lake.arn +} diff --git a/infrastructure/terraform/outputs.tf b/infrastructure/terraform/outputs.tf index b68feebd..8d62a321 100644 --- a/infrastructure/terraform/outputs.tf +++ b/infrastructure/terraform/outputs.tf @@ -1,6 +1,6 @@ output "kafka_bootstrap_brokers" { - description = "Kafka bootstrap broker connection string" - value = module.kafka.bootstrap_brokers + description = "Kafka bootstrap broker connection string (SASL/IAM listeners)" + value = module.kafka.bootstrap_brokers_sasl_iam } output "flink_application_arn" {