Skip to main content
aws in the trenches advanced cloud engineering for senior developers

Advanced Policy Patterns and Attribute-Based Access Control

6 min read Chapter 2 of 21

Advanced Policy Patterns and Attribute-Based Access Control

RBAC (Role-Based Access Control) is what most AWS deployments use: one role per function, policies attached directly. It works until you hit 50 microservices, 8 environments, and 12 teams — then you’re managing 4,800 role-policy combinations, and every new service requires a ticket to the security team.

ABAC (Attribute-Based Access Control) solves the scaling problem by encoding access rules into tags rather than explicit policy statements. The policy logic stays constant; the access changes based on metadata attached to principals and resources.

The Scaling Problem with RBAC

Consider a typical microservice architecture with environment isolation:

# RBAC approach: You end up with policies like these
# For EACH service × environment combination:

policy_order_service_prod = {
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query"],
        "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/prod-orders*"
    }]
}

policy_order_service_staging = {
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query"],
        "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/staging-orders*"
    }]
}

# 10 services × 4 environments = 40 near-identical policies
# Add a new service? Create 4 new policies + 4 new roles.
# Add a new environment? Create 10 new policies + 10 new roles.
# This is O(services × environments) operational burden.
// Java: The RBAC scaling problem visualized
// Each service needs its own IAM client configured with service-specific role
public class RbacScalingProblem {

    // You end up maintaining a registry of role ARNs
    private static final Map<String, Map<String, String>> ROLE_MATRIX = Map.of(
        "order-service", Map.of(
            "prod", "arn:aws:iam::123456789012:role/order-service-prod",
            "staging", "arn:aws:iam::123456789012:role/order-service-staging",
            "dev", "arn:aws:iam::123456789012:role/order-service-dev"
        ),
        "payment-service", Map.of(
            "prod", "arn:aws:iam::123456789012:role/payment-service-prod",
            "staging", "arn:aws:iam::123456789012:role/payment-service-staging",
            "dev", "arn:aws:iam::123456789012:role/payment-service-dev"
        )
        // ... repeat for every service
    );
}

ABAC: One Policy to Rule Them All

With ABAC, you write a single policy that uses tag-based conditions. The policy itself never changes — you control access by tagging resources and principals:

import boto3
import json

# ONE policy handles all services in all environments
abac_policy = {
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:GetItem",
                "dynamodb:PutItem",
                "dynamodb:Query",
                "dynamodb:UpdateItem",
                "dynamodb:DeleteItem"
            ],
            "Resource": "*",
            "Condition": {
                "StringEquals": {
                    # Resource tag 'service' must match principal tag 'service'
                    "aws:ResourceTag/service": "${aws:PrincipalTag/service}",
                    # Resource tag 'environment' must match principal tag 'environment'
                    "aws:ResourceTag/environment": "${aws:PrincipalTag/environment}"
                }
            }
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Resource": "arn:aws:s3:::*/${aws:PrincipalTag/service}/${aws:PrincipalTag/environment}/*",
            "Condition": {
                "StringEquals": {
                    "aws:ResourceTag/service": "${aws:PrincipalTag/service}"
                }
            }
        }
    ]
}

# Now: adding a new service just means tagging the role and resources
# No policy changes. No security team tickets.

iam = boto3.client('iam')

# Tag the role for the new service
iam.tag_role(
    RoleName='microservice-execution-role',
    Tags=[
        {'Key': 'service', 'Value': 'inventory-service'},
        {'Key': 'environment', 'Value': 'prod'}
    ]
)

# Tag the DynamoDB table
dynamodb = boto3.client('dynamodb')
dynamodb.tag_resource(
    ResourceArn='arn:aws:dynamodb:us-east-1:123456789012:table/prod-inventory',
    Tags=[
        {'Key': 'service', 'Value': 'inventory-service'},
        {'Key': 'environment', 'Value': 'prod'}
    ]
)
# Done. The existing policy now grants access. Zero policy modification.
import software.amazon.awssdk.services.iam.IamClient;
import software.amazon.awssdk.services.iam.model.*;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.*;

public class AbacProvisioning {

    public static void provisionNewService(String serviceName, String environment) {
        IamClient iam = IamClient.create();
        DynamoDbClient dynamo = DynamoDbClient.create();

        // Tag the execution role — this is all you need to grant access
        iam.tagRole(TagRoleRequest.builder()
            .roleName("microservice-execution-role")
            .tags(
                Tag.builder().key("service").value(serviceName).build(),
                Tag.builder().key("environment").value(environment).build()
            )
            .build());

        // Tag the DynamoDB table
        dynamo.tagResource(TagResourceRequest.builder()
            .resourceArn("arn:aws:dynamodb:us-east-1:123456789012:table/"
                         + environment + "-" + serviceName)
            .tags(
                software.amazon.awssdk.services.dynamodb.model.Tag.builder()
                    .key("service").value(serviceName).build(),
                software.amazon.awssdk.services.dynamodb.model.Tag.builder()
                    .key("environment").value(environment).build()
            )
            .build());

        System.out.printf("Service %s provisioned for %s — no policy changes needed%n",
            serviceName, environment);
    }
}

Policy Size Limits and Workarounds

AWS enforces hard limits on policy sizes that will bite you in production:

Policy TypeSize LimitNotes
Inline policy (per role)2,048 charactersAfter whitespace removal
Managed policy6,144 charactersAfter whitespace removal
Managed policies per role10Hard limit, no increase
Role trust policy2,048 charactersLimits cross-account principals
SCP5,120 charactersPer SCP document

When you hit these limits (and you will with RBAC at scale), here are the escape hatches:

# Problem: Your resource ARNs exceed 6KB in a managed policy
# Solution 1: Use wildcards with tag conditions instead of ARN lists
# Before (hits size limit with 200+ tables):
bad_policy_resources = [
    f"arn:aws:dynamodb:us-east-1:123456789012:table/{name}"
    for name in all_200_table_names  # This won't fit in 6KB
]

# After (ABAC approach — fits in 500 bytes):
abac_statement = {
    "Effect": "Allow",
    "Action": "dynamodb:*",
    "Resource": "*",
    "Condition": {
        "StringEquals": {
            "aws:ResourceTag/team": "${aws:PrincipalTag/team}"
        }
    }
}

# Solution 2: Permission boundaries as deny-lists instead of allow-lists
# Instead of listing everything the role CAN do,
# define what it CANNOT do — usually a much shorter list
deny_boundary = {
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "*",
            "Resource": "*"
        },
        {
            "Effect": "Deny",
            "Action": [
                "iam:*",
                "organizations:*",
                "account:*"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Deny",
            "Action": "*",
            "Resource": "arn:aws:s3:::company-audit-logs*"
        }
    ]
}

Session Tags: Dynamic ABAC Through AssumeRole

Session tags let you pass attributes at assume-time, enabling dynamic access scoping without pre-provisioning roles per tenant:

import boto3

sts = boto3.client('sts')

# Multi-tenant SaaS: One role, access scoped by session tags
def get_tenant_scoped_session(tenant_id: str, user_email: str):
    """Assume a single shared role but scope it to a specific tenant."""
    response = sts.assume_role(
        RoleArn='arn:aws:iam::123456789012:role/TenantDataAccess',
        RoleSessionName=f'tenant-{tenant_id}',
        Tags=[
            {'Key': 'tenant_id', 'Value': tenant_id},
            {'Key': 'user_email', 'Value': user_email},
            {'Key': 'access_level', 'Value': 'read-write'}
        ],
        TransitiveTagKeys=['tenant_id']  # Preserved if this session assumes another role
    )
    return boto3.Session(
        aws_access_key_id=response['Credentials']['AccessKeyId'],
        aws_secret_access_key=response['Credentials']['SecretAccessKey'],
        aws_session_token=response['Credentials']['SessionToken']
    )

# The role's policy uses session tags:
tenant_isolation_policy = {
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query"],
        "Resource": "arn:aws:dynamodb:*:*:table/multi-tenant-data",
        "Condition": {
            "ForAllValues:StringEquals": {
                "dynamodb:LeadingKeys": ["${aws:PrincipalTag/tenant_id}"]
            }
        }
    }]
}
# Each tenant can only access DynamoDB items where the partition key matches their tenant_id
import software.amazon.awssdk.services.sts.StsClient;
import software.amazon.awssdk.services.sts.model.*;
import software.amazon.awssdk.auth.credentials.*;

public class TenantScopedAccess {

    private final StsClient sts = StsClient.create();

    public AwsCredentials getTenantCredentials(String tenantId, String userEmail) {
        AssumeRoleResponse response = sts.assumeRole(AssumeRoleRequest.builder()
            .roleArn("arn:aws:iam::123456789012:role/TenantDataAccess")
            .roleSessionName("tenant-" + tenantId)
            .tags(
                software.amazon.awssdk.services.sts.model.Tag.builder()
                    .key("tenant_id").value(tenantId).build(),
                software.amazon.awssdk.services.sts.model.Tag.builder()
                    .key("user_email").value(userEmail).build(),
                software.amazon.awssdk.services.sts.model.Tag.builder()
                    .key("access_level").value("read-write").build()
            )
            .transitiveTagKeys("tenant_id")
            .build());

        Credentials creds = response.credentials();
        return AwsSessionCredentials.create(
            creds.accessKeyId(),
            creds.secretAccessKey(),
            creds.sessionToken()
        );
    }
}

Cost of ABAC: Tag-based evaluation adds negligible latency (<1ms per request). The operational savings at scale — fewer policies to audit, fewer roles to manage, self-service provisioning — pay for the upfront design investment within weeks.