CoursesAdvanced cloud securityMulti-account & org identity architecture

Multi-account & org identity architecture

Landing-zone identity, cross-account roles, and blast radius by design.

Advanced35 min · lesson 1 of 15

A cargo ship does not stay afloat because its hull never gets holed. It stays afloat because the hull is split into sealed compartments, so a breach in one floods that compartment and leaves the rest dry. Cloud identity at scale runs on the same trick. Inside a single account, identity is a policy question: who may call what. Spread it across fifty accounts and it turns into an architecture question: where identities live, how a request crosses from one account into another, and whether one compromised workload stays trapped in the compartment where it started.

A landing zone is the blueprint that answers those questions once, up front, so every new account is born with the same walls and the same guardrails instead of drifting into a hand-built snowflake of permissions. Four words carry the whole design, and each cloud spells them differently. The isolation compartment is an AWS account, a GCP project, or an Azure subscription: a separate namespace with its own default trust and its own billing line. You group those compartments into an AWS organizational unit (OU, a labelled branch of the tree), a GCP folder, or an Azure management group, so one rule can cover a whole branch at once. The guardrail is a service control policy (SCP) on AWS, an organization policy constraint on GCP, or an Azure Policy assignment: a rule no principal inside the branch can escape, administrators included. And the whole tree hangs off one control plane at the top: the AWS management account (for years called the master account), the GCP organization node, or the Azure root management group.

The account is the blast-radius boundary

An account, project, or subscription is the strongest isolation the cloud hands you for free. Separate resource namespaces. Separate default trust. A clean line where billing and audit split. The design goal is blunt: a full compromise of one workload compartment must not be able to read, write, or pivot into another. Production lives in a different account from staging. Security tooling lives in a different account from both. The log archive lives in an account almost nobody can sign into, so an attacker who owns production still cannot rewrite the record of what they did there.

Grouping those compartments is what keeps the walls cheap to maintain. An OU works like a floor in an office building: a notice pinned to the stairwell door governs every office on that floor, including the offices nobody has rented yet. Write 'deny anything outside eu-west-1' once, attach it to the branch, and every account beneath it inherits the rule, including accounts you create next year. That inheritance is the whole point. Policy scales with the shape of the tree, not with the size of your team.

Here is one question, show me the compartments and the branches they hang from, asked three ways, once per cloud.

terminal
# AWS: list every account in the organization
aws organizations list-accounts \
--query 'Accounts[].{Id:Id,Name:Name,Status:Status}' --output table
output
------------------------------------------------
| ListAccounts |
+----------------+---------------+-------------+
| Id | Name | Status |
+----------------+---------------+-------------+
| 111111111111 | management | ACTIVE |
| 333333333333 | log-archive | ACTIVE |
| 444444444444 | security | ACTIVE |
| 222222222222 | prod | ACTIVE |
| 555555555555 | staging | ACTIVE |
+----------------+---------------+-------------+
terminal
# GCP: folders hanging off the organization node
gcloud resource-manager folders list --organization=123456789012
output
DISPLAY_NAME PARENT ID
Security organizations/123456789012 456789012345
Workloads organizations/123456789012 567890123456
terminal
# Azure: the management-group tree
az account management-group list \
--query "[].{Name:displayName, Id:name}" -o table
output
Name Id
----------------- ------------------------------------
Tenant root group 6b2c9f1e-0d3a-4e5b-8c7d-1a2b3c4d5e6f
Security security
Workloads workloads

Guardrails even your admins can't lift

A guardrail is a speed limiter bolted to the engine. The driver still decides how hard to press the accelerator, but the limiter caps the top speed no matter what, and you cannot unbolt it from the driver's seat. An SCP works the same way. On its own it grants nothing: it is a filter, not a grant. A principal can act only where an IAM (identity and access management) policy says Allow and no SCP says Deny. IAM decides what a request is trying to do; the SCP decides the most it is ever allowed to do. GCP organization policies come at it from a different angle: instead of filtering who may call what, they constrain how resources may be built, which locations are allowed, whether a virtual machine may get a public IP (internet protocol) address, which domains may own identities. Azure Policy inspects every resource write at the control plane and can wave it through, flag it, or block it outright.

The most common guardrail is a region lock, and it is also the most common way to lock yourself out of your own organization. Global services (IAM, STS, CloudFront, Route 53, and a longer list behind them) are anchored in the us-east-1 region, so a call to any of them reads as us-east-1 no matter where you sit. A naive 'deny everything outside eu-west-1' therefore denies sign-in itself across every account at once. The real policy carries a NotAction escape hatch for those global services, plus a second statement that stops anyone from switching off the logging and threat detection you lean on. The NotAction list below is trimmed to fit the page; the full set of global services is longer, so build yours from the current AWS list rather than copying this one.

region-lock-scp.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RegionLock",
"Effect": "Deny",
"NotAction": ["iam:*","sts:*","organizations:*","cloudfront:*",
"route53:*","waf:*","support:*"],
"Resource": "*",
"Condition": { "StringNotEquals": { "aws:RequestedRegion": "eu-west-1" } }
},
{
"Sid": "ProtectGuardrails",
"Effect": "Deny",
"Action": ["cloudtrail:StopLogging","cloudtrail:DeleteTrail",
"config:StopConfigurationRecorder","guardduty:DeleteDetector"],
"Resource": "*"
}
]
}
terminal
# AWS: create the SCP, capture its id, then attach it to the Workloads OU
aws organizations create-policy --type SERVICE_CONTROL_POLICY \
--name RegionLock-euw1 \
--description "Deny actions outside eu-west-1; keep logging on" \
--content file://region-lock-scp.json \
--query 'Policy.PolicySummary.Id' --output text
aws organizations attach-policy \
--policy-id p-a1b2c3d4 --target-id ou-a1b2-wkld9x0y
output
p-a1b2c3d4
# attach-policy prints nothing; a clean exit (status 0) means it worked
resource-locations.yaml
name: organizations/123456789012/policies/gcp.resourceLocations
spec:
rules:
- values:
allowedValues:
- in:eu-locations
terminal
# GCP: the same region lock as an organization policy
gcloud org-policies set-policy resource-locations.yaml
output
name: organizations/123456789012/policies/gcp.resourceLocations
spec:
etag: CJmB2r0GEIiEmqID
rules:
- values:
allowedValues:
- in:eu-locations
updateTime: '2026-07-22T10:14:05.482Z'
terminal
# Azure: the same idea via the built-in "Allowed locations" policy
az policy assignment create --name allowed-locations-weu \
--display-name "Allowed locations: West Europe" \
--policy e56962a6-4747-49cd-b67b-bf8b01975c4c \
--scope /providers/Microsoft.Management/managementGroups/workloads \
--params '{"listOfAllowedLocations":{"value":["westeurope"]}}' \
--query '{name:name, enforcement:enforcementMode}' -o json
output
{
"name": "allowed-locations-weu",
"enforcement": "Default"
}

An SCP caps what the principals inside your organization can do. It says nothing about who from outside may reach in. That is the job of its newer sibling, the resource control policy (RCP). Where an SCP is a ceiling on your identities, an RCP is a ceiling on your resources: attach one to a branch and no principal, not even an account owned by another company, can touch an S3 (Simple Storage Service) bucket or an SQS (Simple Queue Service) queue in that branch unless it satisfies the RCP. This is how you draw a data perimeter, a rule that says only identities from your own organization may read these buckets, and it shuts off a whole family of data-exfiltration and confused-deputy tricks. RCPs run on a tighter budget than SCPs: 5 attached to any one entity and 5,120 characters each, against 10 and 10,240 on the principal side, so keep them lean. GCP and Azure reach the same resource-perimeter goal by other roads (VPC (virtual private cloud) Service Controls and IAM deny policies on GCP; deny assignments and Azure Policy deny effects on Azure), so treat the perimeter as one idea with three spellings, not one portable policy.

Cross-account access is assumed, never shared

Access from one compartment into another should work like a visitor badge, never like a copied master key. You walk up to the front desk of the target account, prove who you are, and the desk prints a badge stamped with an expiry time and written into the visitor log. When it expires, it is dead. Copy a long-lived key across the seam instead and you have done the opposite: attribution is gone, because every action now reads as 'the key' rather than a named caller, and you cannot revoke it without hunting down every place it was pasted.

In AWS the target account holds a role, and a role carries two policies that do different jobs: a trust policy that says who may assume it, and a permission policy that says what it can do once assumed. The caller runs sts:AssumeRole (STS is the security token service) and gets back temporary credentials with a short, fixed lifetime, fifteen minutes in the call below. GCP spells the same idea as service-account impersonation: grant the caller the roles/iam.serviceAccountTokenCreator role on the target service account and it can mint a one-hour token for that account with no key file on disk. Azure gives a managed identity (an identity the platform creates and rotates for you) a role-based access control (RBAC) assignment scoped into the other subscription, and the workload pulls a fresh token from IMDS, the instance metadata service every virtual machine can reach at one fixed local address (169.254.169.254). Each of these writes a log line you can alert on: CloudTrail records the AssumeRole event, GCP records a GenerateAccessToken entry, Azure records a managed-identity sign-in. A cross-compartment hop nobody expected is a page you want to get.

The external ID on an AWS trust policy plugs a specific hole called the confused deputy. A locksmith who will open 'your' door for anyone who names the door is easy to trick into opening the wrong one. The external ID is a shared code word the caller must present, so a third party who happens to know your role's name still cannot get it assumed on the wrong tenant's behalf. When you wire a SaaS (software-as-a-service) vendor into your account, this is the field that keeps the one role you gave them from turning into a lever into every other customer they serve.

terminal
# AWS: assume a role in the prod account for a 15-minute, logged session
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/DeployBot \
--role-session-name ci-build-8891 --duration-seconds 900 \
--external-id prod-deploys-2026
output
{
"Credentials": {
"AccessKeyId": "ASIA5AIEXAMPLEKEY7Q",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"SessionToken": "IQoJb3JpZ2luX2VjEND...<snip>...",
"Expiration": "2026-07-22T10:29:00Z"
},
"AssumedRoleUser": {
"AssumedRoleId": "AROA3XEXAMPLEID:ci-build-8891",
"Arn": "arn:aws:sts::222222222222:assumed-role/DeployBot/ci-build-8891"
}
}
terminal
# GCP: let the CI account mint tokens for prod, then mint one (no key file)
gcloud iam service-accounts add-iam-policy-binding \
--member="serviceAccount:[email protected]" \
--role="roles/iam.serviceAccountTokenCreator"
gcloud auth print-access-token \
--impersonate-service-account=deploybot@prod-proj.iam.gserviceaccount.com
output
Updated IAM policy for serviceAccount [[email protected]].
bindings:
- members:
- serviceAccount:[email protected]
role: roles/iam.serviceAccountTokenCreator
etag: BwYH1a2b3c4d5e=
version: 1
WARNING: This command is using service account impersonation. All API calls will
be executed as [[email protected]].
ya29.c.c0ASRK0Ga7q9fXhqT2m... # ~1 h token, nothing written to disk
terminal
# Azure: give a managed identity an RBAC role in the prod subscription
az role assignment create \
--assignee-object-id 9f3c1e42-7b0a-4d6e-9c11-8ab2f5d7e001 \
--assignee-principal-type ServicePrincipal \
--role Contributor \
--scope /subscriptions/1111aaaa-2222-3333-4444-555566667777/resourceGroups/rg-app \
--query '{role:roleDefinitionName, principal:principalId, scope:scope}' -o json
output
{
"role": "Contributor",
"principal": "9f3c1e42-7b0a-4d6e-9c11-8ab2f5d7e001",
"scope": "/subscriptions/1111aaaa-2222-3333-4444-555566667777/resourceGroups/rg-app"
}
# the identity now pulls a fresh token from IMDS; no secret ever crossed the boundary

Where the limits bite

These primitives have hard edges worth designing around before you hit them at 200 accounts rather than after. A brand-new AWS organization starts with a ceiling of 10 accounts. That number is adjustable, and AWS will raise it on request as far as 50,000, but a fresh org that assumes it can open 40 accounts on day one will get an error. SCPs cap out at 10 attached to any one entity and 10,240 characters each, so guardrails have to stay dense and lean on inheritance instead of repetition. Human access rides IAM Identity Center permission sets, not a pile of per-account IAM users. On nesting depth the three clouds disagree: AWS OUs go 5 levels deep under the root, GCP folders go 10 (and no single folder may hold more than 300 direct children), and Azure allows 10,000 management groups in a directory but only 6 levels of depth below the root. None of those numbers line up across clouds, and neither do the guardrails: an SCP is not an Azure Policy, and a GCP organization constraint is not an AWS permissions boundary. A real multi-cloud landing zone runs one policy-as-code pipeline per cloud, each reconciled to the same written intent, rather than one tool pretending the three clouds share a shape.

Lock the top of the tree down hard
An SCP never restricts the AWS management account, not even one attached at the organization root, and the same exemption covers RCPs. That is a hard rule baked into how AWS enforces policy. GCP and Azure do not carry that exact exemption (a policy on the Azure root management group applies to everything beneath it, itself included), yet the danger at the top is the same. Whoever holds organization-admin on GCP, or Owner on the Azure root management group, can peel back every guardrail below them, and the AWS management-account holder can ignore the guardrails outright. The region lock that stops all your other accounts will not save you from a mistake made at the very top. Run zero workloads up there. Lock that access behind hardware multi-factor authentication (MFA), sign in almost never, and alert on every single authentication event. A landing zone is only ever as strong as the discipline around that one door.
Three separated identity planes
control plane
management account / org node / root MG
holds SCPs and org policy, runs no workloads
identity provider (single sign-on)
humans authenticate here, mapped to roles
security plane
log-archive account
write-once trail, near-zero human sign-in
security tooling account
GuardDuty / Security Command Center / Defender, org-wide
workload plane
prod / staging / sandbox
one compartment each; reached only via assumed roles
The control plane steers the fleet; it carries no cargo. A workload bug in the management account can reach organization-wide policy, so nothing runs there but the org itself.

Everything so far assumes the caller was already an identity you trust: a human who signed in through your identity provider, or a role in an account you own. The harder case is a workload with no home in your directory at all, a GitHub Actions job or a pod in a cluster you do not run, that needs to act in your cloud without a single stored key. Hand it a long-lived credential and you have quietly knocked a hole in every wall you built. Closing that gap without a stored secret is workload identity federation, and it is the next lesson.

Quick check
01A teammate attaches an SCP whose only statement is Allow s3:* to an OU, expecting every account in it to gain S3 access. No identity gains anything. Why?
Correct — SCPs (and their GCP/Azure equivalents) filter the ceiling, they do not hand out permissions.
Incorrect — SCP changes take effect in minutes, and propagation is not the issue here anyway.
Incorrect — Allow is legal in an SCP (it shapes the ceiling), it simply grants nothing on its own.
Incorrect — a boundary caps an IAM principal's power, it does not turn an SCP into a grant.
02Minutes after you attach a 'Deny everything outside eu-west-1' SCP to an OU, nobody in those accounts can sign in or assume a role. What did the SCP most likely omit?
Incorrect — boundaries cap principals inside an account and have nothing to do with a region Deny blocking sign-in.
Incorrect — session length cannot un-block a request that STS is being denied by region.
Correct — deny those by region and you deny authentication itself across the org.
Incorrect — an SCP needs no Allow to function, and adding one would not stop the region Deny from catching STS.
03A vendor's software needs read access to one S3 bucket in your account. Their onboarding form asks you to create an IAM user and paste in its access key. What is the right response?
Incorrect — a shared long-lived key erases attribution and cannot be revoked without breaking every place it was copied.
Correct — cross-account access is a short-lived assumed role with least privilege and a confused-deputy guard.
Incorrect — that hands a third party maximum blast radius, the opposite of containment.
Incorrect — that spreads the secret further and still leans on a long-lived credential.

Try this

Run gcloud resource-manager folders list --organization=123456789012 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: lock the top of the tree down hard. 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