Skip to content

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:

  1. GetObject/PutObject narrowed by ARN pattern — arn:aws:s3:::my-bucket/tenant-A/* matches only object keys under tenant-A/. The wildcard rules are exactly as documented: * inside a segment matches any string including /.
  2. ListBucket narrowed by an s3:prefix Condition — because s3:ListBucket is a bucket-level action, you can't scope it with a key-prefix ARN. Instead you allow it at the bucket ARN and use the s3:prefix service-specific condition key to force any list request 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, not s3:*).
  • 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 Condition block 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

Where the arc goes next