GDAPRelationships.psm1
# This is a locally sourced Imports file for local development. # It can be imported by the psm1 in local development to add script level variables. # It will merged in the build process. This is for local development only. # region script variables # $script:resourcePath = "$PSScriptRoot\Resources" <# .SYNOPSIS Convert ISO8601 duration to a .NET timespan object .DESCRIPTION Convert ISO8601 duration to a .NET timespan object https://en.wikipedia.org/wiki/ISO_8601#Durations A month is always 30 days A year is always 365 days No support for miliseconds .PARAMETER Duration ISO8601 duration .EXAMPLE Convert-ISO8601ToTimespan -Duration "PT39M6.3580667S" .NOTES Copyright: (c) 2018 Fabian Bader License: MIT https://opensource.org/licenses/MIT #> Function Convert-ISO8601ToTimespan { [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateScript( { if ($_ -match "^P(?<years>\d*Y)?(?<months>\d*M)?(?<days>\d*D)?(T)?(?<hours>\d*H)?(?<minutes>\d*M)?(?<seconds>[\d.]*S)?$" ) { $true } else { throw "Not a valid ISO8601 duration" } } )] [string]$Duration ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Convert-ISO8601ToTimespan" } Process { if ($Duration -match "^P(?<years>\d*Y)?(?<months>\d*M)?(?<days>\d*D)?(T)?(?<hours>\d*H)?(?<minutes>\d*M)?(?<seconds>[\d.]*S)?$" ) { Write-LogMessage -Severity Verbose -Message "Converting ISO8601 $($Duration) date to TimeSpan" $years = [Int32]($matches['years'] -replace "[^\d.,]") $months = [Int32]($matches['months'] -replace "[^\d.,]") $days = [Int32]($matches['days'] -replace "[^\d.,]") $hours = [Int32]($matches['hours'] -replace "[^\d.,]") $minutes = [Int32]($matches['minutes'] -replace "[^\d.,]") $seconds = [Int32]($matches['seconds'] -replace "[^\d.,]") #region Convert years and month to days if ($years -gt 0) { $days = $years * 365 + $days } if ($months -gt 0) { $days = $months * 30 + $days } #endregion $TimeSpan = New-TimeSpan -Days $days -Hours $hours -Minutes $minutes -Seconds $seconds } } End { Write-LogMessage -Severity Verbose -Message "Found TimeSpan of $TimeSpan" $PSCmdlet.WriteObject($TimeSpan, $true) Write-LogMessage -Severity Verbose -Message "Ending Convert-ISO8601ToTimespan" } } Function Format-AccessAssignment { [CmdletBinding()] [OutputType([object])] param ( [Parameter(Mandatory, ValueFromPipeline = $true)] [ValidateNotNull()] [object]$AccessAssignment, [Parameter()] [ValidateNotNullOrEmpty()] [string]$GDAPRelationshipID, [Parameter()] [switch]$Detailed, [Parameter()] [switch]$Generalize, [Parameter(Mandatory)] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Format-AccessAssignment" } process { try { Write-LogMessage -Severity Verbose -Message "Processing accessAssignment $($AccessAssignment.id)" # Add detailed object information if the detailed flag is present if ($Detailed) { Write-LogMessage -Severity Verbose -Message "Detailed flag found, updating delegatedAdminAccessAssignment object with delegatedAdminRelationshipId, accessContainerDisplayName, and roleDefinition details" if (-not [string]::IsNullOrEmpty($GDAPRelationshipID) -and -not $Generalize) { Add-Member -InputObject $AccessAssignment -Name "delegatedAdminRelationshipId" -MemberType NoteProperty -Value $GDAPRelationshipID } $GroupDisplayName = Get-EntraGroupbyNameorId -Group $AccessAssignment.accessContainer.accessContainerId -GraphBaseURL $GraphBaseURL -ErrorAction SilentlyContinue | Select-Object -Property displayName if (-not [string]::IsNullOrEmpty($GroupDisplayName.displayName)) { Add-Member -InputObject $AccessAssignment.accessContainer -Name "accessContainerDisplayName" -MemberType NoteProperty -Value $GroupDisplayName.displayName } $AccessAssignment.accessDetails.unifiedRoles = Get-GDAPAccessRolebyNameorId -RoleDefinition $AccessAssignment.accessDetails.unifiedRoles.roleDefinitionId } if ($Generalize) { $AccessAssignment = $AccessAssignment | Select-Object -Property accessContainer, accessDetails } } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { $PSCmdlet.WriteObject($AccessAssignment, $true) Write-LogMessage -Severity Verbose -Message "Ending Format-AccessAssignment" } } #Format-AccessAssignment Function Format-AdminRelationship { [CmdletBinding()] [OutputType([object])] param ( [Parameter(Mandatory, ValueFromPipeline = $true)] [ValidateNotNull()] [object]$AdminRelationship, [Parameter()] [switch]$Detailed ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Format-AdminRelationship" } process { try { Write-LogMessage -Severity Verbose -Message "Processing adminRelationship $($AdminRelationship.id)" # Add detailed object information if the detailed flag is present if ($Detailed) { Write-LogMessage -Severity Verbose -Message "Detailed flag found, updating delegatedAdminRelationship object with roleDefinition details" $AdminRelationship.accessDetails.unifiedRoles = Get-GDAPAccessRolebyNameorId -RoleDefinition $AdminRelationship.accessDetails.unifiedRoles.roleDefinitionId } } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { $PSCmdlet.WriteObject($AdminRelationship, $true) Write-LogMessage -Severity Verbose -Message "Ending Format-AdminRelationship" } } #Format-AdminRelationship Function Get-CallerPreference { <# .Synopsis Fetches "Preference" variable values from the caller's scope. .DESCRIPTION Script module functions do not automatically inherit their caller's variables, but they can be obtained through the $PSCmdlet variable in Advanced Functions. This function is a helper function for any script module Advanced Function; by passing in the values of $ExecutionContext.SessionState and $PSCmdlet, Get-CallerPreference will set the caller's preference variables locally. .PARAMETER Cmdlet The $PSCmdlet object from a script module Advanced Function. .PARAMETER SessionState The $ExecutionContext.SessionState object from a script module Advanced Function. This is how the Get-CallerPreference function sets variables in its callers' scope, even if that caller is in a different script module. .PARAMETER Name Optional array of parameter names to retrieve from the caller's scope. Default is to retrieve all Preference variables as defined in the about_Preference_Variables help file (as of PowerShell 4.0) This parameter may also specify names of variables that are not in the about_Preference_Variables help file, and the function will retrieve and set those as well. .EXAMPLE Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Imports the default PowerShell preference variables from the caller into the local scope. .EXAMPLE Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState -Name 'ErrorActionPreference','SomeOtherVariable' Imports only the ErrorActionPreference and SomeOtherVariable variables into the local scope. .EXAMPLE 'ErrorActionPreference','SomeOtherVariable' | Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Same as Example 2, but sends variable names to the Name parameter via pipeline input. .INPUTS String .OUTPUTS None. This function does not produce pipeline output. .LINK about_Preference_Variables .LINK https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d #> [CmdletBinding(DefaultParameterSetName = 'AllVariables')] param ( [Parameter(Mandatory = $true)] [ValidateScript({ $_.GetType().FullName -eq 'System.Management.Automation.PSScriptCmdlet' })] $Cmdlet, [Parameter(Mandatory = $true)] [System.Management.Automation.SessionState] $SessionState, [Parameter(ParameterSetName = 'Filtered', ValueFromPipeline = $true)] [string[]] $Name ) begin { $filterHash = @{} } process { if ($null -ne $Name) { foreach ($string in $Name) { $filterHash[$string] = $true } } } end { # List of preference variables taken from the about_Preference_Variables help file in PowerShell version 4.0 $vars = @{ 'ErrorView' = $null 'FormatEnumerationLimit' = $null 'LogCommandHealthEvent' = $null 'LogCommandLifecycleEvent' = $null 'LogEngineHealthEvent' = $null 'LogEngineLifecycleEvent' = $null 'LogProviderHealthEvent' = $null 'LogProviderLifecycleEvent' = $null 'MaximumAliasCount' = $null 'MaximumDriveCount' = $null 'MaximumErrorCount' = $null 'MaximumFunctionCount' = $null 'MaximumHistoryCount' = $null 'MaximumVariableCount' = $null 'OFS' = $null 'OutputEncoding' = $null 'ProgressPreference' = $null 'PSDefaultParameterValues' = $null 'PSEmailServer' = $null 'PSModuleAutoLoadingPreference' = $null 'PSSessionApplicationName' = $null 'PSSessionConfigurationName' = $null 'PSSessionOption' = $null 'ErrorActionPreference' = 'ErrorAction' 'DebugPreference' = 'Debug' 'ConfirmPreference' = 'Confirm' 'WhatIfPreference' = 'WhatIf' 'VerbosePreference' = 'Verbose' 'InformationPreference' = 'InformationAction' 'WarningPreference' = 'WarningAction' } foreach ($entry in $vars.GetEnumerator()) { if (([string]::IsNullOrEmpty($entry.Value) -or -not $Cmdlet.MyInvocation.BoundParameters.ContainsKey($entry.Value)) -and ($PSCmdlet.ParameterSetName -eq 'AllVariables' -or $filterHash.ContainsKey($entry.Name))) { $variable = $Cmdlet.SessionState.PSVariable.Get($entry.Key) if ($null -ne $variable) { if ($SessionState -eq $ExecutionContext.SessionState) { Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false } else { $SessionState.PSVariable.Set($variable.Name, $variable.Value) } } } } if ($PSCmdlet.ParameterSetName -eq 'Filtered') { foreach ($varName in $filterHash.Keys) { if (-not $vars.ContainsKey($varName)) { $variable = $Cmdlet.SessionState.PSVariable.Get($varName) if ($null -ne $variable) { if ($SessionState -eq $ExecutionContext.SessionState) { Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false } else { $SessionState.PSVariable.Set($variable.Name, $variable.Value) } } } } } } # end } #Get-CallerPreference Function Get-CompletionCommand { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [string]$CommandInvocation, [Parameter(Mandatory)] [object[]]$CommandArgumentParameters, [Parameter(Mandatory)] [string]$GDAPRelationshipId, [Parameter(Mandatory = $false)] [System.IO.FileInfo]$LogFilePath ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState # Get this function's invocation as a command line # with literal (expanded) values. $CommandArguments = $(foreach ($bp in $CommandArgumentParameters) { # argument list $valRep = if ($bp.Value -isnot [switch]) { foreach ($val in $bp.Value) { if ($val -is [bool]) { # a Boolean parameter (rare) ('$false', '$true')[$val] # Booleans must be represented this way. } else { # all other types: stringify in a culture-invariant manner. if (-not ($val.GetType().IsPrimitive -or $val.GetType() -in [string], [datetime], [datetimeoffset], [decimal], [bigint])) { Write-LogMessage -Severity Warning -Message "Argument of type [$($val.GetType().FullName)] will likely not round-trip correctly; stringifies to: $val" } # Single-quote the (stringified) value only if necessary # (if it contains argument-mode metacharacters). if ($val -match '[ $''"`,;(){}|&<>@#]') { "'{0}'" -f ($val -replace "'", "''") } else { "$val" } } } } # Synthesize the parameter-value representation. [PSCustomObject]@{ Parameter = $bp.Key Value = ($valRep -join ', ') } }) } process { try { [string]$CompletionCommand = "$($CommandInvocation)" $CommandArguments | ForEach-Object { switch ($_.Parameter) { "SecurityGroupsJSONURL" { $CompletionCommand += " -SecurityGroupsJSONURL $($_.Value)" } "GraphBaseURL" { $CompletionCommand += " -GraphBaseURL $($_.Value)" } "Simplified" { if ($_.Value -ne $false) { $CompletionCommand += " -Simplified" } } "TerminateExisting" { if ($_.Value -ne $false) { $CompletionCommand += " -TerminateExisting" } } } } if ($LogFilePath) { $CompletionCommand += " -LogFilePath '$($LogFilePath)'" } $CompletionCommand += " -GDAPRelationshipID '$($GDAPRelationshipId)'" } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { if (-not ([string]::IsNullOrEmpty($CompletionCommand))) { $PSCmdlet.WriteObject($AccessAssignment, $true) } } } #Get-CompletionCommand Function Get-EntraGroupbyNameorId { [CmdletBinding()] [OutputType([object])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Group, [Parameter(Mandatory)] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Get-EntraGroupbyNameorId" if (-not (Get-MgContext)) { Write-LogMessage -Severity Warning -Message "No Graph API login found, use Connect-MgGraph to login to the Graph API" ; break } } process { try { if ($GraphBaseURL -notmatch '.*/$') { $GraphBaseURL = $GraphBaseURL + "/" } if (Test-Guid $Group) { $GroupLookupURL = "$($GraphBaseURL)groups/$($Group.Trim())?`$select=id,displayName,groupTypes,mailEnabled,securityEnabled" } else { $GroupLookupURL = "$($GraphBaseURL)groups?`$filter=displayName eq ('$($Group.Trim())')&`$select=id,displayName,groupTypes,mailEnabled,securityEnabled" } Write-LogMessage -Severity Verbose -Message "Retrieving group information from Graph API: 'Invoke-MgGraphRequest -Method Get -Uri $GroupLookupURL -OutputType PSObject'" $GroupInformation = Invoke-MgGraphRequest -Method Get -Uri $GroupLookupURL -OutputType PSObject Write-LogMessage -Severity Debug -Message "Result:`n $($GroupInformation | ConvertTo-Json -Depth 5)" $GroupObject = $null if ($GroupInformation.value) { $GroupObject = $GroupInformation.value | Sort-Object -Property @{Expression = { $_.securityEnabled }; Descending = $true }, @{Expression = { $_.groupTypes }; Descending = $true }, @{Expression = { $_.mailEnabled }; Descending = $true } | Select-Object -First 1 -Property id, displayName } elseif (-not ([string]::IsNullOrEmpty($GroupInformation.id))) { $GroupObject = $GroupInformation | Select-Object -Property id, displayName } } catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] { Write-LogMessage -Severity Error -Message "HTTP error when retrieving Graph Groups, status code $($Error[0].Exception.Response.StatusCode.value__),`n$($Error[0].Exception.Response.StatusDescription.value__)" -LastException $_ } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { $PSCmdlet.WriteObject($GroupObject, $true) Write-LogMessage -Severity Verbose -Message "Ending Get-EntraGroupbyNameorId" } } #Get-EntraGroupbyNameorId Function Get-ExistingCustomer { [CmdletBinding()] [OutputType([System.Collections.Generic.List[object]])] param( [Parameter(Mandatory)] [string]$GraphBaseURL ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Get-ExistingCustomer" if (-not (Get-MgContext)) { Write-LogMessage -Severity Warning -Message "No Graph API login found, use Connect-MgGraph to login to the Graph API" ; break } if ($GraphBaseURL -notmatch '.*/$') { $GraphBaseURL = $GraphBaseURL + "/" } $ExistingCustomersURL = "$($GraphBaseURL)tenantRelationships/delegatedAdminCustomers" } process { try { Write-LogMessage -Severity Verbose -Message "Getting existing customers from Graph API: 'Invoke-MgGraphRequest -Method GET -Uri $ExistingCustomersURL -OutputType PSObject'" [System.Collections.Generic.List[object]]$ExistingCustomersObjects = (Invoke-MgGraphRequest -Method GET -Uri $ExistingCustomersURL -OutputType PSObject).value Write-LogMessage -Severity Debug -Message "Result:`n $($ExistingCustomersObjects | ConvertTo-Json -Depth 5)" Write-LogMessage -Severity Verbose -Message "Generating and displaying GridView for customer selection" [System.Collections.Generic.List[object]]$SelectedCustomerObject = $ExistingCustomersObjects | Select-Object -Property displayName, tenantId | Sort-Object -Property displayName | Out-GridView -Title 'Select Customer' -PassThru Write-LogMessage -Severity Debug -Message "Result:`n $($SelectedCustomerObject | ConvertTo-Json -Depth 5)" } catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] { Write-LogMessage -Severity Error -Message "Unable to retrieve existing customers from $($ExistingCustomersURL), status code $($Error[0].Exception.Response.StatusCode.value__),`n$($Error[0].Exception.Response.StatusDescription.value__)" -LastException $_ } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { if ($null -ne $SelectedCustomerObject) { $PSCmdlet.WriteObject($SelectedCustomerObject, $true) } else { throw "No existing customer object selected, please run the script again or remove the `"-SelectFromExistingCustomers`" flag." } Write-LogMessage -Severity Verbose -Message "Ending Get-ExistingCustomer" } } #Get-ExistingCustomer Function Get-HttpQueryStringList { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Scope = 'Function')] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [String] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ } )] $Url ) Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState $Url -split "[?&]" -like "*=*" | ForEach-Object -Begin { $h = @{} } -Process { $h[($_ -split "=", 2 | Select-Object -Index 0)] = ($_ -split "=", 2 | Select-Object -Index 1) } -End { $h } } #Get-HttpQueryStrings Function Get-JSONFromURL { [CmdletBinding()] [OutputType([object])] param ( [Parameter(Mandatory)] [string]$JSONFileURL ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Get-JSONFromURL" } process { try { Write-LogMessage -Severity Verbose -Message "Attempting to download JSON file from $($JSONFileURL) using Invoke-RestMethod" $JSONObject = Invoke-RestMethod -Method Get -Uri $JSONFileURL if ($JSONObject.GetType().Name -eq "String") { $JSONObject = $JSONObject.Substring(1) | ConvertFrom-Json } } catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] { Write-LogMessage -Severity Error -Message "Unable to download JSON file from $($JSONFileURL), status code $($_.Exception.Response.StatusCode) ($($_.Exception.Response.StatusCode.value__))`n$($_.Exception.Response.StatusDescription)" -LastException $_ } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { $PSCmdlet.WriteObject($JSONObject, $true) Write-LogMessage -Severity Verbose -Message "Ending Get-JSONFromURL" } } #Get-JSONFromURL function Get-MgGraphAllPaging { [CmdletBinding( ConfirmImpact = 'Medium' )] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [ValidateNotNull()] [object]$SearchResult ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Get-MgGraphAllPaging" } process { # Set the current page to the search result provided $page = $SearchResult # Extract the NextLink $currentNextLink = $page.'@odata.nextLink' #let's check for nextlinks specifically as a hashtable key if (Get-Member -InputObject $page -Name "@odata.count" -MemberType Properties) { Write-LogMessage -Severity Verbose -Message "First page value count: $($Page.'@odata.count')" } if ((Get-Member -InputObject $page -Name "@odata.nextLink" -MemberType Properties) -or (Get-Member -InputObject $page -Name "value" -MemberType Properties)) { $values = $page.value } else { # set value to a single item if there is only 1 page $values = $page } # Output the values if ($values) { $PSCmdlet.WriteObject($values, $true) } while (-Not ([string]::IsNullOrWhiteSpace($currentNextLink))) { # Make the call to get the next page try { $page = Invoke-MgGraphRequest -Uri $currentNextLink -Method GET -OutputType PSObject } catch { throw $_ } # Extract the NextLink $currentNextLink = $page.'@odata.nextLink' # Output the items in the page $values = $page.value if (Get-Member -InputObject $page -Name "@odata.count" -MemberType Properties) { Write-LogMessage -Severity Verbose -Message "Current page value count: $($Page.'@odata.count')" } # Output the values if ($values) { $PSCmdlet.WriteObject($values, $true) } } } end { Write-LogMessage -Severity Verbose -Message "Ending Get-MgGraphAllPaging" } } #Get-MgGraphAllPaging Function Get-PSUnique { [cmdletbinding()] [alias("gpsu")] [OutputType("object")] Param( [Parameter(Position = 0, Mandatory, ValueFromPipeline)] [ValidateNotNullOrEmpty()] [object]$InputObject ) Begin { Write-LogMessage -Severity Verbose -Message "Starting $($MyInvocation.MyCommand)" Write-LogMessage -Severity Debug -Message "Initializing list" $UniqueList = [System.Collections.Generic.List[object]]::new() } #begin Process { foreach ($item in $InputObject) { if ($UniqueList.Exists( { -not(Compare-Object $args[0].PSObject.properties.value $item.PSObject.Properties.value) })) { Write-LogMessage -Severity Debug -Message "Skipping: $($item |Out-String)" } else { Write-LogMessage -Severity Debug -Message "Adding as unique: $($item | Out-String)" $UniqueList.Add($item) } } } #process End { Write-LogMessage -Severity Verbose -Message "Found $($UniqueList.count) unique objects" Write-LogMessage -Severity Debug -Message "Writing results to the pipeline" $PSCmdlet.WriteObject($UniqueList, $true) Write-LogMessage -Severity Verbose -Message "Ending $($MyInvocation.MyCommand)" } #end } #Get-PSUnique Function Get-TenantDisplayNamebyId { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateScript( { Test-Guid -InputObject $_ })] [string]$TenantID, [Parameter(Mandatory)] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Get-TenantDisplayNamebyId" if (-not (Get-MgContext)) { Write-LogMessage -Severity Warning -Message "No Graph API login found, use Connect-MgGraph to login to the Graph API" ; break } } process { try { if ($GraphBaseURL -notmatch '.*/$') { $GraphBaseURL = $GraphBaseURL + "/" } $TenantInformationbyIDURL = "$($GraphBaseURL)tenantRelationships/findTenantInformationByTenantId(tenantId='$($TenantID)')" Write-LogMessage -Severity Verbose -Message "Retrieving tenant Display Name from Graph API: 'Invoke-MgGraphRequest -Method Get -Uri $TenantInformationbyIDURL -OutputType PSObject'" $TenantInformation = Invoke-MgGraphRequest -Method Get -Uri $TenantInformationbyIDURL -OutputType PSObject Write-LogMessage -Severity Debug -Message "Result:`n $($TenantInformation | ConvertTo-Json -Depth 5)" } catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] { Write-LogMessage -Severity Error -Message "HTTP error when generating GDAP relationship request, status code $($Error[0].Exception.Response.StatusCode.value__),`n$($Error[0].Exception.Response.StatusDescription.value__)" -LastException $_ } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { if (-not ([string]::IsNullOrEmpty($TenantInformation.displayName))) { $PSCmdlet.WriteObject($TenantInformation.displayName, $true) } Write-LogMessage -Severity Verbose -Message "Ending Get-TenantDisplayNamebyId" } } #Get-TenantDisplayNamebyId # Function New-GDAPAction # { # [CmdletBinding()] # param( # [Parameter(Mandatory)] # [string]$ActionFile, # [Parameter(Mandatory)] # [object]$ActionFileId, # [Parameter(Mandatory)] # [ValidateSet('New-GDAPRelationship', 'New-GDAPRelationshipAccessAssignment', 'Get-GDAPRelationship', 'Get-GDAPRelationshipAccessAssignment', 'Get-GDAPRelationshipRequestLink', 'Remove-GDAPRelationship', 'Remove-GDAPRelationshipAccessAssignment', 'Test-GDAPRelationshipStatus')] # [string]$Action, # [Parameter(Mandatory)] # [object]$Parameters, # [Parameter(Mandatory = $false)] # [object]$ValueFrom, # [Parameter(Mandatory)] # [int]$Stage # ) # $AllowedParameters = (Get-Command -Name $Action).Parameters # [System.Collections.Generic.List[string]]$ParameterString = $Parameters | Where-Object { $_.Parameter -in $AllowedParameters.Keys } | ForEach-Object { # "-$($_.Parameter)$(if ($AllowedParameters."$($_.Parameter)".SwitchParameter) { ":`$$($_.Value)"} else { " $($_.Value)"})" # } # if ($ValueFrom) # { # $After = $ValueFrom.ActionId # $InputValue = Get-GDAPAction -ActionFile $ActionFile -Action $ValueFrom.ActionId # $ValueFrom.Parameters | Where-Object { $_.Parameter -in $AllowedParameters.Keys } | ForEach-Object { # "-$($_.Parameter)$(if ($AllowedParameters."$($_.Parameter)".SwitchParameter) { ":`$$($InputValue.Result."($($_.Value))")"} else { " $($InputValue.Result."($($_.Value))")"})" # } # } # $CommandString = "$($Action) $($ParameterString -join ' ')" # $ScriptBlock = [Scriptblock]::Create($CommandString) # switch ($Action) # { # "New-GDAPRelationship" # { # $ExpectedResult = [System.Collections.Generic.List[object]]::new(@( # @{ # Command = [Scriptblock]::Create('-not ([string]::IsNullorEmpty(($Result.id)))') # Value = $true # }, # @{ # Command = [Scriptblock]::Create('$Result.status -iin "approvalPending","approved","active"') # Value = $true # }, # @{ # Command = [Scriptblock]::Create('($Result.accessDetails.unifiedRoles).Count -eq ($Input.RoleDefinition).Count') # Value = $true # } # )) # $CheckStatusCommand = [Scriptblock]::Create('Get-GDAPRelationship -GDAPRelationshipID $Result.id -GraphBaseURL $Input.GraphBaseURL') # } # "Get-GDAPRelationshipRequestLink" # { # $ExpectedResult = [System.Collections.Generic.List[object]]::new(@( # @{ # Command = [Scriptblock]::Create('-not ([string]::IsNullorEmpty(($Result.GDAPInvitationLink)))') # Value = $true # } # )) # } # "New-GDAPRelationshipAccessAssignment" # { # $ExpectedResult = [System.Collections.Generic.List[object]]::new(@( # @{ # Command = [Scriptblock]::Create('-not ([string]::IsNullorEmpty(($Result.id)))') # Value = $true # }, # @{ # Command = [Scriptblock]::Create('$Result.accessContainer.accessContainerId -eq $Input.Group -or $Result.accessContainer.accessContainerDisplayName -eq $Input.Group') # Value = $true # }, # @{ # Command = [Scriptblock]::Create('($Result.accessDetails.unifiedRoles).Count -eq ($Input.RoleDefinition).Count') # Value = $true # } # )) # $CheckStatusCommand = [Scriptblock]::Create('Get-GDAPRelationshipAccessAssignment -GDAPRelationshipID $Input.GDAPRelationshipId -Group $Input.Group -GraphBaseURL $Input.GraphBaseURL') # } # "Remove-GDAPRelationship" # { # $ExpectedResult = [System.Collections.Generic.List[object]]::new(@( # @{ # Command = [Scriptblock]::Create('$Result') # Value = $true # } # )) # $CheckStatusCommand = [Scriptblock]::Create('(Get-GDAPRelationship -GDAPRelationshipID $Input.GDAPRelationshipObject.id -GraphBaseURL $Input.GraphBaseURL).status -iin "terminated","terminating,"terminationRequested"') # } # "Remove-GDAPRelationshipAccessAssignment" # { # $ExpectedResult = [System.Collections.Generic.List[object]]::new(@( # @{ # Command = [Scriptblock]::Create('$Result') # Value = $true # } # )) # $CheckStatusCommand = [Scriptblock]::Create('(Get-GDAPRelationshipAccessAssignment -GDAPRelationshipID $Input.AccessAssignmentObject.delegatedAdminRelationshipId -AccessAssignmentId $Input.AccessAssignmentObject.id -GraphBaseURL $Input.GraphBaseURL).status -iin "deleting","deleted"') # } # } # $ActionObject = @{ # Action = $Action # Parameters = $Parameters # Command = $CommandString # Execute = $ScriptBlock # ValueFrom = $ValueFrom # ExpectedResult = $ExpectedResult # CheckStatusCommand = $CheckStatusCommand # Stage = $Stage # After = $After # ActionId = (New-Guid).Guid # } # $PSCmdlet.WriteObject($ActionObject, $true) # } #New-GDAPAction Function New-GDAPRelationshipRequest { [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([object])] param ( [Parameter(Mandatory)] [string]$GDAPRelationshipID, [Parameter(Mandatory)] [string]$GraphBaseURL ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting New-GDAPRelationshipRequest" $DelegatedAdminCreateRequestURL = "$($GraphBaseURL)tenantRelationships/delegatedAdminRelationships/$($GDAPRelationshipID)/requests" $DelegatedAdminRelationshipRequestBody = @{ action = "lockForApproval" } } process { try { # Verify that the base URL ends in trailing slash if ($GraphBaseURL -notmatch '.*/$') { $GraphBaseURL = $GraphBaseURL + "/" } Write-LogMessage -Severity Verbose -Message "Creating new GDAP request from Graph API: 'Invoke-MgGraphRequest -Method POST -Uri $DelegatedAdminCreateRequestURL -Body (`$DelegatedAdminRelationshipRequestBody | ConvertTo-Json) -OutputType PSObject'" Write-LogMessage -Severity Debug -Message "Graph Request Body value for new request:`n$($DelegatedAdminRelationshipRequestBody | ConvertTo-Json)" if ($PSCmdlet.ShouldProcess(("Request body: `n$($DelegatedAdminRelationshipRequestBody | ConvertTo-Json)"), ("Invoke-MgGraphRequest -Method POST -Uri $DelegatedAdminCreateRequestURL"))) { $GDAPRelationshipRequest = Invoke-MgGraphRequest -Method POST -Uri $DelegatedAdminCreateRequestURL -Body ($DelegatedAdminRelationshipRequestBody | ConvertTo-Json -Depth 5) -StatusCodeVariable IMGRStatusCode -OutputType PSObject Write-LogMessage -Severity Debug -Message "Result:`n $($GDAPRelationshipRequest | ConvertTo-Json -Depth 5)" # Validate the relationship request ID is valid before returning if ($IMGRStatusCode -eq '201') { Write-LogMessage -Severity Verbose -Message "Successfully created GDAP relationship request $($GDAPRelationshipObject.id)" return $GDAPRelationshipRequest } else { Write-LogMessage -Severity Warning -Message "Graph API status code: $($IMGRStatusCode)" throw "GDAP Relationship Request was not created successfully." } } } catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] { Write-LogMessage -Severity Error -Message "HTTP error when generating GDAP relationship request, status code $($Error[0].Exception.Response.StatusCode.value__),`n$($Error[0].Exception.Response.StatusDescription.value__)" -LastException $_ } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { Write-LogMessage -Severity Verbose -Message "Ending New-GDAPRelationshipRequest" } } #New-GDAPRelationshipRequest Function Test-AccessAssignment { [CmdletBinding()] [OutputType([bool])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'GDAPRoles', Justification = 'False positive as rule missing valid calls outside if scopes')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'ValidTemplate', Justification = 'False positive as rule missing valid calls outside if scopes')] param ( [Parameter(Mandatory)] [ValidateNotNull()] [object]$AccessAssignment, [Parameter(Mandatory)] [string]$GraphBaseURL ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Test-AccessAssignment" if (-not $script:GDAPRoleList) { Write-LogMessage -Severity Verbose -Message "Retrieving the list of GDAP Roles" $GDAPRoles = Import-GDAPRoleList } else { Write-LogMessage -Severity Verbose -Message "GDAP Roles already loaded, continuing" $GDAPRoles = $script:GDAPRoleList } } process { $ValidTemplate = $true if ($AccessAssignment.PSobject.Properties.Name -notcontains "accessContainer") { $ValidTemplate = $false; Throw "AccessAssignment is missing the accessContainer property" } if ($AccessAssignment.accessContainer.PSobject.Properties.Name -notcontains "accessContainerId") { $ValidTemplate = $false; Throw "AccessAssignment is missing the accessContainerId property" } if ($AccessAssignment.PSobject.Properties.Name -notcontains "accessDetails") { $ValidTemplate = $false; Throw "AccessAssignment is missing the accessDetails property" } if ($AccessAssignment.accessDetails.PSobject.Properties.Name -notcontains "unifiedRoles") { $ValidTemplate = $false; Throw "AccessAssignment is missing the unifiedRoles property" } if (-not (Test-Guid $AccessAssignment.accessContainer.accessContainerId)) { $ValidTemplate = $false; Throw "accessContainerId `"$($AccessAssignment.accessContainer.accessContainerId)`" is not in GUID format" } if (-not (Get-EntraGroupbyNameorId -Group $AccessAssignment.accessContainer.accessContainerId -GraphBaseURL $GraphBaseURL)) { $ValidTemplate = $false; Throw "accessContainerId `"$($AccessAssignment.accessContainer.accessContainerId)`" does not match an existing Entra ID group object" } $AccessAssignment.accessDetails.unifiedRoles | ForEach-Object { if ($_.PSobject.Properties.Name -notcontains "roleDefinitionId") { $ValidTemplate = $false; Throw "Found a unifiedRoles object missing a roleDefinitionId: $_" } if (-not (Test-Guid $_.roleDefinitionId)) { $ValidTemplate = $false; Throw "Found a unifiedRoles object with a roleDefinitionId not in GUID format: $_" } if ($_.roleDefinitionId -notin $GDAPRoleList.roleDefinitionId) { $ValidTemplate = $false; Throw "Found a unifiedRoles object with a roleDefinitionId `"$($_.roleDefinitionId) that does not match a known Entra ID Role" } } } end { $ValidTemplate Write-LogMessage -Severity Verbose -Message "Ending Test-AccessAssignment" } } #Test-AccessAssignment <# .SYNOPSIS Validates a given input string and checks string is a valid GUID .DESCRIPTION Validates a given input string and checks string is a valid GUID by using the .NET method Guid.TryParse .EXAMPLE PS> Test-Guid -InputObject "3363e9e1-00d8-45a1-9c0c-b93ee03f8c13" .NOTES Uses .NET method [guid]::TryParse() .LINK Adapted from code by Nicola Suter: https://tech.nicolonsky.ch/validating-a-guid-with-powershell/ #> Function Test-Guid { [Cmdletbinding()] [OutputType([bool])] param ( [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [string]$InputObject ) # begin # { # Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState # } process { [guid]::TryParse($InputObject, $([ref][guid]::Empty)) } } #Test-Guid Function Test-Url { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [String] $Url ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState } Process { if ([system.uri]::IsWellFormedUriString($Url, [System.UriKind]::Absolute)) { $true } else { $false } } } #Test-Url Function Write-LogMessage { [CmdletBinding()] param( [Parameter(Mandatory = $false)] [ValidateSet('Debug', 'Verbose', 'Information', 'Warning', 'Error', IgnoreCase = $false)] [string]$Severity = "Information", [Parameter(Mandatory)] [string]$Message, [Parameter(Mandatory = $false)] [string]$RecommendedAction, [Parameter(Mandatory = $false)] [object]$TargetObject, [Parameter(Mandatory = $false)] [switch]$StopOnError, [Parameter(Mandatory = $false)] [System.Management.Automation.ErrorRecord]$LastException ) Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState $LogObject = [PSCustomObject]@{ Timestamp = (Get-Date).ToString() Severity = $Severity Message = $Message RecommendedAction = $RecommendedAction TargetObject = $TargetObject } if ($Severity -in "Debug", "Verbose", "Error") { $CallStackDepth = 0 $fullCallStack = Get-PSCallStack $CallingFunction = $fullCallStack[1].FunctionName if ($CallingFunction -eq "<ScriptBlock>") { $CallingFunction = "$($fullCallStack[1].Command)$($CallingFunction)" } $LogObject | Add-Member -MemberType NoteProperty -Name CallingFunction -Value $CallingFunction } if ($Severity -eq "Error") { if ($LastException.ErrorRecord) { #PSCore Error $LastError = $LastException.ErrorRecord } else { #PS 5.1 Error $LastError = $LastException } if ($LastException.InvocationInfo.MyCommand.Version) { $version = $LastError.InvocationInfo.MyCommand.Version.ToString() } if ($LastError) { $LastErrorObject = @{ 'ExceptionMessage' = $LastError.Exception.Message 'ExceptionSource' = $LastError.Exception.Source 'ExceptionStackTrace' = $LastError.Exception.StackTrace 'PositionMessage' = $LastError.InvocationInfo.PositionMessage 'InvocationName' = $LastError.InvocationInfo.InvocationName 'MyCommandVersion' = $version 'ScriptName' = $LastError.InvocationInfo.ScriptName } $LogObject | Add-Member -MemberType NoteProperty -Name LastError -Value $LastErrorObject } } if ($Severity -in "Debug", "Error") { $FullCallStackWithoutLogFunction = $fullCallStack | ForEach-Object { #loop through all the objects in the callstack result. #excluding the 0 position of the call stack which would represent this write-logmessage function. if ($CallStackDepth -gt 0) { [PSCustomObject]@{ CallStackDepth = $CallStackDepth ScriptLineNumber = $_.ScriptLineNumber FunctionName = $_.FunctionName ScriptName = $_.ScriptName Location = $_.Location Command = $_.Command Arguments = $_.Arguments } } $CallStackDepth++ } $LogObject | Add-Member -MemberType NoteProperty -Name fullCallStackDump -Value $FullCallStackWithoutLogFunction } switch ($Severity) { 'Debug' { $DebugSplat = @{ Message = "$($LogObject.Timestamp) CallingFunction=$($LogObject.CallingFunction)`n $($LogObject.Message)`n $($LogObject.fullCallStackDump | ConvertTo-Json -Depth 5 | Out-String)" } Write-Debug @DebugSplat } 'Verbose' { $VerboseSplat = @{ Message = "$($LogObject.Timestamp) CallingFunction=$($LogObject.CallingFunction)`n $($LogObject.Message)" } Write-Verbose @VerboseSplat } 'Information' { $InformationSplat = @{ MessageData = "INFORMATION: $($LogObject.Timestamp)`n $($LogObject.Message)" } Write-Information @InformationSplat } 'Warning' { $WarningSplat = @{ Message = "$($LogObject.Timestamp)`n $($LogObject.Message)" } Write-Warning @WarningSplat } 'Error' { $ErrorSplat = @{ Message = "$($LogObject.Timestamp) CallingFunction=$($LogObject.CallingFunction)`n $($LogObject.Message)$(if($LogObject.LastError){ "`n $($LogObject.LastError | ConvertTo-Json -Depth 5 | Out-String)" })" RecommendedAction = $LogObject.RecommendedAction TargetObject = $LogObject.TargetObject } if ($StopOnError) { $ErrorSplat.ErrorAction = "Stop" } Write-Error @ErrorSplat } } } #Write-LogMessage <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Build-GDAPRemediation { [CmdletBinding()] param( # [Parameter(Mandatory, # HelpMessage = "The GDAP Template object containing the specifics for the remediation to operate against")] # [ValidateNotNullOrEmpty()] # [object]$GDAPTemplate, # [Parameter(Mandatory, # HelpMessage = "List of client tenants that the remediation should operate against, requires property `"ClientTenantId`", `"ClientTenantName`" is optional but recommended. `"RelationshipName`" can be specified to manually set the created relationship name")] # [ValidateScript({ # if ([string]::IsNullOrEmpty($_.ClientTenantId)) # { # throw "Passed ClientTenant object is not valid, property `"ClientTenantId`" is missing or empty" # } # elseif (-not (Test-Guid -InputObject $_.ClientTenantId)) # { # throw "Passed ClientTenant object is not valid, property `"ClientTenantId`" with value `"$($_.ClientTenantId)`" is not in guid format" # } # else # { # $true # } # })] # [System.Collections.Generic.List[object]]$ClientTenant, # [Parameter(Mandatory = $false, # HelpMessage = "The prefix to use in generated GDAP relationship names")] # [ValidateNotNullOrEmpty()] # [string]$RelationshipPrefix, # [Parameter(Mandatory = $false)] # [ValidateNotNullOrEmpty()] # [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { # Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState # Write-LogMessage -Severity Verbose -Message "Starting Build-GDAPRemediation" # if (-not (Get-MgContext)) # { # Write-LogMessage -Severity Warning -Message "No Graph API login found, use Connect-MgGraph to login to the Graph API" ; break # } } process { # Verify that the base URL ends in trailing slash # if ($GraphBaseURL -notmatch '.*/$') # { # $GraphBaseURL = $GraphBaseURL + "/" # } # Write-LogMessage -Severity Information -Message "Retrieving current GDAP Relationships for $($ClientTenant.Count) Client Tenant(s)" # [System.Collections.Generic.List[object]]$ClientRelationship = $ClientTenant | ForEach-Object { # $_.ExistingRelationship = (Get-GDAPRelationship -Filter "contains(customer/tenantId,'$($ClientTenant.ClientTenantID)')" -GraphBaseURL $GraphBaseURL) # } # Write-LogMessage -Severity Information -Message "Retrieved $($ClientRelationship.Count) existing GDAP Relationship(s)" # $ActiveRelationshipStatus = [System.Collections.Generic.List[object]]::new() # $ClientRelationship | ForEach-Object { # if ($_.status -in 'active', 'approved', 'activating') # { # Write-LogMessage -Severity Information -Message "Testing status of relationship with ID $($_.id)" # $TestResults = Test-GDAPRelationshipStatus -GDAPRelationshipID $_.id -DelegatedAdminAccessAssignment $GDAPTemplate.AccessAssignment -RoleDefinition $GDAPTemplate.RoleDefinition -Differences # $ActiveRelationshipStatus.Add($TestResults) # } # } } end { Write-LogMessage -Severity Verbose -Message "Ending Build-GDAPRemediation" } } #Build-GDAPRemediation <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Compare-GDAPAccessAssignment { [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory, Position = 0, HelpMessage = "The GDAP relationship ID to use for accessAssignments lookup")] [ValidateNotNullOrEmpty()] [ValidateScript( { $_ -match ` '^((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))-((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))$' } )] [string]$GDAPRelationshipID, [Parameter(Mandatory, Position = 1, ParameterSetName = "Object", HelpMessage = "Object containing a delegatedAdminAccessAssignment object to compare")] [object]$DelegatedAdminAccessAssignment, [Parameter(Mandatory, Position = 1, ParameterSetName = "Separate", HelpMessage = "Entra ID Group by ID or Name to use to search for existing accessAssignments")] [ValidateNotNullOrEmpty()] [string]$Group, [Parameter(Mandatory, Position = 2, ParameterSetName = "Separate", HelpMessage = "Array of Entra ID role Guids or role Names to compare to the existing accessAssignments")] [ValidateNotNullOrEmpty()] [System.Collections.Generic.List[string]]$RoleDefinition, [Parameter(Mandatory = $false, Position = 2, ParameterSetName = "Object", HelpMessage = "Enable the return of the reason the object doesn't match")] [Parameter(Mandatory = $false, Position = 3, ParameterSetName = "Separate", HelpMessage = "Enable the return of the reason the object doesn't match")] [switch]$Reason, [Parameter(Mandatory = $false, ParameterSetName = "Object", Position = 3, HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [Parameter(Mandatory = $false, ParameterSetName = "Separate", Position = 4, HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Compare-GDAPAccessAssignment" } process { switch ($PsCmdlet.ParameterSetName) { "Object" { if (Test-AccessAssignment -AccessAssignment $DelegatedAdminAccessAssignment -GraphBaseURL $GraphBaseURL) { $Group = $DelegatedAdminAccessAssignment.accessContainer.accessContainerId $RoleDefinitionId = $DelegatedAdminAccessAssignment.accessDetails.unifiedRoles.roleDefinitionId } elseif (-not (Get-EntraGroupbyNameorId -Group $DelegatedAdminAccessAssignment.accessContainer.accessContainerId -GraphBaseURL $GraphBaseURL)) { throw "Provided delegatedAdminAccessAssignment is invalid" if ($Reason) { $PSCmdlet.WriteObject("Invalid Group", $true) } else { $false } break } else { throw "Provided delegatedAdminAccessAssignment is invalid" if ($Reason) { $PSCmdlet.WriteObject("Invalid delegatedAdminAccessAssignment", $true) } else { $false } break } } "Separate" { if (-not (Get-EntraGroupbyNameorId -Group $Group -GraphBaseURL $GraphBaseURL)) { throw "Provided delegatedAdminAccessAssignment is invalid" if ($Reason) { $PSCmdlet.WriteObject("Invalid Group", $true) } else { $false } break } $RoleDefinitionId = Get-GDAPAccessRolebyNameorId -RoleDefinition $RoleDefinition -ReturnID if ($RoleDefinitionId.Count -ne $RoleDefinition.Count) { Write-LogMessage -Severity Warning -Message "Provided roles included $($RoleDefinition.Count - $RoleDefinitionId.Count) invalid roles" } } } $AccessAssignmentMatch = $true $ExistingGDAPAccessAssignment = Get-GDAPRelationshipAccessAssignment -GDAPRelationshipID $GDAPRelationshipID -Group $Group -GraphBaseURL $GraphBaseURL if ($ExistingGDAPAccessAssignment) { Write-LogMessage -Severity Verbose -Message "Comparing discovered roles with provided roles." Write-LogMessage -Severity Debug -Message "Comparing roleDefinitionId(s) from:`n Discovered roles: $($ExistingGDAPAccessAssignment.accessDetails.unifiedRoles.roleDefinitionId -join ", ")`n Provided roles: $($RoleDefinitionId -join ", ")" $CompareRoles = Compare-Object -ReferenceObject $ExistingGDAPAccessAssignment.accessDetails.unifiedRoles.roleDefinitionId -DifferenceObject $RoleDefinitionId $CompareRoleswithExisting = ( $CompareRoles | Where-Object -FilterScript { $_.SideIndicator -eq '=>' }).InputObject if ($CompareRoleswithExisting.Count -ge 1) { Write-LogMessage -Severity Warning -Message "The following provided Role IDs were not found in the active GDAP relationship:`n$($CompareRoleswithExisting -join ',')" $AccessAssignmentMatch = $false if ($Reason) { $Explanation = "Missing Roles" } } $CompareRoleswithProvided = ( $CompareRoles | Where-Object -FilterScript { $_.SideIndicator -eq '<=' }).InputObject if ($CompareRoleswithProvided.Count -ge 1) { Write-LogMessage -Severity Warning -Message "The following Role IDs were not found in the provided Role IDs but exist in the active GDAP relationship:`n$($CompareRoleswithProvided -join ',')" $AccessAssignmentMatch = $false if ($Reason) { $Explanation = "Extra Roles" } } } else { Write-LogMessage -Severity Warning -Message "No accessAssignment found matching the specified details." $AccessAssignmentMatch = $false if ($Reason) { $Explanation = "Missing Assignment" } } if ($AccessAssignmentMatch) { $Explanation = $true } } end { if ($Reason) { $PSCmdlet.WriteObject($Explanation, $true) } else { $AccessAssignmentMatch } Write-LogMessage -Severity Verbose -Message "Ending Compare-GDAPAccessAssignment" } } #Compare-GDAPAccessAssignment <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Export-GDAPTemplateFromExistingRelationship { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory, ValueFromPipeline = $true, Position = 0, HelpMessage = "The GDAP Relationship ID to use for template details lookup")] [ValidateNotNullOrEmpty()] [ValidateScript( { $_ -match ` '^((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))-((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))$' } )] [string]$GDAPRelationshipID, [Parameter(Mandatory = $false, HelpMessage = "Flag to indicate that role and security group details should be included in the template")] [switch]$Detailed, [Parameter(Mandatory = $false, HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Export-GDAPTemplateFromExistingRelationship" } process { Write-LogMessage -Severity Verbose -Message "Retrieving the existing GDAP Relationship" $ExistingGDAPRelationship = Get-GDAPRelationship -GDAPRelationshipID $GDAPRelationshipID -GraphBaseURL $GraphBaseURL if ($ExistingGDAPRelationship.status -notin 'active', 'approved', 'activating') { Write-LogMessage -Severity Warning -Message "The GDAP relationship request with ID $($GDAPRelationshipID)$(if ($ExistingGDAPRelationship.customer.displayName) { " for $($ExistingGDAPRelationship.customer.displayName)" }) is not in an active state." ; break } else { Write-LogMessage -Severity Verbose -Message "Retrieving the existing accessAssignments" $ExistingGDAPAccessAssignment = Get-GDAPRelationshipAccessAssignment -GDAPRelationshipID $GDAPRelationshipID -Active -GraphBaseURL $GraphBaseURL } if ($Detailed) { [System.Collections.Generic.List[object]]$Roles = Get-GDAPAccessRolebyNameorId -RoleDefinition $ExistingGDAPRelationship.accessDetails.unifiedRoles.RoleDefinitionId } else { [System.Collections.Generic.List[string]]$Roles = $ExistingGDAPRelationship.accessDetails.unifiedRoles } [System.Collections.Generic.List[object]]$AccessAssignment = $ExistingGDAPAccessAssignment | ForEach-Object { Format-AccessAssignment -AccessAssignment $_ -Detailed:$Detailed -Generalize -GraphBaseURL $GraphBaseURL } [string]$GDAPTemplate = [PSCustomObject]@{ Identifier = (New-Guid).Guid Roles = $Roles AccessAssignment = $AccessAssignment Duration = [string]$ExistingGDAPRelationship.duration AutoExtendDuration = [string]$ExistingGDAPRelationship.autoExtendDuration AutoExtendRelationship = (if ($ExistingGDAPRelationship.autoExtendDuration -eq "P180D") { $true } else { $false }) } | ConvertTo-Json -Depth 64 } end { $PSCmdlet.WriteObject($GDAPTemplate, $true) Write-LogMessage -Severity Verbose -Message "Ending Export-GDAPTemplateFromExistingRelationship" } } #Export-GDAPTemplateFromExistingRelationship <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Get-GDAPAccessRolebyNameorId { [CmdletBinding()] [OutputType([System.Collections.Generic.List[object]])] param ( [Parameter(Mandatory, ValueFromPipeline = $true, Position = 0, HelpMessage = "The Role Definition string(s) to find its matching Role Name or Role Id GUID")] [System.Collections.Generic.List[string]]$RoleDefinition, [Parameter(Mandatory = $false, HelpMessage = "Indicates that only the roleDefinitionID should be returned in List<String> format")] [switch]$ReturnID ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Get-GDAPAccessRolebyNameorId" if (-not $script:GDAPRoleList) { Write-LogMessage -Severity Verbose -Message "Retrieving the list of GDAP Roles" $GDAPRoles = Import-GDAPRoleList } else { Write-LogMessage -Severity Verbose -Message "GDAP Roles already loaded, continuing" $GDAPRoles = $script:GDAPRoleList } } process { [System.Collections.Generic.List[object]]$GDAPRolesObject = $RoleDefinition | ForEach-Object { $RoleDefinitionId = $false $RoleDefinitionString = $null $GDAPRoleMatch = $null $RoleDefinitionString = $_.Trim() Write-LogMessage -Severity Verbose -Message "Checking if the RoleDefinition was provided in GUID Id format" if (Test-Guid $RoleDefinitionString) { Write-LogMessage -Severity Debug -Message "GUID detected, Setting `$RoleDefinitionID to true" $RoleDefinitionId = $true } if ($RoleDefinitionId) { Write-LogMessage -Severity Verbose -Message "Looking up GDAP Role `"$RoleDefinitionString`" by RoleDefinitionId" Write-LogMessage -Severity Debug -Message "Parsing `$GDAPRoles for a role with RoleDefinitionId value of $RoleDefinitionString" $GDAPRoleMatch = $GDAPRoles | Where-Object { $_.RoleDefinitionId -ieq $RoleDefinitionString } | Select-Object -First 1 if ($GDAPRoleMatch) { Write-LogMessage -Severity Debug -Message "Found role match name for $RoleDefinitionString of $($GDAPRoleMatch.Name)" $GDAPRoleMatch } else { Write-LogMessage -Severity Warning -Message "Unable to find a matching role by GUID for $RoleDefinitionString." } } else { Write-LogMessage -Severity Verbose -Message "Looking up GDAP Role `"$RoleDefinitionString`" by Name" Write-LogMessage -Severity Debug -Message "Parsing `$GDAPRoles for a role with Name value of $RoleDefinitionString" $GDAPRoleMatch = $GDAPRoles | Where-Object { $_.Name -ieq $RoleDefinitionString } | Select-Object -First 1 if ($GDAPRoleMatch) { Write-LogMessage -Severity Debug -Message "Found role match ID for $RoleDefinitionString of $($GDAPRoleMatch.RoleDefinitionId)" $GDAPRoleMatch } else { Write-LogMessage -Severity Warning -Message "Unable to find a matching role by Name for $RoleDefinitionString." } } } [System.Collections.Generic.List[object]]$GDAPRolesObject = $GDAPRolesObject | Get-PSUnique if ($ReturnID) { [System.Collections.Generic.List[string]]$GDAPRolesObject = $GDAPRolesObject | Select-Object -ExpandProperty RoleDefinitionId } } end { Write-LogMessage -Severity Debug -Message "Found matches for $($GDAPRolesObject.Count) of $($RoleDefinition.Count) RoleDefinition(s)." $PSCmdlet.WriteObject($GDAPRolesObject, $true) Write-LogMessage -Severity Verbose -Message "Ending Get-GDAPAccessRolebyNameorId" } } #Get-GDAPAccessRolebyNameorId <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Get-GDAPRelationship { [CmdletBinding(DefaultParameterSetName = 'Default')] [OutputType([System.Collections.Generic.List[object]])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Detailed', Justification = 'False positive as rule does not scan child scopes')] param ( [Parameter(Mandatory = $false, ParameterSetName = "ByID", HelpMessage = "The GDAP relationship ID provided during the GDAP relationship request creation process")] [ValidateNotNullOrEmpty()] [ValidateScript( { $_ -match ` '^((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))-((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))$' } )] [string]$GDAPRelationshipID, [Parameter(Mandatory = $false, ParameterSetName = "ByFilter", HelpMessage = "Filter used to search relationships based on a specific value, uses OData query parameters")] [ValidateNotNullOrEmpty()] [ValidateScript( { $_ -inotlike "`$filter=*" })] [string]$Filter, [Parameter(Mandatory = $false, HelpMessage = "Flag to indicate that role and security group details should be included in the object")] [ValidateNotNullOrEmpty()] [switch]$Detailed, [Parameter(Mandatory = $false, HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Get-GDAPRelationship" if (-not (Get-MgContext)) { Write-LogMessage -Severity Warning -Message "No Graph API login found, use Connect-MgGraph to login to the Graph API" ; break } } process { try { # Verify that the base URL ends in trailing slash if ($GraphBaseURL -notmatch '.*/$') { $GraphBaseURL = $GraphBaseURL + "/" } # Build the Graph request base URL $DelegatedAdminRelationshipURL = "$($GraphBaseURL)tenantRelationships/delegatedAdminRelationships" # Update the URL based on received input switch ($PsCmdlet.ParameterSetName) { "ByID" { $DelegatedAdminRelationshipURL += "/$($GDAPRelationshipID)" } "ByFilter" { $DelegatedAdminRelationshipURL += "?`$filter=$($Filter)&`$top=15&`$count=true" } default { $DelegatedAdminRelationshipURL += "?`$top=15&`$count=true" } } $DelegatedAdminRelationshipsObjects = [System.Collections.Generic.List[object]]::new() # Submit the Graph API request and receive the delegatedAdminRelationship object Write-LogMessage -Severity Verbose -Message "Retrieving existing GDAP relationships from Graph API: 'Invoke-MgGraphRequest -Method GET -Uri $($DelegatedAdminRelationshipURL) -OutputType PSObject'" $DelegatedAdminRelationships = Invoke-MgGraphRequest -Method GET -Uri $DelegatedAdminRelationshipURL -OutputType PSObject | Get-MgGraphAllPaging Write-LogMessage -Severity Debug -Message "Result:`n$($DelegatedAdminRelationships | ConvertTo-Json -Depth 10)" Write-LogMessage -Severity Verbose -Message "Processing returned array of objects" $DelegatedAdminRelationships | ForEach-Object { $DelegatedAdminRelationship = $null $DelegatedAdminRelationship = Format-AdminRelationship -AdminRelationship $_ -Detailed:$Detailed $DelegatedAdminRelationshipsObjects.Add($DelegatedAdminRelationship) | Out-Null } } catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] { Write-LogMessage -Severity Error -Message "HTTP error when retrieving GDAP relationships, status code $($_.Exception.Response.StatusCode) ($($_.Exception.Response.StatusCode.value__))`n$($_.Exception.Response.StatusDescription)" -LastException $_ } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { if ($DelegatedAdminRelationshipsObjects.Count -ge 1) { $PSCmdlet.WriteObject($DelegatedAdminRelationshipsObjects, $true) } Write-LogMessage -Severity Verbose -Message "Ending Get-GDAPRelationship" } } #Get-GDAPRelationship <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Get-GDAPRelationshipAccessAssignment { [CmdletBinding(DefaultParameterSetName = 'Default')] [OutputType([System.Collections.Generic.List[object]])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Detailed', Justification = 'False positive as rule does not scan child scopes')] param ( [Parameter(Mandatory, HelpMessage = "The GDAP relationship ID to use for accessAssignments lookup")] [ValidateNotNullOrEmpty()] [ValidateScript( { $_ -match ` '^((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))-((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))$' } )] [string]$GDAPRelationshipID, [Parameter(Mandatory, ParameterSetName = "ById", HelpMessage = "The AccessAssignment ID to lookup")] [ValidateNotNullOrEmpty()] [ValidateScript({ if (-not (Test-Guid -InputObject $_)) { throw "AccessAssignment ID `"$_`" is not in a valid guid format" } else { $true } })] [string]$AccessAssignmentId, [Parameter(Mandatory, ParameterSetName = "ByGroup", HelpMessage = "Filters the accessAssignments to specific groups")] [ValidateNotNullOrEmpty()] [System.Collections.Generic.List[string]]$Group, [Parameter(Mandatory, ParameterSetName = "ByFilter", HelpMessage = "Filter used to search accessAssignments based on a specific value, uses OData query parameters")] [ValidateNotNullOrEmpty()] [ValidateScript( { $_ -inotlike "`$filter=*" })] [string]$Filter, [Parameter(Mandatory = $false, ParameterSetName = "Default", HelpMessage = "Flag to indicate that results should be filtered to only accessAssignments with 'active' or 'pending' states")] [Parameter(Mandatory = $false, ParameterSetName = "ByGroup", HelpMessage = "Flag to indicate that results should be filtered to only accessAssignments with 'active' or 'pending' states")] [switch]$Active, [Parameter(Mandatory = $false, HelpMessage = "Flag to indicate that role and security group details should be included in the object")] [switch]$Detailed, [Parameter(Mandatory = $false, HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Get-GDAPRelationshipAccessAssignment" if (-not (Get-MgContext)) { Write-LogMessage -Severity Warning -Message "No Graph API login found, use Connect-MgGraph to login to the Graph API" ; break } } process { try { # Verify that the base URL ends in trailing slash if ($GraphBaseURL -notmatch '.*/$') { $GraphBaseURL = $GraphBaseURL + "/" } # Build the Graph request base URL $DelegatedAdminAccessAssignmentURL = "$($GraphBaseURL)tenantRelationships/delegatedAdminRelationships/$($GDAPRelationshipID)/accessAssignments" # Update the URL based on received input, perform group lookup if not provided in Guid format switch ($PsCmdlet.ParameterSetName) { "ById" { $DelegatedAdminAccessAssignmentURL = "$($DelegatedAdminAccessAssignmentURL)/$($AccessAssignmentId)" } "ByGroup" { $Group = $Group | ForEach-Object { Write-LogMessage -Severity Verbose -Message "Checking if the Group was provided in GUID ObjectId format" if (Test-Guid $_) { Write-LogMessage -Severity Debug -Message "GUID detected, returning group as is" $_ } else { Write-LogMessage -Severity Debug -Message "Performing group lookup" (Get-EntraGroupbyNameorId -Group $_ -GraphBaseURL $GraphBaseURL).id } } $FilterResults = "accessContainer/accessContainerId in ('$($Group -join "', '")')" } "ByFilter" { $FilterResults = $($Filter) } } if ($Active) { if ($FilterResults) { $FilterResults = "(status eq ('active') or status eq ('pending')) and $($FilterResults)" } else { $FilterResults = "(status eq ('active') or status eq ('pending'))" } } if ($FilterResults) { $DelegatedAdminAccessAssignmentURL += "?`$filter=$($FilterResults)&`$count=true" } else { $DelegatedAdminAccessAssignmentURL += "?`$count=true" } $DelegatedAdminRelationshipAccessObjects = [System.Collections.Generic.List[object]]::new() # Submit the Graph API request and receive the delegatedAdminRelationship object Write-LogMessage -Severity Verbose -Message "Retrieving existing GDAP relationship accessAssignments from Graph API: 'Invoke-MgGraphRequest -Method GET -Uri $($DelegatedAdminRelationshipURL)'" $DelegatedAdminRelationshipAccessItems = Invoke-MgGraphRequest -Method GET -Uri $DelegatedAdminAccessAssignmentURL -OutputType PSObject | Get-MgGraphAllPaging Write-LogMessage -Severity Debug -Message "Result:`n$($DelegatedAdminRelationshipAccessItems | ConvertTo-Json -Depth 5)" Write-LogMessage -Severity Verbose -Message "Processing returned array of objects" $DelegatedAdminRelationshipAccessItems | ForEach-Object { $DelegatedAdminRelationshipAccess = $null $DelegatedAdminRelationshipAccess = Format-AccessAssignment -AccessAssignment $_ -GDAPRelationshipID $GDAPRelationshipID -Detailed:$Detailed -GraphBaseURL $GraphBaseURL $DelegatedAdminRelationshipAccessObjects.Add($DelegatedAdminRelationshipAccess) | Out-Null } Write-LogMessage -Severity Debug -Message "Result:`n$($DelegatedAdminRelationshipAccessObjects | ConvertTo-Json -Depth 5)" } catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] { Write-LogMessage -Severity Error -Message "HTTP error when getting GDAP adminAccessAssignments, status code $($_.Exception.Response.StatusCode) ($($_.Exception.Response.StatusCode.value__))`n$($_.Exception.Response.StatusDescription)" -LastException $_ } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { if ($DelegatedAdminRelationshipAccessObjects.Count -ge 1) { $PSCmdlet.WriteObject($DelegatedAdminRelationshipAccessObjects, $true) } Write-LogMessage -Severity Verbose -Message "Ending Get-GDAPRelationshipAccessAssignment" } } #Get-GDAPRelationshipAccessAssignment <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Get-GDAPRelationshipRequestLink { [CmdletBinding(DefaultParameterSetName = "NoEmail")] [OutputType([object])] param ( [Parameter(Mandatory, ParameterSetName = 'Email', HelpMessage = "The GDAP relationship ID provided during the GDAP relationship request creation process")] [Parameter(Mandatory, ParameterSetName = 'NoEmail', HelpMessage = "The GDAP relationship ID provided during the GDAP relationship request creation process")] [ValidateNotNullOrEmpty()] [ValidateScript( { $_ -match ` '^((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))-((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))$' } )] [string]$GDAPRelationshipID, [Parameter(Mandatory = $false, ParameterSetName = 'Email', HelpMessage = "String containing the link that should be included as an indirect reseller link")] [Parameter(Mandatory = $false, ParameterSetName = 'NoEmail', HelpMessage = "String containing the link that should be included as an indirect reseller link")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$IndirectResellerLink, [Parameter(Mandatory = $true, ParameterSetName = 'Email', HelpMessage = "Should boilerplate email text be generated")] [switch]$GenerateEmailText, [Parameter(Mandatory = $false, ParameterSetName = 'Email', HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Get-GDAPRelationshipRequestLink" } process { if ($PsCmdlet.ParameterSetName -eq 'Email' -and $GenerateEmailText) { try { # Get the existing GDAP relationship to pull specific information for the email text $GDAPRelationship = Get-GDAPRelationship -GDAPRelationshipID $GDAPRelationshipID -Detailed -GraphBaseURL $GraphBaseURL if ($null -eq $GDAPRelationship) { Write-LogMessage -Severity Warning -Message "Skipping email text generation, the provided GDAP Relationship ID did not match any existing GDAP Relationships." $GenerateEmailText = $false } else { # Use the Indirect Reseller Link query items to build the tenant display names, or the Graph context if not using Indirect Reseller. if ($IndirectResellerLink) { $LinkQueries = Get-HttpQueryStringList -Url $IndirectResellerLink if ($LinkQueries.GetEnumerator().Name -icontains 'indirectCSPId') { [string]$IndirectCSPName = Get-TenantDisplayNamebyId -TenantID ([regex]::match($LinkQueries.indirectCSPId , '((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))').Groups[1].Value) -GraphBaseURL $GraphBaseURL } if ($LinkQueries.GetEnumerator().Name -icontains 'partnerId') { [string]$TenantDisplayName = Get-TenantDisplayNamebyId -TenantID ([regex]::match($LinkQueries.partnerId , '((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))').Groups[1].Value) -GraphBaseURL $GraphBaseURL } } else { [string]$TenantDisplayName = Get-TenantDisplayNamebyId -TenantID ((Get-MgContext).TenantId) -GraphBaseURL $GraphBaseURL } } } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } # Build the GDAP URL $GDAPInvitationLink = "https://admin.microsoft.com/AdminPortal/Home#/partners/invitation/granularAdminRelationships/$($GDAPRelationshipID)" try { # Generate the email text, include indirect reseller information if specified. Add the roles as individual lines with name and description (emulates the text from the GDAP in the partner portal) if ($PsCmdlet.ParameterSetName -eq 'Email' -and $GenerateEmailText) { if ([string]::IsNullOrEmpty($GDAPRelationship.customer.displayName)) { $EmailText = "$($TenantDisplayName) Microsoft 365 Partner Permissions Request`n`n" } else { $EmailText = "$($GDAPRelationship.customer.displayName) - $($TenantDisplayName) Microsoft 365 Partner Permissions Request`n`n" } # Create the Role Name and Definition object from the existing GDAP Relationship unifiedRoles $GDAPRelationshipRoles = $GDAPRelationship.accessDetails.unifiedRoles if ([string]::IsNullOrEmpty($GDAPRelationship.endDateTime)) { $EndDate = ([System.DateTime]$GDAPRelationship.createdDateTime).Add((Convert-ISO8601ToTimespan -Duration $GDAPRelationship.duration)) } else { $EndDate = [System.DateTime]$GDAPRelationship.endDateTime } if ($IndirectResellerLink) { $EmailText += @" As your cloud services provider, $($TenantDisplayName) offers cloud solutions through our partner, $($IndirectCSPName). Follow the link below to accept this offer and to subscribe to $($IndirectCSPName)'s solutions through $($TenantDisplayName) and to authorize $($TenantDisplayName) as your official local reseller. $($IndirectResellerLink) Note: User with Global Admin permission is required to accept relationship. Customer address must be completed first (https://admin.microsoft.com/Adminportal/Home?#/BillingAccounts/billing-accounts) before using the acceptance link above. Additionally, by clicking the following link you will be able to accept the request for us to administer your cloud services using the roles listed below for the specified timeframe.`n`n "@ } else { $EmailText += @" As your cloud services provider, $($TenantDisplayName) provides the administration of your cloud services through our status as a Microsoft Partner. Follow the link below to accept the request for us to administer your cloud services using the roles listed below for the specified timeframe.`n`n "@ } $EmailText += @" Click to review and accept the below permissions: $($GDAPInvitationLink) Administrative Roles Expiration Date: $(Get-Date -Format "MMMM dd, yyyy" -Date $EndDate) Requested Entra ID roles: "@ $GDAPRelationshipRoles | Sort-Object -Property Name | ForEach-Object { $EmailText += @" `n $($_.Name) $($_.Description) "@ } } # Build and return the output object Write-LogMessage -Severity Verbose -Message "Generating GDAPInvitationLink value." $GDAPRelationshipRequest = @{ GDAPInvitationLink = $GDAPInvitationLink } if ($IndirectResellerLink) { Write-LogMessage -Severity Verbose -Message "Adding IndirectResellerLink value." $GDAPRelationshipRequest.Add('IndirectResellerLink', $IndirectResellerLink) } if ($EmailText) { Write-LogMessage -Severity Verbose -Message "Generating EmailText value." $GDAPRelationshipRequest.Add('EmailText', $EmailText) } } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { $PSCmdlet.WriteObject($GDAPRelationshipRequest) Write-LogMessage -Severity Verbose -Message "Ending Get-GDAPRelationshipRequestLink" } } #Get-GDAPRelationshipRequestLinks <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Import-GDAPRoleList { [CmdletBinding()] [OutputType([System.Collections.Generic.List[object]])] param( [Parameter(Mandatory = $false, ValueFromPipeline = $true, Position = 0, HelpMessage = "Local file path or URL string with the JSON file containing GDAP Entra ID roles, defaults to the included roles file")] [string]$RoleFile ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Import-GDAPRoleList" try { if ([string]::IsNullOrEmpty($RoleFile)) { $RoleFile = (Join-Path -Path $MyInvocation.MyCommand.Module.ModuleBase -ChildPath 'Resources/Roles/GDAPRoles.json') } if (Test-Path $RoleFile -IsValid) { Write-LogMessage -Severity Verbose -Message "Local path of $RoleFile is valid, importing role file." $RoleFile = Resolve-Path -Path $($RoleFile) [System.Collections.Generic.List[object]]$ImportedRoleFile = Get-Content -Path $RoleFile | ConvertFrom-Json -Depth 64 } elseif (Test-Url -Url $RoleFile) { Write-LogMessage -Severity Verbose -Message "URL of $RoleFile is valid, importing role file." [System.Collections.Generic.List[object]]$ImportedRoleFile = Get-JSONFromURL -JSONFileUrl $RoleFile } else { Write-LogMessage -Severity Error -Message "$($RoleFile) is not a valid local file path or URL" -TargetObject $RoleFile -RecommendedAction "Provide a valid `$RoleFile variable that points to a valid JSON local path or URL" -StopOnError } } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ -TargetObject $RoleFile -StopOnError } } process { # Get values from possible RoleList layouts Write-LogMessage -Severity Verbose -Message "Parsing imported Role list to determine which attributes were included for import." try { $Expressions = [System.Collections.Generic.List[object]]::new() $ArrayMember = $ImportedRoleFile | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name | ForEach-Object { foreach ($RoleObject in $ImportedRoleFile) { if ($RoleObject."$_" -is [array]) { $_ } } } | Select-Object -Unique if ($ArrayMember) { $TempImportedRoleFile = [System.Collections.Generic.List[object]]::new() $InternalMemberName = $ImportedRoleFile | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name | Where-Object { $_ -notin $ArrayMember } foreach ($ArrayMemberItem in $ArrayMember) { foreach ($RoleObject in $ImportedRoleFile) { $RoleObject."$ArrayMemberItem" | ForEach-Object { foreach ($InternalMemberNameItem in $InternalMemberName) { Add-Member -InputObject $_ -MemberType NoteProperty -Name "Group$([regex]::replace($InternalMemberNameItem, '^\w', {param($m) "$m".ToUpper()}))" -Value $RoleObject."$InternalMemberNameItem" } $TempImportedRoleFile.Add($_) } } } $ImportedRoleFile = $TempImportedRoleFile } [System.Collections.Generic.List[string]]$RoleOrder = "^(?!Group).*(?:Name)+?.*", "^(?!Group).*(?:RoleDefinition|Object|Id)+?.*", "^(?!Group).*(?:Desc)+?.*", ".*" $ImportedRoleFile | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name | Sort-Object { $MemberName = $_ $RoleOrder.IndexOf(($RoleOrder | Where-Object { $MemberName -imatch $_ } | Select-Object -First 1)) } | ForEach-Object { [string]$ExpressionString = "`$_.$_" [ScriptBlock]$Expression = [ScriptBlock]::Create($ExpressionString) switch -regex ($_) { "^(?!Group).*(?:Name)+?.*" { $Expressions.Add( @{n = "Name"; e = $Expression } ) } "^(?!Group).*(?:Role|Object|Id)+?.*" { $Expressions.Add( @{n = "RoleDefinitionId"; e = $Expression } ) } "^(?!Group).*(?:Desc)+?.*" { $Expressions.Add( @{n = "Description"; e = $Expression } ) } Default { $Expressions.Add( @{n = "$($([regex]::replace($_, '^\w', {param($m) "$m".ToUpper()})))"; e = $Expression } ) } } } [System.Collections.Generic.List[object]]$RoleList = $ImportedRoleFile | Select-Object -Property $Expressions | Get-PSUnique $PSCmdlet.WriteObject($RoleList, $true) } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ -TargetObject $ImportedRoleFile -StopOnError } } end { Set-Variable -Name GDAPRoleList -Value $RoleList -Scope Script $PSCmdlet.WriteObject($RoleList, $true) Write-LogMessage -Severity Verbose -Message "Ending Import-GDAPRoleList" } } #Import-GDAPRoleList <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Import-GDAPTemplate { [CmdletBinding()] [OutputType([object])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'GraphBaseURL', Justification = 'False positive as rule does not scan child scopes')] param( [Parameter(Mandatory, ValueFromPipeline = $true, Position = 0, HelpMessage = "Local file path or URL string with the JSON template file containing one or more delegatedAdminAccessAssignment objects")] [ValidateNotNullOrEmpty()] [string]$TemplateFile, [Parameter(Mandatory = $false, HelpMessage = "Disable the validation of the template against known roles or groups")] [switch]$SkipValidation, [Parameter(Mandatory = $false, HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Import-GDAPTemplate" if (Test-Path $TemplateFile -IsValid) { Write-LogMessage -Severity Verbose -Message "Local path of $TemplateFile is valid, importing template file." $TemplateFile = Resolve-Path -Path $TemplateFile $ImportedTemplate = Get-Content -Path $TemplateFile | ConvertFrom-Json -Depth 64 } elseif (Test-Url -Url $TemplateFile) { Write-LogMessage -Severity Verbose -Message "URL of $TemplateFile is valid, importing template file." [System.Collections.Generic.List[object]]$ImportedTemplate = Get-JSONFromURL -JSONFileUrl $TemplateFile } else { Write-LogMessage -Severity Error -Message "$($TemplateFile) is not a valid local file path or URL" -LastException $_ ; break } $MaximumDuration = "P730D" $AutoExtendDurationValues = "PT0S", "P0D", "P180D" $TemplateObject = [PSCustomObject]@{ Roles = [System.Collections.Generic.List[object]]::new() AccessAssignment = [System.Collections.Generic.List[object]]::new() Duration = $null AutoExtendDuration = $null AutoExtendRelationship = $null } } process { # Get values from possible template layouts Write-LogMessage -Severity Verbose -Message "Parsing imported template to determine which attributes were included for import." $ImportedTemplate | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name | ForEach-Object { switch -Wildcard ($_) { "AccessAssignment*" { [System.Collections.Generic.List[object]]$AccessAssignmentObjects = $ImportedTemplate."$_" } "Role*" { if ("roleDefinitionId" -iin ($ImportedTemplate."$_" | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name)) { [System.Collections.Generic.List[string]]$Roles = $ImportedTemplate."$_".roleDefinitionId } elseif ("Name" -iin ($ImportedTemplate."$_" | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name)) { [System.Collections.Generic.List[string]]$Roles = $ImportedTemplate."$_".Name } else { [System.Collections.Generic.List[string]]$Roles = $ImportedTemplate."$_" } } "Duration*" { if ($SkipValidation) { $TemplateObject.Duration = [string]$ImportedTemplate."$_" } elseif ((Convert-ISO8601ToTimespan -Duration $ImportedTemplate."$_") -le (Convert-ISO8601ToTimespan -Duration $MaximumDuration)) { $TemplateObject.Duration = [string]$ImportedTemplate."$_" } else { Write-LogMessage -Severity Warning -Message "Duration value of `"$($ImportedTemplate."$_")`" is not a valid duration, skipping value" } } "AutoExtendDuration*" { if ($SkipValidation -or $ImportedTemplate."$_" -in $AutoExtendDurationValues) { $TemplateObject.AutoExtendDuration = [string]$ImportedTemplate."$_" } else { Write-LogMessage -Severity Warning -Message "AutoExtendDuration value of `"$($ImportedTemplate."$_")`" is not in the list of valid values, $($AutoExtendDurationValues -join '; '), skipping value" } } "AutoExtendRelationship*" { if ($SkipValidation) { $TemplateObject.AutoExtendRelationship = $ImportedTemplate."$_" } elseif ($ImportedTemplate."$_" -isnot [Boolean]) { Write-LogMessage -Severity Warning -Message "AutoExtendRelationship value of `"$($ImportedTemplate."$_")`" is not a boolean, skipping value" } else { $TemplateObject.AutoExtendRelationship = $ImportedTemplate."$_" } } Default { if ($ImportedTemplate."$_" -iin "accessContainer", "accessDetails") { [System.Collections.Generic.List[object]]$AccessAssignmentObjects = $ImportedTemplate } else { $TemplateObject."$_" = $ImportedTemplate."$_" } } } } if ($SkipValidation) { # Create the assignment objects with no validation, create an null invalid assignment object to prevent errors [System.Collections.Generic.List[object]]$ValidAccessAssignmentObjects = $AccessAssignmentObjects [System.Collections.Generic.List[object]]$InvalidAccessAssignmentObjects = $null } else { # Test if the assignment object(s) are valid [System.Collections.Generic.List[object]]$ValidAccessAssignmentObjects = $AccessAssignmentObjects | Where-Object { Test-AccessAssignment -AccessAssignment $_ -GraphBaseURL $GraphBaseURL } Write-LogMessage -Severity Verbose -Message "Found $($ValidAccessAssignmentObjects.Count) valid AccessAssignment objects out of $($AccessAssignmentObjects.Count) objects in the template file $($TemplateFile)." [System.Collections.Generic.List[object]]$InvalidAccessAssignmentObjects = $AccessAssignmentObjects | Where-Object { $_ -notin $ValidAccessAssignmentObjects } if ($null -ne $TemplateObject.AutoExtendRelationship -and $null -eq $TemplateObject.AutoExtendDuration) { Write-LogMessage -Severity Information -Message "AutoExtendRelationship is set and AutoExtendDuration is empty, auto-filling AutoExtendDuration based on AutoExtendRelationship value" if ($TemplateObject.AutoExtendRelationship) { $TemplateObject.AutoExtendDuration = "P180D" } else { $TemplateObject.AutoExtendDuration = "PT0S" } } elseif ($TemplateObject.AutoExtendRelationship -and $TemplateObject.AutoExtendDuration -ne "P180D") { Write-LogMessage -Severity Warning -Message "AutoExtendRelationship is `$true, but AutoExtendDuration is not equal to `"P180D`", preferring AutoExtendRelationship value and settings AutoExtendDuration to `"P180D`"" $TemplateObject.AutoExtendDuration = "P180D" } } if ($InvalidAccessAssignmentObjects.Count -gt 0) { Write-LogMessage -Severity Warning -Message "Found $($InvalidAccessAssignmentObjects.Count) invalid AccessAssignment objects out of $($AccessAssignmentObjects.Count) objects in the template file $($TemplateFile)." } Write-LogMessage -Severity Verbose -Message "Generating the list of required role IDs from the accessAssignments" [System.Collections.Generic.List[string]]$RequiredRoleID = $ValidAccessAssignmentObjects | ForEach-Object { $_.accessDetails.unifiedRoles.roleDefinitionId } | Select-Object -Unique if ($Roles) { Write-LogMessage -Severity Verbose -Message "Expected Role Definition list included, adding to required roles list" $RequiredRoleID.AddRange($Roles) } $RequiredRoleIDResults = Get-GDAPAccessRolebyNameorId -RoleDefinition $RequiredRoleID $TemplateObject.Roles.AddRange($RequiredRoleIDResults) $TemplateObject.AccessAssignment.AddRange($ValidAccessAssignmentObjects) } end { $PSCmdlet.WriteObject($TemplateObject, $true) Write-LogMessage -Severity Verbose -Message "Ending Import-GDAPTemplate" } } # Import-GDAPTemplate <# .EXTERNALHELP GDAPRelationships-help.xml #> Function New-GDAPRelationship { [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = "GeneratedwithDays")] [OutputType([object])] param ( [Parameter(Mandatory = $false, HelpMessage = "Enter the display name of the client's Entra ID tenant as displayed in the client's Entra Admin Center at https://entra.microsoft.com/#view/Microsoft_AAD_IAM/TenantOverview.ReactView")] [ValidateNotNullOrEmpty()] [string]$ClientTenantName, [Parameter(Mandatory = $false, ParameterSetName = 'NamedwithDays', HelpMessage = "Enter the client's Tenant ID of their Entra ID tenant as displayed in the client's Entra Admin Center at https://entra.microsoft.com/#view/Microsoft_AAD_IAM/TenantOverview.ReactView")] [Parameter(Mandatory = $false, ParameterSetName = 'NamedwithDuration', HelpMessage = "Enter the client's Tenant ID of their Entra ID tenant as displayed in the client's Entra Admin Center at https://entra.microsoft.com/#view/Microsoft_AAD_IAM/TenantOverview.ReactView")] [Parameter(Mandatory = $false, ParameterSetName = 'GeneratedwithDays', HelpMessage = "Enter the client's Tenant ID of their Entra ID tenant as displayed in the client's Entra Admin Center at https://entra.microsoft.com/#view/Microsoft_AAD_IAM/TenantOverview.ReactView")] [Parameter(Mandatory = $false, ParameterSetName = 'GeneratedwithDuration', HelpMessage = "Enter the client's Tenant ID of their Entra ID tenant as displayed in the client's Entra Admin Center at https://entra.microsoft.com/#view/Microsoft_AAD_IAM/TenantOverview.ReactView")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Guid -InputObject $_ })] [string]$ClientTenantID, [Parameter(Mandatory = $false, ParameterSetName = 'NamedwithDays', HelpMessage = "Enter the display name of the GDAP relationship to provision, must be unique")] [Parameter(Mandatory = $false, ParameterSetName = 'NamedwithDuration', HelpMessage = "Enter the display name of the GDAP relationship to provision, must be unique")] [ValidateNotNullOrEmpty()] [ValidateLength(1, 50)] [ValidatePattern("[^a-zA-Z0-9.,&+_-]+")] [string]$GDAPRelationshipName, [Parameter(Mandatory = $false, ParameterSetName = 'NamedwithDays', HelpMessage = "The number of days for the GDAP relationship to live, maximum of 730 days. Defaults to 730 days")] [Parameter(Mandatory = $false, ParameterSetName = 'NamedwithDuration', HelpMessage = "The number of days for the GDAP relationship to live, maximum of 730 days. Defaults to 730 days")] [Parameter(Mandatory = $false, ParameterSetName = 'GeneratedwithDays', HelpMessage = "The number of days for the GDAP relationship to live, maximum of 730 days. Defaults to 730 days")] [Parameter(Mandatory = $false, ParameterSetName = 'GeneratedwithDuration', HelpMessage = "The number of days for the GDAP relationship to live, maximum of 730 days. Defaults to 730 days")] [ValidateRange(1, 730)] [int]$RelationshipExpirationInDays = 730, [Parameter(Mandatory = $false, HelpMessage = "The duration for the GDAP relationship to live in ISO8601 string format, maximum of 730 days. Defaults to 730 days")] [ValidateScript( { if (-not (Convert-ISO8601ToTimespan -Duration $_)) { throw "$_ is not a valid ISO8601 duration string" } elseif (Convert-ISO8601ToTimespan -Duration $_ -gt (Convert-ISO8601ToTimespan -Duration 730)) { throw "$_ is greater than the maximum allowed 730 days, `"P730D`"" } else { return $true } } )] [string]$Duration = "P730D", [Parameter(Mandatory = $false, HelpMessage = "Should the relationship be set to auto extend using the allowed `"P180D`" parameter (will not be used if the Global Administrator role is included in assigned roles)?")] [switch]$AutoExtendRelationship, [Parameter(Mandatory = $false, ParameterSetName = 'GeneratedwithDays', HelpMessage = "The prefix to use in the generated GDAP relationship name, e.g. 'CompName'")] [Parameter(Mandatory = $false, ParameterSetName = 'GeneratedwithDuration', HelpMessage = "The prefix to use in the generated GDAP relationship name, e.g. 'CompName'")] [ValidateNotNullOrEmpty()] [ValidateLength(0, 8)] [string]$RelationshipPrefix, [Parameter(Mandatory, HelpMessage = "GUID Role IDs or Names of Entra ID roles to be used in the GDAP Relationship")] [ValidateNotNullOrEmpty()] [System.Collections.Generic.List[string]]$RoleDefinition, [Parameter(Mandatory = $false, HelpMessage = "Flag to indicate that the a relationshipRequest should be created and set to LockForApproval")] [switch]$LockForApproval, [Parameter(Mandatory = $false, HelpMessage = "Flag to indicate that role and security group details should be included in the return object")] [switch]$Detailed, [Parameter(Mandatory = $false, HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting New-GDAPRelationship" if (-not (Get-MgContext)) { Write-LogMessage -Severity Warning -Message "No Graph API login found, use Connect-MgGraph to login to the Graph API" ; break } } process { try { # Verify that the base URL ends in trailing slash if ($GraphBaseURL -notmatch '.*/$') { $GraphBaseURL = $GraphBaseURL + "/" } # Build Graph URL $DelegatedAdminRelationshipURL = "$($GraphBaseURL)tenantRelationships/delegatedAdminRelationships" # If used, normalize the Relationship Prefix to match and fit if ([string]::IsNullOrEmpty($RelationshipPrefix) -and [string]::IsNullOrEmpty($GDAPRelationshipName) -and -not ([string]::IsNullOrEmpty($ClientTenantID))) { [string]$RelationshipPrefix = Get-TenantDisplayNamebyId -TenantID $ClientTenantID -GraphBaseURL $GraphBaseURL } if (-not ([string]::IsNullOrEmpty($RelationshipPrefix)) -and [string]::IsNullOrEmpty($GDAPRelationshipName)) { $RelationshipPrefix = $RelationshipPrefix.Trim() -replace '\s+', '_' -replace '[^a-zA-Z0-9.,&+_-]' $RelationshipPrefix = $RelationshipPrefix.Substring(0, [System.Math]::Min(8, $RelationshipPrefix.Length)) -replace '[\s_-]+$' } # Build and normalize the GDAP Relationship displayName Write-LogMessage -Severity Verbose -Message "Normalizing/Generating GDAP Relationship Display Name" [string]$RelationshipDisplayName = $(if ($GDAPRelationshipName) { $GDAPRelationshipName } elseif (-not ([string]::IsNullOrEmpty($ClientTenantID)) -and -not ([string]::IsNullOrEmpty($RelationshipPrefix))) { "{0}_{1}_{2}" -f $RelationshipPrefix, $(Get-Date -Format yyyy), $ClientTenantID } elseif (-not ([string]::IsNullOrEmpty($RelationshipPrefix))) { "{0}_{1}_{2}" -f $RelationshipPrefix, $(Get-Date -Format yyyy), $((New-Guid).Guid) } else { "{0}_{1}_{2}" -f "GDAP", $(Get-Date -Format yyyy), $((New-Guid).Guid) }) # Ensure the GDAP Relationship displayName is unique amongst all GDAP relationships, update with random characters until it is unique Write-LogMessage -Severity Verbose -Message "Verifying that the GDAP Relationship displayName is unique amongst all GDAP relationship displayNames" if (-not (Get-GDAPRelationship -Filter "displayName eq '($($RelationshipDisplayName))'" -GraphBaseURL $GraphBaseURL)) { $updateCount = 0 do { Write-LogMessage -Severity Verbose -Message "Generating random digits to attempt to make GDAP Relationship displayName unique" if ($RelationshipDisplayName.Length -lt 50) { $RelationshipDisplayName += ([char]((97..122) + (48..57) | Get-Random)) } else { $updateCount++ $RelationshipDisplayName = $RelationshipDisplayName.Substring(0, ($RelationshipDisplayName.Length - $updateCount)) } } until (-not (Get-GDAPRelationship -Filter "displayName eq '($($RelationshipDisplayName))'" -GraphBaseURL $GraphBaseURL)) } Write-LogMessage -Severity Verbose -Message "GDAP Relationship Dispay Name: $($RelationshipDisplayName)" # Perform role definition lookups $RoleDefinitionId = Get-GDAPAccessRolebyNameorId -RoleDefinition $RoleDefinition -ReturnID # Get Duration switch -Wildcard ($PsCmdlet.ParameterSetName) { "*Days" { $Duration = "P$($RelationshipExpirationInDays.ToString())D" } } # Build the Graph API Message Body with available variables $DelegatedAdminRelationshipBody = [PSCustomObject]@{ displayName = $RelationshipDisplayName duration = $Duration accessDetails = @{ unifiedRoles = @( $RoleDefinitionId | ForEach-Object { @{ roleDefinitionId = $_ } } ) } } if ($AutoExtendRelationship -and $RoleDefinitionId -notcontains '62e90394-69f5-4237-9190-012177145e10') { Add-Member -InputObject $DelegatedAdminRelationshipBody -MemberType NoteProperty -Name 'autoExtendDuration' -Value 'P180D' } else { Add-Member -InputObject $DelegatedAdminRelationshipBody -MemberType NoteProperty -Name 'autoExtendDuration' -Value 'PT0S' } if ($ClientTenantID -and $ClientTenantName) { Add-Member -InputObject $DelegatedAdminRelationshipBody -MemberType NoteProperty -Name 'customer' -Value @{ tenantId = $ClientTenantID displayName = $ClientTenantName } } elseif ($ClientTenantID) { Add-Member -InputObject $DelegatedAdminRelationshipBody -MemberType NoteProperty -Name 'customer' -Value @{ tenantId = $ClientTenantID } } elseif ($ClientTenantName) { Add-Member -InputObject $DelegatedAdminRelationshipBody -MemberType NoteProperty -Name 'customer' -Value @{ displayName = $ClientTenantName } } # Submit the Graph API request and receieve the generated relationship object Write-LogMessage -Severity Verbose -Message "Creating new GDAP relationship from Graph API: 'Invoke-MgGraphRequest -Method POST -Uri $DelegatedAdminRelationshipURL -Body (`$DelegatedAdminRelationshipBody | ConvertTo-Json -Depth 10 -Compress) -StatusCodeVariable IMGRStatusCode -OutputType PSObject'" Write-LogMessage -Severity Debug -Message "Graph Request Body value for new Relationship: `n$($DelegatedAdminRelationshipBody | ConvertTo-Json -Depth 10)" if ($PSCmdlet.ShouldProcess(("Request body: `n$($DelegatedAdminRelationshipBody | ConvertTo-Json -Depth 10 -Compress)"), ("Invoke-MgGraphRequest -Method POST -Uri $DelegatedAdminRelationshipURL -StatusCodeVariable IMGRStatusCode -OutputType PSObject"))) { $CreateDelegatedAdminRelationship = Invoke-MgGraphRequest -Method POST -Uri $DelegatedAdminRelationshipURL -Body ($DelegatedAdminRelationshipBody | ConvertTo-Json -Depth 10 -Compress) -StatusCodeVariable IMGRStatusCode -OutputType PSObject Write-LogMessage -Severity Debug -Message "Result:`n $($CreateDelegatedAdminRelationship | ConvertTo-Json -Depth 10)" # Validate the relationship request ID is valid before returning if (-not ([string]::IsNullOrEmpty($CreateDelegatedAdminRelationship.id)) -and $IMGRStatusCode -eq '201') { # Check that the relationship is in the "created" state Start-Sleep -Milliseconds 100 $Count = 0 do { Write-LogMessage -Severity Verbose -Message "Checking for an status on the new relationship of `"created`": 'Invoke-MgGraphRequest -Method GET -Uri `"$($DelegatedAdminRelationshipURL)/$($CreateDelegatedAdminRelationship.id)`" -OutputType PSObject'" $CheckActive = Invoke-MgGraphRequest -Method GET -Uri "$($DelegatedAdminRelationshipURL)/$($CreateDelegatedAdminRelationship.id)" -OutputType PSObject Start-Sleep -Milliseconds 200 $Count++ } until ($CheckActive.status -eq 'created' -or $Count -gt 10) if ($CheckActive.status -eq 'created' -and $LockForApproval) { # Lock for approval $NewRelationshipRequest = New-GDAPRelationshipRequest -GDAPRelationshipID $CreateDelegatedAdminRelationship.id -GraphBaseURL $GraphBaseURL if ($NewRelationshipRequest.action -ne 'lockForApproval') { Write-LogMessage -Severity Warning -Message "Unable to create relationship request lock, returning adminRelationship anyway." } if ($Detailed) { $DelegatedAdminRelationship = Format-AdminRelationship -AdminRelationship $CreateDelegatedAdminRelationship -Detailed:$Detailed } $PSCmdlet.WriteObject($DelegatedAdminRelationship, $true) } elseif ($CheckActive.status -eq 'created') { if ($Detailed) { $DelegatedAdminRelationship = Format-AdminRelationship -AdminRelationship $CreateDelegatedAdminRelationship -Detailed:$Detailed } $PSCmdlet.WriteObject($DelegatedAdminRelationship, $true) } else { if ($Detailed) { $DelegatedAdminRelationship = Format-AdminRelationship -AdminRelationship $CreateDelegatedAdminRelationship -Detailed:$Detailed } $PSCmdlet.WriteObject($DelegatedAdminRelationship, $true) throw "Admin relationship created but was not found in correct state in a timely manner. Unable to complete relationship.`n RelationshipID: $($CreateDelegatedAdminRelationship.id)`n Current state: $($CheckActive.status)" } } else { Write-LogMessage -Severity Warning -Message "Graph API status code: $($IMGRStatusCode)" throw "No valid admin relationship created." } } } catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] { Write-LogMessage -Severity Error -Message "HTTP error when created GDAP relationship, status code $($_.Exception.Response.StatusCode) ($($_.Exception.Response.StatusCode.value__))`n$($_.Exception.Response.StatusDescription)" -LastException $_ } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ -StopOnError } } end { Write-LogMessage -Severity Verbose -Message "Ending New-GDAPRelationship" } } #New-GDAPRelationship <# .EXTERNALHELP GDAPRelationships-help.xml #> Function New-GDAPRelationshipAccessAssignment { [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([object])] param ( [Parameter(Mandatory, HelpMessage = "The GDAP relationship ID provided during the GDAP relationship request creation process.")] [ValidateNotNullOrEmpty()] [ValidateScript( { $_ -match ` '^((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))-((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))$' } )] [string]$GDAPRelationshipID, [Parameter(Mandatory, HelpMessage = "Entra ID Group Object ID Guid or Display Name to assign specific Entra ID roles.")] [ValidateNotNullOrEmpty()] [string]$Group, [Parameter(Mandatory, HelpMessage = "List of Entra ID role Guids or role Names to be assigned to the security group in the GDAP Relationship")] [ValidateNotNullOrEmpty()] [System.Collections.Generic.List[string]]$RoleDefinition, [Parameter(Mandatory = $false, HelpMessage = "Flag to indicate that role and security group details should be included in the object")] [ValidateNotNullOrEmpty()] [switch]$Detailed, [Parameter(Mandatory = $false, HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting New-GDAPRelationshipAccessAssignment" if (-not (Get-MgContext)) { Write-LogMessage -Severity Warning -Message "No Graph API login found, use Connect-MgGraph to login to the Graph API" ; break } $GDAPRelationshipStatus = Get-GDAPRelationship -GDAPRelationshipID $GDAPRelationshipID -GraphBaseURL $GraphBaseURL if ($GDAPRelationshipStatus.status -notin 'active', 'approved', 'activating') { Write-LogMessage -Severity Warning -Message "The GDAP relationship request with ID $($GDAPRelationshipID)$(if ($GDAPRelationshipStatus.customer.displayName) { " for $($GDAPRelationshipStatus.customer.displayName)" }) has not been completed or needs more time to finalize provisioning." ; break } } process { try { # Verify that the base URL ends in trailing slash if ($GraphBaseURL -notmatch '.*/$') { $GraphBaseURL = $GraphBaseURL + "/" } # Build the Graph request URL $DelegatedAdminAccessAssignmentURL = "$($GraphBaseURL)tenantRelationships/delegatedAdminRelationships/$($GDAPRelationshipID)/accessAssignments" # Perform group lookup if not provided in Guid format Write-LogMessage -Severity Verbose -Message "Checking if the Group was provided in GUID ObjectId format" if (Test-Guid $Group) { Write-LogMessage -Severity Verbose -Message "GUID detected, leaving group as is" } else { Write-LogMessage -Severity Verbose -Message "Performing group lookup for $Group" $Group = (Get-EntraGroupbyNameorId -Group $Group -GraphBaseURL $GraphBaseURL).id } # Perform role definition lookups Write-LogMessage -Severity Verbose -Message "Looking up the provided RoleDefinition(s)" $RoleDefinitionId = Get-GDAPAccessRolebyNameorId -RoleDefinition $RoleDefinition -ReturnID # Build the RoleAccessContainer object with updated/provided values Write-LogMessage -Severity Verbose -Message "Building the RoleAccessContainer object" Write-LogMessage -Severity Debug -Message "RoleAccesContainer input parameters:`n GroupId: $($Group)`n roleDefinitionId: $($RoleDefinitionId -join ", ")" $RoleAccessContainer = @{ accessContainer = @{ accessContainerId = $Group accessContainerType = "securityGroup" } accessDetails = @{ unifiedRoles = @( $RoleDefinitionId | ForEach-Object { @{ roleDefinitionId = $_ } } ) } } Write-LogMessage -Severity Debug -Message "Result: `n$($RoleAccessContainer | ConvertTo-Json -Depth 10)" # Compare the provided Role IDs with the Role IDs found in the GDAP relationship to verify they can all be used, remove those that can't from the RoleAccessContainer. Write-LogMessage -Severity Verbose -Message "Comparing specified roles with allowed role IDs in the GDAP relationship." Write-LogMessage -Severity Debug -Message "Comparing roleDefinitionId(s) from:`n GDAPRelationshipStatus: $($GDAPRelationshipStatus.accessDetails.unifiedRoles.roleDefinitionId -join ", ")`n RoleAccessContainer: $($RoleAccessContainer.accessDetails.unifiedRoles.roleDefinitionId -join ", ")" $CompareRoles = (Compare-Object -ReferenceObject $GDAPRelationshipStatus.accessDetails.unifiedRoles.roleDefinitionId -DifferenceObject $RoleAccessContainer.accessDetails.unifiedRoles.roleDefinitionId -IncludeEqual | Where-Object -FilterScript { $_.SideIndicator -eq '=>' }).InputObject if ($CompareRoles.Count -ge 1) { Write-LogMessage -Severity Warning -Message "The following Role IDs were not found in the active GDAP relationship, they will be skipped:`n$($CompareRoles)" $RoleAccessContainer.accessDetails.unifiedRoles = $RoleAccessContainer.accessDetails.unifiedRoles | Where-Object { $_.roleDefinitionId -notin $CompareRoles } } # Submit the Graph API request and receieve the generated accessAssignment object Write-LogMessage -Severity Verbose -Message "Setting GDAP access assignment from Graph API: 'Invoke-MgGraphRequest -Method POST -Uri $DelegatedAdminAccessAssignmentURL -Body (`$RoleAccessContainer | ConvertTo-Json -Depth 5) -StatusCodeVariable IMGRStatusCode -OutputType PSObject'" Write-LogMessage -Severity Debug -Message "Graph Request Body value for access assigments:`n$($RoleAccessContainer | ConvertTo-Json -Depth 10))" if ($PSCmdlet.ShouldProcess(("Request body: `n$($RoleAccessContainer | ConvertTo-Json -Depth 10)"), ("Invoke-MgGraphRequest -Method POST -Uri $DelegatedAdminAccessAssignmentURL -StatusCodeVariable IMGRStatusCode -OutputType PSObject"))) { $CreateDelegatedAdminRelationshipAccessAssignment = Invoke-MgGraphRequest -Method POST -Uri $DelegatedAdminAccessAssignmentURL -Body ($RoleAccessContainer | ConvertTo-Json -Depth 5) -StatusCodeVariable IMGRStatusCode -OutputType PSObject Write-LogMessage -Severity Debug -Message "Result:`n $($CreateDelegatedAdminRelationshipAccessAssignment | ConvertTo-Json -Depth 10)" if (-not ([string]::IsNullOrEmpty($CreateDelegatedAdminRelationshipAccessAssignment.id)) -and $IMGRStatusCode -eq '201') { $FormattedAccessAssignment = Format-AccessAssignment -AccessAssignment $CreateDelegatedAdminRelationshipAccessAssignment -GDAPRelationshipID $GDAPRelationshipID -Detailed:$Detailed -GraphBaseURL $GraphBaseURL $PSCmdlet.WriteObject($FormattedAccessAssignment, $true) } else { Write-LogMessage -Severity Warning -Message "Graph API status code: $($IMGRStatusCode)" throw "No valid admin access assignment created for group ID $($RoleAccessContainer.accessContainer.accessContainerId)." } } } catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] { Write-LogMessage -Severity Error -Message "HTTP error when creating GDAP access assignments, status code $($_.Exception.Response.StatusCode) ($($_.Exception.Response.StatusCode.value__))`n$($_.Exception.Response.StatusDescription)" -LastException $_ } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { Write-LogMessage -Severity Verbose -Message "Ending New-GDAPRelationshipAccessAssignment" } } #New-GDAPRelationshipAccessAssignment <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Remove-GDAPRelationship { [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = "ByObject")] [OutputType([bool])] param ( [Parameter(Mandatory, ParameterSetName = "ByObject", ValueFromPipeline, HelpMessage = "Object containing details from type delegatedAdminRelationship")] [ValidateScript({ if ([string]::IsNullOrEmpty($_.id)) { throw "Passed GDAPRelationshipObject is not a valid delegatedAdminRelationship object, property `"id`" is missing or empty" } elseif (-not ($_.id -match ` '^((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))-((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))$')) { throw "Passed GDAPRelationshipObject is not a valid delegatedAdminRelationship object, property `"id`" with value `"$($_.id)`" is not in a valid format" } elseif ([string]::IsNullOrEmpty($_."@odata.etag")) { throw "Passed GDAPRelationshipObject is not a valid delegatedAdminRelationship object, property `"@odata.etag`" is missing or empty" } elseif ([string]::IsNullOrEmpty($_.status) -or $_.status -notin "active", "expiring", "created", "pending") { throw "Passed GDAPRelationshipObject is not a valid delegatedAdminRelationship object, property `"status`" is missing, empty, or not in a valid state$(if (-not ([string]::IsNullOrEmpty($_.status))) { " ($($_.status))" })" } else { $true } })] [object]$GDAPRelationshipObject, [Parameter(Mandatory, ParameterSetName = "ByID", HelpMessage = "The GDAP relationship ID provided during the GDAP relationship request creation process.")] [ValidateNotNullOrEmpty()] [ValidateScript( { $_ -match ` '^((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))-((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))$' } )] [string]$GDAPRelationshipID, [Parameter(Mandatory = $false, HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Remove-GDAPRelationship" if (-not (Get-MgContext)) { Write-LogMessage -Severity Warning -Message "No Graph API login found, use Connect-MgGraph to login to the Graph API" ; break } } process { $IMGRStatusCode = $null try { # Verify that the base URL ends in trailing slash if ($GraphBaseURL -notmatch '.*/$') { $GraphBaseURL = $GraphBaseURL + "/" } # Retrieve the delegatedAdminRelationship object if only the GDAPRelationshipID is provided switch ($PsCmdlet.ParameterSetName) { "ById" { $GDAPRelationshipObject = Get-GDAPRelationship -GDAPRelationshipID $GDAPRelationshipID -GraphBaseURL $GraphBaseURL } } Write-LogMessage -Severity Verbose -Message "Attempting to remove existing GDAP relationship $($GDAPRelationshipObject.displayName) ($($GDAPRelationshipObject.id))" # Build the Graph API URL $GDAPRelationshipTerminationURL = "$($GraphBaseURL)tenantRelationships/delegatedAdminRelationships/$($GDAPRelationshipObject.id)" # Validate that the GDAP Relationship is in a valid state if ($GDAPRelationshipObject.status -notin "active", "expiring", "created", "pending") { Write-LogMessage -Severity Error -Message "Existing GDAP Relationship not in a terminatable state, current state $($GDAPRelationshipObject.status), skipping" -TargetObject $GDAPRelationshipObject -StopOnError } else { Write-LogMessage -Severity Verbose -Message "Terminating GDAP Relationship from Graph API: 'Invoke-MgGraphRequest -Method DELETE -Uri $GDAPRelationshipTerminationURL -Headers @{ `"If-Match`" = ($($GDAPRelationshipObject."@odata.etag")) } -StatusCodeVariable IMGRStatusCode -OutputType PSObject'" if ($PSCmdlet.ShouldProcess($GDAPRelationshipTerminationURL, ("Invoke-MgGraphRequest -Method DELETE -Uri $GDAPRelationshipTerminationURL -Headers @{ `"If-Match`" = ($($GDAPRelationshipObject."@odata.etag")) } -StatusCodeVariable IMGRStatusCode -OutputType PSObject"))) { # Call the Graph API termination command $GDAPRelationshipTermination = Invoke-MgGraphRequest -Method DELETE -Uri $GDAPRelationshipTerminationURL -Headers @{ "If-Match" = ($GDAPRelationshipObject."@odata.etag") } -StatusCodeVariable IMGRStatusCode -OutputType PSObject Write-LogMessage -Severity Debug -Message "Result:`n $($GDAPRelationshipTermination | ConvertTo-Json -Depth 10)" # Verify the Graph API command returned the valid Status Code, return the success or failure termination bool if ($IMGRStatusCode -eq '204') { Write-LogMessage -Severity Verbose -Message "Successfully terminated existing GDAP relationship $($GDAPRelationshipObject.id)" $true } else { Write-LogMessage -Severity Warning -Message "Failed to terminate existing GDAP relationship $($GDAPRelationshipObject.id)" Write-LogMessage -Severity Warning -Message "Graph API status code: $($IMGRStatusCode)" $false } } } } catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] { Write-LogMessage -Severity Error -Message "HTTP error when trying to terminate GDAP relationship, status code $($_.Exception.Response.StatusCode) ($($_.Exception.Response.StatusCode.value__))`n$($_.Exception.Response.StatusDescription)" -LastException $_ -StopOnError } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ -StopOnError } } end { Write-LogMessage -Severity Verbose -Message "Ending Remove-GDAPRelationship" } } #Remove-GDAPRelationship <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Remove-GDAPRelationshipAccessAssignment { [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = "ByObject")] [OutputType([bool])] param ( [Parameter(Mandatory, ParameterSetName = "ByID", Position = 0, HelpMessage = "The GDAP Relationship ID for the relationship containing the delegatedAdminAccessAssignment to be removed")] [ValidateNotNullOrEmpty()] [ValidateScript( { $_ -match ` '^((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))-((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))$' } )] [string]$GDAPRelationshipID, [Parameter(Mandatory, ParameterSetName = "ByObject", Position = 0, HelpMessage = "Object containing details from type delegatedAdminAccessAssignment")] [ValidateScript({ if ([string]::IsNullOrEmpty($_.delegatedAdminRelationshipId)) { throw "Passed AccessAssignmentObject is not a valid delegatedAdminAccessAssignment object, property `"delegatedAdminRelationshipId`" is missing or empty" } elseif ($_.delegatedAdminRelationshipId -notmatch ` '^((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))-((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))$') { throw "Passed AccessAssignmentObject is not a valid delegatedAdminAccessAssignment object, property `"delegatedAdminRelationshipId`" is not in a valid format" } elseif ([string]::IsNullOrEmpty($_.id)) { throw "Passed AccessAssignmentObject is not a valid delegatedAdminAccessAssignment object, property `"id`" is missing or empty" } elseif (-not (Test-Guid -InputObject $_.id)) { throw "Passed AccessAssignmentObject is not a valid delegatedAdminAccessAssignment object, property `"id`" with value `"$($_.id)`" is not in guid format" } elseif ([string]::IsNullOrEmpty($_."@odata.etag")) { throw "Passed AccessAssignmentObject is not a valid delegatedAdminAccessAssignment object, property `"@odata.etag`" is missing or empty" } elseif ([string]::IsNullOrEmpty($_.status) -or $_.status -notin "active", "expiring") { throw "Passed AccessAssignmentObject is not a valid delegatedAdminAccessAssignment object, property `"status`" is missing, empty, or not in a valid state$(if (-not ([string]::IsNullOrEmpty($_.status))) { " ($($_.status))" })" } else { $true } })] [object]$AccessAssignmentObject, [Parameter(Mandatory, ParameterSetName = "ByID", Position = 1, HelpMessage = "The GUID formatted id of the delegatedAdminAccessAssignment to be removed.")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Guid -InputObject $_ })] [string]$AccessAssignmentId, [Parameter(Mandatory = $false, Position = 2, HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Remove-GDAPRelationshipAccessAssignment" if (-not (Get-MgContext)) { Write-LogMessage -Severity Warning -Message "No Graph API login found, use Connect-MgGraph to login to the Graph API" ; break } $GDAPRelationshipObject = Get-GDAPRelationship -GDAPRelationshipID $GDAPRelationshipID -GraphBaseURL $GraphBaseURL if (-not ($GDAPRelationshipObject) -or $GDAPRelationshipObject.status -notin "active", "expiring") { Write-LogMessage -Severity Error -Message "The provided GDAP Relationship ID, $GDAPRelationshipID, did not return a valid GDAP Relationship or the GDAP Relationship is not in a valid state for this operation" -LastException $_ ; break } } process { $IMGRStatusCode = $null try { # Verify that the base URL ends in trailing slash if ($GraphBaseURL -notmatch '.*/$') { $GraphBaseURL = $GraphBaseURL + "/" } # Retrieve the delegatedAdminRelationship object if only the GDAPRelationshipID is provided switch ($PsCmdlet.ParameterSetName) { "ById" { $AccessAssignmentObject = Get-GDAPRelationshipAccessAssignment -GDAPRelationshipID $GDAPRelationshipID -AccessAssignmentId $AccessAssignmentId -GraphBaseURL $GraphBaseURL | Select-Object -First 1 } } Write-LogMessage -Severity Verbose -Message "Attempting to remove existing GDAP admin access assignment from GDAP Relationship $($GDAPRelationshipObject.displayName) with id of ($($AccessAssignmentObject.id))" # Build the Graph API URL $GDAPAccessAssignmentTerminationURL = "$($GraphBaseURL)tenantRelationships/delegatedAdminRelationships/$($GDAPRelationshipObject.id)/accessAssignments/$($AccessAssignmentObject.id)" # Validate that the GDAP Relationship is in a valid state if ($AccessAssignmentObject.status -notin "active", "expiring") { throw "Existing GDAP admin access assignment not found or not in a terminatable state, current state $($AccessAssignmentObject.status), skipping" } else { Write-LogMessage -Severity Verbose -Message "Terminating GDAP admin access assignment from Graph API: 'Invoke-MgGraphRequest -Method DELETE -Uri $GDAPAccessAssignmentTerminationURL -Headers @{ `"If-Match`" = ($($AccessAssignmentObject."@odata.etag")) } -StatusCodeVariable IMGRStatusCode -OutputType PSObject'" if ($PSCmdlet.ShouldProcess($GDAPAccessAssignmentTerminationURL, ("Invoke-MgGraphRequest -Method DELETE -Uri $GDAPAccessAssignmentTerminationURL -Headers @{ `"If-Match`" = ($($AccessAssignmentObject."@odata.etag")) } -StatusCodeVariable IMGRStatusCode -OutputType PSObject"))) { # Call the Graph API termination command $GDAPAccessAssignmentTermination = Invoke-MgGraphRequest -Method DELETE -Uri $GDAPAccessAssignmentTerminationURL -Headers @{ "If-Match" = ($AccessAssignmentObject."@odata.etag") } -StatusCodeVariable IMGRStatusCode -OutputType PSObject Write-LogMessage -Severity Debug -Message "Result:`n $($GDAPAccessAssignmentTermination | ConvertTo-Json -Depth 10)" # Verify the Graph API command returned the valid Status Code, return the success or failure termination bool if ($IMGRStatusCode -eq '204') { Write-LogMessage -Severity Verbose -Message "Successfully terminated existing GDAP admin access assignment $($AccessAssignmentObject.id) from GDAP Relationship $($GDAPRelationshipObject.displayName)" $true } else { Write-LogMessage -Severity Warning -Message "Failed to terminate existing GDAP relationship $($AccessAssignmentObject.id) from GDAP Relationship $($GDAPRelationshipObject.displayName)" Write-LogMessage -Severity Warning -Message "Graph API status code: $($IMGRStatusCode)" $false } } } } catch [Microsoft.Graph.PowerShell.Authentication.Helpers.HttpResponseException] { Write-LogMessage -Severity Error -Message "HTTP error when trying to terminate GDAP relationship, status code $($_.Exception.Response.StatusCode) ($($_.Exception.Response.StatusCode.value__))`n$($_.Exception.Response.StatusDescription)" -LastException $_ } catch { Write-LogMessage -Severity Error -Message $_.Exception.Message -LastException $_ } } end { Write-LogMessage -Severity Verbose -Message "Ending Remove-GDAPRelationshipAccessAssignment" } } #Remove-GDAPRelationshipAccessAssignment <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Start-GDAPRemediation { [CmdletBinding(SupportsShouldProcess = $true)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'GraphBaseURL', Justification = 'Testing')] param( [Parameter(Mandatory, ValueFromPipeline = $true, Position = 0, HelpMessage = "Local file path(s) or URL string(s) with the JSON template file(s) containing the GDAP Remediation template")] [ValidateNotNullOrEmpty()] [System.Collections.Generic.List[string]]$RemediationTemplateFile, [Parameter(Mandatory = $false, HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Start-GDAPRemediation" [System.Collections.Generic.List[object]]$RemediationObject = $RemediationTemplateFile | ForEach-Object { if (Test-Path $_ -IsValid) { Write-LogMessage -Severity Verbose -Message "Local path of $_ is valid, importing template file." Get-Content -Path (Resolve-Path -Path $_) | ConvertFrom-Json -Depth 64 } elseif (Test-Url -Url $_) { Write-LogMessage -Severity Verbose -Message "URL of $_ is valid, importing template file." Get-JSONFromURL -JSONFileUrl $_ } else { Write-LogMessage -Severity Error -Message "$($_) is not a valid local file path or URL" -LastException $_ ; break } } if ($RemediationObject.Count -eq 0) { Write-LogMessage -Severity Error -Message "No Remediation Templates imported, unable to continue." -LastException $_ ; break } } process { # Verify that the base URL ends in trailing slash if ($GraphBaseURL -notmatch '.*/$') { $GraphBaseURL = $GraphBaseURL + "/" } if ($PSCmdlet.ShouldProcess(("Doing a thing"), ("Thing I might be doing"))) { "Do the thing" } } end { Write-LogMessage -Severity Verbose -Message "Ending Start-GDAPRemediation" } } #Start-GDAPRemediation <# .EXTERNALHELP GDAPRelationships-help.xml #> Function Test-GDAPRelationshipStatus { [CmdletBinding(DefaultParameterSetName = "Test")] [OutputType([bool], ParameterSetName = "Test")] [OutputType([object], ParameterSetName = "Differences")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'RelationshipMatches', Justification = 'False positive as rule does not scan child scopes')] param ( [Parameter(Mandatory, Position = 0, ParameterSetName = "Test", HelpMessage = "The GDAP relationship ID to use for accessAssignments lookup")] [Parameter(Mandatory, Position = 0, ParameterSetName = "Differences", HelpMessage = "The GDAP relationship ID to use for accessAssignments lookup")] [ValidateNotNullOrEmpty()] [ValidateScript( { $_ -match ` '^((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))-((?:\{{0,1}(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}\}{0,1}))$' } )] [string]$GDAPRelationshipID, [Parameter(Mandatory, Position = 1, ParameterSetName = "Test", HelpMessage = "List containing the delegatedAdminAccessAssignment objects to validate the relationship against")] [Parameter(Mandatory, Position = 1, ParameterSetName = "Differences", HelpMessage = "List containing the delegatedAdminAccessAssignment objects to validate the relationship against")] [System.Collections.Generic.List[object]]$DelegatedAdminAccessAssignment, [Parameter(Mandatory = $false, Position = 2, ParameterSetName = "Test", HelpMessage = "List of Entra ID role Guids or role Names to compare to the list of roles assigned to the adminRelationship")] [Parameter(Mandatory = $false, Position = 2, ParameterSetName = "Differences", HelpMessage = "List of Entra ID role Guids or role Names to compare to the list of roles assigned to the adminRelationship")] [ValidateNotNullOrEmpty()] [System.Collections.Generic.List[string]]$RoleDefinition, [Parameter(Mandatory = $false, Position = 3, ParameterSetName = "Differences", HelpMessage = "Enable the return of the differences between the existing and provided relationships")] [switch]$Differences, [Parameter(Mandatory = $false, Position = 3, ParameterSetName = "Test", HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [Parameter(Mandatory = $false, Position = 4, ParameterSetName = "Differences", HelpMessage = "The base Microsoft Graph API URL to use with trailing slash, e.g. https://graph.microsoft.com/v1.0/")] [ValidateNotNullOrEmpty()] [ValidateScript( { Test-Url -Url $_ })] [string]$GraphBaseURL = "https://graph.microsoft.com/v1.0/" ) begin { Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Write-LogMessage -Severity Verbose -Message "Starting Test-GDAPRelationshipStatus" $RelationshipMatches = $true # Differences object template $DifferencesObject = @{ GDAPRelationshipID = $GDAPRelationshipID Matches = $RelationshipMatches MissingRelationshipRoles = [System.Collections.Generic.List[string]]::new() ExtraRelationshipRoles = [System.Collections.Generic.List[string]]::new() MissingAccessAssignments = [System.Collections.Generic.List[object]]::new() ExtraAccessAssignments = [System.Collections.Generic.List[object]]::new() IncorrectAccessAssignments = [System.Collections.Generic.List[object]]::new() InvalidSourceAccessAssignments = [System.Collections.Generic.List[object]]::new() MatchingAccessAssignments = [System.Collections.Generic.List[object]]::new() } } process { Write-LogMessage -Severity Verbose -Message "Retrieving the existing GDAP Relationship" $ExistingGDAPRelationship = Get-GDAPRelationship -GDAPRelationshipID $GDAPRelationshipID -GraphBaseURL $GraphBaseURL if ($ExistingGDAPRelationship.status -notin 'active', 'approved', 'activating', 'expiring') { Write-LogMessage -Severity Warning -Message "The GDAP relationship request with ID $($GDAPRelationshipID)$(if ($ExistingGDAPRelationship.customer.displayName) { " for $($ExistingGDAPRelationship.customer.displayName)" }) is not in an active state." ; break } else { Write-LogMessage -Severity Verbose -Message "Retrieving the existing accessAssignments" $ExistingGDAPAccessAssignment = Get-GDAPRelationshipAccessAssignment -GDAPRelationshipID $GDAPRelationshipID -Active -GraphBaseURL $GraphBaseURL } Write-LogMessage -Severity Verbose -Message "Validating the list of role IDs currently in the adminRelationship" Write-LogMessage -Severity Verbose -Message "Generating the list of required role IDs from the provided accessAssignments" [System.Collections.Generic.List[string]]$RequiredRoleID = $DelegatedAdminAccessAssignment | ForEach-Object { $_.accessDetails.unifiedRoles.roleDefinitionId } | Select-Object -Unique if ($RoleDefinition) { Write-LogMessage -Severity Verbose -Message "Expected Role Definition list included, adding to required roles list" $RoleDefinitionId = (Get-GDAPAccessRolebyNameorId -RoleDefinition $RoleDefinition).RoleDefinitionId $RequiredRoleID = $RoleDefinitionId + $RequiredRoleIDs | Select-Object -Unique } Write-LogMessage -Severity Verbose -Message "Comparing roleDefinitionId(s) from:`n Discovered roles: $($ExistingGDAPRelationship.accessDetails.unifiedRoles.roleDefinitionId -join ", ")`n Provided roles: $($RequiredRoleID -join ", ")" $CompareRoles = Compare-Object -ReferenceObject $ExistingGDAPRelationship.accessDetails.unifiedRoles.roleDefinitionId -DifferenceObject $RequiredRoleID $CompareRoleswithExisting = ( $CompareRoles | Where-Object -FilterScript { $_.SideIndicator -eq '=>' }).InputObject if ($CompareRoleswithExisting.Count -ge 1) { Write-LogMessage -Severity Verbose -Message "The following required Role IDs were not found in the active GDAP relationship:`n$($CompareRoleswithExisting -join ',')" if ($Differences) { $DifferencesObject.MissingRelationshipRoles.Add($CompareRoleswithExisting) } $RelationshipMatches = $false } $CompareRoleswithProvided = ( $CompareRoles | Where-Object -FilterScript { $_.SideIndicator -eq '<=' }).InputObject if ($CompareRoleswithProvided.Count -ge 1) { Write-LogMessage -Severity Verbose -Message "The following Role IDs were not found in the required Role IDs but exist in the active GDAP relationship:`n$($CompareRoleswithProvided -join ',')" if ($Differences) { $DifferencesObject.ExtraRelationshipRoles.Add($CompareRoleswithProvided) } $RelationshipMatches = $false } if (((-not $Differences) -and $RelationshipMatches) -or ($Differences)) { Write-LogMessage -Severity Verbose -Message "Comparing the provided accessAssignment objects against the existing accessAssignment objects" foreach ($AccessAssignment in $DelegatedAdminAccessAssignment) { switch (Compare-GDAPAccessAssignment -GDAPRelationshipID $GDAPRelationshipID -DelegatedAdminAccessAssignment $AccessAssignment -Reason:$Differences -GraphBaseURL $GraphBaseURL) { "Invalid delegatedAdminAccessAssignment" { $DifferencesObject.InvalidSourceAccessAssignments.Add($AccessAssignment) } "Invalid Group" { $DifferencesObject.InvalidSourceAccessAssignments.Add($AccessAssignment) } "Missing Roles" { $DifferencesObject.IncorrectAccessAssignments.Add($AccessAssignment) $RelationshipMatches = $false } "Extra Roles" { $DifferencesObject.IncorrectAccessAssignments.Add($AccessAssignment) $RelationshipMatches = $false } "Missing Assignment" { $DifferencesObject.MissingAccessAssignments.Add($AccessAssignment) $RelationshipMatches = $false } $false { $RelationshipMatches = $false } $true { $DifferencesObject.MatchingAccessAssignments.Add($AccessAssignment) } } } Write-LogMessage -Severity Verbose -Message "Looking for existing accessAssignment objects that are not in the provided list of accessAssignment objects" $ExistingGDAPAccessAssignment | Where-Object { $_.accessContainer.accessContainerId -notin $DifferencesObject.IncorrectAccessAssignments.accessContainer.accessContainerId -and $_.accessContainer.accessContainerId -notin $DifferencesObject.MissingAccessAssignments.accessContainer.accessContainerId -and $_.accessContainer.accessContainerId -notin $DifferencesObject.MatchingAccessAssignments.accessContainer.accessContainerId } | ForEach-Object { if ($Differences) { $DifferencesObject.ExtraAccessAssignments.Add($_) } $RelationshipMatches = $false } } } end { if ($Differences) { $DifferencesObject.Matches = $RelationshipMatches $PSCmdlet.WriteObject($DifferencesObject, $true) } else { $RelationshipMatches } Write-LogMessage -Severity Verbose -Message "Ending Test-GDAPRelationshipStatus" } } #Test-GDAPRelationshipStatus |