> For the complete documentation index, see [llms.txt](https://docs.stoik.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.stoik.io/email-security/microsoft-365/purview-script.md).

# Purview script

The automated version of [Purview activation errors](/email-security/microsoft-365/purview-errors.md). It connects to Exchange Online, forces a disable then re-enable of audit log ingestion, and confirms the result. If the tenant is dehydrated, it runs `Enable-OrganizationCustomization`, waits for it to finish, then re-enables audit logging. If activation is genuinely refused, it prints a diagnostic (account roles, tenant hydration, license and subscription status).

## Prerequisites

* **Windows PowerShell** or **Azure Cloud Shell** (PowerShell mode). The script refuses to run on macOS/Linux, where the Exchange Online module fails on write operations.
* A tenant admin account.

{% hint style="info" %}
No Windows machine? If the tenant has an active Azure subscription, run it from [Azure Cloud Shell](https://shell.azure.com) in PowerShell mode: upload the script and run it without `-AdminUpn`. Cloud Shell reuses your session, so there is no sign-in prompt. Purview itself does not require an Azure subscription.
{% endhint %}

## Run the script

{% stepper %}
{% step %}

### Copy the script

Copy the [script](#script) with the copy button at the top right of the code block, and save it as `Repair-PurviewAuditLogging.ps1`.
{% endstep %}

{% step %}

### Allow script execution

In PowerShell, run this once per window (session-only, nothing permanent):

```powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
```

{% endstep %}

{% step %}

### Run it with the admin account

Replace `<admin@clientdomain.com>` with a tenant admin. A browser sign-in window opens; the script does the rest.

```powershell
cd C:\path\to\the\folder
.\Repair-PurviewAuditLogging.ps1 -AdminUpn <admin@clientdomain.com>
```

{% endstep %}

{% step %}

### Verify later

`False` right after the run is **normal**: Microsoft applies it within a few minutes (up to about 60 min). Reconnect to check:

```powershell
Connect-ExchangeOnline -UserPrincipalName <admin@clientdomain.com> -ShowBanner:$false
Get-AdminAuditLogConfig | Format-List UnifiedAuditLogIngestionEnabled
```

`True` means it is done.
{% endstep %}
{% endstepper %}

## Troubleshooting

The script runs a diagnostic and tells you which case you are in. Send the `.log` file it created to <protect@stoik.io>.

* **Dehydrated tenant**: handled automatically. The script runs `Enable-OrganizationCustomization`, waits for hydration, then re-enables audit logging.
* **403 Forbidden / refused**: a real rejection. The account lacks the right role (the **Audit Logs** role, granted through Organization Management or Compliance Management), or the tenant has no license that includes Exchange Online.
* **Server-side error**: a transient "try again later" from Microsoft. The script already retries; if it persists, run it again a few minutes later.

## Script

{% code title="Repair-PurviewAuditLogging.ps1" lineNumbers="true" %}

```powershell
<#
.SYNOPSIS
    Repair Microsoft Purview unified audit logging by forcing a disable then re-enable.

.DESCRIPTION
    PREREQUISITE: run this on Windows PowerShell (5.1 or 7+) OR in Azure Cloud Shell.
    The script refuses to run anywhere else (macOS / desktop Linux) - those environments
    hit Exchange Online module quirks and are not supported.

      - Windows      -> pass -AdminUpn (or omit it for an interactive sign-in).
      - Cloud Shell  -> sign-in is automatic (the EXO module reuses your Cloud Shell identity).
                        Use -Device only if you specifically want device-code sign-in.

    Even when audit logging already reads as enabled, the Purview portal can stay stuck on
    its error. Forcing a disable then a re-enable is what unblocks it.

    Re-enabling is asynchronous: Microsoft can take up to ~60 minutes to reflect it, so a
    fresh read may still show False right after a successful call. That is normal.

    If enabling is genuinely refused, the script runs a diagnostic: connected account &
    roles, tenant hydration state, tenant age, and subscription/license status (the last two
    via Microsoft Graph when available) - to point at the real cause (permissions, licensing,
    or a too-new tenant).

    A timestamped .log file is written in the current folder.

.PARAMETER AdminUpn
    Admin UPN (e.g. admin@clientdomain.com). Optional on Windows (interactive sign-in if omitted).

.PARAMETER Device
    Force device-code sign-in. Optional - Cloud Shell signs in automatically without it.

.EXAMPLE
    .\Repair-PurviewAuditLogging.ps1 -AdminUpn admin@clientdomain.com
#>

[CmdletBinding()]
param(
    [string]$AdminUpn,
    [switch]$Device
)

$ErrorActionPreference = 'Stop'
$SupportEmail = 'protect@stoik.io'

# Patience for Microsoft's transient "try again later" errors.
$RetryCount   = 8
$RetryWaitSec = 30   # 8 x 30s = up to ~4 min per toggle

# --- Console helpers --------------------------------------------------------
function Write-Step { param($m) Write-Host "`n==> $m" -ForegroundColor Cyan }
function Write-Ok   { param($m) Write-Host "    [OK]   $m" -ForegroundColor Green }
function Write-Info { param($m) Write-Host "    [..]   $m" -ForegroundColor Gray }
function Write-Warn2{ param($m) Write-Host "    [WARN] $m" -ForegroundColor Yellow }
function Write-Err  { param($m) Write-Host "    [FAIL] $m" -ForegroundColor Red }

# --- Environment detection + prerequisite gate ------------------------------
# Cloud Shell sets one of these; Windows PowerShell 5.1 has no $IsWindows so we also
# accept the Desktop edition.
$IsCloudShell = [bool]$env:ACC_CLOUD `
                -or ($env:POWERSHELL_DISTRIBUTION_CHANNEL -like '*CloudShell*') `
                -or ($env:AZUREPS_HOST_ENVIRONMENT -like '*cloud-shell*')
$OnWindows    = $IsWindows -or ($PSVersionTable.PSEdition -eq 'Desktop')

$exoVersion = (Get-Module -ListAvailable -Name ExchangeOnlineManagement |
               Sort-Object Version -Descending | Select-Object -First 1).Version

Write-Step "Environment"
Write-Host "    OS / platform : $([System.Environment]::OSVersion.VersionString)"
Write-Host "    PowerShell    : $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))"
Write-Host "    Context       : $(if ($IsCloudShell) {'Azure Cloud Shell'} elseif ($OnWindows) {'Windows'} else {'unsupported'})"
Write-Host "    EXO module    : $(if ($exoVersion) {$exoVersion} else {'not installed'})"

if (-not ($OnWindows -or $IsCloudShell)) {
    Write-Err "Unsupported environment."
    Write-Host "    This script only runs on Windows PowerShell or in Azure Cloud Shell." -ForegroundColor Yellow
    Write-Host "    Re-run it from a Windows machine, or from https://shell.azure.com (PowerShell)." -ForegroundColor Yellow
    exit 2
}

# --- Logging ----------------------------------------------------------------
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$LogFile   = Join-Path (Get-Location) "purview-repair-$timestamp.log"
Start-Transcript -Path $LogFile -Append | Out-Null

# Write text to a side file without ever throwing (the .log is held by the transcript).
function Save-ErrorDetail {
    param($Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
    try {
        $f = [System.IO.Path]::ChangeExtension($LogFile, 'error.txt')
        "FULL ERROR DETAIL (for support)`n$Text" | Out-File -FilePath $f -ErrorAction Stop
        return $f
    } catch { return $null }
}

$connected = $false

# Runs Set-AdminAuditLogConfig once and classifies the outcome. EXO REST cmdlets don't
# always honour try/catch, so we inspect -ErrorVariable.
function Set-AuditIngestionOnce {
    param([bool]$Value)
    $ErrorActionPreference = 'Continue'
    # Hide the raw "...up to 60 minutes to apply" warning from the console (we still capture
    # it via -WarningVariable and print our own [OK]). This is a preference, not a command
    # decoration or a stream redirect.
    $WarningPreference = 'SilentlyContinue'
    $err = $null; $warn = $null
    # IMPORTANT: invoke this EXACTLY like a hand-typed command. Decorating the call with
    # -WarningAction SilentlyContinue / 2>$null / | Out-Null makes EXO return a spurious (403)
    # inside a script, while the bare command succeeds. -ErrorVariable / -WarningVariable only
    # capture the streams; they do not change how the cmdlet talks to the server.
    $null = Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $Value `
        -ErrorVariable err -WarningVariable warn
    $errText  = ($err  | ForEach-Object { $_.ToString() }) -join ' '
    $warnText = ($warn | ForEach-Object { $_.ToString() }) -join ' '
    if ($errText) { $script:LastFullError = $errText }

    $dehydrated = ($errText -match 'Enable-OrganizationCustomization')
    $serverSide = ($errText -match 'server side error|try again after some time')
    $forbidden  = (-not $serverSide) -and ($errText -match '403|Forbidden|Interdit|Access is denied|not authorized|insufficient')

    if     ($dehydrated) { $short = 'Tenant is dehydrated (needs Enable-OrganizationCustomization).' }
    elseif ($forbidden)  { $short = '403 Forbidden - permissions / role / licensing problem.' }
    elseif ($serverSide) { $short = "Microsoft is busy (server-side error, 'try again later')." }
    elseif ($errText)    { $short = ($errText -split "`n")[0] }
    else                 { $short = '' }

    [pscustomobject]@{
        Ok = [string]::IsNullOrWhiteSpace($errText); Warning = $warnText; Error = $errText
        Short = $short; Dehydrated = $dehydrated; Forbidden = $forbidden; ServerSide = $serverSide
    }
}

# Toggle the setting, retrying on transient errors only.
function Invoke-AuditToggle {
    param([bool]$Value, [string]$Label)
    $r = $null
    for ($i = 1; $i -le $RetryCount; $i++) {
        $r = Set-AuditIngestionOnce $Value
        if ($r.Ok)         { Write-Ok "$Label accepted by Microsoft."; return $r }
        if ($r.Dehydrated) { return $r }                       # caller hydrates then retries
        if ($r.Forbidden)  { Write-Warn2 "$Label refused (permissions / role / licensing) - not retrying."; return $r }

        Write-Warn2 "$Label failed (attempt $i/$RetryCount): $($r.Short)"
        if ($i -lt $RetryCount) {
            Write-Info "Retrying $Label in ${RetryWaitSec}s."
            Start-Sleep -Seconds $RetryWaitSec
        }
    }
    return $r
}

# Manual checklist shown when Graph can't auto-check subscription / licensing / tenant age.
function Write-ManualChecklist {
    Write-Info "Verify manually in the Microsoft 365 admin center (admin.microsoft.com):"
    Write-Info "  - Billing > Your products : the subscription is Active (not suspended/expired)."
    Write-Info "  - A license that includes Exchange Online is assigned (most M365/O365 plans)."
    Write-Info "  - Setup date : tenants created in the last ~72h may not have audit provisioned yet."
}

# Full diagnostic when enabling is genuinely refused.
function Invoke-FailureDiagnostic {
    param($ErrInfo)
    Write-Step "Diagnostic - why was enabling refused?"
    if ($ErrInfo -and $ErrInfo.Short) { Write-Info "Reported error : $($ErrInfo.Short)" }

    # Connected identity
    $upn = $null
    try {
        $ci  = Get-ConnectionInformation -ErrorAction Stop | Select-Object -First 1
        $upn = $ci.UserPrincipalName
        Write-Info "Connected as   : $upn"
        Write-Info "Tenant id      : $($ci.TenantId)"
    } catch { Write-Info "Could not read connection info." }

    # Tenant / org configuration
    try {
        $org = Get-OrganizationConfig -ErrorAction Stop
        Write-Info "Organization   : $($org.DisplayName) [$($org.Name)]"
        Write-Info "IsDehydrated   : $($org.IsDehydrated)$(if ($org.IsDehydrated) {'  <- run Enable-OrganizationCustomization'})"
    } catch { Write-Info "Could not read Get-OrganizationConfig." }

    # Permissions: does the account hold a role that can set audit config?
    if ($upn) {
        try {
            $roles = Get-ManagementRoleAssignment -RoleAssignee $upn -Delegating:$false -ErrorAction Stop |
                     Select-Object -ExpandProperty Role -Unique
            Write-Info "Roles          : $([string]::Join(', ', $roles))"
            if ($roles -contains 'Audit Logs' -or $roles -contains 'Organization Configuration') {
                Write-Ok "Account holds a role allowing audit configuration."
            } else {
                Write-Warn2 "Account lacks 'Organization Configuration' / 'Audit Logs'."
                Write-Warn2 "=> Add it to the 'Organization Management' role group (Exchange admin center)."
            }
        } catch { Write-Info "Could not enumerate roles ($($_.Exception.Message))." }
    }

    # Subscription / licensing / tenant age via Microsoft Graph (best-effort, non-fatal).
    if (Get-Command Get-MgOrganization -ErrorAction SilentlyContinue) {
        try {
            if (-not (Get-MgContext -ErrorAction SilentlyContinue)) {
                Write-Info "Connecting to Microsoft Graph (read-only) for subscription/license/age checks..."
                Connect-MgGraph -Scopes 'Organization.Read.All' -NoWelcome -ErrorAction Stop
            }
            $o = Get-MgOrganization -ErrorAction Stop | Select-Object -First 1
            if ($o.CreatedDateTime) {
                $age = (New-TimeSpan -Start $o.CreatedDateTime -End (Get-Date)).Days
                Write-Info "Tenant created : $($o.CreatedDateTime) (~$age days ago)"
                if ($age -lt 3) { Write-Warn2 "Tenant is very new (<3 days) - audit provisioning may not be ready. Retry later." }
            }
            foreach ($s in (Get-MgSubscribedSku -ErrorAction Stop)) {
                Write-Info "License        : $($s.SkuPartNumber)  status=$($s.CapabilityStatus)  seats=$($s.PrepaidUnits.Enabled)"
                if ($s.CapabilityStatus -in 'Suspended','Deleted') {
                    Write-Warn2 "Subscription '$($s.SkuPartNumber)' is $($s.CapabilityStatus) - not active."
                }
            }
        } catch {
            Write-Info "Graph check unavailable: $($_.Exception.Message)"
            Write-ManualChecklist
        }
    } else {
        Write-Info "Microsoft Graph module not installed - cannot auto-check subscription/license/tenant age."
        Write-ManualChecklist
    }
}

try {
    # --- Prerequisites -----------------------------------------------------
    Write-Step "Checking prerequisites"
    if (-not $exoVersion) {
        Write-Warn2 "ExchangeOnlineManagement not found. Installing for the current user."
        if ($OnWindows -and $PSVersionTable.PSEdition -eq 'Desktop') {
            # Windows PowerShell 5.1 often needs TLS 1.2 explicitly to reach the PSGallery.
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        }
        Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser -Force -AllowClobber
        Write-Ok "Module installed."
    } else {
        Write-Ok "ExchangeOnlineManagement present ($exoVersion)."
    }
    Import-Module ExchangeOnlineManagement

    # --- Connect -----------------------------------------------------------
    Write-Step "Connecting to Exchange Online"
    # In Azure Cloud Shell, the EXO module reuses the session identity automatically - NO device
    # code. Only force device-code when the user explicitly asks for it with -Device.
    $useDevice = [bool]$Device
    if ($useDevice -and $AdminUpn) {
        Write-Warn2 "Device-code sign-in: open the URL shown below and enter the code."
        Connect-ExchangeOnline -UserPrincipalName $AdminUpn -Device -ShowBanner:$false
    } elseif ($useDevice) {
        Write-Warn2 "Device-code sign-in: open the URL shown below and enter the code."
        Connect-ExchangeOnline -Device -ShowBanner:$false
    } elseif ($AdminUpn) {
        Connect-ExchangeOnline -UserPrincipalName $AdminUpn -ShowBanner:$false
    } else {
        Connect-ExchangeOnline -ShowBanner:$false
    }
    $connected = $true
    Write-Ok "Connected."

    # --- Diagnostic --------------------------------------------------------
    Write-Step "Current state"
    $enabled      = (Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled
    $isDehydrated = (Get-OrganizationConfig).IsDehydrated
    Write-Host "    UnifiedAuditLogIngestionEnabled : $enabled"
    Write-Host "    IsDehydrated                    : $isDehydrated"

    # --- Disable first only if currently ON (the off->on cycle unblocks the portal) -------
    if ($enabled) {
        Write-Step "Step 1 - Disabling ingestion (to force a clean off->on cycle)"
        $d = Invoke-AuditToggle -Value $false -Label "Disable"
        if (-not $d.Ok) { Write-Warn2 "Disable did not confirm ($($d.Short)) - continuing to re-enable anyway." }
    } else {
        Write-Info "Audit logging is currently OFF - enabling directly (no disable needed)."
    }

    # --- Enable ------------------------------------------------------------
    Write-Step "Step 2 - Enabling ingestion"
    $e = Invoke-AuditToggle -Value $true -Label "Enable"
    if ($e.Dehydrated) {
        Write-Warn2 "Dehydrated tenant. Running Enable-OrganizationCustomization."
        try { Enable-OrganizationCustomization } catch { Write-Warn2 $_.Exception.Message }
        $h = 0
        do {
            Start-Sleep -Seconds 15
            $isDehydrated = (Get-OrganizationConfig).IsDehydrated
            $h++
            Write-Info "Waiting for hydration... attempt $h (IsDehydrated = $isDehydrated)"
        } while ($isDehydrated -and $h -lt 8)
        if ($isDehydrated) { throw "Tenant is still dehydrated after waiting. Re-run in a few minutes." }
        Write-Ok "Tenant hydrated."
        $e = Invoke-AuditToggle -Value $true -Label "Re-enable"
    }

    # --- Result ------------------------------------------------------------
    Write-Step "Result"
    Start-Sleep -Seconds 5
    $final = (Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled
    Write-Host "    UnifiedAuditLogIngestionEnabled (fresh read) : $final"

    # The fresh read is the ground truth: if it is True, audit logging IS enabled, even when the
    # last enable call left $e.Ok false (transient 403, "try again" noise on the error stream).
    if ($final) {
        Write-Ok "Audit logging is ENABLED. Done."
    }
    elseif ($e.Ok) {
        Write-Warn2 "Re-enable was accepted but still reads False - normal: Microsoft can take up to"
        Write-Host  "    ~60 minutes to apply it. Re-check with the command below shortly." -ForegroundColor Yellow
    }
    else {
        Write-Err "Enabling was NOT accepted by Microsoft. Running a diagnostic."
        Save-ErrorDetail $script:LastFullError | Out-Null
        Invoke-FailureDiagnostic $e
    }

    # --- Verify later ------------------------------------------------------
    Write-Step "Verify later (reconnect first, the script disconnects at the end)"
    if ($AdminUpn) {
        Write-Host  "        Connect-ExchangeOnline -UserPrincipalName $AdminUpn -ShowBanner:`$false" -ForegroundColor Gray
    } else {
        Write-Host  "        Connect-ExchangeOnline -ShowBanner:`$false" -ForegroundColor Gray
    }
    Write-Host  "        Get-AdminAuditLogConfig | Format-List UnifiedAuditLogIngestionEnabled" -ForegroundColor Gray
    Write-Host  "    'True' means it is enabled."
}
catch {
    Write-Err "Something went wrong: $($_.Exception.Message)"
    if ($connected) { try { Invoke-FailureDiagnostic $null } catch { } }
    $errorFile = Save-ErrorDetail $script:LastFullError
    Write-Host ""
    Write-Host "    >> If you need help, send the log file to $SupportEmail :" -ForegroundColor Yellow
    Write-Host "       $LogFile" -ForegroundColor Yellow
    if ($errorFile) { Write-Host "       $errorFile" -ForegroundColor Yellow }
}
finally {
    if ($connected) {
        Write-Step "Disconnecting"
        try { Disconnect-ExchangeOnline -Confirm:$false } catch { }
        Write-Ok "Disconnected."
    }
    Write-Host "`nLog saved to: $LogFile" -ForegroundColor Gray
    Stop-Transcript | Out-Null
}
```

{% endcode %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.stoik.io/email-security/microsoft-365/purview-script.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
