Implement Least Privilege IAM Policies for Azure Blob Geospatial Access
Scope Azure RBAC assignments to the individual container resource path, assign Storage Blob Data Reader for read-only ingestion or Storage Blob Data Contributor for tile-generation workflows, and layer an Attribute-Based Access Control (ABAC) condition that restricts write operations to a specific blob prefix such as tiles/ or zarr/. Bind all permissions to a managed identity — never to a SAS token, connection string, or client secret embedded in a container image. This eliminates lateral movement across storage accounts while preserving the high-throughput I/O required by rasterio, GDAL, and Zarr processing engines running inside Azure Functions or Container Apps.
Context: Why Azure Blob Permissions Need Prefix-Level Control
Serverless GIS pipelines on Azure typically partition a single storage container into logical zones: source/ for raw imagery ingested from external feeds, tiles/ for XYZ or WMTS tiles generated by processing workers, and zarr/ or parquet/ for chunked intermediate arrays. When a function responsible for tile generation holds Storage Blob Data Contributor at the container root without any write restriction, it can silently overwrite or delete files in source/. A single misconfigured rasterio call writing to the wrong path corrupts weeks of ingest data.
The broader IAM Security Boundaries for Cloud GIS framework establishes that each pipeline stage should hold the narrowest possible permission set on the narrowest possible resource scope. This page implements that principle concretely for Azure Blob Storage using RBAC + ABAC. For the equivalent constraint in AWS environments see Ephemeral Storage Limits in AWS Lambda which discusses how /tmp and S3 access interact during raster extraction workflows.
Prerequisites
Before applying these policies, confirm the following are in place:
- Azure CLI version 2.47 or later — earlier versions do not support
--conditiononaz role assignment create Microsoft.Authorization/roleAssignments/writepermission on the storage account resource group (typically held byUser Access AdministratororOwner)- A system-assigned or user-assigned managed identity already created and attached to the Azure Function, Container App, or Azure Batch pool
azure-identity>=1.13.0andazure-storage-blob>=12.17.0in the Python runtime — these versions include the MSAL token-cache improvements needed for stable IMDS refresh- Diagnostic settings enabled on the storage account, routing
StorageReadandStorageWritecategories to a Log Analytics workspace - Environment variables set in the function host:
AZURE_STORAGE_ACCOUNT=mygeospatialaccount
GEOSPATIAL_CONTAINER=imagery-pipeline
Implementation
1. Assign a read-only role at container scope (ETL ingestion identity)
# Assign Storage Blob Data Reader scoped to a single container.
# Replace placeholders with your actual subscription, resource group,
# storage account, and container names.
az role assignment create \
--assignee-object-id <MANAGED_IDENTITY_OBJECT_ID> \
--assignee-principal-type ServicePrincipal \
--role "Storage Blob Data Reader" \
--scope "/subscriptions/<SUB_ID>/resourceGroups/<RG>/providers/Microsoft.Storage/storageAccounts/<ACCOUNT>/blobServices/default/containers/<GEOSPATIAL_CONTAINER>"
This scope string binds the role to Microsoft.Storage/storageAccounts/blobServices/containers/<GEOSPATIAL_CONTAINER> only. An identity carrying this assignment cannot read any other container in the same account, cannot list accounts, and has no management-plane access (keys, network rules, encryption).
2. Assign a write-restricted role with an ABAC condition (tile-generation identity)
Save the ABAC condition expression to a file:
# condition.json — restricts all blob writes to the tiles/ prefix.
# The condition is evaluated at token issuance, not at the storage service.
cat > /tmp/condition.json << 'EOF'
(
(!(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/write'}))
OR
(@Resource[Microsoft.Storage/storageAccounts/blobServices/containers/blobs:path] StringLike 'tiles/*')
)
EOF
Apply Storage Blob Data Contributor with the condition:
az role assignment create \
--assignee-object-id <TILE_WORKER_MANAGED_IDENTITY_OBJECT_ID> \
--assignee-principal-type ServicePrincipal \
--role "Storage Blob Data Contributor" \
--scope "/subscriptions/<SUB_ID>/resourceGroups/<RG>/providers/Microsoft.Storage/storageAccounts/<ACCOUNT>/blobServices/default/containers/<GEOSPATIAL_CONTAINER>" \
--condition "$(cat /tmp/condition.json)" \
--condition-version "2.0"
3. Python worker using managed identity
import os
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient, ContainerClient
# GDAL and rasterio environment — always explicit, never assumed.
# These paths match the standard Azure Functions Python runtime layout.
os.environ.setdefault("GDAL_DATA", "/usr/share/gdal")
os.environ.setdefault("PROJ_LIB", "/usr/share/proj")
STORAGE_ACCOUNT = os.environ["AZURE_STORAGE_ACCOUNT"]
CONTAINER = os.environ["GEOSPATIAL_CONTAINER"]
ACCOUNT_URL = f"https://{STORAGE_ACCOUNT}.blob.core.windows.net"
def _get_container_client() -> ContainerClient:
"""
Build a ContainerClient backed by managed identity.
exclude_environment_credential=True prevents CI/CD pipeline secrets
from accidentally overriding the managed identity in production.
exclude_visual_studio_code_credential and
exclude_interactive_browser_credential keep the chain silent in
headless function environments.
"""
credential = DefaultAzureCredential(
exclude_environment_credential=True,
exclude_visual_studio_code_credential=True,
exclude_interactive_browser_credential=True,
)
service = BlobServiceClient(account_url=ACCOUNT_URL, credential=credential)
return service.get_container_client(CONTAINER)
def upload_tile(tile_relative_path: str, data: bytes) -> None:
"""
Upload a processed tile to the tiles/ prefix.
The ABAC condition on the managed identity's role assignment
will block any write that targets a path outside tiles/*.
Azure AD evaluates the condition at token issuance — the request
never reaches the storage service if the path fails the condition.
Args:
tile_relative_path: Path relative to tiles/, e.g. "z/x/y.png"
data: Raw tile bytes from the rasterio/GDAL rendering pipeline
"""
client = _get_container_client()
# Enforce the tiles/ prefix in code as a second defensive layer.
blob_path = f"tiles/{tile_relative_path.lstrip('/')}"
blob_client = client.get_blob_client(blob_path)
blob_client.upload_blob(data, overwrite=True)
def read_source_imagery(source_path: str) -> bytes:
"""
Download a raw source file from source/.
The ETL ingestion identity (Storage Blob Data Reader) can read
this path. The tile-generation identity cannot write here due to
the ABAC condition.
Args:
source_path: Path relative to source/, e.g. "sentinel/tile.tif"
"""
client = _get_container_client()
blob_path = f"source/{source_path.lstrip('/')}"
blob_client = client.get_blob_client(blob_path)
stream = blob_client.download_blob()
return stream.readall()
DefaultAzureCredential resolves tokens via the Azure Instance Metadata Service (IMDS) endpoint at 169.254.169.254. In Azure-hosted runtimes the SDK refreshes tokens automatically before expiration, so ABAC conditions are re-evaluated on every token renewal without any code changes.
Verification
Confirm the ABAC condition blocks out-of-prefix writes and permits in-prefix writes:
import os
from azure.core.exceptions import HttpResponseError
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient
STORAGE_ACCOUNT = os.environ["AZURE_STORAGE_ACCOUNT"]
CONTAINER = os.environ["GEOSPATIAL_CONTAINER"]
ACCOUNT_URL = f"https://{STORAGE_ACCOUNT}.blob.core.windows.net"
credential = DefaultAzureCredential(exclude_environment_credential=True)
service = BlobServiceClient(account_url=ACCOUNT_URL, credential=credential)
client = service.get_container_client(CONTAINER)
# This write must succeed (inside tiles/ prefix).
try:
client.get_blob_client("tiles/verify/test.txt").upload_blob(b"ok", overwrite=True)
print("PASS: tiles/verify/test.txt written")
except HttpResponseError as e:
print(f"FAIL: expected success, got {e.status_code} {e.reason}")
# This write must be blocked by the ABAC condition.
try:
client.get_blob_client("source/should-not-write.txt").upload_blob(b"bad", overwrite=True)
print("FAIL: write to source/ was not blocked")
except HttpResponseError as e:
if e.status_code == 403:
print(f"PASS: source/ write correctly blocked (403)")
else:
print(f"UNEXPECTED: {e.status_code} {e.reason}")
Expected console output:
PASS: tiles/verify/test.txt written
PASS: source/ write correctly blocked (403)
Query Log Analytics for any 403 events reaching the storage service (a 403 here means the ABAC condition was evaluated at the service level as a secondary check, not at token issuance — investigate condition syntax if you see these):
AzureDiagnostics
| where ResourceType == "STORAGEACCOUNTS"
| where OperationName in ("PutBlob", "PutBlock", "PutBlockList")
| where StatusCode == 403
| project TimeGenerated, CallerIPAddress, RequestUri, AuthorizationDetails
| order by TimeGenerated desc
Gotchas and Edge Cases
-
ABAC condition version must be
"2.0"—--condition-version 1.0does not support resource attribute expressions like@Resource[...blobs:path]. The CLI accepts1.0silently and the assignment appears valid, but the condition is never evaluated. -
Storage Blob Data Contributorincludes delete — If the tile-generation worker should not delete blobs, use a custom role definition scoping allowed actions toblobs/read,blobs/write, andblobs/add/actiononly. The built-inStorage Blob Data Contributorrole grantsblobs/deletewhich is unrestricted even when an ABAC write condition is attached (conditions and actions are evaluated independently). -
ABAC does not restrict list operations by default — An identity with
Storage Blob Data Readerat container scope can enumerate all blob names in the container including paths undersource/. If you need to prevent enumeration, add a separate ABAC condition targetingMicrosoft.Storage/storageAccounts/blobServices/containers/blobs/filter/actionwith a@Resource[...containers/blobs:path]prefix check. -
Zarr and COG chunk writes generate many small blobs — Writing a Zarr array chunk-by-chunk issues one
PutBlobper chunk. If your ABAC condition usesStringLike 'zarr/*'ensure the GDAL VSI path for cloud-optimized GeoTIFF (COG) overviews also falls under this prefix, otherwise overview writes will be blocked at 403 with a misleading “permission denied” at the GDAL layer (ERROR 3: /vsiaz/container/zarr/overview.tif: CURL error: The requested URL returned error: 403).
Frequently Asked Questions
Can I use a SAS token instead of a managed identity for Azure geospatial pipelines?
You can, but you should not. SAS tokens embed a credential that cannot be revoked instantly if leaked, and they do not support ABAC conditions. Managed identities eliminate credential rotation entirely and support the full RBAC + ABAC policy stack.
Does the ABAC condition apply to blob list operations?
ABAC conditions can target list actions via Microsoft.Storage/storageAccounts/blobServices/containers/blobs/filter/action and the container:name attribute. Omitting a list condition allows the identity to enumerate all blobs in the container, which may expose source-imagery paths. Add a separate condition if enumeration scope also needs restriction.
What happens when DefaultAzureCredential falls back to environment credential in production?
If AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_CLIENT_SECRET are present in the environment, DefaultAzureCredential uses them before the managed identity chain. Exclude environment credential in production by passing exclude_environment_credential=True to prevent accidental credential leakage from misconfigured CI/CD pipelines.
Related
- IAM Security Boundaries for Cloud GIS — parent overview covering per-stage role scoping for all cloud providers
- Memory and CPU Allocation for Raster Workloads — how function memory ceilings interact with large tile-generation jobs
- Reducing Python GDAL Cold Starts with Provisioned Concurrency — managed identity token acquisition adds ~80 ms to cold starts; provisioned concurrency amortises this
- Managing /tmp Storage Limits for GeoTIFF Extraction — complementary I/O hardening for the processing stage
Back to Serverless Geospatial Architecture & Platform Limits