Tarek Cheikh
Founder & AWS Cloud Architect
A Region-restriction SCP is a common guardrail in AWS Organizations, and a mistake in it is easy to make. Forget one global service and you block it everywhere. Mistype one Region and a team loses it. The standard advice is to test on a small OU first. This post gives you a lab that costs nothing and touches no AWS account, so you can find the mistake before the OU does.
You will run AWS's own sample policy against a member account in LocalEmu, an open-source AWS emulator that can enforce IAM and Organizations policies. You will get a table of allowed and denied calls by service and Region, then turn it into a check that exits with an error when the policy does not do what you meant.
The sample is Deny-access-to-AWS-based-on-the-requested-AWS-region.json. It denies every action when aws:RequestedRegion is not in your list (eu-central-1 and eu-west-1 in the sample), except for a NotAction list of approved global services, such as IAM, Organizations, STS, Route 53 and CloudFront. A second condition in the same statement lets one privileged role bypass the restriction. AWS's own README warns that the example "might not include all of the latest global AWS services or operations", so the list is yours to review. That is exactly the kind of thing worth testing.
You need Python 3.11 or newer. IAM enforcement is opt-in, and with it on, setup calls need a key listed in ROOT_ACCESS_KEYS. The script below uses the well-known example key from the AWS documentation. This lab was run with localemu 1.4.0.
pip install localemu==1.4.0 boto3 requests
IAM_ENFORCEMENT=1 ROOT_ACCESS_KEYS=AKIAIOSFODNN7EXAMPLE localemu start
This URL is pinned to a commit, so the lab keeps working if the repository changes. The repository is MIT-0 licensed.
curl -o region-lock.json https://raw.githubusercontent.com/aws-samples/service-control-policy-examples/088df0b0812c55bb0b90824c155776dfdf690404/Region-controls/Deny-access-to-AWS-based-on-the-requested-AWS-region.json
The script builds an organization with a member account inside a sandbox OU, because SCPs do not affect the management account (more on that below). It works as a role in the member account, runs the same four calls in four Regions before the policy exists, attaches the policy to the OU, and runs them again. The expected result is written from the policy's intent: regional services only in the two allowed Regions, IAM everywhere. One deliberate change to the sample: its privileged-role bypass is filled with a role name that does not exist, so the matrix tests the Region rule only (the last section explains why). Save it as scp_lab.py next to region-lock.json:
import json
import sys
import boto3
import requests
from botocore.exceptions import ClientError
EP = "http://localhost:4566"
ROOT = dict(aws_access_key_id="AKIAIOSFODNN7EXAMPLE",
aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
ALLOWED = {"eu-west-1", "eu-central-1"} # the Regions in the policy
REGIONS = ["eu-west-1", "eu-central-1", "us-east-1", "ap-southeast-1"]
def client(service, region="eu-west-1", **creds):
return boto3.client(service, endpoint_url=EP, region_name=region, **creds)
# 1. An organization with a member account inside a sandbox OU
org = client("organizations", **ROOT)
org.create_organization(FeatureSet="ALL")
root_id = org.list_roots()["Roots"][0]["Id"]
org.enable_policy_type(RootId=root_id, PolicyType="SERVICE_CONTROL_POLICY")
org.create_account(Email="member@example.com", AccountName="member")
member = next(a["Id"] for a in org.list_accounts()["Accounts"] if a["Name"] == "member")
ou = org.create_organizational_unit(ParentId=root_id, Name="sandbox")["OrganizationalUnit"]["Id"]
org.move_account(AccountId=member, SourceParentId=root_id, DestinationParentId=ou)
# 2. Work as a role in the member account
c = client("sts", **ROOT).assume_role(
RoleArn=f"arn:aws:iam::{member}:role/OrganizationAccountAccessRole",
RoleSessionName="lab")["Credentials"]
creds = dict(aws_access_key_id=c["AccessKeyId"],
aws_secret_access_key=c["SecretAccessKey"],
aws_session_token=c["SessionToken"])
# 3. The calls to test: three regional services and one global one (IAM)
def calls(phase):
def s3_create_bucket(region):
kw = {} if region == "us-east-1" else {
"CreateBucketConfiguration": {"LocationConstraint": region}}
client("s3", region, **creds).create_bucket(
Bucket=f"scp-lab-{phase}-{region}-{member}", **kw)
return {
"s3 CreateBucket": s3_create_bucket,
"dynamodb ListTables": lambda r: client("dynamodb", r, **creds).list_tables(),
"sqs ListQueues": lambda r: client("sqs", r, **creds).list_queues(),
"iam ListUsers": lambda r: client("iam", r, **creds).list_users(),
}
def run(label, phase):
results = {}
print(f"\n{label}")
print(f"{'call':<22}" + "".join(f"{r:<16}" for r in REGIONS))
for name, call in calls(phase).items():
row = f"{name:<22}"
for region in REGIONS:
try:
call(region)
results[(name, region)] = True
except ClientError as e:
results[(name, region)] = False
assert e.response["Error"]["Code"] == "AccessDenied", e
row += f"{'allowed' if results[(name, region)] else 'DENIED':<16}"
print(row)
return results
# 4. Before the SCP: everything is allowed
before = run("Before the SCP", "before")
assert all(before.values()), "baseline should allow every call"
# 5. AWS's sample Region-restriction SCP, with its two placeholders filled in
policy = open("region-lock.json").read()
policy = policy.replace("${Account}", member).replace("[PRIVILEGED_ROLE]", "NoSuchRole")
pid = org.create_policy(Name="region-lock", Description="lab",
Type="SERVICE_CONTROL_POLICY",
Content=policy)["Policy"]["PolicySummary"]["Id"]
org.attach_policy(PolicyId=pid, TargetId=ou)
# 6. Expected outcome, from the policy's intent: regional services only in the
# allowed Regions, IAM (listed in NotAction) everywhere
def expected(name, region):
return name == "iam ListUsers" or region in ALLOWED
after = run("After the SCP", "after")
bad = [(k, v) for k, v in after.items() if v != expected(*k)]
for (name, region), got in bad:
print(f"UNEXPECTED: {name} in {region} was {'allowed' if got else 'denied'}")
# 7. Why was one of them denied?
d = requests.get(EP + "/_localemu/iam/decisions",
params={"action": "s3:CreateBucket", "decision": "EXPLICIT_DENY"}).json()
print("\nExample decision:", json.dumps(d["decisions"][-1], indent=2))
sys.exit(1 if bad else 0)
Keep LocalEmu running in its terminal and run python scp_lab.py from a second one. The script creates the organization, so run it once per fresh LocalEmu: to run it again, stop LocalEmu and start it again with a new HOME or a fresh install directory (a second run on the same instance fails with AlreadyInOrganizationException). The baseline allows everything:
call eu-west-1 eu-central-1 us-east-1 ap-southeast-1
s3 CreateBucket allowed allowed allowed allowed
dynamodb ListTables allowed allowed allowed allowed
sqs ListQueues allowed allowed allowed allowed
iam ListUsers allowed allowed allowed allowed
After the policy is attached to the OU:
call eu-west-1 eu-central-1 us-east-1 ap-southeast-1
s3 CreateBucket allowed allowed DENIED DENIED
dynamodb ListTables allowed allowed DENIED DENIED
sqs ListQueues allowed allowed DENIED DENIED
iam ListUsers allowed allowed allowed allowed
Regional services work in eu-west-1 and eu-central-1 and fail everywhere else. IAM keeps working in every Region because iam:* is in the NotAction list. The script prints a sample decision at the end and exits with 0.
A check is only useful if it can fail. Run these two, each against a fresh LocalEmu as explained above, and each in its own folder holding a modified copy of the policy and a copy of the script (the script reads region-lock.json from the current directory):
mkdir broken && cp scp_lab.py broken/
A mistyped Region. Replace eu-central-1 with eu-north-1 in the allowed list:
sed 's/"eu-central-1"/"eu-north-1"/' region-lock.json > broken/region-lock.json
(cd broken && python scp_lab.py)
UNEXPECTED: s3 CreateBucket in eu-central-1 was denied
UNEXPECTED: dynamodb ListTables in eu-central-1 was denied
UNEXPECTED: sqs ListQueues in eu-central-1 was denied
A forgotten global service. Delete the iam:* line from the NotAction list:
sed '/"iam:\*",/d' region-lock.json > broken/region-lock.json
(cd broken && python scp_lab.py)
UNEXPECTED: iam ListUsers in us-east-1 was denied
UNEXPECTED: iam ListUsers in ap-southeast-1 was denied
In both cases the script exits with 1, so it can run in a pipeline and stop a pull request that changes the policy in the wrong way.
When a call fails and you do not know which policy did it, ask LocalEmu. GET /_localemu/iam/decisions returns recent decisions, filtered by principal_arn, action, resource or decision. For a denied bucket creation it returned (abbreviated, the real entry also has an id, a timestamp and the account id):
{
"decision": "EXPLICIT_DENY",
"principal_arn": "arn:aws:sts::933400785829:assumed-role/OrganizationAccountAccessRole/lab",
"action": "s3:CreateBucket",
"resource": "arn:aws:s3:::scp-lab-after-us-east-1-933400785829",
"reason": "Organizations SCP deny",
"source": "scp",
"matched_statement": null
}
The log is an in-memory ring buffer of 2000 entries, not an audit trail.
Treat a local pass as a strong hint, not as proof. Two differences showed up while building this lab:
aws:PrincipalArn holds the role ARN, not the session ARN. In our test the sample's bypass did not exempt an assumed role, for two reasons. LocalEmu compared the pattern against the session ARN (arn:aws:sts::...:assumed-role/...), and it treated the key name as case-sensitive: the sample spells it aws:PrincipalARN, and AWS documents that condition key names are not case-sensitive. Only the session ARN pattern with the spelling aws:PrincipalArn let the role through, which is the opposite of what AWS does with the role ARN. That is why the script fills the placeholder with a role name that does not exist: it leaves the bypass out of the test instead of giving a wrong answer. Check that part of your policy in AWS.For the last step, AWS's own guidance applies: test in a separate organization or an OU that represents production, then roll out to broader OUs over time.
ALLOWED and REGIONS with your own Regions.calls(), one line each, and add the global services you rely on to the policy's NotAction list if the matrix shows them blocked.region-lock.json in the repository that owns your SCPs and run the script on every change.The same approach works for any SCP: attach it to an OU in the lab, state what each call should do, and let the exit code tell you. The SCP glossary entry covers the basics, and the LocalEmu tool page and documentation have the install options, including Docker. If you find something LocalEmu gets wrong, the repository takes issues and pull requests.
Tarek Cheikh
This article is just the start. Get the full picture with our free whitepaper - 8 chapters covering IAM, S3, VPC, monitoring, agentic AI security, compliance, and a prioritized action plan with 50+ CLI commands.
KloudSec, built by the team behind Toc Consulting
What you just read is a manual, one-time check. KloudSec runs this and 400+ more automated checks across 90+ AWS services, continuously, agentless, live in about 5 minutes.
Toc Consulting: AWS Security & Cloud Architecture
Our team helps engineering teams secure and architect AWS the right way: assessment in week one, a prioritized action plan in week two.
awsmount is a free, open-source tool from KloudSec that shows an AWS account as a read-only folder, so ls, cat, grep, find and diff work on it. Secret-looking values are hidden by default and awsmount only reads.
Seven practical recipes with awsmount: find security groups open to the world, see what uses a security group, follow links between resources, diff two snapshots, keep your account in Git, and give an AI coding agent read-only access.
KloudSec releases iam-to-english, a free open-source CLI that explains AWS IAM policies in plain English, flags 21 known privilege escalation paths, including combinations spread across statements, diffs policy changes and gates CI. It runs offline.