Back to Blog

AWS IAM Least-Privilege Design: Policy Patterns, Condition Keys, and Terraform

Design AWS IAM policies following least-privilege principles. Covers resource-level permissions, condition keys for MFA and region locking, role chaining, service-linked roles, permission boundaries, Terraform IAM modules, and common over-permissive patterns to avoid.

Viprasol Tech Team
April 28, 2027
13 min read

"Action": "*", "Resource": "*" — this line appears in AWS documentation examples and quickly finds its way into production policies where it was never meant to be permanent. Over-permissive IAM is one of the most common causes of cloud security incidents: a compromised Lambda function can read every secret, a misconfigured EC2 instance can exfiltrate all S3 data, a developer credential can delete production databases.

Least-privilege IAM is not about being paranoid — it's about ensuring that when something goes wrong, the blast radius is bounded.

The Least-Privilege Checklist

For every IAM role and policy:

  1. Actions: Only the specific actions needed (not wildcards unless truly required)
  2. Resources: Only the specific ARNs (not * unless the service doesn't support resource-level permissions)
  3. Conditions: Add where meaningful — region, MFA, VPC, time of day
  4. Review: Rotate and audit quarterly using AWS Access Analyzer

Application Roles: ECS Task

# terraform/iam/ecs-task-role.tf

# Task execution role: allows ECS to pull images + fetch secrets
resource "aws_iam_role" "ecs_execution" {
  name = "${var.app_name}-ecs-execution-${var.environment}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ecs_execution_managed" {
  role       = aws_iam_role.ecs_execution.name
  # AWS-managed policy: ECR pull + CloudWatch logs + SSM parameter read
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# Grant access to specific secrets only (not all secrets)
resource "aws_iam_role_policy" "ecs_execution_secrets" {
  name = "secrets-access"
  role = aws_iam_role.ecs_execution.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["secretsmanager:GetSecretValue"]
      Resource = [
        "arn:aws:secretsmanager:${var.aws_region}:${var.aws_account_id}:secret:${var.app_name}/${var.environment}/*"
      ]
    }]
  })
}

# Task role: what the application can DO at runtime
resource "aws_iam_role" "ecs_task" {
  name = "${var.app_name}-ecs-task-${var.environment}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "ecs_task_app" {
  name = "app-permissions"
  role = aws_iam_role.ecs_task.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      # S3: access only app bucket, only specific prefixes
      {
        Effect   = "Allow"
        Action   = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
        Resource = ["arn:aws:s3:::${var.app_name}-${var.environment}/uploads/*"]
      },
      {
        Effect   = "Allow"
        Action   = ["s3:ListBucket"]
        Resource = ["arn:aws:s3:::${var.app_name}-${var.environment}"]
        Condition = {
          StringLike = { "s3:prefix" = ["uploads/*"] }
        }
      },
      # SQS: access only specific queues
      {
        Effect   = "Allow"
        Action   = ["sqs:SendMessage", "sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
        Resource = [
          "arn:aws:sqs:${var.aws_region}:${var.aws_account_id}:${var.app_name}-*.fifo",
          "arn:aws:sqs:${var.aws_region}:${var.aws_account_id}:${var.app_name}-*"
        ]
      },
      # SES: send email only from app domain
      {
        Effect   = "Allow"
        Action   = ["ses:SendEmail", "ses:SendRawEmail"]
        Resource = ["arn:aws:ses:${var.aws_region}:${var.aws_account_id}:identity/mail.viprasol.com"]
      },
      # CloudWatch: write logs + metrics
      {
        Effect   = "Allow"
        Action   = ["logs:CreateLogStream", "logs:PutLogEvents", "cloudwatch:PutMetricData"]
        Resource = ["*"]  # CloudWatch doesn't support resource-level for these actions
      }
    ]
  })
}

☁️ Is Your Cloud Costing Too Much?

Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.

  • AWS, GCP, Azure certified engineers
  • Infrastructure as Code (Terraform, CDK)
  • Docker, Kubernetes, GitHub Actions CI/CD
  • Typical audit recovers $500–$3,000/month in savings

Lambda Execution Role

# terraform/iam/lambda-role.tf

resource "aws_iam_role" "lambda" {
  name = "${var.function_name}-lambda-${var.environment}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

# Basic Lambda execution: CloudWatch logs only
resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# VPC access (if Lambda runs in VPC)
resource "aws_iam_role_policy_attachment" "lambda_vpc" {
  count      = var.vpc_enabled ? 1 : 0
  role       = aws_iam_role.lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}

# Function-specific permissions (principle of least privilege)
resource "aws_iam_role_policy" "lambda_app" {
  name = "function-permissions"
  role = aws_iam_role.lambda.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      # Only read from specific DynamoDB table + only specific attributes
      {
        Effect   = "Allow"
        Action   = ["dynamodb:GetItem", "dynamodb:Query"]
        Resource = ["arn:aws:dynamodb:${var.aws_region}:${var.aws_account_id}:table/${var.table_name}"]
      },
      # SSM Parameter Store: only specific paths
      {
        Effect   = "Allow"
        Action   = ["ssm:GetParameter", "ssm:GetParameters"]
        Resource = [
          "arn:aws:ssm:${var.aws_region}:${var.aws_account_id}:parameter/${var.app_name}/${var.environment}/*"
        ]
      }
    ]
  })
}

Developer Roles with Condition Keys

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "RequireMFAForSensitiveActions",
      "Effect": "Deny",
      "Action": [
        "iam:CreateUser",
        "iam:DeleteUser",
        "iam:AttachUserPolicy",
        "iam:CreateAccessKey",
        "ec2:TerminateInstances",
        "rds:DeleteDBInstance",
        "s3:DeleteBucket"
      ],
      "Resource": "*",
      "Condition": {
        "BoolIfExists": {
          "aws:MultiFactorAuthPresent": "false"
        }
      }
    },
    {
      "Sid": "LockToApprovedRegions",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": ["us-east-1", "eu-west-1"]
        },
        "StringNotEquals": {
          "aws:PrincipalArn": "arn:aws:iam::ACCOUNT_ID:role/BreakGlassAdmin"
        }
      }
    },
    {
      "Sid": "AllowS3AccessToOwnPrefix",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::company-dev-bucket/users/${aws:username}/*"
      // ${aws:username} is replaced at evaluation time with the caller's IAM username
    },
    {
      "Sid": "DenyProductionResourceModification",
      "Effect": "Deny",
      "Action": ["rds:DeleteDBInstance", "rds:ModifyDBInstance", "ec2:TerminateInstances"],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:ResourceTag/Environment": "production"
        }
      }
    }
  ]
}

⚙️ DevOps Done Right — Zero Downtime, Full Automation

Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.

  • Staging + production environments with feature flags
  • Automated security scanning in the pipeline
  • Uptime monitoring + alerting + runbook automation
  • On-call support handover docs included

Permission Boundaries

Permission boundaries cap the maximum permissions a role can have, even if its attached policies grant more. Critical for delegate-and-trust models where developers can create roles.

# terraform/iam/permission-boundary.tf

# The permission boundary: maximum what any app role can do
resource "aws_iam_policy" "app_permission_boundary" {
  name        = "${var.app_name}-permission-boundary"
  description = "Maximum permissions for any role created by ${var.app_name} CI/CD"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      # Allow access to app resources only
      {
        Effect = "Allow"
        Action = [
          "s3:*", "sqs:*", "sns:*",
          "secretsmanager:GetSecretValue",
          "ssm:GetParameter*",
          "logs:*", "cloudwatch:PutMetricData",
          "xray:*"
        ]
        Resource = [
          "arn:aws:s3:::${var.app_name}-*",
          "arn:aws:sqs:*:${var.aws_account_id}:${var.app_name}-*",
          "arn:aws:secretsmanager:*:${var.aws_account_id}:secret:${var.app_name}/*",
          "arn:aws:ssm:*:${var.aws_account_id}:parameter/${var.app_name}/*",
          "arn:aws:logs:*:${var.aws_account_id}:*",
        ]
      },
      # Explicitly deny IAM privilege escalation — can never create roles with more power
      {
        Effect   = "Deny"
        Action   = ["iam:CreateRole", "iam:AttachRolePolicy", "iam:PutRolePolicy", "iam:PassRole"]
        Resource = "*"
      }
    ]
  })
}

# Apply boundary when creating any app role
resource "aws_iam_role" "app_with_boundary" {
  name                 = "${var.app_name}-app-role"
  permissions_boundary = aws_iam_policy.app_permission_boundary.arn

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

AWS Access Analyzer: Find Over-Permissive Policies

# terraform/iam/access-analyzer.tf

# Enable Access Analyzer for the account (run once per region)
resource "aws_accessanalyzer_analyzer" "account" {
  analyzer_name = "${var.app_name}-analyzer"
  type          = "ACCOUNT"
}

# Optional: archive known-safe findings
resource "aws_accessanalyzer_archive_rule" "internal_s3" {
  analyzer_name = aws_accessanalyzer_analyzer.account.analyzer_name
  rule_name     = "internal-s3-access"

  filter {
    criteria = "resourceType"
    eq       = ["AWS::S3::Bucket"]
  }
  filter {
    criteria = "principal.AWS"
    contains = [var.aws_account_id] # Internal account access is expected
  }
}

Common Over-Permissive Patterns to Fix

// ❌ PATTERN 1: Wildcard actions on a service
{
  "Action": "s3:*",      // Includes s3:DeleteBucket, s3:PutBucketPolicy
  "Resource": "*"
}

// ✅ FIX: Only the actions your app actually needs
{
  "Action": ["s3:GetObject", "s3:PutObject"],
  "Resource": "arn:aws:s3:::my-bucket/uploads/*"
}

// ❌ PATTERN 2: PassRole on *
{
  "Action": "iam:PassRole",
  "Resource": "*"  // Can pass any role — including AdministratorAccess
}

// ✅ FIX: Only pass specific roles
{
  "Action": "iam:PassRole",
  "Resource": "arn:aws:iam::ACCOUNT_ID:role/my-ecs-task-role"
}

// ❌ PATTERN 3: Secrets Manager on *
{
  "Action": "secretsmanager:GetSecretValue",
  "Resource": "*"  // Can read EVERY secret in the account
}

// ✅ FIX: Only app-specific secrets by path
{
  "Action": "secretsmanager:GetSecretValue",
  "Resource": "arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:my-app/production/*"
}

// ❌ PATTERN 4: No resource-level restrictions on RDS
{
  "Action": ["rds:DeleteDBInstance", "rds:ModifyDBCluster"],
  "Resource": "*"
}

// ✅ FIX: Tag-based restriction
// Add Condition: StringEquals aws:ResourceTag/Environment: "development"

Cost and Timeline Estimates

IAM is a configuration concern, not a runtime cost:

ScopeTeamTimelineCost Range
IAM audit of existing roles1 cloud engineer1–2 days$400–800
Implement least-privilege for all app roles1 engineer1–2 weeks$2,000–4,000
+ Permission boundaries + Access Analyzer1 engineer2–3 days$600–1,200
Full IAM Terraform module with CI/CD roles1 engineer1–2 weeks$2,500–5,000

See Also


Working With Viprasol

IAM mistakes are silent until they're catastrophic. Our team designs least-privilege IAM from day one — task roles with resource-level ARN restrictions, developer roles with MFA and region conditions, permission boundaries that prevent privilege escalation, and Access Analyzer to surface over-permissive policies before they become incidents.

What we deliver:

  • ECS task role and execution role with resource-level S3, SQS, and Secrets Manager permissions
  • Lambda execution role with function-specific permissions
  • Developer roles with MFA-required conditions for sensitive actions
  • Permission boundary to cap maximum role permissions (prevents escalation)
  • Terraform IAM module with reusable patterns across environments

Talk to our team about your AWS security architecture →

Or explore our cloud infrastructure services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need DevOps & Cloud Expertise?

Scale your infrastructure with confidence. AWS, GCP, Azure certified team.

Free consultation • No commitment • Response within 24 hours

Viprasol · Big Data & Analytics

Making sense of your data at scale?

Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.