Tenant Isolation¶
Trigger: HTTP | State: stateless | Guarantee: request-response | Difficulty: advanced
Overview¶
This recipe demonstrates per-request tenant isolation in an Azure Functions Python v2 app. The function resolves a tenant ID from an explicit request header or a JWT claim, maps that tenant to isolated infrastructure, and executes the request against the tenant's own database.
The example combines the db, validation, openapi, and logging integrations so the handler stays declarative while tenant routing remains visible and auditable.
When to Use¶
- You run a shared control plane with tenant-dedicated data stores.
- You need to route each request to tenant-specific resources at runtime.
- You want a single HTTP endpoint that can serve multiple tenants without mixing data.
- You need structured logs and validated inputs around tenant resolution.
When NOT to Use¶
- Your workload is truly single-tenant and can bind to one fixed database.
- Tenant boundaries are enforced upstream and the function never needs tenant context.
- You need cross-tenant analytics or fan-out queries in the request path.
- Your isolation model is row-level security in one shared database rather than tenant-specific resources.
Architecture¶
flowchart LR
request[HTTP request] --> resolver[Tenant resolver\nheader or JWT]
resolver --> policy[Validate tenant mapping]
policy --> db1[(Tenant A DB)]
policy --> db2[(Tenant B DB)]
policy --> dbn[(Tenant N DB)]
db1 --> response[HTTP response]
db2 --> response
dbn --> response
Behavior¶
sequenceDiagram
participant Client
participant Function as HTTP function
participant Resolver as Tenant resolver
participant Config as App settings
participant DB as Tenant database
Client->>Function: POST /api/tenant/invoices/query
Function->>Resolver: Read X-Tenant-ID or JWT tid
Resolver-->>Function: tenant-a
Function->>Config: Resolve TENANT_TENANT_A_DB_URL
Config-->>Function: SQLAlchemy URL
Function->>DB: Query tenant-local invoices
DB-->>Function: tenant-scoped rows
Function-->>Client: 200 JSON response
Prerequisites¶
- Python 3.10+
- Azure Functions Core Tools v4
azure-functions-db-pythonazure-functions-validation-pythonazure-functions-openapi-pythonazure-functions-logging-python- One database per tenant with a compatible
invoicestable
Project Structure¶
examples/security-and-tenancy/tenant_isolation/
|-- function_app.py
|-- host.json
|-- local.settings.json.example
|-- pyproject.toml
`-- README.md
Implementation¶
The tenant resolver follows a simple precedence order:
- Use
X-Tenant-IDwhen an upstream gateway already resolved the tenant. - Otherwise inspect the bearer token payload and read
tidortenant_id. - Convert the tenant ID into a tenant-specific app setting name such as
TENANT_TENANT_A_DB_URL. - Pass that database URL into
azure-functions-db-pythonso the injected reader targets the correct store.
def resolve_tenant_id(req: func.HttpRequest) -> str:
tenant_id = req.headers.get("X-Tenant-ID", "").strip()
if tenant_id:
return tenant_id
authorization = req.headers.get("Authorization", "")
if authorization.lower().startswith("bearer "):
claims = decode_jwt_without_validation(authorization.split(" ", 1)[1])
tenant_id = str(claims.get("tid") or claims.get("tenant_id") or "").strip()
if tenant_id:
return tenant_id
raise ValueError("Missing tenant context.")
def resolve_tenant_db_url(req: func.HttpRequest) -> str:
tenant_id = resolve_tenant_id(req)
setting_name = f"TENANT_{normalize_tenant_key(tenant_id)}_DB_URL"
db_url = os.getenv(setting_name, "").strip()
if not db_url:
raise ValueError(f"No database mapping configured for tenant '{tenant_id}'.")
return db_url
The HTTP handler validates the JSON body, emits structured logs, and returns tenant-scoped rows from the isolated database.
Run Locally¶
cd examples/security-and-tenancy/tenant_isolation
pip install -e ".[dev]"
cp local.settings.json.example local.settings.json
func start
Configure one setting per tenant:
{
"Values": {
"TENANT_TENANT_A_DB_URL": "postgresql+psycopg://user:pass@localhost:5432/tenant_a",
"TENANT_TENANT_B_DB_URL": "postgresql+psycopg://user:pass@localhost:5432/tenant_b"
}
}
Expected Output¶
POST /api/tenant/invoices/query
X-Tenant-ID: tenant-a
-> 200 {
"tenant_id": "tenant-a",
"count": 2,
"items": [
{"invoice_id": "inv-100", "customer_id": "cust-7", "status": "open", "amount": 125.0},
{"invoice_id": "inv-101", "customer_id": "cust-7", "status": "paid", "amount": 90.0}
]
}
Production Considerations¶
- Isolation model: tenant-specific databases provide stronger blast-radius reduction than shared tables, but increase provisioning and migration overhead.
- Tenant registry: move tenant-to-resource mapping to App Configuration, Key Vault, or a control-plane database when static app settings become hard to manage.
- JWT handling: the sample only decodes claims for routing. In production, validate signature, issuer, audience, and tenant policy before trusting the token.
- Observability: log tenant ID, request correlation, and selected resource without logging secrets or raw tokens.
- Fail closed: return an error when tenant context is missing or unmapped; never fall back to a default shared database.