m365advisor-tests/iso27002/Test-MtIso27002-Entra.Tests.ps1

##########################################################################
# ISO 27002 / ISO 27002 - Entra ID Security & Governance Tests
# Ported from M365-Assess: Entra/*
# Controls: A.5.15, A.5.16, A.5.18, A.8.2, A.8.3, A.8.5, A.8.9
##########################################################################

Describe "ISO 27002 - Entra ID Admin Accounts" -Tag "ISO 27002", "ISO 27002", "ISO27002:A.5.18", "ISO27002:A.5.18" {
    BeforeAll {
        $script:graphConnected = $false
        try {
            $context = Get-MgContext -ErrorAction Stop
            if ($context -and $context.TenantId) {
                $script:graphConnected = $true
            }
        } catch {}

        $script:gaRoleTemplateId = '62e90394-69f5-4237-9190-012177145e10'
        $script:praRoleTemplateId = 'e8611ab8-c189-46e8-94e1-60213ab1f814'

        $script:subscribedSkus = $null
        try {
            $script:subscribedSkus = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/subscribedSkus' -ErrorAction Stop
        } catch {}

        # Detect Entra ID P2 license for PIM / Access Reviews
        $script:hasP2License = $false
        if ($script:subscribedSkus -and $script:subscribedSkus['value']) {
            $aadP2PlanId = 'eec0eb4f-6444-4f95-aba0-50c24d67f998'
            foreach ($sku in $script:subscribedSkus['value']) {
                if ($sku['capabilityStatus'] -ne 'Enabled') { continue }
                $plans = if ($sku['servicePlans']) { @($sku['servicePlans']) } else { @() }
                foreach ($sp in $plans) {
                    if ($sp['servicePlanId'] -eq $aadP2PlanId -and $sp['provisioningStatus'] -eq 'Success') {
                        $script:hasP2License = $true
                        break
                    }
                }
                if ($script:hasP2License) { break }
            }
        }
    }

    It "ISO27002.5.18.1: Operational Global Administrators count must be between 2 and 4" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        $gaRole = $null
        try {
            $gaRole = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/directoryRoles?`$filter=displayName eq 'Global Administrator'" -ErrorAction Stop
        } catch {
            throw "Failed to fetch directory roles: $($_.Exception.Message)"
        }

        if (-not $gaRole -or -not $gaRole['value'] -or $gaRole['value'].Count -eq 0) {
            Set-ItResult -Skipped -Because "Global Administrator directory role is not activated in this tenant"
            return
        }

        $roleId = $gaRole['value'][0]['id']
        $membersResp = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/directoryRoles/$roleId/members" -ErrorAction Stop
        $allAdmins = if ($membersResp -and $membersResp['value']) { @($membersResp['value']) } else { @() }

        # Simple heuristic to identify break-glass/emergency accounts from UPN/DisplayName
        $operationalAdmins = @($allAdmins | Where-Object {
            $upn = ("$($_['userPrincipalName'])".ToLower())
            $name = ("$($_['displayName'])".ToLower())
            -not ($upn -match 'breakglass|emergency|bypass|emergencyadmin' -or $name -match 'break-glass|emergency')
        })

        $gaCount = $operationalAdmins.Count
        $gaCount | Should -BeGreaterOrEqual 2 -Because "ISO A.5.18 requires redundancy - at least 2 Global Administrators are needed for emergency access"
        $gaCount | Should -BeLessOrEqual 4 -Because "ISO A.5.18 requires minimizing privileged roles - no more than 4 Global Administrators should exist"
    }

    It "ISO27002.5.18.2: PIM must manage Global Administrator role (no permanent standing assignments)" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:hasP2License) {
            Set-ItResult -Skipped -Because "PIM requires an Entra ID P2 license which is not present"
            return
        }

        $gaRoleResp = $null
        try {
            $gaRoleResp = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/directoryRoles(roleTemplateId='$script:gaRoleTemplateId')/members" -ErrorAction Stop
        } catch {
            throw "Failed to fetch GA role members: $($_.Exception.Message)"
        }

        $gaMembers = if ($gaRoleResp -and $gaRoleResp['value']) { @($gaRoleResp['value']) } else { @() }

        $eligibleResp = Invoke-MgGraphRequest -Method GET -Uri "/beta/roleManagement/directory/roleEligibilityScheduleInstances?`$filter=roleDefinitionId eq '$script:gaRoleTemplateId'" -ErrorAction SilentlyContinue
        $eligibleIds = if ($eligibleResp -and $eligibleResp['value']) {
            @($eligibleResp['value'] | ForEach-Object { $_['principalId'] })
        } else { @() }

        $permanentGAs = @($gaMembers | Where-Object { $_['id'] -notin $eligibleIds })
        $permanentGAs.Count | Should -Be 0 -Because "ISO A.5.18 requires privileged roles to use JIT access (PIM) - no standing permanent GA assignments are allowed"
    }

    It "ISO27002.5.18.3: Guest access reviews must be configured" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:hasP2License) {
            Set-ItResult -Skipped -Because "Access reviews require an Entra ID P2 license"
            return
        }

        $reviews = $null
        try {
            $reviews = Invoke-MgGraphRequest -Method GET -Uri '/beta/identityGovernance/accessReviews/definitions?$top=100' -ErrorAction Stop
        } catch {
            Set-ItResult -Skipped -Because "Unable to query access review definitions: $($_.Exception.Message)"
            return
        }

        $allReviews = if ($reviews -and $reviews['value']) { @($reviews['value']) } else { @() }
        $guestReviews = @($allReviews | Where-Object {
            $_['scope'] -and ($_['scope']['query'] -match 'guest' -or $_['scope']['@odata.type'] -match 'guest')
        })

        $guestReviews.Count | Should -BeGreaterOrEqual 1 -Because "ISO A.5.18 / A.5.16 require periodic reviews of guest access to maintain authorization controls"
    }

    It "ISO27002.5.18.4: Privileged roles access reviews must be configured" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:hasP2License) {
            Set-ItResult -Skipped -Because "Access reviews require an Entra ID P2 license"
            return
        }

        $reviews = $null
        try {
            $reviews = Invoke-MgGraphRequest -Method GET -Uri '/beta/identityGovernance/accessReviews/definitions?$top=100' -ErrorAction Stop
        } catch {
            Set-ItResult -Skipped -Because "Unable to query access review definitions"
            return
        }

        $allReviews = if ($reviews -and $reviews['value']) { @($reviews['value']) } else { @() }
        $roleReviews = @($allReviews | Where-Object {
            $_['scope'] -and ($_['scope']['query'] -match 'roleManagement|directoryRole')
        })

        $roleReviews.Count | Should -BeGreaterOrEqual 1 -Because "ISO A.5.18 requires periodic reviews of administrator assignments to enforce least privilege"
    }

    It "ISO27002.5.18.5: Global Administrator activation must require approval" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:hasP2License) {
            Set-ItResult -Skipped -Because "PIM activation policy requires an Entra ID P2 license"
            return
        }

        $filter = "scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '$script:gaRoleTemplateId'"
        $uri = "/v1.0/policies/roleManagementPolicyAssignments?`$filter=$filter&`$expand=policy(`$expand=rules)"
        $assignmentsResp = $null
        try {
            $assignmentsResp = Invoke-MgGraphRequest -Method GET -Uri $uri -ErrorAction Stop
        } catch {
            Set-ItResult -Skipped -Because "Unable to read PIM activation policy for Global Administrator"
            return
        }

        $assignment = if ($assignmentsResp -and $assignmentsResp['value']) { @($assignmentsResp['value'])[0] } else { $null }
        $rules = if ($assignment -and $assignment['policy'] -and $assignment['policy']['rules']) {
            @($assignment['policy']['rules'])
        } else { @() }

        $approvalRule = $rules | Where-Object { $_['@odata.type'] -match 'ApprovalRule' } | Select-Object -First 1
        $approvalRequired = $false
        if ($approvalRule -and $approvalRule['setting']) {
            $approvalRequired = [bool]$approvalRule['setting']['isApprovalRequired']
        }

        $approvalRequired | Should -Be $true -Because "ISO A.5.18 requires high-impact role activation to be authorized by a secondary reviewer"
    }

    It "ISO27002.5.18.6: Privileged Role Administrator activation must require approval" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:hasP2License) {
            Set-ItResult -Skipped -Because "PIM activation policy requires an Entra ID P2 license"
            return
        }

        $filter = "scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '$script:praRoleTemplateId'"
        $uri = "/v1.0/policies/roleManagementPolicyAssignments?`$filter=$filter&`$expand=policy(`$expand=rules)"
        $assignmentsResp = $null
        try {
            $assignmentsResp = Invoke-MgGraphRequest -Method GET -Uri $uri -ErrorAction Stop
        } catch {
            Set-ItResult -Skipped -Because "Unable to read PIM activation policy for Privileged Role Administrator"
            return
        }

        $assignment = if ($assignmentsResp -and $assignmentsResp['value']) { @($assignmentsResp['value'])[0] } else { $null }
        $rules = if ($assignment -and $assignment['policy'] -and $assignment['policy']['rules']) {
            @($assignment['policy']['rules'])
        } else { @() }

        $approvalRule = $rules | Where-Object { $_['@odata.type'] -match 'ApprovalRule' } | Select-Object -First 1
        $approvalRequired = $false
        if ($approvalRule -and $approvalRule['setting']) {
            $approvalRequired = [bool]$approvalRule['setting']['isApprovalRequired']
        }

        $approvalRequired | Should -Be $true -Because "ISO A.5.18 requires Privileged Role Administrator activation to be authorized by a secondary reviewer"
    }

    It "ISO27002.5.18.7: All Global Administrator accounts must be cloud-only" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        $gaMembers = $null
        try {
            $gaMembers = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/directoryRoles/roleTemplateId=$script:gaRoleTemplateId/members?`$select=displayName,userPrincipalName,onPremisesSyncEnabled" -ErrorAction Stop
        } catch {
            throw "Failed to fetch GA role members"
        }

        $gaList = if ($gaMembers -and $gaMembers['value']) { @($gaMembers['value']) } else { @() }
        $syncedAdmins = @($gaList | Where-Object { $_['onPremisesSyncEnabled'] -eq $true })

        $syncedAdmins.Count | Should -Be 0 -Because "ISO A.5.18 requires admin accounts to be decoupled from on-premises directories to prevent domain controller compromise escalation"
    }

    It "ISO27002.5.18.8: Global Administrators must not be assigned full M365 E3/E5 productivity licenses" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        $gaUsersLicense = $null
        try {
            $gaUsersLicense = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/directoryRoles/roleTemplateId=$script:gaRoleTemplateId/members?`$select=displayName,assignedLicenses" -ErrorAction Stop
        } catch {
            throw "Failed to fetch GA license assignments"
        }

        # Common productivity SKU IDs (M365/O365 E3 and E5 suites)
        $productivitySkus = @(
            '05e9a617-0261-4cee-bb36-b42c3d50e6a0', # SPE_E3 (M365 E3)
            '06ebc4ee-1bb5-47dd-8120-11324bc54e06', # SPE_E5 (M365 E5)
            '6fd2c87f-b296-42f0-b197-1e91e994b900', # ENTERPRISEPACK (O365 E3)
            'c7df2760-2c81-4ef7-b578-5b5392b571df'  # ENTERPRISEPREMIUM (O365 E5)
        )

        $gaLicenseList = if ($gaUsersLicense -and $gaUsersLicense['value']) { @($gaUsersLicense['value']) } else { @() }
        $heavyLicensed = @($gaLicenseList | Where-Object {
            $licenses = $_['assignedLicenses']
            $licenses | Where-Object { $productivitySkus -contains $_['skuId'] }
        })

        $heavyLicensed.Count | Should -Be 0 -Because "ISO A.5.18 / A.8.9 require dedicated admin accounts with minimal scope - admin accounts should not have mailboxes/OneDrive to mitigate phishing risk"
    }

    It "ISO27002.8.3.1: Restrict non-admin users from accessing Microsoft Entra admin center" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        $authPolicy = $null
        try {
            $authPolicy = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/policies/authorizationPolicy' -ErrorAction Stop
        } catch {
            throw "Failed to fetch authorization policy: $($_.Exception.Message)"
        }

        $restricted = $authPolicy['restrictNonAdminUsers']
        $restricted | Should -Be $true -Because "ISO A.8.3 / A.5.15 require limiting access to management directories to administrative accounts only"
    }

    It "ISO27002.8.5.1: All Global Administrators must have MFA registered" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        $adminMembers = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/directoryRoles/roleTemplateId=$script:gaRoleTemplateId/members?`$select=id,displayName" -ErrorAction Stop
        $adminList = if ($adminMembers -and $adminMembers['value']) { @($adminMembers['value']) } else { @() }

        if ($adminList.Count -eq 0) {
            Set-ItResult -Skipped -Because "No Global Administrator members found to verify MFA"
            return
        }

        $mfaDetails = Invoke-MgGraphRequest -Method GET -Uri '/beta/reports/authenticationMethods/userRegistrationDetails' -ErrorAction Stop
        $mfaList = if ($mfaDetails -and $mfaDetails['value']) { @($mfaDetails['value']) } else { @() }

        $adminIds = @($adminList | ForEach-Object { $_['id'] })
        $adminMfa = @($mfaList | Where-Object { $_['id'] -in $adminIds })

        $adminsNoMfa = @($adminMfa | Where-Object { -not $_['isMfaRegistered'] })
        $adminsNoMfa.Count | Should -Be 0 -Because "ISO A.8.5 / A.5.15 require strong authentication controls for all administrative roles"
    }
}

Describe "ISO 27002 - Entra ID Password & MFA Settings" -Tag "ISO 27002", "ISO 27002", "ISO27002:A.8.5", "ISO27002:A.5.15", "ISO27002:A.8.5", "ISO27002:A.5.15" {
    BeforeAll {
        $script:authMethodsPolicy = $null
        try {
            $script:authMethodsPolicy = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/policies/authenticationMethodsPolicy' -ErrorAction Stop
        } catch {}
    }

    It "ISO27002.5.15.1: Security Defaults or equivalent Conditional Access policies must be enabled" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        $secDefaults = $null
        try {
            $secDefaults = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/policies/identitySecurityDefaultsEnforcementPolicy' -ErrorAction Stop
        } catch {
            throw "Failed to fetch security defaults policy"
        }

        $sdEnabled = $secDefaults['isEnabled']
        if ($sdEnabled -eq $true) {
            $sdEnabled | Should -Be $true -Because "Security defaults are enabled"
            return
        }

        # If security defaults is off, evaluate if there are active Conditional Access policies
        $caResp = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/identity/conditionalAccess/policies' -ErrorAction Stop
        $policies = if ($caResp -and $caResp['value']) { @($caResp['value']) } else { @() }
        $enabledCount = @($policies | Where-Object { $_['state'] -eq 'enabled' }).Count

        $enabledCount | Should -BeGreaterThan 0 -Because "ISO A.5.15 / A.8.5 require tenant security defaults or active Conditional Access policies for MFA enforcement"
    }

    It "ISO27002.8.5.2: Telephony-based authentication methods (SMS and Voice) must be disabled" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:authMethodsPolicy) {
            Set-ItResult -Skipped -Because "Authentication methods policy not available"
            return
        }

        $authMethods = $script:authMethodsPolicy['authenticationMethodConfigurations']
        if (-not $authMethods) {
            Set-ItResult -Skipped -Because "No auth method configurations returned"
            return
        }

        $smsMethod = $authMethods | Where-Object { $_['id'] -eq 'Sms' }
        $smsState = if ($smsMethod) { $smsMethod['state'] } else { 'disabled' }
        $smsState | Should -Be 'disabled' -Because "ISO A.8.5 requires disabling weak factors - SMS authentication is vulnerable to SIM swap attacks"

        $voiceMethod = $authMethods | Where-Object { $_['id'] -eq 'Voice' }
        $voiceState = if ($voiceMethod) { $voiceMethod['state'] } else { 'disabled' }
        $voiceState | Should -Be 'disabled' -Because "ISO A.8.5 requires disabling weak factors - Voice call authentication is vulnerable to telephony-based intercepts"
    }

    It "ISO27002.8.5.3: Email OTP authentication must be disabled" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:authMethodsPolicy) {
            Set-ItResult -Skipped -Because "Authentication methods policy not available"
            return
        }

        $authMethods = $script:authMethodsPolicy['authenticationMethodConfigurations']
        $emailMethod = $authMethods | Where-Object { $_['id'] -eq 'Email' }
        $emailState = if ($emailMethod) { $emailMethod['state'] } else { 'disabled' }

        $emailState | Should -Be 'disabled' -Because "ISO A.8.5 requires restricting weak methods - Email OTP is a weak authentication factor"
    }

    It "ISO27002.8.5.4: Custom banned password lists must be enabled" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        $pwSettings = $null
        try {
            $pwSettingsResp = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/settings' -ErrorAction Stop
            $pwSettings = $pwSettingsResp['value'] | Where-Object { $_['displayName'] -eq 'Password Rule Settings' }
        } catch {
            # 404 or BadRequest is common when never configured (uses Microsoft default, custom list not active)
        }

        if (-not $pwSettings) {
            $null | Should -Not -Be $null -Because "ISO A.8.5 requires enforcing banned passwords - Custom banned password list has not been initialized"
            return
        }

        $enforceCustomEntry = $pwSettings['values'] | Where-Object { $_['name'] -eq 'EnableBannedPasswordCheck' }
        $enforceCustom = if ($enforceCustomEntry) { $enforceCustomEntry['value'] } else { 'False' }

        $enforceCustom | Should -Be 'True' -Because "ISO A.8.5 requires custom banned password check to be explicitly enabled"
    }

    It "ISO27002.8.5.5: Smart Lockout threshold must be 10 or fewer attempts" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        $pwSettings = $null
        try {
            $pwSettingsResp = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/settings' -ErrorAction Stop
            $pwSettings = $pwSettingsResp['value'] | Where-Object { $_['displayName'] -eq 'Password Rule Settings' }
        } catch {}

        if (-not $pwSettings) {
            Set-ItResult -Skipped -Because "Directory settings not customized (using default smart lockout)"
            return
        }

        $lockoutEntry = $pwSettings['values'] | Where-Object { $_['name'] -eq 'LockoutThreshold' }
        $lockoutThreshold = if ($lockoutEntry) { [int]$lockoutEntry['value'] } else { 10 }

        $lockoutThreshold | Should -BeLessOrEqual 10 -Because "ISO A.8.5 requires smart lockout thresholds to restrict brute-force password guessing attempts"
    }

    It "ISO27002.8.5.6: Verified domains must have passwords set to never expire (with MFA)" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        $domains = $null
        try {
            $domains = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/domains' -ErrorAction Stop
        } catch {
            throw "Failed to fetch domains"
        }

        $domainList = if ($domains -and $domains['value']) { @($domains['value']) } else { @() }
        $verifiedDomains = @($domainList | Where-Object { $_['isVerified'] -eq $true })

        if ($verifiedDomains.Count -eq 0) {
            Set-ItResult -Skipped -Because "No verified domains found"
            return
        }

        $failingDomains = @()
        foreach ($dom in $verifiedDomains) {
            $validityDays = $dom['passwordValidityPeriodInDays']
            if ($validityDays -ne 2147483647) { $failingDomains += $dom['id'] }
        }

        $failingDomains.Count | Should -Be 0 -Because "ISO A.8.5 recommends password-never-expires policy (when MFA is enforced) to prevent predictable password updates"
    }

    It "ISO27002.8.5.7: Authenticator fatigue protection (number matching + app info context) must be enabled" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:authMethodsPolicy) {
            Set-ItResult -Skipped -Because "Authentication methods policy not available"
            return
        }

        $authMethods = $script:authMethodsPolicy['authenticationMethodConfigurations']
        $authenticator = $authMethods | Where-Object { $_['id'] -eq 'MicrosoftAuthenticator' }

        if (-not $authenticator) {
            Set-ItResult -Skipped -Because "Microsoft Authenticator not configured"
            return
        }

        $featureSettings = $authenticator['featureSettings']
        if (-not $featureSettings) {
            throw "Feature settings for Authenticator are missing"
        }

        $numberMatchState = $featureSettings['numberMatchingRequiredState']
        $appInfoState = $featureSettings['displayAppInformationRequiredState']

        # Absent or default means enabled/enforced by MS default
        $numberMatch = if ($numberMatchState) { $numberMatchState['state'] } else { 'default' }
        $appInfo = if ($appInfoState) { $appInfoState['state'] } else { 'disabled' }

        $numberMatchOn = $numberMatch -in @('enabled', 'default')
        $appInfoOn = $appInfo -eq 'enabled'

        $numberMatchOn | Should -Be $true -Because "ISO A.8.5 requires Authenticator number matching to prevent accidental MFA approvals"
        $appInfoOn | Should -Be $true -Because "ISO A.8.5 requires application context display in MFA prompts to block MFA fatigue attacks"
    }

    It "ISO27002.8.5.8: System-Preferred MFA must be enabled" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:authMethodsPolicy) {
            Set-ItResult -Skipped -Because "Authentication methods policy not available"
            return
        }

        $systemPreferred = $script:authMethodsPolicy['systemCredentialPreferences']
        # Absent or default resolves to enabled by Microsoft
        $sysState = if ($systemPreferred) { $systemPreferred['state'] } else { 'enabled' }
        $sysEnabled = $sysState -in @('enabled', 'default')

        $sysEnabled | Should -Be $true -Because "ISO A.8.5 requires system-preferred MFA to prompt users for their strongest registered method first"
    }
}

Describe "ISO 27002 - Entra ID Device Settings" -Tag "ISO 27002", "ISO 27002", "ISO27002:A.8.2", "ISO27002:A.8.9", "ISO27002:A.8.2", "ISO27002:A.8.9" {
    BeforeAll {
        $script:devicePolicy = $null
        try {
            $script:devicePolicy = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/policies/deviceRegistrationPolicy' -ErrorAction Stop
        } catch {}

        $script:devicePolicyBeta = $null
        try {
            $script:devicePolicyBeta = Invoke-MgGraphRequest -Method GET -Uri '/beta/policies/deviceRegistrationPolicy' -ErrorAction Stop
        } catch {}
    }

    It "ISO27002.8.2.1: Microsoft Entra Join permissions must be restricted (not set to All)" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:devicePolicy) {
            Set-ItResult -Skipped -Because "Device registration policy not accessible"
            return
        }

        $joinType = $script:devicePolicy['azureADJoin']['allowedToJoin']['@odata.type']
        $joinRestricted = $joinType -ne '#microsoft.graph.allDeviceRegistrationMembership'

        $joinRestricted | Should -Be $true -Because "ISO A.8.2 / A.8.9 require limiting device registration permission to authorized user groups to prevent unmanaged device join"
    }

    It "ISO27002.8.9.1: Maximum device quota per user must be 15 or fewer" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:devicePolicy) {
            Set-ItResult -Skipped -Because "Device registration policy not accessible"
            return
        }

        $maxDevices = $script:devicePolicy['userDeviceQuota']
        $maxDevices | Should -BeLessOrEqual 15 -Because "ISO A.8.9 requires restricting the maximum number of devices a user can join to reduce attack surface"
    }

    It "ISO27002.8.3.2: Global Administrator role must not be added as local administrator on Entra-joined devices" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:devicePolicy) {
            Set-ItResult -Skipped -Because "Device registration policy not accessible"
            return
        }

        $gaLocalAdmin = $true
        if ($script:devicePolicy['azureADJoin']['localAdmins']) {
            $gaLocalAdmin = $script:devicePolicy['azureADJoin']['localAdmins']['enableGlobalAdmins']
        }

        $gaLocalAdmin | Should -Be $false -Because "ISO A.8.3 requires restriction of administrative access - adding GAs as local admins exposes their accounts to compromise on user endpoints"
    }

    It "ISO27002.8.9.2: Local Administrator Password Solution (LAPS) must be enabled in Entra ID" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:devicePolicyBeta) {
            Set-ItResult -Skipped -Because "Beta device registration policy not accessible"
            return
        }

        $lapsEnabled = $false
        if ($script:devicePolicyBeta['localAdminPassword']) {
            $lapsEnabled = $script:devicePolicyBeta['localAdminPassword']['isEnabled']
        }

        $lapsEnabled | Should -Be $true -Because "ISO A.8.9 requires automated local administrator password rotation via cloud LAPS to protect endpoints"
    }
}

Describe "ISO 27002 - Entra ID App Consent & Group Settings" -Tag "ISO 27002", "ISO 27002", "ISO27002:A.8.9", "ISO27002:A.8.3", "ISO27002:A.8.9", "ISO27002:A.8.3" {
    BeforeAll {
        $script:authPolicy = $null
        try {
            $script:authPolicy = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/policies/authorizationPolicy' -ErrorAction Stop
        } catch {}
    }

    It "ISO27002.8.9.3: User consent for applications must be blocked" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:authPolicy) {
            Set-ItResult -Skipped -Because "Authorization policy not available"
            return
        }

        $consentPolicy = $script:authPolicy['defaultUserRolePermissions']['permissionGrantPoliciesAssigned']
        $isRestricted = ($null -eq $consentPolicy) -or ($consentPolicy.Count -eq 0)

        $isRestricted | Should -Be $true -Because "ISO A.8.9 / A.8.3 require blocking end-user application consent to prevent unauthorized data access by third-party integrations"
    }

    It "ISO27002.8.9.4: Users must not be allowed to register custom applications" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:authPolicy) {
            Set-ItResult -Skipped -Because "Authorization policy not available"
            return
        }

        $canRegister = $script:authPolicy['defaultUserRolePermissions']['allowedToCreateApps']
        $canRegister | Should -Be $false -Because "ISO A.8.9 requires restricting developer features in production - end users should not register OAuth applications"
    }

    It "ISO27002.5.16.1: Users must not be allowed to create security groups" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:authPolicy) {
            Set-ItResult -Skipped -Because "Authorization policy not available"
            return
        }

        $canCreateGroups = $script:authPolicy['defaultUserRolePermissions']['allowedToCreateSecurityGroups']
        $canCreateGroups | Should -Be $false -Because "ISO A.5.16 / A.8.9 require structured group governance to maintain accurate access boundaries"
    }

    It "ISO27002.8.9.5: Non-admin users must be restricted from creating directories/tenants" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        if (-not $script:authPolicy) {
            Set-ItResult -Skipped -Because "Authorization policy not available"
            return
        }

        $canCreateTenants = $script:authPolicy['defaultUserRolePermissions']['allowedToCreateTenants']
        $canCreateTenants | Should -Be $false -Because "ISO A.8.9 requires restricting unauthorized shadow IT tenant creation under the corporate root"
    }

    It "ISO27002.8.9.6: Admin Consent Workflow must be enabled" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        $adminConsentSettings = $null
        try {
            $adminConsentSettings = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/policies/adminConsentRequestPolicy' -ErrorAction Stop
        } catch {
            throw "Failed to fetch admin consent request policy"
        }

        $adminConsentSettings['isEnabled'] | Should -Be $true -Because "ISO A.8.9 requires a structured admin consent workflow to allow request, review, and approval of enterprise integrations"
    }

    It "ISO27002.8.9.7: LinkedIn account connections must be disabled" {
        if (-not $script:graphConnected) {
            Set-ItResult -Skipped -Because "Not authenticated to Microsoft Graph"
            return
        }

        $tenantId = $null
        try {
            $context = Get-MgContext
            $tenantId = $context.TenantId
        } catch {}

        if (-not $tenantId) {
            Set-ItResult -Skipped -Because "Tenant ID could not be determined"
            return
        }

        $orgSettings = $null
        try {
            $orgSettings = Invoke-MgGraphRequest -Method GET -Uri "/beta/organization/$tenantId" -ErrorAction Stop
        } catch {
            throw "Failed to fetch organization settings"
        }

        $linkedInEnabled = $true
        if ($orgSettings -and $orgSettings['linkedInConfiguration']) {
            $linkedInEnabled = -not $orgSettings['linkedInConfiguration']['isDisabled']
        }

        $linkedInEnabled | Should -Be $false -Because "ISO A.8.9 requires disabling consumer directory synchronization to prevent data leakage of user information"
    }
}