Multi-Cloud Hardening: Consistent Security Posture Across Providers

Multi-Cloud Hardening: Consistent Security Posture Across Providers

Problem

Running infrastructure across multiple cloud providers means maintaining consistent security controls across fundamentally different systems. AWS security groups, GCP firewall rules, and smaller provider firewalls all accomplish the same goal with different syntax, different defaults, and different failure modes. IAM models diverge significantly: AWS IAM policies, GCP IAM bindings, and provider-specific RBAC systems all express access control differently.

Most multi-cloud deployments end up with inconsistent security postures. The AWS account has fine-grained IAM policies because that is where the team started. The GCP project has overly permissive roles because it was set up quickly for a specific workload. The smaller provider has whatever defaults it shipped with. Nobody has a unified view of security posture across all providers.

The result: your security is only as strong as your weakest provider configuration. An attacker does not need to breach your hardened AWS account when your GCP project has a public storage bucket.

Target systems: Infrastructure spanning AWS, GCP, and/or smaller providers (Civo, Vultr, DigitalOcean). Terraform for infrastructure as code. Centralised observability across providers.

Threat Model

  • Adversary: Attacker targeting the least-hardened provider in a multi-cloud deployment. Also: automated scanners that discover misconfigured resources across any cloud provider.
  • Objective: Exploit inconsistent security controls. Find the public bucket, the overly permissive IAM role, or the unpatched VM on the provider that received less attention.
  • Blast radius: A breach on one provider may provide credentials or network access to resources on other providers (cross-cloud VPN, shared secrets, federated identity). Multi-cloud does not inherently limit blast radius unless provider boundaries are enforced as trust boundaries.

Configuration

Unified Firewall Module

Abstract provider-specific firewall syntax behind a common Terraform interface.

# modules/firewall/main.tf
# Unified firewall module that works across providers

variable "provider_type" {
  type        = string
  description = "Cloud provider: aws, gcp, civo"
}

variable "rules" {
  type = list(object({
    name      = string
    direction = string  # "ingress" or "egress"
    protocol  = string
    port      = number
    source    = string  # CIDR block
    action    = string  # "allow" or "deny"
  }))
}

# AWS Security Group
resource "aws_security_group" "this" {
  count = var.provider_type == "aws" ? 1 : 0
  name  = "hardened-sg"

  dynamic "ingress" {
    for_each = [for r in var.rules : r if r.direction == "ingress" && r.action == "allow"]
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = ingress.value.protocol
      cidr_blocks = [ingress.value.source]
      description = ingress.value.name
    }
  }

  dynamic "egress" {
    for_each = [for r in var.rules : r if r.direction == "egress" && r.action == "allow"]
    content {
      from_port   = egress.value.port
      to_port     = egress.value.port
      protocol    = egress.value.protocol
      cidr_blocks = [egress.value.source]
      description = egress.value.name
    }
  }

  tags = {
    managed-by = "terraform"
    security   = "hardened"
  }
}

# GCP Firewall Rule
resource "google_compute_firewall" "this" {
  for_each = var.provider_type == "gcp" ? {
    for r in var.rules : r.name => r
  } : {}

  name      = each.value.name
  network   = var.network_name
  direction = upper(each.value.direction)

  dynamic "allow" {
    for_each = each.value.action == "allow" ? [1] : []
    content {
      protocol = each.value.protocol
      ports    = [each.value.port]
    }
  }

  dynamic "deny" {
    for_each = each.value.action == "deny" ? [1] : []
    content {
      protocol = each.value.protocol
      ports    = [each.value.port]
    }
  }

  source_ranges = each.value.direction == "ingress" ? [each.value.source] : null
}

# Civo Firewall
resource "civo_firewall" "this" {
  count = var.provider_type == "civo" ? 1 : 0
  name  = "hardened-fw"

  dynamic "ingress_rule" {
    for_each = [for r in var.rules : r if r.direction == "ingress"]
    content {
      label      = ingress_rule.value.name
      protocol   = ingress_rule.value.protocol
      port_range = tostring(ingress_rule.value.port)
      cidr       = [ingress_rule.value.source]
      action     = ingress_rule.value.action
    }
  }
}
# Usage: consistent rules across all providers
module "firewall_aws" {
  source        = "./modules/firewall"
  provider_type = "aws"
  rules         = local.standard_firewall_rules
}

module "firewall_gcp" {
  source        = "./modules/firewall"
  provider_type = "gcp"
  network_name  = google_compute_network.main.name
  rules         = local.standard_firewall_rules
}

module "firewall_civo" {
  source        = "./modules/firewall"
  provider_type = "civo"
  rules         = local.standard_firewall_rules
}

locals {
  standard_firewall_rules = [
    {
      name      = "allow-https"
      direction = "ingress"
      protocol  = "tcp"
      port      = 443
      source    = "0.0.0.0/0"
      action    = "allow"
    },
    {
      name      = "allow-ssh-vpn-only"
      direction = "ingress"
      protocol  = "tcp"
      port      = 22
      source    = "10.0.0.0/8"  # VPN CIDR only
      action    = "allow"
    },
  ]
}

Unified IAM Role Definitions

Map common role definitions to provider-specific IAM constructs.

# modules/iam-role/main.tf
variable "role_name" {
  type = string
}

variable "role_type" {
  type        = string
  description = "Standard role: readonly, deployer, admin"
  validation {
    condition     = contains(["readonly", "deployer", "admin"], var.role_type)
    error_message = "role_type must be: readonly, deployer, or admin"
  }
}

variable "provider_type" {
  type = string
}

# AWS IAM Role
resource "aws_iam_role" "this" {
  count = var.provider_type == "aws" ? 1 : 0
  name  = var.role_name

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Federated = var.oidc_provider_arn
      }
      Condition = {
        StringEquals = {
          "${var.oidc_provider}:sub" = "system:serviceaccount:${var.namespace}:${var.service_account}"
        }
      }
    }]
  })
}

resource "aws_iam_role_policy" "this" {
  count = var.provider_type == "aws" ? 1 : 0
  name  = "${var.role_name}-policy"
  role  = aws_iam_role.this[0].id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = var.role_type == "readonly" ? [
      {
        Effect   = "Allow"
        Action   = ["s3:GetObject", "s3:ListBucket"]
        Resource = var.resource_arns
      }
    ] : var.role_type == "deployer" ? [
      {
        Effect   = "Allow"
        Action   = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
        Resource = var.resource_arns
      },
      {
        Effect   = "Allow"
        Action   = ["ecr:GetAuthorizationToken", "ecr:BatchGetImage", "ecr:PutImage"]
        Resource = "*"
      }
    ] : []  # admin handled separately with explicit review
  })
}

# GCP IAM Binding
resource "google_project_iam_member" "this" {
  for_each = var.provider_type == "gcp" ? toset(
    var.role_type == "readonly" ? ["roles/storage.objectViewer"] :
    var.role_type == "deployer" ? ["roles/storage.objectAdmin", "roles/container.developer"] :
    []
  ) : toset([])

  project = var.project_id
  role    = each.value
  member  = "serviceAccount:${var.service_account_email}"
}

Cross-Cloud Network Security

# cross-cloud-vpn.tf
# Encrypted site-to-site VPN between providers

# AWS side
resource "aws_vpn_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "cross-cloud-vpn" }
}

resource "aws_customer_gateway" "gcp" {
  bgp_asn    = 65000
  ip_address = google_compute_address.vpn_ip.address
  type       = "ipsec.1"
  tags       = { Name = "gcp-gateway" }
}

resource "aws_vpn_connection" "to_gcp" {
  vpn_gateway_id      = aws_vpn_gateway.main.id
  customer_gateway_id = aws_customer_gateway.gcp.id
  type                = "ipsec.1"
  static_routes_only  = true

  tags = { Name = "aws-to-gcp" }
}

# Route only specific CIDRs through the VPN
# Do NOT route all traffic cross-cloud
resource "aws_vpn_connection_route" "gcp_services" {
  vpn_connection_id      = aws_vpn_connection.to_gcp.id
  destination_cidr_block = "10.100.0.0/16"  # GCP service subnet only
}

Centralised Observability

# otel-collector-multicloud.yaml
# Single OTel collector config that normalises metrics from all providers
apiVersion: v1
kind: ConfigMap
metadata:
  name: otel-collector-config
data:
  config.yaml: |
    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317

      # AWS CloudWatch metrics
      awscloudwatch:
        region: us-east-1
        metrics:
          named:
            - namespace: AWS/EC2
              metric_name: CPUUtilization
              period: 300
            - namespace: AWS/RDS
              metric_name: DatabaseConnections
              period: 300

      # GCP Monitoring metrics
      googlecloudmonitoring:
        project: my-project
        metrics_list:
          - metric_type: "compute.googleapis.com/instance/cpu/utilization"

    processors:
      # Normalise provider-specific labels to common schema
      attributes:
        actions:
          - key: cloud.provider
            action: upsert
          - key: cloud.region
            action: upsert
          - key: cloud.account_id
            action: upsert

    exporters:
      prometheusremotewrite:
        endpoint: "https://prometheus.grafana.net/api/prom/push"
        headers:
          Authorization: "Bearer ${GRAFANA_CLOUD_TOKEN}"

    service:
      pipelines:
        metrics:
          receivers: [otlp, awscloudwatch, googlecloudmonitoring]
          processors: [attributes]
          exporters: [prometheusremotewrite]

Provider-Specific Security Features

Some provider-specific security features should not be abstracted away. Use them directly.

# Use AWS GuardDuty - do not abstract this
resource "aws_guardduty_detector" "main" {
  enable = true

  datasources {
    s3_logs { enable = true }
    kubernetes { audit_logs { enable = true } }
    malware_protection { scan_ec2_instance_with_findings { ebs_volumes { enable = true } } }
  }
}

# Use GCP Security Command Center - do not abstract this
resource "google_project_service" "scc" {
  service = "securitycenter.googleapis.com"
}

# Forward findings from both to a central alert pipeline
# GuardDuty -> EventBridge -> SNS -> PagerDuty
# SCC -> Pub/Sub -> Cloud Function -> PagerDuty

Expected Behaviour

  • Firewall rules are defined once and applied consistently across all providers via Terraform
  • IAM roles follow the same permission model (readonly, deployer, admin) regardless of provider
  • Cross-cloud VPN traffic is encrypted and routed only to specific service CIDRs
  • All metrics and logs from all providers flow to a single observability backend
  • Provider-specific security features (GuardDuty, SCC) are enabled and alert to a central pipeline
  • A security posture change on one provider triggers the same Terraform module update for all providers

Trade-offs

Decision Impact Risk Mitigation
Unified Terraform modules Consistent security across providers Modules must handle provider-specific edge cases; adds abstraction complexity Keep modules focused on common patterns. Provider-specific features stay outside the abstraction.
Single observability backend Unified security view across all infrastructure Vendor dependency on observability provider OTel collector makes the exporter swappable. Switch backends without changing instrumentation.
Cross-cloud VPN Encrypted inter-provider communication VPN becomes a single point of failure for cross-cloud services Run redundant VPN tunnels. Design services to degrade gracefully if cross-cloud connectivity fails.
Provider-specific features not abstracted Best-in-class detection per provider (GuardDuty, SCC) Different alert formats, different severity scales, different response procedures per provider Normalise alert severity in the central pipeline. Map provider-specific findings to common categories.

Failure Modes

Failure Symptom Detection Recovery
Terraform module drift between providers Security groups on one provider do not match intent terraform plan shows unexpected diff; compliance scan detects inconsistency Run terraform apply to reconcile. Add CI check that runs terraform plan on every PR.
Cross-cloud VPN tunnel down Services cannot reach cross-cloud dependencies VPN health check fails; application timeout errors Redundant tunnels. If both fail, services should return degraded responses (not crash).
Provider IAM mapping incorrect Role on one provider has more permissions than intended Periodic IAM audit script compares effective permissions across providers Fix the Terraform module mapping. Add integration tests that verify IAM role capabilities per provider.
Observability backend unreachable No metrics or logs from any provider OTel collector health check; no data in dashboards OTel collector buffers locally. Switch to secondary backend or self-hosted Prometheus as fallback.

When to Consider a Managed Alternative

Grafana Cloud (#108) for cloud-agnostic observability that ingests metrics, logs, and traces from any provider through OTel. Civo (#22) and Vultr (#12) as managed Kubernetes alternatives to hyperscalers, offering simpler security models with fewer provider-specific quirks. For teams that find multi-cloud Terraform abstraction too complex, standardising on a single Kubernetes distribution across providers reduces the abstraction surface.

Premium content pack: Multi-cloud Terraform module pack. Unified firewall, IAM, and VPN modules for AWS, GCP, and Civo. OTel collector configurations for cross-cloud metric normalisation. Compliance check scripts that compare security posture across providers.