CoursesAdvanced cloud securityGuardrails as code & landing zones

Guardrails as code & landing zones

SCPs/Org Policy, Control Tower, and secure-by-default accounts.

Expert30 min · lesson 15 of 15

Building codes exist so a house is wired safely before anyone moves in. The inspector does not wait for a fire to find the fault. A landing zone is the building code for your cloud estate: a repeatable set of guardrails, written as code, that every new account, project, or subscription inherits the second it is created. The account shows up already logged, already fenced, already wired to central sign-in. It is secure on day zero instead of after a week-long hardening sprint. This is the last lesson of the course because the landing zone is where everything you built earlier (identity, network, encryption, logging, detection) stops being a thing each team has to remember and becomes a property of how accounts are born.

Preventive guardrails, not detective alerts

There are two ways to stop a bad action. You can catch it after it happens and raise an alarm, or you can refuse it before it can happen at all. A smoke detector is the first kind: useful, but the fire already started. A deadbolt is the second kind: the intruder never gets through the door. Detective controls (a Cloud Security Posture Management scan that flags a bad setting, an Amazon GuardDuty threat alert) are the smoke detector. They are worth having, but the misconfiguration was live for some window before anyone looked. A preventive guardrail is the deadbolt. It turns the request down at the moment it is made.

Each cloud has its own lock. On AWS it is a Service Control Policy (SCP), a rule you hang high in the account tree that filters what every account beneath it may do. On Google Cloud it is an Organization Policy constraint. On Azure it is an Azure Policy assignment with a Deny effect. All three attach at a parent scope and flow downhill, so one statement fences every user and role below it, including the account's own administrators. You put the non-negotiables here (no public data, no turning off the audit log, no building outside approved regions) so they cannot be crossed rather than merely reported after the fact.

This is the point where a single mistake, or a single stolen admin credential, stops being a breach. The stolen credential can still do whatever the account's own permissions allow. But it cannot switch off CloudTrail or open a bucket to the whole internet, because the guardrail sitting above the account refuses those specific calls no matter who is making them.

An SCP is a filter, not a grant. It works like a strainer over a pot: it can hold things back, but it cannot add what was never there. The SCP hands out no permission; it only removes them, and an explicit Deny always beats any Allow the account's own Identity and Access Management (IAM) might contain. When you first switch SCPs on, AWS hangs a built-in policy called FullAWSAccess on every node, which allows everything, so nothing is actually removed until you add a Deny of your own. Detach that default and attach an allow-list in its place, and the whole branch flips from 'block a few named things' to 'permit only these named things.'

Two numbers are worth committing to memory. You can attach at most 5 SCPs to any single root, Organizational Unit, or account, and one SCP document can hold at most 5,120 characters, so real baselines get split into a few tight policies instead of one giant file. One exemption matters more than the rest: SCPs never restrict the AWS Organizations management account (the payer account at the very top). That is exactly why every serious landing zone keeps the management account empty and puts real workloads in member accounts under an OU (an Organizational Unit, a folder that groups accounts). AWS's newer Resource Control Policies (RCPs) push the same idea onto the resource side, letting you say things like 'deny all access to our S3 buckets unless the caller belongs to our organization,' and they carry the identical management-account exemption.

Google's constraints come in two shapes. A boolean constraint is a switch you flip on or off (storage.publicAccessPrevention is one). A list constraint takes a set of allowed or denied values (gcp.resourceLocations, which pins data to particular regions, and gcp.restrictNonCmekServices, which forces customer-managed encryption keys, the CMEK keys you hold and control instead of the provider's defaults). Azure's effects are a wider menu: Deny blocks the request, Audit only records a violation, and DeployIfNotExists can add the missing piece for you (switch on diagnostic logging, say). Same idea in all three clouds, different spelling.

scp-baseline.json
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "ProtectPublicAccessBlock", "Effect": "Deny",
"Action": ["s3:PutAccountPublicAccessBlock", "s3:PutBucketPublicAccessBlock"],
"Resource": "*" },
{ "Sid": "ProtectCloudTrail", "Effect": "Deny",
"Action": ["cloudtrail:StopLogging", "cloudtrail:DeleteTrail"],
"Resource": "*" },
{ "Sid": "RegionLock", "Effect": "Deny",
"NotAction": ["iam:*", "organizations:*", "sts:*", "route53:*", "cloudfront:*"],
"Resource": "*",
"Condition": { "StringNotEquals": {
"aws:RequestedRegion": ["us-east-1", "eu-west-1"] } } }
]
}
terminal
# Create the policy in AWS Organizations, then attach it to the Workloads OU
$ aws organizations create-policy --type SERVICE_CONTROL_POLICY \
--name baseline-guardrails --description "Baseline preventive guardrails" \
--content file://scp-baseline.json
$ aws organizations attach-policy \
--policy-id p-9f8e7d6c --target-id ou-ab12-workloads
output
{
"Policy": {
"PolicySummary": {
"Id": "p-9f8e7d6c",
"Arn": "arn:aws:organizations::111122223333:policy/o-ab12cd34ef/service_control_policy/p-9f8e7d6c",
"Name": "baseline-guardrails",
"Description": "Baseline preventive guardrails",
"Type": "SERVICE_CONTROL_POLICY",
"AwsManaged": false
}
}
}
# attach-policy returns no output on success
terminal
# Now sign in as an account admin and try the forbidden action
$ aws s3api put-public-access-block --bucket app-logs \
--public-access-block-configuration BlockPublicPolicy=false
output
An error occurred (AccessDenied) when calling the PutPublicAccessBlock operation:
User: arn:aws:sts::222233334444:assumed-role/AdminRole/dev is not authorized to
perform: s3:PutBucketPublicAccessBlock with an explicit deny in a service control policy

One subtlety trips people up. For a call to go through, every SCP from the root down to the account has to allow it, and the account's own IAM has to allow it too. They stack, and all of them must say yes, so a permission the root takes away can never be granted back at a lower level. That stacking also explains the region lock above, which uses NotAction with a short list of global services: IAM, STS (Security Token Service, the service that hands out temporary credentials), Organizations, Route 53, and CloudFront. Those services do not run in any single region, so their calls often reach AWS with no aws:RequestedRegion value at all. A plain region deny reads a missing value as 'not one of my allowed regions' and turns the call down, which would lock you out of the very services you need to operate the account. The NotAction list waves those global calls past the region check.

The same fence, three dialects

The tree has a different name in each cloud but the same shape. AWS nests accounts inside Organizational Units. Google nests projects inside folders. Azure nests subscriptions inside management groups (MGs). In every case a guardrail set on a parent is inherited by every child, current and future, so you write the baseline once at the top of the Workloads branch and every account that will ever hang below it is bound automatically. Built-in constraints and definitions cover the usual suspects (public access, data residency, encryption, allowed machine types). Where they fall short, GCP custom constraints and Azure custom policy definitions let you write your own conditions. Only the syntax changes.

pap.yaml
name: organizations/845123456789/policies/storage.publicAccessPrevention
spec:
rules:
- enforce: true
locations.yaml
name: organizations/845123456789/policies/gcp.resourceLocations
spec:
rules:
- values:
allowedValues:
- in:eu-locations
terminal
# Boolean constraint: no public buckets anywhere under the org
$ gcloud org-policies set-policy pap.yaml
# List constraint: data may only live in EU regions
$ gcloud org-policies set-policy locations.yaml
output
name: organizations/845123456789/policies/storage.publicAccessPrevention
spec:
etag: CJnR8r4GELiPq_wC
rules:
- enforce: true
updateTime: '2026-07-14T09:12:44.881Z'
name: organizations/845123456789/policies/gcp.resourceLocations
spec:
etag: CMeR8r4GELiPq_wC
rules:
- values:
allowedValues:
- in:eu-locations
updateTime: '2026-07-14T09:14:02.317Z'
terminal
# The bucket defers to the org policy above it: its own setting reads 'inherited'
$ gcloud storage buckets describe gs://app-uploads-eu \
--format='default(public_access_prevention)'
# Prove the list policy bites: try to build a bucket outside the EU
$ gcloud storage buckets create gs://app-scratch --location=us-east1
output
public_access_prevention: inherited
ERROR: (gcloud.storage.buckets.create) HTTPError 412: 'us-east1' violates
constraint 'constraints/gcp.resourceLocations' on the resource
'projects/_/buckets/app-scratch'.
deny.rules.json
{
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Storage/storageAccounts" },
{ "field": "Microsoft.Storage/storageAccounts/allowBlobPublicAccess",
"notEquals": "false" }
]
},
"then": { "effect": "deny" }
}
terminal
# Define the rule at the landing-zone management group, then assign it there
$ az policy definition create --name deny-public-storage \
--display-name "Deny storage accounts allowing public blob access" \
--management-group corp-landingzone --mode Indexed \
--rules @deny.rules.json --output none
$ az policy assignment create --name baseline-deny-public-storage \
--scope /providers/Microsoft.Management/managementGroups/corp-landingzone \
--policy /providers/Microsoft.Management/managementGroups/corp-landingzone/providers/Microsoft.Authorization/policyDefinitions/deny-public-storage
output
{
"enforcementMode": "Default",
"name": "baseline-deny-public-storage",
"policyDefinitionId": "/providers/Microsoft.Management/managementGroups/corp-landingzone/providers/Microsoft.Authorization/policyDefinitions/deny-public-storage",
"scope": "/providers/Microsoft.Management/managementGroups/corp-landingzone"
}
terminal
# Try to create a storage account that allows public blobs
$ az storage account create --name stpublicdemo \
--resource-group rg-app --allow-blob-public-access true
output
(RequestDisallowedByPolicy) Resource 'stpublicdemo' was disallowed by policy.
Policy identifiers: '[{"policyAssignment":{"name":"baseline-deny-public-storage"},
"policyDefinition":{"name":"deny-public-storage"}}]'
Code: RequestDisallowedByPolicy

Prove it before you trust it

The most dangerous guardrail is the one that looks real and blocks nothing. So after every change, do the thing the policy forbids and confirm you are stopped. You just saw all three denials: the AWS AccessDenied naming the service control policy, the GCP create rejected for violating the constraint, the Azure RequestDisallowedByPolicy naming the assignment. That is the proof. A green checkmark in the console is not.

Because a bad Deny can wall a whole branch off from a service, or wall you out of fixing it, each cloud gives you a way to write the rule and watch what it would do before it bites. Azure assignments take an enforcement mode: set DoNotEnforce and the policy still evaluates and reports non-compliance, but blocks nothing. Google policies take a dryRunSpec in place of (or alongside) the live spec; would-be violations are written to your Cloud Audit Logs while real requests pass through untouched. AWS has no native dry run for SCPs, so you stage the policy on a throwaway sandbox OU, test against it, and only then attach it to Workloads.

pap-dryrun.yaml
# dryRunSpec, not spec: log what WOULD be denied, block nothing yet
name: organizations/845123456789/policies/storage.publicAccessPrevention
dryRunSpec:
rules:
- enforce: true
terminal
# Azure: assign the same deny, but only watch while you measure impact
$ az policy assignment create --name stage-deny-public-storage \
--scope /providers/Microsoft.Management/managementGroups/corp-landingzone \
--policy /providers/Microsoft.Management/managementGroups/corp-landingzone/providers/Microsoft.Authorization/policyDefinitions/deny-public-storage \
--enforcement-mode DoNotEnforce
# After the next compliance scan, how many resources WOULD be non-compliant?
$ az policy state summarize --management-group corp-landingzone \
--query "value[0].results.nonCompliantResources"
output
{
"enforcementMode": "DoNotEnforce",
"name": "stage-deny-public-storage",
...
}
7
A guardrail authored is not a guardrail enforced
The worst failure mode is a policy that exists and blocks nothing: an SCP attached to the wrong OU (or to the exempt management account), an Azure assignment left in DoNotEnforce, or a GCP policy that carries only a dryRunSpec. The console shows a green, official-looking guardrail while public buckets sail straight through it. After every change, attempt the forbidden action and confirm the deny. Never assume authoring equals enforcing.

Account vending: secure on day zero

A baseline is only as good as the machine that stamps it onto new accounts. That machine is account vending. On AWS it is Control Tower or the Landing Zone Accelerator, with Account Factory for Terraform (AFT) for a fully code-driven version. On Google it is the project factory and the Cloud Foundation Toolkit. On Azure it is Azure Landing Zones from the Cloud Adoption Framework (CAF). Whichever you use, it hands each new account over with the baseline already welded on: the organization audit trail delivering to a locked, write-once log-archive account (past logs cannot be edited or deleted), the guardrail policies attached, central single sign-on (SSO) wired to roles, a hardened default network, and threat detection switched on. A team asks for an account and receives a safe one. There is no window where it sits unprotected and no checklist anyone can forget.

Control Tower packages many of these guardrails as named controls (formerly called guardrails) that you turn on against an OU by identifier. Some are preventive, backed by an SCP; some are detective, backed by an AWS Config rule. Enabling one is a single call, and listing what is already on an OU tells you what a new account there will inherit.

terminal
# Turn on a managed control against the Workloads OU
$ aws controltower enable-control \
--control-identifier arn:aws:controltower:us-east-1::control/AWS-GR_RESTRICTED_COMMON_PORTS \
--target-identifier arn:aws:organizations::111122223333:ou/o-ab12cd34ef/ou-ab12-workloads
# List what is already enforced there
$ aws controltower list-enabled-controls \
--target-identifier arn:aws:organizations::111122223333:ou/o-ab12cd34ef/ou-ab12-workloads
output
{
"operationIdentifier": "d8f1a2b3-4c5d-6e7f-8a9b-0c1d2e3f4a5b"
}
{
"enabledControls": [
{ "controlIdentifier": "arn:aws:controltower:us-east-1::control/AWS-GR_RESTRICTED_COMMON_PORTS" },
{ "controlIdentifier": "arn:aws:controltower:us-east-1::control/AWS-GR_ENCRYPTED_VOLUMES" }
]
}
One landing zone, guardrails inherited downhill
Organization root (AWS Org / GCP Org / Azure tenant root MG)
Baseline deny guardrails
SCP / Org Policy / Azure Policy, inherited by every scope below
Vending pipeline
Control Tower / project factory / Azure Landing Zones stamp the baseline
Management account stays empty
SCPs never restrict it, so no workloads live here
Security OU / folder / MG
Log archive account
write-once org trail, cross-account, tamper-evident
Central detection
GuardDuty / Security Command Center / Defender findings aggregated
Workloads OU / folder / MG
Prod & non-prod accounts
inherit region lock, no public data, logging on, CMEK required
Secure on day zero
no unprotected window, no hardening checklist to forget
Sandbox OU / folder / MG
Looser guardrails
experiments allowed, hard budget cap and auto-expiry
Dry-run staging
test a new deny here before it reaches Workloads
Set the deny once on a parent scope and every child account, present and future, is bound. Keep the management account empty, because SCPs never touch it.

Trade-offs at scale

Preventive guardrails have a real blast radius (when one is wrong, it can break a lot at once). Too broad a Deny and you can wall an entire branch off from a service, or wall yourself out of repairing it. Keep a break-glass path: an emergency role or account, kept locked away and heavily logged, that a human can reach when the guardrail itself is the thing standing in the way. Roll changes out through the dry-run modes above, test in a non-production OU or folder first, and keep every exemption narrow, time-boxed, and reviewed, because a carve-out is a hole in the fence that tends to outlive the reason it was cut.

At scale the money matters as much as the security. Guardrails you apply by hand drift, because the account someone spins up in a hurry is the exact one that skips the runbook, which is the whole argument for vending over documentation. Hang cost guardrails next to the security ones: hard budget caps and auto-expiry on sandbox accounts, region locks that also trim cross-region egress (outbound data transfer) bills. Watch for drift from a dashboard instead of an incident (Control Tower drift detection, Azure Policy compliance state, GCP findings in Security Command Center). And remember one caveat that catches everyone once: a Deny only judges new and changed resources. The public storage account that already existed keeps serving traffic; the policy marks it non-compliant but does not reach back and shut it. Closing that gap takes a remediation task (Azure's DeployIfNotExists or Modify, an AWS Config remediation, a GCP fix) or a person doing it by hand.

Quick check
01An account's own IAM policy allows s3:*. An SCP attached to that account's OU denies only s3:PutBucketPublicAccessBlock. What can the account's admin actually do with S3?
Incorrect — an SCP Deny is scoped to the actions it names, not the entire service.
Incorrect — an SCP is a filter that removes permissions, and this statement is a Deny, not a grant.
Correct — FullAWSAccess still permits the rest, and the one Deny wins over the IAM Allow for that single action.
Incorrect — an explicit Deny anywhere in the chain always beats an Allow.
02You assign a Deny policy for public blob access at the corp-landingzone management group. New public storage accounts are blocked, but three accounts that were already public keep serving public blobs. Why?
Correct — Deny stops new and changed resources, so pre-existing violations need a remediation task or a human.
Incorrect — new public accounts ARE blocked, which proves the assignment is enforcing.
Incorrect — Deny applies to resource create/update operations regardless of when the subscription was added.
Incorrect — older resources are still evaluated for compliance and can be remediated; they are just not blocked retroactively.
03A teammate 'enforced' storage.publicAccessPrevention last week, but public buckets are still being created. gcloud org-policies describe storage.publicAccessPrevention --organization=845123456789 returns a block with only a dryRunSpec: (enforce true) and no spec:. What is happening?
Incorrect — an etag is present, which means the policy was stored successfully.
Correct — dryRunSpec is watch-only, so real requests pass until a live spec exists.
Incorrect — it is a boolean constraint, and enforce: true is the correct shape.
Incorrect — inheritance is effectively immediate, and the dry-run-only spec is the real reason nothing is blocked.

Try this

Run gcloud org-policies set-policy pap.yaml on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a guardrail authored is not a guardrail enforced. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related