AWS Resource Control Policies & Data Perimeter Design
Design and enforce an organization-wide data perimeter with Resource Control Policies. Covers how RCPs differ from SCPs, the 45 supported services, quotas and exemptions, condition-key patterns, safe rollout across an OU hierarchy, and diagnosing RCP denials.
๐ Prerequisites
- Working knowledge of AWS Organizations, OU hierarchies, and Service Control Policies.
- Fluency in IAM policy evaluation logic โ identity-based, resource-based, and permissions boundaries.
- An organization with all features enabled. RCPs are unavailable in consolidated-billing-only organizations.
- Terraform or CloudFormation experience for deploying organization policies as code.
- Access to CloudTrail across member accounts for pre-deployment impact analysis.
๐ก The Gap RCPs Were Built to Close
For a decade, the only organization-wide preventative control AWS offered was the Service Control Policy โ and an SCP can only constrain principals that live inside your accounts. It has nothing to say about a principal in someone else's account calling into your S3 bucket, or about a resource-based policy that a developer widened last Tuesday. Resource control policies close that gap by bounding what can be done to your resources, no matter who is asking. This guide covers what RCPs actually enforce, where they stop, and how to build a data perimeter with them without locking yourself out of production.
What You'll Learn
๐ท๏ธ Topics Covered
Why "SCPs for Resources" Is the Wrong Mental Model
Nearly every introduction to RCPs opens with some version of "they're SCPs, but for resources." It is a useful one-line orientation and a bad foundation for policy design, because the two policy types differ in four ways that change how you write them.
1. They Point in Opposite Directions
An SCP bounds what principals in your member accounts can do โ anywhere, to anything. An RCP bounds what anyone can do to resources in your member accounts. The AWS Organizations documentation frames the choice exactly this way: use an SCP to limit IAM principals within your organization's member accounts; use an RCP to restrict principals external to your organization making requests against resources inside it.
The practical consequence is that RCPs reach principals an SCP can never touch. An RCP evaluates on requests from principals outside your organization, and it applies to the account root user. The one class of caller it does not reach is service-linked roles, covered below.
๐ The Same Control, Two Directions
SCP โโ "principals in MY accounts may not call s3:PutObject
against buckets outside my organization"
โ stops MY people writing data OUT (exfiltration by my identities)
RCP โโ "no principal may call s3:GetObject against MY buckets
unless they belong to my organization"
โ stops OTHER people reading data OUT (exfiltration to their identities)
Neither substitutes for the other. A complete perimeter needs both,
which is why the AWS data perimeter guidance pairs them. 2. Inheritance Is a Union of Denies, Not an Intersection of Allows
This is the difference that most often produces surprising results, and it is worth being precise about.
With SCPs, a permission survives only if every policy in the path from the root down to the account allows it. Attaching an SCP that allows less at any level narrows the effective set. Practitioners internalise this as "SCPs intersect."
RCPs do not work that way. For customer-authored RCPs the Effect element must be Deny โ Allow is supported only in the AWS managed RCPFullAWSAccess policy. Evaluation is therefore a union: a permission is denied for a resource if any RCP from the root, through each OU in the direct path, down to the account itself denies it. There is no allow-list to intersect, because you cannot write an allow.
๐ RCPFullAWSAccess and Why You Cannot Detach It
When you enable RCPs, AWS attaches this managed policy to the organization root, every OU, and every account. It cannot be detached, and it counts against your per-entity policy quota.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RCPFullAWSAccess",
"Effect": "Allow",
"Principal": "*",
"Action": "*",
"Resource": "*"
}
]
} Read this carefully, because it is widely misread. This policy does not grant anything. Its function is to let all principals and actions pass through RCP evaluation, so that enabling RCPs is a no-op until you attach your first Deny. Permissions still come entirely from identity-based and resource-based policies. The AWS documentation is explicit that no permissions are granted by an RCP.
3. The Syntax Is Resource-Based, and Deliberately Constrained
RCPs use resource-based policy syntax, which means a Principal element is mandatory in every statement. It also means several elements you reach for out of habit are simply unavailable.
โ ๏ธ Syntax Constraints That Bite on the First Policy
REQUIRED in every statement: Effect, Principal, Action, Resource (or NotResource)
Effect must be "Deny" in any policy you write.
"Allow" exists only in RCPFullAWSAccess.
Principal mandatory, and the ONLY permitted value is "*".
You scope to specific principals via the Condition element,
never via Principal.
Action must name a service prefix. "Action": "*" is not permitted
in a customer managed RCP. Wildcards within a service are fine:
"s3:*", "sts:Get*", "kms:Decrypt".
NOT SUPPORTED: NotPrincipal NotAction
Version must be "2012-10-17".
The Principal: "*" plus condition-based scoping pattern is the single biggest stylistic departure from SCP authoring. If you find yourself trying to name a principal in the Principal block, you are writing a bucket policy, not an RCP.
4. Effective Permissions Are an Intersection Across Four Policy Types
AWS states the model directly: effective permissions are the logical intersection of what the RCPs and SCPs allow and what the identity-based and resource-based policies allow. In operational terms, an RCP can only ever take permissions away. A resource owner who attaches a resource-based policy granting full access to the world still gets nothing if an RCP above their account denies it โ and equally, an RCP that denies nothing grants nothing.
๐ฏ What This Changes About How You Write Them
- Design for the union. Because any level can deny, an RCP at the root is genuinely organization-wide with no per-OU escape hatch. Exceptions must be written into the policy's conditions, not layered underneath it.
- Budget your denies. There is no allow-list to relax later, so an over-broad deny is corrected by editing the policy, not by attaching a narrower one below.
- Condition keys carry all the nuance. With
Principalpinned to"*"andNotActionunavailable, every exception you need lives in theConditionblock. - Never rely on an RCP to grant. An RCP is a ceiling. Removing an identity policy and expecting the RCP to keep access working is a category error.
Which Services RCPs Actually Cover
If you read a blog post about RCPs written any time in the year after launch, it told you they support five services: S3, STS, KMS, SQS, and Secrets Manager. That was accurate at the November 2024 launch. It is badly out of date now, and it is the most common factual error in current RCP writing.
At the time of writing, the AWS Organizations documentation lists 45 services. The set includes several that people still assume are out of scope โ DynamoDB and DynamoDB Accelerator among them, which directly contradicts the widely-cited claim that RCPs cannot cover AWS databases.
๐ Current Supported Service Prefixes
appconfig cognito-identity kendra s3
appstream cognito-idp kinesisvideo secretsmanager
autoscaling comprehend kms signin
clouddirectory comprehendmedical logs sqs
cloudfront cost-optimization-hub memorydb sts
cloudsearch dax networkmonitor support
codebuild dynamodb opensearch textract
codecommit ecr aoss timestream-influxdb
codepipeline events pca-connector-ad transcribe
firehose polly transfer
fis pricing translate
health inspector-scan wafv2 This list has grown steadily โ ECR and OpenSearch Serverless were added in June 2025, Cognito and CloudWatch Logs in January 2026, with further expansions in between. Treat the AWS Organizations documentation page as the source of truth and re-check it before you scope a policy; anything written down here, including this guide, has a shelf life.
Supported Service Does Not Mean Supported Action
This is the nuance that catches people who checked the service list and stopped there. An RCP applies to actions that authorize a resource as part of the request. AWS defines this precisely: the resources RCPs apply to are those appearing in the "Resource type" column of the action table in the Service Authorization Reference. If an action has an entry in that column, the RCP attached to the account owning the resource is evaluated.
So s3:GetObject authorizes the object resource and is in scope. An action in a supported service that authorizes no resource type is not covered by an RCP, even though its service prefix appears in the list above. Before you rely on a deny statement, confirm the specific action authorizes a resource.
๐ Verifying an Action Is In Scope
1. Open the Service Authorization Reference for the service:
https://docs.aws.amazon.com/service-authorization/latest/reference/
list_<service>.html
2. Find the action in the Actions table.
3. Check the "Resource types" column.
Populated -> the action authorizes a resource; an RCP applies.
Empty -> no resource is authorized; the RCP will not fire.
4. Confirm the resource is owned by a MEMBER account in your org.
RCPs apply to resources owned by accounts in the organization that
attached them. They do not reach resources in external accounts,
even when your principals are the ones calling. That last point is worth restating because it is the mirror image of what most people expect. Consider a bucket owned by Account A inside your organization, with a bucket policy granting access to a user in Account B outside it. Your RCP applies to that bucket when Account B's user calls it. But your RCP does not apply to resources in Account B when your own users call them. Protecting against your principals reaching outside is SCP territory.
Limits, Exemptions, and the Gaps They Leave
RCP quotas are meaningfully tighter than SCP quotas, and the exemption list is longer than most teams realise. Both shape the architecture, so they belong at the design stage rather than the debugging stage.
Quotas
๐ RCP Quotas Against SCP Quotas
RCP SCP
Maximum policy document size 5,120 10,240 characters
Maximum attached to root 5 10
Maximum attached per OU 5 10
Maximum attached per account 5 10
Maximum policies in the organization 2,000 10,000
The RCPFullAWSAccess managed policy counts against the 5.
Your usable budget is therefore FOUR customer RCPs per entity.
Inherited policies do not count against the per-entity limit โ
only policies directly attached to that root, OU, or account. Half the character budget and half the attachment slots of an SCP is a real constraint at scale. A perimeter built as one policy per control objective will hit the four-policy ceiling quickly, which pushes you toward grouping several statements into fewer, larger documents โ right into the 5,120-character wall.
โ ๏ธ The Whitespace Trap in Infrastructure as Code
- Symptom: a policy that saves cleanly in the console fails with a size error when the same JSON is applied through Terraform or the CLI.
- Cause: AWS documents that saving through the console strips extra whitespace between JSON elements and outside quotation marks, and does not count it. Saving through an SDK operation or the CLI stores the policy exactly as provided, with no automatic removal.
- Consequence: a pretty-printed policy read with
file("perimeter.json")can spend hundreds of its 5,120 characters on indentation. - Fix: build the policy with Terraform's
jsonencode(), which emits compact JSON, or withaws_iam_policy_document. Reservefile()for policies you have already minified, and assert the length in CI.
What RCPs Cannot Reach
Five exemptions, each with an architectural consequence.
๐ซ The Exemption List
1. Resources in the MANAGEMENT ACCOUNT
RCPs affect member accounts only. Delegated administrator accounts
are member accounts, so they ARE covered.
2. SERVICE-LINKED ROLES
RCPs do not affect the effective permissions of any service-linked
role, and do not affect an AWS service's ability to assume one โ
the SLR trust policy is out of scope too.
3. AWS MANAGED KMS KEYS
Created and managed by AWS on your behalf; their permissions cannot
be changed or constrained by an RCP.
4. kms:RetireGrant
Explicitly listed as unaffected. Grant retirement is authorised
through a separate path.
5. Anything a permission was never granted for
An RCP does not grant. A principal with no identity or resource
policy has no access regardless of what the RCP permits. The management account exemption is the one with teeth. If your organization's management account holds workload resources โ an S3 bucket someone stood up years ago, a KMS key still in use โ those resources sit permanently outside every perimeter control you build. This is an independent argument for the long-standing AWS guidance to keep workloads out of the management account entirely, and it is worth auditing before you design the perimeter rather than discovering it afterwards.
The service-linked role exemption matters differently. It means an RCP cannot be used to constrain what an AWS service does on your behalf through its SLR. That is usually what you want โ it is why a well-scoped RCP does not break AWS Config, GuardDuty, or Backup โ but it also means an SLR is not a control point you can tighten from Organizations.
The Data Perimeter Model
A data perimeter is not a product and not a single policy. It is a set of coarse-grained guardrails that hold three invariants true across every account you own: only trusted identities reach your resources, your identities reach only trusted resources, and both happen only over expected networks. RCPs implement one column of that grid. Understanding which column keeps you from trying to make RCPs do work they structurally cannot.
๐งญ Three Perimeters, Three Enforcement Points
MY IDENTITIES MY RESOURCES MY NETWORKS
--------------- --------------- ---------------
Identity (n/a) RCP VPC endpoint
perimeter aws:PrincipalOrgID policy
"only trusted aws:PrincipalIsAWS aws:PrincipalOrgID
identities" Service
Resource SCP (n/a) VPC endpoint
perimeter aws:ResourceOrgID policy
"only trusted aws:ResourceOrgID
resources"
Network SCP RCP (n/a)
perimeter aws:SourceIp aws:SourceVpc
"only expected aws:SourceVpc aws:VpceOrgID
networks" aws:SourceVpce aws:ViaAWSService RCPs own the middle column. They answer "who may touch my resources, and from where." They cannot answer "what may my principals touch" โ that is the SCP column, and it is why the AWS data perimeter guidance ships SCP examples alongside the RCP examples rather than instead of them.
The Reference Policy Set
AWS maintains a public repository of data perimeter policy examples. The RCP folder is organised into three policies, and the split is worth adopting because it maps onto how the controls fail and get exempted independently.
๐ฆ How to Split the Policies
- Identity perimeter RCP. Denies access from principals outside your organization, with carve-outs for AWS service principals and named third-party accounts. Contains the confused-deputy statement.
- Network perimeter RCP. Denies access that does not originate from your VPCs, your corporate CIDRs, or an AWS service acting on your behalf. Two variants exist depending on whether the services you use support
aws:VpceOrgID. - Governance RCP. Protects the dependencies the other two rely on โ principally the session tags used to scope them. Without this, a trusted third party can tag its own sessions to exempt itself from your network perimeter.
Three policies plus RCPFullAWSAccess is exactly four of your five per-entity slots. That is not a coincidence in the reference design, and it is the practical reason to resist splitting further.
Writing the Core Perimeter RCPs
The policies below are written from the AWS reference patterns and the condition-key semantics documented in the IAM User Guide. Treat them as annotated starting points. Every one of them will deny something you need on the first attempt in a real organization, which is what the rollout section is for.
The Identity Perimeter
The core statement denies any action against your resources when the calling principal is not in your organization and is not an AWS service.
๐ EnforceOrgIdentities
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EnforceOrgIdentities",
"Effect": "Deny",
"Principal": "*",
"Action": [
"s3:*",
"sqs:*",
"kms:*",
"secretsmanager:*",
"sts:AssumeRole",
"ecr:*",
"dynamodb:*"
],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:PrincipalOrgID": "o-EXAMPLE12345",
"aws:PrincipalAccount": [
"111122223333",
"444455556666"
]
},
"BoolIfExists": {
"aws:PrincipalIsAWSService": "false"
}
}
}
]
} Four things in that statement earn their place, and three of them are the reason hand-rolled versions break.
StringNotEqualsIfExistsrather thanStringNotEquals. If the condition key is absent from the request context,StringNotEqualsevaluates true and the deny fires. TheIfExistssuffix makes the statement skip requests where the key was never populated, which is the difference between a perimeter and an outage.aws:PrincipalIsAWSServiceinstead ofNotPrincipal. RCPs do not supportNotPrincipal, and AWS documents that you cannot useNotPrincipalwith a service principal in any case. This condition key is the supported way to exempt AWS services from a Deny.aws:PrincipalAccountfor named third parties. Vendors with legitimate cross-account access โ a backup provider, a CSPM scanner โ go here by account ID. Keep the list short and reviewed; every entry is a hole in the perimeter.- An explicit action list, not a wildcard.
"Action": "*"is invalid in a customer RCP. Enumerate service prefixes, and only ones that appear on the supported-services list.
โ ๏ธ Why STS Actions Are Enumerated, Not Wildcarded
The AWS reference policy deliberately omits several STS actions from this statement, and the reasoning is not obvious.
sts:AssumeRoleWithSAMLandsts:AssumeRoleWithWebIdentityare excluded. Theaws:PrincipalOrgIDkey is only present in the request context when the calling principal is a member of an organization. Federated users are not, so including these actions would deny every SAML and OIDC federation in your estate.sts:SetSourceIdentityandsts:TagSessionare excluded to avoid breaking the federation calls above when they set a source identity or pass session tags.sts:GetCallerIdentityis excluded because it requires no permissions at all, so denying it accomplishes nothing.
Federated identity is instead constrained by separate statements that pin the trusted OIDC provider and, for multi-tenant providers such as GitHub Actions, the specific tenant within it via the sub claim.
Confused Deputy Protection
The identity statement lets every AWS service principal through. That is necessary โ CloudTrail, VPC Flow Logs, and Config all write to your buckets using service principals โ but on its own it means any AWS service can be induced to act against your resources on someone else's behalf. The second statement closes that.
๐ญ EnforceConfusedDeputyProtection
{
"Sid": "EnforceConfusedDeputyProtection",
"Effect": "Deny",
"Principal": "*",
"Action": [
"s3:*",
"sqs:*",
"kms:*",
"secretsmanager:*",
"ecr:*"
],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:SourceOrgID": "o-EXAMPLE12345",
"aws:SourceAccount": [
"111122223333",
"444455556666"
]
},
"Bool": {
"aws:PrincipalIsAWSService": "true"
},
"Null": {
"aws:SourceAccount": "false"
}
}
} Read this as: when an AWS service principal is calling, and the request carries a source account, that source must belong to my organization.
The Null operator on aws:SourceAccount is the load-bearing element and the part most often dropped. It scopes the statement to only those requests that actually populate the cross-service confused-deputy keys. AWS documents the choice explicitly: aws:SourceAccount is used in the Null check rather than aws:SourceOrgID so that the control still applies when a request originates from an account that does not belong to any organization. A pleasant side effect is that when a service adds support for these keys, it comes under the policy automatically.
Several integrations legitimately do not populate aws:SourceOrgID, and the Null condition is what stops them breaking. AWS names the cases: services that assume service roles you created, where iam:PassRole already enforces same-account placement; services using KMS grants, where the grant's encryption context restricts it to the resource it was created for; and Classic and Application Load Balancer access logging in some Regions, which uses service principals without populating the key.
The Network Perimeter
The network statement asserts that requests to your resources arrive over networks you control. Which condition key you reach for depends on service support.
๐ EnforceNetworkPerimeter
{
"Sid": "EnforceNetworkPerimeter",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:*", "sqs:*", "kms:*", "secretsmanager:*"],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:SourceIp": ["203.0.113.0/24", "198.51.100.0/24"],
"aws:VpceOrgID": "o-EXAMPLE12345",
"aws:PrincipalAccount": ["111122223333"]
},
"BoolIfExists": {
"aws:PrincipalIsAWSService": "false",
"aws:ViaAWSService": "false"
},
"ArnNotLikeIfExists": {
"aws:PrincipalArn": "arn:aws:iam::*:role/aws:ec2-infrastructure"
}
}
} aws:VpceOrgIDmatches any VPC endpoint in your organization in one key, which is far more maintainable than enumerating endpoint IDs. Check the supported-services list for this key before relying on it.aws:SourceVpcis the fallback for services that do not supportaws:VpceOrgIDโ but VPC IDs are only unique within a Region, so AWS pairs it withaws:RequestedRegionto prevent a VPC ID collision in another Region from satisfying the condition. If you useaws:SourceVpc, use both.aws:ViaAWSServiceexempts forward access sessions: the pattern where you call service A, and service A then calls service B using your credentials. Athena reading from S3 on your behalf, or S3 calling KMS for SSE-KMS on upload, both land here. Omit this and you will break SSE-KMS.- The
aws:ec2-infrastructurerole ARN is how EC2 decrypts encrypted EBS volumes โ it calls KMS from the service network under a role created in your account. It needs an explicit exemption.
๐ฅ Two Things This Policy Will Break on Day One
- CloudFront origin access identity. If any distribution still uses OAI to reach an S3 origin, the identity perimeter statement severs it. The correct fix is migrating to origin access control. If you need an interim bridge, exempt the OAI user ARN โ
arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <id>โ viaaws:PrincipalArn, and treat it as debt with a date on it. - ELB access logging. In some Regions, Classic and Application Load Balancers publish logs to your buckets using AWS account credentials rather than a service principal, so
aws:PrincipalIsAWSServicedoes not exempt them. Add the regional ELB account ID toaws:PrincipalAccount. Scope the bucket grant to the path containing the account ID, since the log object names always carry it.
Scoping and Exceptions
With NotAction unavailable and Principal pinned to "*", every exception lives in conditions or in NotResource. Four mechanisms, in rough order of preference:
๐ท๏ธ Exempting Specific Resources
aws:ResourceTag/<key> Tag-based exclusion. Cleanest, but only works
for resources supporting tag-based authorization.
Check the Service Authorization Reference.
s3:ExistingObjectTag Service-specific equivalents where the generic
key is unsupported.
aws:ResourceAccount Exclude everything owned by named accounts.
aws:ResourceOrgPaths Exclude an entire OU subtree.
NotResource Exclude specific ARNs outright. Blunt, verbose,
and expensive against a 5,120-character budget.
Use last.
If you scope the perimeter by tags on principals โ the reference policies use a dp:include:network convention โ you must also prevent untrusted parties setting those tags themselves. That is the entire purpose of the governance RCP: a statement blocking third parties from passing data-perimeter session tags on sts:AssumeRole. Without it, an account outside your organization can tag its own identities to opt out of your network perimeter.
Rolling Out Safely Across an OU Hierarchy
AWS states this plainly and it is worth repeating: do not attach RCPs to the organization root without thoroughly testing the impact first. The recommended progression is individual test accounts, then OUs lower in the hierarchy, then upward. What that guidance does not tell you is how to know the impact before you attach anything, which is the part worth engineering.
Phase 1 โ Measure Before You Write
Two data sources tell you what a perimeter would break, and both are available before you create a single policy.
๐ CloudTrail as a Pre-Deployment Simulator
-- Athena over an organization CloudTrail trail.
-- Every request to your resources from a principal outside the org:
-- these are exactly the calls the identity perimeter would deny.
SELECT
useridentity.type AS principal_type,
useridentity.accountid AS calling_account,
eventsource,
eventname,
count(*) AS calls
FROM cloudtrail_logs
WHERE eventtime >= '2026-06-01'
AND recipientaccountid <> useridentity.accountid
AND useridentity.accountid NOT IN (SELECT account_id FROM my_org_accounts)
AND eventsource IN (
's3.amazonaws.com','kms.amazonaws.com','sqs.amazonaws.com',
'secretsmanager.amazonaws.com','sts.amazonaws.com','ecr.amazonaws.com'
)
GROUP BY 1,2,3,4
ORDER BY calls DESC;
-- Ninety days is the minimum useful window. Quarterly batch jobs,
-- annual audit tooling, and DR tests will not appear in thirty. Every row is either a third party you must allow-list or an access path you did not know existed. Both are findings. AWS additionally recommends reviewing IAM Access Analyzer external access findings alongside CloudTrail, since those surface resources that are currently shared externally or public whether or not anyone has called them recently. CloudTrail shows you what happened; Access Analyzer shows you what could.
Phase 2 โ Stage Through the Hierarchy
๐ช Attachment Order
1. Sandbox account Attach. Break things deliberately.
Verify every deny you expect actually fires.
2. Non-production OU Attach. Run the full CI/CD suite,
cross-account deploys, and a DR drill.
Watch CloudTrail AccessDenied volume daily.
3. One production OU Attach to your lowest-criticality prod OU.
Hold for a full business cycle โ month-end
close and batch windows are where the
surprises live.
4. Remaining production OUs One at a time.
5. Organization root Only after every OU below it has run the
policy clean. At this point attaching at
root is a consolidation, not a change.
Rollback at any stage is DetachPolicy, which takes effect immediately.
Keep the detach command in the runbook and make sure the on-call
engineer has permissions to run it from the management account. One structural note on this sequence. Because RCP inheritance is a union of denies, moving a policy up the hierarchy never relaxes anything โ an RCP at the root denies for every account beneath it, and no OU-level policy can carve an exception out. This is the opposite of the SCP intuition, where a permissive parent leaves room for a restrictive child. Plan for exceptions to live inside the policy conditions from the start, because you cannot add them underneath later.
Phase 3 โ Watch the Right Signal
An RCP denial appears in CloudTrail as an AccessDenied error like any other. Alert on the rate of change rather than the absolute count โ most organizations have a persistent background level of denied calls, and what matters is the step change when a policy attaches.
๐ Break-Glass Considerations
- The management account is your escape hatch, by design. RCPs do not affect resources in it, and only it can detach a policy. Guard access to it accordingly.
- RCPs apply to the root user of member accounts. A break-glass procedure that assumes root can bypass organization controls is wrong for RCPs. Test it.
- Delegated administrator accounts are member accounts. Your security tooling account gets no exemption. If your Security Hub or Config delegated admin needs cross-account reach, verify it under the policy before rollout, not after.
- Service-linked roles are exempt. This is what keeps AWS-native services working, and it is also why you should not assume an RCP constrains everything an AWS service does in your accounts.
Deploying and Validating RCPs as Code
An organization policy applied by hand in the console is an outage waiting for a Friday. RCPs deploy through the same Organizations API as SCPs, which means Terraform, CloudFormation, and CDK all handle them โ with one size trap specific to RCPs.
๐๏ธ Terraform
resource "aws_organizations_policy" "identity_perimeter" {
name = "identity-perimeter-rcp"
description = "Denies access from principals outside the organization"
type = "RESOURCE_CONTROL_POLICY"
# jsonencode emits compact JSON. Never use file() with a
# pretty-printed document: the CLI and SDK store the policy
# verbatim, so indentation counts against the 5,120 limit.
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "EnforceOrgIdentities"
Effect = "Deny"
Principal = "*"
Action = ["s3:*", "sqs:*", "kms:*", "secretsmanager:*"]
Resource = "*"
Condition = {
StringNotEqualsIfExists = {
"aws:PrincipalOrgID" = data.aws_organizations_organization.current.id
}
BoolIfExists = {
"aws:PrincipalIsAWSService" = "false"
}
}
}
]
})
}
resource "aws_organizations_policy_attachment" "identity_perimeter_nonprod" {
policy_id = aws_organizations_policy.identity_perimeter.id
target_id = aws_organizations_organizational_unit.nonprod.id
} Attach to one target per resource block and stage them across separate applies. A single Terraform run that attaches a new RCP to eight OUs at once removes your ability to observe each attachment independently.
Validating the Policy Document in CI
Generic IaC scanners have limited coverage of organization policies, so the checks worth the most here are the ones you write against the documented RCP constraints. Each of these catches a failure mode that otherwise surfaces as an API error at apply time or, worse, as a policy that attaches cleanly and denies nothing.
โ A Pre-Apply Gate
#!/usr/bin/env python3
"""Validate an RCP document against documented Organizations constraints."""
import json, sys
SUPPORTED = { # keep in sync with the AWS Organizations docs
"s3","sts","kms","sqs","secretsmanager","ecr","aoss","dynamodb","dax",
"logs","cognito-identity","cognito-idp","events","cloudfront","wafv2",
# ... full list in the supported-services section above
}
MAX_CHARS = 5120
def validate(path):
raw = open(path).read()
doc, errs = json.loads(raw), []
compact = json.dumps(doc, separators=(",", ":"))
if len(compact) > MAX_CHARS:
errs.append(f"size {len(compact)} exceeds {MAX_CHARS} even minified")
elif len(raw) > MAX_CHARS:
errs.append(f"size {len(raw)} exceeds limit as written; minify "
f"before apply (compacts to {len(compact)})")
if doc.get("Version") != "2012-10-17":
errs.append("Version must be 2012-10-17")
for st in doc.get("Statement", []):
sid = st.get("Sid", "<no sid>")
if st.get("Effect") != "Deny":
errs.append(f"{sid}: Effect must be Deny in a customer RCP")
if st.get("Principal") != "*":
errs.append(f"{sid}: Principal must be exactly '*'")
if "NotPrincipal" in st or "NotAction" in st:
errs.append(f"{sid}: NotPrincipal/NotAction unsupported in RCPs")
actions = st.get("Action", [])
actions = [actions] if isinstance(actions, str) else actions
if not actions:
errs.append(f"{sid}: Action is required")
for a in actions:
if a == "*":
errs.append(f"{sid}: bare '*' not permitted in Action")
continue
prefix = a.split(":")[0]
if prefix not in SUPPORTED:
errs.append(f"{sid}: '{prefix}' is not RCP-supported; "
f"this statement will never fire")
# IfExists discipline: a bare StringNotEquals on a key that may be
# absent denies requests that never populated it.
for key in st.get("Condition", {}):
if key in ("StringNotEquals", "ArnNotLike", "StringNotLike"):
errs.append(f"{sid}: {key} without IfExists is high risk; "
f"absent keys will trigger the deny")
return errs
if __name__ == "__main__":
failed = False
for p in sys.argv[1:]:
for e in validate(p):
print(f"{p}: {e}"); failed = True
sys.exit(1 if failed else 0)
The unsupported-prefix check is the one that pays for itself. A statement denying rds:* is syntactically valid, attaches without complaint, and enforces nothing โ the policy looks like a control in your compliance evidence while doing no work. That silent no-op is more dangerous than a policy that fails loudly.
The IfExists check is deliberately a warning rather than an error, because there are legitimate uses of the bare operators. Treat it as something a human confirms in review, not a gate.
๐งช Testing Behaviour, Not Just Syntax
- The IAM Policy Simulator does not evaluate RCPs. Do not treat a clean simulation as evidence that a perimeter holds.
- Test with real cross-account calls from a sandbox. Stand up a caller in an account outside the organization and assert the denial from outside in. Nothing else exercises the actual evaluation path.
- Assert the exceptions, not only the denials. A perimeter that blocks everything is trivially easy to write. The tests that matter are the ones proving CloudTrail delivery, SSE-KMS, and your named third parties still work.
- Re-run against the supported-services list on a schedule. The list grows. A service that was out of scope when you wrote the policy may be in scope now, which changes what your wildcards cover.
Diagnosing RCP Denials
An RCP denial looks like every other AccessDenied, which makes attribution the hard part. Work the layers in a fixed order rather than guessing.
๐ฆ Attribution Order
1. Is the resource in a MEMBER account?
Management account resources are never denied by an RCP.
If the resource lives there, the RCP is not your culprit.
2. Is the service on the supported list, and does the action
authorize a resource type?
Check the "Resource types" column in the Service Authorization
Reference. An empty column means the RCP never evaluated.
3. Is the caller a service-linked role?
SLRs are exempt. If an SLR is being denied, look elsewhere.
4. Is the key the condition depends on actually present?
aws:PrincipalOrgID is absent for federated principals.
aws:SourceOrgID is absent for several service integrations.
A bare StringNotEquals on an absent key denies the request.
This is the single most common self-inflicted RCP outage.
5. Walk the attachment path.
aws organizations list-policies-for-target \
--target-id <account-id> \
--filter RESOURCE_CONTROL_POLICY
Then repeat for each parent OU up to the root. Any one of
them can be the source of the deny.
6. Only then look at SCPs, identity policies, resource policies,
permissions boundaries, session policies, and VPC endpoint
policies.
Step 4 deserves emphasis because it produces denials that look impossible. A policy that works perfectly for IAM roles fails for every SAML-federated user, because aws:PrincipalOrgID simply is not in the request context for a federated principal โ the key is populated only when the calling principal is a member of an organization. The condition evaluates as "not equal," the deny fires, and nothing in the policy looks wrong. The IfExists suffix exists for precisely this case.
๐ Enumerating the Full Attachment Path
#!/usr/bin/env bash
# Print every RCP that applies to an account, root-first.
set -euo pipefail
ACCOUNT="$1"
TARGET="$ACCOUNT"
PATH_IDS=()
while :; do
PATH_IDS=("$TARGET" "${PATH_IDS[@]}")
PARENT=$(aws organizations list-parents --child-id "$TARGET" \
--query 'Parents[0].Id' --output text)
[ "$PARENT" = "None" ] && break
TARGET="$PARENT"
[[ "$PARENT" == r-* ]] && { PATH_IDS=("$PARENT" "${PATH_IDS[@]}"); break; }
done
for id in "${PATH_IDS[@]}"; do
echo "== $id"
aws organizations list-policies-for-target \
--target-id "$id" \
--filter RESOURCE_CONTROL_POLICY \
--query 'Policies[].{Name:Name,Id:Id}' --output table
done Because any policy in this path can deny, reading them root-first mirrors the evaluation order and usually surfaces the offending statement faster than starting at the account.
๐ Key Takeaways
- RCPs deny; they never grant. Effective permissions are the intersection of RCPs, SCPs, identity-based policies, and resource-based policies. Removing an identity policy and expecting the RCP to hold access open is a category error.
- Inheritance is a union of denies. Any RCP in the path from root to account can deny. There is no allow-list to intersect and no way to relax a parent deny from below.
- The five-service claim is obsolete. The supported list stands at 45 services and includes DynamoDB. Check the current documentation rather than any blog post, including this one.
- Supported service is not supported action. An RCP only fires on actions that authorize a resource type in the Service Authorization Reference.
- You get four usable RCPs per entity, at 5,120 characters each. Plan the policy set against that budget before writing the first statement.
- The management account is outside the perimeter, permanently. Audit it for workload resources before you design anything.
- Use the IfExists operators. A bare
StringNotEqualson a condition key that is absent from the request context evaluates true and fires the deny. This is the most common self-inflicted RCP outage, and it is whyaws:PrincipalOrgIDbreaks federated access. - Exempt AWS services with
aws:PrincipalIsAWSService, then constrain them withaws:SourceOrgID. The first without the second leaves every AWS service as a confused deputy against your resources. - Measure with CloudTrail and Access Analyzer before writing a line of policy. Ninety days minimum. CloudTrail shows what happened; Access Analyzer shows what is currently exposed.
- Stage attachments sandbox to non-prod to one prod OU to root. Never attach at root first, and remember that a root-level deny cannot be relaxed by anything below it.
- Gate the policy document in CI. A statement naming a service that RCPs do not support attaches cleanly and enforces nothing โ a control that exists only in your compliance evidence.