🔐 Cloud IAM Essentials: Who Can Do What, and Where
The model behind access control on Azure, AWS, Google Cloud and Oracle Cloud (principals, roles, policies and scopes), with each cloud's evaluation rules modelled and run, roles for machines instead of keys, and a least-privilege recipe
💡 How this page was made. The evaluation models were run for real as small Python programs on 6 October 2026 (nothing was downloaded). They are my own simplified models of each cloud's rules, written to show how decisions are reached: they are not the clouds' real engines, and the real services have many more features. The rules they model come from the providers' documentation, read on 6 October 2026. Commands that need a cloud account are marked from the documentation, not run here. Related pages: the account pages (Azure, AWS, Google Cloud, Oracle) introduce each cloud's identity system, and the networking page covers the firewalls (security groups and their cousins).
The one question
Every access system answers the same question for every request: may this principal perform this action on this resource? Three nouns and one default:
- Principal: who is asking. A person, a group, or a program (a workload).
- Action (permission): what they want to do: read a bucket, start a machine, change a policy.
- Resource and scope: what it is done to, and how widely a grant applies: one object, a folder of them, a whole account.
- The default is deny. Nothing is allowed until something allows it. Everything below is about how the allows (and the denies) are written down and combined.
How the four clouds name things:
- AWS: an IAM policy is a JSON document of statements (Effect, Action, Resource). Identities are users, groups and roles.
- Azure: Azure RBAC. A role assignment = a security principal + a role definition + a scope.
- Google Cloud: IAM. An allow policy binds principals to roles on a resource. Deny policies exist too.
- Oracle Cloud: IAM policies written as sentences: Allow group G to VERB resource-type in compartment C.
Principals: people and programs
People should sign in through your identity provider (the company directory, or the cloud's own: Entra ID, IAM Identity Center, Google Account or Workspace, an OCI identity domain), and get temporary access. Groups are how you give access to many people at once: give the group the access, and move people in and out of the group.
Programs need identities too, and this is where most breaches begin, because the easy way is a long-lived secret in a file. Each cloud has a better way, where the machine itself is the identity:
- AWS: an IAM role (it has no password or access keys: whoever assumes it receives temporary credentials). A service such as EC2 or Lambda is a service principal that may assume the role, so the code on the machine finds its credentials by itself. AWS's documentation says to use IAM users with access keys only when there is no alternative.
- Azure: a managed identity (the identity of an Azure resource, with no secrets to manage), or a service principal for automation outside Azure.
- Google Cloud: a service account, a non-human identity with an email address such as NAME@PROJECT.iam.gserviceaccount.com. You attach it to a VM or other resource, and the code on it inherits the access. Google says service account keys are a security risk if not managed correctly, and points to Workload Identity Federation instead.
- Oracle Cloud: a dynamic group (a group of resources defined by a matching rule) plus an instance principal: code on the instance authenticates as the instance. (The oci CLI page shows --auth instance_principal.)
The rule everywhere: no keys for machines on the cloud, short-lived tokens for everything else. The CLI pages show the sign-in methods for people.
How a policy looks
AWS: JSON statements
A permissions policy says what is allowed. A role also has a trust policy that says who may assume it. Both are JSON. This is the trust policy that lets EC2 machines use a role, parsed by Python to prove it is valid JSON, followed by a broken version (a trailing comma, the commonest mistake):
>>> import json
>>> trust = '{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Principal": {"Service": "ec2.amazonaws.com"}, "Action": "sts:AssumeRole"}]}'
>>> doc = json.loads(trust)
>>> doc['Statement'][0]['Principal']
{'Service': 'ec2.amazonaws.com'}
>>> doc['Statement'][0]['Action']
'sts:AssumeRole'
>>> bad = '{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",}]}'
>>> json.loads(bad)
Traceback (most recent call last):
[...]
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 86 (char 85)
The permissions side of the same idea, written the usual way (from the documentation, not run here):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"]
}
]
}
It reads: allow these two actions on this bucket and its objects. Anything else is denied by default.
Azure, Google Cloud and Oracle: assignments, bindings, sentences
- Azure: "Ana has the Reader role on subscription s1", created with a role assignment (below).
- Google Cloud: "user:ana has roles/viewer on project lab", a binding in the project's allow policy.
- Oracle Cloud: "Allow group Developers to manage instance-family in compartment lab". The sentence has a subject, a verb (inspect, read, use or manage, from least to most), a resource type (one type like instances, a family like instance-family, or all-resources) and a location (a compartment, or the whole tenancy). Policies only allow: Oracle's policy language has no deny statement.
How decisions are made: four models
AWS: allow, but explicit deny wins
From AWS's documentation: for a request inside one account, the identity-based and resource-based policies are combined as a union (an allow in either grants access) and an explicit deny in any policy overrides every allow. A permissions boundary or an organization policy (SCP) is a limit: the result is the intersection, so the action must be allowed by both. The model:
>>> from fnmatch import fnmatch
>>> def matches(stmt, action, resource):
... acts = stmt['Action'] if isinstance(stmt['Action'], list) else [stmt['Action']]
... ress = stmt['Resource'] if isinstance(stmt['Resource'], list) else [stmt['Resource']]
... return any(fnmatch(action, a) for a in acts) and any(fnmatch(resource, r) for r in ress)
...
>>> def evaluate(policies, action, resource, boundary=None):
... stmts = [s for p in policies for s in p]
... if any(s['Effect'] == 'Deny' and matches(s, action, resource) for s in stmts):
... return 'DENY (explicit deny always wins)'
... allowed = any(s['Effect'] == 'Allow' and matches(s, action, resource) for s in stmts)
... if not allowed: return 'DENY (default: nothing allowed it)'
... if boundary is not None and not any(s['Effect'] == 'Allow' and matches(s, action, resource) for s in boundary):
... return 'DENY (outside the permissions boundary)'
... return 'ALLOW'
...
>>> reader = [{'Effect': 'Allow', 'Action': ['s3:Get*', 's3:List*'], 'Resource': '*'}]
>>> nodelete = [{'Effect': 'Deny', 'Action': 's3:Delete*', 'Resource': 'arn:aws:s3:::prod-*'}]
>>> extra = [{'Effect': 'Allow', 'Action': 's3:*', 'Resource': '*'}]
>>> evaluate([reader], 's3:GetObject', 'arn:aws:s3:::lab-data/a.txt')
'ALLOW'
>>> evaluate([reader], 's3:PutObject', 'arn:aws:s3:::lab-data/a.txt')
'DENY (default: nothing allowed it)'
>>> evaluate([reader], 'ec2:RunInstances', '*')
'DENY (default: nothing allowed it)'
>>> evaluate([extra, nodelete], 's3:DeleteObject', 'arn:aws:s3:::prod-data/a.txt')
'DENY (explicit deny always wins)'
>>> evaluate([extra, nodelete], 's3:DeleteObject', 'arn:aws:s3:::lab-data/a.txt')
'ALLOW'
>>> evaluate([extra], 's3:PutObject', 'arn:aws:s3:::lab-data/a.txt', boundary=reader)
'DENY (outside the permissions boundary)'
>>> evaluate([extra], 's3:GetObject', 'arn:aws:s3:::lab-data/a.txt', boundary=reader)
'ALLOW'
Read the results:
- A reader policy (s3:Get* and s3:List*) allows GetObject. PutObject is denied: nothing allowed it (the default). ec2:RunInstances is denied too.
- With a broad s3:* allow, deleting from prod-data is denied by an explicit deny ("never delete in prod buckets"), while deleting from lab-data is allowed. A deny beats any allow, however broad.
- With the same broad allow but a boundary that only permits reads, PutObject is denied (outside the boundary) and GetObject is allowed. The boundary is a ceiling.
Azure: additive roles on a hierarchy, and deny assignments
Microsoft's documentation: roles are additive, "your effective permissions are the sum of your role assignments", assignments at a scope apply to everything below (management group, subscription, resource group, resource), and a deny assignment, if one applies, blocks access before anything else is considered. The model, with Ana as Reader on the whole subscription and Contributor on one resource group, and Ben as Owner of that group:
>>> roles = {'Reader': {'read'}, 'Contributor': {'read', 'write', 'delete'}, 'Owner': {'read', 'write', 'delete', 'assign'}}
>>> assignments = [('ana', 'Reader', '/subscriptions/s1'), ('ana', 'Contributor', '/subscriptions/s1/resourceGroups/lab-rg'), ('ben', 'Owner', '/subscriptions/s1/resourceGroups/lab-rg')]
>>> denies = [('ben', 'delete', '/subscriptions/s1/resourceGroups/lab-rg/providers/vm/prod-vm')]
>>> def inside(resource, scope): return resource == scope or resource.startswith(scope + '/')
...
>>> def azure_allowed(who, action, resource):
... for d_who, d_act, d_scope in denies:
... if d_who == who and d_act == action and inside(resource, d_scope): return 'DENIED by a deny assignment'
... granted = set()
... for a_who, role, scope in assignments:
... if a_who == who and inside(resource, scope): granted |= roles[role]
... return 'ALLOWED' if action in granted else 'not allowed (no role grants it here)'
...
>>> rg = '/subscriptions/s1/resourceGroups/lab-rg'
>>> azure_allowed('ana', 'write', rg + '/providers/vm/test-vm')
'ALLOWED'
>>> azure_allowed('ana', 'write', '/subscriptions/s1/resourceGroups/other-rg/providers/vm/x')
'not allowed (no role grants it here)'
>>> azure_allowed('ana', 'read', '/subscriptions/s1/resourceGroups/other-rg/providers/vm/x')
'ALLOWED'
>>> azure_allowed('ben', 'delete', rg + '/providers/vm/test-vm')
'ALLOWED'
>>> azure_allowed('ben', 'delete', rg + '/providers/vm/prod-vm')
'DENIED by a deny assignment'
>>> azure_allowed('ben', 'assign', '/subscriptions/s1')
'not allowed (no role grants it here)'
- Ana can write in lab-rg (the Contributor assignment applies there), but not in other-rg, where only Reader reaches her. She can read other-rg through the subscription-level Reader.
- Ben, an Owner of lab-rg, can delete the test VM, but a deny assignment stops him deleting the production VM. He cannot assign roles at the subscription level, because his Owner role is only on the resource group.
- Scope is the lever. The same role means something different at different scopes, and a role given high up reaches everything below.
Google Cloud: allow policies inherited downward, deny policies first
Google's documentation: allow policies inherit down the hierarchy (organization, folder, project, resource), and "IAM always checks relevant deny policies before checking relevant allow policies": a deny at any level stays in effect below it, and lower levels cannot override it. A deny rule lists denied principals, exception principals and denied permissions. The model:
>>> parent = {'project:lab': 'folder:dev', 'folder:dev': 'org:acme', 'org:acme': None}
>>> allow = {'org:acme': [('group:admins', 'owner')], 'folder:dev': [('group:devs', 'viewer')], 'project:lab': [('user:ana', 'editor')]}
>>> perms = {'viewer': {'compute.get', 'compute.list'}, 'editor': {'compute.get', 'compute.list', 'compute.create', 'compute.delete'}, 'owner': {'compute.get', 'compute.list', 'compute.create', 'compute.delete', 'iam.set'}}
>>> groups = {'user:ana': {'group:devs'}, 'user:cy': {'group:devs'}}
>>> deny = {'folder:dev': [('compute.delete', {'group:admins'})]}
>>> def chain(node):
... while node: yield node; node = parent[node]
...
>>> def google_check(who, perm, project):
... me = {who} | groups.get(who, set())
... for node in chain(project):
... for denied_perm, exceptions in deny.get(node, []):
... if denied_perm == perm and who not in exceptions and not (me & exceptions): return 'DENIED by a deny policy on ' + node
... for node in chain(project):
... for member, role in allow.get(node, []):
... if member in me and perm in perms[role]: return 'ALLOWED by %s on %s' % (role, node)
... return 'denied (no role grants it)'
...
>>> google_check('user:ana', 'compute.create', 'project:lab')
'ALLOWED by editor on project:lab'
>>> google_check('user:cy', 'compute.list', 'project:lab')
'ALLOWED by viewer on folder:dev'
>>> google_check('user:cy', 'compute.create', 'project:lab')
'denied (no role grants it)'
>>> google_check('user:ana', 'compute.delete', 'project:lab')
'DENIED by a deny policy on folder:dev'
- Ana, an editor of the project, may create machines (the project-level binding).
- Cy is in the devs group, which has viewer on the folder: the viewer role flows down to the project, so he can list but not create.
- Ana cannot delete, although the Editor role includes it: a deny policy on the folder forbids compute.delete for everyone except the admins group. That is the use of deny policies: a guardrail above the grants.
- Google also warns about basic roles (Owner, Editor, Viewer): they contain thousands of permissions, and it advises not to grant them in production unless there is no alternative.
Oracle Cloud: allow-only sentences with inheritance
Oracle's documentation: a policy attached to a compartment (or the tenancy) applies to child compartments, there is no deny, and the four verbs include the lower ones (manage includes use, read and inspect). The model parses real policy sentences and checks requests:
>>> import re
>>> VERBS = ['inspect', 'read', 'use', 'manage']
>>> STATEMENTS = ['Allow group Developers to manage instance-family in compartment lab', 'Allow group Auditors to read all-resources in tenancy', 'Allow group Interns to inspect all-resources in compartment lab']
>>> def parse(s):
... m = re.match(r'Allow group (\S+) to (\w+) (\S+) in (tenancy|compartment (\S+))$', s)
... return {'group': m.group(1), 'verb': m.group(2), 'type': m.group(3), 'where': m.group(5) or 'tenancy'}
...
>>> policy = [parse(s) for s in STATEMENTS]
>>> policy[0]
{'group': 'Developers', 'verb': 'manage', 'type': 'instance-family', 'where': 'lab'}
>>> FAMILY = {'instance-family': {'instances', 'instance-pools'}}
>>> def covers(where, comp): return where == 'tenancy' or where == comp or comp.startswith(where + ':')
...
>>> def oci_check(group, verb, rtype, comp):
... for p in policy:
... if p['group'] == group and covers(p['where'], comp) and (p['type'] == rtype or p['type'] == 'all-resources' or rtype in FAMILY.get(p['type'], ())) and VERBS.index(p['verb']) >= VERBS.index(verb):
... return 'ALLOWED by: %s %s in %s' % (p['verb'], p['type'], p['where'])
... return 'denied (no statement allows it)'
...
>>> oci_check('Developers', 'use', 'instances', 'lab')
'ALLOWED by: manage instance-family in lab'
>>> oci_check('Developers', 'use', 'instances', 'lab:test')
'ALLOWED by: manage instance-family in lab'
>>> oci_check('Developers', 'use', 'instances', 'production')
'denied (no statement allows it)'
>>> oci_check('Auditors', 'read', 'buckets', 'lab:test')
'ALLOWED by: read all-resources in tenancy'
>>> oci_check('Auditors', 'manage', 'buckets', 'lab')
'denied (no statement allows it)'
>>> oci_check('Interns', 'read', 'instances', 'lab')
'denied (no statement allows it)'
- The parsed first statement is a small dictionary: group Developers, verb manage, type instance-family, location lab.
- Developers can use instances in lab and, through inheritance, in its child compartment lab:test, but not in production: no statement covers it.
- Auditors have read all-resources in tenancy: they can read buckets anywhere, but cannot manage them. Interns have only inspect, so even reading instances is denied.
- Because there is no deny, safety comes from granting little, in the right compartment. Keep sensitive things in their own compartments, and write policies at the lowest location that works.
Giving a machine access, without a key (from the documentation, not run here)
- AWS. Make a role with the trust policy above (service ec2.amazonaws.com) and a permissions policy, then attach it to the instance (through an instance profile, which the console builds for you). Programs using an AWS SDK find the credentials by themselves.
aws iam create-role --role-name web-role --assume-role-policy-document file://trust.json aws iam attach-role-policy --role-name web-role --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
- Azure. Turn on a managed identity for the VM, then give it a role at the narrowest scope.
az role assignment create --assignee PRINCIPAL_ID --role "Storage Blob Data Reader" --scope /subscriptions/SUB/resourceGroups/lab-rg
- Google Cloud. Create a service account, give it a role, and attach it when you create the VM.
gcloud iam service-accounts create web-sa gcloud projects add-iam-policy-binding PROJECT_ID --member="serviceAccount:web-sa@PROJECT_ID.iam.gserviceaccount.com" --role="roles/storage.objectViewer" gcloud compute instances create web1 --service-account=web-sa@PROJECT_ID.iam.gserviceaccount.com
Do not rely on the default Compute Engine service account: Google's documentation says the default accounts automatically receive the Editor role, and recommends disabling that.
- Oracle Cloud. Make a dynamic group with a matching rule for your instances, then a policy that allows the group.
Allow dynamic-group web-servers to read objects in compartment lab
Names of roles (AmazonS3ReadOnlyAccess, Storage Blob Data Reader, roles/storage.objectViewer) are examples: look up the current ones before use.
A least-privilege recipe
- Start from the job, not the person: "this application reads from one bucket".
- Pick the narrowest built-in role that does it (AWS managed policies, Azure built-in roles, Google predefined roles, Oracle's smallest verb). The documentation of AWS and Google both recommend starting from managed or predefined roles and narrowing.
- Scope it small: a bucket or a resource group or one compartment, not the account.
- Prefer groups and roles to individuals.
- Add what the errors ask for, one permission at a time, rather than starting with admin and "tightening later". Later never comes.
- Review and remove. AWS provides last accessed information for users, roles and permissions to show what is unused, and its documentation says to remove it regularly. The other clouds have similar tools.
- Log everything. AWS CloudTrail, Azure's activity logs, Google Cloud Audit Logs and Oracle's audit service record who did what.
- Keep an emergency path: a tightly controlled, MFA-protected break-glass account, tested before you need it.
Common mistakes
- Owner, Administrator or * for everyone, "to make it work".
- Keys in files, scripts or repositories.
- The default service accounts (Google's Editor grant is the famous one).
- Wildcard principals: AWS's "Principal": "*", Oracle's any-user (its documentation says not to use it, prefer any-group).
- Forgetting inheritance: a grant on the organization, folder, subscription or tenancy reaches everything below it.
- Confusing the two kinds of role on Azure: Azure RBAC roles control resources, while Microsoft Entra roles control the directory (users, groups). They are separate systems.
- Thinking a firewall is access control: the network rules decide who can reach a thing, and IAM decides who may use it. You need both.
- Never testing a deny. Try the action as the restricted identity and check that it is refused.
What this page did not cover
Conditions (time, source address, tags), federation and SSO setup, cross-account and cross-tenant access, organization-wide guardrails (AWS SCPs, Azure Policy, Google organization policies), secrets management and privileged access tools. Each cloud's IAM documentation covers them.
The cheat sheet
- The question: may this principal do this action on this resource (scope)? Default: deny.
- AWS: JSON policies (Effect, Action, Resource). Union of allows, explicit deny wins, boundaries and SCPs are intersections. Roles give temporary credentials (trust policy = who may assume). EC2 gets a role, not keys.
- Azure: role assignment = principal + role + scope. Additive, inherited down, deny assignments first. Managed identities for machines. Azure RBAC is not Entra roles.
- Google Cloud: allow policy = principals bound to roles on a resource, inherited down. Deny policies are checked first. Service accounts attached to resources, no keys, avoid basic roles and default accounts.
- Oracle Cloud: Allow group G to VERB type in compartment C. Verbs inspect, read, use, manage. Allow only, inherited by child compartments. Dynamic groups and instance principals for machines.
- Habits: narrow roles, small scope, groups not people, no keys, review unused access, log, test the deny.
← Cloud Networking Essentials: Addresses, Subnets, Routes and Rules · ↑ All tutorials · Containers on the Cloud: Images, Registries and Serverless Containers →