Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 1 ๐Ÿ“– 7 min read

Least-Privilege AWS IAM for Small Teams: Permission Boundaries, Access Analyzer, and Terraform

Small teams hit the same IAM wall. Either one person holds admin and becomes the bottleneck for every new Lambda role, or everyone gets AdministratorAccess "for now" and it never gets taken back. Neither scales. Permiss

Small teams hit the same IAM wall. Either one person holds admin and becomes the bottleneck for every new Lambda role, or everyone gets AdministratorAccess "for now" and it never gets taken back. Neither scales.

Permission boundaries offer a middle path: developers can create and manage their own roles, but no role they create can ever exceed a ceiling you define. Pair that with IAM Access Analyzer to generate policies from real usage and flag unused access, and you get least privilege without a dedicated security team.

How permission boundaries work

A permission boundary is a managed policy attached to a user or role that sets the maximum permissions it can have. Effective permissions are the intersection:

effective = identity policies รขห†ยฉ permission boundary   (minus any explicit deny, SCPs, etc.)

A boundary grants nothing by itself. If the boundary allows s3:* but the role's policy allows only s3:GetObject, the role gets s3:GetObject. If the role's policy allows iam:* but the boundary doesn't, the role gets no IAM access.

The useful pattern: let developers call iam:CreateRole only if they attach the boundary. Then even a role with AdministratorAccess attached is capped.

Step 1: The boundary policy

This boundary allows typical application services, blocks privilege escalation, and prevents anyone from removing or editing the boundary itself. Replace 123456789012 with your account ID.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowAppServices",
      "Effect": "Allow",
      "Action": [
        "s3:*",
        "dynamodb:*",
        "sqs:*",
        "sns:*",
        "lambda:*",
        "logs:*",
        "cloudwatch:*",
        "xray:*",
        "events:*",
        "states:*",
        "ssm:GetParameter*",
        "secretsmanager:GetSecretValue",
        "kms:Decrypt",
        "kms:GenerateDataKey*"
      ],
      "Resource": "*"
    },
    {
      "Sid": "AllowRoleManagementOnlyWithBoundary",
      "Effect": "Allow",
      "Action": [
        "iam:CreateRole",
        "iam:PutRolePolicy",
        "iam:AttachRolePolicy",
        "iam:DetachRolePolicy",
        "iam:DeleteRolePolicy"
      ],
      "Resource": "arn:aws:iam::123456789012:role/app/*",
      "Condition": {
        "StringEquals": {
          "iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/app-boundary"
        }
      }
    },
    {
      "Sid": "AllowRoleHousekeeping",
      "Effect": "Allow",
      "Action": [
        "iam:GetRole",
        "iam:GetRolePolicy",
        "iam:ListRolePolicies",
        "iam:ListAttachedRolePolicies",
        "iam:TagRole",
        "iam:UntagRole",
        "iam:DeleteRole",
        "iam:UpdateAssumeRolePolicy",
        "iam:PassRole"
      ],
      "Resource": "arn:aws:iam::123456789012:role/app/*"
    },
    {
      "Sid": "DenyBoundaryTampering",
      "Effect": "Deny",
      "Action": [
        "iam:CreatePolicyVersion",
        "iam:DeletePolicy",
        "iam:DeletePolicyVersion",
        "iam:SetDefaultPolicyVersion"
      ],
      "Resource": "arn:aws:iam::123456789012:policy/app-boundary"
    },
    {
      "Sid": "DenyBoundaryRemoval",
      "Effect": "Deny",
      "Action": [
        "iam:DeleteRolePermissionsBoundary",
        "iam:DeleteUserPermissionsBoundary"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DenyUserAndKeyCreation",
      "Effect": "Deny",
      "Action": [
        "iam:CreateUser",
        "iam:CreateAccessKey",
        "iam:CreateLoginProfile",
        "organizations:*",
        "account:*"
      ],
      "Resource": "*"
    }
  ]
}

Key choices:

  • Path scoping (role/app/*). Developer-created roles live under the /app/ path, so they can't touch your break-glass or CI roles elsewhere.
  • iam:PermissionsBoundary condition. CreateRole, PutRolePolicy, and AttachRolePolicy succeed only when the target role carries this exact boundary. That condition key is supported on those actions.
  • iam:PassRole limited to /app/. Developers can hand their roles to Lambda or ECS, but can't pass a more powerful role.
  • Explicit denies stop anyone from rewriting or detaching the boundary.

Apply the same boundary to the developers' own role. That way the developer, and every role the developer creates, sits under the same ceiling. Without it, a developer could create a role without the boundary through some other permission they hold.

Step 2: Terraform for the boundary and a bounded role

data "aws_caller_identity" "current" {}

resource "aws_iam_policy" "app_boundary" {
  name        = "app-boundary"
  description = "Permission ceiling for developer-managed application roles"
  policy = templatefile("${path.module}/app-boundary.json.tftpl", {
    account_id = data.aws_caller_identity.current.account_id
  })
}

data "aws_iam_policy_document" "lambda_trust" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "orders_api" {
  name                 = "orders-api"
  path                 = "/app/"
  assume_role_policy   = data.aws_iam_policy_document.lambda_trust.json
  permissions_boundary = aws_iam_policy.app_boundary.arn
}

data "aws_iam_policy_document" "orders_api" {
  statement {
    sid       = "OrdersTable"
    actions   = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query"]
    resources = ["arn:aws:dynamodb:us-east-1:${data.aws_caller_identity.current.account_id}:table/orders"]
  }
  statement {
    sid       = "Logs"
    actions   = ["logs:CreateLogStream", "logs:PutLogEvents"]
    resources = ["arn:aws:logs:us-east-1:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/orders-api:*"]
  }
}

resource "aws_iam_role_policy" "orders_api" {
  name   = "orders-api"
  role   = aws_iam_role.orders_api.id
  policy = data.aws_iam_policy_document.orders_api.json
}

Save the boundary JSON from Step 1 as app-boundary.json.tftpl, replacing 123456789012 with ${account_id}. The templatefile call fills it in.

The role's own policy is narrow; the boundary is the safety net if someone later attaches something broad. To make the boundary hard to forget, wrap the role in a small module where permissions_boundary and path are fixed and not variables:

# modules/app-role/main.tf
variable "name" { type = string }
variable "trust_policy_json" { type = string }
variable "boundary_arn" { type = string }

resource "aws_iam_role" "this" {
  name                 = var.name
  path                 = "/app/"
  assume_role_policy   = var.trust_policy_json
  permissions_boundary = var.boundary_arn
}

output "role_arn" { value = aws_iam_role.this.arn }
output "role_name" { value = aws_iam_role.this.name }

Proving the boundary works

Assume the developer role and try to create a role without the boundary:

aws iam create-role \
  --role-name test-no-boundary \
  --path /app/ \
  --assume-role-policy-document file://lambda-trust.json
# An error occurred (AccessDenied) ... not authorized to perform: iam:CreateRole

Then retry with --permissions-boundary arn:aws:iam::123456789012:policy/app-boundary and it succeeds. Put this check in your onboarding docs; it is the fastest way to show the team that the guardrail is real.

Step 3: Generate policies from real usage with Access Analyzer

Writing narrow policies by hand is slow. IAM Access Analyzer can read CloudTrail and generate a policy based on what a role actually called. You need a CloudTrail trail (management events) logging to S3, and a service role Access Analyzer can use to read it.

aws accessanalyzer start-policy-generation \
  --policy-generation-details '{"principalArn":"arn:aws:iam::123456789012:role/app/orders-api"}' \
  --cloud-trail-details '{
    "trails":[{"cloudTrailArn":"arn:aws:cloudtrail:us-east-1:123456789012:trail/management","allRegions":true}],
    "accessRole":"arn:aws:iam::123456789012:role/service-role/AccessAnalyzerMonitorServiceRole",
    "startTime":"2026-09-15T00:00:00Z",
    "endTime":"2026-10-15T00:00:00Z"
  }'
# returns a jobId

aws accessanalyzer get-generated-policy \
  --job-id <jobId> \
  --include-resource-placeholders

The output is a draft policy listing the actions used. Treat it as a starting point:

  • It captures management events by default. Data events such as s3:GetObject and dynamodb:GetItem appear only if your trail logs them, so add those by hand if needed.
  • Use --include-resource-placeholders and replace placeholders with real ARNs; a generated "Resource": "*" is not least privilege.
  • Use a window that covers the role's full cycle, including monthly jobs, or you'll strip permissions that run rarely.

Before applying any edited policy, validate it:

aws accessanalyzer validate-policy \
  --policy-type IDENTITY_POLICY \
  --policy-document file://orders-api.json

This catches errors, security warnings (like iam:PassRole with *), and suggestions. You can run it in CI against terraform show -json output so bad policies fail review.

Step 4: Unused-access findings

Policy generation is per role and on demand. Unused-access analyzers run continuously across the account and report:

  • roles not used within the tracking period,
  • unused access keys and passwords,
  • permissions granted but not used (at service and action level).

Create one in Terraform:

resource "aws_accessanalyzer_analyzer" "unused" {
  analyzer_name = "unused-access"
  type          = "ACCOUNT_UNUSED_ACCESS"

  configuration {
    unused_access {
      unused_access_age = 90
    }
  }
}

Unused-access analyzers are a paid feature, billed per IAM role and user analyzed per month, so check current pricing before enabling. For a small account the cost is usually modest. External-access analyzers (type = "ACCOUNT") are free and worth enabling too, since they flag resources shared outside your account.

List active findings:

aws accessanalyzer list-findings-v2 \
  --analyzer-arn arn:aws:access-analyzer:us-east-1:123456789012:analyzer/unused-access \
  --filter '{"status":{"eq":["ACTIVE"]}}'

Review them in a monthly slot. Delete unused roles, remove unused services from policies, and archive findings that are deliberate (for example, disaster-recovery roles that sit idle by design) with an archive rule so they don't keep resurfacing.

A least-privilege checklist for small teams

  • [ ] Humans sign in through IAM Identity Center; no IAM users with long-lived keys.
  • [ ] A permission boundary exists and is attached to developer roles and every role they create.
  • [ ] Developer-managed roles live under a dedicated path such as /app/.
  • [ ] iam:PassRole is scoped to that path, never *.
  • [ ] The boundary denies editing or removing itself.
  • [ ] Application roles are created through a Terraform module that fixes the boundary.
  • [ ] New role policies start from Access Analyzer policy generation, then get real resource ARNs.
  • [ ] validate-policy runs in CI on IAM changes.
  • [ ] An external-access analyzer is enabled in every region you use.
  • [ ] An unused-access analyzer is enabled and findings are reviewed monthly.
  • [ ] Break-glass admin access exists, is documented, and alerts when used.

Wrap-up

Permission boundaries let a small team move fast without one person approving every role, and Access Analyzer turns "least privilege" from a guess into something based on actual usage. Start with the boundary, since it caps the blast radius immediately, then tighten individual roles over time as the findings come in.

Written by the ByteBridge Talent LLC software engineering team.

ByteBridge Talent LLC is an E-Verify enrolled tech staffing firm in Grand Island, Nebraska: bytebridgetalent.com

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.