> ## Documentation Index
> Fetch the complete documentation index at: https://docs.contraforce.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Auditing Enterprise App Permissions

> Independently verify the permissions granted to ContraForce enterprise applications in your Microsoft Entra ID tenant using read-only audit scripts.

<Info>
  **Who is this for?** IT administrators, security teams, and change advisory board (CAB) reviewers who need to independently verify the permissions granted to ContraForce enterprise applications before or after onboarding.
</Info>

## Overview

ContraForce provides two audit scripts that enumerate the permissions granted to ContraForce enterprise applications in your Microsoft Entra ID tenant. Both scripts produce an identical JSON output that you can compare against the [Enterprise Applications Reference](/guides/technical/enterprise-applications) to verify that only the documented permissions are in place.

Choose the script that fits your environment:

|                      | PowerShell                                      | Python                                           |
| -------------------- | ----------------------------------------------- | ------------------------------------------------ |
| **Best for**         | Microsoft-native environments                   | Cross-platform / CLI-first teams                 |
| **Runtime**          | PowerShell 7.0+ (`pwsh`)                        | Python 3.10+                                     |
| **Auth method**      | Microsoft Graph PowerShell SDK                  | Azure CLI (`az login`)                           |
| **Dependencies**     | `Microsoft.Graph` modules (Microsoft-published) | `azure-identity` (Microsoft-published) + `httpx` |
| **Government cloud** | `-Cloud AzureUSGovernment`                      | `-c AzureUSGovernment`                           |

<Warning>
  These scripts are **read-only**. They do not create, modify, or delete any objects in your tenant. All API calls are HTTP `GET` requests to Microsoft Graph.
</Warning>

## What the Scripts Access

Both scripts make read-only queries to the Microsoft Graph REST API. The specific endpoint depends on your cloud environment:

| Environment      | Graph API Base                     | Identity Platform                   |
| ---------------- | ---------------------------------- | ----------------------------------- |
| Commercial / GCC | `https://graph.microsoft.com/v1.0` | `https://login.microsoftonline.com` |
| GCC High / DoD   | `https://graph.microsoft.us/v1.0`  | `https://login.microsoftonline.us`  |

No other endpoints are contacted beyond the Microsoft identity platform for token acquisition.

| Graph API Endpoint                                   | Purpose                                                                             |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `GET /me`                                            | Resolve the authenticated operator's identity (UPN and object ID)                   |
| `GET /organization`                                  | Resolve the tenant ID                                                               |
| `GET /servicePrincipals`                             | Look up ContraForce and Microsoft resource API service principals by name or app ID |
| `GET /servicePrincipals/{id}/oauth2PermissionGrants` | Read delegated permission grants for each application                               |
| `GET /servicePrincipals/{id}/appRoleAssignments`     | Read application permission assignments for each application                        |

### Data in the Output File

The output JSON contains:

* **Application names and app IDs** for each ContraForce enterprise application
* **Permission names and descriptions** resolved to human-readable values
* **The resource API** each permission targets (Microsoft Graph, WindowsDefenderATP, etc.)
* **Metadata**: timestamp, tenant ID, cloud environment, authenticated operator, and tool version

The output does not contain secrets, tokens, or user data beyond the operator identifier. In government cloud environments, the operator's UPN is automatically redacted and replaced with their Entra object ID (an opaque GUID). See [Output File Access Control](#output-file-access-control) for handling guidance.

## Applications Audited

In **commercial (AzureCloud)** environments, both scripts use built-in app IDs that match the [Quick Reference](/guides/technical/enterprise-applications#quick-reference) table:

| Application                        | App ID                                 |
| ---------------------------------- | -------------------------------------- |
| ContraForce API                    | `24d97bc0-8f2b-45d5-8e0b-7fe286732ef2` |
| ContraForce Portal                 | `8b7cb435-9526-47ee-b79a-34433f0daad2` |
| ContraForce for MDE                | `6efccc6a-f0d3-49e5-92d0-17d4afa9ba52` |
| ContraForce Gamebooks for MDE      | `ad7b0e79-3c37-4408-bf8f-eb89522cc920` |
| ContraForce Gamebooks for Identity | `36b0d51c-4c0f-4810-9cc4-bfbd40c7dd4a` |
| ContraForce Gamebooks for Email    | `44dbf6fe-45e3-48a3-bac3-f8d4cf1dba6d` |
| ContraForce Sentinel Hunting       | `6bf1c74d-7ade-4671-a507-166936f89a1f` |
| ContraForce User Management        | `460b65b7-3a5e-4a2c-98d0-e48fd35374a9` |

You can verify these app IDs in the **Microsoft Entra Admin Center** under **Enterprise Applications** before running the scripts.

<Warning>
  **Government cloud environments** use different app IDs. Contact [support@contraforce.com](mailto:support@contraforce.com) to obtain the app IDs for your environment. Pass them to the script via `-AppsFile` (PowerShell) or `--apps-file` (Python) — a JSON file containing an array of objects with `Name`/`AppId` (PowerShell) or `name`/`app_id` (Python) fields.
</Warning>

## Prerequisites

<Tabs>
  <Tab title="PowerShell">
    **Runtime:** [PowerShell 7.0+](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) (`pwsh`, not Windows PowerShell 5.1)

    **Microsoft Graph modules:**

    ```powershell theme={null}
    Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Applications -Scope CurrentUser
    ```

    **Required Graph scopes:** `Application.Read.All` and `Directory.Read.All`

    **Authenticate before running:**

    ```powershell theme={null}
    # Commercial / GCC (-Cloud AzureCloud, the default)
    Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All"

    # GCC High (-Cloud AzureUSGovernment)
    Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All" -Environment USGov

    # DoD (-Cloud AzureUSGovernment)
    Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All" -Environment USGovDoD
    ```

    For environments without a browser, use device code flow:

    ```powershell theme={null}
    Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All" -UseDeviceCode
    ```
  </Tab>

  <Tab title="Python">
    **Runtime:** [Python 3.10+](https://devguide.python.org/versions/) (older versions will produce a clear error message)

    **Azure CLI:** [Install the Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) and sign in:

    ```bash theme={null}
    # Commercial / GCC
    az login

    # GCC High / DoD
    az cloud set --name AzureUSGovernment
    az login
    ```

    **Python packages:**

    ```bash theme={null}
    pip install azure-identity httpx
    ```

    The script uses `AzureCliCredential` from the Microsoft-published `azure-identity` library, which reuses your existing `az login` session. The `httpx` library handles HTTP communication with the Graph API.

    <Tip>
      For stricter environments, pin exact versions: `pip install azure-identity==1.19.0 httpx==0.28.1`. If your organization uses an internal package mirror, install from that source instead of public PyPI.
    </Tip>
  </Tab>
</Tabs>

### Minimum Permissions

The account running the script needs read access to service principals and their permission grants. The minimum Microsoft Entra ID role is **Directory Reader**, or you can grant the following Microsoft Graph API permissions directly:

| Graph Permission       | Type      | Purpose                                               |
| ---------------------- | --------- | ----------------------------------------------------- |
| `Application.Read.All` | Delegated | Read service principal properties and app roles       |
| `Directory.Read.All`   | Delegated | Read delegated permission grants and role assignments |

These are **read-only** permissions. They do not grant the ability to modify applications, permissions, or any other tenant objects.

## Running the Scripts

<Tabs>
  <Tab title="PowerShell">
    ```powershell theme={null}
    # Commercial (default)
    pwsh -File Audit-EnterpriseApps.ps1

    # Custom output path
    pwsh -File Audit-EnterpriseApps.ps1 -OutputPath ./audit-2026-02-12.json

    # Government (GCC High / DoD) — requires -AppsFile
    pwsh -File Audit-EnterpriseApps.ps1 -Cloud AzureUSGovernment -AppsFile ./gov-apps.json
    ```

    The `-Cloud` parameter value must match the cloud you authenticated to with `Connect-MgGraph -Environment`. The script validates this and exits with an error if there is a mismatch. Government environments require `-AppsFile` because app IDs differ from commercial.
  </Tab>

  <Tab title="Python">
    ```bash theme={null}
    # Commercial (default)
    python audit_enterprise_apps.py

    # Custom output path
    python audit_enterprise_apps.py -o audit-2026-02-12.json

    # Government (GCC High / DoD) — requires --apps-file
    python audit_enterprise_apps.py -c AzureUSGovernment -a gov-apps.json
    ```

    The `-c` flag must match the Azure CLI cloud set via `az cloud set --name`. Government environments require `az cloud set --name AzureUSGovernment` before `az login` and `--apps-file` (`-a`) because app IDs differ from commercial.
  </Tab>
</Tabs>

Both scripts display progress as they resolve permissions and will report a summary when complete. A **non-zero exit code** means one or more applications were not found in the tenant — this is expected if you haven't consented all ContraForce applications (for example, Sentinel Hunting is only required for XDR + SIEM deployments).

## Government Cloud Environments (GCC High / DoD)

Both scripts support Microsoft Azure Government cloud environments used by public sector organizations subject to CMMC, FedRAMP, or ITAR requirements.

Both scripts use the same `-Cloud` / `-c` parameter values, which match the `az cloud set --name` values:

| Cloud                       | Value               | PowerShell                 | Python                 |
| --------------------------- | ------------------- | -------------------------- | ---------------------- |
| Commercial (default)        | `AzureCloud`        | `-Cloud AzureCloud`        | `-c AzureCloud`        |
| Government (GCC High / DoD) | `AzureUSGovernment` | `-Cloud AzureUSGovernment` | `-c AzureUSGovernment` |

When running in a government cloud environment, the scripts automatically:

1. **Use the correct Graph endpoint** — `graph.microsoft.us` instead of `graph.microsoft.com`
2. **Redact the operator's UPN** — Records the Entra object ID (an opaque GUID) instead of the User Principal Name in the `generatedBy` metadata field
3. **Restrict output file permissions** — Limits file access to the current user only (PowerShell: Windows ACL; Python: POSIX `chmod 600`)

You can also redact the UPN in commercial environments using the `-RedactUPN` switch (PowerShell) or `--redact-upn` flag (Python).

<Warning>
  The script validates that the connected session matches the requested cloud environment. If you specify `-Cloud AzureUSGovernment` but are connected to a commercial Graph session, the script will exit with an error and instructions to reconnect.
</Warning>

## Audit Scripts

<CodeGroup>
  ```powershell Audit-EnterpriseApps.ps1 icon="terminal" expandable theme={null}
  #Requires -Version 7.0
  #Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Applications

  <#
  .SYNOPSIS
      Audits ContraForce enterprise application permissions in the current Microsoft Entra ID tenant.

  .DESCRIPTION
      Queries Microsoft Graph for all documented ContraForce enterprise applications,
      resolves their delegated and application permissions to human-readable names,
      and outputs a structured JSON file suitable for diffing against documentation.

      Supports Commercial, GCC High, and DoD cloud environments.

      Requires:
      - Microsoft.Graph PowerShell modules:
          Install-Module Microsoft.Graph.Applications -Scope CurrentUser
      - An authenticated Microsoft Graph session with Directory.Read.All:
          Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All"
          Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All" -Environment USGov
      - The target tenant must have the ContraForce enterprise applications consented

  .PARAMETER OutputPath
      Path for the output JSON file. Defaults to enterprise-apps-audit.json in the current directory.

  .PARAMETER Cloud
      The Microsoft cloud environment to audit. Uses the same values as
      'az cloud set --name': AzureCloud (Commercial) or AzureUSGovernment
      (GCC High / DoD). Defaults to AzureCloud.

  .PARAMETER AppsFile
      Path to a JSON file listing the applications to audit. Each entry must have
      'Name' and 'AppId' fields. Required for government cloud environments where
      app IDs differ from commercial. Contact support@contraforce.com for your
      environment's app IDs.

  .PARAMETER RedactUPN
      When set, records the operator's Entra object ID instead of the User Principal Name
      in the generatedBy metadata field. Automatically enabled for AzureUSGovernment
      to avoid recording PII in audit artifacts.

  .EXAMPLE
      pwsh -File Audit-EnterpriseApps.ps1
      pwsh -File Audit-EnterpriseApps.ps1 -OutputPath ./audit-2026-02-12.json
      pwsh -File Audit-EnterpriseApps.ps1 -Cloud AzureUSGovernment -AppsFile ./gov-apps.json -OutputPath ./audit-gov.json
  #>

  param(
      [string]$OutputPath = "enterprise-apps-audit.json",

      [ValidateSet("AzureCloud", "AzureUSGovernment")]
      [string]$Cloud = "AzureCloud",

      [string]$AppsFile,

      [switch]$RedactUPN
  )

  $ErrorActionPreference = "Stop"

  # ── Cloud environment metadata ───────────────────────────────────────────────
  $EnvironmentNames = @{
      "AzureCloud"        = "Commercial"
      "AzureUSGovernment" = "US Government (GCC High / DoD)"
  }

  # Maps our -Cloud parameter values to the Connect-MgGraph -Environment values
  # that Microsoft Graph PowerShell SDK uses internally.  GCC High uses "USGov"
  # and DoD uses "USGovDoD" — both map to our single "AzureUSGovernment" value.
  $ValidMgEnvironments = @{
      "AzureCloud"        = @("Global")
      "AzureUSGovernment" = @("USGov", "USGovDoD")
  }

  # ── Pre-flight: verify Microsoft Graph authentication ─────────────────────────
  $context = Get-MgContext
  if (-not $context) {
      $suggestedEnv = $ValidMgEnvironments[$Cloud][0]
      $envFlag = if ($Cloud -ne "AzureCloud") {
          " -Environment $suggestedEnv"
      } else { "" }
      Write-Error ("No active Microsoft Graph session. Connect first:`n" +
          "  Connect-MgGraph -Scopes 'Application.Read.All','Directory.Read.All'$envFlag`n" +
          "  Connect-MgGraph -Scopes 'Application.Read.All','Directory.Read.All'$envFlag -UseDeviceCode")
      exit 1
  }

  # Validate the connected environment matches the requested one.
  # Connect-MgGraph -Environment uses "Global", "USGov", "USGovDoD" internally,
  # so we check against the valid set for the -Cloud value the operator chose.
  if ($context.Environment -notin $ValidMgEnvironments[$Cloud]) {
      $validList = $ValidMgEnvironments[$Cloud] -join "' or '"
      Write-Error ("Environment mismatch: connected to '$($context.Environment)' " +
          "but -Cloud '$Cloud' expects '$validList'.`n" +
          "Disconnect and reconnect to the correct environment:`n" +
          "  Disconnect-MgGraph`n" +
          "  Connect-MgGraph -Scopes 'Application.Read.All','Directory.Read.All' -Environment $($ValidMgEnvironments[$Cloud][0])")
      exit 1
  }

  # Resolve operator identity via Graph API (avoids parsing access tokens)
  $meResponse = Invoke-MgGraphRequest -Uri '/me?$select=id,userPrincipalName' -Method GET
  $operatorOid = $meResponse.id
  $operatorUpn = $meResponse.userPrincipalName

  # Auto-enable UPN redaction for government environments
  $effectiveRedact = $RedactUPN.IsPresent -or ($Cloud -ne "AzureCloud")
  $generatedBy = if ($effectiveRedact) { $operatorOid } else { $operatorUpn }

  $envDisplayName = $EnvironmentNames[$Cloud]
  Write-Host "Environment: $envDisplayName ($Cloud)" -ForegroundColor Green
  Write-Host "Authenticated as: $generatedBy (Tenant: $($context.TenantId))" -ForegroundColor Green

  # ── ContraForce enterprise applications to audit ──────────────────────────────
  # App IDs differ by cloud environment.  The commercial IDs are built-in;
  # government cloud app IDs are provided by ContraForce upon request and
  # passed via -AppsFile.
  if ($AppsFile) {
      $AppsToAudit = Get-Content $AppsFile -Raw | ConvertFrom-Json
  } elseif ($Cloud -eq "AzureCloud") {
      $AppsToAudit = @(
          @{ Name = "ContraForce API"; AppId = "24d97bc0-8f2b-45d5-8e0b-7fe286732ef2" }
          @{ Name = "ContraForce Portal"; AppId = "8b7cb435-9526-47ee-b79a-34433f0daad2" }
          @{ Name = "ContraForce for MDE"; AppId = "6efccc6a-f0d3-49e5-92d0-17d4afa9ba52" }
          @{ Name = "ContraForce Gamebooks for MDE"; AppId = "ad7b0e79-3c37-4408-bf8f-eb89522cc920" }
          @{ Name = "ContraForce Gamebooks for Identity"; AppId = "36b0d51c-4c0f-4810-9cc4-bfbd40c7dd4a" }
          @{ Name = "ContraForce Gamebooks for Email"; AppId = "44dbf6fe-45e3-48a3-bac3-f8d4cf1dba6d" }
          @{ Name = "ContraForce Sentinel Hunting"; AppId = "6bf1c74d-7ade-4671-a507-166936f89a1f" }
          @{ Name = "ContraForce User Management"; AppId = "460b65b7-3a5e-4a2c-98d0-e48fd35374a9" }
      )
  } else {
      Write-Error ("Government cloud environments require -AppsFile.`n" +
          "App IDs differ by cloud environment. Contact support@contraforce.com`n" +
          "to obtain the app IDs for your environment.")
      exit 1
  }

  # ── Well-known first-party resource APIs ──────────────────────────────────────
  # Maps Entra object IDs → friendly names for the Microsoft APIs that ContraForce
  # integrates with. Object IDs are tenant-local so we resolve them at runtime.
  $ResourceAPIs = @(
      "Microsoft Graph"
      "Windows Azure Service Management API"
      "WindowsDefenderATP"
      "Microsoft Threat Protection"
      "Log Analytics API"
  )

  # ── Friendly display names for resource APIs ──────────────────────────────────
  $FriendlyNames = @{
      "Windows Azure Service Management API" = "Azure Service Management"
  }

  # ── Permission lookup tables ─────────────────────────────────────────────────
  $AppRoleLookup = @{}       # resourceSPId -> { roleId -> { name, description } }
  $DescByNameLookup = @{}    # resourceSPId -> { scopeName -> description }
  $ResourceNameById = @{}    # resourceSPId -> friendly name

  function Register-ServicePrincipal {
      param(
          [Parameter(Mandatory)]$Sp,
          [string]$FriendlyName
      )
      $id = $Sp.Id
      $ResourceNameById[$id] = if ($FriendlyName) { $FriendlyName } else { $Sp.DisplayName }

      $roles = @{}
      foreach ($role in $Sp.AppRoles) {
          $roles[$role.Id] = @{ name = $role.Value; description = $role.Description }
      }
      $AppRoleLookup[$id] = $roles

      $descByName = @{}
      foreach ($scope in $Sp.Oauth2PermissionScopes) {
          $descByName[$scope.Value] = $scope.AdminConsentDescription
      }
      $DescByNameLookup[$id] = $descByName
  }

  # ── Resolve resource API service principals ───────────────────────────────────
  Write-Host "Resolving resource API service principals..." -ForegroundColor Cyan

  foreach ($apiName in $ResourceAPIs) {
      $sp = Get-MgServicePrincipal -Filter "displayName eq '$apiName'" `
          -Property Id, DisplayName, AppRoles, Oauth2PermissionScopes -Top 1
      if (-not $sp) {
          Write-Warning "Resource API not found in tenant: $apiName"
          continue
      }

      $friendly = if ($FriendlyNames.ContainsKey($sp.DisplayName)) {
          $FriendlyNames[$sp.DisplayName]
      } else { $sp.DisplayName }
      Register-ServicePrincipal -Sp $sp -FriendlyName $friendly

      Write-Host "  Resolved: $friendly ($($sp.Id)) — $($AppRoleLookup[$sp.Id].Count) app roles, $($DescByNameLookup[$sp.Id].Count) delegated scopes"
  }

  # ── Also index ContraForce apps themselves (they expose internal scopes) ──────
  # Why startswith('ContraForce')?
  # Some ContraForce apps delegate to each other via custom oauth2 scopes (e.g. the
  # Portal app exposes scopes consumed by the API app). To resolve these internal
  # cross-app scopes to human-readable names instead of raw GUIDs, we index ALL
  # service principals whose display name starts with "ContraForce" — not just the
  # 8 customer-facing apps listed in $AppsToAudit. Permissions that originate from
  # these internal apps are tagged with "internal": true in the output so auditors
  # can distinguish them from Microsoft first-party API permissions.
  $AllCFServicePrincipals = Get-MgServicePrincipal `
      -Filter "startswith(displayName, 'ContraForce')" `
      -Property Id, DisplayName, AppRoles, Oauth2PermissionScopes -All
  foreach ($cfSp in $AllCFServicePrincipals) {
      if (-not $ResourceNameById.ContainsKey($cfSp.Id)) {
          Register-ServicePrincipal -Sp $cfSp
      }
  }

  # ── Audit each application ───────────────────────────────────────────────────
  $results = [System.Collections.Generic.List[object]]::new()

  foreach ($app in $AppsToAudit) {
      Write-Host "`nAuditing: $($app.Name) ($($app.AppId))..." -ForegroundColor Cyan

      # Find the service principal in this tenant
      $sp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" `
          -Property Id, DisplayName, AppId -Top 1
      if (-not $sp) {
          Write-Warning "  NOT FOUND in tenant — skipping"
          $results.Add([ordered]@{
                  applicationName        = $app.Name
                  appId                  = $app.AppId
                  status                 = "NOT_FOUND"
                  delegatedPermissions   = @()
                  applicationPermissions = @()
              })
          continue
      }
      $spId = $sp.Id

      # ── Delegated permissions (oauth2PermissionGrants) ────────────────────────
      $grants = Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $spId -All
      $delegated = [System.Collections.Generic.List[object]]::new()

      foreach ($grant in $grants) {
          $resourceId = $grant.ResourceId
          $resourceName = if ($ResourceNameById.ContainsKey($resourceId)) {
              $ResourceNameById[$resourceId]
          } else { $resourceId }
          $isInternal = $resourceName -like "ContraForce *"
          $scopeNames = ($grant.Scope -split ' ') | Where-Object { $_ -ne '' } | Sort-Object

          foreach ($scope in $scopeNames) {
              $desc = if ($DescByNameLookup.ContainsKey($resourceId) -and
                          $DescByNameLookup[$resourceId].ContainsKey($scope)) {
                  $DescByNameLookup[$resourceId][$scope]
              } else { $null }

              $delegated.Add([ordered]@{
                      permission  = $scope
                      api         = $resourceName
                      type        = "Delegated"
                      description = $desc
                      internal    = $isInternal
                  })
          }
      }

      # ── Application permissions (appRoleAssignments) ──────────────────────────
      $assignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $spId -All
      $appPerms = [System.Collections.Generic.List[object]]::new()

      foreach ($assignment in $assignments) {
          $resourceName = $assignment.ResourceDisplayName
          $resourceId = $assignment.ResourceId
          $roleId = $assignment.AppRoleId

          # Resolve the role ID to a permission name and description
          $permName = $roleId
          $desc = $null
          if ($AppRoleLookup.ContainsKey($resourceId)) {
              $lookup = $AppRoleLookup[$resourceId]
              if ($lookup.ContainsKey($roleId)) {
                  $permName = $lookup[$roleId].name
                  $desc = $lookup[$roleId].description
              }
          }

          # Use friendly resource name if available
          $friendlyResource = if ($ResourceNameById.ContainsKey($resourceId)) {
              $ResourceNameById[$resourceId]
          } else { $resourceName }

          $appPerms.Add([ordered]@{
                  permission  = $permName
                  api         = $friendlyResource
                  type        = "Application"
                  description = $desc
              })
      }

      # Sort for stable output
      $sortedDelegated = $delegated | Sort-Object { $_.api }, { $_.permission }
      $sortedAppPerms = $appPerms  | Sort-Object { $_.api }, { $_.permission }

      $results.Add([ordered]@{
              applicationName        = $sp.DisplayName
              appId                  = $sp.AppId
              status                 = "OK"
              delegatedPermissions   = @($sortedDelegated)
              applicationPermissions = @($sortedAppPerms)
          })

      Write-Host "  Delegated: $($delegated.Count) | Application: $($appPerms.Count)"
  }

  # ── Build output document ────────────────────────────────────────────────────
  $output = [ordered]@{
      metadata     = [ordered]@{
          generatedAt  = (Get-Date -Format "o")
          tenantId     = $context.TenantId
          environment  = $Cloud
          generatedBy  = $generatedBy
          toolVersion  = "2.1.0"
          description  = "ContraForce enterprise application permissions snapshot for documentation auditing."
      }
      applications = @($results)
  }

  $fullPath = [System.IO.Path]::GetFullPath($OutputPath)
  $json = $output | ConvertTo-Json -Depth 10
  # Normalize to LF line endings
  $json = $json -replace "`r`n", "`n"
  [System.IO.File]::WriteAllText(
      $fullPath,
      "$json`n",
      [System.Text.UTF8Encoding]::new($false)
  )

  # ── Restrict file permissions for government environments ─────────────────────
  # In GCC High and DoD environments, restrict the output file so only the current
  # user can read/write it. This prevents other accounts on shared jump boxes from
  # accessing tenant permission data.
  if ($Cloud -ne "AzureCloud") {
      try {
          $acl = Get-Acl -Path $fullPath
          $acl.SetAccessRuleProtection($true, $false)  # disable inheritance, remove inherited rules
          $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
          $rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
              $currentUser,
              [System.Security.AccessControl.FileSystemRights]::FullControl,
              [System.Security.AccessControl.AccessControlType]::Allow
          )
          $acl.SetAccessRule($rule)
          Set-Acl -Path $fullPath -AclObject $acl
          Write-Host "  File permissions restricted to current user." -ForegroundColor Yellow
      } catch {
          Write-Warning "Could not restrict file permissions on '$fullPath': $_"
      }
  }

  Write-Host "`nAudit complete. Output written to: $OutputPath" -ForegroundColor Green
  Write-Host "Environment: $envDisplayName"
  Write-Host "Applications audited: $($results.Count)"
  Write-Host "Tenant: $($context.TenantId)"

  # ── Exit with non-zero code if any apps were not found ────────────────────────
  $failures = @($results | Where-Object { $_.status -eq "NOT_FOUND" })
  if ($failures.Count -gt 0) {
      Write-Warning "$($failures.Count) application(s) were not found in the tenant."
      exit 1
  }
  ```

  ```python audit_enterprise_apps.py icon="python" expandable theme={null}
  #!/usr/bin/env python3
  """Audit ContraForce enterprise application permissions in Microsoft Entra ID.

  Queries Microsoft Graph for all documented ContraForce enterprise applications,
  resolves their delegated and application permissions to human-readable names,
  and outputs a structured JSON file suitable for diffing against documentation.

  Requires:
      - Python 3.10+ (older versions will produce a clear version error).
        See https://devguide.python.org/versions/ for supported Python versions.
      - ``azure-identity>=1.19`` and ``httpx>=0.28``::

          pip install azure-identity httpx

      - Azure CLI (``az login``) session — used by ``AzureCliCredential`` for token acquisition
      - The target tenant must have the ContraForce enterprise applications consented

  Usage:
      python audit_enterprise_apps.py
      python audit_enterprise_apps.py -o audit-2026-02-12.json
      python audit_enterprise_apps.py -c AzureUSGovernment
      python audit_enterprise_apps.py -c AzureUSGovernment -o gov-audit.json
  """

  import argparse
  import dataclasses
  import importlib.util
  import json
  import os
  import stat
  import sys
  import time
  from datetime import datetime, timezone
  from typing import Any

  # ── Requires: Python 3.10+, azure-identity>=1.19, httpx>=0.28 ────────────────
  # Mirrors PowerShell's #Requires — validate before any third-party import.
  if sys.version_info < (3, 10):  # noqa: UP036
      print(
          f"Python 3.10+ is required (running {sys.version.split()[0]})",
          file=sys.stderr,
      )
      raise SystemExit(1)

  _REQUIRED_PACKAGES = [
      ("httpx", "httpx>=0.28"),
      ("azure.identity", "azure-identity>=1.19"),
  ]
  _missing = [spec for mod, spec in _REQUIRED_PACKAGES if importlib.util.find_spec(mod) is None]
  if _missing:
      print(
          f"Missing required packages: {', '.join(_missing)}\n"
          "Install via: pip install azure-identity httpx",
          file=sys.stderr,
      )
      raise SystemExit(1)
  del _REQUIRED_PACKAGES, _missing

  import httpx  # noqa: E402
  from azure.core.exceptions import ClientAuthenticationError  # noqa: E402
  from azure.identity import AzureCliCredential, CredentialUnavailableError  # noqa: E402

  TOOL_VERSION = "2.1.0"

  # ── Cloud environment endpoints ───────────────────────────────────────────────
  # Microsoft Graph endpoints differ by cloud environment.  Commercial (including
  # GCC) uses graph.microsoft.com; GCC High and DoD use graph.microsoft.us.
  # See: https://learn.microsoft.com/en-us/graph/deployments
  CLOUD_ENVIRONMENTS: dict[str, dict[str, str]] = {
      "AzureCloud": {
          "graph_base": "https://graph.microsoft.com/v1.0",
          "graph_scope": "https://graph.microsoft.com/.default",
          "name": "Commercial",
      },
      "AzureUSGovernment": {
          "graph_base": "https://graph.microsoft.us/v1.0",
          "graph_scope": "https://graph.microsoft.us/.default",
          "name": "US Government (GCC High / DoD)",
      },
  }

  # ContraForce enterprise applications to audit (Commercial / AzureCloud only).
  # App IDs differ by cloud environment.  Government cloud app IDs are provided
  # by ContraForce upon request and passed via --apps-file.
  COMMERCIAL_APPS = [
      {"name": "ContraForce API", "app_id": "24d97bc0-8f2b-45d5-8e0b-7fe286732ef2"},
      {"name": "ContraForce Portal", "app_id": "8b7cb435-9526-47ee-b79a-34433f0daad2"},
      {"name": "ContraForce for MDE", "app_id": "6efccc6a-f0d3-49e5-92d0-17d4afa9ba52"},
      {"name": "ContraForce Gamebooks for MDE", "app_id": "ad7b0e79-3c37-4408-bf8f-eb89522cc920"},
      {
          "name": "ContraForce Gamebooks for Identity",
          "app_id": "36b0d51c-4c0f-4810-9cc4-bfbd40c7dd4a",
      },
      {
          "name": "ContraForce Gamebooks for Email",
          "app_id": "44dbf6fe-45e3-48a3-bac3-f8d4cf1dba6d",
      },
      {
          "name": "ContraForce Sentinel Hunting",
          "app_id": "6bf1c74d-7ade-4671-a507-166936f89a1f",
      },
      {
          "name": "ContraForce User Management",
          "app_id": "460b65b7-3a5e-4a2c-98d0-e48fd35374a9",
      },
  ]

  # Well-known first-party resource APIs that ContraForce integrates with.
  RESOURCE_APIS = [
      "Microsoft Graph",
      "Windows Azure Service Management API",
      "WindowsDefenderATP",
      "Microsoft Threat Protection",
      "Log Analytics API",
  ]

  # Friendly display names for resource APIs.
  FRIENDLY_NAMES: dict[str, str] = {
      "Windows Azure Service Management API": "Azure Service Management",
  }


  @dataclasses.dataclass
  class PermissionRegistry:
      """Lookup tables for resolving permission IDs to human-readable names."""

      app_roles: dict[str, dict[str, dict[str, str | None]]] = dataclasses.field(
          default_factory=dict,
      )
      delegated_scopes: dict[str, dict[str, dict[str, str | None]]] = dataclasses.field(
          default_factory=dict,
      )
      resource_names: dict[str, str] = dataclasses.field(default_factory=dict)
      _scope_desc_by_name: dict[str, dict[str, str | None]] = dataclasses.field(
          default_factory=dict, repr=False,
      )

      def index_service_principal(self, sp: dict, *, friendly_name: str | None = None) -> None:
          """Index a service principal's roles and scopes for later resolution."""
          sp_id = sp["id"]
          self.resource_names[sp_id] = friendly_name or sp["displayName"]

          self.app_roles[sp_id] = {
              role["id"]: {"name": role["value"], "description": role.get("description")}
              for role in sp.get("appRoles") or []
          }

          scopes_by_id: dict[str, dict[str, str | None]] = {}
          desc_by_name: dict[str, str | None] = {}
          for scope in sp.get("oauth2PermissionScopes") or []:
              desc = scope.get("adminConsentDescription")
              scopes_by_id[scope["id"]] = {"name": scope["value"], "description": desc}
              desc_by_name[scope["value"]] = desc

          self.delegated_scopes[sp_id] = scopes_by_id
          self._scope_desc_by_name[sp_id] = desc_by_name

      def resolve_scope_description(self, resource_id: str, scope_name: str) -> str | None:
          """Look up a delegated scope description by name."""
          return self._scope_desc_by_name.get(resource_id, {}).get(scope_name)


  class AuditAuthError(Exception):
      """Raised when Azure CLI authentication fails."""


  class GraphClient:
      """Lightweight Microsoft Graph client backed by httpx and AzureCliCredential."""

      def __init__(self, *, cloud: str = "AzureCloud") -> None:
          env = CLOUD_ENVIRONMENTS[cloud]
          self._graph_base = env["graph_base"]
          self._graph_scope = env["graph_scope"]

          try:
              self._credential = AzureCliCredential()
              self._token = self._credential.get_token(self._graph_scope)
          except (CredentialUnavailableError, ClientAuthenticationError) as e:
              raise AuditAuthError(
                  "Azure CLI is not authenticated. Run 'az login' first.\n"
                  "For government cloud: az cloud set --name AzureUSGovernment && az login"
              ) from e

          self._http = httpx.Client(
              timeout=httpx.Timeout(30.0, connect=10.0),
          )
          self._refresh_auth()

          # Resolve identity from Microsoft Graph API rather than decoding the
          # access token JWT on the client side.  Access tokens are intended for
          # the resource server, not the client — decoding them without signature
          # verification is architecturally incorrect and raises red flags in
          # security reviews.  Using /me and /organization is both correct and
          # eliminates the need for a JWT verification library.
          self._resolve_identity()

      def _resolve_identity(self) -> None:
          """Resolve authenticated user and tenant identity from Microsoft Graph."""
          me_resp = self._request_with_retry(
              f"{self._graph_base}/me",
              params={"$select": "id,userPrincipalName"},
          )
          if me_resp is not None:
              me = me_resp.json()
              self.user_upn: str = me.get("userPrincipalName", "")
              self.user_oid: str = me.get("id", "")
          else:
              self.user_upn = ""
              self.user_oid = ""
              print(
                  "  WARNING: Could not resolve user identity from /me.",
                  file=sys.stderr,
              )

          org_resp = self._request_with_retry(
              f"{self._graph_base}/organization",
              params={"$select": "id"},
          )
          if org_resp is not None:
              orgs = org_resp.json().get("value", [])
              self.tenant_id: str = orgs[0]["id"] if orgs else ""
          else:
              self.tenant_id = ""
              print(
                  "  WARNING: Could not resolve tenant from /organization.",
                  file=sys.stderr,
              )

      # NOTE: Remove quotes when minimum version is Python 3.14+ (PEP 649).
      def __enter__(self) -> "GraphClient":  # quoted: class name isn't bound yet
          return self

      def __exit__(self, *exc: object) -> None:
          self._http.close()

      def _refresh_auth(self) -> None:
          """Refresh the bearer token if it is within 5 minutes of expiry."""
          if self._token.expires_on - time.time() < 300:
              self._token = self._credential.get_token(self._graph_scope)
          self._http.headers["Authorization"] = f"Bearer {self._token.token}"

      def _request_with_retry(
          self, url: str, params: dict[str, str] | None = None, *, max_retries: int = 3,
      ) -> httpx.Response | None:
          """GET with retry and 429/Retry-After handling."""
          for attempt in range(max_retries + 1):
              self._refresh_auth()
              try:
                  resp = self._http.get(url, params=params)
                  if resp.status_code == 429:
                      try:
                          retry_after = int(resp.headers.get("Retry-After", 2**attempt))
                      except ValueError:
                          retry_after = 2**attempt
                      print(f"  Throttled, retrying in {retry_after}s...", file=sys.stderr)
                      time.sleep(retry_after)
                      continue
                  resp.raise_for_status()
                  return resp
              except httpx.HTTPError as e:
                  if attempt < max_retries:
                      time.sleep(2**attempt)
                      continue
                  print(
                      f"  WARNING: Graph call failed after {max_retries + 1} attempts: {url}\n"
                      f"  {e}",
                      file=sys.stderr,
                  )
                  return None
          return None

      def paginated_get(self, url: str, params: dict[str, str] | None = None) -> list[dict]:
          """GET with automatic @odata.nextLink pagination."""
          all_values: list[dict] = []
          current_url: str | None = url
          current_params = params
          while current_url:
              resp = self._request_with_retry(current_url, current_params)
              if resp is None:
                  print(
                      f"  WARNING: Pagination interrupted — returning {len(all_values)} "
                      f"partial result(s) for {url}",
                      file=sys.stderr,
                  )
                  break
              data = resp.json()
              all_values.extend(data.get("value", []))
              current_url = data.get("@odata.nextLink")
              current_params = None  # nextLink includes query params
          return all_values

      def list_service_principals(
          self, *, odata_filter: str, select: list[str],
      ) -> list[dict]:
          """Query /servicePrincipals with an OData filter."""
          return self.paginated_get(
              f"{self._graph_base}/servicePrincipals",
              params={"$filter": odata_filter, "$select": ",".join(select)},
          )

      def get_oauth2_permission_grants(self, sp_id: str) -> list[dict]:
          """Get delegated permission grants for a service principal."""
          return self.paginated_get(
              f"{self._graph_base}/servicePrincipals/{sp_id}/oauth2PermissionGrants",
          )

      def get_app_role_assignments(self, sp_id: str) -> list[dict]:
          """Get application permission assignments for a service principal."""
          return self.paginated_get(
              f"{self._graph_base}/servicePrincipals/{sp_id}/appRoleAssignments",
          )


  def resolve_resource_apis(graph: GraphClient, registry: PermissionRegistry) -> None:
      """Resolve resource API service principals and populate the registry."""
      print("\033[36mResolving resource API service principals...\033[0m")

      select = ["id", "displayName", "appRoles", "oauth2PermissionScopes"]

      for api_name in RESOURCE_APIS:
          results = graph.list_service_principals(
              odata_filter=f"displayName eq '{api_name}'",
              select=select,
          )
          if not results:
              print(f"  WARNING: Resource API not found in tenant: {api_name}", file=sys.stderr)
              continue

          sp = results[0]
          sp_id = sp["id"]
          friendly_name = FRIENDLY_NAMES.get(sp["displayName"], sp["displayName"])
          registry.index_service_principal(sp, friendly_name=friendly_name)

          print(
              f"  Resolved: {friendly_name} ({sp_id})"
              f" — {len(registry.app_roles[sp_id])} app roles,"
              f" {len(registry.delegated_scopes[sp_id])} delegated scopes"
          )


  def index_contraforce_apps(graph: GraphClient, registry: PermissionRegistry) -> None:
      """Index ContraForce apps for internal cross-app scope resolution.

      Some ContraForce applications delegate to each other via custom OAuth2
      scopes (e.g., the Portal delegates to the API).  This queries for ALL
      service principals whose displayName starts with ``ContraForce`` — not
      just the eight audited apps — so that cross-app scopes resolve to
      human-readable names instead of raw GUIDs in the output.

      This broader query does NOT grant any additional access; it only reads
      public service principal metadata visible to any authenticated directory
      reader.  Permissions returned from this query are tagged with
      ``"internal": true`` in the output to distinguish them from permissions
      that grant access to tenant data.
      """
      cf_sps = graph.list_service_principals(
          odata_filter="startswith(displayName, 'ContraForce')",
          select=["id", "displayName", "oauth2PermissionScopes"],
      )

      for cf_sp in cf_sps:
          if cf_sp["id"] not in registry.resource_names:
              registry.index_service_principal(cf_sp)


  def audit_delegated_permissions(
      graph: GraphClient,
      sp_id: str,
      registry: PermissionRegistry,
  ) -> list[dict[str, Any]]:
      """Query and resolve delegated permissions (oauth2PermissionGrants)."""
      grants = graph.get_oauth2_permission_grants(sp_id)
      delegated: list[dict[str, Any]] = []

      for grant in grants:
          resource_id = grant["resourceId"]
          resource_name = registry.resource_names.get(resource_id, resource_id)
          is_internal = resource_name.startswith("ContraForce ")
          scope_names = sorted(s for s in grant.get("scope", "").split() if s)

          for scope in scope_names:
              delegated.append({
                  "permission": scope,
                  "api": resource_name,
                  "type": "Delegated",
                  "description": registry.resolve_scope_description(resource_id, scope),
                  "internal": is_internal,
              })

      return sorted(delegated, key=lambda p: (p["api"], p["permission"]))


  def audit_application_permissions(
      graph: GraphClient,
      sp_id: str,
      registry: PermissionRegistry,
  ) -> list[dict[str, Any]]:
      """Query and resolve application permissions (appRoleAssignments)."""
      assignments = graph.get_app_role_assignments(sp_id)
      app_perms: list[dict[str, Any]] = []

      for assignment in assignments:
          resource_name = assignment.get("resourceDisplayName", "")
          resource_id = assignment["resourceId"]
          role_id = assignment["appRoleId"]

          # Resolve the role ID to a permission name and description
          role = registry.app_roles.get(resource_id, {}).get(role_id)
          perm_name = role["name"] if role else role_id
          desc = role["description"] if role else None

          app_perms.append({
              "permission": perm_name,
              "api": registry.resource_names.get(resource_id, resource_name),
              "type": "Application",
              "description": desc,
          })

      return sorted(app_perms, key=lambda p: (p["api"], p["permission"]))


  def audit_app(
      graph: GraphClient,
      app: dict[str, str],
      registry: PermissionRegistry,
  ) -> dict[str, Any]:
      """Audit a single enterprise application."""
      print(f"\n\033[36mAuditing: {app['name']} ({app['app_id']})...\033[0m")

      results = graph.list_service_principals(
          odata_filter=f"appId eq '{app['app_id']}'",
          select=["id", "displayName", "appId"],
      )
      if not results:
          print("  WARNING: NOT FOUND in tenant — skipping", file=sys.stderr)
          return {
              "applicationName": app["name"],
              "appId": app["app_id"],
              "status": "NOT_FOUND",
              "delegatedPermissions": [],
              "applicationPermissions": [],
          }

      sp = results[0]
      sp_id = sp["id"]

      delegated = audit_delegated_permissions(graph, sp_id, registry)
      app_perms = audit_application_permissions(graph, sp_id, registry)

      print(f"  Delegated: {len(delegated)} | Application: {len(app_perms)}")

      return {
          "applicationName": sp["displayName"],
          "appId": sp["appId"],
          "status": "OK",
          "delegatedPermissions": delegated,
          "applicationPermissions": app_perms,
      }


  def parse_args() -> argparse.Namespace:
      """Parse command-line arguments."""
      parser = argparse.ArgumentParser(
          description="Audit ContraForce enterprise application permissions in Microsoft Entra ID.",
      )
      parser.add_argument(
          "-o", "--output",
          default="enterprise-apps-audit.json",
          help="Path for the output JSON file (default: enterprise-apps-audit.json)",
      )
      parser.add_argument(
          "-c", "--cloud",
          choices=list(CLOUD_ENVIRONMENTS),
          default="AzureCloud",
          help=(
              "Cloud environment to audit: AzureCloud (default) or"
              " AzureUSGovernment (GCC High / DoD). Must match the environment"
              " set with 'az cloud set --name <value>'."
          ),
      )
      parser.add_argument(
          "-a", "--apps-file",
          help=(
              "Path to a JSON file listing the applications to audit. Each entry"
              " must have 'name' and 'app_id' fields. Required for government"
              " cloud environments where app IDs differ from commercial."
              " Contact support@contraforce.com for your environment's app IDs."
          ),
      )
      parser.add_argument(
          "--redact-upn",
          action="store_true",
          help=(
              "Record the operator's Entra object ID instead of UPN in output"
              " metadata. Automatically enabled for government environments."
          ),
      )
      return parser.parse_args()


  def main() -> int:
      """Run the enterprise application audit and return exit code."""
      args = parse_args()
      cloud = args.cloud

      # Resolve the application list: built-in for commercial, file-based for gov
      if args.apps_file:
          with open(args.apps_file, encoding="utf-8") as f:
              apps_to_audit = json.load(f)
      elif cloud == "AzureCloud":
          apps_to_audit = COMMERCIAL_APPS
      else:
          print(
              "ERROR: Government cloud environments require --apps-file.\n"
              "App IDs differ by cloud environment. Contact support@contraforce.com\n"
              "to obtain the app IDs for your environment.",
              file=sys.stderr,
          )
          return 1

      try:
          graph = GraphClient(cloud=cloud)
      except AuditAuthError as e:
          print(f"ERROR: {e}", file=sys.stderr)
          return 1

      with graph:
          # Auto-enable UPN redaction for government cloud environments
          redact_upn = args.redact_upn or cloud != "AzureCloud"
          generated_by = graph.user_oid if redact_upn else graph.user_upn

          tenant_id = graph.tenant_id
          env_name = CLOUD_ENVIRONMENTS[cloud]["name"]
          print(
              f"\033[32mAuthenticated as: {generated_by}"
              f" (Tenant: {tenant_id}, Environment: {env_name})\033[0m"
          )

          registry = PermissionRegistry()
          resolve_resource_apis(graph, registry)
          index_contraforce_apps(graph, registry)

          results = [audit_app(graph, app, registry) for app in apps_to_audit]

      # Build output document
      output = {
          "metadata": {
              "generatedAt": datetime.now(timezone.utc).isoformat(),  # noqa: UP017
              "tenantId": tenant_id,
              "generatedBy": generated_by,
              "toolVersion": TOOL_VERSION,
              "environment": cloud,
              "description": (
                  "ContraForce enterprise application permissions snapshot"
                  " for documentation auditing."
              ),
          },
          "applications": results,
      }

      output_json = json.dumps(output, indent=2, ensure_ascii=False) + "\n"
      with open(args.output, "w", encoding="utf-8", newline="\n") as f:
          f.write(output_json)

      # Restrict file permissions for government cloud environments
      if cloud != "AzureCloud":
          try:
              os.chmod(args.output, stat.S_IRUSR | stat.S_IWUSR)
          except OSError:
              print(
                  f"  WARNING: Could not restrict file permissions on {args.output}.\n"
                  '  On Windows, run: icacls <file> /inheritance:r /grant:r "%USERNAME%":F',
                  file=sys.stderr,
              )

      print(f"\n\033[32mAudit complete. Output written to: {args.output}\033[0m")
      print(f"Applications audited: {len(results)}")
      print(f"Tenant: {tenant_id}")
      print(f"Environment: {env_name}")
      if redact_upn:
          print("Operator identity: redacted (object ID used)")

      # Exit with non-zero code if any apps were not found
      failures = [r for r in results if r["status"] == "NOT_FOUND"]
      if failures:
          print(
              f"\nWARNING: {len(failures)} application(s) were not found in the tenant.",
              file=sys.stderr,
          )
          return 1

      return 0


  if __name__ == "__main__":
      sys.exit(main())
  ```
</CodeGroup>

## Understanding the Output

Both scripts produce a JSON file with the same structure. Here's an abbreviated example:

```json Output Schema theme={null}
{
  "metadata": {
    "generatedAt": "2026-02-12T18:30:00.000000+00:00",
    "tenantId": "your-tenant-id",
    "generatedBy": "admin@company.example",
    "toolVersion": "2.1.0",
    "environment": "AzureCloud",
    "description": "ContraForce enterprise application permissions snapshot for documentation auditing."
  },
  "applications": [
    {
      "applicationName": "ContraForce API",
      "appId": "24d97bc0-8f2b-45d5-8e0b-7fe286732ef2",
      "status": "OK",
      "delegatedPermissions": [
        {
          "permission": "Application.Read.All",
          "api": "Microsoft Graph",
          "type": "Delegated",
          "description": "Allows the app to read applications and service principals on behalf of the signed-in user.",
          "internal": false
        }
      ],
      "applicationPermissions": []
    }
  ]
}
```

### Key Fields

| Field         | Description                                                                                                                                                                                                      |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`      | `OK` if the application was found and audited. `NOT_FOUND` if the application is not consented in the tenant.                                                                                                    |
| `type`        | `Delegated` (on-behalf-of user) or `Application` (app-only, no user context). See [Permission Types Explained](/guides/technical/enterprise-applications#permission-types-explained).                            |
| `internal`    | `true` for permissions between ContraForce applications (e.g., Portal delegating to API). These are internal platform scopes and do not grant access to your tenant data. Only present on delegated permissions. |
| `environment` | The cloud environment the audit was run against (`AzureCloud` or `AzureUSGovernment`). Matches the `az cloud set --name` value.                                                                                  |
| `generatedBy` | The authenticated operator. In government environments, this is an Entra object ID (GUID) rather than a UPN.                                                                                                     |
| `toolVersion` | Include this when sharing audit results with ContraForce support for traceability.                                                                                                                               |

### Internal Scope Resolution

You may notice the output includes delegated permissions where the `api` field shows a ContraForce application name (e.g., `"ContraForce Portal"`) and `"internal": true`. These are cross-application delegations where one ContraForce app delegates to another via custom OAuth2 scopes.

To resolve these internal scopes to human-readable names instead of raw GUIDs, the scripts query for all service principals whose display name starts with `ContraForce` — not just the eight applications listed in the audit table. This broader query:

* **Does not grant additional access** — it reads public service principal metadata that any authenticated directory reader can see
* **Is clearly tagged** — all permissions from internal apps are marked `"internal": true` in the output
* **Improves readability** — without this, internal scopes would appear as opaque GUIDs that are difficult to review

### Comparing Against Documentation

To verify your tenant's permissions match the documented permissions:

1. Run the audit script to produce `enterprise-apps-audit.json`
2. Open the [Enterprise Applications Reference](/guides/technical/enterprise-applications)
3. For each application in the JSON output, compare its `delegatedPermissions` and `applicationPermissions` against the corresponding tables in the reference
4. Permissions marked `"internal": true` are ContraForce-to-ContraForce delegations and are not listed in the reference tables

### Comparing Across Audit Runs

To track permission changes over time, save each audit output with a date-stamped filename:

```bash theme={null}
# Date-stamped output
python audit_enterprise_apps.py -o audit-2026-02-12.json

# Later, compare against a previous audit
diff audit-2026-01-15.json audit-2026-02-12.json
```

Because the scripts sort all permissions alphabetically and produce deterministic JSON output, standard text diff tools (`diff`, VS Code's built-in compare, or `Compare-Object` in PowerShell) will surface only actual permission changes — not ordering noise.

Store audit outputs alongside your change management records. The `metadata.generatedAt` and `metadata.toolVersion` fields provide traceability for each snapshot.

## Output File Access Control

The audit output reveals your tenant's permission surface for ContraForce applications. While it does not contain secrets or tokens, it should be treated as internal documentation.

**Government cloud environments:** Both scripts automatically restrict the output file so only the current user can read or write it:

* **PowerShell (Windows):** Removes inherited ACL entries and grants `FullControl` only to the current user via `Set-Acl`
* **Python (POSIX):** Sets file mode to `600` (`chmod u=rw,go=`) via `os.chmod`

**Commercial environments:** File permissions are not restricted automatically. If you are running the audit on a shared workstation or jump box, consider restricting access manually:

<Tabs>
  <Tab title="Windows">
    ```powershell theme={null}
    icacls enterprise-apps-audit.json /inheritance:r /grant:r "%USERNAME%":F
    ```
  </Tab>

  <Tab title="Linux / macOS">
    ```bash theme={null}
    chmod 600 enterprise-apps-audit.json
    ```
  </Tab>
</Tabs>

## Using This Output as Audit Evidence

The audit output is designed to serve as evidence in change advisory board (CAB) reviews, compliance audits, and periodic access reviews.

**For CAB / Change Management:**

* Run the audit before and after onboarding a new ContraForce module
* Include both snapshots in your change record to show exactly which permissions were added
* The `toolVersion` field ensures reviewers know which version of the script produced the output

**For Periodic Access Reviews (CMMC, SOC 2, FedRAMP):**

* Schedule monthly or quarterly audit runs and archive the output alongside your review documentation
* Use date-stamped filenames (e.g., `audit-2026-Q1.json`) for easy retrieval
* Compare successive outputs using `diff` to identify any permission drift

**For Incident Response:**

* If you suspect unauthorized permission changes, run an immediate audit and compare against your most recent baseline
* The `generatedAt` timestamp provides a verifiable point-in-time snapshot

<Tip>
  Store audit outputs in a version-controlled repository or a tamper-evident storage location (such as an Azure Storage account with immutable blob policies) to maintain an auditable chain of custody.
</Tip>

## Troubleshooting

| Issue                                                           | Cause                                                                    | Resolution                                                                                                                                                                                                     |
| --------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NOT_FOUND` for some applications                               | Application not consented in tenant                                      | Expected if you haven't deployed all modules. See [Applications by Module](/guides/technical/enterprise-applications#applications-by-module).                                                                  |
| PowerShell: "No active Microsoft Graph session"                 | `Connect-MgGraph` not run                                                | Run `Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All"` first. For government cloud, add `-Environment USGov` (GCC High) or `-Environment USGovDoD` (DoD).                                   |
| PowerShell: "Environment mismatch"                              | `-Cloud` value does not match the `Connect-MgGraph -Environment` session | Disconnect with `Disconnect-MgGraph` and reconnect with the correct `-Environment` flag matching your `-Cloud` value.                                                                                          |
| "Government cloud environments require -AppsFile / --apps-file" | No apps file provided for government cloud                               | App IDs differ by cloud environment. Contact [support@contraforce.com](mailto:support@contraforce.com) for your environment's app IDs and provide them via `-AppsFile` (PowerShell) or `--apps-file` (Python). |
| Python: "Azure CLI is not authenticated"                        | `az login` not run or session expired                                    | Run `az login` to authenticate. For government cloud, run `az cloud set --name AzureUSGovernment` first.                                                                                                       |
| Python: "Missing required packages"                             | `azure-identity` or `httpx` not installed                                | Run `pip install azure-identity httpx`                                                                                                                                                                         |
| Python: "Python 3.10+ is required"                              | Running on an older Python version                                       | Install [Python 3.10 or newer](https://devguide.python.org/versions/)                                                                                                                                          |
| Script takes a long time                                        | Microsoft Graph API throttling                                           | Normal in large tenants. The scripts handle `429 Retry-After` responses automatically.                                                                                                                         |
| Permissions don't match documentation                           | Permissions changed since last documentation update                      | Contact [support@contraforce.com](mailto:support@contraforce.com) with the audit JSON and `toolVersion`                                                                                                        |

***

## Related Resources

<CardGroup cols={2}>
  <Card title="Enterprise Applications Reference" icon="key" href="/guides/technical/enterprise-applications">
    Complete reference for all ContraForce enterprise applications and their permissions
  </Card>

  <Card title="Azure Resources Deployed" icon="cloud" href="/guides/technical/azure-resources-deployed">
    All Azure resources provisioned during ContraForce onboarding
  </Card>

  <Card title="Roles & Permissions" icon="shield" href="/guides/general-support/roles-and-permissions-reference">
    ContraForce platform roles and what each can do
  </Card>

  <Card title="Platform Onboarding" icon="rocket" href="/guides/onboarding/platform-onboarding">
    Step-by-step guide to onboarding your parent workspace
  </Card>
</CardGroup>

<Note>
  Questions about the audit scripts or enterprise application permissions? Contact us at [support@contraforce.com](mailto:support@contraforce.com).
</Note>
