source/Private/DirectoryRoleOperations.ps1

function Invoke-RJGraphRequest {
    <#
    .SYNOPSIS
    Calls the Microsoft Graph REST API with the current Az session.
 
    .PARAMETER Method
    HTTP method.
 
    .PARAMETER Uri
    Absolute Microsoft Graph URI.
 
    .PARAMETER Body
    Optional request body, serialized as JSON.
 
    .OUTPUTS
    GET (collection endpoints only): the 'value' entries of every page, following @odata.nextLink.
    Other methods: the parsed response body, or $null when empty.
    #>

    [CmdletBinding()]
    [OutputType([System.Object[]])]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('GET', 'POST', 'PATCH', 'DELETE')]
        [string]$Method,

        [Parameter(Mandatory)]
        [string]$Uri,

        [System.Collections.IDictionary]$Body
    )

    $items = @()
    $nextUri = $Uri
    while ($nextUri) {
        $requestParams = @{
            Method      = $Method
            Uri         = $nextUri
            ErrorAction = 'Stop'
        }
        if ($Body) {
            $requestParams.Payload = ($Body | ConvertTo-Json -Depth 5 -Compress)
        }

        Write-Verbose "Microsoft Graph: $Method $nextUri"
        $response = Invoke-AzRestMethod @requestParams

        if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) {
            throw "Microsoft Graph request failed ($Method $nextUri): HTTP $($response.StatusCode) - $($response.Content)"
        }

        if ($Method -ne 'GET') {
            if ([string]::IsNullOrWhiteSpace($response.Content)) {
                return $null
            }
            return ($response.Content | ConvertFrom-Json -ErrorAction Stop)
        }

        $page = $response.Content | ConvertFrom-Json -ErrorAction Stop
        if ($null -ne $page.value) {
            $items += @($page.value)
        }
        $nextUri = $page.'@odata.nextLink'
    }

    return ,@($items)
}

function Get-RJDirectoryRoleDefinitionList {
    <#
    .SYNOPSIS
    Returns the built-in Entra ID directory role definitions (id, displayName) of the current tenant.
    #>

    [CmdletBinding()]
    [OutputType([System.Object[]])]
    param()

    Write-Verbose "Retrieving built-in Entra ID directory role definitions"
    $filter = [uri]::EscapeDataString('isBuiltIn eq true')
    $definitions = Invoke-RJGraphRequest -Method GET -Uri "$MSGraphApiBaseUrl/roleManagement/directory/roleDefinitions?`$filter=$filter&`$select=id,displayName"
    return ,@($definitions)
}

function Get-RJDirectoryRoleAssignmentList {
    <#
    .SYNOPSIS
    Returns the Entra ID directory role assignments of a principal.
 
    .PARAMETER PrincipalId
    The object ID of the user, group or service principal.
    #>

    [CmdletBinding()]
    [OutputType([System.Object[]])]
    param(
        [Parameter(Mandatory)]
        [string]$PrincipalId
    )

    Write-Verbose "Retrieving Entra ID directory role assignments for principal $PrincipalId"
    $filter = [uri]::EscapeDataString("principalId eq '$PrincipalId'")
    $assignments = Invoke-RJGraphRequest -Method GET -Uri "$MSGraphApiBaseUrl/roleManagement/directory/roleAssignments?`$filter=$filter"
    return ,@($assignments)
}

function Get-RJDirectoryRoleAssignmentDiff {
    <#
    .SYNOPSIS
    Compares the desired directory roles of a principal with its tenant-wide role assignments.
 
    .DESCRIPTION
    Only direct, tenant-wide assignments of the principal are considered. A role held through membership
    in a role-assignable group is not detected, so it is reported as missing and gets a redundant direct
    assignment.
 
    .PARAMETER PrincipalId
    The object ID of the principal.
 
    .PARAMETER RoleNames
    Display names of the desired directory roles. Every name must exist as a role definition in the tenant.
 
    .OUTPUTS
    PSCustomObject with RolesToAdd ({Name, RoleDefinitionId} per missing role), ExistingRoles (names),
    TotalDesired and ChangeType (CreateAll/AddRoles/NoChanges).
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [string]$PrincipalId,

        [Parameter(Mandatory)]
        [string[]]$RoleNames
    )

    $uniqueRoleNames = New-Object System.Collections.Generic.List[string]
    foreach ($roleName in $RoleNames) {
        if ($uniqueRoleNames -notcontains $roleName) {
            $uniqueRoleNames.Add($roleName)
        }
    }
    $RoleNames = $uniqueRoleNames.ToArray()

    $definitions = Get-RJDirectoryRoleDefinitionList
    $definitionNames = @($definitions.displayName)
    $unknownRoles = @($RoleNames | Where-Object { $_ -notin $definitionNames })
    if ($unknownRoles.Count -gt 0) {
        throw "Directory role(s) not found in this tenant: $($unknownRoles -join ', ')"
    }

    $currentAssignments = Get-RJDirectoryRoleAssignmentList -PrincipalId $PrincipalId
    $assignedDefinitionIds = @($currentAssignments | Where-Object { $_.directoryScopeId -eq '/' } | ForEach-Object { $_.roleDefinitionId })

    $rolesToAdd = @()
    $existingRoles = @()
    foreach ($roleName in $RoleNames) {
        $definition = $definitions | Where-Object { $_.displayName -eq $roleName } | Select-Object -First 1
        if ($definition.id -in $assignedDefinitionIds) {
            $existingRoles += $roleName
        }
        else {
            $rolesToAdd += [PSCustomObject]@{
                Name             = $roleName
                RoleDefinitionId = $definition.id
            }
        }
    }

    $changeType = if ($rolesToAdd.Count -eq 0) { 'NoChanges' }
                  elseif ($existingRoles.Count -eq 0) { 'CreateAll' }
                  else { 'AddRoles' }

    return [PSCustomObject]@{
        RolesToAdd    = $rolesToAdd
        ExistingRoles = $existingRoles
        TotalDesired  = $RoleNames.Count
        ChangeType    = $changeType
    }
}

function Add-RJDirectoryRoleAssignment {
    <#
    .SYNOPSIS
    Assigns an Entra ID directory role tenant-wide to a principal.
 
    .PARAMETER PrincipalId
    The object ID of the principal.
 
    .PARAMETER RoleDefinitionId
    The role definition (template) ID.
 
    .PARAMETER RoleName
    Display name of the role, used for output only.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$PrincipalId,

        [Parameter(Mandatory)]
        [string]$RoleDefinitionId,

        [Parameter(Mandatory)]
        [string]$RoleName
    )

    if ($PSCmdlet.ShouldProcess("Directory role '$RoleName' for principal $PrincipalId", "Assign")) {
        Write-Verbose "Assigning directory role '$RoleName' ($RoleDefinitionId) to principal $PrincipalId"

        $body = [ordered]@{
            '@odata.type'    = '#microsoft.graph.unifiedRoleAssignment'
            roleDefinitionId = $RoleDefinitionId
            principalId      = $PrincipalId
            directoryScopeId = '/'
        }
        $null = Invoke-RJGraphRequest -Method POST -Uri "$MSGraphApiBaseUrl/roleManagement/directory/roleAssignments" -Body $body

        Write-Information " Assigned directory role: $RoleName"
    }
}

function Set-RJAutomationAccountDirectoryRole {
    <#
    .SYNOPSIS
    Ensures a principal holds every given Entra ID directory role.
 
    .PARAMETER PrincipalId
    The object ID of the Automation Account managed identity.
 
    .PARAMETER RoleNames
    Display names of the directory roles to assign.
 
    .OUTPUTS
    Hashtable with Assigned (roles assigned by this call) and AlreadyAssigned (roles that were already present).
    Stops at the first assignment that fails.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string]$PrincipalId,

        [Parameter(Mandatory)]
        [string[]]$RoleNames
    )

    $result = @{
        Assigned        = @()
        AlreadyAssigned = @()
    }

    $diff = Get-RJDirectoryRoleAssignmentDiff -PrincipalId $PrincipalId -RoleNames $RoleNames

    foreach ($roleName in $diff.ExistingRoles) {
        Write-Information " Directory role '$roleName' already assigned — skipping."
    }
    $result.AlreadyAssigned = @($diff.ExistingRoles)

    $rolesToAdd = @($diff.RolesToAdd)
    if ($rolesToAdd.Count -eq 0) {
        return $result
    }

    $roleNamesToAdd = @($rolesToAdd | ForEach-Object { $_.Name })
    if (-not $PSCmdlet.ShouldProcess("Principal $PrincipalId", "Assign directory roles: $($roleNamesToAdd -join ', ')")) {
        return $result
    }

    foreach ($role in $rolesToAdd) {
        Add-RJDirectoryRoleAssignment -PrincipalId $PrincipalId -RoleDefinitionId $role.RoleDefinitionId -RoleName $role.Name -Confirm:$false
        $result.Assigned += $role.Name
    }

    return $result
}