Public/Test-AadkerbReadiness.ps1

function Test-AadkerbReadiness {
    <#
    .SYNOPSIS
        Diagnoses Microsoft Entra Kerberos (AADKERB) authentication readiness for Azure Files.
    .DESCRIPTION
        Performs comprehensive checks to identify issues preventing Azure Files SMB mounts
        using Entra Kerberos authentication with cloud-only identities. Validates:
          - Storage account AADKERB configuration and SMB security settings
          - Auto-generated Entra app registration and admin consent
          - Conditional Access policies that may block the kerberos/1.0 client
          - Client-side registry, services, Kerberos tickets, and device join state
          - RBAC role assignments for SMB share access
    .PARAMETER StorageAccountName
        The name of the Azure Storage Account configured with AADKERB.
    .PARAMETER ResourceGroupName
        The resource group containing the storage account.
    .PARAMETER SubscriptionId
        Azure subscription ID. If omitted the current az CLI context is used.
    .PARAMETER SkipClientChecks
        Skip client-side checks (useful from a pipeline or jump-box).
    .PARAMETER SkipConditionalAccessChecks
        Skip Conditional Access policy checks.
    .EXAMPLE
        Test-AadkerbReadiness -StorageAccountName 'mystorageaccount' -ResourceGroupName 'rg-myproject-01'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$StorageAccountName,

        [Parameter(Mandatory)]
        [string]$ResourceGroupName,

        [Parameter()]
        [string]$SubscriptionId,

        [Parameter()]
        [switch]$SkipClientChecks,

        [Parameter()]
        [switch]$SkipConditionalAccessChecks
    )

    Set-StrictMode -Version Latest

    #region Helpers
    function Write-ColorInfo {
        param(
            [Parameter(Position = 0)]
            [string]$Message,
            [System.ConsoleColor]$ForegroundColor
        )
        $params = @{}
        if ($PSBoundParameters.ContainsKey('ForegroundColor')) { $params['ForegroundColor'] = $ForegroundColor }
        Write-Host $Message @params
    }

    $passCount = 0
    $warnCount = 0
    $failCount = 0

    function Write-Check {
        param(
            [ValidateSet('PASS', 'WARN', 'FAIL', 'INFO')]
            [string]$Status,
            [string]$Message
        )
        switch ($Status) {
            'PASS' { Write-ColorInfo " [PASS] $Message" -ForegroundColor Green;  Set-Variable -Name passCount -Value ($passCount + 1) -Scope 1 }
            'WARN' { Write-ColorInfo " [WARN] $Message" -ForegroundColor Yellow; Set-Variable -Name warnCount -Value ($warnCount + 1) -Scope 1 }
            'FAIL' { Write-ColorInfo " [FAIL] $Message" -ForegroundColor Red;    Set-Variable -Name failCount -Value ($failCount + 1) -Scope 1 }
            'INFO' { Write-ColorInfo " [INFO] $Message" -ForegroundColor Cyan }
        }
    }

    function Write-Section {
        param([string]$Title)
        Write-ColorInfo ''
        Write-ColorInfo '=====================================================================' -ForegroundColor White
        Write-ColorInfo " $Title" -ForegroundColor White
        Write-ColorInfo '=====================================================================' -ForegroundColor White
    }

    function Invoke-GraphRequest {
        param(
            [string]$Uri,
            [string]$Method = 'GET'
        )
        try {
            $response = az rest --method $Method --url $Uri --headers 'Content-Type=application/json' 2>&1
            if ($LASTEXITCODE -ne 0) {
                Write-Check -Status WARN -Message "Graph API call failed: $response"
                return $null
            }
            return ($response | ConvertFrom-Json)
        }
        catch {
            Write-Check -Status WARN -Message "Graph API call exception: $_"
            return $null
        }
    }
    #endregion Helpers

    # ── Pre-flight ───────────────────────────────────────────────────────────
    Assert-AzCliLogin | Out-Null

    Write-ColorInfo ''
    Write-ColorInfo '╔═══════════════════════════════════════════════════════════════════════╗' -ForegroundColor Cyan
    Write-ColorInfo '║ AADKERB Readiness Diagnostic — Azure Files + Entra Kerberos ║' -ForegroundColor Cyan
    Write-ColorInfo '╚═══════════════════════════════════════════════════════════════════════╝' -ForegroundColor Cyan
    Write-ColorInfo ''
    Write-ColorInfo " Storage Account : $StorageAccountName"
    Write-ColorInfo " Resource Group : $ResourceGroupName"
    Write-ColorInfo " Timestamp : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"

    #region 1 — Storage Account Configuration
    Write-Section '1. Storage Account — AADKERB Configuration'

    $subArgs = if ($SubscriptionId) { @('--subscription', $SubscriptionId) } else { @() }

    try {
        $sa = az storage account show --name $StorageAccountName --resource-group $ResourceGroupName @subArgs 2>&1 | ConvertFrom-Json
    }
    catch {
        Write-Check -Status FAIL -Message "Unable to retrieve storage account '$StorageAccountName'. Verify az CLI login and permissions."
        Write-ColorInfo "`nDiagnostic aborted — cannot continue without storage account access." -ForegroundColor Red
        return
    }

    $directoryType = $sa.azureFilesIdentityBasedAuthentication.directoryServiceOptions
    if ($directoryType -eq 'AADKERB') {
        Write-Check -Status PASS -Message "Directory service type is 'AADKERB'."
    }
    elseif ($null -eq $directoryType -or $directoryType -eq 'None') {
        Write-Check -Status FAIL -Message "Directory service type is '$directoryType'. Expected 'AADKERB'."
    }
    else {
        Write-Check -Status WARN -Message "Directory service type is '$directoryType' (AD or AADDS). This script diagnoses AADKERB only."
    }

    $adProps = $sa.azureFilesIdentityBasedAuthentication.activeDirectoryProperties
    if ($directoryType -eq 'AADKERB') {
        if ($null -eq $adProps -or [string]::IsNullOrWhiteSpace($adProps.domainName) -or [string]::IsNullOrWhiteSpace($adProps.domainGuid)) {
            Write-Check -Status FAIL -Message "activeDirectoryProperties is not set. The SMB server cannot validate Kerberos tickets without domainName/domainGuid."
        }
        else {
            Write-Check -Status PASS -Message "activeDirectoryProperties set — domainName='$($adProps.domainName)', domainGuid='$($adProps.domainGuid)'"
        }
    }

    $defaultPerm = $sa.azureFilesIdentityBasedAuthentication.defaultSharePermission
    if ($defaultPerm -and $defaultPerm -ne 'None') {
        Write-Check -Status PASS -Message "Default share-level permission: $defaultPerm"
    }
    else {
        Write-Check -Status FAIL -Message "Default share-level permission is '$defaultPerm'. Cloud-only identities require a default permission."
    }

    $smbVersions  = $sa.fileServiceProperties.protocolSettings.smb.versions
    $smbAuthTypes = $sa.fileServiceProperties.protocolSettings.smb.authenticationMethods
    $smbKerbEnc   = $sa.fileServiceProperties.protocolSettings.smb.kerberosTicketEncryption
    $smbChanEnc   = $sa.fileServiceProperties.protocolSettings.smb.channelEncryption

    Write-Check -Status INFO -Message "SMB versions : $($smbVersions -join ', ')"
    Write-Check -Status INFO -Message "SMB authentication types : $($smbAuthTypes -join ', ')"
    Write-Check -Status INFO -Message "SMB Kerberos encryption : $($smbKerbEnc -join ', ')"
    Write-Check -Status INFO -Message "SMB channel encryption : $($smbChanEnc -join ', ')"

    if ($smbAuthTypes -and ($smbAuthTypes -match 'Kerberos')) {
        Write-Check -Status PASS -Message 'Kerberos is listed as an SMB authentication type.'
    }
    else {
        Write-Check -Status WARN -Message 'Kerberos is not explicitly listed in SMB authentication types.'
    }
    #endregion

    #region 2 — Entra App Registration & Admin Consent
    Write-Section '2. Entra ID — Storage Account App Registration & Admin Consent'

    $appDisplayName = "[Storage Account] $StorageAccountName.file.core.windows.net"
    Write-Check -Status INFO -Message "Looking up app registration: $appDisplayName"

    $spResult = Invoke-GraphRequest -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=displayName eq '$appDisplayName'&`$select=id,appId,displayName"

    $storageSpObjectId = $null
    $storageAppId = $null

    if ($null -eq $spResult -or $spResult.value.Count -eq 0) {
        Write-Check -Status FAIL -Message "Service principal '$appDisplayName' not found."
    }
    else {
        $storageSp = $spResult.value[0]
        $storageSpObjectId = $storageSp.id
        $storageAppId = $storageSp.appId
        Write-Check -Status PASS -Message "Service principal found — Object ID: $storageSpObjectId, App ID: $storageAppId"

        $grantsResult = Invoke-GraphRequest -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$storageSpObjectId/oauth2PermissionGrants?`$select=scope,consentType,resourceId"

        if ($null -eq $grantsResult -or $grantsResult.value.Count -eq 0) {
            Write-Check -Status FAIL -Message 'No admin consent grants found. Admin consent for openid profile User.Read is required.'
        }
        else {
            $allScopes = ($grantsResult.value | ForEach-Object { $_.scope }) -join ' '
            Write-Check -Status INFO -Message "Granted scopes: $allScopes"

            foreach ($scope in @('openid', 'profile', 'User.Read')) {
                if ($allScopes -match [regex]::Escape($scope)) {
                    Write-Check -Status PASS -Message "Required scope '$scope' is granted."
                }
                else {
                    Write-Check -Status FAIL -Message "Required scope '$scope' is NOT granted."
                }
            }
        }

        $appObjResult = Invoke-GraphRequest -Uri "https://graph.microsoft.com/v1.0/applications?`$filter=appId eq '$storageAppId'&`$select=id,tags"
        if ($null -ne $appObjResult -and $appObjResult.value.Count -gt 0) {
            $appTags = $appObjResult.value[0].tags
            if ($appTags -contains 'kdc_enable_cloud_group_sids') {
                Write-Check -Status PASS -Message "Application manifest tag 'kdc_enable_cloud_group_sids' is present."
            }
            else {
                Write-Check -Status FAIL -Message "Application manifest tag 'kdc_enable_cloud_group_sids' is MISSING."
            }
        }
        else {
            Write-Check -Status WARN -Message "Unable to look up application registration by appId '$storageAppId'."
        }
    }
    #endregion

    #region 3 — Conditional Access Policies
    if (-not $SkipConditionalAccessChecks) {
        Write-Section '3. Conditional Access — Policies That May Block kerberos/1.0'

        if (-not $storageAppId) {
            Write-Check -Status WARN -Message 'Skipping CA checks — storage account app ID could not be determined.'
        }
        else {
            $caResult = Invoke-GraphRequest -Uri 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies'

            if ($null -eq $caResult) {
                Write-Check -Status WARN -Message 'Unable to read Conditional Access policies.'
            }
            else {
                $enabledPolicies = @($caResult.value | Where-Object { $_.state -eq 'enabled' })
                Write-Check -Status INFO -Message "Total enabled CA policies: $($enabledPolicies.Count)"

                $blockingPolicies = @()
                foreach ($policy in $enabledPolicies) {
                    $includeApps = $policy.conditions.applications.includeApplications
                    $excludeApps = $policy.conditions.applications.excludeApplications
                    $appInScope  = ($includeApps -contains 'All') -or ($includeApps -contains $storageAppId)
                    $appExcluded = ($excludeApps -contains $storageAppId)

                    if (-not $appInScope -or $appExcluded) { continue }

                    $grant = $policy.grantControls
                    $isBlocking = $false
                    $blockReasons = @()

                    if ($grant.builtInControls -contains 'mfa') { $isBlocking = $true; $blockReasons += 'Requires MFA' }
                    if ($grant.authenticationStrength) { $isBlocking = $true; $blockReasons += "Requires authentication strength: $($grant.authenticationStrength.displayName ?? $grant.authenticationStrength.id)" }
                    if ($grant.termsOfUse -and $grant.termsOfUse.Count -gt 0) { $isBlocking = $true; $blockReasons += 'Requires Terms of Use acceptance' }

                    if ($isBlocking) {
                        $blockingPolicies += [PSCustomObject]@{ PolicyId = $policy.id; DisplayName = $policy.displayName; Reasons = ($blockReasons -join '; ') }
                    }
                }

                if ($blockingPolicies.Count -eq 0) {
                    Write-Check -Status PASS -Message "No CA policies found that would block AADKERB for app '$storageAppId'."
                }
                else {
                    Write-Check -Status FAIL -Message "$($blockingPolicies.Count) CA policy/policies will block AADKERB authentication:"
                    foreach ($bp in $blockingPolicies) {
                        Write-Check -Status FAIL -Message " → [$($bp.PolicyId)] $($bp.DisplayName) — $($bp.Reasons)"
                    }
                }
            }
        }
    }
    else {
        Write-Section '3. Conditional Access — SKIPPED'
    }
    #endregion

    #region 4 — Sign-in Log Analysis
    Write-Section '4. Entra ID — Recent Sign-in Failures for Storage Account App'

    if ($storageAppId) {
        $signInFilter = "appId eq '$storageAppId' and status/errorCode ne 0"
        $signInUrl    = "https://graph.microsoft.com/v1.0/auditLogs/signIns?`$filter=$signInFilter&`$top=10&`$orderby=createdDateTime desc&`$select=createdDateTime,userPrincipalName,appDisplayName,status,conditionalAccessStatus,clientAppUsed"

        $signInResult = Invoke-GraphRequest -Uri $signInUrl

        if ($null -eq $signInResult -or $signInResult.value.Count -eq 0) {
            Write-Check -Status INFO -Message 'No recent sign-in failures found for the storage account app.'
        }
        else {
            Write-Check -Status WARN -Message "$($signInResult.value.Count) recent sign-in failure(s) found:"
            foreach ($entry in $signInResult.value) {
                Write-ColorInfo " $($entry.createdDateTime) | $($entry.userPrincipalName) | Error: $($entry.status.errorCode) | $($entry.status.failureReason) | CA: $($entry.conditionalAccessStatus) | Client: $($entry.clientAppUsed)" -ForegroundColor DarkYellow
            }
        }
    }
    else {
        Write-Check -Status WARN -Message 'Skipping sign-in log analysis — storage account app ID not determined.'
    }
    #endregion

    #region 5 — Client-side Checks
    if (-not $SkipClientChecks) {
        Write-Section '5. Client Device — Configuration & Readiness'

        $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue
        if ($os) {
            Write-Check -Status INFO -Message "OS: $($os.Caption) (Build $($os.BuildNumber))"
            if ([int]$os.BuildNumber -ge 22000) {
                Write-Check -Status PASS -Message "OS build $($os.BuildNumber) is supported for cloud-only AADKERB."
            }
            else {
                Write-Check -Status WARN -Message "OS build $($os.BuildNumber) may not support cloud-only identities."
            }
        }

        $dsregOutput = dsregcmd /status 2>&1
        if ($dsregOutput -match 'AzureAdJoined\s*:\s*YES') {
            Write-Check -Status PASS -Message 'Device is Microsoft Entra joined.'
        }
        else {
            Write-Check -Status FAIL -Message 'Device does not appear to be Entra joined or hybrid joined.'
        }

        $regPath  = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\Kerberos\Parameters'
        $regValue = Get-ItemProperty -Path $regPath -Name 'CloudKerberosTicketRetrievalEnabled' -ErrorAction SilentlyContinue
        if ($regValue -and $regValue.CloudKerberosTicketRetrievalEnabled -eq 1) {
            Write-Check -Status PASS -Message 'CloudKerberosTicketRetrievalEnabled = 1 (enabled).'
        }
        else {
            Write-Check -Status FAIL -Message 'CloudKerberosTicketRetrievalEnabled registry value not found or not set to 1.'
        }

        foreach ($svc in @(@{Name = 'WinHttpAutoProxySvc'; Display = 'WinHTTP Web Proxy Auto-Discovery'}, @{Name = 'iphlpsvc'; Display = 'IP Helper'})) {
            $service = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue
            if ($service -and $service.Status -eq 'Running') {
                Write-Check -Status PASS -Message "Service '$($svc.Display)' ($($svc.Name)) is running."
            }
            else {
                Write-Check -Status WARN -Message "Service '$($svc.Display)' ($($svc.Name)) is not running."
            }
        }

        $klistString = (klist 2>&1) -join "`n"
        if ($klistString -match 'krbtgt/KERBEROS\.MICROSOFTONLINE\.COM') {
            Write-Check -Status PASS -Message 'Entra Kerberos TGT found.'
        }
        else {
            Write-Check -Status WARN -Message 'No Entra Kerberos TGT found.'
        }

        $cifsPattern = "cifs/$StorageAccountName\.file\.core\.windows\.net"
        if ($klistString -match $cifsPattern) {
            Write-Check -Status PASS -Message "CIFS service ticket found for $StorageAccountName."
        }
        else {
            Write-Check -Status INFO -Message "No CIFS service ticket cached yet for $StorageAccountName."
        }

        Write-Check -Status INFO -Message "Testing TCP 445 to $StorageAccountName.file.core.windows.net..."
        $tcpTest = Test-NetConnection -ComputerName "$StorageAccountName.file.core.windows.net" -Port 445 -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
        if ($tcpTest.TcpTestSucceeded) {
            Write-Check -Status PASS -Message "TCP 445 is reachable to $StorageAccountName.file.core.windows.net."
        }
        else {
            Write-Check -Status FAIL -Message 'TCP 445 is NOT reachable.'
        }
    }
    else {
        Write-Section '5. Client Device — SKIPPED'
    }
    #endregion

    #region 6 — RBAC Role Assignments
    Write-Section '6. Storage Account — RBAC Role Assignments'

    try {
        $roleAssignments = @(az role assignment list `
            --scope "/subscriptions/$($sa.id.Split('/')[2])/resourceGroups/$ResourceGroupName/providers/Microsoft.Storage/storageAccounts/$StorageAccountName" `
            --query "[?contains(roleDefinitionName, 'Storage File Data SMB Share')]" 2>&1 | ConvertFrom-Json)

        if ($roleAssignments.Count -eq 0) {
            Write-Check -Status WARN -Message "No 'Storage File Data SMB Share *' role assignments found."
        }
        else {
            Write-Check -Status PASS -Message "$($roleAssignments.Count) SMB Share role assignment(s) found:"
            foreach ($ra in $roleAssignments) {
                Write-Check -Status INFO -Message " → $($ra.roleDefinitionName) — Principal: $($ra.principalName) ($($ra.principalType))"
            }
        }
    }
    catch {
        Write-Check -Status WARN -Message "Unable to list RBAC role assignments. Error: $_"
    }
    #endregion

    #region Summary
    Write-ColorInfo ''
    Write-ColorInfo '=====================================================================' -ForegroundColor White
    Write-ColorInfo ' SUMMARY' -ForegroundColor White
    Write-ColorInfo '=====================================================================' -ForegroundColor White
    Write-ColorInfo ''
    Write-ColorInfo " PASS : $passCount" -ForegroundColor Green
    Write-ColorInfo " WARN : $warnCount" -ForegroundColor Yellow
    Write-ColorInfo " FAIL : $failCount" -ForegroundColor Red
    Write-ColorInfo ''

    if ($failCount -eq 0 -and $warnCount -eq 0) {
        Write-ColorInfo ' ✅ All checks passed. AADKERB should be fully operational.' -ForegroundColor Green
    }
    elseif ($failCount -eq 0) {
        Write-ColorInfo " ⚠️ No failures but $warnCount warning(s). Review the items above." -ForegroundColor Yellow
    }
    else {
        Write-ColorInfo " ❌ $failCount failure(s) detected. Address the items above before testing Azure Files mount." -ForegroundColor Red
    }
    Write-ColorInfo ''
    #endregion
}