Checks/Section05-IdentityServices.ps1

# =============================================================================
# Section 5: Identity Services - Custom Check Functions
# CIS Microsoft Azure Foundations Benchmark v5.0.0
# =============================================================================
# Custom functions for Identity controls that require direct API calls beyond
# the standard GraphAPIProperty pattern. Dispatched via 'Custom' CheckPattern.
# Each function receives -ControlDef (hashtable) and -ResourceCache (hashtable).
# =============================================================================

function Test-CIS512-MFAAllUsers {
    <#
    .SYNOPSIS
        CIS 5.1.2 - Ensure that 'multifactor authentication' is 'enabled' for all users.
    .DESCRIPTION
        Uses Microsoft Graph to query the userRegistrationDetails report and check
        whether all non-guest, enabled users have MFA registered.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        # Attempt to query user registration details via Graph API
        $pageSize = if ($script:CISConfig.GraphApiPageSize) { $script:CISConfig.GraphApiPageSize } else { 999 }
        $uri = "https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails?`$top=$pageSize"
        $allUsers         = [System.Collections.Generic.List[object]]::new()
        $currentUri       = $uri

        # Page through all results
        do {
            try {
                $response = Invoke-MgGraphRequest -Method GET -Uri $currentUri -ErrorAction Stop
            }
            catch {
                # If Graph is unavailable, fall back to Get-MgUser approach
                return Test-CIS512-MFAAllUsers-Fallback -ControlDef $ControlDef -ErrorMessage $_.Exception.Message
            }

            if ($response.value) {
                foreach ($item in $response.value) {
                    $allUsers.Add($item)
                }
            }
            $currentUri = $response.'@odata.nextLink'
        } while ($currentUri)

        if ($allUsers.Count -eq 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'WARNING' `
                -Details "No user registration details returned from Graph API. Verify permissions (UserAuthenticationMethod.Read.All)." `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        # Exclude guest users and disabled accounts - CIS requirement targets active organization members only
        $allUsers = @($allUsers | Where-Object {
            $_.userType -ne 'Guest' -and
            ($_.isAdmin -ne $null -or $_.userPrincipalName -ne $null)  # ensure valid user objects
        })
        # Filter out disabled accounts if the property is available
        $allUsers = @($allUsers | Where-Object {
            # userRegistrationDetails may include an isEnabled or accountEnabled property
            $enabled = $_.isEnabled
            if ($null -eq $enabled) { $enabled = $_.accountEnabled }
            # If the property is not available, include the user (assume enabled)
            $null -eq $enabled -or $enabled -eq $true
        })

        if ($allUsers.Count -eq 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'WARNING' `
                -Details "No non-guest users found in user registration details." `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        $totalCount  = $allUsers.Count
        $failedList  = [System.Collections.Generic.List[string]]::new()
        $passedCount = 0

        foreach ($user in $allUsers) {
            $isMfaRegistered = $false
            if ($user.isMfaRegistered -eq $true) {
                $isMfaRegistered = $true
            }
            elseif ($user.methodsRegistered -and ($user.methodsRegistered -match 'mfa|microsoftAuthenticator|fido2|softwareOneTimePasscode|passKeyDeviceBound|windowsHelloForBusiness')) {
                # Strong MFA methods only — SMS/phone alone is not considered strong MFA per NIST SP 800-63B
                $isMfaRegistered = $true
            }

            if ($isMfaRegistered) {
                $passedCount++
            }
            else {
                # Redact PII: show only first initial and domain to avoid leaking full names/UPNs in reports
                $upn = $user.userPrincipalName
                if ($upn -and $upn -match '^(.)[^@]*(@.+)$') {
                    $redacted = "$($Matches[1])***$($Matches[2])"
                } else {
                    $redacted = if ($user.userDisplayName) { "$($user.userDisplayName[0])***" } else { 'Unknown' }
                }
                $failedList.Add($redacted)
            }
        }

        $failedCount = $failedList.Count
        if ($failedCount -gt 0) {
            # Cap the displayed list to avoid excessively long details
            $displayMax   = if ($script:CISConfig.MaxDisplayItems) { $script:CISConfig.MaxDisplayItems } else { 20 }
            $displayNames = if ($failedCount -le $displayMax) { $failedList -join '; ' } else { ($failedList[0..($displayMax - 1)] -join '; ') + " ... and $($failedCount - $displayMax) more" }
            $details      = "$failedCount of $totalCount user(s) do not have MFA registered: $displayNames"
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details $details `
                -AffectedResources $failedList.ToArray() `
                -TotalResources $totalCount `
                -PassedResources $passedCount `
                -FailedResources $failedCount
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'PASS' `
            -Details "All $totalCount user(s) have MFA registered." `
            -TotalResources $totalCount `
            -PassedResources $passedCount `
            -FailedResources 0
    }
    catch {
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'ERROR' `
            -Details "Error checking MFA status for all users: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS512-MFAAllUsers-Fallback {
    <#
    .SYNOPSIS
        Fallback MFA check when Graph registration details endpoint is unavailable.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter()]
        [string]$ErrorMessage
    )

    try {
        $users = @(Get-MgUser -All -Property Id, DisplayName, UserPrincipalName, AccountEnabled, UserType -ErrorAction Stop |
            Where-Object { $_.AccountEnabled -eq $true -and $_.UserType -ne 'Guest' })

        if ($users.Count -eq 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'WARNING' `
                -Details "No enabled users found via Get-MgUser. Original Graph error: $ErrorMessage" `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        $totalCount  = $users.Count
        $failedList  = [System.Collections.Generic.List[string]]::new()
        $passedCount = 0

        $maxUsers = if ($script:CISConfig.MfaFallbackMaxUsers) { $script:CISConfig.MfaFallbackMaxUsers } else { 500 }
        if ($totalCount -gt $maxUsers) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'WARNING' `
                -Details "Tenant has $totalCount enabled users, exceeding fallback MFA check limit of $maxUsers. Use the primary Graph API endpoint (UserAuthenticationMethod.Read.All scope) for accurate results. Connect with: Connect-MgGraph -Scopes UserAuthenticationMethod.Read.All" `
                -TotalResources $totalCount -PassedResources 0 -FailedResources 0
        }

        # Strong MFA method type fragments only — per CIS v5.0.0 and NIST SP 800-63B,
        # phone/SMS and TemporaryAccessPass are not considered strong MFA methods
        $mfaMethodTypes = @(
            'Fido2', 'MicrosoftAuthenticator',
            'SoftwareOath', 'WindowsHelloForBusiness',
            'Passkey'
        )

        foreach ($user in $users) {
            try {
                $methods = @(Get-MgUserAuthenticationMethod -UserId $user.Id -ErrorAction Stop)
                # Check if any registered method is MFA-capable (not just password or email)
                $hasMfaMethod = $false
                foreach ($method in $methods) {
                    $methodType = if ($method.AdditionalProperties.'@odata.type') {
                        $method.AdditionalProperties.'@odata.type'
                    } else { '' }
                    foreach ($mfaType in $mfaMethodTypes) {
                        if ($methodType -match $mfaType) {
                            $hasMfaMethod = $true
                            break
                        }
                    }
                    if ($hasMfaMethod) { break }
                }

                if ($hasMfaMethod) {
                    $passedCount++
                }
                else {
                    # Redact PII: show only first initial to avoid leaking full names in reports
                    $redactedName = if ($user.UserPrincipalName -and $user.UserPrincipalName -match '^(.)[^@]*(@.+)$') { "$($Matches[1])***$($Matches[2])" } elseif ($user.DisplayName) { "$($user.DisplayName[0])***" } else { 'Unknown' }
                    $failedList.Add($redactedName)
                }
            }
            catch {
                $redactedName = if ($user.DisplayName) { "$($user.DisplayName[0])***" } else { 'Unknown' }
                $failedList.Add("$redactedName [Error retrieving methods]")
            }

            # Throttle to avoid rate limiting
            $batchSize = if ($script:CISConfig.MfaFallbackBatchSize) { $script:CISConfig.MfaFallbackBatchSize } else { 50 }
            if (($passedCount + $failedList.Count) % $batchSize -eq 0 -and ($passedCount + $failedList.Count) -gt 0) {
                Start-Sleep -Milliseconds 500
            }
        }

        $failedCount = $failedList.Count
        if ($failedCount -gt 0) {
            $displayMax   = if ($script:CISConfig.MaxDisplayItems) { $script:CISConfig.MaxDisplayItems } else { 20 }
            $displayNames = if ($failedCount -le $displayMax) { $failedList -join '; ' } else { ($failedList[0..($displayMax - 1)] -join '; ') + " ... and $($failedCount - $displayMax) more" }
            $details      = "$failedCount of $totalCount user(s) do not have MFA-capable authentication methods registered (fallback check): $displayNames"
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details $details `
                -AffectedResources $failedList.ToArray() `
                -TotalResources $totalCount `
                -PassedResources $passedCount `
                -FailedResources $failedCount
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'PASS' `
            -Details "All $totalCount user(s) have MFA-capable authentication methods registered (fallback check)." `
            -TotalResources $totalCount `
            -PassedResources $passedCount `
            -FailedResources 0
    }
    catch {
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'ERROR' `
            -Details "Error in MFA fallback check: $($_.Exception.Message). Original error: $ErrorMessage"
    }
}

function Test-CIS516-GuestInviteRestrictions {
    <#
    .SYNOPSIS
        CIS 5.16 - Ensure 'Guest invite restrictions' is set to 'Only users assigned to specific admin roles' or 'No one'.
    .DESCRIPTION
        Retrieves the authorization policy and checks AllowInvitesFrom equals
        'adminsAndGuestInviters' or 'none'.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $authPolicy = Get-MgPolicyAuthorizationPolicy -ErrorAction Stop
        $allowInvitesFrom = $authPolicy.AllowInvitesFrom

        $acceptableValues = @('adminsAndGuestInviters', 'none')

        if ($allowInvitesFrom -in $acceptableValues) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "Guest invite restrictions are properly configured. AllowInvitesFrom = '$allowInvitesFrom'." `
                -TotalResources 1 -PassedResources 1 -FailedResources 0
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'FAIL' `
            -Details "Guest invite restrictions are not adequately restricted. AllowInvitesFrom = '$allowInvitesFrom'. Expected: 'adminsAndGuestInviters' or 'none'." `
            -AffectedResources @("AuthorizationPolicy (AllowInvitesFrom: $allowInvitesFrom)") `
            -TotalResources 1 -PassedResources 0 -FailedResources 1
    }
    catch {
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'ERROR' `
            -Details "Error checking guest invite restrictions: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS523-CustomAdminRoles {
    <#
    .SYNOPSIS
        CIS 5.23 - Ensure that no custom subscription administrator roles exist.
    .DESCRIPTION
        Retrieves custom role definitions and checks if any have Actions containing
        the wildcard '*' which grants full subscription-level permissions.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $customRoles = @(Get-AzRoleDefinition -Custom -ErrorAction Stop)

        if ($customRoles.Count -eq 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "No custom role definitions found." `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        $totalCount  = $customRoles.Count
        $failedList  = [System.Collections.Generic.List[string]]::new()
        $passedCount = 0

        foreach ($role in $customRoles) {
            $hasWildcard = $false
            if ($role.Actions) {
                foreach ($action in $role.Actions) {
                    if ($action -eq '*') {
                        $hasWildcard = $true
                        break
                    }
                }
            }

            if ($hasWildcard) {
                # Only flag roles assignable at subscription or root scope
                $isSubScope = $false
                if ($role.AssignableScopes) {
                    foreach ($scope in $role.AssignableScopes) {
                        # Match subscription scope (/subscriptions/xxx) or root scope (/)
                        if ($scope -eq '/' -or $scope -match '^/subscriptions/[^/]+$') {
                            $isSubScope = $true
                            break
                        }
                    }
                }
                else {
                    # No scopes defined — assume subscription-level (conservative)
                    $isSubScope = $true
                }

                if ($isSubScope) {
                    $scopeDisplay = if ($role.AssignableScopes) { ($role.AssignableScopes -join ', ') } else { 'unknown' }
                    $failedList.Add("$($role.Name) (Id: $($role.Id), Scopes: $scopeDisplay)")
                }
                else {
                    # Wildcard action but only at resource group or lower — not a subscription admin
                    $passedCount++
                }
            }
            else {
                $passedCount++
            }
        }

        $failedCount = $failedList.Count
        if ($failedCount -gt 0) {
            $details = "Found $failedCount custom role(s) with wildcard (*) actions: $($failedList -join '; ')"
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'FAIL' `
                -Details $details `
                -AffectedResources $failedList.ToArray() `
                -TotalResources $totalCount `
                -PassedResources $passedCount `
                -FailedResources $failedCount
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'PASS' `
            -Details "All $totalCount custom role(s) are properly scoped without wildcard actions." `
            -TotalResources $totalCount `
            -PassedResources $passedCount `
            -FailedResources 0
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking custom admin roles: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS527-SubscriptionOwners {
    <#
    .SYNOPSIS
        CIS 5.27 - Ensure there are between 2 and 3 subscription owners.
    .DESCRIPTION
        Retrieves Owner role assignments at the subscription scope and verifies
        the count is between 2 and 3 (inclusive).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $ownerAssignments = @(Get-AzRoleAssignment -RoleDefinitionName 'Owner' -ErrorAction Stop |
            Where-Object { $_.Scope -match '^/subscriptions/[^/]+$' })

        # CIS benchmark counts total security principals (users, groups, SPs, managed identities)
        # assigned the Owner role — NOT expanded group memberships
        $totalCount = $ownerAssignments.Count

        $ownerDetails = ($ownerAssignments | ForEach-Object {
            $type = if ($_.ObjectType -eq 'User') { 'User' } elseif ($_.ObjectType -eq 'Group') { 'Group' } else { $_.ObjectType }
            "$($_.DisplayName) [$type]"
        }) -join ', '

        if ($totalCount -ge 2 -and $totalCount -le 3) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "Subscription has $totalCount Owner role assignment(s), within the recommended range (2-3). Owners: $ownerDetails" `
                -TotalResources $totalCount `
                -PassedResources $totalCount `
                -FailedResources 0
        }

        if ($totalCount -lt 2) {
            $details = "Subscription has only $totalCount Owner role assignment(s). Minimum 2 recommended for availability. Owners: $ownerDetails"
        }
        else {
            $details = "Subscription has $totalCount Owner role assignment(s), exceeding the recommended maximum of 3. Owners: $ownerDetails"
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'FAIL' `
            -Details $details `
            -AffectedResources ($ownerAssignments | ForEach-Object { "$($_.DisplayName) ($($_.SignInName)) [$($_.ObjectType)]" }) `
            -TotalResources $totalCount `
            -PassedResources 0 `
            -FailedResources $totalCount
    }
    catch {
        $status = if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') { 'WARNING' } else { 'ERROR' }
        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status $status `
            -Details "$(if ($status -eq 'WARNING') { 'Insufficient permissions' } else { 'Error' }) checking subscription owners: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}

function Test-CIS533-UserAccessAdmin {
    <#
    .SYNOPSIS
        CIS 5.3.3 - Ensure use of 'User Access Administrator' role is restricted.
    .DESCRIPTION
        Checks for User Access Administrator role assignments at root scope (/).
        These should be empty or minimal.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlDef,

        [Parameter(Mandatory)]
        [hashtable]$ResourceCache
    )

    try {
        $rootAssignments = @(Get-AzRoleAssignment -RoleDefinitionName 'User Access Administrator' -Scope '/' -ErrorAction Stop)

        $assignmentCount = $rootAssignments.Count

        if ($assignmentCount -eq 0) {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'PASS' `
                -Details "No User Access Administrator role assignments found at root scope (/)." `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        # Any assignment at root scope is a concern
        $affectedResources = $rootAssignments | ForEach-Object {
            "$($_.DisplayName) ($($_.SignInName)) - Scope: $($_.Scope)"
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'FAIL' `
            -Details "Found $assignmentCount User Access Administrator assignment(s) at root scope (/). This role should be tightly restricted. Assigned to: $(($rootAssignments | ForEach-Object { $_.DisplayName }) -join ', '). Note: Some assignments may be temporary PIM/JIT activations - verify in Azure AD PIM." `
            -AffectedResources $affectedResources `
            -TotalResources $assignmentCount `
            -PassedResources 0 `
            -FailedResources $assignmentCount
    }
    catch {
        # Permission error is common when checking root scope
        if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization') {
            return New-CISCheckResult `
                -ControlId $ControlDef.ControlId `
                -Title $ControlDef.Title `
                -Status 'WARNING' `
                -Details "Insufficient permissions to check User Access Administrator assignments at root scope. Elevated access is required. Error: $($_.Exception.Message)" `
                -TotalResources 0 -PassedResources 0 -FailedResources 0
        }

        return New-CISCheckResult `
            -ControlId $ControlDef.ControlId `
            -Title $ControlDef.Title `
            -Status 'ERROR' `
            -Details "Error checking User Access Administrator role: $(Format-CISErrorMessage $_.Exception.Message)"
    }
}