Compute

EC2 ABAC: operate only your own team's instances by tag

One policy that lets each team start, stop, reboot and launch only instances tagged with its own Team value, with tag changes locked down.

Published · Updated .

The problem

With many teams in one account, per-team policies that list instance IDs never keep up. Attribute-based access control (ABAC) uses one policy for everyone: a principal may act on an instance when the instance's Team tag equals the principal's Team tag. New instances and teams need tags, not policy edits.

ABAC is only as strong as the tags, and the usual examples leave three gaps. The Deny meant to protect the Team tag typically uses ForAnyValue with aws:TagKeys, but DeleteTags without a tag list deletes every user-defined tag and sends no aws:TagKeys, so that condition never matches. Nothing stops a launch tagged with another team's value. And it is often unclear how principals get a Team tag they cannot choose themselves.

The policies

Team ABAC permissions for EC2

IAM identity-based policy. Attach to: Each team's operator role (role-tag pattern) or one shared role whose sessions carry a Team session tag

Team ABAC permissions for EC2 · IAM identity-based policyOpen in Inline Policy Generator
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "OperateOwnTeamInstances",
      "Effect": "Allow",
      "Action": [
        "ec2:StartInstances",
        "ec2:StopInstances",
        "ec2:RebootInstances"
      ],
      "Resource": "arn:aws:ec2:eu-west-1:111122223333:instance/*",
      "Condition": {
        "StringEquals": {
          "aws:ResourceTag/Team": "${aws:PrincipalTag/Team}"
        }
      }
    },
    {
      "Sid": "SeeInstances",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeInstanceStatus",
        "ec2:DescribeTags"
      ],
      "Resource": "*"
    },
    {
      "Sid": "LaunchSupportingResources",
      "Effect": "Allow",
      "Action": "ec2:RunInstances",
      "Resource": [
        "arn:aws:ec2:eu-west-1::image/*",
        "arn:aws:ec2:eu-west-1:111122223333:subnet/*",
        "arn:aws:ec2:eu-west-1:111122223333:network-interface/*",
        "arn:aws:ec2:eu-west-1:111122223333:security-group/*",
        "arn:aws:ec2:eu-west-1:111122223333:key-pair/*"
      ]
    },
    {
      "Sid": "LaunchOnlyTaggedForOwnTeam",
      "Effect": "Allow",
      "Action": "ec2:RunInstances",
      "Resource": [
        "arn:aws:ec2:eu-west-1:111122223333:instance/*",
        "arn:aws:ec2:eu-west-1:111122223333:volume/*"
      ],
      "Condition": {
        "StringEquals": {
          "aws:RequestTag/Team": "${aws:PrincipalTag/Team}"
        },
        "ForAllValues:StringEquals": {
          "aws:TagKeys": [
            "Team",
            "Name"
          ]
        }
      }
    },
    {
      "Sid": "TagOnlyDuringLaunch",
      "Effect": "Allow",
      "Action": "ec2:CreateTags",
      "Resource": "arn:aws:ec2:eu-west-1:111122223333:*/*",
      "Condition": {
        "StringEquals": {
          "ec2:CreateAction": "RunInstances"
        }
      }
    },
    {
      "Sid": "DenyTeamTagChangesAfterLaunch",
      "Effect": "Deny",
      "Action": [
        "ec2:CreateTags",
        "ec2:DeleteTags"
      ],
      "Resource": "arn:aws:ec2:*:111122223333:instance/*",
      "Condition": {
        "ForAnyValue:StringEqualsIgnoreCase": {
          "aws:TagKeys": [
            "Team"
          ]
        },
        "StringNotEquals": {
          "ec2:CreateAction": "RunInstances"
        }
      }
    },
    {
      "Sid": "DenyDeletingAllTags",
      "Effect": "Deny",
      "Action": "ec2:DeleteTags",
      "Resource": "arn:aws:ec2:*:111122223333:instance/*",
      "Condition": {
        "Null": {
          "aws:TagKeys": "true"
        }
      }
    }
  ]
}

Statement by statement

  • OperateOwnTeamInstances: ${aws:PrincipalTag/Team} is replaced at request time with the caller's Team tag. A caller without the tag gets no value, StringEquals cannot match, and nothing is granted. EC2 dry runs evaluate this condition too, which the checks rely on.
  • SeeInstances: these Describe calls do not support resource-level permissions, so every team can list every instance and its tags. ABAC here controls actions, not visibility.
  • LaunchSupportingResources: RunInstances is authorized against every resource it touches. The AMI, subnet, network interface, security group and key pair are used, not created with team tags, so they need no tag condition. AMI ARNs have no account ID.
  • LaunchOnlyTaggedForOwnTeam: the new instance and its volumes must carry Team equal to the caller's own tag. ForAllValues on aws:TagKeys limits launch tags to the keys Team and Name, which also blocks a second key such as team. No Null guard is needed because the RequestTag condition already requires a Team tag.
  • TagOnlyDuringLaunch: when a launch specifies tags, EC2 also checks ec2:CreateTags; ec2:CreateAction limits this permission to that check, so the role cannot tag existing resources.
  • DenyTeamTagChangesAfterLaunch: a guard against other policies that grant tagging. IgnoreCase blocks a Team key in any capitalization, because EC2 tag keys are case-sensitive while IAM condition key names are not. StringNotEquals on ec2:CreateAction matches when the key is absent, as it is for standalone CreateTags and DeleteTags calls, so only tagging at launch is exempt.
  • DenyDeletingAllTags: DeleteTags with no tag list removes every user-defined tag and carries no aws:TagKeys, so the ForAnyValue statement cannot match it. This Null check is true exactly when the key is absent.

Shared-role pattern: trust policy that takes Team from your identity provider

IAM role trust policy. Attach to: The trust relationship of one shared operator role, when Team comes from SAML session tags

Shared-role pattern: trust policy that takes Team from your identity provider · IAM role trust policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "FederatedTeamMembers",
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::111122223333:saml-provider/ExampleCorpIdP"
      },
      "Action": [
        "sts:AssumeRoleWithSAML",
        "sts:TagSession"
      ],
      "Condition": {
        "StringEquals": {
          "SAML:aud": "https://signin.aws.amazon.com/saml",
          "aws:RequestTag/Team": [
            "payments",
            "search"
          ]
        }
      }
    }
  ]
}

The form generators on this site don't edit trust policy documents; copy it, then check it with IAM Access Analyzer as shown below.

Statement by statement

  • FederatedTeamMembers: sts:TagSession must be allowed for the identity provider to pass session tags, as in AWS's SAML ABAC tutorial. The aws:RequestTag/Team condition requires a Team tag with one of the listed values, so no session is created without a recognized team. The value comes from your directory through the SAML PrincipalTag attribute, not from the person signing in.

Before you start

  • Every instance in scope has a Team tag applied by an administrator or pipeline. Values are case-sensitive: payments and Payments are different teams.
  • Principals get Team as a tag on a per-team IAM role, or as a session tag from a SAML identity provider (IAM Identity Center calls these attributes for access control).
  • Permission to create managed policies and roles in account 111122223333, plus a SAML provider for the shared-role pattern.

Deploy it step by step

  1. Decide where the Team value comes from. Role tags are simplest: one role per team, tagged Team=<name>, all with the same managed policy. Their trust policies should not allow sts:TagSession, because STS lets a session tag override a role tag with the same key. Session tags suit one shared role; the trust policy above accepts them only from your identity provider. If IAM users call assume-role --tags against a role that allows sts:TagSession without pinning the values, they choose their own team.
    Commands (replace <placeholders>)
    aws iam tag-role --role-name ec2-operator-payments --tags Key=Team,Value=payments
  2. Validate and create the managed policy. Validate both documents before creating anything, then attach the same managed policy to every team role.
    Commands (replace <placeholders>)
    aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY --policy-document file://policy.json
    aws accessanalyzer validate-policy --policy-type RESOURCE_POLICY --validate-policy-resource-type AWS::IAM::AssumeRolePolicyDocument --policy-document file://trust-policy.json
    aws iam create-policy --policy-name ec2-team-abac --policy-document file://policy.json
    aws iam attach-role-policy --role-name ec2-operator-payments --policy-arn arn:aws:iam::111122223333:policy/ec2-team-abac
  3. Tag existing instances from an administrator role. The operator role cannot tag existing instances, by design. Apply Team with an administrator or pipeline role, and keep the list of valid values short and documented. AWS Organizations tag policies can standardize capitalization across accounts.
    Commands (replace <placeholders>)
    aws ec2 create-tags --resources <instance-id> --tags Key=Team,Value=payments --region eu-west-1
  4. Launch with tags on the instance and its volumes. LaunchOnlyTaggedForOwnTeam covers instance and volume resources, so a launch must tag both. An instance profile also requires iam:PassRole for its role, and a launch template requires RunInstances on the launch-template resource.
    Commands (replace <placeholders>)
    aws ec2 run-instances --image-id <ami-id> --instance-type t3.micro --subnet-id <subnet-id> --tag-specifications 'ResourceType=instance,Tags=[{Key=Team,Value=payments}]' 'ResourceType=volume,Tags=[{Key=Team,Value=payments}]' --dry-run --region eu-west-1

Verify it without changing anything

These checks only read state, simulate, or use dry runs, so they are safe to run in any account. Exact error wording varies by service and over time.

Own team's instance versus another team's, dry run

Run as a principal whose Team is payments. With --dry-run, EC2 evaluates permissions and stops, so neither instance changes state.

Commands (replace <placeholders>)
aws ec2 start-instances --instance-ids <payments-instance-id> --dry-run --region eu-west-1
aws ec2 reboot-instances --instance-ids <search-instance-id> --dry-run --region eu-west-1

Expected: DryRunOperation ('Request would have succeeded, but DryRun flag is set.') for the payments instance and UnauthorizedOperation for the search instance. UnauthorizedOperation on both usually means the session has no Team tag or the values differ in case.

Retagging and mistagged launches are refused

One call moves an instance into another team; the other launches one tagged for another team.

Commands (replace <placeholders>)
aws ec2 create-tags --resources <payments-instance-id> --tags Key=Team,Value=search --dry-run --region eu-west-1
aws ec2 run-instances --image-id <ami-id> --instance-type t3.micro --subnet-id <subnet-id> --tag-specifications 'ResourceType=instance,Tags=[{Key=Team,Value=search}]' 'ResourceType=volume,Tags=[{Key=Team,Value=search}]' --dry-run --region eu-west-1

Expected: UnauthorizedOperation for both. The same launch with Team=payments should return DryRunOperation if the other launch requirements in the steps are met.

Prove the delete-all-tags guard with the simulator

This page never prints a delete-tags command, even with --dry-run, so a dropped flag cannot strip tags. The first simulation models DeleteTags without a tag list, so aws:TagKeys is absent; the second models deleting an unrelated key.

Commands (replace <placeholders>)
aws iam simulate-custom-policy --policy-input-list file://policy.json --action-names ec2:DeleteTags --resource-arns arn:aws:ec2:eu-west-1:111122223333:instance/<instance-id>
aws iam simulate-custom-policy --policy-input-list file://policy.json --action-names ec2:DeleteTags --resource-arns arn:aws:ec2:eu-west-1:111122223333:instance/<instance-id> --context-entries ContextKeyName=aws:TagKeys,ContextKeyValues=CostCenter,ContextKeyType=stringList

Expected: explicitDeny for the first, matched by DenyDeletingAllTags, and implicitDeny for the second, because nothing allows DeleteTags and nothing denies removing CostCenter. implicitDeny on the first means the Null statement is missing.

Check the tag comparison itself

You supply both tag values as context, so you can try mismatches, missing tags and case differences without real instances.

Commands (replace <placeholders>)
aws iam simulate-custom-policy --policy-input-list file://policy.json --action-names ec2:StopInstances --resource-arns arn:aws:ec2:eu-west-1:111122223333:instance/<instance-id> --context-entries ContextKeyName=aws:PrincipalTag/Team,ContextKeyValues=payments,ContextKeyType=string ContextKeyName=aws:ResourceTag/Team,ContextKeyValues=search,ContextKeyType=string

Expected: implicitDeny with the values shown and allowed once both are payments. If the response lists keys under MissingContextValues, supply them; treat the simulator as a model and confirm with the dry runs above.

Common mistakes and troubleshooting

ForAnyValue alone in the tag-protection Deny

IAM documents that with ForAnyValue a missing context key is no match, so such a Deny skips any request without tag keys, which is exactly DeleteTags without --tags. Pair it with a Null check, as DenyDeletingAllTags does.

Values that differ only in case

StringEquals compares values case-sensitively, so a role tagged Payments cannot operate instances tagged payments. Standardize values with tag policies or a pipeline check rather than loosening the Allow.

Instances that go straight back to stopped

With volumes encrypted under a customer managed KMS key, StartInstances can be authorized and still fail, the instance returning to stopped, if the caller cannot create the KMS grant EC2 needs. Check StateReason and the key policy before blaming the ABAC condition.

Limitations and production considerations

  • Whoever controls tags controls access: administrators and pipelines that tag instances, anyone with iam:TagRole or iam:UntagRole on the operator roles, and whoever manages the Team attribute in your identity provider. Protect those paths as carefully as the policy.
  • The policy covers start, stop, reboot and launch. ModifyInstanceAttribute, TerminateInstances or Session Manager access need their own tag conditions, and stop and termination protection add a second barrier on critical instances.
  • The Deny statements only constrain this principal. For an organization-wide rule, put a similar Deny, including the Null case, in an SCP whose exemption for your tagging pipeline is itself protected.

Official AWS documentation

Account IDs, bucket and organization names are AWS documentation placeholders. Test in a non-production account before use (see the methodology).