tests/Test-Assessment.21929.ps1

<#
.SYNOPSIS
    Checks if all entitlement management packages that apply to guests have expirations or access reviews configured in their assignment policies.
#>


function Test-Assessment-21929{
    [ZtTest(
        Category = 'Identity governance',
        ImplementationCost = 'Medium',
        MinimumLicense = ('P2','Governance'),
        Pillar = 'Identity',
        RiskLevel = 'Medium',
        SfiPillar = 'Protect tenants and isolate production systems',
        TenantType = ('Workforce','External'),
        TestId = 21929,
        Title = 'All entitlement management packages that apply to guests have expirations or access reviews configured in their assignment policies',
        UserImpact = 'Medium'
    )]
    [CmdletBinding()]
    param()

    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    if( -not (Get-ZtLicense EntraIDP2) ) {
        Add-ZtTestResultDetail -SkippedBecause NotLicensedEntraIDP2
        return
    }

    $activity = 'Checking entitlement management packages for external users have proper controls'
    Write-ZtProgress -Activity $activity -Status 'Getting assignment policies'

    # Query 1: Get all assignment policies with expanded access package information
    $assignmentPolicies = Invoke-ZtGraphRequest -RelativeUri 'identityGovernance/entitlementManagement/assignmentPolicies' -QueryParameters @{'$expand' = 'accessPackage'} -ApiVersion v1.0

    # Handle case where no policies exist or API returns null
    if ($null -eq $assignmentPolicies -or $assignmentPolicies.Count -eq 0) {
        Write-PSFMessage 'No assignment policies found in the tenant' -Level Verbose
        $assignmentPolicies = @()
    }

    # Client-side filter for policies that apply to external users
    $externalUserPolicies = @()

    foreach ($policy in $assignmentPolicies) {
        # Skip if requestorSettings is null or missing
        if ($null -eq $policy.requestorSettings) {
            Write-PSFMessage "Skipping policy $($policy.id) - no requestorSettings" -Level Debug
            continue
        }

        $requestorSettings = $policy.requestorSettings

        # Check if policy allows self-service or on-behalf requests
        $allowsSelfService = $requestorSettings.enableTargetsToSelfAddAccess -eq $true -or
                           $requestorSettings.enableTargetsToSelfRemoveAccess -eq $true -or
                           $requestorSettings.enableTargetsToSelfUpdateAccess -eq $true -or
                           $requestorSettings.enableOnBehalfRequestorsToAddAccess -eq $true -or
                           $requestorSettings.enableOnBehalfRequestorsToRemoveAccess -eq $true -or
                           $requestorSettings.enableOnBehalfRequestorsToUpdateAccess -eq $true

        if($allowsSelfService){
            # Check if policy applies to external users using allowedTargetScope property
            $appliesToExternal = Test-ZtExternalUserScope -TargetScope $policy.allowedTargetScope

            # Include policy if it allows requests AND applies to external users
            if ($appliesToExternal) {
                $externalUserPolicies += $policy
            }
        }

    }

    Write-PSFMessage "Found $($externalUserPolicies.Count) assignment policies that apply to external users" -Level Verbose

    # Query 2: Evaluate expiration and access review controls for each policy
    $policiesWithoutControls = @()
    $allPoliciesData = @()

    foreach ($policy in $externalUserPolicies) {
        # Check expiration configuration - handle null expiration object
        $hasExpiration = $null -ne $policy.expiration -and $policy.expiration.type -ne 'noExpiration'

        # Check access review configuration
        # Review settings must be enabled AND have a schedule with recurrence pattern configured
        $hasAccessReview = $false
        if ($null -ne $policy.reviewSettings -and $policy.reviewSettings.isEnabled -eq $true) {
            # Check if schedule exists with proper recurrence pattern
            if ($null -ne $policy.reviewSettings.schedule -and
                $null -ne $policy.reviewSettings.schedule.recurrence -and
                $null -ne $policy.reviewSettings.schedule.recurrence.pattern) {
                $hasAccessReview = $true
            }
        }

        # Skip policies with missing access package information
        if ($null -eq $policy.accessPackage) {
            Write-PSFMessage "Skipping policy $($policy.id) - no accessPackage information" -Level Verbose
            continue
        }

        # Create policy data for reporting
        $policyData = [PSCustomObject]@{
            AccessPackageId = $policy.accessPackage.id
            AccessPackageName = $policy.accessPackage.displayName
            AssignmentPolicyId = $policy.id
            AssignmentPolicyName = $policy.displayName
            HasExpiration = $hasExpiration
            HasAccessReview = $hasAccessReview
            HasControls = $hasExpiration -or $hasAccessReview
        }

        $allPoliciesData += $policyData

        # Track policies without proper controls
        if (-not ($hasExpiration -or $hasAccessReview)) {
            $policiesWithoutControls += $policyData
        }
    }

    # Assessment logic
    $passed = $policiesWithoutControls.Count -eq 0

    # Build report markdown
    $portalLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_ERM/DashboardBlade/~/elmEntitlement'

    if ($passed) {
        $testResultMarkdown = "All access package assignment policies for external users include expiration or access reviews.`n`n%PolicyDetails%"
    } else {
        $testResultMarkdown = "Access package assignment policies without expiration and without access reviews were found for external users.`n`n%PolicyDetails%"
    }

    # Build policy details table
    $mdInfo = "## [Access package assignment policies for external users]($portalLink)`n`n"

    if ($allPoliciesData.Count -gt 0) {
        $mdInfo += "| Access package | Assignment policy | Expiry configured | Access review configured | Status |`n"
        $mdInfo += "| :------------- | :---------------- | :------------------ | :--------------------- | :----- |`n"

        # Sort to show non-compliant policies first, then by access package and policy name
        foreach ($policyData in ($allPoliciesData | Sort-Object HasControls, AccessPackageName, AssignmentPolicyName)) {
            $packageName = $policyData.AccessPackageName
            $policyName = $policyData.AssignmentPolicyName

            $expirationStatus = if ($policyData.HasExpiration) { 'Yes' } else { 'No' }
            $reviewStatus = if ($policyData.HasAccessReview) { 'Yes' } else { 'No' }
            $overallStatus = if ($policyData.HasControls) { '✅ Compliant' } else { '❌ Non-compliant' }

            $mdInfo += "| $packageName | $policyName | $expirationStatus | $reviewStatus | $overallStatus |`n"
        }
    } else {
        $mdInfo += "No access package assignment policies found that apply to external users.`n"
    }

    # Replace placeholder in test result markdown
    $testResultMarkdown = $testResultMarkdown -replace "%PolicyDetails%", $mdInfo

    $params = @{
        TestId = '21929'
        Status = $passed
        Result = $testResultMarkdown
    }

    Add-ZtTestResultDetail @params
}