Providers/Entra/Public/New-EntraRoleEligibility.ps1

function New-EntraRoleEligibility {
    <#
    .EXTERNALHELP TestEnvironment-Help.xml
    .SYNOPSIS
        Makes seeded principals *eligible* for the seeded custom roles, and never active in them
    #>


    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('EntraRoleEligibility')]
    param(
        [Parameter()]
        [string[]]$EligibilityKey,

        [Parameter()]
        [switch]$ShowProgress,

        [Parameter()]
        [switch]$PassThru
    )

    $connection = Get-EntraConnection
    $marker = Get-EntraSeedMarker -Connection $connection

    $definitions = @(Get-EntraSeedData -Name 'EntraRoleEligibilities')
    if ($EligibilityKey) {
        $definitions = @($definitions | Where-Object { $EligibilityKey -contains $_.Key })
        $missing = @($EligibilityKey | Where-Object { $definitions.Key -notcontains $_ })
        if ($missing) {
            Write-Error "No seed definition for eligibility key(s): $($missing -join ', ')" -ErrorAction Stop
            return
        }
    }

    # Resolved once. Both collections are small, and asking per row would cost a call each.
    $roleDefinitions = @(Get-EntraSeededObject -Type DirectoryRoles -Connection $connection)
    if ($roleDefinitions.Count -eq 0) {
        Write-Warning ('No seeded custom role definitions exist, so there is nothing to be eligible for. ' +
            'Run New-EntraDirectoryRole first.')
        return
    }

    $roleSeed = @(Get-EntraSeedData -Name 'EntraDirectoryRoles')
    $units = @(Get-EntraSeededObject -Type AdministrativeUnits -Connection $connection)

    # Existing eligibilities, read once so a re-run is quiet rather than a wall of conflicts.
    $existing = @()
    try {
        $existing = @(Invoke-EntraRequest -Method GET -Connection $connection -Paginate `
                -Path '/roleManagement/directory/roleEligibilitySchedules' `
                -Query @{ '$select' = 'id,principalId,roleDefinitionId,directoryScopeId' })
    }
    catch {
        Write-Verbose "Could not read the existing eligibility schedules: $($_.Exception.Message)"
    }

    $created = [System.Collections.Generic.List[object]]::new()
    $principalCache = @{}
    $index = 0

    foreach ($definition in $definitions) {
        $index++
        Write-TestProgress -Activity 'Seeding role eligibilities' -Status $definition.Key `
            -PercentComplete ([int](100 * $index / [Math]::Max(1, $definitions.Count))) -ShowProgress:$ShowProgress

        # --- The role, which must be one this module created -----------------------------
        $roleRow = $roleSeed | Where-Object { $_.Key -eq $definition.RoleKey } | Select-Object -First 1
        if (-not $roleRow) {
            Write-Warning "Eligibility '$($definition.Key)' names role key '$($definition.RoleKey)', which has no seed definition. Skipping it."
            continue
        }

        $roleName = '{0}{1}' -f $marker.Prefix, $roleRow.DisplayName
        $role = $roleDefinitions | Where-Object { $_.displayName -eq $roleName } | Select-Object -First 1
        if (-not $role) {
            Write-Warning ("Eligibility '$($definition.Key)' names role '$roleName', which does not exist as a seeded " +
                'custom role. Skipping it - this function will not make anybody eligible for a role it did not create.')
            continue
        }

        # --- The principal ---------------------------------------------------------------
        $principalKind = if ($definition.PrincipalKind -eq 'Group') { 'Group' } else { 'User' }
        $principalId = Resolve-EntraSeededId -Key $definition.PrincipalKey -Kind $principalKind `
            -Cache $principalCache -Connection $connection

        if (-not $principalId) {
            Write-Warning ("Eligibility '$($definition.Key)' names $($principalKind.ToLower()) " +
                "'$($definition.PrincipalKey)', which does not exist. Skipping it.")
            continue
        }

        # --- The scope -------------------------------------------------------------------
        $scope = '/'
        if ($definition.ScopeKind -eq 'AdministrativeUnit') {
            $unitName = '{0}{1}' -f $marker.Prefix, $definition.ScopeUnit
            $unit = $units | Where-Object { $_.displayName -eq $unitName } | Select-Object -First 1
            if (-not $unit) {
                Write-Warning ("Eligibility '$($definition.Key)' is scoped to administrative unit '$unitName', which " +
                    'does not exist. Skipping it rather than widening the scope to the whole directory.')
                continue
            }
            $scope = "/administrativeUnits/$($unit.id)"
        }

        $already = $existing | Where-Object {
            $_.principalId -eq $principalId -and $_.roleDefinitionId -eq $role.id -and $_.directoryScopeId -eq $scope
        } | Select-Object -First 1

        $describe = '{0} eligible for {1} at {2}' -f $definition.PrincipalKey, $roleName, $scope

        if ($already) {
            Write-Verbose "Eligibility already exists: $describe"
            $created.Add([PSCustomObject]@{
                    PSTypeName = 'EntraRoleEligibility'
                    Key        = $definition.Key
                    Id         = $already.id
                    Role       = $roleName
                    Principal  = $definition.PrincipalKey
                    Kind       = $principalKind
                    Scope      = $scope
                    Purpose    = $definition.Purpose
                })
            continue
        }

        if (-not $PSCmdlet.ShouldProcess($describe, 'Create eligible role schedule')) { continue }

        # TryParse writes zero into its target when it fails, so it cannot be given the variable
        # holding the default - a bad row would silently become a zero-day eligibility.
        $days = 30
        $parsed = 0
        if ($definition.DurationDays -and [int]::TryParse($definition.DurationDays, [ref]$parsed) -and $parsed -gt 0) {
            $days = $parsed
        }

        try {
            # adminAssign on roleEligibilityScheduleRequests. The active counterpart lives at
            # roleAssignmentScheduleRequests, and this module never posts to it.
            $request = Invoke-EntraRequest -Method POST -Connection $connection `
                -Path '/roleManagement/directory/roleEligibilityScheduleRequests' -Body @{
                action           = 'adminAssign'
                principalId      = $principalId
                roleDefinitionId = $role.id
                directoryScopeId = $scope

                # For the human reading PIM's audit view, and for nothing else. The justification
                # lives on the *request*, not on the schedule it produces - a
                # unifiedRoleEligibilitySchedule exposes no such property - so teardown cannot
                # read it back and does not try. Ownership rests entirely on the role definition
                # the schedule points at, which is the one marker that survives.
                justification    = $marker.Description
                scheduleInfo     = @{
                    startDateTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
                    expiration    = @{
                        # afterDuration rather than noExpiration, on purpose. A lab that is
                        # never torn down stops offering the activation after this elapses.
                        type     = 'afterDuration'
                        duration = 'P{0}D' -f $days
                    }
                }
            }
        }
        catch {
            $message = $_.Exception.Message

            # An eligibility that is already there. Reached whenever the pre-read above failed -
            # it is caught to a verbose line, so a throttled read makes every row look new - and
            # a correctly seeded tenant would then report three failures for being correct.
            # New-EntraDirectoryRole and the group membership in New-EntraGuestUser both treat
            # "already there" as the ordinary answer on a re-run, and so does this.
            if ($message -match 'RoleAssignmentExists|RoleEligibilityExists|already exists|conflicting object') {
                Write-Verbose "Eligibility already exists: $describe"
                $created.Add([PSCustomObject]@{
                        PSTypeName = 'EntraRoleEligibility'
                        Key        = $definition.Key
                        Id         = $null
                        Role       = $roleName
                        Principal  = $definition.PrincipalKey
                        Kind       = $principalKind
                        Scope      = $scope
                        Purpose    = $definition.Purpose
                    })
                continue
            }

            # PIM is a P2 feature. Without the licence the create is refused, and the message
            # names the subscription rather than the request, which reads like a bug in the
            # caller unless it is said out loud.
            if ($message -match 'AadPremium|subscription|licen[cs]e|RoleAssignmentPolicy|not enabled') {
                Write-Warning ("Could not create the eligibility '$($definition.Key)': $message. Privileged Identity " +
                    'Management requires Entra ID P2 - without it the custom roles still exist, and nothing is ' +
                    'eligible for them. Skip this step with -Skip RoleEligibilities.')
            }
            else {
                Write-Warning "Could not create the eligibility '$($definition.Key)': $message"
            }
            continue
        }

        $created.Add([PSCustomObject]@{
                PSTypeName = 'EntraRoleEligibility'
                Key        = $definition.Key

                # targetScheduleId, not the request's own id. The POST returns a
                # roleEligibilityScheduleRequest, whose id addresses the *request* - a different
                # object at a different endpoint from the schedule it produced. Returning that
                # would make Id mean one thing on a first run and another on a re-run, where the
                # already-exists path above reports the schedule id.
                Id         = if ($request.targetScheduleId) { $request.targetScheduleId } else { $request.id }
                Role       = $roleName
                Principal  = $definition.PrincipalKey
                Kind       = $principalKind
                Scope      = $scope
                Purpose    = $definition.Purpose
            })

        Write-Verbose "Created eligibility: $describe"
    }

    Write-TestProgress -Activity 'Seeding role eligibilities' -Completed -ShowProgress:$ShowProgress

    if ($PassThru) { return $created.ToArray() }
}