Building an AWS Data Perimeter, Part 2: SCPs, RCPs, and Proving the Perimeter Holds
Attaching SCPs and RCPs to an AWS Organization, enforcing the new aws:VpceOrgID condition, and running denial tests against an external account with CloudTrail evidence.
Part 1 built the substrate: org structure, VPC with private endpoints, S3 bucket, KMS CMK, and an EC2 instance in a subnet with no internet gateway. Nothing was denied yet. This part is where the denials start.
The goal: attach a Service Control Policy, a Resource Control Policy, and VPC endpoint policies to the Workloads OU, then run tests from an external AWS account to prove the perimeter fires. The headline test is kms:DescribeKey from an external account returning with an explicit deny in a resource control policy. Getting there required understanding how RCPs are authored and how AWS surfaces error messages to external callers.
Phase 3: Attaching the Controls
The SCP: Identity Perimeter
The SCP denies internal principals from acting on resources outside the org, via the condition aws:ResourceOrgID != aws:PrincipalOrgID.
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "DenyActionsOnResourcesOutsideOrg",
"Effect": "Deny",
"Action": ["s3:*", "sts:*", "kms:*"],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:ResourceOrgID": "${aws:PrincipalOrgID}"
},
"BoolIfExists": {
"aws:PrincipalIsAWSService": "false"
},
"ArnNotLikeIfExists": {
"aws:PrincipalArn": ["arn:aws:iam::*:role/aws-service-role/*"]
}
}
}]
}
StringNotEqualsIfExists rather than StringNotEquals matters here. For AWS-managed shared resources, aws:ResourceOrgID is absent from the request context. A strict StringNotEquals would deny access because the missing key counts as a mismatch. IfExists makes the check a no-op when the key is missing, which is the correct behavior.
The BoolIfExists aws:PrincipalIsAWSService false exemption prevents service-linked roles from being blocked. Without it, AWS services making calls on your behalf (EC2 metadata refreshes, Auto Scaling operations) may start failing intermittently.
The RCP: Resource Perimeter
RCPs are the more interesting exercise. They look like SCPs but have two structural differences that catch you immediately:
First: RCPs require "Principal": "*". SCPs are identity guardrails with an implied principal (the account’s IAM identities). RCPs evaluate as resource-based policies and must declare who the statement applies to. Omitting Principal produces a console error: Missing Principal: Add a Principal element to the policy statement.
Second: the service-caller problem needs two statements, not one. A human IAM principal calling S3 directly carries aws:PrincipalOrgID in the request. An AWS service calling S3 on a principal’s behalf (CloudFront fetching from S3, Athena querying a bucket) carries aws:SourceOrgID instead. A single statement cannot check both. StringNotEqualsIfExists can only appear once as a JSON key, and adding it twice produces Duplicate JSON key: StringNotEqualsIfExists.
The canonical pattern from aws-samples/data-perimeter-policy-examples splits this into two statements:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EnforceOrgIdentities",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:*", "sts:*", "kms:*"],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:PrincipalOrgID": "${aws:ResourceOrgID}"
},
"BoolIfExists": {
"aws:PrincipalIsAWSService": "false"
}
}
},
{
"Sid": "EnforceConfusedDeputyProtection",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:*", "sts:*", "kms:*"],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:SourceOrgID": "${aws:ResourceOrgID}"
},
"Bool": {
"aws:PrincipalIsAWSService": "true"
},
"Null": {
"aws:SourceAccount": "false"
}
}
}
]
}
The second statement’s Null: aws:SourceAccount: false is the confused-deputy guard. Some older AWS service integrations do not populate aws:SourceOrgID. Without the Null check, StringNotEqualsIfExists no-ops on those calls and they bypass the deny. The Null check forces the request to carry source context first.
Both policies were attached to the Workloads OU (ou-XXXX-XXXXXXXXXX), leaving FullAWSAccess and RCPFullAWSAccess attached at root.


Those two AWS-managed policies must never be removed. They are the implicit allow everything else denies on top of. Removing either collapses the effective policy to deny-all org-wide.
The VPC Endpoint Policies
The three perimeter endpoints (S3, STS, KMS) had their Full Access policies replaced with org-scoped allow policies.
The S3 gateway endpoint checks both legs (aws:PrincipalOrgID and aws:ResourceOrgID), since traffic destined for a foreign-org bucket should be denied at the endpoint:
{
"Statement": [{
"Effect": "Allow", "Principal": "*", "Action": "*", "Resource": "*",
"Condition": {
"StringEquals": {
"aws:PrincipalOrgID": "o-XXXXXXXXXX",
"aws:ResourceOrgID": "o-XXXXXXXXXX"
}
}
}]
}
The STS interface endpoint only checks aws:PrincipalOrgID. STS has no meaningful aws:ResourceOrgID for AssumeRole, since the target role is evaluated on the destination account’s side, not the endpoint’s.
The aws:VpceOrgID Bucket Policy Condition
The most recent piece of the perimeter. aws:VpceOrgID was released August 2025 and covers an attack vector the RCP alone does not address: an internal principal using a non-org VPC endpoint to reach your bucket.
The RCP blocks external principals. But a compromised internal account could build its own VPC endpoint and route traffic to your bucket through it. The RCP would not fire, since the principal is internal. The bucket policy condition does:
{
"Sid": "DenyAccessFromNonOrgVpce",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::dp-prod-s3-test-2747XXXXXXXX/*", ...],
"Condition": {
"StringNotEqualsIfExists": { "aws:VpceOrgID": "o-XXXXXXXXXX" },
"BoolIfExists": { "aws:ViaAWSService": "false" },
"Null": { "aws:VpceOrgID": "false" }
}
}
The Null: aws:VpceOrgID: false is critical. It scopes the deny to requests that arrived via a VPC endpoint. Without it, the deny would fire on direct internet requests, where aws:VpceOrgID is absent and StringNotEqualsIfExists no-ops. It would also block the cross-account trusted-reader path from workload-b, which has no VPC endpoint.

Before aws:VpceOrgID, the equivalent control used aws:SourceVpce with a specific endpoint ID, which meant updating every bucket policy each time a new endpoint appeared. The org-level condition covers the entire endpoint fleet in one value.
Positive Path Sanity Check
After attaching the SCP and RCP, the first thing to confirm is that the positive path still works. From Session Manager on the EC2 instance:
$ aws s3 cp s3://dp-prod-s3-test-2747XXXXXXXX/test.txt /tmp/test.txt
$ cat /tmp/test.txt
data-perimeter-test-object
The SCP and RCP saw aws:PrincipalOrgID matching aws:ResourceOrgID and passed the request through. The VPCe policies admitted it because the EC2 instance role is in the org. Three layers active, positive path intact.

Phase 3 also surfaced IAM gaps that Phase 2 had left dormant: kms:Decrypt never granted to the EC2 role, sts:AssumeRole never granted to the EC2 role’s identity policy, and kms:Decrypt never granted to the trusted-reader role’s identity policy. Phase 2 only exercised the upload path; Phase 3 was the first time reads, assumes, and cross-account calls ran end-to-end.
Phase 4: Proving It
The Setup
The external test account (1169XXXXXXXX) has an IAM user (dp-negative-test-user) with explicit Allow on s3:GetObject, sts:AssumeRole, and kms:DescribeKey against the test resources.

The non-obvious requirement: the IAM policy on the external user must permit the actions. Otherwise the denial comes from IAM before the RCP ever evaluates, and the test proves nothing about the perimeter.
For each test, the resource-side policy was temporarily opened to the external principal, the test was run, and the policy was restored immediately. The restore is not optional. Leaving a foreign-account principal in a key policy or trust policy is a hygiene failure: if the RCP is detached for any reason, the gap becomes live.
T1 and T2: S3 and STS Return Generic AccessDenied
Both calls were denied, and neither returned the RCP attribution string:
AccessDenied: User: arn:aws:iam::1169XXXXXXXX:user/dp-negative-test-user
is not authorized to perform: s3:GetObject


Not a perimeter failure. The AWS S3 troubleshooting documentation is explicit:
“Enhanced access denied messages are returned only for same-account requests or for requests within the same organization in AWS Organizations. Cross-account requests outside of the same organization return a generic
Access Deniedmessage.”
AWS deliberately hides the policy structure from external callers. The resource owner sees the full attribution in CloudTrail; the attacker sees only Access Denied. STS follows the same rule. The RCP fired on both, but the evidence is in the CloudTrail data events, not the CLI response.
T3: KMS Returns the Explicit Attribution String
KMS does not apply the same restriction. From the external account, with the external user temporarily added to the KMS key policy:

AccessDeniedException: User: arn:aws:iam::1169XXXXXXXX:user/dp-negative-test-user
is not authorized to perform: kms:DescribeKey on this resource
with an explicit deny in a resource control policy
That string comes from the same RCP (dp-prod-rcp-trusted-principals) that fired on T1 and T2. KMS surfaces the attribution; S3 and STS suppress it for external callers. The perimeter is active on all three services.
T5: SCP Identity Perimeter: Structural Proof
T5 covers an internal principal writing to a bucket in a foreign org, blocked by the SCP DenyActionsOnResourcesOutsideOrg. The SCP is attached to the Workloads OU, confirmed during Phase 3 validation. A live denial test requires a writable bucket in a foreign-org account. The external account used for T1-T3 lost accessible credentials during this phase, so a live CLI call was not possible.
The structural proof stands: aws organizations list-policies-for-target confirmed dp-prod-scp-resource-perimeter is attached. The SCP condition (StringNotEqualsIfExists: aws:ResourceOrgID != aws:PrincipalOrgID) mirrors the RCP condition that fired on T1-T3 from the identity side. A future run with a persistent external account would close this gap with a live denial and a CloudTrail errorMessage: with an explicit deny in a service control policy.
T6: Anonymous Request, CloudTrail Data Event
An unsigned curl to the test bucket returned HTTP/1.1 403 Forbidden as expected.

The value isn’t the 403 itself, but what the org trail recorded:
eventTime: 2026-05-29T00:39:25Z
eventSource: s3.amazonaws.com
eventName: GetObject
userIdentity.type: AWSAccount (anonymous)
errorCode: AccessDenied
This event came from the S3 data event selector added in Phase 3 (dp-prod-s3-test-bucket-events). Without data events enabled, denied GetObject calls produce no CloudTrail entry. The selector was scoped to the test bucket only, keeping log volume and cost minimal.
Positive Recheck
After the negative tests, Session Manager into the EC2 instance confirmed the positive path:
$ aws sts get-caller-identity
# arn:aws:sts::2747XXXXXXXX:assumed-role/dp-prod-role-ec2-test-client/...
$ aws s3 cp s3://dp-prod-s3-test-2747XXXXXXXX/test.txt /tmp/phase4-positive.txt
$ cat /tmp/phase4-positive.txt
data-perimeter-test-object

All three perimeter layers active, positive path intact.
Evidence Summary
| Test | What fired | CLI output | CloudTrail |
|---|---|---|---|
| T1 (S3 GetObject) | RCP EnforceOrgIdentities | Generic AccessDenied (S3 hides attribution from external callers) | Data event in workload-a trail |
| T2 (STS AssumeRole) | RCP on STS | Generic AccessDenied | In external account’s own trail |
| T3 (KMS DescribeKey) | RCP on KMS | with an explicit deny in a resource control policy | Confirmed directly |
| T5 (SCP identity) | SCP DenyActionsOnResourcesOutsideOrg | Not live-tested: SCP attached and confirmed; external credentials unavailable | n/a |
| T6 (anonymous S3) | Bucket default-deny | HTTP/1.1 403 Forbidden | Data event: anonymous AccessDenied |
| Positive recheck | All controls pass through | data-perimeter-test-object | n/a |
What This Buys You
Before this project, a developer misconfiguring a bucket policy, or an attacker who exfiltrated a long-lived access key from a CI pipeline, could read that bucket. The only defense was the bucket policy on that specific resource.
After: the RCP is org-wide. It cannot be overridden by a bucket policy, an IAM policy, or a resource-based policy. The only way to bypass it: detach it from the Workloads OU in the management account, which requires management-account access and leaves a CloudTrail record. A developer’s misconfigured bucket policy no longer creates a perimeter hole. An attacker with a stolen key from account 1169XXXXXXXX gets AccessDenied before they can enumerate anything.
The aws:VpceOrgID condition closes the VPC endpoint exfiltration path the RCP alone cannot cover. Together, they mean both the “who is calling” question and the “which network path did this arrive on” question have org-level answers, not per-resource answers.
RCPs launched November 2024. aws:VpceOrgID shipped August 2025. Both are recent enough that most production environments have neither deployed. The full project source, policy JSON, and Phase 4 evidence live in the repo.