Zero Standing Privilege Lab
A hands-on lab deploying a ZSP gateway that manages time-bounded access for non-human identities (AI agents, service principals, automation) and human administrators.
Cost: ~$5-10/month (Function App, Log Analytics) Cleanup: Delete the resource group
Blog Post: For detailed explanations of the architecture and security concepts, see Just-In-Time Access for AI Agents.
Why NHI Security Matters
AI agents and automation workflows need Azure access. Giving them standing permissions is the wrong answer:
- AI coding assistants requesting temporary access to deploy infrastructure
- Backup automation needing Key Vault secrets only during backup windows
- CI/CD pipelines requiring temporary scoped write access for deployments
- Security scanners needing read access on a schedule
This lab demonstrates the Zero Standing Privilege pattern: service principals and managed identities start with zero permissions and receive time-bounded access on demand.
Prerequisites
- Azure subscription with Owner access
- Azure CLI configured (
az login) - PowerShell 7+ (
pwsh) - Entra ID P1 or P2 license (for group-based role assignment)
- Privileged Role Administrator directory role (required to create role-assignable Entra groups)
- (Optional) Azure Functions Core Tools for local testing
Architecture
Components:
- Access Requestors โ Backup Service Principal (NHI), Human Administrator, Durable Timer (auto-revoke)
- ZSP Function Gateway โ Validates requests, creates RBAC role assignments, manages Entra group membership, schedules revocation timers, emits audit events
- Target Resources โ Key Vault (secrets), Storage Account (blob data)
- Audit Pipeline โ Data Collection Endpoint (DCE) + Data Collection Rule (DCR) โ Log Analytics
ZSPAudit_CLcustom table
Quick Start
1. Open the Lab Files
# From a local checkout of this repository:
cd labs/zsp-azure
2. Deploy
./scripts/Deploy-Lab.ps1
Or with custom settings:
./scripts/Deploy-Lab.ps1 -ProjectName "my-zsp" -Location "westus2"
The script will:
- Deploy Azure resources via Bicep (Resource Group, Key Vault, Storage, Function App, Log Analytics, DCE)
- Create Entra ID objects (ZSP groups, directory role assignments, backup SP)
- Create the
ZSPAudit_CLcustom table and Data Collection Rule (DCR) - Grant Graph API permissions and RBAC roles to the Function App managed identity
- Configure Function App settings with Entra object IDs, DCR endpoint, and schedule
- Deploy Function code
- Run a smoke test
3. Save the Endpoints
After deployment completes, note the outputs (resource names include a unique suffix):
Function App URL: https://<project>-gw-<suffix>.azurewebsites.net
ZSP Groups:
Intune Admins: <group-id>
Security Reader: <group-id>
Backup Service Principal: <sp-object-id>
Test NHI Access (Primary Use Case)
Grant Service Principal Access
Request temporary Key Vault access for the configured backup service principal:
FUNCTION_URL="https://<project>-gw-<suffix>.azurewebsites.net"
FUNCTION_KEY="<from deployment output>"
BACKUP_SP_ID="<backup-sp-object-id>"
KEYVAULT_ID="/subscriptions/<sub>/resourceGroups/<project>-rg/providers/Microsoft.KeyVault/vaults/<keyvault-name>"
curl -X POST "$FUNCTION_URL/api/nhi-access" \
-H "Content-Type: application/json" \
-H "x-functions-key: $FUNCTION_KEY" \
-d '{
"sp_object_id": "'"$BACKUP_SP_ID"'",
"scope": "'"$KEYVAULT_ID"'",
"role": "Key Vault Secrets User",
"duration_minutes": 10,
"workflow_id": "manual-test"
}'
Expected response:
{
"status": "granted",
"assignment_id": "/subscriptions/.../roleAssignments/...",
"assignment_name": "cb11eadc-0c5d-4961-b124-607a1d74e691",
"sp_object_id": "...",
"scope": "...",
"role": "Key Vault Secrets User",
"expires_at": "2026-01-27T21:06:16.156493",
"duration_minutes": 10,
"workflow_id": "manual-test",
"orchestrator_instance_id": "b10a200905204d0bb10d54fc4e1a73e0"
}
Verify Role Assignment
az role assignment list \
--assignee "$BACKUP_SP_ID" \
--scope "$KEYVAULT_ID" \
--query "[].roleDefinitionName"
Verify Revocation
Wait 10 minutes, then check again:
az role assignment list \
--assignee "$BACKUP_SP_ID" \
--scope "$KEYVAULT_ID" \
--query "[].roleDefinitionName"
# Should return empty list
Test Human Admin Access
The gateway also supports human administrators who need temporary Entra ID role access:
Request Admin Access
curl -X POST "$FUNCTION_URL/api/admin-access" \
-H "Content-Type: application/json" \
-H "x-functions-key: $FUNCTION_KEY" \
-d '{
"user_id": "YOUR_ENTRA_USER_OBJECT_ID",
"group_id": "<intune-admin-group-id>",
"duration_minutes": 15,
"justification": "Investigating device compliance issue - ticket INC0012345"
}'
Verify Access
az ad group member list --group "<intune-admin-group-id>" --query "[].displayName"
Verify Revocation
Wait for expiry, then check again:
az ad group member list --group "<intune-admin-group-id>" --query "[].displayName"
# Should return empty list
View Audit Logs
Query Log Analytics
WORKSPACE_ID="<log-analytics-workspace-id>"
az monitor log-analytics query \
--workspace "$WORKSPACE_ID" \
--analytics-query "ZSPAudit_CL | where TimeGenerated > ago(1h) | project TimeGenerated, EventType, IdentityType, PrincipalId, Target" \
--output table
Sample Queries
All access grants (last 24 hours):
ZSPAudit_CL
| where TimeGenerated > ago(24h)
| where EventType == "AccessGrant"
| project TimeGenerated, IdentityType, PrincipalId, Target, Role, DurationMinutes
| order by TimeGenerated desc
Failed access attempts:
ZSPAudit_CL
| where TimeGenerated > ago(24h)
| where Result == "Failed"
| project TimeGenerated, IdentityType, PrincipalId, ErrorMessage
NHI access outside normal patterns:
ZSPAudit_CL
| where TimeGenerated > ago(7d)
| where IdentityType == "nhi"
| where EventType == "AccessGrant"
| summarize count() by bin(TimeGenerated, 1h), PrincipalId
| where count_ > 5
File Structure
labs/zsp-azure/
โโโ _index.md # This file
โโโ bicep/
โ โโโ main.bicep # Main orchestrator
โ โโโ main.bicepparam # Parameter template
โ โโโ modules/
โ โโโ core.bicep # RG, Key Vault, Storage
โ โโโ function.bicep # Function App, Plan, Insights
โ โโโ monitoring.bicep # Log Analytics, DCE
โโโ scripts/
โ โโโ Deploy-Lab.ps1 # Main deployment script
โ โโโ Deploy-Azure.ps1 # Bicep deployment
โ โโโ Setup-EntraID.ps1 # Entra ID objects
โ โโโ Grant-Permissions.ps1 # Graph API permissions
โ โโโ Configure-Function.ps1# Function settings
โ โโโ Test-Lab.ps1 # Smoke tests
โโโ function/
โโโ function_app.py # Main function handlers
โโโ nhi_access.py # NHI ZSP logic
โโโ admin_access.py # Human ZSP logic
โโโ audit.py # Logging utilities
โโโ requirements.txt
โโโ host.json
Configuration Options
Maximum Access Duration
Edit the -MaxAccessDurationMinutes parameter when deploying:
./scripts/Deploy-Lab.ps1 -MaxAccessDurationMinutes 240
Supported Roles (NHI)
The gateway supports these Azure built-in roles:
| Role | Use Case |
|---|---|
| Key Vault Secrets User | Read secrets during backup |
| Key Vault Reader | Read vault metadata |
| Storage Blob Data Reader | Read backup data |
| Storage Blob Data Contributor | Write backup data |
| Reader | Read-only access to resources |
Add Custom Roles
Edit function/nhi_access.py to add role definition IDs:
ROLE_DEFINITIONS = {
# ... existing roles ...
"Custom Role Name": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
}
Cleanup
Remove all Azure resources (including Function App, Key Vault, Storage, Log Analytics, DCE, and DCR):
az group delete --name <project>-rg --yes
Also clean up Entra ID objects (these live outside the resource group):
# Delete ZSP groups
az ad group delete --group "SG-Intune-Admins-ZSP"
az ad group delete --group "SG-Security-Reader-ZSP"
# Delete backup service principal
az ad app delete --id "<backup-app-id>"
Troubleshooting
Deployment Fails on Entra ID Objects
Entra ID has eventual consistency. The scripts include retry logic, but if deployment fails:
# Re-run just the Entra setup
./scripts/Setup-EntraID.ps1 -ProjectName "zsp-lab"
Function App Returns 500
Check Application Insights for errors:
az monitor app-insights query \
--apps "zsp-lab-insights" \
--analytics-query "exceptions | where timestamp > ago(1h) | project timestamp, problemId, outerMessage"
Graph API Permission Denied
Ensure the managed identity has admin consent:
./scripts/Grant-Permissions.ps1 \
-FunctionAppPrincipalId "<function-principal-id>" \
-ResourceGroupId "<resource-group-id>"
