Multi-account & org identity architecture
Landing-zone identity, cross-account roles, and blast radius by design.
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.
# AWS: list every account in the organizationaws organizations list-accounts \--query 'Accounts[].{Id:Id,Name:Name,Status:Status}' --output table
------------------------------------------------| ListAccounts |+----------------+---------------+-------------+| Id | Name | Status |+----------------+---------------+-------------+| 111111111111 | management | ACTIVE || 333333333333 | log-archive | ACTIVE || 444444444444 | security | ACTIVE || 222222222222 | prod | ACTIVE || 555555555555 | staging | ACTIVE |+----------------+---------------+-------------+
# GCP: folders hanging off the organization nodegcloud resource-manager folders list --organization=123456789012
DISPLAY_NAME PARENT IDSecurity organizations/123456789012 456789012345Workloads organizations/123456789012 567890123456
# Azure: the management-group treeaz account management-group list \--query "[].{Name:displayName, Id:name}" -o table
Name Id----------------- ------------------------------------Tenant root group 6b2c9f1e-0d3a-4e5b-8c7d-1a2b3c4d5e6fSecurity securityWorkloads 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.
{"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": "*"}]}
# AWS: create the SCP, capture its id, then attach it to the Workloads OUaws 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 textaws organizations attach-policy \--policy-id p-a1b2c3d4 --target-id ou-a1b2-wkld9x0y
p-a1b2c3d4# attach-policy prints nothing; a clean exit (status 0) means it worked
name: organizations/123456789012/policies/gcp.resourceLocationsspec:rules:- values:allowedValues:- in:eu-locations
# GCP: the same region lock as an organization policygcloud org-policies set-policy resource-locations.yaml
name: organizations/123456789012/policies/gcp.resourceLocationsspec:etag: CJmB2r0GEIiEmqIDrules:- values:allowedValues:- in:eu-locationsupdateTime: '2026-07-22T10:14:05.482Z'
# Azure: the same idea via the built-in "Allowed locations" policyaz 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
{"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.
# AWS: assume a role in the prod account for a 15-minute, logged sessionaws 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
{"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"}}
# 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
Updated IAM policy for serviceAccount [[email protected]].bindings:- members:- serviceAccount:[email protected]role: roles/iam.serviceAccountTokenCreatoretag: BwYH1a2b3c4d5e=version: 1WARNING: This command is using service account impersonation. All API calls willbe executed as [[email protected]].ya29.c.c0ASRK0Ga7q9fXhqT2m... # ~1 h token, nothing written to disk
# Azure: give a managed identity an RBAC role in the prod subscriptionaz 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
{"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.
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.
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.