Building an AWS Data Perimeter, Part 2: SCPs, RCPs, and Proving the Perimeter Holds

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 actually fires. The headline test is kms:DescribeKey from an external account returning with an explicit deny in a resource control policy. Getting to that line required understanding a few things about 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. The condition is aws:ResourceOrgID != aws:PrincipalOrgID: if the resource being accessed belongs to a different org, the action is denied.
{
"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 to those resources because the key is missing and therefore mismatches. IfExists makes the check a no-op when the key is not present, which is the correct behaviour.
The BoolIfExists aws:PrincipalIsAWSService false exemption prevents service-linked roles from being blocked. Without it, AWS services making internal calls on your behalf (EC2 instance metadata refreshes, Auto Scaling operations) may start failing intermittently.
The RCP: Resource Perimeter
RCPs are the more interesting authoring exercise. They look like SCPs but have two structural differences that catch you immediately:
First: RCPs require "Principal": "*". SCPs are identity guardrails and have 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 requires two statements, not one. When a human IAM principal calls S3 directly, the request carries aws:PrincipalOrgID. When an AWS service calls S3 on behalf of a principal (CloudFront fetching from S3, Athena querying a bucket), the request instead carries aws:SourceOrgID. A single statement cannot check both, because 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 (the key is absent, IfExists treats the condition as satisfied) and they bypass the deny. The Null check forces the request to carry source context before the second statement fires.
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 that everything else denies on top of. Removing either collapses the effective policy to deny-all across the entire org.
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 requires both legs (aws:PrincipalOrgID and aws:ResourceOrgID) because 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 does not carry a meaningful aws:ResourceOrgID for AssumeRole because the resource (the target role) is evaluated on the destination account’s side, not the endpoint’s side.
The aws:VpceOrgID Bucket Policy Condition
This is the most recent piece of the perimeter. aws:VpceOrgID was released August 2025 and covers an attack vector that 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 create their own VPC with their own VPC endpoint and attempt to route traffic to your bucket through that endpoint. The RCP would not fire (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 some VPC endpoint. Without it, the deny would fire on direct internet requests (where aws:VpceOrgID is absent and StringNotEqualsIfExists no-ops) and the cross-account trusted-reader path from workload-b (which has no VPC endpoint) would be blocked. The condition only activates when a VPCe is actually in use.

Before aws:VpceOrgID, the equivalent control required aws:SourceVpce with a specific endpoint ID. Updating every bucket policy every time a new endpoint was created. 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 existing 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-objectThe SCP and RCP evaluated the request, saw aws:PrincipalOrgID matching aws:ResourceOrgID, and passed it through. The VPCe policies evaluated and admitted the request because the EC2 instance role is in the org. Three layers active, positive path intact.

Phase 3 also surfaced several IAM gaps that Phase 2 had left dormant: kms:Decrypt never granted to the EC2 role (Phase 2 only tested uploads), sts:AssumeRole never granted to the EC2 role’s identity policy, and kms:Decrypt never granted to the trusted-reader role’s identity policy. All three were missing because Phase 2 only exercised the upload path. Phase 3 was the first time reads, assumes, and cross-account calls were exercised 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 specific test resources.

This is 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 real 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. Neither returned the RCP attribution string:
AccessDenied: User: arn:aws:iam::1169XXXXXXXX:user/dp-negative-test-user
is not authorized to perform: s3:GetObject

This is 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; 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 policywith 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 is the scenario where an internal principal writes to a bucket in a foreign org, blocked by the SCP DenyActionsOnResourcesOutsideOrg. The SCP is attached to the Workloads OU and confirmed in 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) is the identity-side mirror of the RCP condition that fired on T1-T3. A future run of this project 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 is not 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: AccessDeniedThis 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 the log volume and cost minimal.
Positive Recheck
After all 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 Actually 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 thing in the way 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 is to 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 that the RCP alone cannot cover. The two together mean that 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 are in the repo.