Skip to content

title: IAM policy patterns description: Least-privilege IAM trust and resource policy snippets: GitHub Actions OIDC, cross-account assume-role with ExternalId, S3 TLS-only and encryption-required bucket policies, and a separated KMS key policy. tags: - terraform - aws


IAM policy patterns

Copy-pastable least-privilege policies for the things you wire up on every project: CI/CD federation, cross-account access, locked-down S3 buckets, and KMS keys with a clean Admin / Use / Grant split.

Prefer aws_iam_policy_document

Generating JSON via data "aws_iam_policy_document" keeps interpolation safe (no string-quoting bugs), surfaces typos at plan time, and lets you reuse statement blocks. Hand-written JSON is fine for small static documents.


GitHub Actions OIDC trust

Federate GitHub Actions into AWS without long-lived access keys. The trust policy below scopes the role to a specific repository, branch (main), and deployment environment (prod).

data "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"
}

data "aws_iam_policy_document" "github_actions_trust" {
  statement {
    sid     = "GitHubActionsOIDC"
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [data.aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    # Repo + ref + environment scoping. Every condition narrows the trust.
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values = [
        "repo:acme-co/platform:ref:refs/heads/main",
        "repo:acme-co/platform:environment:prod",
      ]
    }
  }
}

resource "aws_iam_role" "github_actions_deploy" {
  name               = "github-actions-deploy"
  assume_role_policy = data.aws_iam_policy_document.github_actions_trust.json
}

Always pin sub, never just repo:*

A trust policy that only checks token.actions.githubusercontent.com:aud grants every GitHub Actions workflow on the planet permission to assume the role. The sub claim must be pinned to your repo plus a branch, tag, or environment.

You also need the OIDC provider itself once per account:

resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  # GitHub publishes thumbprints: let AWS pick them up automatically since
  # the 2023-07 change. An empty list works on current provider versions.
  thumbprint_list = []
}

Cross-account sts:AssumeRole with ExternalId

Classic third-party / cross-account access. The ExternalId defends against the confused-deputy problem when the trusted account is shared.

variable "trusted_account_id" {
  type        = string
  description = "12-digit AWS account ID allowed to assume this role."
}

variable "external_id" {
  type        = string
  description = "Shared secret presented by the trusted principal on AssumeRole."
  sensitive   = true
}

data "aws_iam_policy_document" "cross_account_trust" {
  statement {
    sid     = "CrossAccountAssumeRole"
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${var.trusted_account_id}:root"]
    }

    condition {
      test     = "StringEquals"
      variable = "sts:ExternalId"
      values   = [var.external_id]
    }

    # Optional: require MFA for human users assuming the role.
    condition {
      test     = "Bool"
      variable = "aws:MultiFactorAuthPresent"
      values   = ["true"]
    }
  }
}

resource "aws_iam_role" "cross_account" {
  name               = "acme-readonly-from-partner"
  assume_role_policy = data.aws_iam_policy_document.cross_account_trust.json
}

The rendered JSON looks like:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CrossAccountAssumeRole",
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Principal": { "AWS": "arn:aws:iam::222233334444:root" },
      "Condition": {
        "StringEquals": { "sts:ExternalId": "REDACTED" },
        "Bool":         { "aws:MultiFactorAuthPresent": "true" }
      }
    }
  ]
}

S3 bucket policy: TLS-only + require encrypted PUTs

Two statements every S3 bucket should carry: deny any request that wasn't made over HTTPS, and deny any PutObject that doesn't ask for server-side encryption.

data "aws_iam_policy_document" "bucket_hardening" {
  statement {
    sid     = "DenyInsecureTransport"
    effect  = "Deny"
    actions = ["s3:*"]

    resources = [
      aws_s3_bucket.this.arn,
      "${aws_s3_bucket.this.arn}/*",
    ]

    principals {
      type        = "*"
      identifiers = ["*"]
    }

    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }

  statement {
    sid     = "DenyUnencryptedPut"
    effect  = "Deny"
    actions = ["s3:PutObject"]
    resources = ["${aws_s3_bucket.this.arn}/*"]

    principals {
      type        = "*"
      identifiers = ["*"]
    }

    condition {
      test     = "StringNotEquals"
      variable = "s3:x-amz-server-side-encryption"
      values   = ["aws:kms", "AES256"]
    }
  }

  statement {
    sid     = "DenyMissingEncryptionHeader"
    effect  = "Deny"
    actions = ["s3:PutObject"]
    resources = ["${aws_s3_bucket.this.arn}/*"]

    principals {
      type        = "*"
      identifiers = ["*"]
    }

    condition {
      test     = "Null"
      variable = "s3:x-amz-server-side-encryption"
      values   = ["true"]
    }
  }
}

resource "aws_s3_bucket_policy" "this" {
  bucket = aws_s3_bucket.this.id
  policy = data.aws_iam_policy_document.bucket_hardening.json
}

Two statements for the encryption check

StringNotEquals only fires when the header is present and wrong. To also catch requests that omit the header entirely you need the second Null-conditioned statement.


KMS key policy: Admin / Use / Grant separation

A common mistake is granting kms:* to the root principal and calling it a day. Splitting the policy into three roles: administer, use, and grant. Makes audits actually possible.

data "aws_caller_identity" "current" {}

data "aws_iam_policy_document" "kms_key" {
  # 1) Root account retains break-glass control over the key.
  statement {
    sid     = "EnableIAMUserPermissions"
    effect  = "Allow"
    actions = ["kms:*"]
    resources = ["*"]

    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"]
    }
  }

  # 2) Admins: rotate, schedule deletion, edit policy. NO data plane.
  statement {
    sid    = "KeyAdministration"
    effect = "Allow"

    actions = [
      "kms:Create*",
      "kms:Describe*",
      "kms:Enable*",
      "kms:List*",
      "kms:Put*",
      "kms:Update*",
      "kms:Revoke*",
      "kms:Disable*",
      "kms:Get*",
      "kms:Delete*",
      "kms:TagResource",
      "kms:UntagResource",
      "kms:ScheduleKeyDeletion",
      "kms:CancelKeyDeletion",
    ]

    resources = ["*"]

    principals {
      type        = "AWS"
      identifiers = var.key_admin_role_arns
    }
  }

  # 3) Users: encrypt/decrypt data, but cannot change the key itself.
  statement {
    sid    = "KeyUsage"
    effect = "Allow"

    actions = [
      "kms:Encrypt",
      "kms:Decrypt",
      "kms:ReEncrypt*",
      "kms:GenerateDataKey*",
      "kms:DescribeKey",
    ]

    resources = ["*"]

    principals {
      type        = "AWS"
      identifiers = var.key_user_role_arns
    }
  }

  # 4) Grants: AWS services (RDS, EBS, etc.) need to create grants on behalf
  #    of users. Scoped with ViaService so an Allow on kms:CreateGrant alone
  #    can't be used outside the integrated services.
  statement {
    sid    = "AllowAttachmentOfPersistentResources"
    effect = "Allow"

    actions = [
      "kms:CreateGrant",
      "kms:ListGrants",
      "kms:RevokeGrant",
    ]

    resources = ["*"]

    principals {
      type        = "AWS"
      identifiers = var.key_user_role_arns
    }

    condition {
      test     = "Bool"
      variable = "kms:GrantIsForAWSResource"
      values   = ["true"]
    }
  }
}

resource "aws_kms_key" "this" {
  description             = "App data encryption key"
  enable_key_rotation     = true
  deletion_window_in_days = 30
  policy                  = data.aws_iam_policy_document.kms_key.json
}

Don't drop the root statement

AWS will let you save a key policy without the root principal: and then nobody can edit it again. The "EnableIAMUserPermissions" statement is your one and only break-glass. Keep it.


References