tests/Test-Assessment.51016.ps1

<#
.SYNOPSIS
    Privileged Identity Management is enforced for Intune Administrator role assignments.
 
.DESCRIPTION
    Checks that no enabled Member user holds a permanent standing Intune Administrator role
    assignment, and that the PIM activation policy for the role enforces MFA, justification,
    and a bounded activation duration (≤ PT8H).
 
    Four queries are executed:
    Q1 – Fetch the Intune Administrator role definition by templateId (3a2c62db-5318-420d-8d74-23affee5d9d5).
    Q2 – List roleAssignmentScheduleInstances for the role; count permanent standing enabled-Member
         assignments (assignmentType = 'Assigned', endDateTime = null).
    Q3 – List roleEligibilitySchedules for the role (informational, does not drive Pass/Fail).
    Q4 – Fetch the roleManagementPolicyAssignment and inspect Enablement_EndUser_Assignment and
         Expiration_EndUser_Assignment activation rules.
 
.NOTES
    Test ID: 51016
    Category: Devices
    Pillar: Devices
    Required API permissions: Directory.Read.All, RoleManagement.Read.All
#>


function Test-Assessment-51016 {
    [ZtTest(
        Category = 'Devices',
        CompatibleLicense = ('INTUNE_A&AAD_PREMIUM_P2'),
        ImplementationCost = 'Low',
        Pillar = 'Devices',
        RiskLevel = 'High',
        SfiPillar = 'Protect identities and secrets',
        TenantType = ('Workforce'),
        TestId = 51016,
        Title = 'Privileged Identity Management is enforced for Intune Administrator role assignments',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param()

    #region Helper Functions
    # Calls Add-ZtTestResultDetail and returns $true when the exception is 401/403/5xx.
    # Returns $false for all other errors so the caller can rethrow.
    function Test-ZtPimHttpError {
        param($Err, $Params)
        $status = Get-ZtHttpStatusCode -ErrorRecord $Err

        if ($status -eq 401 -or $status -eq 403 -or $status -ge 500) {
            Add-ZtTestResultDetail @Params
            return $true
        }
        return $false
    }
    #endregion Helper Functions

    #region Data Collection
    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose

    $intuneAdminTemplateId = '3a2c62db-5318-420d-8d74-23affee5d9d5'
    $activity = 'Checking PIM enforcement for Intune Administrator role assignments'
    $investigateParams = @{
        TestId       = '51016'
        Title        = 'Privileged Identity Management is enforced for Intune Administrator role assignments'
        Status       = $false
        Result       = '⚠️ The Microsoft Entra Privileged Identity Management API returned an authorization (401/403) or transient (5xx) error, so coverage could not be determined. Re-run after verifying caller permissions — Global Reader at tenant scope.'
        CustomStatus = 'Investigate'
    }

    # Q1: Fetch the Intune Administrator role definition by its well-known templateId.
    # For built-in directory roles, id == templateId, so a direct GET avoids $filter requirements.
    Write-ZtProgress -Activity $activity -Status 'Getting Intune Administrator role definition'

    $roleDefinition = $null
    try {
            $roleDefinition = Invoke-ZtGraphRequest -RelativeUri "roleManagement/directory/roleDefinitions/$intuneAdminTemplateId" -ApiVersion beta -ErrorAction Stop
    }
    catch {
        if (Test-ZtPimHttpError -Err $_ -Params $investigateParams) { return }
        throw
    }

    $intuneAdminRoleId = $roleDefinition.id

    # Q2: Enumerate roleAssignmentScheduleInstances for the Intune Administrator role.
    # assignmentType = 'Assigned' = permanent standing access; 'Activated' = time-bound PIM activation.
    Write-ZtProgress -Activity $activity -Status 'Getting role assignment schedule instances'

    $assignmentInstances = @()
    try {
        $assignmentInstances = @(Invoke-ZtGraphRequest `
            -RelativeUri 'roleManagement/directory/roleAssignmentScheduleInstances' `
            -Filter "roleDefinitionId eq '$intuneAdminRoleId'" `
            -QueryParameters @{ '$expand' = 'principal' } `
            -ApiVersion beta `
            -ErrorAction Stop)
    }
    catch {
        if (Test-ZtPimHttpError -Err $_ -Params $investigateParams) { return }
        throw
    }

    # Q3: Enumerate roleEligibilitySchedules (informational only — does not drive Pass/Fail).
    # Note: roleEligibilitySchedules is used instead of roleEligibilityScheduleInstances because
    # the latter does not accept roleDefinitionId as a $filter parameter.
    Write-ZtProgress -Activity $activity -Status 'Getting PIM eligible assignments'

    $eligibilitySchedules = @()
    try {
        $eligibilitySchedules = @(Invoke-ZtGraphRequest `
            -RelativeUri 'roleManagement/directory/roleEligibilitySchedules' `
            -Filter "roleDefinitionId eq '$intuneAdminRoleId'" `
            -QueryParameters @{ '$expand' = 'principal' } `
            -ApiVersion beta `
            -ErrorAction Stop)
    }
    catch {
        if (Test-ZtPimHttpError -Err $_ -Params $investigateParams) { return }
        throw
    }

    # Q4: Retrieve the role-management policy assignment and expand activation rules.
    Write-ZtProgress -Activity $activity -Status 'Getting role management activation policy'

    $policyAssignment = $null
    try {
        $policyFilter = "scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '$intuneAdminRoleId'"
        $policyResults = @(Invoke-ZtGraphRequest `
            -RelativeUri 'policies/roleManagementPolicyAssignments' `
            -Filter $policyFilter `
            -QueryParameters @{ '$expand' = 'policy($expand=rules)' } `
            -ApiVersion beta `
            -ErrorAction Stop)
        if ($policyResults.Count -gt 0) {
            $policyAssignment = $policyResults[0]
        }
    }
    catch {
        if (Test-ZtPimHttpError -Err $_ -Params $investigateParams) { return }
        throw
    }
    #endregion Data Collection

    #region Assessment Logic
    # Fail fast: no Q4 policy assignment found
    if (-not $policyAssignment) {
        $params = @{
            TestId = '51016'
            Title  = 'Privileged Identity Management is enforced for Intune Administrator role assignments'
            Status = $false
            Result = '❌ No PIM role-management policy assignment was found for the Intune Administrator role. The role has no activation policy bound — PIM is not enforced.'
        }
        Add-ZtTestResultDetail @params
        return
    }

    # Extract activation policy rules from Q4
    $rules          = $policyAssignment.policy.rules
    $enablementRule = $rules | Where-Object { $_.id -eq 'Enablement_EndUser_Assignment' }
    $expirationRule = $rules | Where-Object { $_.id -eq 'Expiration_EndUser_Assignment' }

    # Evaluate Q4 enablement controls
    $hasMfa               = $enablementRule -and ($enablementRule.enabledRules -contains 'MultiFactorAuthentication')
    $hasJustification     = $enablementRule -and ($enablementRule.enabledRules -contains 'Justification')
    $isExpirationRequired = $expirationRule -and ($expirationRule.isExpirationRequired -eq $true)

    # Parse maximumDuration and compare against PT8H (8 hours).
    # [System.Xml.XmlConvert]::ToTimeSpan() handles ISO 8601 durations natively.
    $maxDurationRaw    = $expirationRule.maximumDuration
    $maxDurationOk     = $false
    $maxDurationParsed = $false
    if ($isExpirationRequired -and $maxDurationRaw) {
        try {
            $maxDuration       = [System.Xml.XmlConvert]::ToTimeSpan($maxDurationRaw)
            $maxDurationParsed = $true
            $maxDurationOk     = $maxDuration.TotalSeconds -gt 0 -and $maxDuration.TotalSeconds -le 28800
        }
        catch { }
    }

    # Q2: Count permanent standing enabled-Member assignments.
    # Criteria: assignmentType='Assigned', endDateTime=null, principal is enabled Member user.
    $permanentStandingAssignments = @(
        $assignmentInstances | Where-Object {
            $_.assignmentType -eq 'Assigned' -and
            $null -eq $_.endDateTime -and
            $_.principal.'@odata.type' -eq '#microsoft.graph.user' -and
            $_.principal.userType -eq 'Member' -and
            $_.principal.accountEnabled -eq $true
        }
    )
    $permanentStandingCount = $permanentStandingAssignments.Count

    $passed = $hasMfa -and $hasJustification -and $isExpirationRequired -and $maxDurationOk -and ($permanentStandingCount -eq 0)

    if ($passed) {
        $testResultMarkdown = '✅ The Intune Administrator role is managed with safe privileged-access controls — no enabled Member user has standing permanent Intune Administrator access, and PIM activations require MFA, justification, and a bounded duration.'
    }
    else {
        $testResultMarkdown = '❌ The Intune Administrator role is not adequately protected — one or more enabled Member users has standing permanent Intune Administrator access, or the activation policy is missing MFA / justification / duration limits.'
    }
    $testResultMarkdown += "`n`n%TestResult%"
    #endregion Assessment Logic

    #region Report Generation
    $tenantId          = (Get-MgContext).TenantId
    $portalUrl         = "https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/UserRolesViewModelMenuBlade/~/members/menuId/members/roleName/Intune%20Administrator/roleObjectId/$intuneAdminRoleId/isRoleCustom~/false/roleTemplateId/$intuneAdminRoleId/resourceId/$tenantId/isInternalCall~/true"
    $roleSettingsPortalUrl = "https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/UserRolesViewModelMenuBlade/~/settings/menuId/members/roleName/Intune%20Administrator/roleObjectId/$intuneAdminRoleId/isRoleCustom~/false/roleTemplateId/$intuneAdminRoleId/resourceId/$tenantId/isInternalCall~/true"
    $maxItemsToDisplay = 10

    # --- Table 1: PIM Eligibility (Q3 — Informational) ---
    $eligibilityRows = ''
    if ($eligibilitySchedules.Count -gt 0) {
        foreach ($schedule in ($eligibilitySchedules | Select-Object -First $maxItemsToDisplay)) {
            $principalName = Get-SafeMarkdown -Text ($schedule.principal.displayName ?? $schedule.principalId)
            $odataType     = $schedule.principal.'@odata.type'
            $principalType = if ($odataType) { $odataType -replace '#microsoft.graph\.', '' } else { 'Unknown' }
            $memberType    = if ($schedule.memberType) { $schedule.memberType } else { '-' }
            $startRaw      = $schedule.scheduleInfo.startDateTime
            $exp           = $schedule.scheduleInfo.expiration
            $startDt       = if ($startRaw) { Get-FormattedDate $startRaw } else { 'N/A' }
            $endDt         = if ($exp.endDateTime)                              { Get-FormattedDate $exp.endDateTime }
                             elseif ($exp.type -eq 'noExpiration' -or -not $exp) { 'Permanent' }
                             elseif ($exp.duration)                              { $exp.duration }
                             else                                                { 'Permanent' }
            $scheduleStatus = if ($schedule.status) { $schedule.status } else { '-' }
            $eligibilityRows += "| $principalName | $principalType | $memberType | $startDt → $endDt | $scheduleStatus |`n"
        }
    }
    else {
        $activatedCount = @($assignmentInstances | Where-Object { $_.assignmentType -eq 'Activated' }).Count
        if ($activatedCount -gt 0) {
            $eligibilityRows = "| *(No persisted eligibility schedules, but $activatedCount current PIM activation(s) confirm PIM is in use)* | | | | |`n"
        }
        else {
            $eligibilityRows = "| *No eligible assignments found* | | | | |`n"
        }
    }

    # --- Table 2: Activation policy rules (Q4) ---
    $mfaValue    = if ($hasMfa) { 'Yes' } else { 'No' }
    $mfaStatus   = if ($hasMfa) { '✅ Pass' } else { '❌ Fail' }
    $justValue   = if ($hasJustification) { 'Yes' } else { 'No' }
    $justStatus  = if ($hasJustification) { '✅ Pass' } else { '❌ Fail' }
    $expireValue = if ($isExpirationRequired) { 'true' } else { 'false' }
    $expireStatus = if ($isExpirationRequired) { '✅ Pass' } else { '❌ Fail' }

    $durationDisplayValue = if ($maxDurationRaw) { $maxDurationRaw } else { 'Not set' }
    $durationStatus = if ($maxDurationOk) {
        '✅ Pass'
    }
    elseif (-not $isExpirationRequired) {
        '❌ Fail (expiration not required)'
    }
    elseif (-not $maxDurationParsed) {
        '❌ Fail (duration not parseable)'
    }
    else {
        "❌ Fail ($durationDisplayValue exceeds PT8H)"
    }

    $policyRows  = "| Enablement | MFA on activation | $mfaValue | Yes | $mfaStatus |`n"
    $policyRows += "| Enablement | Justification required | $justValue | Yes | $justStatus |`n"
    $policyRows += "| Expiration | Activation must expire | $expireValue | true | $expireStatus |`n"
    $policyRows += "| Expiration | Maximum activation duration | $durationDisplayValue | ≤PT8H | $durationStatus |`n"

    # --- Table 3: Standing permanent assignments (Q2) ---
    $standingRows = ''
    if ($assignmentInstances.Count -gt 0) {
        foreach ($instance in ($assignmentInstances | Select-Object -First $maxItemsToDisplay)) {
            $principalName = Get-SafeMarkdown -Text ($instance.principal.displayName ?? $instance.principalId)
            $upn           = if ($instance.principal.userPrincipalName) { $instance.principal.userPrincipalName } else { '-' }
            $assignType    = if ($instance.assignmentType) { $instance.assignmentType } else { '-' }
            $memberType    = if ($instance.memberType) { $instance.memberType } else { '-' }
            $endTime       = if ($null -eq $instance.endDateTime) { 'Permanent' } else { Get-FormattedDate $instance.endDateTime }
            $accountEnabled = if ($instance.principal.accountEnabled -eq $true) { 'Yes' } else { 'No' }

            $isPermanentStanding = (
                $instance.assignmentType -eq 'Assigned' -and
                $null -eq $instance.endDateTime -and
                $instance.principal.'@odata.type' -eq '#microsoft.graph.user' -and
                $instance.principal.userType -eq 'Member' -and
                $instance.principal.accountEnabled -eq $true
            )
            $rowStatus = if ($isPermanentStanding) { '❌ Fail' } else { '✅ Pass' }
            $standingRows += "| $principalName | $upn | $assignType | $memberType | $endTime | $accountEnabled | $rowStatus |`n"
        }
    }
    else {
        $standingRows = "| _no rows returned — no enabled Member user holds standing Intune Administrator_ | | | | | | ✅ Pass |`n"
    }

    $mdInfo  = "`n## [PIM eligibility (Informational)]($portalUrl)`n`n"
    $mdInfo += "| Principal | Type | Member type | Eligibility window | Status |`n"
    $mdInfo += "| :-------- | :--- | :---------- | :----------------- | :----- |`n"
    $mdInfo += $eligibilityRows
    if ($eligibilitySchedules.Count -gt $maxItemsToDisplay) {
        $mdInfo += "`n`n_**Note**: This table is truncated and showing the first $maxItemsToDisplay of $($eligibilitySchedules.Count) eligible assignments._`n"
    }
    $mdInfo += "`n## [Activation policy rules]($roleSettingsPortalUrl)`n`n"
    $mdInfo += "| Rule | Setting | Value | Required value | Status |`n"
    $mdInfo += "| :--- | :------ | :---- | :------------- | :----- |`n"
    $mdInfo += $policyRows
    $mdInfo += "`n## [Standing permanent assignments]($portalUrl)`n`n"
    $mdInfo += "| Principal | UPN | Assignment type | Member type | End time | Enabled | Status |`n"
    $mdInfo += "| :-------- | :-- | :-------------- | :---------- | :------- | :------ | :----- |`n"
    $mdInfo += $standingRows
    if ($assignmentInstances.Count -gt $maxItemsToDisplay) {
        $mdInfo += "`n`n_**Note**: This table is truncated and showing the first $maxItemsToDisplay of $($assignmentInstances.Count) role assignment instances._`n"
    }

    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
    #endregion Report Generation

    $params = @{
        TestId = '51016'
        Title  = 'Privileged Identity Management is enforced for Intune Administrator role assignments'
        Status = $passed
        Result = $testResultMarkdown
    }
    Add-ZtTestResultDetail @params
}