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 } |