DevOpsil
Security
88%
Needs Review

AWS IAM Policy Conditions For Cross-Account Resource Access Without Over-Privileging

Riku TanakaRiku Tanaka12 min read

AWS IAM Policy Conditions for Cross-Account Resource Access Without Over-Privileging

Cross-account resource access in AWS is like giving someone the keys to your house—you want them to get in, but only through the right door, at the right time, and for the right reasons. After spending countless late nights troubleshooting cross-account permission failures and cleaning up over-privileged policies, I've learned that IAM policy conditions are your best friend for implementing secure, granular cross-account access.

Most organizations start with overly permissive cross-account policies because they're easier to implement. But trust me, when you're dealing with a security incident at 2 AM because someone accessed resources they shouldn't have, you'll wish you'd spent the extra time on proper conditions from the beginning.

Understanding the Cross-Account Challenge

Cross-account access patterns are everywhere in modern AWS architectures. You might have:

  • Centralized logging accounts that need to read CloudTrail logs from multiple production accounts
  • CI/CD pipelines deploying applications across dev, staging, and production accounts
  • Monitoring systems collecting metrics and logs from various business unit accounts
  • Data analytics platforms aggregating data from operational accounts

The challenge isn't just enabling access—it's doing so without creating security holes that could be exploited later. Every cross-account trust relationship is a potential attack vector if not properly constrained.

The Foundation: Trust Policies and Resource-Based Policies

Before diving into conditions, let's establish the two primary mechanisms for cross-account access:

Trust Policies (AssumeRole)

Trust policies define who can assume a role. Here's a basic example:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::TRUSTED-ACCOUNT-ID:root"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

This is dangerous because it allows anyone in the trusted account to assume the role. We need conditions to restrict this.

Resource-Based Policies

Resource-based policies are attached directly to resources like S3 buckets or KMS keys:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::TRUSTED-ACCOUNT-ID:root"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-bucket/*"
    }
  ]
}

Again, this is overly permissive without proper conditions.

Essential IAM Policy Conditions for Cross-Account Access

Let's dive into the specific conditions that will help you implement secure cross-account access without over-privileging.

1. Time-Based Conditions

Time-based restrictions are crucial for operational access patterns. Your CI/CD pipeline probably shouldn't be deploying to production at 3 AM on a Sunday.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/DeploymentRole"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "DateGreaterThan": {
          "aws:CurrentTime": "2023-01-01T00:00:00Z"
        },
        "DateLessThan": {
          "aws:CurrentTime": "2024-12-31T23:59:59Z"
        },
        "ForAllValues:StringLike": {
          "aws:RequestedRegion": ["us-east-1", "us-west-2"]
        },
        "IpAddress": {
          "aws:SourceIp": ["203.0.113.0/24", "198.51.100.0/24"]
        }
      }
    }
  ]
}

For more granular time control, use business hours restrictions:

{
  "Condition": {
    "ForAllValues:NumericBetween": {
      "aws:TokenIssueTime": ["32400", "64800"]
    },
    "ForAllValues:StringLike": {
      "aws:userid": ["AIDACKCEVSQ6C2EXAMPLE:deployment-user"]
    }
  }
}

The aws:TokenIssueTime represents seconds since midnight UTC. This example allows access between 9 AM and 6 PM UTC.

2. Source IP and VPC Endpoint Restrictions

Network-based conditions ensure access only comes from expected locations:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/DataProcessorRole"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::sensitive-data-bucket/*",
      "Condition": {
        "StringEquals": {
          "aws:SourceVpce": "vpce-12345678"
        }
      }
    },
    {
      "Effect": "Deny",
      "Principal": "*",
      "Action": "*",
      "Resource": "arn:aws:s3:::sensitive-data-bucket/*",
      "Condition": {
        "StringNotEquals": {
          "aws:SourceVpce": "vpce-12345678"
        }
      }
    }
  ]
}

This ensures all access goes through a specific VPC endpoint, preventing data exfiltration through unexpected network paths.

For IP-based restrictions with fallback options:

{
  "Condition": {
    "IpAddressIfExists": {
      "aws:SourceIp": [
        "203.0.113.0/24",
        "198.51.100.0/24"
      ]
    },
    "Bool": {
      "aws:ViaAWSService": "false"
    }
  }
}

The IfExists operator allows the condition to pass if the key doesn't exist, which is useful for service-to-service calls.

3. MFA and Strong Authentication Requirements

For sensitive operations, require multi-factor authentication:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/AdminRole"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "Bool": {
          "aws:MultiFactorAuthPresent": "true"
        },
        "NumericLessThan": {
          "aws:MultiFactorAuthAge": "3600"
        }
      }
    }
  ]
}

This requires MFA and ensures the MFA token is less than one hour old. For external identity providers, you might want:

{
  "Condition": {
    "StringEquals": {
      "saml:aud": "https://signin.aws.amazon.com/saml"
    },
    "ForAnyValue:StringLike": {
      "saml:groups": ["AdminGroup", "PowerUserGroup"]
    },
    "StringLike": {
      "saml:cn": "*@company.com"
    }
  }
}

4. Request Context Conditions

These conditions examine the context of the API request itself:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/BackupRole"
      },
      "Action": [
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::backup-bucket",
        "arn:aws:s3:::backup-bucket/*"
      ],
      "Condition": {
        "StringEquals": {
          "s3:x-amz-server-side-encryption": "AES256"
        },
        "Bool": {
          "s3:SecureTransport": "true"
        },
        "StringLike": {
          "s3:prefix": "backups/production/*"
        }
      }
    }
  ]
}

This ensures that cross-account backup operations only work with encrypted data over HTTPS and only access specific prefixes.

5. Service-Specific Conditions

Different AWS services have their own condition keys. Here's how to restrict cross-account RDS access:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/DatabaseMonitoringRole"
      },
      "Action": [
        "rds:DescribeDBInstances",
        "rds:DescribeDBClusters"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "rds:DatabaseEngine": ["mysql", "postgres"]
        },
        "ForAllValues:StringLike": {
          "rds:db-tag/Environment": ["production", "staging"]
        }
      }
    }
  ]
}

For Lambda cross-account invocation with restrictions:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:root"
      },
      "Action": "lambda:InvokeFunction",
      "Resource": "arn:aws:lambda:us-east-1:ACCOUNT:function:data-processor",
      "Condition": {
        "StringEquals": {
          "lambda:InvocationType": "RequestResponse"
        },
        "ForAllValues:StringLike": {
          "aws:userid": ["AIDACKCEVSQ6C2EXAMPLE:*"]
        }
      }
    }
  ]
}

Advanced Condition Patterns

Combining Multiple Conditions

Complex scenarios require combining multiple condition types. Here's a policy for a cross-account CI/CD role that can only deploy during business hours from specific IP ranges:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/CICDRole"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "unique-external-id-12345"
        },
        "IpAddress": {
          "aws:SourceIp": [
            "203.0.113.0/24",
            "198.51.100.0/24"
          ]
        },
        "DateGreaterThan": {
          "aws:TokenIssueTime": "2023-01-01T00:00:00Z"
        },
        "NumericBetween": {
          "aws:TokenIssueTime": ["32400", "64800"]
        },
        "StringLike": {
          "aws:userid": "AIDACKCEVSQ6C2EXAMPLE:ci-cd-*"
        }
      }
    }
  ]
}

Dynamic Conditions with Tags

Use resource tags to create dynamic access patterns:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/DataAnalystRole"
      },
      "Action": [
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::analytics-data",
        "arn:aws:s3:::analytics-data/*"
      ],
      "Condition": {
        "StringEquals": {
          "s3:ExistingObjectTag/AccessLevel": "public",
          "s3:ExistingObjectTag/Department": "${aws:PrincipalTag/Department}"
        },
        "ForAllValues:StringEquals": {
          "aws:PrincipalTag/Project": ["analytics", "reporting"]
        }
      }
    }
  ]
}

This policy allows access only to objects tagged with the same department as the principal's department tag, and only for users tagged with specific projects.

Real-World Implementation Scenarios

Scenario 1: Centralized Logging Architecture

In this scenario, multiple application accounts need to send logs to a centralized logging account:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCrossAccountLogDelivery",
      "Effect": "Allow",
      "Principal": {
        "AWS": [
          "arn:aws:iam::111122223333:root",
          "arn:aws:iam::444455556666:root",
          "arn:aws:iam::777788889999:root"
        ]
      },
      "Action": [
        "s3:PutObject",
        "s3:PutObjectAcl"
      ],
      "Resource": "arn:aws:s3:::central-logs-bucket/*",
      "Condition": {
        "StringEquals": {
          "s3:x-amz-server-side-encryption": "aws:kms",
          "s3:x-amz-server-side-encryption-aws-kms-key-id": "arn:aws:kms:us-east-1:LOGGING-ACCOUNT:key/12345678-1234-1234-1234-123456789012"
        },
        "StringLike": {
          "s3:x-amz-metadata-directive": "REPLACE"
        },
        "ForAllValues:StringEquals": {
          "s3:ExistingObjectTag/LogSource": ["cloudtrail", "application", "vpc-flow"]
        }
      }
    }
  ]
}

Scenario 2: Cross-Account Database Access

For a scenario where an analytics account needs read-only access to production databases:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/AnalyticsReadRole"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "analytics-read-only-2023"
        },
        "Bool": {
          "aws:SecureTransport": "true"
        },
        "DateGreaterThan": {
          "aws:TokenIssueTime": "2023-01-01T00:00:00Z"
        },
        "DateLessThan": {
          "aws:TokenIssueTime": "2023-12-31T23:59:59Z"
        },
        "IpAddress": {
          "aws:SourceIp": "10.0.0.0/8"
        }
      }
    }
  ]
}

Combined with a permission policy that restricts database operations:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "rds:DescribeDBInstances",
        "rds:DescribeDBClusters",
        "rds-data:ExecuteStatement"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "rds:DatabaseEngine": ["mysql", "postgres"]
        },
        "ForAllValues:StringLike": {
          "rds:db-tag/Environment": "production"
        },
        "StringLike": {
          "rds-data:StatementType": "SELECT"
        }
      }
    }
  ]
}

Scenario 3: Emergency Break-Glass Access

Sometimes you need emergency access mechanisms with strong audit trails:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EmergencyAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/EmergencyResponseRole"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "Bool": {
          "aws:MultiFactorAuthPresent": "true"
        },
        "NumericLessThan": {
          "aws:MultiFactorAuthAge": "1800"
        },
        "StringEquals": {
          "sts:ExternalId": "${aws:userid}"
        },
        "IpAddress": {
          "aws:SourceIp": ["203.0.113.0/24"]
        }
      }
    }
  ]
}

This requires MFA within the last 30 minutes, uses the caller's user ID as an external ID for additional verification, and restricts source IP.

Monitoring and Alerting for Cross-Account Access

Having proper policies is only half the battle. You need monitoring to ensure they're working correctly and to detect potential abuse.

CloudTrail Monitoring

Set up CloudTrail events to monitor cross-account AssumeRole operations:

{
  "eventVersion": "1.05",
  "userIdentity": {
    "type": "AssumedRole",
    "principalId": "AIDACKCEVSQ6C2EXAMPLE:cross-account-user",
    "arn": "arn:aws:sts::123456789012:assumed-role/CrossAccountRole/cross-account-user",
    "accountId": "123456789012"
  },
  "eventTime": "2023-11-20T15:30:00Z",
  "eventSource": "sts.amazonaws.com",
  "eventName": "AssumeRole",
  "sourceIPAddress": "203.0.113.1",
  "resources": [
    {
      "accountId": "987654321098",
      "type": "AWS::STS::Role",
      "ARN": "arn:aws:iam::987654321098:role/TargetRole"
    }
  ]
}

Create CloudWatch alarms for unexpected cross-account access patterns:

aws logs put-metric-filter \
    --log-group-name CloudTrail/CrossAccountAccess \
    --filter-name UnexpectedCrossAccountAssumeRole \
    --filter-pattern '{ ($.eventName = AssumeRole) && ($.sourceIPAddress != 203.0.113.*) }' \
    --metric-transformations \
        metricName=UnexpectedCrossAccountAccess,\
        metricNamespace=Security,\
        metricValue=1

Access Analyzer

Use AWS IAM Access Analyzer to identify overly permissive cross-account policies:

aws accessanalyzer create-analyzer \
    --analyzer-name cross-account-analyzer \
    --type ACCOUNT \
    --tags Key=Purpose,Value=CrossAccountSecurity

Access Analyzer will flag external access and help you identify where your policies might be too permissive.

Common Pitfalls and How to Avoid Them

1. The "Works on My Machine" Syndrome

Problem: Policies work in development but fail in production due to different IP ranges, time zones, or network configurations.

Solution: Use policy simulation and test across all environments:

aws iam simulate-principal-policy \
    --policy-source-arn arn:aws:iam::123456789012:role/TestRole \
    --action-names s3:GetObject \
    --resource-arns arn:aws:s3:::test-bucket/test-object \
    --context-entries ContextKeyName=aws:SourceIp,ContextKeyValues=203.0.113.1,ContextKeyType=ip

2. Time Zone Confusion

Problem: Time-based conditions fail because of UTC vs. local time confusion.

Solution: Always use UTC in policies and document time restrictions clearly:

{
  "Comment": "Allow access during US business hours (9 AM - 6 PM EST = 14:00 - 23:00 UTC)",
  "Condition": {
    "ForAllValues:NumericBetween": {
      "aws:TokenIssueTime": ["50400", "82800"]
    }
  }
}

3. Condition Key Confusion

Problem: Using the wrong condition keys or operators for specific scenarios.

Solution: Create a reference document mapping your use cases to appropriate condition keys:

Use CaseCondition KeyOperator
Restrict by source IPaws:SourceIpIpAddress
Require encryption in transitaws:SecureTransportBool
Time-based accessaws:CurrentTimeDateGreaterThan/DateLessThan
MFA requirementaws:MultiFactorAuthPresentBool
Resource taggingaws:ResourceTag/TagKeyStringEquals

4. External ID Mismanagement

Problem: Hardcoding external IDs or using predictable values.

Solution: Use unique, unpredictable external IDs and rotate them regularly:

import secrets
import string

def generate_external_id():
    """Generate a cryptographically secure external ID"""
    alphabet = string.ascii_letters + string.digits
    return ''.join(secrets.choice(alphabet) for i in range(32))

external_id = generate_external_id()
print(f"Use this external ID: {external_id}")

Testing and Validation

Policy Simulation

Use the IAM policy simulator to test your conditions before deployment:

# Test S3 access from specific IP
aws iam simulate-principal-policy \
    --policy-source-arn arn:aws:iam::123456789012:role/CrossAccountRole \
    --action-names s3:GetObject \
    --resource-arns arn:aws:s3:::my-bucket/my-object \
    --context-entries \
        ContextKeyName=aws:SourceIp,ContextKeyValues=203.0.113.1,ContextKeyType=ip \
        ContextKeyName=s3:SecureTransport,ContextKeyValues=true,ContextKeyType=boolean

Integration Testing

Create automated tests for your cross-account access patterns:

import boto3
import pytest
from moto import mock_sts, mock_iam

@mock_sts
@mock_iam
def test_cross_account_assume_role_with_conditions():
    # Setup
    sts_client = boto3.client('sts', region_name='us-east-1')
    
    # Test assuming role with valid conditions
    try:
        response = sts_client.assume_role(
            RoleArn='arn:aws:iam::987654321098:role/TestRole',
            RoleSessionName='test-session',
            ExternalId='valid-external-id'
        )
        assert response['Credentials']['AccessKeyId']
    except Exception as e:
        pytest.fail(f"Valid assume role failed: {e}")
    
    # Test assuming role with invalid external ID
    with pytest.raises(Exception):
        sts_client.assume_role(
            RoleArn='arn:aws:iam::987654321098:role/TestRole',
            RoleSessionName='test-session',
            ExternalId='invalid-external-id'
        )

Performance and Scale Considerations

Policy Evaluation Performance

IAM policy evaluation has performance implications, especially with complex conditions:

  1. Condition Complexity: Each condition adds evaluation overhead
  2. Policy Size: Larger policies take longer to evaluate
  3. Number of Policies: Multiple policies attached to a principal increase evaluation time

Best Practice: Use specific conditions rather than complex logic:

// Good - specific and fast
{
  "Condition": {
    "StringEquals": {
      "aws:RequestedRegion": "us-east-1"
    }
  }
}

// Avoid - complex pattern matching
{
  "Condition": {
    "StringLike": {
      "aws:RequestedRegion": "us-*-[0-9]"
    }
  }
}

Scaling Cross-Account Access

When managing hundreds of accounts, consider policy templates and automation:

import json
import boto3

def create_cross_account_policy(trusted_accounts, resource_arn, conditions=None):
    """Generate standardized cross-account policy"""
    policy = {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Principal": {
                    "AWS": [f"arn:aws:iam::{account}:root" for account in trusted_accounts]
                },
                "Action": "sts:AssumeRole"
            }
        ]
    }
    
    if conditions:
        policy["Statement"][0]["Condition"] = conditions
    
    return json.dumps(policy, indent=2)

# Standard conditions for production access
production_conditions = {
    "StringEquals": {
        "sts:ExternalId": "production-access-2023"
    },
    "Bool": {
        "aws:SecureTransport": "true"
    },
    "IpAddress": {
        "aws:SourceIp": ["10.0.0.0/8", "172.16.0.0/12"]
    }
}

policy = create_cross_account_policy(
    trusted_accounts=["123456789012", "234567890123"],
    resource_arn="arn:aws:iam::987654321098:role/ProductionAccess",
    conditions=production_conditions
)

Conclusion

Implementing secure cross-account access without over-privileging requires a deep understanding of IAM policy conditions and careful planning. The key principles are:

  1. Start with least privilege: Only grant the minimum permissions needed
  2. Layer your conditions: Combine multiple condition types for defense in depth
  3. Monitor continuously: Use CloudTrail and Access Analyzer to detect issues
  4. Test thoroughly: Use policy simulation and integration testing
  5. Document everything: Make your security decisions auditable and repeatable

Remember, security is not a one-time configuration—it's an ongoing process. Review your cross-account policies regularly, monitor for anomalies, and update conditions as your architecture evolves.

The examples and patterns in this article provide a solid foundation, but every organization's needs are different. Adapt these patterns to your specific use cases, and always err on the side of caution. It's better to have a user contact you about access issues than to have an attacker gain unauthorized access to your resources.

When you're implementing these policies, start with a staging environment, test thoroughly, and roll out changes gradually. And yes, you'll probably get paged at 3 AM when someone can't access something they need—but you'll also sleep better knowing your cross-account access is properly secured.

Share:

Was this article helpful?

Riku Tanaka
Riku Tanaka

SRE & Observability Engineer

If it's not measured, it doesn't exist. SLO-driven, metrics-obsessed, and the person who gets paged at 3 AM so you don't have to. Observability isn't optional.

Related Articles

More in Security

View all →

Discussion