Back to Blog

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

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
13 min read
Updated 2027

"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

> **Quick answer.** Least-privilege IAM bounds the blast radius when credentials are compromised. For every role, grant only the specific actions needed (avoid wildcards), scope to exact resource ARNs rather than *, and add condition keys (region, source IP, MFA) where meaningful. The Action:* / Resource:* pattern from docs should never become permanent in production.

# 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"
        }
      }
    }
  ]
}
AWS - AWS IAM Least-Privilege Design: Policy Patterns, Condition Keys

⚙️ 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"

What to Expect Financially

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

Explore More


What We Bring to the Table

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.

Enforcing IAM Least Privilege with Terraform

Codifying IAM least privilege Terraform workflows keeps permissions reviewable, version-controlled, and reproducible across every environment. Define policies with the aws_iam_policy_document data source rather than inline JSON, so each statement, action, and resource ARN is explicit and diffable in pull requests. Scope policies to specific resources instead of wildcards, and layer condition keys, such as aws:SourceVpc or aws:PrincipalTag, to tighten access further. Attach managed policies to roles, not users, and use assume-role trust policies for short-lived credentials. Run automated checks like tfsec or Checkov in CI to catch over-broad grants before they merge. At Viprasol Tech, our senior engineers take full ownership of this workflow, treating IAM as living infrastructure that evolves safely alongside your AWS footprint and access requirements.

AWSIAMSecurityTerraformInfrastructureCloudDevOps
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

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.