Advanced Policy Patterns and Attribute-Based Access Control
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 Type | Size Limit | Notes |
|---|---|---|
| Inline policy (per role) | 2,048 characters | After whitespace removal |
| Managed policy | 6,144 characters | After whitespace removal |
| Managed policies per role | 10 | Hard limit, no increase |
| Role trust policy | 2,048 characters | Limits cross-account principals |
| SCP | 5,120 characters | Per 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.