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.


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.

Prefix zones inside the imagery-pipeline containerFour stages left to right: an external imagery feed lands in the source prefix, which the tile worker reads with a read-only identity; the worker runs as an Azure Function with a managed identity holding Storage Blob Data Contributor plus an attribute-based condition, and writes only into the tiles prefix.One container, three prefixes, two identitiesExternal feedSentinel sceneslanded by ingestsource/raw imageryread-only to the workerTile workerFunction with amanaged identitytiles/the only prefix thecondition permitsScope the assignment at container level and the worker still holds every prefix inside it — which is why the write condition, not the scope, is whatprotects source/.
Nothing here is a separate resource. All three prefixes are the same container, so the only thing standing between the tile worker and the ingest data is the condition on its role assignment.

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"
Blob operations permitted by Reader, Contributor, and Contributor with an ABAC write conditionA grid of five blob operations against three role assignments at container scope. Reading source imagery is allowed by all three. Writing a tile is refused by Reader and allowed by both Contributor variants. Writing into the source prefix is allowed by plain Contributor and blocked with 403 once the condition is attached. Deleting a blob and listing every blob name remain allowed under both Contributor variants, because the write condition does not cover them.What each assignment actually permitsData ReaderData ContributorContributor + ABACRead source/sentinel/tile.tifallowedallowedallowedWrite tiles/z/x/y.png403allowedallowedWrite source/should-not-write.txt403allowed403 blockedDelete a blob under source/403allowedallowedList every blob name in thecontainerallowedallowedallowedRoles are the built-in Storage Blob Data Reader and Storage Blob Data Contributor assigned at container scope; the third column adds thecondition-version 2.0 StringLike 'tiles/*' write condition.
The condition constrains exactly one action. Delete and list pass straight through it, which is why a worker that must not delete needs a custom role definition rather than a tighter condition.

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.

Token acquisition and the two writes that followA sequence between the tile worker, Microsoft Entra ID, and the blob container. The worker asks the instance metadata endpoint for a token, Entra ID matches the role assignment and its condition, and returns a bearer token scoped to one container. A write to the tiles prefix returns 201 Created; a write to the source prefix returns 403 Forbidden because the path does not satisfy the condition.No secret, one container, one prefixTile workerMicrosoft Entra IDBlob containerRequest tokenIMDS at 169.254.169.254Match the role assignmentContributor at container scope, condition version 2.0Bearer tokenscoped to one containerPUT tiles/z/x/y.pngupload_blob(overwrite=True)201 CreatedPUT source/should-not-write.txtthe verification case403 Forbiddenpath fails StringLike 'tiles/*'
The credential never touches the deployment package or an app setting — the worker asks for it per token lifetime, and the condition travels with the assignment rather than with the code.

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