Per-cloud scoping — the concrete snippets¶
Fixes for trap 1 (over-broad scope) and the resource-narrowing half of trap 2 (data-plane vs control-plane), in the form each cloud actually takes.
For each cloud: the broad grant to avoid, the narrow grant to prefer, and (where the cloud supports it) a condition that narrows further without needing a per-resource assignment.
Azure — --scope narrowing + role choice¶
Azure role assignments take a --scope argument that is literally the
resource id of whatever you're scoping to. Four levels are supported
per the scope-overview docs,
plus sub-resource scoping for services that expose child resources
(the storage-account → blob-container example).
The broad grant to avoid¶
# NEVER: subscription-scoped Blob Data Contributor. This gives the workload
# read/write on EVERY blob in EVERY storage account in the subscription.
az role assignment create \
--assignee "$WORKLOAD_MI_OID" \
--role "Storage Blob Data Contributor" \
--scope "/subscriptions/$SUB_ID"
Narrow to a resource group¶
# Better: RG-scoped. Still broad — every storage account in the RG.
az role assignment create \
--assignee "$WORKLOAD_MI_OID" \
--role "Storage Blob Data Contributor" \
--scope "/subscriptions/$SUB_ID/resourceGroups/$RG"
Narrow to a specific storage account¶
# Better: single storage account. All containers still writable.
az role assignment create \
--assignee "$WORKLOAD_MI_OID" \
--role "Storage Blob Data Contributor" \
--scope "/subscriptions/$SUB_ID/resourceGroups/$RG/providers/Microsoft.Storage/storageAccounts/$ACCT"
Narrow to a single container inside the account (the leaf)¶
# Best: a single blob container. Every other container in this account
# is invisible to the workload identity.
az role assignment create \
--assignee "$WORKLOAD_MI_OID" \
--role "Storage Blob Data Contributor" \
--scope "/subscriptions/$SUB_ID/resourceGroups/$RG/providers/Microsoft.Storage/storageAccounts/$ACCT/blobServices/default/containers/$CONTAINER"
The four --scope strings above are directly from the Azure RBAC scope
docs —
the sub-resource form (.../blobServices/default/containers/...) is
what makes the container-level grant possible.
The role choice matters as much as the scope¶
Per trap 2 in the concept page,
picking Storage Account Contributor — even at container scope, which
Azure will happily accept — grants listkeys, which grants shared-key
access, which bypasses the container-level Entra ID grant entirely.
Pick a data-plane role for data-plane work. Verify the picked role
does not include Microsoft.Storage/storageAccounts/listkeys/action
in its permissions before rolling out.
Who can make the assignment above¶
Per role-assignments-steps §Step 4,
you need Microsoft.Authorization/roleAssignments/write at the target
scope. Held by Owner, User Access Administrator, and
Role Based Access Control Administrator — the last is the narrowest
and is what CI/CD identities that provision workloads should have.
Contributor cannot assign roles, which is a frequent CI/CD surprise.
GCP — resource-level binding + IAM Conditions¶
GCP's answer to "narrow scope" is to bind the role on the specific
resource (resource-types-with-policies
lists which resource types support setIamPolicy) and — optionally —
attach an IAM Condition
in CEL that narrows further.
The broad grant to avoid¶
# NEVER: project-wide Storage Object Admin. Every bucket in the project
# is now readable + writable by the workload SA.
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:$WORKLOAD_SA" \
--role="roles/storage.objectAdmin"
Narrow to a single bucket¶
# Better: bucket-scoped binding. The workload can only see this bucket.
gsutil iam ch \
"serviceAccount:$WORKLOAD_SA:roles/storage.objectAdmin" \
"gs://$BUCKET"
Narrow further with an IAM Condition (object name prefix)¶
Attach the condition to a project-level binding so it stays evaluable
against resource.name:
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:$WORKLOAD_SA" \
--role="roles/storage.objectAdmin" \
--condition="expression=resource.name.startsWith('projects/_/buckets/$BUCKET/objects/tenant-A/'),title=tenant_a_only,description=only tenant A prefix"
The CEL expression is the load-bearing part: resource.name.startsWith(
'projects/_/buckets/$BUCKET/objects/tenant-A/') grants the role only
for objects under that key prefix, not the whole bucket. Supported
attributes include resource.name, resource.type, resource.service,
and request.time — enough for scope-narrowing, temporal grants, and
service-slice grants.
Caveat straight from the conditions docs: basic roles
(roles/owner, roles/editor, roles/viewer) cannot have
conditions attached. If you find yourself trying to add a condition
and it silently doesn't take, check the role isn't a basic role — you
have to use a predefined or custom role.
For Secret Manager, per-secret bindings¶
# Bind the role on the SPECIFIC secret. Other secrets in the project
# remain invisible.
gcloud secrets add-iam-policy-binding "$SECRET_NAME" \
--project="$PROJECT_ID" \
--member="serviceAccount:$WORKLOAD_SA" \
--role="roles/secretmanager.secretAccessor"
The resource format is projects/$PROJECT_ID/secrets/$SECRET_NAME;
the full IAM resource name (with //secretmanager.googleapis.com/ prefix)
is what appears in resource.name inside a CEL condition.
Who can make the binding above¶
resourcemanager.projects.setIamPolicy (held by roles/owner and
roles/resourcemanager.projectIamAdmin), or the resource-specific
equivalent (storage.buckets.setIamPolicy, secretmanager.secrets.setIamPolicy).
Grant your CI/CD SA roles/resourcemanager.projectIamAdmin, not owner.
AWS — narrow Resource ARN + Condition on the policy statement¶
AWS scopes access with the Resource element in the IAM policy on the
identity — there is no per-resource role assignment like Azure or GCP.
Wildcards (* and ?) in the ARN + a Condition block are the levers
per reference_policies_elements_resource
and reference_policies_elements_condition.
The broad grant to avoid¶
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "*"
}]
}
"Resource": "*" grants access to every object in every bucket in
the account for these actions.
Narrow to a specific bucket + prefix¶
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": [
"arn:aws:s3:::my-bucket/tenant-A/*"
]
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::my-bucket",
"Condition": {
"StringLike": {"s3:prefix": ["tenant-A/*"]}
}
}]
}
Two things going on:
GetObject/PutObjectnarrowed by ARN pattern —arn:aws:s3:::my-bucket/tenant-A/*matches only object keys undertenant-A/. The wildcard rules are exactly as documented:*inside a segment matches any string including/.ListBucketnarrowed by ans3:prefixCondition— becauses3:ListBucketis a bucket-level action, you can't scope it with a key-prefix ARN. Instead you allow it at the bucket ARN and use thes3:prefixservice-specific condition key to force anylistrequest to specify the tenant's prefix. Without this pair, the workload could list every key in the bucket even if it couldn't read them.
The "some actions require *" trap¶
Straight from the docs: "Some AWS services do not allow you to specify
actions for individual resources. In these cases, any actions that you
list in the Action or NotAction element apply to all resources in
that service." Examples: iam:ListRoles requires Resource: "*";
ec2:DescribeInstances requires Resource: "*". Cross-reference the
Actions, Resources, and Condition Keys per service
table before assuming an action is resource-scopable.
When an action must be *, narrow via Condition — e.g., limit
ec2:DescribeInstances to instances tagged with the workload's tenant
using aws:ResourceTag/tenant.
Condition — temporal + IP + MFA gating¶
Common patterns worth having in the muscle memory:
"Condition": {
"IpAddress": {"aws:SourceIp": ["203.0.113.0/24"]},
"DateGreaterThan": {"aws:CurrentTime": "2026-01-01T00:00:00Z"},
"DateLessThan": {"aws:CurrentTime": "2026-12-31T23:59:59Z"},
"BoolIfExists": {"aws:MultiFactorAuthPresent": "true"},
"StringEquals": {"aws:PrincipalTag/team": "platform"},
"ArnLike": {"aws:SourceArn": "arn:aws:lambda:us-east-1:*:function:api-*"}
}
The condition keys split cleanly into global (aws: prefix — work
on any service) and service-specific (s3:, dynamodb:, ec2:
prefix — only work on that service's actions). The global condition
keys reference
lists what's globally available.
Who can make the policy above¶
iam:PutRolePolicy / iam:AttachRolePolicy on the target role,
iam:CreatePolicy for managed policies, and iam:PassRole — the
last is the one CI/CD identities repeatedly forget. Without PassRole
the pipeline can create a role and attach a policy but cannot hand the
role to (e.g.) a Lambda function or ECS task at creation time. Grant
PassRole narrowly with a Condition on iam:PassedToService:
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::123456789012:role/api-lambda-*",
"Condition": {"StringEquals": {"iam:PassedToService": "lambda.amazonaws.com"}}
}
What the three clouds share¶
The layers are identical even when the syntax isn't:
- Layer 1 — pick the narrowest role/permission set that matches the
operation (data-plane read, not "storage admin";
getObject, nots3:*). - Layer 2 — pick the narrowest resource scope the workload can function at (a single container, a single bucket, a single ARN prefix — not the account/subscription/project).
- Layer 3 — add conditions when the resource-scope is still too broad
and the cloud supports them (Azure has weaker per-condition support
and mostly relies on scope; GCP has CEL conditions on bindings; AWS
has the
Conditionblock on statements — the richest of the three).
And on every cloud: the CI/CD identity that provisions your workload has the most privileged rights in the account, because it needs the ability to create IAM grants. Protect it accordingly — it is the implicit apex of the trust hierarchy on every cloud.
Docs verified 2026-08-09¶
- Azure RBAC scope levels + sub-resource scope format (
.../blobServices/default/containers/...): scope-overview - Azure role-assignments prerequisites (
Microsoft.Authorization/roleAssignments/write): role-assignments-steps#step-4 - GCP IAM resource-types-with-policies (which resources support setIamPolicy): docs.cloud.google.com/iam/docs/resource-types-with-policies
- GCP IAM Conditions (CEL on bindings;
resource.name.startsWith(...)example; basic-roles limitation): docs.cloud.google.com/iam/docs/conditions-overview - AWS IAM
Resourceelement (ARN + wildcards; "some actions require*" caveat): reference_policies_elements_resource - AWS IAM
Conditionelement (operators, global vs service-specific keys,s3:prefixexample): reference_policies_elements_condition - AWS global condition keys reference: reference_policies_condition-keys
Where the arc goes next¶
- Back to least-privilege overview — the three traps in cross-cloud form.
- Attributed writes revisits the arc as a whole; least-privilege is the last of the six posture layers.