Skip to content

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.


Azure RBAC + ABAC enforcement flow for a geospatial serverless worker Diagram showing how a managed identity attached to an Azure Function requests a token from Microsoft Entra ID, which evaluates the RBAC role assignment and ABAC condition, then either issues the token or returns 403 before the storage service is contacted. Azure Function (managed identity) requests token Microsoft Entra ID ① Check role assignment (container scope) ② Evaluate ABAC condition pass → token issued fail → 403 (no token) Azure Blob Storage container scope only tiles/* or zarr/* writes Write accepted tiles/output.tif written to container 403 Forbidden write to source/* blocked ABAC evaluated before any network call reaches storage

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 --condition on az role assignment create
  • Microsoft.Authorization/roleAssignments/write permission on the storage account resource group (typically held by User Access Administrator or Owner)
  • 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.0 and azure-storage-blob>=12.17.0 in the Python runtime — these versions include the MSAL token-cache improvements needed for stable IMDS refresh
  • Diagnostic settings enabled on the storage account, routing StorageRead and StorageWrite categories to a Log Analytics workspace
  • Environment variables set in the function host:
bash
AZURE_STORAGE_ACCOUNT=mygeospatialaccount
GEOSPATIAL_CONTAINER=imagery-pipeline

Implementation

1. Assign a read-only role at container scope (ETL ingestion identity)

bash
# 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:

bash
# 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:

bash
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

python
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:

python
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:

code
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):

kusto
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.0 does not support resource attribute expressions like @Resource[...blobs:path]. The CLI accepts 1.0 silently and the assignment appears valid, but the condition is never evaluated.

  • Storage Blob Data Contributor includes delete — If the tile-generation worker should not delete blobs, use a custom role definition scoping allowed actions to blobs/read, blobs/write, and blobs/add/action only. The built-in Storage Blob Data Contributor role grants blobs/delete which 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 Reader at container scope can enumerate all blob names in the container including paths under source/. If you need to prevent enumeration, add a separate ABAC condition targeting Microsoft.Storage/storageAccounts/blobServices/containers/blobs/filter/action with 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 PutBlob per chunk. If your ABAC condition uses StringLike '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.


Back to Serverless Geospatial Architecture & Platform Limits