IAM Showdown: AWS vs GCP vs Azure Identity
Master identity and access management across cloud providers. Compare IAM models, best practices for least privilege, and cross-cloud identity federation strategies.
Identity is the new perimeter. Get IAM wrong and you’ll be cleaning up breaches. Each cloud has a different model — AWS uses policies attached to principals, GCP binds roles to resources, and Azure uses role assignments with scope. Let’s untangle them.
IAM Model Comparison
| Concept | AWS | GCP | Azure |
|---|---|---|---|
| User Identity | IAM User | Google Account | Azure AD User |
| Service Identity | IAM Role | Service Account | Managed Identity |
| Permission Set | IAM Policy | IAM Role | Role Definition |
| Assignment | Policy Attachment | IAM Binding | Role Assignment |
| Hierarchy | Account → Resource | Org → Folder → Project | Management Group → Subscription → RG |
| Inheritance | No | Yes (downward) | Yes (downward) |
AWS IAM
IAM Policies
# Policy document
data "aws_iam_policy_document" "s3_access" {
statement {
sid = "ReadBucket"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:ListBucket"
]
resources = [
aws_s3_bucket.data.arn,
"${aws_s3_bucket.data.arn}/*"
]
}
statement {
sid = "WriteLogs"
effect = "Allow"
actions = [
"s3:PutObject"
]
resources = [
"${aws_s3_bucket.logs.arn}/app-logs/*"
]
condition {
test = "StringEquals"
variable = "s3:x-amz-acl"
values = ["bucket-owner-full-control"]
}
}
statement {
sid = "DenyDelete"
effect = "Deny"
actions = [
"s3:DeleteObject",
"s3:DeleteBucket"
]
resources = ["*"]
}
}
resource "aws_iam_policy" "s3_access" {
name = "s3-access-policy"
description = "S3 access for application"
policy = data.aws_iam_policy_document.s3_access.json
}
IAM Roles for Services
# Role for Lambda
resource "aws_iam_role" "lambda" {
name = "lambda-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}]
})
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy_attachment" "lambda_s3" {
role = aws_iam_role.lambda.name
policy_arn = aws_iam_policy.s3_access.arn
}
# Role for EKS service account (IRSA)
resource "aws_iam_role" "eks_pod" {
name = "eks-pod-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRoleWithWebIdentity"
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.eks.arn
}
Condition = {
StringEquals = {
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" = "system:serviceaccount:default:my-app"
}
}
}]
})
}
Cross-Account Access
# In account A: Role that account B can assume
resource "aws_iam_role" "cross_account" {
name = "cross-account-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${var.account_b_id}:root"
}
Condition = {
StringEquals = {
"sts:ExternalId" = var.external_id
}
}
}]
})
}
# In account B: Policy to assume role
data "aws_iam_policy_document" "assume_role" {
statement {
actions = ["sts:AssumeRole"]
resources = [
"arn:aws:iam::${var.account_a_id}:role/cross-account-role"
]
}
}
Permission Boundaries
# Boundary that limits maximum permissions
resource "aws_iam_policy" "boundary" {
name = "developer-boundary"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:*",
"dynamodb:*",
"lambda:*",
"logs:*",
"cloudwatch:*"
]
Resource = "*"
},
{
Effect = "Deny"
Action = [
"iam:*",
"organizations:*",
"ec2:*NetworkAcl*",
"ec2:*SecurityGroup*"
]
Resource = "*"
}
]
})
}
resource "aws_iam_role" "developer" {
name = "developer-role"
permissions_boundary = aws_iam_policy.boundary.arn
# ... assume_role_policy
}
GCP IAM
IAM Bindings
# Project-level binding
resource "google_project_iam_member" "storage_viewer" {
project = var.project_id
role = "roles/storage.objectViewer"
member = "serviceAccount:${google_service_account.app.email}"
}
# Resource-level binding (more granular)
resource "google_storage_bucket_iam_member" "data_access" {
bucket = google_storage_bucket.data.name
role = "roles/storage.objectViewer"
member = "serviceAccount:${google_service_account.app.email}"
}
# Conditional IAM binding
resource "google_project_iam_member" "conditional" {
project = var.project_id
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.app.email}"
condition {
title = "expires_after_2025"
description = "Expires at end of 2025"
expression = "request.time < timestamp('2026-01-01T00:00:00Z')"
}
}
# Multiple bindings with google_project_iam_binding
resource "google_project_iam_binding" "editors" {
project = var.project_id
role = "roles/editor"
members = [
"user:[email protected]",
"serviceAccount:${google_service_account.cicd.email}",
]
}
Service Accounts
resource "google_service_account" "app" {
account_id = "my-app"
display_name = "My Application Service Account"
project = var.project_id
}
# Key (avoid if possible - use Workload Identity instead)
resource "google_service_account_key" "app" {
service_account_id = google_service_account.app.name
}
# Allow GKE to use service account (Workload Identity)
resource "google_service_account_iam_member" "workload_identity" {
service_account_id = google_service_account.app.name
role = "roles/iam.workloadIdentityUser"
member = "serviceAccount:${var.project_id}.svc.id.goog[default/my-app]"
}
Custom Roles
resource "google_project_iam_custom_role" "custom" {
role_id = "customStorageReader"
title = "Custom Storage Reader"
description = "Read-only access to specific buckets"
permissions = [
"storage.buckets.get",
"storage.buckets.list",
"storage.objects.get",
"storage.objects.list",
]
project = var.project_id
}
Organization Policy Constraints
resource "google_organization_policy" "disable_sa_keys" {
org_id = var.org_id
constraint = "iam.disableServiceAccountKeyCreation"
boolean_policy {
enforced = true
}
}
resource "google_organization_policy" "allowed_locations" {
org_id = var.org_id
constraint = "gcp.resourceLocations"
list_policy {
allow {
values = ["us-locations", "eu-locations"]
}
}
}
Azure IAM
Role Assignments
# Built-in role assignment
resource "azurerm_role_assignment" "contributor" {
scope = azurerm_resource_group.main.id
role_definition_name = "Contributor"
principal_id = azuread_service_principal.app.object_id
}
# At subscription level
resource "azurerm_role_assignment" "reader" {
scope = data.azurerm_subscription.current.id
role_definition_name = "Reader"
principal_id = azuread_group.developers.object_id
}
# At resource level
resource "azurerm_role_assignment" "storage" {
scope = azurerm_storage_account.main.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_user_assigned_identity.app.principal_id
}
Managed Identities
# System-assigned identity (tied to resource lifecycle)
resource "azurerm_linux_virtual_machine" "app" {
name = "app-vm"
# ...
identity {
type = "SystemAssigned"
}
}
# User-assigned identity (independent lifecycle)
resource "azurerm_user_assigned_identity" "app" {
name = "app-identity"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
}
# Attach to resource
resource "azurerm_linux_virtual_machine" "app" {
name = "app-vm"
# ...
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.app.id]
}
}
Custom Role Definitions
resource "azurerm_role_definition" "custom" {
name = "Custom VM Operator"
scope = data.azurerm_subscription.current.id
description = "Start/stop VMs without full contributor"
permissions {
actions = [
"Microsoft.Compute/virtualMachines/start/action",
"Microsoft.Compute/virtualMachines/powerOff/action",
"Microsoft.Compute/virtualMachines/restart/action",
"Microsoft.Compute/virtualMachines/read",
]
not_actions = []
}
assignable_scopes = [
data.azurerm_subscription.current.id,
]
}
Azure AD Groups
resource "azuread_group" "developers" {
display_name = "Developers"
security_enabled = true
}
resource "azuread_group_member" "developer" {
group_object_id = azuread_group.developers.id
member_object_id = azuread_user.developer.object_id
}
# Assign role to group
resource "azurerm_role_assignment" "developers" {
scope = azurerm_resource_group.dev.id
role_definition_name = "Contributor"
principal_id = azuread_group.developers.object_id
}
Cross-Cloud Identity Federation
AWS to GCP (Workload Identity Federation)
# In GCP: Create workload identity pool
resource "google_iam_workload_identity_pool" "aws" {
workload_identity_pool_id = "aws-pool"
display_name = "AWS Pool"
project = var.project_id
}
resource "google_iam_workload_identity_pool_provider" "aws" {
workload_identity_pool_id = google_iam_workload_identity_pool.aws.workload_identity_pool_id
workload_identity_pool_provider_id = "aws-provider"
display_name = "AWS Provider"
project = var.project_id
aws {
account_id = var.aws_account_id
}
}
# Grant access to GCP service account
resource "google_service_account_iam_member" "aws_access" {
service_account_id = google_service_account.app.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.aws.name}/attribute.aws_role/arn:aws:sts::${var.aws_account_id}:assumed-role/${var.aws_role_name}"
}
GCP to AWS (OIDC Federation)
# In AWS: Create OIDC provider
resource "aws_iam_openid_connect_provider" "gcp" {
url = "https://accounts.google.com"
client_id_list = [
google_service_account.app.unique_id
]
thumbprint_list = [var.google_oidc_thumbprint]
}
# Role that GCP can assume
resource "aws_iam_role" "gcp_access" {
name = "gcp-access-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRoleWithWebIdentity"
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.gcp.arn
}
Condition = {
StringEquals = {
"accounts.google.com:sub" = google_service_account.app.unique_id
}
}
}]
})
}
Best Practices Checklist
# IAM security checklist
general:
- Use groups/roles, not individual user permissions
- Implement least privilege from day one
- Enable MFA for all human users
- Use service accounts/managed identities for machines
- Rotate credentials regularly
- Log all IAM changes
aws_specific:
- Use permission boundaries for delegated admin
- Enable AWS CloudTrail for IAM actions
- Use AWS Organizations SCPs for guardrails
- Prefer IRSA over node roles for EKS
gcp_specific:
- Use Workload Identity instead of SA keys
- Apply organization policies for constraints
- Use conditional IAM bindings for time-based access
- Grant roles at lowest scope possible
azure_specific:
- Use managed identities over service principals
- Implement Privileged Identity Management (PIM)
- Use Azure AD groups for role assignments
- Enable Azure AD Identity Protection
Key Takeaways
- GCP IAM is hierarchical — permissions inherit down from org to folder to project
- AWS uses explicit policy attachment — no inheritance, more control
- Azure centers on AD — groups and PIM are essential
- Avoid long-lived credentials — use federation and managed identities
- Audit everything — CloudTrail, Audit Logs, Activity Logs are non-negotiable
“The principle of least privilege isn’t just security theater — it’s the difference between a contained incident and a complete breach.”