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: Run the repository’s manifest-driven cleanup script. It validates immutable Entra object IDs and provenance before deleting those objects; Azure resource-group deletion is a separate explicit switch.
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>
Before requesting access, call GET /api/health. HTTP 200 with
status: healthy means the audit ingestion dependency is ready. Missing or
malformed DCR_ENDPOINT or DCR_RULE_ID settings return HTTP 503 with
status: degraded; request admission, scheduled grants, and grant activities
all fail closed before changing privilege. Existing deployments must update the
ZSPAudit_CL table and DCR schema before deploying this Function version.
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>"
NHI_RESPONSE="$(curl --fail --silent --show-error -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"
}')"
echo "$NHI_RESPONSE" | jq .
STATUS_URL="$(echo "$NHI_RESPONSE" | jq -r '.statusQueryGetUri')"
Expected HTTP 202 Durable management response (URLs abbreviated):
{
"id": "b10a200905204d0bb10d54fc4e1a73e0",
"statusQueryGetUri": "https://.../runtime/webhooks/durabletask/instances/...",
"sendEventPostUri": "https://...",
"terminatePostUri": "https://...",
"purgeHistoryDeleteUri": "https://..."
}
The 202 response means the safety workflow was accepted, not that access is
already active. Poll the management URL and wait for customStatus.status to
become active:
while true; do
STATUS="$(curl --fail --silent --show-error "$STATUS_URL")"
echo "$STATUS" | jq '{runtimeStatus, customStatus}'
[ "$(echo "$STATUS" | jq -r '.customStatus.status // empty')" = "active" ] && break
case "$(echo "$STATUS" | jq -r '.runtimeStatus')" in
Failed|Terminated|Completed) exit 1 ;;
esac
sleep 2
done
Only then should the role assignment be used. A failed, terminated, or already completed orchestration means access must not be assumed.
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
ADMIN_RESPONSE="$(curl --fail --silent --show-error -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"
}')"
echo "$ADMIN_RESPONSE" | jq .
ADMIN_STATUS_URL="$(echo "$ADMIN_RESPONSE" | jq -r '.statusQueryGetUri')"
Poll ADMIN_STATUS_URL with the same lifecycle check and wait for
customStatus.status == "active" before attempting administrative work.
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
Admin Lifecycle Ownership and Recovery
Human-admin requests for the same user/group pair are serialized through a Durable Entity owner lock. A second lifecycle cannot adopt the membership while another instance owns it. Before compensation or expiry revocation, the orchestrator verifies that the exact same instance still owns the lock; it releases the lock only after the membership has been removed successfully.
If the entity confirms a different owner, status becomes ownership_lost. If
the ownership lookup itself fails, status becomes ownership_unverified. Both
paths attempt a correlated failed-revoke audit event, retain the owner lock, and
refuse to delete the membership. If either status appears, cleanup fails, or a
lifecycle fails with its owner lock retained:
- Stop new requests for that user/group pair and preserve the orchestration ID.
- Inspect the Durable instance history, Entra audit logs, and current group membership to determine whether the failed lifecycle created the membership.
- Remove the user manually only after that attribution is confirmed. A Graph group-membership edge has no per-membership owner token, so do not remove a pre-existing or independently managed membership.
- After confirming the entitlement is absent, repair or purge the matching
admin_entitlement_ownerDurable Entity state. In this disposable lab, a task-hub reset/redeployment is the fallback if targeted entity recovery is unavailable. - Re-run verification before accepting another request.
Never clear the entity first: doing so can allow an overlapping grant while the old membership still exists. For the same reason, do not modify privileged ZSP group memberships manually during an active lifecycle.
Treat the function key and Durable management URLs as credentials. Send the
function key only in x-functions-key as shown above, and keep management URLs
out of shared logs because they contain access tokens in their query strings.
View Audit Logs
Every new grant, revoke, and ownership-guard failure contains two exact
correlation keys: LifecycleId is the Durable orchestration instance ID, and
EntitlementId is the deterministic admin ownership key or complete Azure role
assignment resource ID. Use both fields together; principal and target alone
cannot distinguish repeated lifecycles safely.
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, LifecycleId, EntitlementId" \
--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, LifecycleId, EntitlementId
| order by TimeGenerated desc
Failed access attempts:
ZSPAudit_CL
| where TimeGenerated > ago(24h)
| where Result == "Failed"
| project TimeGenerated, IdentityType, PrincipalId, LifecycleId, EntitlementId, ErrorMessage
Expired grants without an exact successful revoke:
let grace = 15m;
let exact_grants =
ZSPAudit_CL
| where EventType == "AccessGrant" and Result == "Success"
| where isnotempty(ExpiresAt)
| extend Expiry = todatetime(ExpiresAt)
| where Expiry < ago(grace)
| where isnotempty(LifecycleId) and isnotempty(EntitlementId)
| summarize GrantTime = min(TimeGenerated), LastExpiry = max(Expiry),
PrincipalId = take_any(PrincipalId), Target = take_any(Target),
Role = take_any(Role), IdentityType = take_any(IdentityType)
by LifecycleId, EntitlementId;
let exact_revokes =
ZSPAudit_CL
| where EventType == "AccessRevoke" and Result == "Success"
| where isnotempty(LifecycleId) and isnotempty(EntitlementId)
| summarize RevokeTime = max(TimeGenerated) by LifecycleId, EntitlementId;
let exact_findings =
exact_grants
| join kind=leftanti exact_revokes on LifecycleId, EntitlementId
| extend CorrelationStatus = "Exact",
Finding = "Expired lifecycle entitlement with no successful revoke";
let legacy_findings =
ZSPAudit_CL
| where EventType == "AccessGrant" and Result == "Success"
| where isnotempty(ExpiresAt)
| extend LastExpiry = todatetime(ExpiresAt)
| where LastExpiry < ago(grace)
| where isempty(LifecycleId) or isempty(EntitlementId)
| extend CorrelationStatus = "LegacyUncorrelated",
Finding = "Legacy expired grant lacks exact lifecycle correlation; review manually";
union exact_findings, legacy_findings
| order by LastExpiry asc
Alert on CorrelationStatus == "Exact". Route LegacyUncorrelated rows to
manual review instead of guessing a match from principal and target.
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>"
