Appearance
Quarterly Stale Service Principal Cleanup
Status: Runbook Owner: Infrastructure (Kobe executes, Rory approves all deletions) Schedule: First business day of January, April, July, October Related: quarterly-sp-permission-review.md
Purpose
Detect and remove Entra service principals that are no longer used. Stale SPs are a credential-compromise risk and a compliance finding under HIPAA least-privilege.
Cleanup Criteria
An SP is a cleanup candidate if ALL of the following are true:
- No sign-in activity in 90+ days (per
AuditLogs/AADServicePrincipalSignInLogs) - No documented automation use (not listed in
bedrock-docs/compliance/lpriv-evidence-mapping.md) - No active federated credentials referenced by a live GitHub Actions workflow
- Not a break-glass account (never delete)
Cleanup candidates require Rory's explicit approval before deletion.
Procedure
1. List all service principals (both tenants)
PROD tenant:
az login --tenant d477c9f8-<...>.onmicrosoft.com
az ad sp list --query "[].{appId:appId, displayName:displayName, notes:notes, createdDateTime:createdDateTime}" -o json > /tmp/sp-prod.jsonDDE tenant:
az login --tenant ff1c5d68-<...>.onmicrosoft.com
az ad sp list --query "[].{appId:appId, displayName:displayName, notes:notes, createdDateTime:createdDateTime}" -o json > /tmp/sp-dde.json2. Check last sign-in per SP
KQL:
AADServicePrincipalSignInLogs
| where TimeGenerated >= ago(90d)
| summarize LastSignIn = max(TimeGenerated) by ServicePrincipalIdCLI:
az monitor log-analytics query -w 5d76d1f2-<...> --analytics-query '<query above>' -o json > /tmp/sp-signins.json3. Join datasets and build candidate list
Use the Python helper in scripts/sp-cleanup-candidates.py (template to be added to ops-automation). For each candidate, gather:
- Display name, appId, creation date, owner (if any), notes field
- Assigned roles (
az role assignment list --assignee <appId> --all) - Federated credentials (
az ad app federated-credential list --id <appId>) - Any secrets or certs attached (
az ad app credential list --id <appId>)
Write report to /tmp/sp-cleanup-candidates-<YYYYQN>.md and attach to SecOps change ticket for Rory's review.
4. Get approval
- Open a SecOps change ticket:
POST /api/changeswith body describing proposed deletions - Tag Rory — wait for explicit approval reply
5. Perform deletions (only after approval)
For each approved SP:
az role assignment list --assignee <appId> --all --query "[].id" -o tsv | xargs -n1 az role assignment delete --ids
az ad app federated-credential list --id <appId> --query "[].id" -o tsv | xargs -I{} az ad app federated-credential delete --id <appId> --federated-credential-id {}
az ad sp delete --id <appId>
az ad app delete --id <appId>6. Evidence
aws s3 cp /tmp/sp-cleanup-candidates-<YYYYQN>.md s3://cirius-hipaa-evidence/sp-cleanup/<YYYYQN>/
curl -s -X POST -H "X-API-Key: $SECOPS_API_KEY" -H "Content-Type: application/json" \
-d '{"evidence_type":"sp-cleanup","period_start":"<ISO>","period_end":"<ISO>","s3_key":"sp-cleanup/<YYYYQN>/sp-cleanup-candidates-<YYYYQN>.md","source_system":"PROD"}' \
https://soc.bedrockcybersecurity.org/api/evidenceSuccess Criteria
- [ ] All SPs in both tenants enumerated
- [ ] Sign-in data pulled for 90-day window
- [ ] Candidate list compiled and sent to Rory
- [ ] Approved deletions executed; unapproved retained
- [ ] Evidence uploaded to S3 and registered in SecOps
Schedule
- Q1: January first business day
- Q2: April first business day
- Q3: July first business day
- Q4: October first business day
Change Log
| Date | Change | Author |
|---|---|---|
| 2026-04-16 | Initial runbook | Kobe |