Skip to main content

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.

Standing privilege vs ZSP exposure comparison
Standing privilege: 24/7 access for a 5-minute job. ZSP: access only during execution.

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

Architecture diagram showing ZSP Gateway with NHI and admin access paths, Durable Functions timer for revocation, and Log Analytics audit trail
Zero Standing Privilege Gateway โ€” AI agents and service principals use /api/nhi-access for RBAC assignments. Human admins use /api/admin-access for group membership. All access is time-bounded and logged.

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_CL custom 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:

  1. Deploy Azure resources via Bicep (Resource Group, Key Vault, Storage, Function App, Log Analytics, DCE)
  2. Create Entra ID objects (ZSP groups, directory role assignments, backup SP)
  3. Create the ZSPAudit_CL custom table and Data Collection Rule (DCR)
  4. Grant Graph API permissions and RBAC roles to the Function App managed identity
  5. Configure Function App settings with Entra object IDs, DCR endpoint, and schedule
  6. Deploy Function code
  7. 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
Zero Standing Privilege Audit Trail dashboard showing access grants and revocations
Zero Standing Privilege Audit Trail: every grant and revocation logged with identity type, role, duration, and workflow ID.

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:

RoleUse Case
Key Vault Secrets UserRead secrets during backup
Key Vault ReaderRead vault metadata
Storage Blob Data ReaderRead backup data
Storage Blob Data ContributorWrite backup data
ReaderRead-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>"

Resources

Keyboard Shortcuts

Navigation
Ctrl + K Open search / command palette
? Show this help
ESC Close dialogs
Actions
G then H Go to Home
G then B Go to Blog
G then A Go to About
G then C Go to Contact
G then T Go to Threat Feeds
G then G Go to Glossary
Shift + C Copy page URL
Easter Eggs
โ†‘โ†‘โ†“โ†“โ†โ†’โ†โ†’BA Konami code
Click cat 9ร— Nine lives activation
Click logo 9ร— Cat Burglar mode