A data perimeter answers one brutal question: even if a valid IAM credential leaks, can an attacker move your data out of the org, or pull it in from somewhere you do not trust? Service Control Policies (SCPs) get you part of the way, but they are an identity-side control — they constrain your principals. They say nothing about a foreign or anonymous principal hitting your S3 bucket with a presigned URL, or one of your own roles writing to a bucket in someone else’s account. That gap is exactly where exfiltration lives, and it is the gap that has shown up in nearly every “leaked key → data gone” incident report of the last five years.
Resource Control Policies (RCPs), generally available since November 2024, close it: org-wide guardrails that attach to the resource side of a request and evaluate against whoever is calling — including principals from outside your organization and unauthenticated callers redeeming a presigned URL. Pair them with Declarative Policies to durably pin EC2 configuration (IMDSv2 required, no public AMIs, no public snapshots) and you have a perimeter that survives credential theft, console misconfiguration, and the intern who clicks “make public.” This guide builds all three — the identity perimeter (RCP), the resource perimeter (SCP), the network perimeter (VPC endpoints + aws:SourceVpce), and the durable EC2 baseline (declarative) — end to end, with the exact aws CLI, the JSON, the Terraform, and the CloudTrail pre-flight that keeps you from paging yourself at 2 a.m.
Everything assumes AWS Organizations with all features enabled and a real OU structure. If you are still in consolidated-billing-only mode, fix that first — RCPs, SCPs, and declarative policies all require all-features mode and none of them protect the management account. Because this is a reference you will return to mid-rollout, the policy-type matrix, the condition keys, the supported services, the error codes, and the symptom→cause→fix playbook are all laid out as scannable tables — read the prose once, then keep the tables open while you stage the waves.
What problem this solves
The pain is specific and it is expensive. You have spent years building a clean SCP layer, you feel governed, and then a CI/CD access key leaks from a build log. The key belongs to a legitimate org principal, so every identity-side control you own — every SCP — waves it through. The attacker mints a presigned S3 URL and pulls a customer dataset from a coffee-shop IP to a bucket in their account. Nothing in your guardrails fired, because nothing was watching the resource side of the request or the network it came from. The data is gone, the disclosure clock is running, and your post-incident review says “the credential was valid, the read was authorized” — which is true, and is the whole problem.
What breaks without a data perimeter: exfiltration via leaked keys (the read is authorized), exfiltration via your own roles writing to a foreign bucket (a malicious or compromised principal stages data outward), ingestion of poisoned data from buckets you do not trust, confused-deputy attacks where a foreign principal assumes one of your roles, and the slow-motion leak of accidentally-public AMIs and EBS snapshots that ride along when an account is shared too widely. Each of these is invisible to SCPs because SCPs only constrain your principals acting on resources within your policy’s reach.
Who hits this: any organization past ~20 accounts with real data in S3, credentials in Secrets Manager and KMS, cross-account role assumption, and third-party SaaS vendors (observability, CSPM, backup) with roles in your accounts. It bites hardest on regulated workloads (fintech, health, gov) where a single foreign-principal read is a reportable event, and on platform teams who think SCPs make them safe. The fix is almost never “tighten an IAM policy” — it is “put a guardrail on the resource side and the network side that holds regardless of who is calling.”
To frame the whole field before the deep dive, here is every perimeter dimension this article builds, the control that owns it, the condition key it leans on, and the attack it actually stops:
| Perimeter dimension | The assertion | Control that owns it | Primary condition key | Attack it stops |
|---|---|---|---|---|
| Trusted identities | Only my-org principals touch my resources | RCP (resource side) | aws:PrincipalOrgID |
Foreign/anonymous principal reads your bucket |
| Trusted resources | My principals only touch my-org resources | SCP (identity side) | aws:ResourceOrgID |
Your role exfiltrates to a foreign bucket |
| Trusted networks | Requests come only from expected networks | RCP + VPCE policy | aws:SourceVpce, aws:SourceIp |
Leaked key replayed from the open internet |
| Durable EC2 baseline | IMDSv2 required; no public AMIs/snapshots | Declarative policy | (config, not condition) | SSRF→credential theft; accidental public sharing |
| Service-to-service | AWS-internal calls keep working | (exemptions in all above) | aws:PrincipalIsAWSService, aws:SourceOrgID |
False denials breaking replication/logging |
Learning objectives
By the end of this article you can:
- Explain precisely how RCPs differ from SCPs — resource side vs identity side, who each catches, and why both are required for a complete perimeter.
- Read the AWS authorization chain (identity policy AND every SCP AND every RCP AND the resource-based policy) and predict whether a given request is allowed or denied.
- Enable the
RESOURCE_CONTROL_POLICYandDECLARATIVE_POLICY_EC2policy types and author each of the four perimeter layers in JSON,awsCLI, and Terraform. - Build the trusted-identities RCP that denies S3/STS/SQS/KMS/Secrets Manager to foreign principals while correctly exempting AWS service principals via
aws:SourceOrgIDandaws:PrincipalIsAWSService. - Build the trusted-resources SCP (
aws:ResourceOrgID) and the trusted-networks RCP (aws:SourceVpcewithNull/IfExistsguards) without locking out console or service traffic. - Pin EC2 with a Declarative Policy —
http_tokens: required(IMDSv2),image_block_public_access,snapshot_block_public_access, and a user-facingexception_message. - Stage the rollout sandbox → non-prod → prod → root using a CloudTrail Lake pre-flight that finds every foreign-principal relationship before any deny goes live, and keep a break-glass path that is never subject to the perimeter.
- Carve out documented exceptions (AWS services, SaaS roles with
sts:ExternalId, cross-org sharing) by ARN without ever weakening the baseline, and verify the perimeter holds with IAM Access Analyzer set to the organization zone of trust.
Prerequisites & where this fits
You should already understand AWS Organizations and SCPs: an OU (Organizational Unit) is a container of accounts, policies attach at root/OU/account and inherit downward, and SCPs are deny-by-default-overridable guardrails that filter what your principals may do. You should know IAM policy evaluation — that an explicit Deny always wins, that access requires an Allow somewhere, and what a resource-based policy (an S3 bucket policy, a KMS key policy, a role trust policy) is versus an identity policy. You should be comfortable running aws CLI with a management-account or delegated-admin profile and reading JSON.
This sits at the top of the governance and identity track. It assumes the org foundation from AWS Organizations: SCP Guardrails and Delegated Admin and the landing-zone shape from AWS Control Tower: Multi-Account Landing Zone. It builds directly on IAM evaluation order from IAM Fundamentals: Users, Roles, Policies, Evaluation and the least-privilege patterns in IAM Least Privilege and Permission Boundaries. The network leg leans on VPC Deep Dive: Subnets, Routing, IGW, NAT, Endpoints and AWS PrivateLink: Service Provider and Consumer Cross-Account. The cross-account exception pattern is covered in depth in IAM Cross-Account Roles, External ID, Confused Deputy, Session Policies.
A quick map of who owns and confirms each perimeter leg during a rollout, so you pull the right person into the room:
| Perimeter leg | What lives here | Who usually owns it | What confirms it |
|---|---|---|---|
| Org policy types | RCP/SCP/declarative enablement | Cloud platform / org admin | aws organizations describe-organization |
| Identity perimeter (RCP) | Resource-side org check | Security / platform | CloudTrail AccessDenied on foreign reads |
| Resource perimeter (SCP) | Egress org check | Security / platform | Denied writes to foreign org IDs |
| Network perimeter | VPC endpoints + aws:SourceVpce |
Network team | VPC Flow Logs + endpoint policy |
| EC2 baseline (declarative) | IMDSv2, public-access blocks | Compute / platform | aws ec2 get-image-block-public-access-state |
| Exceptions | SaaS roles, cross-org shares | Security + app owners | Access Analyzer external findings |
Core concepts
Five mental models make every later policy obvious.
Authorization is an intersection, and Deny always wins. A request to AWS is allowed only if it survives every applicable policy and no policy denies it. Adding RCPs extends the chain on the resource side:
Request allowed = identity policy (or resource-based policy) ALLOW
AND every SCP from root to account (no DENY)
AND every RCP from root to account (no DENY) <- resource side, ANY caller
AND the resource-based policy on the target (no DENY)
A single Deny anywhere in that chain kills the request, no matter how many Allows exist. RCPs and SCPs are deny-only: they can never grant access, only remove it from whatever was otherwise permitted.
SCPs watch the caller; RCPs watch the resource. An SCP evaluates against the principal making the call — it can only constrain principals inside your org. An RCP evaluates against the resource being acted on, for any caller, including a principal from a foreign org or an anonymous caller redeeming a presigned URL. “My principals on trusted resources” (SCP) versus “trusted identities on my resources” (RCP) is the first fork in every perimeter decision.
The baseline allows everything; you layer additive denies. RCPs start from an implicit RCPFullAWSAccess policy that allows all actions on all resources, exactly like FullAWSAccess for SCPs. You never grant in an RCP — you attach Deny-with-condition statements on top of that baseline. Detach the baseline and you break access org-wide, so never do that; author additive denies instead.
The management account is exempt — keep data out of it. Neither SCPs nor RCPs apply to the management account’s own principals or resources, even when attached at the root. A workload or bucket living in the management account is outside your perimeter. This is also your break-glass anchor: a role in the management account is, by definition, never filtered by the perimeter.
The supported-service list is small and deliberate. At GA, RCPs support exactly S3, STS, SQS, KMS, and Secrets Manager — the precise set of services that hold or broker your data and credentials. That is not a limitation to lament; it is the realistic exfiltration surface. Lock those five on the resource side and you have closed the doors attackers actually walk through.
The hard limits and quotas that shape how you author and attach these policies — hit one of these mid-rollout and the error is cryptic, so know them up front:
| Limit / quota | RCP | SCP | Declarative (EC2) |
|---|---|---|---|
| Max policy document size | 5,120 characters | 5,120 characters | 10,000 characters |
| Max policies attached per entity (root/OU/account) | 5 | 5 | 5 |
| Max OU nesting depth | 5 levels | 5 levels | 5 levels |
| Audit-only / report mode | None | None | N/A (config, not deny) |
| Applies to management account | No | No | No (mgmt acct exempt) |
| Implicit baseline you must keep | RCPFullAWSAccess |
FullAWSAccess |
None |
| Services in scope (GA) | 5 (S3/STS/SQS/KMS/Secrets) | All | EC2 attributes only |
| Requires all-features Organizations | Yes | Yes | Yes |
The vocabulary in one table
Pin down every moving part before the deep sections. The glossary repeats these for lookup; this is the mental model side by side:
| Concept | One-line definition | Side of the request | Why it matters to the perimeter |
|---|---|---|---|
| SCP | Deny guardrail on your principals | Identity (caller) | Stops your roles reaching foreign resources |
| RCP | Deny guardrail on your resources | Resource (target) | Stops any caller reaching your resources |
| Declarative policy | Durable service config baseline | Service config | Pins IMDSv2, blocks public AMIs/snapshots |
RCPFullAWSAccess |
Implicit allow-all RCP baseline | Resource | Never detach; layer denies on top |
aws:PrincipalOrgID |
Org ID of the calling principal | Condition key | Reject foreign principals on your resources |
aws:ResourceOrgID |
Org ID owning the target resource | Condition key | Reject your principals touching foreign resources |
aws:SourceOrgID |
Org ID of the resource owner in a service call | Condition key | Exempt AWS-service-on-your-behalf traffic |
aws:PrincipalIsAWSService |
Call made by an AWS service principal | Condition key | Exempt service-linked / log-delivery calls |
aws:SourceVpce |
VPC endpoint the request traversed | Condition key | Enforce network trust |
| Break-glass role | Mgmt-account role outside the perimeter | Identity | Your way back in if a policy wedges you |
RCPs vs SCPs: closing the resource-side gap
The mental model that matters most: a request is authorized only if it survives the intersection of every applicable policy, and RCPs add a brand-new term to that intersection on the resource side. SCPs evaluate against the principal making the call; RCPs evaluate against the resource being acted on, regardless of who the caller is — including principals from outside your organization and anonymous callers. That is the entire point, and it is the one thing to internalize before writing a line of JSON.
Here is the full side-by-side. Read every row — the differences are exactly where the perimeter logic lives:
| Dimension | SCP | RCP |
|---|---|---|
| Attaches to | Root / OU / account | Root / OU / account |
| Evaluates against | The principal (caller) | The resource (target) |
| Constrains | Your org’s principals | The resource, for any caller |
| Catches external principals? | No | Yes |
| Catches anonymous / presigned-URL callers? | No | Yes |
| Can it grant access? | No — deny only | No — deny only |
| Implicit baseline | FullAWSAccess (allow all) |
RCPFullAWSAccess (allow all) |
| Affects management account? | No | No |
| Max policy size | 5,120 characters | 5,120 characters |
| Supported services (GA) | All services | S3, STS, SQS, KMS, Secrets Manager |
| Audit-only / report mode | No | No |
| Evaluated together with resource-based policy? | Independently | Yes — RCP Deny overrides bucket/key policy Allow |
Two consequences shape everything below, and both are easy to get wrong:
RCPs start from an implicit
RCPFullAWSAccessbaseline that allows everything, exactly likeFullAWSAccessfor SCPs. You layer deny-with-condition statements on top. If you detach the baseline you break access org-wide, so never do that — author additive denies instead.
The management account is exempt from both SCPs and RCPs. Attaching at the root does not protect the management account’s own resources. Keep workloads and data out of it, and use a management-account role as your break-glass path.
The supported-service list is small but deliberately chosen — it is precisely the set of services that hold or broker data and credentials. Here is what each supported service contributes to the perimeter and the headline action you most want to govern:
| Service | What it holds / brokers | Headline action to govern | Exfiltration path it closes |
|---|---|---|---|
| S3 | Your object data | s3:GetObject, s3:PutObject |
Presigned-URL read; write to foreign bucket |
| STS | Temporary credentials | sts:AssumeRole* |
Foreign principal assuming your role (confused deputy) |
| KMS | Encryption keys | kms:Decrypt, kms:GenerateDataKey |
Decrypting your data with a leaked grant |
| SQS | Queued messages / events | sqs:SendMessage, sqs:ReceiveMessage |
Draining a queue from outside the org |
| Secrets Manager | Credentials & secrets | secretsmanager:GetSecretValue |
Pulling DB/API secrets with a leaked key |
A note on why STS is the highest-leverage line in the whole perimeter: role assumption is the front door to nearly every privilege-escalation and lateral-movement path. Denying assumption of your roles by foreign principals shuts a class of confused-deputy attacks at the resource boundary, before any downstream permission even comes into play. If you only had room for one RCP statement, it would be the sts:AssumeRole deny.
The three trust dimensions
A data perimeter is the cross-product of three statements that should all be true for every legitimate request. Miss one corner and you have a perimeter with a hole in it:
- Trusted identities — only principals from my org touch my resources (
aws:PrincipalOrgID), enforced by RCP. - Trusted resources — my principals only touch resources in my org (
aws:ResourceOrgID), enforced by SCP. - Trusted networks — requests only come from my expected networks (
aws:SourceVpce,aws:SourceVpc,aws:SourceIp), enforced by RCP + VPC endpoint policy.
RCPs own the trusted-identities-on-my-resources corner. SCPs own my-principals-on-trusted-resources. VPC endpoint policies and aws:SourceVpce conditions own trusted networks. Map every control you write back to one of these cells, or you are adding noise. The mapping is worth memorizing as a grid:
| Trust dimension | Enforced by | On which side | Condition key(s) | Without it… |
|---|---|---|---|---|
| Trusted identities | RCP | Resource | aws:PrincipalOrgID |
Foreign principal reads your data |
| Trusted resources | SCP | Identity | aws:ResourceOrgID |
Your principal writes to attacker’s bucket |
| Trusted networks | RCP + VPCE policy | Resource + endpoint | aws:SourceVpce, aws:SourceVpc, aws:SourceIp |
Leaked key works from any IP on earth |
The condition keys you will lean on, what each means, and where it shines — get the semantics exactly right because a wrong key is a silent hole:
| Key | Type | Evaluates | Where it shines | Common mistake |
|---|---|---|---|---|
aws:PrincipalOrgID |
String | Org ID of the caller | RCP: reject foreign principals | Using it in an SCP (it’s caller-side there, redundant) |
aws:ResourceOrgID |
String | Org ID owning the target resource | SCP: stop writes to foreign resources | Expecting RCP to enforce it (resource is foreign, out of reach) |
aws:SourceOrgID |
String | Org ID of the resource owner in an AWS-service call | RCP: scope service-principal access | Forgetting it → breaks S3 replication, log delivery |
aws:PrincipalIsAWSService |
Bool | Request made by an AWS service principal | RCP: exempt service-to-service calls | Forgetting it → denies service-linked roles |
aws:PrincipalOrgPaths |
String (multi) | OU path of the caller | Scope to specific OUs | Path format typos (o-x/r-x/ou-x/) |
aws:SourceVpce |
String | VPC endpoint ID the request traversed | Network trust | No Null/IfExists guard → denies console |
aws:SourceVpc |
String | VPC ID the request originated from | Broader network trust | Confusing it with aws:SourceVpce |
aws:SourceIp |
IP | Public source IP of the caller | Allowlist office / egress IPs | Breaks on NAT/proxy IP churn |
aws:ViaAWSService |
Bool | Call made via another AWS service | Exempt indirect service calls | Conflating with aws:PrincipalIsAWSService |
sts:ExternalId |
String | Agreed secret on role assumption | SaaS vendor carve-out | Reusing the same ExternalId across vendors |
The IfExists operator suffix deserves its own note, because it is the single most common cause of accidental lockouts. Here is how each operator behaves when the key is absent from the request — the difference between a clean perimeter and a 2 a.m. page:
| Operator form | Behaviour when key present | Behaviour when key absent | Use it when |
|---|---|---|---|
StringNotEquals |
Deny if value differs | Deny fires (key missing ≠ your value) | You are certain the key is always present |
StringNotEqualsIfExists |
Deny if value differs | Condition skipped (no deny) | Almost always — avoids stray denials |
Null: {"key":"true"} |
— | Matches only when key is absent | Scope a statement to “no key present” |
Null: {"key":"false"} |
Matches only when key is present | — | “Only apply when the request had this key” |
Bool: {"...":"false"} |
Matches when bool is false | Depends on IfExists |
Pair with IfExists for service exemptions |
Enable RCPs and author the identity perimeter
RCPs are a policy type you turn on at the root, then attach. Enable it from the management account (or a delegated Organizations admin). First find the root, then enable the type:
# Find the root ID
ROOT_ID=$(aws organizations list-roots --query 'Roots[0].Id' --output text)
# Enable the Resource Control Policy type on the org
aws organizations enable-policy-type \
--root-id "$ROOT_ID" \
--policy-type RESOURCE_CONTROL_POLICY
# Terraform equivalent — enable both policy types on the org
resource "aws_organizations_organization" "this" {
feature_set = "ALL"
enabled_policy_types = [
"SERVICE_CONTROL_POLICY",
"RESOURCE_CONTROL_POLICY",
"DECLARATIVE_POLICY_EC2",
]
}
Now the core control: deny any access to S3, STS, SQS, KMS, and Secrets Manager resources unless the calling principal belongs to my org or the call is made by an AWS service on my behalf. The two exception conditions are critical — without them you break replication, AWS service integrations, and a long tail of legitimate cross-service traffic.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EnforceOrgIdentityPerimeter",
"Effect": "Deny",
"Principal": "*",
"Action": [
"s3:*",
"sts:AssumeRole",
"sts:AssumeRoleWithSAML",
"sts:AssumeRoleWithWebIdentity",
"sqs:*",
"kms:*",
"secretsmanager:*"
],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:PrincipalOrgID": "o-abc123def4",
"aws:SourceOrgID": "o-abc123def4"
},
"BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
}
}
]
}
Why each piece is the way it is — this is the part teams copy without understanding and then debug for a week:
| Element | What it does | If you omit / change it |
|---|---|---|
"Effect": "Deny" |
Additive deny over the allow-all baseline | RCPs cannot allow; deny is the only option |
"Principal": "*" |
Applies to every caller, incl. anonymous | Required in an RCP — the resource side has no implicit principal |
StringNotEqualsIfExists |
Deny only when key present and ≠ your value | StringNotEquals (no IfExists) fires on absent keys → breaks service calls |
aws:PrincipalOrgID + aws:SourceOrgID in one block |
OR — deny only if principal is foreign and source-org mismatches | Splitting them into two blocks makes it an AND-of-denies → over-blocks |
aws:SourceOrgID |
Exempts a service principal acting under a resource you own | Omit → S3 replication, CloudTrail-to-bucket writes denied |
BoolIfExists aws:PrincipalIsAWSService false |
Exempts service-linked calls carrying no org key | Omit → log delivery / SLR traffic denied |
sts:AssumeRole* enumerated |
Names the three assume actions explicitly | sts:* would also catch GetCallerIdentity etc. (usually harmless, but noisy) |
Attach it — and start at a sandbox OU (covered in the rollout section), never the root:
# Create the policy, capture its ID, attach to the sandbox OU first
RCP_ID=$(aws organizations create-policy \
--name "rcp-identity-perimeter" \
--type RESOURCE_CONTROL_POLICY \
--content file://rcp-identity-perimeter.json \
--query 'Policy.PolicySummary.Id' --output text)
aws organizations attach-policy \
--policy-id "$RCP_ID" \
--target-id ou-root-sandbox01
resource "aws_organizations_policy" "identity_perimeter" {
name = "rcp-identity-perimeter"
type = "RESOURCE_CONTROL_POLICY"
content = file("${path.module}/rcp-identity-perimeter.json")
}
resource "aws_organizations_policy_attachment" "identity_perimeter_sandbox" {
policy_id = aws_organizations_policy.identity_perimeter.id
target_id = "ou-root-sandbox01" # promote to non-prod → prod → root in waves
}
The actions to enumerate per service, and the trade-off between the broad wildcard and a tight list, so you choose deliberately:
| Service | Broad (service:*) |
Tight (enumerate) | Recommendation |
|---|---|---|---|
| S3 | s3:* |
s3:GetObject, s3:PutObject, s3:DeleteObject, s3:ListBucket |
Start broad; data-plane is what matters |
| STS | sts:* |
sts:AssumeRole, sts:AssumeRoleWithSAML, sts:AssumeRoleWithWebIdentity |
Tight — avoid catching identity-introspection calls |
| KMS | kms:* |
kms:Decrypt, kms:GenerateDataKey, kms:ReEncrypt* |
Broad is fine; KMS is all sensitive |
| SQS | sqs:* |
sqs:SendMessage, sqs:ReceiveMessage, sqs:DeleteMessage |
Broad is fine |
| Secrets Manager | secretsmanager:* |
secretsmanager:GetSecretValue, secretsmanager:BatchGetSecretValue |
Broad is fine; read is the exfil path |
The resource side of trusted resources (SCP)
RCPs stop the world from reaching in. To stop your principals from reaching out — exfiltrating to a bucket in an attacker’s account — you need an SCP keyed on aws:ResourceOrgID. RCPs cannot express this because the target resource is foreign and outside your policy’s reach; only an identity-side SCP on your principals can constrain where they write.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyAccessToForeignResources",
"Effect": "Deny",
"Action": [
"s3:PutObject",
"s3:GetObject",
"sqs:SendMessage",
"secretsmanager:GetSecretValue"
],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:ResourceOrgID": "o-abc123def4"
},
"BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
}
}
]
}
This is the pair to the RCP: together they assert “my principals only touch my resources, and my resources are only touched by my principals.” Exempt the AWS service principal again, or you will break things like Systems Manager pulling a public patch baseline (which legitimately lives outside your org). The two policies are mirror images — keep them straight with this table:
| Statement | Policy type | Reads | Denies when | Exemption |
|---|---|---|---|---|
EnforceOrgIdentityPerimeter |
RCP (resource side) | aws:PrincipalOrgID of caller |
Caller’s org ≠ yours | aws:SourceOrgID, aws:PrincipalIsAWSService |
DenyAccessToForeignResources |
SCP (identity side) | aws:ResourceOrgID of target |
Target resource’s org ≠ yours | aws:PrincipalIsAWSService |
What each layer cannot do alone — the reason you need both, stated as a coverage matrix:
| Threat | RCP alone | SCP alone | Both |
|---|---|---|---|
| Foreign principal reads your S3 bucket | Blocked | Not blocked | Blocked |
| Your role writes to attacker’s S3 bucket | Not blocked | Blocked | Blocked |
| Anonymous presigned-URL read of your bucket | Blocked | Not blocked | Blocked |
| Your role pulls poisoned data from foreign bucket | Not blocked | Blocked | Blocked |
| AWS service replicating to your DR bucket | Allowed (exempt) | Allowed (exempt) | Allowed |
A subtle reason this SCP must be tightly scoped to a few actions rather than s3:*: an over-broad aws:ResourceOrgID deny on all S3 actions can catch s3:ListAllMyBuckets and other account-level calls where aws:ResourceOrgID is absent, and with StringNotEqualsIfExists that is fine — but if you ever drop the IfExists, every such call denies. Enumerate the data-plane actions you actually mean.
Trusted networks with VPC endpoints and aws:SourceVpce
Identity trust alone is not enough: a leaked long-lived key for one of your roles still belongs to your org, so aws:PrincipalOrgID passes cleanly. The network dimension is what catches it — that stolen key is being used from the open internet, not from inside your VPC. This is the leg that actually stops the presigned-URL-from-a-coffee-shop scenario.
Enforce it in two layers. First, on the VPC endpoint policy for the S3 gateway/interface endpoint, restrict to your org so the endpoint itself cannot be used as a tunnel to foreign buckets:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EndpointOrgScopeOnly",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:*",
"Resource": "*",
"Condition": {
"StringEquals": { "aws:ResourceOrgID": "o-abc123def4" }
}
}
]
}
Second, layer an RCP that denies S3 data-plane calls unless they arrive through an approved endpoint or from an expected network. Use ...IfExists so console and AWS-service paths that legitimately lack these keys are not collateral damage:
{
"Sid": "EnforceNetworkPerimeterS3",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:SourceVpce": ["vpce-0a1b2c3d4e5f", "vpce-9z8y7x6w5v4u"]
},
"BoolIfExists": {
"aws:PrincipalIsAWSService": "false",
"aws:ViaAWSService": "false"
},
"Null": { "aws:SourceVpce": "false" }
}
}
The Null check on aws:SourceVpce set to false means “this condition only applies when the request actually traversed a VPC endpoint” — combined with the service exemptions, it avoids denying console traffic that never had a VPCE in the first place. Tune the exact keys to your topology; the principle is to fail closed only for traffic you genuinely expect to be endpoint-bound.
The network condition keys, what each catches, and the guard each needs — the difference between a tight net and a self-inflicted outage:
| Key | Catches | Guard it needs | Failure mode without the guard |
|---|---|---|---|
aws:SourceVpce |
Request not via an approved endpoint | Null: false + service exemptions |
Denies console (no VPCE) and service calls |
aws:SourceVpc |
Request not from an approved VPC | IfExists |
Denies AWS-internal traffic lacking the key |
aws:SourceIp |
Request not from an allowlisted public IP | IfExists; accounts for VPCE |
Denies private-endpoint traffic (no public IP) |
aws:ViaAWSService |
Indirect service calls | pair with IfExists |
Over-blocks legitimate service-mediated calls |
A decision table for which network key to reach for, given your topology:
| If your access is… | Use | Why |
|---|---|---|
| All through interface/gateway VPC endpoints | aws:SourceVpce allowlist |
Most precise; ties to specific endpoints |
| From whole VPCs, mixed endpoint usage | aws:SourceVpc allowlist |
Coarser but resilient to endpoint churn |
| From fixed corporate egress IPs (no VPCE) | aws:SourceIp allowlist |
Works for on-prem / office traffic |
| A mix of the above | Combine with OR semantics in one block |
Pass if any trusted path matches |
Durable EC2 controls with Declarative Policies
SCPs and RCPs deny actions. Declarative Policies are a different animal: they set a baseline service configuration that the service itself enforces and reports, and the setting persists even as AWS ships new APIs. For EC2 they are the cleanest way to mandate IMDSv2 and kill public sharing across the entire org — including future accounts — without writing a deny for every relevant action and chasing every new API AWS releases.
Enable the policy type, then attach a declarative policy that requires IMDSv2 on instance launch and blocks public AMI and public EBS snapshot sharing:
aws organizations enable-policy-type \
--root-id "$ROOT_ID" \
--policy-type DECLARATIVE_POLICY_EC2
{
"ec2_attributes": {
"exception_message": {
"value": "Blocked by org data-perimeter declarative policy. Contact #cloud-platform."
},
"instance_metadata_defaults": {
"http_tokens": { "value": "required" }
},
"image_block_public_access": {
"state": { "value": "block_new_sharing" }
},
"snapshot_block_public_access": {
"state": { "value": "block_all_sharing" }
}
}
}
What this buys you over a stack of SCP denies — and why declarative is the right tool here specifically:
| Attribute | What it enforces | Values | What it neutralizes |
|---|---|---|---|
instance_metadata_defaults.http_tokens |
IMDSv2 as the account default | required, optional, no_preference |
SSRF → IMDSv1 → credential theft |
instance_metadata_defaults.http_put_response_hop_limit |
Metadata hop limit | integer (e.g. 1–64) |
Container-escape token theft (set low) |
instance_metadata_defaults.http_endpoint |
Whether IMDS is reachable | enabled, disabled, no_preference |
Disable IMDS entirely where unused |
image_block_public_access.state |
Blocks making AMIs public | block_new_sharing, unblocked |
Accidental public AMI exposure |
snapshot_block_public_access.state |
Blocks making EBS snapshots public | block_all_sharing, block_new_sharing, unblocked |
Accidental public snapshot data leak |
serial_console_access.state |
EC2 serial console availability | enabled, disabled |
Out-of-band console access surface |
exception_message.value |
User-facing message on a block | free text | Cryptic UnauthorizedOperation confusion |
Declarative vs SCP-deny for the same EC2 outcome — the head-to-head that explains the choice:
| Goal | Declarative policy | Equivalent SCP deny | Why declarative wins |
|---|---|---|---|
| Require IMDSv2 | http_tokens: required (one line) |
Deny ec2:RunInstances unless ec2:MetadataHttpTokens = required |
Persists across new launch APIs; sets the default |
| Block public AMIs | image_block_public_access: block_new_sharing |
Deny ec2:ModifyImageAttribute with public add |
Service-enforced + reported, not just blocked |
| Block public snapshots | snapshot_block_public_access: block_all_sharing |
Deny ec2:ModifySnapshotAttribute public add |
Covers all sharing paths structurally |
| Self-documenting failure | exception_message surfaced to user |
None — generic UnauthorizedOperation |
Operators know why and who to contact |
The headline wins: http_tokens: required forces IMDSv2 as the account default, neutering the SSRF-to-credential-theft path behind multiple high-profile breaches — new instances inherit it with no per-instance config. image_block_public_access and snapshot_block_public_access make accidental public AMI/snapshot sharing structurally impossible. exception_message is surfaced to the user on a block, so the failure is self-documenting. Attach to the sandbox OU first, verify with the service-state APIs, then promote:
# Verify the declarative policy actually took effect at the service layer
aws ec2 get-image-block-public-access-state --region ap-south-1
aws ec2 get-snapshot-block-public-access-state --region ap-south-1
aws ec2 get-instance-metadata-defaults --region ap-south-1
Staged rollout without locking yourself out
This is where teams either succeed or page themselves at 2 a.m. The discipline is simple to state and easy to skip: build the OU shape so you can roll forward, not just out. Attach to a leaf sandbox OU, then a non-prod OU, then prod, then root last. Org policy inheritance is cumulative, so a deny that is correct at the root is the last thing you attach, never the first.
Root <- attach RCP/SCP here LAST, after weeks of evidence
|- OU Sandbox (1 account) <- attach FIRST, break things here on purpose
|- OU NonProd <- second wave
|- OU Prod <- third wave
|- OU Security <- exempt or test in isolation
The wave plan as a table — what you attach where, what you watch, and the go/no-go gate for promoting:
| Wave | Target | What you attach | What you watch | Go/no-go to promote |
|---|---|---|---|---|
| 0 | (none — pre-flight) | Nothing | CloudTrail Lake query (90 days) | Every foreign relationship allowlisted |
| 1 | OU Sandbox (1 acct) | Identity RCP + SCP + declarative | AccessDenied events; app smoke tests |
Zero unexpected denies after soak |
| 2 | OU NonProd | Same set | CI/CD, integration tests, vendor roles | No pipeline breakage for 1 week |
| 3 | OU Prod | Same set | Prod error rates, dependency calls | No customer-facing incident for 2 weeks |
| 4 | Root | Same set | Org-wide AccessDenied; Access Analyzer |
All exceptions documented; break-glass verified |
Gather evidence before you deny anything. RCPs and SCPs have no audit-only mode, so you simulate the deny with a CloudTrail query first. Find any request in the last 90 days that your perimeter would have blocked — i.e., access to your resources by a principal whose org ID is not yours:
-- CloudTrail Lake: would the identity perimeter have broken anything?
SELECT eventTime, eventSource, eventName,
userIdentity.arn AS principal,
userIdentity.accountId AS caller_account,
sourceIPAddress
FROM cloudtrail_event_data_store
WHERE eventSource IN ('s3.amazonaws.com', 'sts.amazonaws.com',
'kms.amazonaws.com', 'secretsmanager.amazonaws.com')
AND userIdentity.accountId NOT IN (SELECT account_id FROM my_org_accounts)
AND userIdentity.principalId != 'AWSService'
AND eventTime > timestamp '2026-03-01 00:00:00'
ORDER BY eventTime DESC;
Every row is a relationship you must explicitly allowlist (next section) before the deny goes live. Zero rows in the sandbox after a soak period is your green light to promote. The pre-flight queries you should run, one per perimeter leg:
| Pre-flight question | Filter on | A row means |
|---|---|---|
| Foreign principal reading my resources? | userIdentity.accountId NOT IN (org) |
Identity-RCP would deny it — allowlist or expect breakage |
| My principal touching a foreign resource? | requestParameters target ARN’s account ∉ org |
Resource-SCP would deny it |
| Data-plane call with no VPCE? | vpcEndpointId IS NULL on s3 data events |
Network-RCP would deny it |
| Service principal acting on my bucket? | userIdentity.type = 'AWSService' |
Must stay exempt — verify aws:SourceOrgID covers it |
| IMDSv1 still in use? | requestParameters.MetadataHttpTokens != 'required' |
Declarative http_tokens: required will flip it |
Keep an out-of-band break-glass role in the management account that is, by definition, never subject to RCPs or SCPs. If a perimeter policy ever wedges your delegated admin, that is your way back in. Test it before wave 1, not during an incident.
Exceptions: AWS services, SaaS roles, cross-org sharing
A real perimeter has documented holes. Manage them as named exceptions, not by weakening the baseline — the moment you relax the baseline “just for this vendor,” you have lost the perimeter.
AWS service principals — already handled by the aws:PrincipalIsAWSService / aws:SourceOrgID conditions in the identity RCP. Resist the urge to add per-service Sids; the generic exemption is more robust as AWS adds services.
Third-party SaaS roles — a vendor (observability, CSPM, backup) assumes a role in your account from their account, so aws:PrincipalOrgID will not match. Do not punch a hole in the RCP. Instead, scope the role trust policy precisely with the vendor’s account and the sts:ExternalId they issued you, and carve that single role out of the RCP by ARN:
{
"Sid": "AllowVendorWithExternalId",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::210987654321:root" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "kloudvin-prod-7f3a9c2e" }
}
}
To keep the RCP intact while permitting this single foreign principal, add an ArnNotLike carve-out for that role to the RCP’s deny condition so the vendor role is the only external ARN allowed through:
"Condition": {
"StringNotEqualsIfExists": { "aws:PrincipalOrgID": "o-abc123def4" },
"ArnNotLikeIfExists": {
"aws:PrincipalArn": "arn:aws:iam::*:role/vendor/ObservabilityIngest"
},
"BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
}
Cross-org data sharing — when you genuinely share a bucket with a partner org, scope it to their org ID via aws:PrincipalOrgID on the bucket policy and add their org to a dedicated, reviewed allowlist in the RCP. Never relax to Principal: "*" without a condition.
The exception catalogue — every legitimate hole, how to scope it, and the wrong way teams do it:
| Exception | Right way to scope it | Wrong way (do not) | Review cadence |
|---|---|---|---|
| AWS service principals | aws:SourceOrgID + aws:PrincipalIsAWSService (generic) |
Per-service Sids you maintain by hand |
Set once; revisit on service adoption |
| SaaS vendor role | Trust policy: vendor account + sts:ExternalId; RCP ArnNotLike carve-out |
Open Principal:"*" or remove the org check |
Quarterly + on vendor change |
| Partner cross-org S3 share | Bucket policy aws:PrincipalOrgID = partner org; RCP allowlist their org ID |
Drop the RCP org condition entirely | Quarterly |
| DR replication to separate org | Add DR org ID to the RCP StringNotEquals OR set |
Disable replication denial broadly | On DR topology change |
| Break-glass admin | Management-account role (inherently exempt) | A role inside a workload OU you “trust” | Test before each wave |
The condition operators you’ll use for carve-outs, and exactly what each matches:
| Operator | Matches | Use for |
|---|---|---|
ArnNotLike (with IfExists) |
Principal ARN does not match a wildcard pattern | Allowing one vendor role through a deny |
StringNotEquals (multi-value) |
None of the listed org IDs match | Allowlisting multiple partner/DR orgs |
StringEquals on sts:ExternalId |
The agreed shared secret matches | Confused-deputy-safe vendor assumption |
ForAllValues:StringEquals |
Every value in a multi-value key is in your set | Tag/OU-path allowlists |
Architecture at a glance
The diagram traces a request as it actually flows through the perimeter, left to right, and pins each failure/control onto the exact hop where it bites. Read it as the path a call takes: a caller — which may be one of your principals inside a VPC, a foreign principal, or an anonymous client redeeming a presigned URL — sends an API request. Before it ever reaches your data, it passes the network perimeter (the VPC endpoint and the aws:SourceVpce RCP that fails closed if the call did not come through an approved endpoint), then the identity perimeter (the resource-side RCP checking aws:PrincipalOrgID, which rejects any caller from outside your org while exempting AWS service principals via aws:SourceOrgID), and only then reaches the protected data plane — S3, STS, KMS, SQS, and Secrets Manager. Running alongside, the resource perimeter SCP watches the outbound direction so your own principals cannot write to a foreign bucket, and the declarative EC2 baseline sits to the side pinning IMDSv2 and blocking public AMIs and snapshots on every account, present and future.
The numbered badges mark the five places this perimeter most commonly fails or fires: a network-RCP false-deny on console traffic that lacked a VPCE (badge 1), the identity-RCP rejecting a foreign or presigned-URL caller (badge 2 — the win condition), an over-broad deny accidentally blocking an AWS service principal because aws:SourceOrgID was omitted (badge 3), the resource-SCP catching an exfiltration write to a foreign org (badge 4), and the declarative baseline flipping an IMDSv1 instance to IMDSv2-required (badge 5). The whole method is in that path: every request must clear network trust and identity trust before it touches data, your principals are simultaneously fenced from foreign resources, and the EC2 fleet is pinned to a safe baseline that survives new APIs. Follow the arrows and you can see precisely why a leaked key replayed from a coffee shop returns AccessDenied while S3 replication to your DR org keeps flowing.
Real-world scenario
Meridian Pay, a fintech platform team, ran roughly 180 accounts with a clean SCP layer and felt well-governed — until a red-team engagement exfiltrated a customer dataset using a leaked CI/CD access key. The key, scraped from a verbose build log, generated an S3 presigned URL, and the data was pulled from a coffee-shop IP to an attacker-controlled bucket in a foreign account. The SCPs did nothing: the key was a legitimate org principal and the read was authorized. The board asked the one question that mattered — “if this were real, what stops it?” — and the honest answer was nothing we have today.
The constraint was brutal, because Meridian Pay’s data plane was a web of legitimate cross-account flows: a central data lake read by twelve consumer accounts, three SaaS integrations (observability, CSPM, and a backup vendor) with roles in production, and — the killer — S3 replication to a DR account in a separate org mandated by their regulator for blast-radius isolation. A blunt aws:PrincipalOrgID deny would have taken down the DR replication and all three SaaS integrations on day one, turning a security improvement into a self-inflicted outage and a failed audit.
They solved it with a layered RCP plus a network deny, rolled through the OU waves over three weeks. The DR-org replication was handled by adding the DR org’s ID to the StringNotEquals OR set so cross-org replication kept flowing; the three SaaS roles were carved out by ARN with sts:ExternalId on their trust policies; and the presigned-URL exfil path was killed by the network RCP — a presigned URL carries the signer’s identity, but the redemption came from outside any VPC endpoint, so aws:SourceVpce failed closed:
{
"Sid": "DenyDataReadOutsideOrgAndNetwork",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:PrincipalOrgID": ["o-abc123def4", "o-dr9876fedc5"],
"aws:SourceOrgID": "o-abc123def4"
},
"Null": { "aws:SourceVpce": "false" },
"StringNotEqualsIfExists": {
"aws:SourceVpce": ["vpce-0a1b2c3d4e5f"]
},
"BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
}
}
The CloudTrail Lake pre-flight surfaced exactly four foreign-principal relationships — the DR org, and the three vendor roles. All four were allowlisted before go-live; the rollout caused zero production incidents. The same leaked key, replayed by the red team post-deployment from the same coffee-shop IP, returned AccessDenied — the presigned URL was valid, the signer was a real org principal, but the redemption came from no trusted endpoint and the network RCP failed it closed.
The incident-to-fix arc as a timeline, because the order is the lesson:
| Stage | What happened | Control state | Outcome |
|---|---|---|---|
| Baseline | Clean SCP layer, no RCP/network leg | Identity-side only | Felt governed |
| Red team | Leaked CI/CD key → presigned URL → exfil | SCPs allow (valid principal) | Data exfiltrated |
| Pre-flight | CloudTrail Lake 90-day query | Evidence gathered | 4 foreign relationships found |
| Wave 1–2 | RCP+SCP+network on sandbox→non-prod | DR + 3 vendors allowlisted | Zero breakage |
| Wave 3–4 | Promote to prod → root | Full perimeter live | Replication + vendors intact |
| Re-test | Same key replayed post-deploy | Network RCP fails closed | AccessDenied |
The lesson on their wall afterward: “SCPs govern your people. A perimeter governs everyone — including the attacker holding your own key.”
Advantages and disadvantages
The RCP-plus-declarative model both closes the resource-side gap and introduces real operational weight. Weigh it honestly before you commit an org to it:
| Advantages (why this model protects you) | Disadvantages (why it bites) |
|---|---|
| Holds even when a valid credential leaks — the read is denied on the resource side regardless of caller | No audit-only mode for RCP/SCP — you must simulate denies via CloudTrail before attaching, or break things blind |
| Catches external and anonymous callers (presigned URLs) that SCPs structurally cannot | Cumulative inheritance means a root-level deny is hard to roll back without org-wide impact |
| Declarative policies persist across new APIs — no chasing every new EC2 action with a fresh deny | RCP supports only 5 services at GA — DynamoDB, EFS, RDS data, etc. are not yet covered on the resource side |
aws:SourceOrgID / aws:PrincipalIsAWSService exemptions keep service-to-service traffic working |
A single missing IfExists can lock out an entire OU’s console, including yours |
One http_tokens: required line neutralizes SSRF→credential theft fleet-wide |
Declarative EC2 attributes are EC2-specific; other services need their own controls |
| IAM Access Analyzer at the org zone-of-trust continuously proves the perimeter holds | Exceptions (SaaS, cross-org) require ongoing review — a perimeter is never “done” |
exception_message makes blocks self-documenting for users |
The management account stays exempt — easy to forget it is outside the perimeter |
The model is right for any org past ~20 accounts with real data, credentials, cross-account flows, and a regulatory or risk reason to assume credentials will leak. It bites hardest on teams without a CloudTrail Lake / Athena pre-flight habit (they break things), teams that conflate SCP and RCP semantics (they write redundant or wrong policies), and orgs that treat exceptions as one-time rather than reviewed. Every disadvantage is manageable — but only with the staged rollout, the pre-flight, and the break-glass path this article insists on.
Hands-on lab
Stand up a minimal identity perimeter on a sandbox OU, prove a foreign-style read is denied, and tear it down. This is free — Organizations policies cost nothing; you pay only for any test data you create. Run with a management-account or delegated-admin profile.
Step 1 — Capture the org and root IDs.
ORG_ID=$(aws organizations describe-organization --query 'Organization.Id' --output text)
ROOT_ID=$(aws organizations list-roots --query 'Roots[0].Id' --output text)
echo "Org: $ORG_ID Root: $ROOT_ID"
Expected: an o-xxxxxxxxxx org ID and an r-xxxx root ID.
Step 2 — Enable the RCP policy type (idempotent; ignore “already enabled”).
aws organizations enable-policy-type \
--root-id "$ROOT_ID" --policy-type RESOURCE_CONTROL_POLICY 2>/dev/null || true
aws organizations describe-organization \
--query 'Organization.AvailablePolicyTypes' --output table
Expected: a row showing RESOURCE_CONTROL_POLICY with status ENABLED.
Step 3 — Create a sandbox OU (or reuse one) and note its ID.
OU_ID=$(aws organizations create-organizational-unit \
--parent-id "$ROOT_ID" --name "sandbox-perimeter-lab" \
--query 'OrganizationalUnit.Id' --output text)
echo "Sandbox OU: $OU_ID"
Step 4 — Author the identity-perimeter RCP, substituting your org ID.
cat > /tmp/rcp-identity.json <<JSON
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "EnforceOrgIdentityPerimeter",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:GetObject","s3:PutObject","sts:AssumeRole",
"kms:Decrypt","secretsmanager:GetSecretValue"],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:PrincipalOrgID": "$ORG_ID",
"aws:SourceOrgID": "$ORG_ID"
},
"BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
}
}]
}
JSON
Step 5 — Create and attach the policy to the sandbox OU only.
RCP_ID=$(aws organizations create-policy \
--name "rcp-lab-identity-perimeter" --type RESOURCE_CONTROL_POLICY \
--content file:///tmp/rcp-identity.json \
--query 'Policy.PolicySummary.Id' --output text)
aws organizations attach-policy --policy-id "$RCP_ID" --target-id "$OU_ID"
aws organizations list-policies-for-target \
--target-id "$OU_ID" --filter RESOURCE_CONTROL_POLICY --output table
Expected: the attach succeeds and the list shows rcp-lab-identity-perimeter.
Step 6 — Prove it (conceptually) and read the CloudTrail evidence. From a sandbox-account principal, a read by your org principal still works (your org ID matches); a read attempted by a principal outside your org would be denied. Confirm what would fire:
# In CloudTrail (or CloudTrail Lake), a denied foreign read shows errorCode AccessDenied
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=GetObject \
--max-results 5 --query 'Events[].CloudTrailEvent' --output text
Validation checklist — what each step proved, mapped to the real-world move:
| Step | What you did | What it proves | Production analogue |
|---|---|---|---|
| 2 | Enabled RESOURCE_CONTROL_POLICY |
The type must be on before attach | First-time org enablement |
| 4 | Authored deny with IfExists + exemptions |
The baseline is allow-all; you add denies | Writing the real identity RCP |
| 5 | Attached to sandbox OU only | Inheritance is scoped; root is last | Wave-1 of the staged rollout |
| 6 | Read CloudTrail for denies | Evidence proves the perimeter, not faith | Post-rollout verification |
Cleanup (no lingering cost, but tidy up).
aws organizations detach-policy --policy-id "$RCP_ID" --target-id "$OU_ID"
aws organizations delete-policy --policy-id "$RCP_ID"
aws organizations delete-organizational-unit --organizational-unit-id "$OU_ID"
Cost note. Organizations policies, OUs, and attachments are free. The only charges in this lab come from any S3/KMS test objects you create — pennies, and deleting them stops everything.
Common mistakes & troubleshooting
This is the playbook — the part you bookmark. First as a scannable table you can read mid-rollout, then the entries that bite hardest with full confirm-command detail.
| # | Symptom | Root cause | Confirm (exact cmd / path) | Fix |
|---|---|---|---|---|
| 1 | Whole OU loses console/S3 access right after attach | StringNotEquals without IfExists — deny fires on absent keys |
CloudTrail AccessDenied on calls lacking aws:PrincipalOrgID |
Switch to StringNotEqualsIfExists |
| 2 | S3 replication / CloudTrail-to-bucket writes start failing | aws:SourceOrgID exemption omitted |
CloudTrail AccessDenied with userIdentity.type = AWSService |
Add aws:SourceOrgID to the OR block |
| 3 | Log delivery / service-linked roles denied | aws:PrincipalIsAWSService exemption missing |
Denied events with no org key and SLR ARN | Add BoolIfExists aws:PrincipalIsAWSService false |
| 4 | Console S3 access denied after network RCP | aws:SourceVpce deny lacks Null:false guard |
Denied events from console with no vpcEndpointId |
Add Null:{"aws:SourceVpce":"false"} + service exemptions |
| 5 | Vendor SaaS role suddenly can’t assume | Foreign principal hits the org-ID deny | CloudTrail AssumeRole AccessDenied from vendor account |
ArnNotLike carve-out + sts:ExternalId on trust policy |
| 6 | DR replication to separate org breaks | DR org ID not in the StringNotEquals OR set |
Denied s3:Replicate*/PutObject to DR account |
Add DR org ID to the allowlist set |
| 7 | “Cannot attach policy: type not enabled” | RESOURCE_CONTROL_POLICY not enabled on root |
describe-organization shows type absent |
enable-policy-type RESOURCE_CONTROL_POLICY |
| 8 | RCP attaches but never denies anything | Attached to a branch above the resources, or baseline detached | list-policies-for-target; check RCPFullAWSAccess present |
Attach at correct OU; re-add baseline |
| 9 | Management-account bucket still world-readable | Management account is exempt from RCP/SCP | Resource lives in mgmt account | Move data out of mgmt account |
| 10 | Two org-ID denies in separate blocks over-block | Split into two statements → AND-of-denies | Read the policy: each foreign condition denies independently | Combine into one StringNotEquals* block (OR) |
| 11 | IMDSv1 instances still launching | Declarative DECLARATIVE_POLICY_EC2 not enabled/attached |
get-instance-metadata-defaults shows optional |
Enable type; attach declarative policy |
| 12 | Public AMI shared by accident despite policy | image_block_public_access set to unblocked or not inherited |
get-image-block-public-access-state = unblocked |
Set block_new_sharing; attach at OU |
| 13 | Policy edit rejected — “exceeds maximum size” | RCP/SCP body > 5,120 characters | Count chars; whitespace counts | Trim, split logically, or remove redundant Sids |
| 14 | Deny works in sandbox, breaks unrelated app in prod | A prod-only foreign relationship never appeared in sandbox pre-flight | Re-run CloudTrail Lake scoped to prod accounts | Allowlist it; never skip the per-wave pre-flight |
The expanded form, with the full reasoning for the entries that cost the most hours:
1. Whole OU loses console and S3 access immediately after you attach the RCP.
Root cause: You used StringNotEquals instead of StringNotEqualsIfExists. Many legitimate requests — console calls, some service paths — arrive without aws:PrincipalOrgID at all. With plain StringNotEquals, “key absent” is treated as “not equal to your org,” so the deny fires on everything.
Confirm: CloudTrail shows AccessDenied on calls where aws:PrincipalOrgID is absent; the denied principals include your own console sessions.
Fix: Change every StringNotEquals in the perimeter to StringNotEqualsIfExists. This is the single most common self-inflicted outage in RCP rollouts.
2. S3 replication or CloudTrail-to-bucket writes start failing after the identity RCP goes live.
Root cause: You omitted aws:SourceOrgID from the exemption. When an AWS service (S3 replication, CloudTrail log delivery) acts on a resource you own, the call carries aws:SourceOrgID (your org) but not necessarily aws:PrincipalOrgID. Without exempting aws:SourceOrgID, the deny catches your own service traffic.
Confirm: CloudTrail AccessDenied events with userIdentity.type = AWSService against your buckets, timed to the attach.
Fix: List both aws:PrincipalOrgID and aws:SourceOrgID in the same StringNotEqualsIfExists block (an OR), so the deny fires only when both are foreign.
3. Console S3 access is denied after you add the network (aws:SourceVpce) RCP.
Root cause: Console traffic never traverses a VPC endpoint, so aws:SourceVpce is absent — and your StringNotEqualsIfExists on aws:SourceVpce still fired because you forgot the Null guard, or the service exemptions.
Confirm: Denied events originate from console sessions with no vpcEndpointId in the CloudTrail record.
Fix: Add Null: {"aws:SourceVpce": "false"} (apply only when a VPCE was actually used) plus BoolIfExists exemptions for aws:PrincipalIsAWSService and aws:ViaAWSService. The network deny must fail closed only for traffic you expect to be endpoint-bound.
4. A SaaS vendor role can no longer assume into your account.
Root cause: The vendor assumes from their account, so aws:PrincipalOrgID is foreign and the identity RCP’s sts:AssumeRole deny catches it — correctly, but you need this one exception.
Confirm: CloudTrail AssumeRole with AccessDenied, source = the vendor’s account ID.
Fix: Carve the specific role out with ArnNotLikeIfExists on aws:PrincipalArn, and require sts:ExternalId on that role’s trust policy so the carve-out is confused-deputy-safe. Never widen the org check.
5. The RCP attaches cleanly but denies nothing — the perimeter does not hold.
Root cause: Either it is attached to an OU that does not contain the resources, or someone detached the implicit RCPFullAWSAccess baseline (or a typo means the Deny condition never matches).
Confirm: aws organizations list-policies-for-target --filter RESOURCE_CONTROL_POLICY shows where it is attached; confirm RCPFullAWSAccess is still present alongside it.
Fix: Attach at the OU/account that owns the resources; never detach the baseline; re-test with a deliberate foreign-style call.
6. Two foreign-org checks in separate statements over-block legitimate traffic.
Root cause: You split aws:PrincipalOrgID and aws:SourceOrgID (or two partner org IDs) into separate Deny statements. Each statement denies independently, so the effective logic is “deny if foreign-by-principal OR deny if foreign-by-source” — an AND-of-denies that blocks service traffic the OR would have allowed.
Confirm: Read the policy; you have two Deny blocks each with one key.
Fix: Combine the keys into a single StringNotEqualsIfExists block — multiple keys in one block are an OR, which is what you want.
Error and API-exception reference
The exact error strings you will see — at the Organizations control plane when authoring/attaching, and at the data plane when the perimeter fires — with what each means and the move it calls for:
| Error / exception | Where it surfaces | What it means | Move |
|---|---|---|---|
PolicyTypeNotEnabledException |
create-policy / attach-policy |
The policy type is not enabled on the root | enable-policy-type RESOURCE_CONTROL_POLICY |
PolicyTypeAlreadyEnabledException |
enable-policy-type |
Type already on (benign) | Ignore; proceed to attach |
MalformedPolicyDocumentException |
create-policy |
JSON invalid or condition operator unknown | Validate JSON; check operator spelling |
PolicyNotAttachedException |
detach-policy |
Detaching where it was never attached | Confirm target with list-policies-for-target |
DuplicatePolicyAttachmentException |
attach-policy |
Already attached to that target | No action needed |
ConstraintViolationException (max policies) |
attach-policy |
>5 policies of a type on the entity | Consolidate statements into fewer policies |
PolicyDocumentSizeLimitExceeded |
create/update-policy |
Body > 5,120 chars (RCP/SCP) | Trim whitespace; merge Sids; split logically |
AccessDenied (S3 data plane) |
Caller / CloudTrail | RCP denied a foreign or non-VPCE caller | Expected (perimeter working) or allowlist |
AccessDeniedException (STS) |
Caller / CloudTrail | RCP denied a foreign AssumeRole |
Carve out by ARN + ExternalId if legitimate |
AccessDenied with userIdentity.type=AWSService |
CloudTrail | Service call false-denied (missing exemption) | Add aws:SourceOrgID / PrincipalIsAWSService |
UnauthorizedOperation (EC2, no message) |
EC2 API | Declarative block with no exception_message |
Add exception_message.value for clarity |
Blocked by org data-perimeter... (your text) |
EC2 API | Declarative exception_message surfaced |
Working as intended; route user to your channel |
The CLI command reference
The commands you will reach for across the lifecycle — enable, author, attach, verify, roll back — in one place:
| Goal | Command |
|---|---|
| Find the root ID | aws organizations list-roots --query 'Roots[0].Id' --output text |
| Enable RCP type | aws organizations enable-policy-type --root-id $ROOT_ID --policy-type RESOURCE_CONTROL_POLICY |
| Enable declarative EC2 type | aws organizations enable-policy-type --root-id $ROOT_ID --policy-type DECLARATIVE_POLICY_EC2 |
| Which types are enabled | aws organizations describe-organization --query 'Organization.AvailablePolicyTypes' |
| Create a policy | aws organizations create-policy --name N --type RESOURCE_CONTROL_POLICY --content file://p.json |
| Attach to an OU | aws organizations attach-policy --policy-id $ID --target-id $OU_ID |
| What is attached to a target | aws organizations list-policies-for-target --target-id $OU_ID --filter RESOURCE_CONTROL_POLICY |
| What a policy is attached to | aws organizations list-targets-for-policy --policy-id $ID |
| Roll back (detach) | aws organizations detach-policy --policy-id $ID --target-id $OU_ID |
| Verify IMDSv2 default | aws ec2 get-instance-metadata-defaults --region <r> |
| Verify AMI public-access block | aws ec2 get-image-block-public-access-state --region <r> |
| Verify snapshot public-access block | aws ec2 get-snapshot-block-public-access-state --region <r> |
| Stand up org Access Analyzer | aws accessanalyzer create-analyzer --analyzer-name org-perimeter --type ORGANIZATION |
Best practices
Crisp, production-grade rules distilled from real rollouts:
- Never detach the
RCPFullAWSAccess/FullAWSAccessbaseline. Both are allow-all foundations; you only ever add deny statements on top. - Use
...IfExistson every condition operator in the perimeter. The cost of forgetting it once is an OU-wide console lockout. - Combine related foreign-org keys in one block (OR semantics). Splitting them into separate statements creates an AND-of-denies that over-blocks.
- Always exempt AWS service principals with
aws:SourceOrgIDandaws:PrincipalIsAWSService— the generic exemption is more robust than per-serviceSids. - Run the CloudTrail Lake pre-flight before every wave, scoped to that wave’s accounts. A prod-only foreign relationship will not appear in a sandbox scan.
- Attach sandbox → non-prod → prod → root, in that order. Root is last, after weeks of evidence, never first.
- Keep a break-glass role in the management account and test it before wave 1. The management account is inherently exempt — that is your safety net.
- Keep all data and workloads out of the management account. It is outside the perimeter; a bucket there is unprotected.
- Pair RCP and SCP — one for trusted identities on your resources, one for your principals on trusted resources. Neither alone is a perimeter.
- Add the network leg (
aws:SourceVpce) for the presigned-URL / leaked-key-from-the-internet class. Identity trust alone passes a valid leaked key. - Manage exceptions as named, reviewed carve-outs (ARN +
sts:ExternalId), never by relaxing the baseline. - Wire IAM Access Analyzer at the org zone of trust and drive external-access findings to zero-or-justified on a weekly cadence.
- Treat policies as code — version the JSON, review it, deploy via Terraform/pipeline, and diff the live attachment against the repo.
Security notes
The perimeter is the security control, but it has its own security properties to get right:
| Concern | Risk if ignored | Mitigation |
|---|---|---|
| Least privilege on the policy editor | Anyone who can edit RCPs can open the perimeter | Restrict organizations:CreatePolicy/AttachPolicy to a small admin role; require change review |
| Break-glass abuse | An exempt mgmt-account role is a high-value target | Tightly scope, MFA-gate, alert on every assumption, rotate |
| Encryption at rest | KMS in scope means key policy + RCP interact | Ensure KMS key policies also restrict to aws:PrincipalOrgID; RCP Deny overrides key-policy Allow |
| Network isolation | Public S3/PaaS endpoints bypass the VPC leg | Force traffic through interface/gateway endpoints; deny non-VPCE data-plane calls |
| Identity for service calls | Over-broad service exemption is a hole | Scope aws:SourceOrgID to your org only; do not blanket-allow all service principals |
| Presigned URL lifetime | Long-lived presigned URLs widen the window | Keep signature TTLs short; the network RCP catches redemption regardless |
| Audit of exceptions | Stale vendor carve-outs become forgotten holes | Quarterly review; remove on vendor offboarding; Access Analyzer continuous check |
| CloudTrail integrity | The perimeter’s evidence base must be trustworthy | Org-trail to a locked log-archive account; KMS-encrypt; vault-lock the bucket |
The defense-in-depth layering, from the credential outward — each layer assumes the one before it can fail:
| Layer | Assumes | Adds |
|---|---|---|
| IAM least privilege | Nothing | Minimal grant per principal |
| SCP | Principal may be over-permissioned | Your principals can’t reach foreign resources |
| RCP | A credential may leak | Foreign/anon callers can’t reach your resources |
| Network (VPCE) RCP | A valid key may be stolen | Only endpoint-bound traffic touches data |
| Declarative EC2 | Misconfig will happen | IMDSv2 + no public AMIs/snapshots, fleet-wide |
| Access Analyzer | All the above may have gaps | Continuous proof of external exposure |
The perimeter as an attacker would test it — each attack, the layer that catches it, and the single signal that proves the catch:
| Attack attempt | Caught by | Proof signal |
|---|---|---|
| Foreign principal reads your bucket directly | Identity RCP | AccessDenied, non-org userIdentity.accountId |
| Anonymous presigned-URL read from the internet | Network RCP | AccessDenied, no vpcEndpointId |
| Leaked org key reused from a coffee-shop IP | Network RCP | AccessDenied, aws:SourceVpce absent |
| Your compromised role writes to attacker’s bucket | Resource SCP | AccessDenied on PutObject, foreign ResourceOrgID |
| Foreign principal assumes one of your roles | Identity RCP (sts:AssumeRole) |
AccessDeniedException, foreign source account |
| SSRF pulls IMDSv1 credentials from an instance | Declarative (http_tokens:required) |
IMDSv1 request rejected; IMDSv2 token required |
| Accidental public AMI/snapshot share | Declarative (block-public-access) | ModifyImageAttribute public-add blocked |
| Decrypt your data with a leaked KMS grant | Identity RCP (kms:Decrypt) |
AccessDenied, foreign aws:PrincipalOrgID |
Cost & sizing
The good news on cost: the perimeter itself is free. AWS Organizations, SCPs, RCPs, declarative policies, OUs, and attachments carry no charge. What costs money is the evidence and verification machinery around them — and even that is modest. Here is what actually drives the bill:
| Cost driver | What it is | Rough cost | How to right-size |
|---|---|---|---|
| RCP / SCP / declarative policies | The guardrails themselves | Free | No action — there is no per-policy charge |
| CloudTrail management events | First copy of mgmt events | Free (first trail) | Use the org trail; don’t duplicate |
| CloudTrail data events (S3/object-level) | Per-object S3/Lambda events for the pre-flight | ~$0.10 / 100k events (~₹8) | Scope data events to perimeter buckets only |
| CloudTrail Lake event data store | Storage + scanned data for pre-flight queries | ~$2.50/GB ingested (~₹210); query per-GB scanned | Set retention sensibly; partition queries by date |
| Athena (if querying via S3 instead) | Per-TB scanned over CloudTrail S3 logs | ~$5/TB scanned (~₹420) | Partition by date; query narrow windows |
| IAM Access Analyzer (external access) | Continuous external-access analysis | Free | Run one org-level analyzer |
| IAM Access Analyzer unused access | Optional unused-access analyzer | per-resource monthly | Enable selectively if you also want unused-access |
| VPC interface endpoints (network leg) | Per-AZ-hour + per-GB for interface endpoints | ~$0.01/AZ-hr + ~$0.01/GB (~₹0.85 each) | Use gateway endpoints for S3/DynamoDB (free) |
A sizing rule of thumb: for an org of ~180 accounts, the dominant line is the CloudTrail Lake / Athena pre-flight, and even a 90-day, all-accounts scan of S3/STS/KMS/Secrets management events is typically single-digit dollars per query if you partition by date. The gateway VPC endpoint for S3 is free — prefer it over an interface endpoint for the network leg wherever your topology allows, and reserve paid interface endpoints for services that require them. The verification you should never cut to save cost is the per-wave pre-flight; a single missed foreign relationship that breaks production costs far more than the query.
Free-tier and always-free notes: Organizations and its policies are always free; the first CloudTrail trail of management events is free; IAM Access Analyzer external-access findings are free; gateway VPC endpoints are free. The only reliably-recurring spend is data-event capture and Lake/Athena scanning, both of which you control by scoping and partitioning.
Interview & exam questions
Mapped to AWS Certified Security – Specialty (SCS-C02) and Solutions Architect – Professional (SAP-C02), where data-perimeter and Organizations governance show up heavily.
1. What is the fundamental difference between an SCP and an RCP? An SCP evaluates against the principal making the request and can only constrain principals inside your organization. An RCP evaluates against the resource being acted on, for any caller — including principals from foreign orgs and anonymous callers. SCPs are the identity-side guardrail; RCPs are the resource-side guardrail. Both are deny-only and never grant.
2. A leaked, valid IAM key is used to read an S3 bucket via a presigned URL. Which control stops it, and why don’t SCPs?
SCPs don’t, because the key is a legitimate org principal and the read is authorized — there is nothing on the identity side to deny. The network-perimeter RCP (aws:SourceVpce failing closed) stops it: the presigned URL’s redemption comes from outside any approved VPC endpoint, so the resource-side deny fires regardless of the valid signer.
3. Which services does RCP support at GA, and why those specifically? S3, STS, SQS, KMS, and Secrets Manager — the set of services that hold or broker data and credentials. Locking the resource side of exactly these closes the realistic exfiltration paths (object data, role assumption, queues, keys, secrets).
4. Why must you use StringNotEqualsIfExists rather than StringNotEquals in a perimeter deny?
Because many legitimate requests arrive without the condition key (e.g., console calls with no aws:PrincipalOrgID). Plain StringNotEquals treats “key absent” as “not equal to your value,” firing the deny on legitimate traffic. IfExists skips the condition when the key is absent, so the deny only fires when the key is present and mismatched.
5. Your S3 replication breaks the moment the identity RCP attaches. What did you forget?
The aws:SourceOrgID exemption (and/or aws:PrincipalIsAWSService). When an AWS service acts on a resource you own, the call carries aws:SourceOrgID (your org) but may lack aws:PrincipalOrgID. List both keys in the same StringNotEqualsIfExists block so the deny only fires when both are foreign.
6. What is a Declarative Policy and how does it differ from an SCP deny for enforcing IMDSv2?
A declarative policy sets a durable service configuration baseline (e.g., http_tokens: required) that EC2 itself enforces and reports, and it persists even as AWS ships new launch APIs. An SCP deny on ec2:RunInstances with a metadata condition only blocks the actions you enumerate; declarative policy sets the default and survives new APIs, plus surfaces an exception_message.
7. Why is the management account special in a data perimeter? Neither SCPs nor RCPs apply to the management account’s own principals or resources, even when attached at the root. So you must keep all data and workloads out of it — and you can use a management-account role as a break-glass path that is inherently exempt from the perimeter.
8. How do you allow a third-party SaaS vendor to assume a role without weakening the RCP?
Scope the role’s trust policy to the vendor’s account plus a sts:ExternalId, then carve that specific role out of the RCP with ArnNotLikeIfExists on aws:PrincipalArn. The org check stays intact for everyone else; the vendor is the only foreign ARN allowed through, and ExternalId makes it confused-deputy-safe.
9. Why do you stage attachment sandbox → non-prod → prod → root rather than attaching at root first? Org policy inheritance is cumulative and there is no audit-only mode. A deny attached at the root applies everywhere immediately; if it is wrong it breaks the whole org. Attaching at a leaf sandbox OU first lets you break things safely, gather evidence, and promote only after a soak — root is the last step, not the first.
10. How do you find what a perimeter would break before attaching it? Run a CloudTrail Lake (or Athena) query over the last ~90 days for access to your resources by principals whose account ID is not in your org (excluding AWS service calls). Every row is a foreign-principal relationship you must explicitly allowlist before the deny goes live. Zero unexpected rows in the target scope is your go signal.
11. What does aws:ResourceOrgID enforce, and which policy type uses it?
It identifies the org that owns the target resource. You use it in an SCP (identity side) to stop your principals from writing to or reading resources in a foreign org — the exfiltration-outward direction. An RCP cannot enforce it because the foreign resource is outside your policy’s reach.
12. If an RCP Deny and an S3 bucket policy Allow conflict, which wins?
The Deny wins. RCPs are evaluated alongside resource-based policies, and an explicit Deny in any applicable policy overrides any Allow. This is precisely why an RCP can close a hole that a permissive bucket policy left open.
Quick check
- SCPs evaluate against the ______ side of a request; RCPs evaluate against the ______ side.
- Name the five services RCP supports at GA.
- Why must perimeter denies use
...IfExistsoperators? - Which two condition keys exempt legitimate AWS-service-to-service traffic in the identity RCP?
- Which perimeter leg stops a valid leaked key replayed from the open internet, and which key does it use?
Answers
- Identity (principal / caller) side for SCPs; resource (target) side for RCPs.
- S3, STS, SQS, KMS, and Secrets Manager.
- Because many legitimate requests lack the condition key (e.g., console calls with no
aws:PrincipalOrgID); withoutIfExists, “key absent” is treated as a mismatch and the deny fires on legitimate traffic, locking out whole OUs. aws:SourceOrgID(service acting on a resource you own) andaws:PrincipalIsAWSService(service-linked / log-delivery calls carrying no org key).- The network-perimeter RCP, using
aws:SourceVpce(with aNull/IfExistsguard) — it fails closed when the request did not traverse an approved VPC endpoint, even though the leaked key’s org ID passes.
Glossary
- Data perimeter — the set of controls asserting that only trusted identities, from trusted networks, touch your resources, and your principals only touch trusted resources.
- SCP (Service Control Policy) — an Organizations deny-guardrail evaluated against the calling principal; constrains only your org’s principals.
- RCP (Resource Control Policy) — an Organizations deny-guardrail evaluated against the target resource, for any caller including foreign/anonymous; GA Nov 2024 for S3, STS, SQS, KMS, Secrets Manager.
- Declarative Policy — an Organizations policy that pins a durable service configuration baseline (e.g., EC2 IMDSv2, public-access blocks) that the service enforces and reports, persisting across new APIs.
RCPFullAWSAccess— the implicit allow-all baseline RCP; you layer deny statements on top and never detach it.aws:PrincipalOrgID— condition key holding the org ID of the calling principal; used in RCPs to reject foreign callers.aws:ResourceOrgID— condition key holding the org ID owning the target resource; used in SCPs to stop writes to foreign resources.aws:SourceOrgID— condition key holding the org ID of the resource owner during an AWS-service call; used to exempt service-to-service traffic.aws:PrincipalIsAWSService— boolean condition key, true when the request is made by an AWS service principal; used to exempt service-linked calls.aws:SourceVpce— condition key holding the VPC endpoint ID the request traversed; the basis of the network-trust leg.sts:ExternalId— an agreed shared secret on a cross-account role-assumption; defends against the confused-deputy problem for SaaS vendors.- Confused deputy — an attack where a trusted intermediary (e.g., a role) is tricked into acting on an attacker’s behalf; mitigated by
ExternalIdand resource-side perimeter checks. - IMDSv2 — the session-token-protected EC2 Instance Metadata Service version; required via
http_tokens: requiredto neutralize SSRF-to-credential-theft. - Break-glass role — a tightly-scoped management-account role, inherently exempt from RCPs/SCPs, used to recover if a perimeter policy wedges your admins.
- CloudTrail Lake — a managed, queryable event data store used here for the pre-flight scan that finds foreign-principal relationships before any deny goes live.
Next steps
- Lock down the org foundation these policies attach to with AWS Organizations: SCP Guardrails and Delegated Admin.
- Stand up the multi-account landing zone that hosts the OU structure in AWS Control Tower: Multi-Account Landing Zone.
- Deepen the cross-account exception pattern (External ID, session policies) in IAM Cross-Account Roles, External ID, Confused Deputy, Session Policies.
- Wire continuous external-exposure detection with IAM Access Analyzer: Unused Access, Policy Generation, Custom Checks.
- Build the network leg properly with VPC Deep Dive: Subnets, Routing, IGW, NAT, Endpoints and AWS PrivateLink: Service Provider and Consumer Cross-Account.