Intermediate ⏱ 120 min πŸ“‹ 10 Steps

Build App Compliance & Lifecycle Management

Audit the OAuth app estate, create permission governance policies, implement admin consent workflows, conduct access reviews, set up dormant app cleanup, and build compliance dashboards and audit reports.

πŸ“‹ Overview

About This Lab

App compliance and lifecycle management ensures OAuth apps are continuously monitored, reviewed, and managed throughout their lifecycle. from initial consent through periodic review to eventual decommissioning. This lab covers building compliance policies, implementing approval workflows, conducting periodic access reviews, and managing the app lifecycle to maintain a clean and secure app estate.

🏒 Enterprise Use Case

An organisation discovers 340 OAuth apps in their tenant but only 120 are approved through IT governance. The remaining 220 were consented by individual users without security review. The compliance team mandates quarterly access reviews, app owner attestation, and automatic decommissioning of unused apps. The security team must build an app compliance program from scratch.

🎯 What You Will Learn

  1. Audit the current app inventory and categorise by risk
  2. Create app compliance policies for permission governance
  3. Implement admin consent workflows for new app requests
  4. Conduct quarterly app access reviews
  5. Configure app owner attestation requirements
  6. Set up dormant app detection and cleanup
  7. Create app lifecycle management workflows
  8. Build compliance dashboards for app governance
  9. Generate app compliance reports for audit
  10. Design an ongoing app governance program

πŸ”‘ Why This Matters

Without lifecycle management, OAuth apps accumulate like technical debt. Dormant apps with high permissions are silent attack surfaces. Former employees'' consented apps remain active. Acquired companies bring unknown app estates. A structured lifecycle management program keeps the app estate clean, compliant, and defensible.

βš™οΈ Prerequisites

  • Licensing: Microsoft 365 E5, or Microsoft Defender for Cloud Apps + App Governance add-on
  • Roles: Security Administrator, Application Administrator, or Global Administrator in Microsoft Entra ID
  • Prior Labs: Completion of Lab 01 (monitoring) and Lab 02 (threat detection)
  • Portal Access: Microsoft Defender XDR portal + Microsoft Entra admin centre
  • Tooling: PowerShell 7+ with Microsoft.Graph module v2.x (Install-Module Microsoft.Graph -Scope CurrentUser)
  • Permissions: Graph scopes - Application.Read.All, DelegatedPermissionGrant.ReadWrite.All, AppRoleAssignment.ReadWrite.All, AuditLog.Read.All
  • Data: At least 30 days of App Governance telemetry and Entra ID sign-in logs for dormant app detection

Step 1 Β· Audit and Categorise the App Estate

Before you can build a compliance program, you need a complete inventory of every OAuth app in your tenant - including who consented to it, what permissions it holds, whether the publisher is verified, and when it was last used. The PowerShell script below exports the full app inventory from Microsoft Graph, categorises each app by risk level based on permission scope and publisher verification status, and produces a structured CSV for compliance review.

  1. Export the full OAuth app inventory from App Governance
  2. Categorise each app: Approved, Under Review, Unapproved, Banned
  3. Tag apps by business function: HR, Finance, Marketing, IT, Engineering
  4. Identify apps with no clear business owner
# WHAT: Export and categorise the full OAuth app estate with risk-based classification
# WHY:  You cannot govern what you cannot see - this script builds a complete inventory
#       with automatic risk scoring based on permissions, publisher status, and certification
# REQUIRES: Microsoft.Graph module v2.x
#           Scopes: Application.Read.All, DelegatedPermissionGrant.ReadWrite.All

Connect-MgGraph -Scopes "Application.Read.All","DelegatedPermissionGrant.ReadWrite.All"

# Retrieve all service principals (represents apps consented in the tenant)
$servicePrincipals = Get-MgServicePrincipal -All -Property `
    Id,AppId,DisplayName,PublisherName,VerifiedPublisher,`
    AccountEnabled,AppRoles,Oauth2PermissionScopes,`
    SignInAudience,CreatedDateTime,Tags

Write-Host "Found $($servicePrincipals.Count) service principals" -ForegroundColor Cyan

# Define high-risk Graph permission scopes that require admin governance
$highRiskScopes = @(
    "Mail.ReadWrite", "Mail.Read", "Files.ReadWrite.All",
    "User.ReadWrite.All", "Directory.ReadWrite.All",
    "Sites.ReadWrite.All", "MailboxSettings.ReadWrite"
)

# Build the compliance inventory
$inventory = foreach ($sp in $servicePrincipals) {
    # Get all delegated permission grants for this app
    $grants = Get-MgServicePrincipalOauth2PermissionGrant `
        -ServicePrincipalId $sp.Id -ErrorAction SilentlyContinue
    $allScopes = ($grants | ForEach-Object { $_.Scope -split " " }) | Sort-Object -Unique
    $hasHighRisk = ($allScopes | Where-Object { $_ -in $highRiskScopes }).Count -gt 0

    # Determine risk category
    $riskLevel = switch ($true) {
        ($hasHighRisk -and -not $sp.VerifiedPublisher.DisplayName) { "Critical" }
        ($hasHighRisk) { "High" }
        (-not $sp.VerifiedPublisher.DisplayName) { "Medium" }
        default { "Low" }
    }

    [PSCustomObject]@{
        AppName            = $sp.DisplayName
        AppId              = $sp.AppId
        Publisher          = $sp.PublisherName
        PublisherVerified  = [bool]$sp.VerifiedPublisher.DisplayName
        Enabled            = $sp.AccountEnabled
        RiskLevel          = $riskLevel
        HighRiskScopes     = ($allScopes | Where-Object { $_ -in $highRiskScopes }) -join "; "
        AllScopes          = $allScopes -join "; "
        CreatedDate        = $sp.CreatedDateTime
        SignInAudience     = $sp.SignInAudience
    }
}

# Export and display summary
$inventory | Export-Csv "AppGovernance-Inventory-$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
$inventory | Group-Object RiskLevel | Select-Object Name, Count | Format-Table -AutoSize
Write-Host "Inventory exported successfully." -ForegroundColor Green
💡 Pro Tip: Schedule this inventory export weekly via Azure Automation and store results in a SharePoint list. This creates a historical record of your app estate over time - invaluable during audits when you need to prove apps were reviewed and risk-assessed on a regular cadence.

Step 2 Β· Create Permission Governance Policies

  1. Create a policy requiring admin consent for apps requesting high-privilege permissions
  2. Create a policy blocking apps from unverified publishers that request Mail or Files access
  3. Define acceptable permission levels by app category
  4. Document exceptions with business justification requirements

Step 3 Β· Implement Admin Consent Workflows

Admin consent workflows prevent users from granting apps access to organisational data without security review. But you also need visibility into the permissions that have already been granted. The KQL query below audits every OAuth permission grant across your entire tenant - showing which apps hold which permissions, who consented, and whether grants were admin-level or user-level - giving you the evidence needed to enforce the governance policies you defined in Step 2.

  1. Enable the admin consent request workflow in Entra ID
  2. Designate reviewers from security and IT governance teams
  3. Create a review checklist: publisher verification, permission justification, data residency, privacy policy
  4. Set SLA for consent request review: 48 hours for standard, 24 hours for urgent
// WHAT: Comprehensive audit of all OAuth permission grants across the tenant
// WHY:  Before enforcing admin consent workflows, you need a complete picture of
//       existing grants - which apps have what access, who approved it, and whether
//       any grants bypass your new governance policies
// TABLE: AuditLogs - captures all consent grant operations in Entra ID
// KEY FIELDS:
//   OperationName      - "Consent to application" or "Add delegated permission grant"
//   TargetResources    - the app that received the permission grant
//   InitiatedBy        - user or admin who performed the consent
//   AdditionalDetails  - contains the specific permission scopes granted
// OUTPUT: Every permission grant in the last 90 days with grantor identity, scope
//         details, and consent type (admin vs user) for compliance review

// All consent operations in the last 90 days
AuditLogs
| where TimeGenerated > ago(90d)
| where OperationName in (
    "Consent to application",
    "Add delegated permission grant",
    "Add app role assignment to service principal"
)
| extend AppName = tostring(TargetResources[0].displayName),
         AppId = tostring(TargetResources[0].id),
         ConsentedBy = tostring(InitiatedBy.user.userPrincipalName),
         ConsentType = iff(OperationName == "Add app role assignment to service principal",
                          "Application (Admin)", "Delegated (User)"),
         Scopes = tostring(parse_json(
             tostring(TargetResources[0].modifiedProperties))[0].newValue)
| project TimeGenerated, AppName, AppId, ConsentedBy, ConsentType, Scopes, OperationName
| order by TimeGenerated desc

// Summary: permission grants by consent type and risk level
// AuditLogs
// | where TimeGenerated > ago(90d)
// | where OperationName has "consent" or OperationName has "permission grant"
// | extend ConsentType = iff(OperationName has "app role", "Admin", "User")
// | summarize GrantCount = count() by ConsentType, bin(TimeGenerated, 7d)
// | render timechart
💡 Pro Tip: Focus your initial review on user-consented grants for unverified publishers with high-privilege scopes (Mail.ReadWrite, Files.ReadWrite.All). These represent the highest-risk gap in your current governance - apps that bypassed admin review and hold sensitive permissions from unknown publishers.

Step 4 Β· Conduct Quarterly Access Reviews

  1. Schedule quarterly app access reviews with app owners
  2. For each app: verify continued business need, review permissions, assess data access
  3. Revoke apps that fail review or have no app owner response
  4. Track review completion rates and compliance

Step 5 Β· Configure App Owner Attestation

App owner attestation ensures every approved app has an accountable business owner who periodically confirms the app is still needed and its permissions remain appropriate. A critical part of attestation is credential hygiene - tracking when app secrets and certificates expire, flagging apps with credentials older than your rotation policy, and ensuring no app operates with stale or untracked credentials. The PowerShell script below audits credential expiration across all app registrations.

  1. Assign a business owner to every approved app
  2. Require annual attestation: the owner confirms the app is still needed and permissions are appropriate
  3. Set automatic reminder notifications 30 days before attestation deadline
  4. Auto-disable apps whose owners fail to attest within the grace period
# WHAT: Audit credential expiration and rotation compliance across all app registrations
# WHY:  Stale credentials are a top attack vector - expired secrets may indicate
#       abandoned apps, while secrets older than 180 days violate most compliance
#       frameworks (SOC 2, ISO 27001 recommend 90–180 day rotation)
# REQUIRES: Microsoft.Graph module v2.x
#           Scopes: Application.Read.All

Connect-MgGraph -Scopes "Application.Read.All"

# Retrieve all app registrations with credential details
$apps = Get-MgApplication -All -Property Id,AppId,DisplayName,`
    PasswordCredentials,KeyCredentials,CreatedDateTime

$today = Get-Date
$rotationPolicyDays = 180  # Max credential age before non-compliance

# Analyse each app's credential status
$credentialReport = foreach ($app in $apps) {
    $allCreds = @()

    # Check client secrets (password credentials)
    foreach ($secret in $app.PasswordCredentials) {
        $age = ($today - $secret.StartDateTime).Days
        $daysToExpiry = ($secret.EndDateTime - $today).Days
        $allCreds += [PSCustomObject]@{
            Type       = "ClientSecret"
            KeyId      = $secret.KeyId
            AgeDays    = $age
            ExpiresIn  = $daysToExpiry
            Status     = if ($daysToExpiry -lt 0) { "EXPIRED" }
                         elseif ($daysToExpiry -lt 30) { "EXPIRING-SOON" }
                         elseif ($age -gt $rotationPolicyDays) { "ROTATION-OVERDUE" }
                         else { "Compliant" }
        }
    }

    # Check certificates (key credentials)
    foreach ($cert in $app.KeyCredentials) {
        $age = ($today - $cert.StartDateTime).Days
        $daysToExpiry = ($cert.EndDateTime - $today).Days
        $allCreds += [PSCustomObject]@{
            Type       = "Certificate"
            KeyId      = $cert.KeyId
            AgeDays    = $age
            ExpiresIn  = $daysToExpiry
            Status     = if ($daysToExpiry -lt 0) { "EXPIRED" }
                         elseif ($daysToExpiry -lt 30) { "EXPIRING-SOON" }
                         elseif ($age -gt $rotationPolicyDays) { "ROTATION-OVERDUE" }
                         else { "Compliant" }
        }
    }

    # Flag apps with no credentials (may be unused) or non-compliant credentials
    $nonCompliant = $allCreds | Where-Object { $_.Status -ne "Compliant" }

    [PSCustomObject]@{
        AppName         = $app.DisplayName
        AppId           = $app.AppId
        TotalCreds      = $allCreds.Count
        Expired         = ($allCreds | Where-Object { $_.Status -eq "EXPIRED" }).Count
        ExpiringSoon    = ($allCreds | Where-Object { $_.Status -eq "EXPIRING-SOON" }).Count
        RotationOverdue = ($allCreds | Where-Object { $_.Status -eq "ROTATION-OVERDUE" }).Count
        WorstStatus     = if ($nonCompliant) { ($nonCompliant | Sort-Object ExpiresIn | 
                            Select-Object -First 1).Status } else { "Compliant" }
        NoCreds         = $allCreds.Count -eq 0
    }
}

# Export and display summary
$credentialReport | Export-Csv "CredentialRotation-Audit-$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
$credentialReport | Group-Object WorstStatus | Select-Object Name, Count | Format-Table -AutoSize
Write-Host "`nApps with no credentials (potentially unused): $(($credentialReport | Where-Object NoCreds).Count)" -ForegroundColor Yellow
💡 Pro Tip: Combine this credential audit with owner attestation - when notifying an app owner for annual review, include the credential status. If their app has an expired or overdue credential, the attestation form should require a rotation plan before re-approval. This turns attestation from a rubber-stamp exercise into a genuine security checkpoint.

Step 6 Β· Detect and Clean Up Dormant Apps

  1. Create a policy to identify apps with no activity in the last 90 days
  2. Notify app owners about dormant app status
  3. Set a 30-day grace period for owners to confirm the app is still needed
  4. Auto-revoke apps that remain dormant after the grace period
  5. Track the number of dormant apps cleaned up per quarter

Step 7 Β· Create Lifecycle Management Workflows

Lifecycle management requires proactive identification of apps that are no longer serving a business purpose. Dormant apps - those with valid permissions but no recent API activity - are particularly dangerous because they represent unmonitored access pathways that an attacker could exploit. The KQL query below identifies apps with no sign-in or API activity in the last 90 days while still holding active permission grants, producing a prioritised cleanup list.

  1. Define lifecycle stages: Request > Review > Approve > Monitor > Review > Decommission
  2. Create workflows for each stage using Power Automate or Sentinel playbooks
  3. Automate notifications at each stage transition
  4. Document the lifecycle policy and communicate to all stakeholders
// WHAT: Identify dormant OAuth apps with active permissions but no recent API activity
// WHY:  Dormant apps are silent attack surfaces - they hold valid permissions and tokens
//       but are not monitored because no one is actively using them. Attackers target
//       exactly these apps because compromise goes unnoticed for months
// TABLES: AADServicePrincipalSignInLogs - service principal authentication events
//         CloudAppEvents - app API activity against Microsoft 365 services
// OUTPUT: Apps with no sign-in or API activity in 90+ days, ranked by permission
//         privilege level - these are candidates for decommissioning

// Step 1: Find all apps that DID authenticate in the last 90 days
let ActiveApps = AADServicePrincipalSignInLogs
| where TimeGenerated > ago(90d)
| where ResultType == 0
| distinct AppId;
// Step 2: Find all apps that made API calls in the last 90 days
let ActiveAPIApps = CloudAppEvents
| where Timestamp > ago(90d)
| distinct Application;
// Step 3: Find apps in AuditLogs that received consent but are NOT in the active sets
AuditLogs
| where TimeGenerated > ago(365d)
| where OperationName in (
    "Consent to application",
    "Add delegated permission grant",
    "Add app role assignment to service principal"
)
| extend AppName = tostring(TargetResources[0].displayName),
         AppId = tostring(TargetResources[0].id),
         ConsentedBy = tostring(InitiatedBy.user.userPrincipalName),
         ConsentDate = TimeGenerated
| summarize LastConsent = max(ConsentDate),
            ConsentedBy = any(ConsentedBy)
    by AppName, AppId
| where AppId !in (ActiveApps)
    and AppName !in (ActiveAPIApps)
| extend DormantDays = datetime_diff('day', now(), LastConsent)
| where DormantDays > 90
| project AppName, AppId, LastConsent, DormantDays, ConsentedBy
| order by DormantDays desc
💡 Pro Tip: Implement a β€œsoft decommission” workflow: disable the service principal first and wait 30 days before removing consent grants. This allows app owners to re-activate if the app turns out to be needed for a quarterly process that was outside the 90-day detection window (e.g., annual licence renewal tools).

Step 8 Β· Build Compliance Dashboards

  1. Create a dashboard showing: total apps, approved vs. unapproved, high-risk vs. low-risk
  2. Track metrics: new apps consented per month, apps revoked, attestation compliance rate
  3. Show permission distribution: percentage of apps with high, medium, low permissions
  4. Monitor dormant app cleanup progress

Step 9 Β· Generate Audit Compliance Reports

Compliance reporting transforms your app governance activities into auditable evidence that satisfies regulatory frameworks like SOC 2, ISO 27001, and NIST CSF. The PowerShell script below generates a comprehensive quarterly compliance report covering app inventory status, permission governance metrics, attestation compliance rates, and dormant app cleanup progress - formatted for direct presentation to compliance committees and external auditors.

  1. Export quarterly compliance reports with: app inventory, review results, remediation actions
  2. Include attestation compliance rates and exception documentation
  3. Map app governance controls to regulatory requirements (SOC 2, ISO 27001)
  4. Present findings to the compliance committee
# WHAT: Generate a quarterly App Governance compliance report for audit
# WHY:  Auditors require documented evidence of ongoing app governance - this script
#       produces a structured report covering inventory, risk distribution, attestation
#       rates, dormant app cleanup, and permission governance metrics
# REQUIRES: Microsoft.Graph module v2.x
#           Scopes: Application.Read.All, AuditLog.Read.All

Connect-MgGraph -Scopes "Application.Read.All","AuditLog.Read.All"

$reportDate = Get-Date -Format "yyyy-MM-dd"
$quarterLabel = "Q" + [math]::Ceiling((Get-Date).Month / 3) + " " + (Get-Date).Year

# --- Section 1: App Inventory Summary ---
$allSPs = Get-MgServicePrincipal -All -Property Id,DisplayName,`
    AccountEnabled,VerifiedPublisher,AppId

$inventorySummary = [PSCustomObject]@{
    TotalApps             = $allSPs.Count
    EnabledApps           = ($allSPs | Where-Object AccountEnabled).Count
    DisabledApps          = ($allSPs | Where-Object { -not $_.AccountEnabled }).Count
    VerifiedPublisherApps = ($allSPs | Where-Object { $_.VerifiedPublisher.DisplayName }).Count
    UnverifiedApps        = ($allSPs | Where-Object { -not $_.VerifiedPublisher.DisplayName }).Count
}

# --- Section 2: Permission Governance Metrics ---
$highRiskScopes = @("Mail.ReadWrite","Files.ReadWrite.All","User.ReadWrite.All",
                    "Directory.ReadWrite.All","Sites.ReadWrite.All")
$appsWithHighPerms = 0
foreach ($sp in $allSPs | Select-Object -First 200) {
    $grants = Get-MgServicePrincipalOauth2PermissionGrant `
        -ServicePrincipalId $sp.Id -ErrorAction SilentlyContinue
    $scopes = ($grants | ForEach-Object { $_.Scope -split " " }) | Sort-Object -Unique
    if ($scopes | Where-Object { $_ -in $highRiskScopes }) { $appsWithHighPerms++ }
}

# --- Section 3: Consent Activity (last 90 days) ---
# Note: Consent audit data is queried separately via KQL/Advanced Hunting

# --- Section 4: Build the report ---
$report = @"
========================================
  APP GOVERNANCE COMPLIANCE REPORT
  $quarterLabel | Generated: $reportDate
========================================

1. APP INVENTORY
   Total apps in tenant:       $($inventorySummary.TotalApps)
   Enabled / Active:           $($inventorySummary.EnabledApps)
   Disabled / Blocked:         $($inventorySummary.DisabledApps)
   Verified publisher:         $($inventorySummary.VerifiedPublisherApps)
   Unverified publisher:       $($inventorySummary.UnverifiedApps)

2. PERMISSION GOVERNANCE
   Apps with high-risk perms:  $appsWithHighPerms
   High-risk scope coverage:   $($highRiskScopes -join ', ')

3. COMPLIANCE CONTROL MAPPING
   SOC 2 CC6.1 (Logical Access)  - Admin consent workflow enabled
   SOC 2 CC6.3 (Access Removal)  - Dormant app cleanup active
   ISO 27001 A.9.2.5 (Review)    - Quarterly access review scheduled
   ISO 27001 A.9.4.1 (Restrict)  - Publisher verification enforced
   NIST CSF PR.AC-1 (Identities)  - App inventory maintained

4. RECOMMENDATIONS
   - Review $($inventorySummary.UnverifiedApps) unverified publisher apps
   - Investigate $appsWithHighPerms apps with high-risk permissions
   - Complete attestation cycle for current quarter
"@

# Save report
$reportPath = "AppGov-ComplianceReport-$quarterLabel-$reportDate.txt"
$report | Out-File $reportPath -Encoding UTF8
Write-Host $report
Write-Host "`nReport saved to: $reportPath" -ForegroundColor Green
💡 Pro Tip: Automate this report on a quarterly schedule via Azure Automation and email it to your compliance distribution list using Send-MgUserMail. Include trend data (comparing current quarter to previous) to demonstrate continuous improvement - auditors value evidence of a maturing program over point-in-time perfection.

Step 10 Β· Design Ongoing Governance Program

  • Monthly: Review new app consents, monitor threat alerts, clean up dormant apps
  • Quarterly: Conduct access reviews, generate compliance reports, review policies
  • Annually: Full app estate audit, attestation cycle, program maturity assessment

πŸ“š Documentation Resources

ResourceDescription
App Governance OverviewManage and govern OAuth apps with App Governance in Defender for Cloud Apps
Admin Consent WorkflowConfigure the admin consent workflow in Microsoft Entra ID
App Credential ManagementBest practices for managing application credentials and certificate rotation
Microsoft Graph - Service PrincipalsProgrammatically manage service principals, permissions, and consent grants
Entra ID Access ReviewsPlan and deploy access reviews for applications and service principals
AuditLogs Table ReferenceSchema reference for Entra ID audit logs used in consent and permission queries

Summary

What You Accomplished

  • Audited and categorised the entire OAuth app estate
  • Created permission governance policies and admin consent workflows
  • Implemented quarterly access reviews and owner attestation
  • Set up dormant app detection and automatic cleanup
  • Built compliance dashboards and generated audit reports

Next Steps

← Previous Lab Next Lab β†’