DSCResources/MSFT_AADPermissionGrantPolicy/MSFT_AADPermissionGrantPolicy.psm1
|
Confirm-M365DSCModuleDependency -ModuleName 'MSFT_AADPermissionGrantPolicy' # Cache for service principal lookups to avoid redundant Graph API calls $Script:ServicePrincipalCache = [System.Collections.Generic.Dictionary[System.String, System.Object]]::new() function Get-TargetResource { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = $true)] [System.String] $Id, [Parameter()] [System.String] $DisplayName, [Parameter()] [System.String] $Description, [Parameter()] [Microsoft.Management.Infrastructure.CimInstance[]] $Includes, [Parameter()] [Microsoft.Management.Infrastructure.CimInstance[]] $Excludes, [Parameter()] [ValidateSet('Present', 'Absent')] [System.String] $Ensure = 'Present', [Parameter()] [System.Management.Automation.PSCredential] $Credential, [Parameter()] [System.String] $ApplicationId, [Parameter()] [System.String] $TenantId, [Parameter()] [System.Management.Automation.PSCredential] $ApplicationSecret, [Parameter()] [System.String] $CertificateThumbprint, [Parameter()] [Switch] $ManagedIdentity, [Parameter()] [System.String[]] $AccessTokens ) Write-Verbose -Message "Getting configuration of Entra Permission Grant Policy {$Id}" try { $null = New-M365DSCConnection -Workload 'MicrosoftGraph' ` -InboundParameters $PSBoundParameters #Ensure the proper dependencies are installed in the current environment. Confirm-M365DSCDependencies #region Telemetry $ResourceName = $MyInvocation.MyCommand.ModuleName -replace 'MSFT_', '' $CommandName = $MyInvocation.MyCommand $data = Format-M365DSCTelemetryParameters -ResourceName $ResourceName ` -CommandName $CommandName ` -Parameters $PSBoundParameters Add-M365DSCTelemetryEvent -Data $data #endregion $nullResult = $PSBoundParameters $nullResult.Ensure = 'Absent' if (-not $Script:exportedInstance -or $Script:exportedInstance.Id -ne $Id) { if (-not [System.String]::IsNullOrEmpty($Id)) { $getValue = Get-MgBetaPolicyPermissionGrantPolicy -PermissionGrantPolicyId $Id ` -ErrorAction SilentlyContinue } else { $getValue = Get-MgBetaPolicyPermissionGrantPolicy -PermissionGrantPolicyId $Id ` -ErrorAction SilentlyContinue } } else { $getValue = $Script:exportedInstance } if ($null -eq $getValue) { Write-Verbose -Message "No Entra Permission Grant Policy with Id {$Id} was found" return $nullResult } Write-Verbose -Message "Found Entra Permission Grant Policy with Id {$Id}" # Convert Includes collection to hashtable array $includesArray = @() if ($null -ne $getValue.Includes) { foreach ($include in $getValue.Includes) { $includesArray += Get-PermissionGrantConditionSetAsHashtable -ConditionSet $include } } # Convert Excludes collection to hashtable array $excludesArray = @() if ($null -ne $getValue.Excludes) { foreach ($exclude in $getValue.Excludes) { $excludesArray += Get-PermissionGrantConditionSetAsHashtable -ConditionSet $exclude } } $result = @{ Id = $getValue.Id DisplayName = $getValue.DisplayName Description = $getValue.Description Includes = [Array]$includesArray Excludes = [Array]$excludesArray Ensure = 'Present' Credential = $Credential ApplicationId = $ApplicationId TenantId = $TenantId ApplicationSecret = $ApplicationSecret CertificateThumbprint = $CertificateThumbprint ManagedIdentity = $ManagedIdentity.IsPresent AccessTokens = $AccessTokens } return $result } catch { New-M365DSCLogEntry -Message 'Error retrieving data:' ` -Exception $_ ` -Source $($MyInvocation.MyCommand.Source) ` -TenantId $TenantId ` -Credential $Credential throw } } function Set-TargetResource { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.String] $Id, [Parameter()] [System.String] $DisplayName, [Parameter()] [System.String] $Description, [Parameter()] [Microsoft.Management.Infrastructure.CimInstance[]] $Includes, [Parameter()] [Microsoft.Management.Infrastructure.CimInstance[]] $Excludes, [Parameter()] [ValidateSet('Present', 'Absent')] [System.String] $Ensure = 'Present', [Parameter()] [System.Management.Automation.PSCredential] $Credential, [Parameter()] [System.String] $ApplicationId, [Parameter()] [System.String] $TenantId, [Parameter()] [System.Management.Automation.PSCredential] $ApplicationSecret, [Parameter()] [System.String] $CertificateThumbprint, [Parameter()] [Switch] $ManagedIdentity, [Parameter()] [System.String[]] $AccessTokens ) Write-Verbose -Message "Setting configuration of Entra Permission Grant Policy {$Id}" #Ensure the proper dependencies are installed in the current environment. Confirm-M365DSCDependencies #region Telemetry $ResourceName = $MyInvocation.MyCommand.ModuleName -replace 'MSFT_', '' $CommandName = $MyInvocation.MyCommand $data = Format-M365DSCTelemetryParameters -ResourceName $ResourceName ` -CommandName $CommandName ` -Parameters $PSBoundParameters Add-M365DSCTelemetryEvent -Data $data #endregion try { # Clear the service principal cache for fresh lookups $Script:ServicePrincipalCache = [System.Collections.Generic.Dictionary[System.String, System.Object]]::new() $null = New-M365DSCConnection -Workload 'MicrosoftGraph' ` -InboundParameters $PSBoundParameters $currentPolicy = Get-TargetResource @PSBoundParameters # CREATE if ($Ensure -eq 'Present' -and $currentPolicy.Ensure -eq 'Absent') { Write-Verbose -Message "Creating new Entra Permission Grant Policy with Id {$Id} and DisplayName {$DisplayName}" $createParameters = @{ Id = $Id DisplayName = $DisplayName Description = $Description } New-MgBetaPolicyPermissionGrantPolicy @createParameters | Out-Null # Add Includes if ($null -ne $Includes -and $Includes.Count -gt 0) { foreach ($include in $Includes) { Write-Verbose -Message "Adding include condition set {$($include.Id)}" $includeParams = Get-PermissionGrantConditionSetAsParameters -ConditionSet $include New-MgBetaPolicyPermissionGrantPolicyInclude -PermissionGrantPolicyId $Id @includeParams | Out-Null } } # Add Excludes if ($null -ne $Excludes -and $Excludes.Count -gt 0) { foreach ($exclude in $Excludes) { Write-Verbose -Message "Adding exclude condition set {$($exclude.Id)}" $excludeParams = Get-PermissionGrantConditionSetAsParameters -ConditionSet $exclude New-MgBetaPolicyPermissionGrantPolicyExclude -PermissionGrantPolicyId $Id @excludeParams | Out-Null } } } # UPDATE elseif ($Ensure -eq 'Present' -and $currentPolicy.Ensure -eq 'Present') { Write-Verbose -Message "Updating Entra Permission Grant Policy with Id {$Id} and DisplayName {$DisplayName}" $updateParameters = @{ PermissionGrantPolicyId = $Id } if ($PSBoundParameters.ContainsKey('DisplayName') -and $DisplayName -ne $currentPolicy.DisplayName) { $updateParameters.Add('DisplayName', $DisplayName) } if ($PSBoundParameters.ContainsKey('Description') -and $Description -ne $currentPolicy.Description) { $updateParameters.Add('Description', $Description) } if ($updateParameters.Count -gt 1) { Update-MgBetaPolicyPermissionGrantPolicy @updateParameters | Out-Null } # Sync Includes - use content-based matching since desired state # condition sets may not have Id (auto-generated by Graph API) Write-Verbose -Message "Syncing Includes" if ($null -ne $Includes) { $matchedCurrentIncludeIds = @() # Find matches for each desired include by content foreach ($desiredInclude in $Includes) { $matchFound = $false foreach ($currentInclude in $currentPolicy.Includes) { if ($currentInclude.Id -notin $matchedCurrentIncludeIds -and (Test-ConditionSetsEqual -ConditionSet1 $desiredInclude -ConditionSet2 $currentInclude)) { $matchedCurrentIncludeIds += $currentInclude.Id $matchFound = $true break } } if (-not $matchFound) { Write-Verbose -Message "Adding include condition set" $includeParams = Get-PermissionGrantConditionSetAsParameters -ConditionSet $desiredInclude New-MgBetaPolicyPermissionGrantPolicyInclude -PermissionGrantPolicyId $Id @includeParams | Out-Null } } # Remove current includes that were not matched to any desired include foreach ($currentInclude in $currentPolicy.Includes) { if ($currentInclude.Id -notin $matchedCurrentIncludeIds) { Write-Verbose -Message "Removing include condition set {$($currentInclude.Id)}" Remove-MgBetaPolicyPermissionGrantPolicyInclude ` -PermissionGrantPolicyId $Id ` -PermissionGrantConditionSetId $currentInclude.Id | Out-Null } } } # Sync Excludes - use content-based matching since desired state # condition sets may not have Id (auto-generated by Graph API) Write-Verbose -Message "Syncing Excludes" if ($null -ne $Excludes) { $matchedCurrentExcludeIds = @() # Find matches for each desired exclude by content foreach ($desiredExclude in $Excludes) { $matchFound = $false foreach ($currentExclude in $currentPolicy.Excludes) { if ($currentExclude.Id -notin $matchedCurrentExcludeIds -and (Test-ConditionSetsEqual -ConditionSet1 $desiredExclude -ConditionSet2 $currentExclude)) { $matchedCurrentExcludeIds += $currentExclude.Id $matchFound = $true break } } if (-not $matchFound) { Write-Verbose -Message "Adding exclude condition set" $excludeParams = Get-PermissionGrantConditionSetAsParameters -ConditionSet $desiredExclude New-MgBetaPolicyPermissionGrantPolicyExclude -PermissionGrantPolicyId $Id @excludeParams | Out-Null } } # Remove current excludes that were not matched to any desired exclude foreach ($currentExclude in $currentPolicy.Excludes) { if ($currentExclude.Id -notin $matchedCurrentExcludeIds) { Write-Verbose -Message "Removing exclude condition set {$($currentExclude.Id)}" Remove-MgBetaPolicyPermissionGrantPolicyExclude ` -PermissionGrantPolicyId $Id ` -PermissionGrantConditionSetId $currentExclude.Id | Out-Null } } } } # REMOVE elseif ($Ensure -eq 'Absent' -and $currentPolicy.Ensure -eq 'Present') { Write-Verbose -Message "Removing Entra Permission Grant Policy {$Id}" Remove-MgBetaPolicyPermissionGrantPolicy -PermissionGrantPolicyId $Id | Out-Null } } catch { New-M365DSCLogEntry -Message 'Error updating data:' ` -Exception $_ ` -Source $($MyInvocation.MyCommand.Source) ` -TenantId $TenantId ` -Credential $Credential throw } } function Test-TargetResource { [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter(Mandatory = $true)] [System.String] $Id, [Parameter()] [System.String] $DisplayName, [Parameter()] [System.String] $Description, [Parameter()] [Microsoft.Management.Infrastructure.CimInstance[]] $Includes, [Parameter()] [Microsoft.Management.Infrastructure.CimInstance[]] $Excludes, [Parameter()] [ValidateSet('Present', 'Absent')] [System.String] $Ensure = 'Present', [Parameter()] [System.Management.Automation.PSCredential] $Credential, [Parameter()] [System.String] $ApplicationId, [Parameter()] [System.String] $TenantId, [Parameter()] [System.Management.Automation.PSCredential] $ApplicationSecret, [Parameter()] [System.String] $CertificateThumbprint, [Parameter()] [Switch] $ManagedIdentity, [Parameter()] [System.String[]] $AccessTokens ) #Ensure the proper dependencies are installed in the current environment. Confirm-M365DSCDependencies #region Telemetry $ResourceName = $MyInvocation.MyCommand.ModuleName -replace 'MSFT_', '' $CommandName = $MyInvocation.MyCommand $data = Format-M365DSCTelemetryParameters -ResourceName $ResourceName ` -CommandName $CommandName ` -Parameters $PSBoundParameters Add-M365DSCTelemetryEvent -Data $data #endregion Write-Verbose -Message "Testing configuration of Entra Permission Grant Policy {$Id}" $compareParameters = Get-CompareParameters $result = Test-M365DSCTargetResource -DesiredValues $PSBoundParameters ` -ResourceName $($MyInvocation.MyCommand.Source).Replace('MSFT_', '') ` @compareParameters Write-Verbose -Message "Test-TargetResource returned $result" return $result } function Export-TargetResource { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter()] [System.Management.Automation.PSCredential] $Credential, [Parameter()] [System.String] $ApplicationId, [Parameter()] [System.String] $TenantId, [Parameter()] [System.Management.Automation.PSCredential] $ApplicationSecret, [Parameter()] [System.String] $CertificateThumbprint, [Parameter()] [Switch] $ManagedIdentity, [Parameter()] [System.String[]] $AccessTokens ) $ConnectionMode = New-M365DSCConnection -Workload 'MicrosoftGraph' ` -InboundParameters $PSBoundParameters #Ensure the proper dependencies are installed in the current environment. Confirm-M365DSCDependencies #region Telemetry $ResourceName = $MyInvocation.MyCommand.ModuleName -replace 'MSFT_', '' $CommandName = $MyInvocation.MyCommand $data = Format-M365DSCTelemetryParameters -ResourceName $ResourceName ` -CommandName $CommandName ` -Parameters $PSBoundParameters Add-M365DSCTelemetryEvent -Data $data #endregion try { $Script:ExportMode = $true [array] $Script:exportedInstances = Get-MgBetaPolicyPermissionGrantPolicy -All:$true ` -ErrorAction Stop $dscContent = '' $i = 1 if ($Script:exportedInstances.Length -eq 0) { Write-M365DSCHost -Message $Global:M365DSCEmojiGreenCheckMark -CommitWrite } else { Write-M365DSCHost -Message "`r`n" -DeferWrite } foreach ($policy in $Script:exportedInstances) { if ($null -ne $Global:M365DSCExportResourceInstancesCount) { $Global:M365DSCExportResourceInstancesCount++ } Write-M365DSCHost -Message " |---[$i/$($Script:exportedInstances.Count)] $($policy.Id)" -DeferWrite $Params = @{ Id = $policy.Id Credential = $Credential ApplicationId = $ApplicationId TenantId = $TenantId ApplicationSecret = $ApplicationSecret CertificateThumbprint = $CertificateThumbprint ManagedIdentity = $ManagedIdentity.IsPresent AccessTokens = $AccessTokens } $Script:exportedInstance = $policy $Results = Get-TargetResource @Params if ($null -ne $Results.Includes) { $complexTypeStringResult = Get-M365DSCDRGComplexTypeToString ` -ComplexObject $Results.Includes ` -CIMInstanceName 'MSFT_AADPermissionGrantConditionSet' if (-not [System.String]::IsNullOrWhiteSpace($complexTypeStringResult)) { $Results.Includes = $complexTypeStringResult } else { $Results.Remove('Includes') | Out-Null } } if ($null -ne $Results.Excludes) { $complexTypeStringResult = Get-M365DSCDRGComplexTypeToString ` -ComplexObject $Results.Excludes ` -CIMInstanceName 'MSFT_AADPermissionGrantConditionSet' if (-not [System.String]::IsNullOrWhiteSpace($complexTypeStringResult)) { $Results.Excludes = $complexTypeStringResult } else { $Results.Remove('Excludes') | Out-Null } } $currentDSCBlock = Get-M365DSCExportContentForResource -ResourceName $ResourceName ` -ConnectionMode $ConnectionMode ` -ModulePath $PSScriptRoot ` -Results $Results ` -Credential $Credential ` -NoEscape @('Includes', 'Excludes') $dscContent += $currentDSCBlock Save-M365DSCPartialExport -Content $currentDSCBlock ` -FileName $Global:PartialExportFileName Write-M365DSCHost -Message $Global:M365DSCEmojiGreenCheckMark -CommitWrite $i++ } return $dscContent } catch { Write-M365DSCHost -Message $Global:M365DSCEmojiRedX New-M365DSCLogEntry -Message 'Error during Export:' ` -Exception $_ ` -Source $($MyInvocation.MyCommand.Source) ` -TenantId $TenantId ` -Credential $Credential throw } } #region Helper Functions function Get-CompareParameters { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param() # Normalize condition sets in desired values so that permission names # compare correctly against the current values. return @{ PostProcessing = { param($DesiredValues, $CurrentValues, $ValuesToCheck, $ignore) foreach ($propertyName in @('Includes', 'Excludes')) { if ($null -ne $ValuesToCheck[$propertyName]) { $normalizedSets = @() foreach ($conditionSet in $ValuesToCheck[$propertyName]) { $normalizedSets += Get-PermissionGrantConditionSetAsHashtable -ConditionSet $conditionSet } $ValuesToCheck[$propertyName] = [Array]$normalizedSets $DesiredValues[$propertyName] = [Array]$normalizedSets } } return [System.Tuple[Hashtable, Hashtable, Hashtable]]::new($DesiredValues, $CurrentValues, $ValuesToCheck) } } } <# .SYNOPSIS Resolves a ResourceApplication AppId GUID to the service principal display name. .DESCRIPTION This helper function takes a ResourceApplication value (typically an AppId GUID returned by the Microsoft Graph API) and resolves it to the service principal's display name. The wildcard value 'any' and values that are already names (not GUIDs) are returned unchanged. Uses the module-scoped ServicePrincipalCache for performance. .PARAMETER ResourceApplication The ResourceApplication value to resolve. Can be an AppId GUID, a display name, or a wildcard. .OUTPUTS System.String Returns the service principal display name, or the original value if it cannot be resolved. .EXAMPLE $name = Resolve-ResourceApplicationName -ResourceApplication '00000003-0000-0000-c000-000000000000' # Returns 'Microsoft Graph' #> function Resolve-ResourceApplicationName { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.String] $ResourceApplication ) # Pass through wildcard if ($ResourceApplication -eq 'any') { return $ResourceApplication } # If not a GUID, assume it is already a display name $guidResult = [System.Guid]::Empty if (-not [System.Guid]::TryParse($ResourceApplication, [ref]$guidResult)) { return $ResourceApplication } try { $cacheKey = $guidResult.ToString() if ($Script:ServicePrincipalCache.ContainsKey($cacheKey)) { $servicePrincipal = $Script:ServicePrincipalCache[$cacheKey] } else { $servicePrincipal = Get-MgServicePrincipal -Filter "AppId eq '$cacheKey'" -ErrorAction SilentlyContinue $Script:ServicePrincipalCache[$cacheKey] = $servicePrincipal if ($null -ne $servicePrincipal -and -not [System.String]::IsNullOrEmpty($servicePrincipal.DisplayName)) { $Script:ServicePrincipalCache[$servicePrincipal.DisplayName] = $servicePrincipal } } if ($null -ne $servicePrincipal) { Write-Verbose -Message "Resolved ResourceApplication '$ResourceApplication' to name '$($servicePrincipal.DisplayName)'." return $servicePrincipal.DisplayName } } catch { Write-Verbose -Message "Error resolving ResourceApplication '$ResourceApplication': $_" } return $ResourceApplication } <# .SYNOPSIS Resolves a ResourceApplication display name to the service principal AppId GUID. .DESCRIPTION This helper function takes a ResourceApplication value (a service principal display name or AppId GUID) and resolves it to the AppId GUID. Values that are already GUIDs and the wildcard value 'any' are returned unchanged. The resolved service principal is added to the module-scoped ServicePrincipalCache for subsequent lookups. .PARAMETER ResourceApplication The ResourceApplication value to resolve. Can be a display name, an AppId GUID, or a wildcard. .OUTPUTS System.String Returns the AppId GUID, or the original value if it cannot be resolved. .EXAMPLE $appId = Resolve-ResourceApplicationId -ResourceApplication 'Microsoft Graph' # Returns '00000003-0000-0000-c000-000000000000' #> function Resolve-ResourceApplicationId { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.String] $ResourceApplication ) # Pass through wildcard if ($ResourceApplication -eq 'any') { return $ResourceApplication } # If already a GUID, return as-is $guidResult = [System.Guid]::Empty if ([System.Guid]::TryParse($ResourceApplication, [ref]$guidResult)) { return $ResourceApplication } try { # Check if the display name is already in the cache if ($Script:ServicePrincipalCache.ContainsKey($ResourceApplication)) { $servicePrincipal = $Script:ServicePrincipalCache[$ResourceApplication] } else { # Look up the service principal by display name $escapedName = $ResourceApplication -replace "'", "''" $servicePrincipal = Get-MgServicePrincipal -Filter "DisplayName eq '$escapedName'" -ErrorAction SilentlyContinue } if ($null -ne $servicePrincipal) { # Handle array result (multiple SPs with same name) if ($servicePrincipal -is [Array]) { Write-Verbose -Message "Multiple service principals found for DisplayName '$ResourceApplication'. Using the first match." $servicePrincipal = $servicePrincipal[0] } $Script:ServicePrincipalCache[$servicePrincipal.AppId] = $servicePrincipal if (-not [System.String]::IsNullOrEmpty($servicePrincipal.DisplayName)) { $Script:ServicePrincipalCache[$servicePrincipal.DisplayName] = $servicePrincipal } Write-Verbose -Message "Resolved ResourceApplication name '$ResourceApplication' to AppId '$($servicePrincipal.AppId)'." return $servicePrincipal.AppId } } catch { Write-Verbose -Message "Error resolving ResourceApplication name '$ResourceApplication': $_" } Write-Verbose -Message "Could not resolve ResourceApplication name '$ResourceApplication' to an AppId." return $ResourceApplication } <# .SYNOPSIS Converts a permission display name to its GUID using the resource application's service principal. .DESCRIPTION This helper function resolves permission names (e.g., 'User.Read') to their corresponding GUIDs by looking up the resource application's service principal and checking both Oauth2PermissionScopes (delegated) and AppRoles (application) collections. Wildcard values ('all', 'any') and existing GUIDs are passed through unchanged. .PARAMETER PermissionName The permission name or GUID to resolve. .PARAMETER ResourceApplicationId The appId of the resource application whose service principal contains the permission definitions. .PARAMETER PermissionType The type of permission: 'delegated' or 'application'. Used to determine whether to search Oauth2PermissionScopes or AppRoles. .OUTPUTS System.String Returns the permission GUID, or the original value if it is already a GUID or a wildcard. .EXAMPLE $guid = ConvertTo-PermissionGuid -PermissionName 'User.Read' -ResourceApplicationId '00000003-0000-0000-c000-000000000000' -PermissionType 'delegated' #> function ConvertTo-PermissionGuid { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.String] $PermissionName, [Parameter()] [System.String] $ResourceApplicationId, [Parameter()] [System.String] $PermissionType ) # Pass through wildcard values if ($PermissionName -eq 'all' -or $PermissionName -eq 'any') { return 'all' } # Check if already a GUID $guidResult = [System.Guid]::Empty if ([System.Guid]::TryParse($PermissionName, [ref]$guidResult)) { return $PermissionName } # Cannot resolve without a specific resource application if ([System.String]::IsNullOrEmpty($ResourceApplicationId) -or $ResourceApplicationId -eq 'any') { Write-Verbose -Message "Cannot resolve permission name '$PermissionName' without a specific ResourceApplication." return $PermissionName } # If ResourceApplicationId is not a GUID, try to resolve it as a service principal name $appIdGuid = [System.Guid]::Empty if (-not [System.Guid]::TryParse($ResourceApplicationId, [ref]$appIdGuid)) { $resolvedId = Resolve-ResourceApplicationId -ResourceApplication $ResourceApplicationId if (-not [System.Guid]::TryParse($resolvedId, [ref]$appIdGuid)) { Write-Verbose -Message "ResourceApplication '$ResourceApplicationId' could not be resolved to a valid GUID." return $PermissionName } $ResourceApplicationId = $resolvedId } try { $cacheKey = $appIdGuid.ToString() if ($Script:ServicePrincipalCache.ContainsKey($cacheKey)) { Write-Verbose -Message "Using cached service principal for ResourceApplication '$ResourceApplicationId'." $servicePrincipal = $Script:ServicePrincipalCache[$cacheKey] } else { $servicePrincipal = Get-MgServicePrincipal -Filter "AppId eq '$cacheKey'" -ErrorAction SilentlyContinue $Script:ServicePrincipalCache[$cacheKey] = $servicePrincipal if ($null -ne $servicePrincipal -and -not [System.String]::IsNullOrEmpty($servicePrincipal.DisplayName)) { $Script:ServicePrincipalCache[$servicePrincipal.DisplayName] = $servicePrincipal } } if ($null -eq $servicePrincipal) { Write-Verbose -Message "Service principal for ResourceApplication '$ResourceApplicationId' not found." return $PermissionName } if ($PermissionType -eq 'delegated') { $scope = $servicePrincipal.Oauth2PermissionScopes | Where-Object { $_.Value -eq $PermissionName } if ($null -ne $scope) { Write-Verbose -Message "Resolved delegated permission '$PermissionName' to GUID '$($scope.Id)'." return $scope.Id.ToString() } } elseif ($PermissionType -eq 'application') { $role = $servicePrincipal.AppRoles | Where-Object { $_.Value -eq $PermissionName } if ($null -ne $role) { Write-Verbose -Message "Resolved application permission '$PermissionName' to GUID '$($role.Id)'." return $role.Id.ToString() } } # Try both collections if PermissionType is not specified or not found $scope = $servicePrincipal.Oauth2PermissionScopes | Where-Object { $_.Value -eq $PermissionName } if ($null -ne $scope) { Write-Verbose -Message "Resolved permission '$PermissionName' to GUID '$($scope.Id)' from Oauth2PermissionScopes." return $scope.Id.ToString() } $role = $servicePrincipal.AppRoles | Where-Object { $_.Value -eq $PermissionName } if ($null -ne $role) { Write-Verbose -Message "Resolved permission '$PermissionName' to GUID '$($role.Id)' from AppRoles." return $role.Id.ToString() } Write-Verbose -Message "Permission '$PermissionName' not found in service principal for '$ResourceApplicationId'." } catch { Write-Verbose -Message "Error resolving permission '$PermissionName': $_" } return $PermissionName } <# .SYNOPSIS Converts a permission GUID to its display name. .DESCRIPTION This helper function takes a permission GUID and resolves it to its display name by looking up the service principal's Oauth2PermissionScopes (delegated) and AppRoles (application). Values that are already display names (non-GUID strings) and wildcard values ('all', 'any') are returned unchanged. .PARAMETER PermissionId The permission GUID to resolve. .PARAMETER ResourceApplicationId The AppId GUID of the resource application (service principal) that defines the permission. Required for resolution. If not provided or set to a wildcard, the original value is returned. .PARAMETER PermissionType The type of permission: 'delegated' or 'application'. Used to search the correct collection first. .OUTPUTS System.String Returns the permission display name, or the original value if it cannot be resolved. .EXAMPLE $name = ConvertTo-PermissionName -PermissionId 'e1fe6dd8-ba31-4d61-89e7-88639da4683d' -ResourceApplicationId '00000003-0000-0000-c000-000000000000' -PermissionType 'delegated' # Returns 'User.Read' #> function ConvertTo-PermissionName { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.String] $PermissionId, [Parameter()] [System.String] $ResourceApplicationId, [Parameter()] [System.String] $PermissionType ) # Pass through wildcard values if ($PermissionId -eq 'all' -or $PermissionId -eq 'any') { return $PermissionId } # If not a GUID, assume it is already a display name $guidResult = [System.Guid]::Empty if (-not [System.Guid]::TryParse($PermissionId, [ref]$guidResult)) { return $PermissionId } # Cannot resolve without a specific resource application if ([System.String]::IsNullOrEmpty($ResourceApplicationId) -or $ResourceApplicationId -eq 'any') { Write-Verbose -Message "Cannot resolve permission GUID '$PermissionId' without a specific ResourceApplication." return $PermissionId } # If ResourceApplicationId is not a GUID, try to resolve it as a service principal name $appIdGuid = [System.Guid]::Empty if (-not [System.Guid]::TryParse($ResourceApplicationId, [ref]$appIdGuid)) { $resolvedId = Resolve-ResourceApplicationId -ResourceApplication $ResourceApplicationId if (-not [System.Guid]::TryParse($resolvedId, [ref]$appIdGuid)) { Write-Verbose -Message "ResourceApplication '$ResourceApplicationId' could not be resolved to a valid GUID." return $PermissionId } $ResourceApplicationId = $resolvedId } try { $cacheKey = $appIdGuid.ToString() if ($Script:ServicePrincipalCache.ContainsKey($cacheKey)) { Write-Verbose -Message "Using cached service principal for ResourceApplication '$ResourceApplicationId'." $servicePrincipal = $Script:ServicePrincipalCache[$cacheKey] } else { $servicePrincipal = Get-MgServicePrincipal -Filter "AppId eq '$cacheKey'" -ErrorAction SilentlyContinue $Script:ServicePrincipalCache[$cacheKey] = $servicePrincipal } if ($null -eq $servicePrincipal) { Write-Verbose -Message "Service principal for ResourceApplication '$ResourceApplicationId' not found." return $PermissionId } if ($PermissionType -eq 'delegated') { $scope = $servicePrincipal.Oauth2PermissionScopes | Where-Object { $_.Id -eq $guidResult } if ($null -ne $scope) { Write-Verbose -Message "Resolved delegated permission GUID '$PermissionId' to name '$($scope.Value)'." return $scope.Value } } elseif ($PermissionType -eq 'application') { $role = $servicePrincipal.AppRoles | Where-Object { $_.Id -eq $guidResult } if ($null -ne $role) { Write-Verbose -Message "Resolved application permission GUID '$PermissionId' to name '$($role.Value)'." return $role.Value } } # Try both collections if PermissionType is not specified or not found $scope = $servicePrincipal.Oauth2PermissionScopes | Where-Object { $_.Id -eq $guidResult } if ($null -ne $scope) { Write-Verbose -Message "Resolved permission GUID '$PermissionId' to name '$($scope.Value)' from Oauth2PermissionScopes." return $scope.Value } $role = $servicePrincipal.AppRoles | Where-Object { $_.Id -eq $guidResult } if ($null -ne $role) { Write-Verbose -Message "Resolved permission GUID '$PermissionId' to name '$($role.Value)' from AppRoles." return $role.Value } Write-Verbose -Message "Permission GUID '$PermissionId' not found in service principal for '$ResourceApplicationId'." } catch { Write-Verbose -Message "Error resolving permission GUID '$PermissionId': $_" } return $PermissionId } <# .SYNOPSIS Converts a permission grant condition set object to a hashtable representation. .DESCRIPTION This helper function takes a condition set object (from the Microsoft Graph API) and converts it to a hashtable format suitable for DSC configuration comparison. Only non-null properties are included in the result. .PARAMETER ConditionSet The condition set object to convert. This can be a PSCustomObject from the Graph API or a hashtable/CIM instance from DSC configuration. .OUTPUTS System.Collections.Hashtable .EXAMPLE $hashtable = Get-PermissionGrantConditionSetAsHashtable -ConditionSet $graphObject #> function Get-PermissionGrantConditionSetAsHashtable { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = $true)] [System.Object] $ConditionSet ) $result = @{ Id = $ConditionSet.Id } if ($null -ne $ConditionSet.CertifiedClientApplicationsOnly) { $result.Add('CertifiedClientApplicationsOnly', $ConditionSet.CertifiedClientApplicationsOnly) } if ($null -ne $ConditionSet.ClientApplicationIds) { $result.Add('ClientApplicationIds', [string[]]$ConditionSet.ClientApplicationIds) } if ($null -ne $ConditionSet.ClientApplicationPublisherIds) { $result.Add('ClientApplicationPublisherIds', [string[]]$ConditionSet.ClientApplicationPublisherIds) } if ($null -ne $ConditionSet.ClientApplicationTenantIds) { $result.Add('ClientApplicationTenantIds', [string[]]$ConditionSet.ClientApplicationTenantIds) } if ($null -ne $ConditionSet.ClientApplicationsFromVerifiedPublisherOnly) { $result.Add('ClientApplicationsFromVerifiedPublisherOnly', $ConditionSet.ClientApplicationsFromVerifiedPublisherOnly) } if ($null -ne $ConditionSet.PermissionClassification) { $result.Add('PermissionClassification', $ConditionSet.PermissionClassification) } if ($null -ne $ConditionSet.PermissionType) { $result.Add('PermissionType', $ConditionSet.PermissionType) } if ($null -ne $ConditionSet.ResourceApplication) { $result.Add('ResourceApplication', $ConditionSet.ResourceApplication) } if ($null -ne $ConditionSet.Permissions) { $resolvedPermissions = @() foreach ($permission in $ConditionSet.Permissions) { $resolvedPermissions += ConvertTo-PermissionName ` -PermissionId $permission ` -ResourceApplicationId $ConditionSet.ResourceApplication ` -PermissionType $ConditionSet.PermissionType } $result.Add('Permissions', [string[]]$resolvedPermissions) } return $result } <# .SYNOPSIS Converts a condition set to Microsoft Graph API parameters. .DESCRIPTION This helper function takes a condition set (from DSC configuration) and converts it to a hashtable of parameters suitable for passing to Microsoft Graph API cmdlets (New-MgBetaPolicyPermissionGrantPolicyInclude/Exclude). .PARAMETER ConditionSet The condition set to convert. This can be a CIM instance or hashtable. .OUTPUTS System.Collections.Hashtable .EXAMPLE $params = Get-PermissionGrantConditionSetAsParameters -ConditionSet $cimInstance New-MgBetaPolicyPermissionGrantPolicyInclude @params #> function Get-PermissionGrantConditionSetAsParameters { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = $true)] [System.Object] $ConditionSet ) $params = @{} if ($null -ne $ConditionSet.CertifiedClientApplicationsOnly) { $params.Add('CertifiedClientApplicationsOnly', [bool]$ConditionSet.CertifiedClientApplicationsOnly) } if ($null -ne $ConditionSet.ClientApplicationIds -and $ConditionSet.ClientApplicationIds.Count -gt 0) { $params.Add('ClientApplicationIds', [string[]]$ConditionSet.ClientApplicationIds) } if ($null -ne $ConditionSet.ClientApplicationPublisherIds -and $ConditionSet.ClientApplicationPublisherIds.Count -gt 0) { $params.Add('ClientApplicationPublisherIds', [string[]]$ConditionSet.ClientApplicationPublisherIds) } if ($null -ne $ConditionSet.ClientApplicationTenantIds -and $ConditionSet.ClientApplicationTenantIds.Count -gt 0) { $params.Add('ClientApplicationTenantIds', [string[]]$ConditionSet.ClientApplicationTenantIds) } if ($null -ne $ConditionSet.ClientApplicationsFromVerifiedPublisherOnly) { $params.Add('ClientApplicationsFromVerifiedPublisherOnly', [bool]$ConditionSet.ClientApplicationsFromVerifiedPublisherOnly) } if (-not [string]::IsNullOrEmpty($ConditionSet.PermissionClassification)) { $params.Add('PermissionClassification', $ConditionSet.PermissionClassification) } # Pass through ResourceApplication as-is (expects GUID or 'any') if (-not [string]::IsNullOrEmpty($ConditionSet.ResourceApplication)) { $params.Add('ResourceApplication', $ConditionSet.ResourceApplication) } if (-not [string]::IsNullOrEmpty($ConditionSet.PermissionType)) { $params.Add('PermissionType', $ConditionSet.PermissionType) } # Convert permission names to GUIDs if ($null -ne $ConditionSet.Permissions -and $ConditionSet.Permissions.Count -gt 0) { $resourceAppValue = $ConditionSet.ResourceApplication $resolvedPermissions = @() foreach ($permission in $ConditionSet.Permissions) { $resolvedPermissions += ConvertTo-PermissionGuid ` -PermissionName $permission ` -ResourceApplicationId $resourceAppValue ` -PermissionType $ConditionSet.PermissionType } $params.Add('Permissions', [string[]]$resolvedPermissions) } return $params } <# .SYNOPSIS Compares two permission grant condition sets for equality. .DESCRIPTION This helper function performs a deep comparison of two condition sets to determine if they are logically equivalent. Array properties are compared after sorting to ensure order-independent comparison. .PARAMETER ConditionSet1 The first condition set to compare. .PARAMETER ConditionSet2 The second condition set to compare. .OUTPUTS System.Boolean Returns $true if the condition sets are equivalent, $false otherwise. .EXAMPLE $areEqual = Test-ConditionSetsEqual -ConditionSet1 $desired -ConditionSet2 $current #> function Test-ConditionSetsEqual { [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter(Mandatory = $true)] [System.Object] $ConditionSet1, [Parameter(Mandatory = $true)] [System.Object] $ConditionSet2 ) # Convert both to hashtables for comparison $hash1 = Get-PermissionGrantConditionSetAsHashtable -ConditionSet $ConditionSet1 $hash2 = Get-PermissionGrantConditionSetAsHashtable -ConditionSet $ConditionSet2 # Compare each property, skipping Id (auto-generated by Graph API) foreach ($key in $hash1.Keys) { if ($key -eq 'Id') { continue } if (-not $hash2.ContainsKey($key)) { return $false } $value1 = $hash1[$key] $value2 = $hash2[$key] # Handle array comparison if ($value1 -is [Array] -and $value2 -is [Array]) { if ($value1.Count -ne $value2.Count) { return $false } $sorted1 = $value1 | Sort-Object $sorted2 = $value2 | Sort-Object for ($i = 0; $i -lt $sorted1.Count; $i++) { if ($sorted1[$i] -ne $sorted2[$i]) { return $false } } } else { if ($value1 -ne $value2) { return $false } } } # Check for keys in hash2 that are not in hash1 (excluding Id) foreach ($key in $hash2.Keys) { if ($key -eq 'Id') { continue } if (-not $hash1.ContainsKey($key)) { return $false } } return $true } #endregion Export-ModuleMember -Function *-TargetResource |