Modules/M365DSCExportUtil.psm1
|
$Script:M365DSCStringReplacementMap = @{} $Script:M365DSCMandatoryKeyCache = @{} $Script:M365DSCCompiledRegexCache = @{} $Script:M365DSCAuthenticationParameterSet = @{ ServicePrincipalWithThumbprint = @('ApplicationId', 'CertificateThumbprint', 'TenantId') ServicePrincipalWithSecret = @('ApplicationId', 'ApplicationSecret', 'TenantId') ServicePrincipalWithPath = @('ApplicationId', 'CertificatePath', 'CertificatePassword', 'TenantId') CredentialsWithTenantId = @('Credential', 'TenantId') CredentialsWithApplicationId = @('Credential', 'ApplicationId') Credentials = @('Credential') ManagedIdentity = @('ManagedIdentity', 'TenantId') AccessTokens = @('AccessTokens', 'TenantId') } $Script:M365DSCRelationIndex = $null $Script:M365DSCExportComponentNames = $null <# .SYNOPSIS Returns the resource names offered by the Components argument completer. .DESCRIPTION Resolved on first completion rather than at import, because the manifest reader lives in a module that loads after this one. .FUNCTIONALITY Internal .OUTPUTS System.String[] #> function Get-M365DSCExportComponentName { [CmdletBinding()] [OutputType([System.String[]])] param() if ($null -ne $Script:M365DSCExportComponentNames) { return $Script:M365DSCExportComponentNames } $names = @() $dscResourcesFolder = Join-Path -Path $PSScriptRoot -ChildPath '../DscResources/' if (Test-Path -Path $dscResourcesFolder) { $names = @(Get-ChildItem -Path $dscResourcesFolder -Recurse -Filter 'MSFT_*.psm1' -File | ForEach-Object { $_.Name -replace 'MSFT_', '' -replace '.psm1', '' }) } if ($names.Count -eq 0) { $manifestPath = Join-Path -Path $PSScriptRoot -ChildPath '../Microsoft365DSC.psd1' if (Test-Path -Path $manifestPath) { $names = @((Import-PowerShellDataFile -Path $manifestPath).DscResourcesToExport) } } $Script:M365DSCExportComponentNames = [System.String[]] $names return $Script:M365DSCExportComponentNames } Register-ArgumentCompleter -CommandName Export-M365DSCConfiguration -ParameterName Components -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $resources = (Get-M365DSCExportComponentName) -like "$wordToComplete*" foreach ($resource in $resources) { [System.Management.Automation.CompletionResult]::new($resource, $resource, 'ParameterValue', $resource) } } <# .SYNOPSIS Exports tenant configuration to Microsoft365DSC configuration content. .DESCRIPTION Entry point for ReverseDSC export. Validates authentication inputs, resolves target resources, executes extraction, and returns the generated configuration content. .PARAMETER LaunchWebUI Indicates that the export Web UI should be launched. .PARAMETER Path Specifies the output path for exported configuration files. .PARAMETER FileName Specifies the output configuration file name. .PARAMETER ConfigurationName Specifies the generated DSC configuration name. .PARAMETER Components Specifies component names to export. .PARAMETER ExcludeComponents Specifies component names to exclude. .PARAMETER Workloads Specifies workloads used to derive components. .PARAMETER Mode Specifies the export mode. .PARAMETER GenerateInfo Indicates whether informational metadata should be generated in export output. .PARAMETER Filters Specifies resource-level filters used during export. .PARAMETER ApplicationId Specifies the application id used for app-based authentication. .PARAMETER TenantId Specifies the tenant id or tenant domain used for authentication. .PARAMETER ApplicationSecret Specifies the application secret used for app-based authentication. .PARAMETER CertificateThumbprint Specifies the certificate thumbprint used for app-based authentication. .PARAMETER Credential Specifies delegated credentials used for authentication. .PARAMETER CertificatePassword Specifies the password used to read the certificate file. .PARAMETER CertificatePath Specifies the certificate file path used for app-based authentication. .PARAMETER ManagedIdentity Indicates that managed identity authentication should be used. .PARAMETER AccessTokens Specifies one or more pre-acquired access tokens. .PARAMETER SubscriptionId Specifies the Azure subscription id used by Azure resources. .PARAMETER Validate Indicates whether the exported configuration should be validated. .PARAMETER Parallel Indicates whether export should execute in parallel. .PARAMETER ThrottleLimit Specifies the number of parallel workers. Requires Parallel. Default: 5. .PARAMETER TokenReplacement Specifies token replacement mappings applied to exported content. .PARAMETER WithStatistics Indicates whether export statistics should be collected. .PARAMETER IncludeDependencies Indicates whether dependency extraction and DependsOn generation should run. .EXAMPLE PS> Export-M365DSCConfiguration -Components @("AADApplication", "AADConditionalAccessPolicy", "AADGroupsSettings") -Credential $Credential .EXAMPLE PS> Export-M365DSCConfiguration -Mode 'Default' -ApplicationId '2560bb7c-bc85-415f-a799-841e10ec4f9a' -TenantId 'contoso.sharepoint.com' -ApplicationSecret 'abcdefghijkl' .EXAMPLE PS> Export-M365DSCConfiguration -Components @("AADApplication", "AADConditionalAccessPolicy", "AADGroupsSettings") -Credential $Credential -Path 'C:\DSC' -FileName 'MyConfig.ps1' .EXAMPLE PS> Export-M365DSCConfiguration -Credential $Credential -Filters @{AADApplication = "DisplayName eq 'MyApp'"} -TokenReplacement @{ 'alternate-email.onmicrosoft.com' = 'AlternateEmail' } .EXAMPLE PS> Export-M365DSCConfiguration -Workloads @("SPO") -ExcludeComponents @("SPOPropertyBag") -Credential $Credential .EXAMPLE PS> Export-M365DSCConfiguration -Workloads @("SPO") -ApplicationId $clientId -TenantId $tenantName -CertificateThumbprint $certThumbprint -IncludeDependencies .EXAMPLE PS> Export-M365DSCConfiguration -Workloads @("SPO") -ApplicationId $clientId -TenantId $tenantName -CertificateThumbprint $certThumbprint -IncludeDependencies -Parallel -ThrottleLimit 2 .FUNCTIONALITY Public #> function Export-M365DSCConfiguration { [CmdletBinding(DefaultParameterSetName = 'Export')] param ( [Parameter(ParameterSetName = 'WebUI')] [Switch] $LaunchWebUI, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.String] $Path, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.String] $FileName, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.String] $ConfigurationName, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.String[]] $Components, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.String[]] $ExcludeComponents, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [ValidateSet('AAD', 'ADO', 'AZURE', 'COMMERCE', 'DEFENDER', 'EXO', 'FABRIC', 'INTUNE', 'O365', 'OD', 'PLANNER', 'PP', 'SC', 'SENTINEL', 'SH', 'SPO', 'TEAMS', 'VIVA')] [System.String[]] $Workloads, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [ValidateSet('Default', 'Full')] [System.String] $Mode = 'Default', [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.Boolean] $GenerateInfo = $false, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.Collections.Hashtable] $Filters, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.String] $ApplicationId, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [ValidateScript({ $invalid = $false if ([System.Guid]::TryParse($_, [ref][System.Guid]::Empty)) { throw 'Please provide the tenant name (e.g., contoso.onmicrosoft.com or contoso.onsovcloud.com for sovereign tenants) for TenantId instead of its GUID.' } $invalid = $_ -notmatch '.onmicrosoft.' -and $_ -notmatch '.onsovcloud.' if ($invalid) { Write-Warning -Message 'We recommend providing the TenantId property in the format of <tenant>.onmicrosoft.* or <tenant>.onsovcloud.* for sovereign tenants.' } return $true })] [System.String] $TenantId, # TODO: Change to PSCredential during next breaking change [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.String] $ApplicationSecret, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.String] $CertificateThumbprint, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.Management.Automation.PSCredential] $Credential, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.Management.Automation.PSCredential] $CertificatePassword, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.String] $CertificatePath, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [Switch] $ManagedIdentity, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.String[]] $AccessTokens, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.String] $SubscriptionId, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [Switch] $Validate, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel', Mandatory = $true)] [Switch] $Parallel, [Parameter(ParameterSetName = 'ExportParallel')] [ValidateRange(1, [System.Int32]::MaxValue)] [System.Int32] $ThrottleLimit = 5, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [System.Collections.Hashtable] $TokenReplacement, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [Switch] $WithStatistics, [Parameter(ParameterSetName = 'Export')] [Parameter(ParameterSetName = 'ExportParallel')] [Switch] $IncludeDependencies ) if ($IncludeDependencies.IsPresent) { Write-Warning -Message "The -IncludeDependencies parameter is currently in preview. Please review the generated configuration to ensure it captures the dependencies as expected. If you encounter any issues or have feedback, please report it at https://github.com/Microsoft365DSC/Microsoft365DSC." } $currentStartDateTime = [System.DateTime]::Now $Global:M365DSCExportInProgress = $true $Global:MaximumFunctionCount = 32767 Clear-M365DSCHostMessageCache # Initialize the relation assembly and reset its state for this export session Initialize-M365DSCDllLoader -ErrorAction Stop [Microsoft365DSC.Relations.ExportInstanceNames]::Reset() [Microsoft365DSC.Intune.ConfigurationPolicyCache]::Reset() Initialize-M365DSCExportCollectionCache Reset-M365DSCConnectionFailureCache # Clear performance caches for fresh export $Script:M365DSCMandatoryKeyCache = @{} $Script:M365DSCCompiledRegexCache = @{} Set-M365DSCStringReplacementMap -Clear $Global:M365DSCStringReplacementMap = $null Clear-ConfigurationDataContent # Track cross-resource relations only when the caller asked for DependsOn output. [Microsoft365DSC.Relations.ExportRelationSession]::Reset() if ($IncludeDependencies.IsPresent) { $null = New-M365DSCExportRelationSession } # LaunchWebUI specified, launching that now if ($LaunchWebUI) { Write-Output -InputObject "Launching web page 'https://export.microsoft365dsc.com'" explorer 'https://export.microsoft365dsc.com' return } # Suppress Progress overlays $Global:ProgressPreference = 'SilentlyContinue' # Check ErrorActionPreference - Azure DevOps and other Pipeline environments set it to 'Stop' by default if ($ErrorActionPreference -eq 'Stop' -and -not $PSBoundParameters.ContainsKey('ErrorAction')) { $ErrorActionPreference = 'Continue' } ##### FIRST CHECK AUTH PARAMETERS if ($PSBoundParameters.ContainsKey('Credential') -eq $true -and ` -not [System.String]::IsNullOrEmpty($Credential)) { if ($Credential.Username -notmatch '.onmicrosoft.' -and $Credential.Username -notmatch '.onsovcloud.') { Write-Warning -Message 'We recommend providing the username in the format of <tenant>.onmicrosoft.* (or <tenant>.onsovcloud.* for sovereign tenants) for the Credential property.' } } if ($PSBoundParameters.ContainsKey('CertificatePath') -eq $true -and ` $PSBoundParameters.ContainsKey('CertificatePassword') -eq $false) { throw 'You have to specify CertificatePassword when you specify CertificatePath' } if ($PSBoundParameters.ContainsKey('CertificatePassword') -eq $true -and ` $PSBoundParameters.ContainsKey('CertificatePath') -eq $false) { throw 'You have to specify CertificatePath when you specify CertificatePassword' } if ($PSBoundParameters.ContainsKey('ApplicationId') -eq $true -and ` $PSBoundParameters.ContainsKey('Credential') -eq $false -and ` $PSBoundParameters.ContainsKey('TenantId') -eq $false) { throw 'You have to specify TenantId when you specify ApplicationId' } if ($PSBoundParameters.ContainsKey('ApplicationId') -eq $true -and ` $PSBoundParameters.ContainsKey('TenantId') -eq $true -and ` $PSBoundParameters.ContainsKey('Credential') -eq $false -and ` ($PSBoundParameters.ContainsKey('CertificateThumbprint') -eq $false -and ` $PSBoundParameters.ContainsKey('ApplicationSecret') -eq $false -and ` $PSBoundParameters.ContainsKey('CertificatePath') -eq $false)) { throw 'You have to specify ApplicationSecret, CertificateThumbprint or CertificatePath when you specify ApplicationId/TenantId' } if (($PSBoundParameters.ContainsKey('ApplicationId') -eq $false -or ` $PSBoundParameters.ContainsKey('TenantId') -eq $false) -and ` ($PSBoundParameters.ContainsKey('Credential') -eq $false -and ` $PSBoundParameters.ContainsKey('CertificateThumbprint') -eq $true -or ` $PSBoundParameters.ContainsKey('ApplicationSecret') -eq $true -or ` $PSBoundParameters.ContainsKey('CertificatePath') -eq $true)) { throw 'You have to specify ApplicationId and TenantId when you specify ApplicationSecret, CertificateThumbprint or CertificatePath' } # Default to Credential if no authentication mechanism were provided if ($PSBoundParameters.ContainsKey('Credential') -eq $false -and ` $ManagedIdentity.IsPresent -eq $false -and ` $PSBoundParameters.ContainsKey('ApplicationId') -eq $false -and ` $PSBoundParameters.ContainsKey('AccessTokens') -eq $false) { $Credential = Get-Credential } #region Telemetry $data = [System.Collections.Generic.Dictionary[[System.String], [System.Object]]]::new() $data.Add('Path', [System.String]::IsNullOrEmpty($Path)) $data.Add('FileName', $null -ne [System.String]::IsNullOrEmpty($FileName)) $data.Add('Components', $Components) $data.Add('Workloads', $Workloads) #endregion Confirm-M365DSCDependencies # Make sure we are not connected to Microsoft Graph on another tenant # except if connected through MSCloudLoginAssistant - it will handle the connection try { Confirm-M365DSCLoadedModule -ModuleName 'Microsoft.Graph.Authentication' $currentConnectionProfile = Get-MSCloudLoginConnectionProfile -Workload 'MicrosoftGraph' if ($null -ne (Get-MgContext) -and -not $currentConnectionProfile.Connected) { Disconnect-MgGraph -ErrorAction Stop | Out-Null Reset-MSCloudLoginConnectionProfileContext -Workload 'MicrosoftGraph' } } catch { Write-Verbose -Message 'No existing connections to Microsoft Graph' } $Tenant = Get-M365DSCTenantNameFromParameterSet -ParameterSet $PSBoundParameters $Script:ConnectionMode = Get-M365DSCAuthenticationMode $PSBoundParameters $data.Add('Tenant', $Tenant) $currentExportID = (New-Guid).ToString() $data.Add('M365DSCExportId', $currentExportID) $data.Add('ConnectionMode', $Script:ConnectionMode) $telemetryParams = Get-M365DSCTelemetryConnectionParameter # Define connection to Graph parameters because it is required by the telemetry. if ($null -eq $telemetryParams -or ` ($null -ne $telemetryParams -and ` $telemetryParams.Keys.Count -eq 0)) { $telemetryParams = @{ Credential = $Credential ApplicationId = $ApplicationId TenantId = $TenantId ApplicationSecret = $ApplicationSecret CertificateThumbprint = $CertificateThumbprint CertificatePassword = $CertificatePassword CertificatePath = $CertificatePath Identity = $ManagedIdentity.IsPresent AccessTokens = $AccessTokens } Set-M365DSCTelemetryConnectionParameter -Parameters $telemetryParams } Add-M365DSCTelemetryEvent -Type 'ExportInitiated' -Data $data Initialize-M365DSCResourcesDictionary if ($PSBoundParameters.ContainsKey('TokenReplacement')) { Set-M365DSCStringReplacementMap -Map $TokenReplacement } $resourceSettings = Get-M365DSCResourceSettings try { if ($null -ne $Workloads) { Write-M365DSCHost -Message "Exporting Microsoft 365 configuration for Workloads: $($Workloads -join ', ')" Start-M365DSCConfigurationExtract -Credential $Credential ` -Workloads $Workloads ` -ExcludeComponents $ExcludeComponents ` -Mode $Mode ` -Path $Path -FileName $FileName ` -ConfigurationName $ConfigurationName ` -ApplicationId $ApplicationId ` -ApplicationSecret $ApplicationSecret ` -TenantId $TenantId ` -CertificateThumbprint $CertificateThumbprint ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ManagedIdentity:$ManagedIdentity.IsPresent ` -AccessTokens $AccessTokens ` -SubscriptionId $SubscriptionId ` -GenerateInfo $GenerateInfo ` -Filters $Filters ` -Validate:$Validate.IsPresent ` -Parallel:$Parallel.IsPresent ` -ThrottleLimit $ThrottleLimit ` -ResourceSettings $resourceSettings ` -ErrorAction $ErrorActionPreference ` -WithStatistics:$WithStatistics.IsPresent ` -IncludeDependencies:$IncludeDependencies.IsPresent } elseif ($null -ne $Components) { Write-M365DSCHost -Message "Exporting Microsoft 365 configuration for Components: $($Components -join ', ')" Start-M365DSCConfigurationExtract -Credential $Credential ` -Components $Components ` -ExcludeComponents $ExcludeComponents ` -Path $Path -FileName $FileName ` -ConfigurationName $ConfigurationName ` -ApplicationId $ApplicationId ` -ApplicationSecret $ApplicationSecret ` -TenantId $TenantId ` -CertificateThumbprint $CertificateThumbprint ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ManagedIdentity:$ManagedIdentity.IsPresent ` -AccessTokens $AccessTokens ` -SubscriptionId $SubscriptionId ` -GenerateInfo $GenerateInfo ` -Filters $Filters ` -Validate:$Validate.IsPresent ` -Parallel:$Parallel.IsPresent ` -ThrottleLimit $ThrottleLimit ` -ResourceSettings $resourceSettings ` -ErrorAction $ErrorActionPreference ` -WithStatistics:$WithStatistics.IsPresent ` -IncludeDependencies:$IncludeDependencies.IsPresent } elseif ($null -ne $Mode) { Write-M365DSCHost -Message "Exporting Microsoft 365 configuration for Mode: $Mode" Start-M365DSCConfigurationExtract -Credential $Credential ` -Mode $Mode ` -ExcludeComponents $ExcludeComponents ` -Path $Path -FileName $FileName ` -ConfigurationName $ConfigurationName ` -ApplicationId $ApplicationId ` -ApplicationSecret $ApplicationSecret ` -TenantId $TenantId ` -CertificateThumbprint $CertificateThumbprint ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ManagedIdentity:$ManagedIdentity.IsPresent ` -AccessTokens $AccessTokens ` -SubscriptionId $SubscriptionId ` -GenerateInfo $GenerateInfo ` -AllComponents ` -Filters $Filters ` -Validate:$Validate.IsPresent ` -Parallel:$Parallel.IsPresent ` -ThrottleLimit $ThrottleLimit ` -ResourceSettings $resourceSettings ` -ErrorAction $ErrorActionPreference ` -WithStatistics:$WithStatistics.IsPresent ` -IncludeDependencies:$IncludeDependencies.IsPresent } } finally { Reset-M365DSCExportCollectionCache Reset-M365DSCConnectionFailureCache } if ($IncludeDependencies.IsPresent) { $relationSession = [Microsoft365DSC.Relations.ExportRelationSession]::Current if ($null -eq $relationSession -or $relationSession.InstanceCount -eq 0) { Write-Warning -Message ('No resource instances were recorded for dependency tracking, so no DependsOn ' + ` 'statements were generated. Please report this at https://github.com/Microsoft365DSC/Microsoft365DSC.') } } # Release the export-scoped state held on the relation assembly [Microsoft365DSC.Relations.ExportInstanceNames]::Reset() [Microsoft365DSC.Relations.ExportRelationSession]::Reset() [Microsoft365DSC.Intune.ConfigurationPolicyCache]::Reset() Reset-M365DSCExportCollectionCache Reset-M365DSCConnectionFailureCache $Global:M365DSCExportInProgress = $false $data = [System.Collections.Generic.Dictionary[[System.String], [System.Object]]]::new() if ([System.String]::IsNullOrEmpty($data.Tenant) -and -not [System.String]::IsNullOrEmpty($TenantId)) { $data.Add('Tenant', $TenantId) } else { $data.Add('Tenant', $Tenant) } $data.Add('M365DSCExportId', $currentExportID) $data.Add('ConnectionMode', $Script:ConnectionMode) $timeTaken = [System.DateTime]::Now.Subtract($currentStartDateTime) $data.Add('TotalSeconds', $timeTaken.TotalSeconds) Add-M365DSCTelemetryEvent -Type 'ExportCompleted' -Data $data } <# .SYNOPSIS Returns exportable resource names for a selected export mode. .DESCRIPTION Filters resource settings by mode and optionally excludes configuration resources when Full mode is used. .PARAMETER Mode Specifies the export mode used to select resources. .PARAMETER ExcludeConfigurationResources Indicates that configuration-only resources should be excluded in Full mode. .EXAMPLE Get-M365DSCResourcesByExportMode -Mode 'Default' This command retrieves all resources that are available in the Default export mode. .EXAMPLE Get-M365DSCResourcesByExportMode -Mode 'Full' This command retrieves all resources that are available in the Full export mode. .OUTPUTS System.String[] #> function Get-M365DSCResourcesByExportMode { [CmdletBinding()] [OutputType([System.String[]])] param ( [Parameter(Mandatory = $true)] [ValidateSet('Default', 'Full')] [System.String] $Mode, [Parameter(Mandatory = $false)] [switch] $ExcludeConfigurationResources ) $resourceSettings = Get-M365DSCResourceSettings $resources = [System.Collections.Generic.List[System.String]]::new($resourceSettings.Keys.Count) foreach ($resource in $resourceSettings.Keys) { if ($Mode -eq 'Default' -and $resourceSettings[$resource].mode -eq 'Configuration') { $resources.Add($resource) } elseif ($Mode -eq 'Full') { if ($ExcludeConfigurationResources -and $resourceSettings[$resource].mode -eq 'Configuration') { continue } $resources.Add($resource) } } return $resources.ToArray() } <# .SYNOPSIS Builds DSC resource block content for a single exported resource instance. .DESCRIPTION Converts resource export results into DSC text content. It normalizes authentication fields, handles escaping rules, and emits resource block content for the target module. .PARAMETER ResourceName Specifies the resource name being rendered. .PARAMETER ConnectionMode Specifies the resolved authentication connection mode. .PARAMETER ModulePath Specifies the path to the resource module used during export rendering. .PARAMETER Results Specifies exported resource values to render. .PARAMETER Credential Specifies delegated credentials used for contextual rendering. .PARAMETER NoEscape Specifies property names that should not be string-escaped. .PARAMETER SkipAuthenticationUpdate Indicates that authentication fields should not be transformed. .PARAMETER SkipRemoveSpecialCharacters Indicates that special characters should not be removed from the resource instance name. .PARAMETER AllowVariablesInStrings Indicates that variable placeholders may be preserved inside strings. .PARAMETER RawResults Specifies the original unprocessed export result values. .OUTPUTS System.String #> function Get-M365DSCExportContentForResource { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.String] $ResourceName, [Parameter(Mandatory = $true)] [System.String] [ValidateSet('ServicePrincipalWithThumbprint', 'ServicePrincipalWithSecret', 'ServicePrincipalWithPath', 'CredentialsWithTenantId', 'CredentialsWithApplicationId', 'Credentials', 'ManagedIdentity', 'AccessTokens')] $ConnectionMode, [Parameter(Mandatory = $true)] [System.String] $ModulePath, [Parameter(Mandatory = $true)] [System.Collections.Hashtable] $Results, [Parameter()] [System.Management.Automation.PSCredential] $Credential, [Parameter()] [System.String[]] $NoEscape, [Parameter()] [switch] $SkipAuthenticationUpdate, [Parameter()] [switch] $SkipRemoveSpecialCharacters, [Parameter()] [switch] $AllowVariablesInStrings, [Parameter()] [System.Collections.Hashtable] $RawResults ) $OrganizationName = '' if ($ConnectionMode -like 'ServicePrincipal*' -or ` $ConnectionMode -eq 'ManagedIdentity') { $OrganizationName = $Results.TenantId } elseif ($null -ne $Credential.UserName) { $OrganizationName = $Credential.UserName.Split('@')[1] } else { $OrganizationName = '' } if (-not $SkipAuthenticationUpdate) { $withoutAuthentication = Update-M365DSCExportAuthenticationResults -ConnectionMode $ConnectionMode ` -Results $Results $Results = $withoutAuthentication.Results $NoEscape += $withoutAuthentication.NoEscape } $NoEscape = $NoEscape | Select-Object -Unique $primaryKey = '' if ($Script:M365DSCMandatoryKeyCache.ContainsKey($ResourceName)) { $Keys = $Script:M365DSCMandatoryKeyCache[$ResourceName] } else { $Keys = @(Get-M365DSCResourceMandatoryKey -ResourceName $ResourceName) $Script:M365DSCMandatoryKeyCache[$ResourceName] = $Keys } if ($Keys.Contains('IsSingleInstance')) { $primaryKey = '' } elseif ($Keys.Contains('DisplayName') -and -not [System.String]::IsNullOrEmpty($Results.DisplayName)) { $primaryKey = $Results.DisplayName } elseif ($Keys.Contains('Name')) { $primaryKey = $Results.Name } elseif ($Keys.Contains('Title')) { $primaryKey = $Results.Title } elseif ($Keys.Contains('Identity')) { $primaryKey = $Results.Identity } elseif ($Keys.Contains('Id')) { $primaryKey = $Results.Id } elseif ($Keys.Contains('CDNType')) { $primaryKey = $Results.CDNType } elseif ($Keys.Contains('WorkspaceName')) { $primaryKey = $Results.WorkspaceName } elseif ($Keys.Contains('OrganizationName')) { $primaryKey = $Results.OrganizationName } elseif ($Keys.Contains('DomainName')) { $primaryKey = $Results.DomainName } elseif ($Keys.Contains('UserPrincipalName')) { $primaryKey = $Results.UserPrincipalName } if ([String]::IsNullOrEmpty($primaryKey) -and -not $Keys.Contains('IsSingleInstance')) { foreach ($Key in $Keys) { $primaryKey += $Results.$Key } } $instanceName = $ResourceName if (-not [System.String]::IsNullOrEmpty($primaryKey)) { if ($SkipRemoveSpecialCharacters) { $instanceName += "-$primaryKey" } else { $instanceName += "-$(Remove-M365DSCSpecialCharacters -String $primaryKey)" } } if ($Results.ContainsKey('Workload')) { $instanceName += "-$($Results.Workload)" } # Check to see if a resource with this exact name was already exported, if so, append a # number to the end. Claiming the name is one atomic operation on the shared registry, so # two runspaces of a parallel export cannot both decide the same name is free. Initialize-M365DSCDllLoader -ErrorAction Stop $instanceName = [Microsoft365DSC.Relations.ExportInstanceNames]::Reserve($instanceName) # Record the instance and resolve its cross-resource relations $relationSession = Get-M365DSCExportRelationSession if ($null -ne $relationSession) { $resolveResults = $Results if ($null -ne $RawResults) { $resolveResults = $RawResults } $relationSession.RegisterInstance($ResourceName, $instanceName, $primaryKey, $resolveResults) $relationSession.ResolveRelations($ResourceName, $instanceName, $resolveResults) } $content = [System.Text.StringBuilder]::new() [void]$content.Append(" $ResourceName `"$instanceName`"`r`n") [void]$content.Append(" {`r`n") $partialContent = Get-DSCBlock -Params $Results -ModulePath $ModulePath -NoEscape $NoEscape -AllowVariablesInStrings:$AllowVariablesInStrings if ($partialContent.IndexOf($OrganizationName, [System.StringComparison]::OrdinalIgnoreCase) -gt 0) { if (-not $Script:M365DSCCompiledRegexCache.ContainsKey("OrgColon_$OrganizationName")) { $Script:M365DSCCompiledRegexCache["OrgColon_$OrganizationName"] = [regex]::new([regex]::Escape($OrganizationName + ':'), 'IgnoreCase, Compiled') $Script:M365DSCCompiledRegexCache["OrgAt_$OrganizationName"] = [regex]::new([regex]::Escape('@' + $OrganizationName), 'IgnoreCase, Compiled') $Script:M365DSCCompiledRegexCache["Org_$OrganizationName"] = [regex]::new([regex]::Escape($OrganizationName), 'IgnoreCase, Compiled') } $partialContent = $Script:M365DSCCompiledRegexCache["OrgColon_$OrganizationName"].Replace($partialContent, "`$(`$OrganizationName):") $partialContent = $Script:M365DSCCompiledRegexCache["OrgAt_$OrganizationName"].Replace($partialContent, "@`$OrganizationName") $partialContent = $Script:M365DSCCompiledRegexCache["Org_$OrganizationName"].Replace($partialContent, "`$OrganizationName") } # Apply additional string to variable replacements from mapping if ($Global:M365DSCStringReplacementMap) { Set-M365DSCStringReplacementMap -Map $Global:M365DSCStringReplacementMap } if ($null -ne $Script:M365DSCStringReplacementMap -and $Script:M365DSCStringReplacementMap.Count -gt 0) { foreach ($entry in $Script:M365DSCStringReplacementMap.GetEnumerator()) { $target = $entry.Key $varName = $entry.Value if ([System.String]::IsNullOrEmpty($target) -or [System.String]::IsNullOrEmpty($varName)) { Write-Verbose -Message "Skipping invalid string replacement map entry: Key = '$target', VariableName = '$varName'" continue } # Skip if already handled as OrganizationName if ($OrganizationName -and ($target -ieq $OrganizationName)) { Write-Verbose -Message "Skipping replacement for target [$target] because it matches the OrganizationName: '$OrganizationName'" continue } if ($partialContent.IndexOf($target, [System.StringComparison]::OrdinalIgnoreCase) -gt 0) { $cacheKeyBase = "Map_$target" if (-not $Script:M365DSCCompiledRegexCache.ContainsKey("${cacheKeyBase}_colon")) { $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_colon"] = [regex]::new([regex]::Escape($target + ':'), 'IgnoreCase, Compiled') $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_at"] = [regex]::new([regex]::Escape('@' + $target), 'IgnoreCase, Compiled') $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_plain"] = [regex]::new([regex]::Escape($target), 'IgnoreCase, Compiled') } $partialContent = $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_colon"].Replace($partialContent, "`$(`$ConfigurationData.NonNodeData.$varName):") $partialContent = $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_at"].Replace($partialContent, "@`$(`$ConfigurationData.NonNodeData.$varName)") $partialContent = $Script:M365DSCCompiledRegexCache["${cacheKeyBase}_plain"].Replace($partialContent, "`$(`$ConfigurationData.NonNodeData.$varName)") } } } [void]$content.Append($partialContent) [void]$content.Append(" }`r`n") if ($null -ne $Global:M365DSCExportInstanceTally) { $Global:M365DSCExportInstanceTally++ } return $content.ToString() } <# .SYNOPSIS Updates the export string replacement map. .DESCRIPTION Merges replacement entries into the module string replacement map and optionally clears existing mappings first. .PARAMETER Map Specifies replacement mappings where key is source text and value is replacement token. .PARAMETER Clear Indicates that existing mappings should be cleared before applying Map. #> function Set-M365DSCStringReplacementMap { [CmdletBinding()] param( [Parameter()] [System.Collections.Hashtable] $Map, [Parameter()] [switch] $Clear ) if ($Clear) { $Script:M365DSCStringReplacementMap = @{} } if ($PSBoundParameters.ContainsKey('Map')) { foreach ($key in $Map.Keys) { $Script:M365DSCStringReplacementMap[$key] = $Map[$key] } } } <# .SYNOPSIS Returns the current export string replacement map. .DESCRIPTION Returns a clone of the in-memory map used for token replacement in exported content. .OUTPUTS System.Collections.Hashtable #> function Get-M365DSCStringReplacementMap { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param() return $Script:M365DSCStringReplacementMap.Clone() } <# .SYNOPSIS Joins split DSC configuration files into a single configuration content block. .DESCRIPTION This function is used to join two or more M365DSC configurations into a single configuration. The function reads the configuration from the specified paths and combines them into a single configuration. Please note that the function won't be updating the authentication parameters if they differ between the configurations. Make sure that the authentication parameters are the same over all configurations. .PARAMETER ConfigurationFile Specifies the base configuration file name. .PARAMETER ConfigurationPath Specifies the folder containing configuration files to merge. .EXAMPLE Join-M365DSCConfiguration -ConfigurationFile 'M365TenantConfig.ps1' -ConfigurationPath 'D:\testbed' This example joins the 'M365TenantConfig.ps1' file with all the configuration files in the 'D:\testbed' directory. .FUNCTIONALITY Public #> function Join-M365DSCConfiguration { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ConfigurationFile, [Parameter(Mandatory = $true)] [string] $ConfigurationPath ) if ($ConfigurationFile -notlike '*.ps1') { throw 'The ConfigurationFile parameter must be a .ps1 file.' } if (-not (Test-Path -Path $ConfigurationPath)) { throw 'The ConfigurationPath parameter must be a valid path.' } $ConfigurationFilePath = Join-Path -Path $ConfigurationPath -ChildPath $ConfigurationFile $ConfigurationPath = Join-Path -Path $ConfigurationPath -ChildPath '*' $baseConfiguration = ConvertTo-DSCObject -Path $ConfigurationFilePath $additionalConfigurations = Get-Item -Path $ConfigurationPath -Filter *.ps1 -Exclude $ConfigurationFile | ForEach-Object { ConvertTo-DSCObject -Path $_.FullName } $combinedArray = @($baseConfiguration) + @($additionalConfigurations) $combinedConfiguration = ConvertFrom-DSCObject -DSCResources $combinedArray # Indent all lines by 8 spaces to match the indentation of the configuration file $combinedConfiguration = $combinedConfiguration -replace '(?m)^', ' ' $combinedConfiguration = $combinedConfiguration.TrimEnd() # Remove everything in the "Node localhost" part in the configuration file, while excluding the last two closing brackets $content = Get-Content -Path $ConfigurationFilePath -Raw $content = $content -replace '(?s)(?<=Node localhost\s*\{)(.*\s{8}\}?)(?=\s*\})', '' # Append the combined configuration after the "Node localhost" part in the configuration file $content = $content -replace '(?s)(?<=Node localhost\s*\{)', "`r`n$combinedConfiguration" return $content } <# .SYNOPSIS Splits a large DSC configuration file into smaller files. .DESCRIPTION Parses Node localhost resource blocks and writes chunked configuration files based on maximum file size and optional resource count limits. .PARAMETER Path Specifies the source configuration file path. .PARAMETER OutputFolder Specifies the destination folder for split files. .PARAMETER MaxFileSizeMB Specifies the maximum file size per output file in megabytes. .PARAMETER MaxResources Specifies the maximum number of resource blocks per output file. .EXAMPLE Split-M365DSCConfiguration -Path 'C:\Configs\M365TenantConfig.ps1' -OutputFolder 'C:\Configs\Split' -MaxFileSizeMB 2 -MaxResources 50 This example splits the 'M365TenantConfig.ps1' file into smaller files, each with a maximum size of 2 MB and a maximum of 50 resources, saving them in the 'C:\Configs\Split' folder. .FUNCTIONALITY Public #> function Split-M365DSCConfiguration { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.String] $Path, [Parameter()] [System.String] $OutputFolder = (Split-Path $Path), [Parameter()] [System.Double] $MaxFileSizeMB = 3, [Parameter()] [System.Int32] $MaxResources = 0 # 0 = ignore resource count limit ) $fileContent = Get-Content -Encoding utf8 -Path $Path -Raw # Extract content inside "Node localhost { ... }" $pattern = 'Node localhost\s*{([\s\S]*)\s+}(\r|\n)+\s+}' $nodeMatch = [regex]::Match($fileContent, $pattern) if (-not $nodeMatch.Success) { throw "Could not find a 'Node localhost { ... }' block in file: $Path" } $nodeContent = $nodeMatch.Groups[1].Value # Extract header (everything before Node localhost) $header = ($fileContent -split 'Node localhost')[0] + "Node localhost`n {`n" $footer = "`n }`n}`n`nM365TenantConfig -ConfigurationData .\ConfigurationData.psd1" # Split into DSC resource text blocks using brace-depth parsing $resources = @() $lines = $nodeContent -split "`r?`n" $currentResource = [System.Text.StringBuilder]::new() $braceDepth = 0 $insideResource = $false for ($i = 0; $i -lt $lines.Count; $i++) { $line = $lines[$i] # Detect resource start if (-not $insideResource -and $line.Trim() -match '^[a-zA-Z0-9_]+\s+"[^"]+"') { $insideResource = $true $null = $currentResource.Clear() $null = $currentResource.AppendLine($line) # Calculate brace depth $braceDepth = ($line -split '{').Count - ($line -split '}').Count continue } if ($insideResource) { $null = $currentResource.AppendLine($line) # Adjust brace depth based on line content $braceDepth += ($line -split '{').Count - ($line -split '}').Count # End of resource block if ($braceDepth -le 0) { $resources += ' ' + $currentResource.ToString().Trim() $insideResource = $false } } } if (-not $resources) { throw 'No DSC resources found in the Node block.' } # Splitting logic $i = 1 $currentGroup = @() $currentSize = 0 $maxBytes = $MaxFileSizeMB * 1MB foreach ($res in $resources) { # Calculate size of the resource in bytes $resBytes = [System.Text.Encoding]::UTF8.GetByteCount($res) $resourceCountLimitReached = ($MaxResources -gt 0 -and $currentGroup.Count -ge $MaxResources) $sizeLimitReached = ($currentSize + $resBytes) -gt $maxBytes # Write current group if limits are reached if (($sizeLimitReached -or $resourceCountLimitReached) -and $currentGroup.Count -gt 0) { $outPath = Join-Path $OutputFolder ('M365TenantConfig_{0}.ps1' -f $i) $configText = $header + ($currentGroup -join "`n") + $footer Set-Content -Path $outPath -Value $configText -Encoding UTF8 -Force Write-M365DSCHost -Message "Created: $outPath" -CommitWrite $i++ $currentGroup = @() $currentSize = 0 } $currentGroup += $res $currentSize += $resBytes } # Write final group if ($currentGroup.Count -gt 0) { $outPath = Join-Path $OutputFolder ('M365TenantConfig_{0}.ps1' -f $i) $configText = $header + ($currentGroup -join "`n`n") + $footer Set-Content -Path $outPath -Value $configText -Encoding UTF8 -Force Write-M365DSCHost -Message "Created: $outPath" -CommitWrite } } <# .SYNOPSIS Normalizes authentication fields in exported resource results. .DESCRIPTION Transforms authentication-related properties into configuration-data references and returns updated results with no-escape property metadata. .PARAMETER ConnectionMode Specifies the authentication mode used for transformation rules. .PARAMETER Results Specifies exported resource values to normalize. .OUTPUTS System.Collections.Hashtable #> function Update-M365DSCExportAuthenticationResults { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = $true)] [ValidateSet('ServicePrincipalWithThumbprint', 'ServicePrincipalWithSecret', 'ServicePrincipalWithPath', 'CredentialsWithTenantId', 'CredentialsWithApplicationId', 'Credentials', 'ManagedIdentity', 'AccessTokens')] [System.String] $ConnectionMode, [Parameter(Mandatory = $true)] [System.Collections.Hashtable] $Results ) $noEscape = @() if ($Results.ContainsKey('ManagedIdentity') -and -not $Results.ManagedIdentity) { $Results.Remove('ManagedIdentity') } if ($ConnectionMode -in @('Credentials', 'CredentialsWithTenantId')) { $Results.Credential = '$CredsCredential' $noEscape += 'Credential' # Credentials mode removes TenantId; CredentialsWithTenantId keeps it. $keysToRemove = @('ApplicationId', 'ApplicationSecret', 'CertificateThumbprint', 'CertificatePath', 'CertificatePassword') if ($ConnectionMode -eq 'Credentials') { $keysToRemove += 'TenantId' } foreach ($key in $keysToRemove) { if ($Results.ContainsKey($key)) { $Results.Remove($key) | Out-Null } } } else { # Handle Credential based on CredentialsWithApplicationId mode if ($Results.ContainsKey('Credential')) { if ($ConnectionMode -eq 'CredentialsWithApplicationId') { $Results.Credential = '$CredsCredential' $noEscape += 'Credential' } else { $Results.Remove('Credential') | Out-Null } } # Keys that map to a simple ConfigurationData reference when non-empty $configDataKeys = @('ApplicationId', 'CertificateThumbprint', 'CertificatePath', 'TenantId') foreach ($key in $configDataKeys) { if (-not [System.String]::IsNullOrEmpty($Results.$key)) { $Results.$key = "`$ConfigurationData.NonNodeData.$key" $noEscape += $key } else { try { $Results.Remove($key) | Out-Null } catch { Write-Verbose -Message "Error removing $key from Update-M365DSCExportAuthenticationResults" } } } # ApplicationSecret gets a PSCredential wrapper if (-not [System.String]::IsNullOrEmpty($Results.ApplicationSecret)) { $Results.ApplicationSecret = "New-Object System.Management.Automation.PSCredential ('ApplicationSecret', (ConvertTo-SecureString `$ConfigurationData.NonNodeData.ApplicationSecret -AsPlainText -Force))" $noEscape += 'ApplicationSecret' } else { try { $Results.Remove('ApplicationSecret') | Out-Null } catch { Write-Verbose -Message 'Error removing ApplicationSecret from Update-M365DSCExportAuthenticationResults' } } # CertificatePassword gets resolved as credentials if ($null -ne $Results.CertificatePassword) { $Results.CertificatePassword = '$CredsCertificatePassword' $noEscape += 'CertificatePassword' } else { try { $Results.Remove('CertificatePassword') | Out-Null } catch { Write-Verbose -Message 'Error removing CertificatePassword from Update-M365DSCExportAuthenticationResults' } } if ($null -ne $Results.AccessTokens) { $Results.AccessTokens = "`$ConfigurationData.NonNodeData.AccessTokens" $noEscape += 'AccessTokens' } } return @{ Results = $Results NoEscape = $noEscape } } <# .SYNOPSIS Registers a discovered resource dependency during export. .DESCRIPTION Adds a source-target dependency record to the global export dependency collector. .PARAMETER SourceInstanceName Specifies the source resource instance name. .PARAMETER SourceResourceName Specifies the source resource type name. .PARAMETER TargetResourceType Specifies the target resource type name. .PARAMETER TargetKey Specifies the target key value used to resolve the dependency target instance. #> function Register-M365DSCExportDependency { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.String] $SourceInstanceName, [Parameter(Mandatory = $true)] [System.String] $SourceResourceName, [Parameter(Mandatory = $true)] [System.String] $TargetResourceType, [Parameter(Mandatory = $true)] [System.String] $TargetKey ) $session = Get-M365DSCExportRelationSession if ($null -ne $session) { $session.RegisterDependency($SourceInstanceName, $SourceResourceName, $TargetResourceType, $TargetKey) } } <# .SYNOPSIS Removes JavaScript-style comments from a JSON document. .DESCRIPTION The relation templates are annotated with // and /* */ comments. Windows PowerShell's ConvertFrom-Json rejects those, so they are stripped before parsing on that edition. The scanner tracks string literals so that a comment marker appearing inside a value is left untouched. This exists only to keep Windows PowerShell working. Delete it, and its caller in Get-M365DSCRelationIndex, once the module requires PowerShell 7. .PARAMETER Json Specifies the raw JSON document. .OUTPUTS System.String .FUNCTIONALITY Internal #> function Remove-M365DSCJsonComment { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [System.String] $Json ) $builder = [System.Text.StringBuilder]::new($Json.Length) $inString = $false $escaped = $false $index = 0 while ($index -lt $Json.Length) { $character = $Json[$index] if ($inString) { [void]$builder.Append($character) if ($escaped) { $escaped = $false } elseif ($character -eq '\') { $escaped = $true } elseif ($character -eq '"') { $inString = $false } $index++ continue } if ($character -eq '"') { $inString = $true [void]$builder.Append($character) $index++ continue } if ($character -eq '/' -and ($index + 1) -lt $Json.Length) { $next = $Json[$index + 1] if ($next -eq '/') { while ($index -lt $Json.Length -and $Json[$index] -ne "`n") { $index++ } continue } if ($next -eq '*') { $index += 2 while (($index + 1) -lt $Json.Length -and -not ($Json[$index] -eq '*' -and $Json[$index + 1] -eq '/')) { $index++ } $index += 2 continue } } [void]$builder.Append($character) $index++ } return $builder.ToString() } <# .SYNOPSIS Expands relation entries, resolving any template references they contain. .DESCRIPTION A relation may be a $ref pointing at another template rather than a relation of its own. This returns a flat list with every reference replaced by the relations it names. .PARAMETER Relations Specifies the relation entries to expand. .PARAMETER Templates Specifies all templates, used to look references up. .PARAMETER Visited Specifies the template names already being expanded, used to stop reference cycles. .OUTPUTS System.Collections.Generic.List[System.Object] .FUNCTIONALITY Internal #> function Expand-M365DSCRelationTemplate { [CmdletBinding()] [OutputType([System.Collections.Generic.List[System.Object]])] param ( [Parameter(Mandatory = $true)] [AllowNull()] [System.Object] $Relations, [Parameter(Mandatory = $true)] [System.Object] $Templates, [Parameter()] [System.Collections.Generic.HashSet[System.String]] $Visited ) if ($null -eq $Visited) { $Visited = [System.Collections.Generic.HashSet[System.String]]::new([System.StringComparer]::OrdinalIgnoreCase) } $expanded = [System.Collections.Generic.List[System.Object]]::new() foreach ($relation in $Relations) { $reference = $relation.'$ref' if ([System.String]::IsNullOrEmpty($reference)) { $expanded.Add($relation) continue } $referencedName = $reference.Split('/')[-1] if (-not $Visited.Add($referencedName)) { Write-Verbose -Message "Skipping circular relation template reference '$referencedName'." continue } $referenced = Expand-M365DSCRelationTemplate -Relations $Templates.$referencedName.relations ` -Templates $Templates ` -Visited $Visited $expanded.AddRange($referenced) [void]$Visited.Remove($referencedName) } return , $expanded } <# .SYNOPSIS Returns the relation index, building it on first use. .DESCRIPTION Parses M365DSCRelationTemplates.json and inverts it into a lookup keyed by resource name. The templates list the resources each relation applies to, so without this inversion every exported instance would have to scan every template. Parsing stays in PowerShell deliberately: handing the parsed schema to the relation assembly keeps that assembly free of a JSON dependency, and therefore free of assembly version conflicts with other modules loaded in the same session. .OUTPUTS Microsoft365DSC.Relations.RelationIndex .FUNCTIONALITY Internal #> function Get-M365DSCRelationIndex { [CmdletBinding()] # Quoted so the attribute does not force the type to resolve while the module is being # parsed, which happens before the assemblies are loaded. [OutputType('Microsoft365DSC.Relations.RelationIndex')] param() if ($null -ne $Script:M365DSCRelationIndex) { return $Script:M365DSCRelationIndex } Initialize-M365DSCDllLoader -ErrorAction Stop $templatesPath = Join-Path -Path $PSScriptRoot -ChildPath 'M365DSCRelationTemplates.json' $rawTemplates = Get-Content -Path $templatesPath -Raw if ($PSVersionTable.PSEdition -eq 'Desktop') { $rawTemplates = Remove-M365DSCJsonComment -Json $rawTemplates } $templates = ($rawTemplates | ConvertFrom-Json).templates $builder = [Microsoft365DSC.Relations.RelationIndexBuilder]::new() foreach ($template in $templates.PSObject.Properties) { $relations = Expand-M365DSCRelationTemplate -Relations $template.Value.relations -Templates $templates foreach ($resourceName in $template.Value.resources) { foreach ($relation in $relations) { $builder.AddRelation( $resourceName, $relation.property, $relation.childProperty, $relation.targetResource, $relation.targetKeyProperty, $relation.condition) } } } $Script:M365DSCRelationIndex = $builder.Build() return $Script:M365DSCRelationIndex } <# .SYNOPSIS Creates the relation session used to collect dependencies during an export. .DESCRIPTION Returns a session that records exported instances, accumulates the references between them, and rewrites the finished configuration with DependsOn declarations. .OUTPUTS Microsoft365DSC.Relations.ExportRelationSession .FUNCTIONALITY Internal #> function New-M365DSCExportRelationSession { [CmdletBinding()] [OutputType('Microsoft365DSC.Relations.ExportRelationSession')] param() Initialize-M365DSCDllLoader -ErrorAction Stop $index = Get-M365DSCRelationIndex return [Microsoft365DSC.Relations.ExportRelationSession]::Start($index) } <# .SYNOPSIS Returns the relation session the running export is accumulating into. .DESCRIPTION The session lives on the relation assembly rather than in a variable. A parallel export runs its resources in a pool of runspaces, and PowerShell variables are not shared across runspaces, so a session held in one would be invisible to the workers that need to write to it. The assemblies are loaded once per process, so static state on them is visible everywhere. .OUTPUTS Microsoft365DSC.Relations.ExportRelationSession, or $null when the export was not asked for dependency tracking. .FUNCTIONALITY Internal #> function Get-M365DSCExportRelationSession { [CmdletBinding()] [OutputType('Microsoft365DSC.Relations.ExportRelationSession')] param() Initialize-M365DSCDllLoader -ErrorAction Stop return [Microsoft365DSC.Relations.ExportRelationSession]::Current } <# .SYNOPSIS Resolves relation templates into concrete export dependencies. .DESCRIPTION Evaluates configured relation templates for the resource instance and registers dependencies for referenced target resources. .PARAMETER ResourceName Specifies the source resource type name. .PARAMETER InstanceName Specifies the source resource instance name. .PARAMETER Results Specifies exported property values used to evaluate relation definitions. #> function Resolve-M365DSCExportRelations { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.String] $ResourceName, [Parameter(Mandatory = $true)] [System.String] $InstanceName, [Parameter(Mandatory = $true)] [System.Collections.Hashtable] $Results ) $session = Get-M365DSCExportRelationSession if ($null -eq $session) { return } $session.ResolveRelations($ResourceName, $InstanceName, $Results) } <# .SYNOPSIS Injects DependsOn statements into exported DSC content. .DESCRIPTION Resolves collected dependencies to exported instances, injects DependsOn arrays into source resource blocks, and generates minimal stub blocks for unresolved targets. .PARAMETER DSCContent Specifies the exported DSC content to enrich with dependency data. .OUTPUTS System.String #> function Add-M365DSCExportDependsOn { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.String] $DSCContent ) $session = Get-M365DSCExportRelationSession if ($null -eq $session -or $session.DependencyCount -eq 0) { return $DSCContent } $processedContent = $session.InjectDependsOn($DSCContent, (New-M365DSCStubBlockOption)) foreach ($warning in $session.Warnings) { Write-Verbose -Message $warning } return $processedContent } <# .SYNOPSIS Collects the inputs needed to render dependency stub blocks. .DESCRIPTION Gathers the mandatory properties of every resource and the authentication properties for the current connection mode, so the relation assembly can render stubs without calling back into the module while it rewrites the configuration. .OUTPUTS Microsoft365DSC.Relations.StubBlockOptions .FUNCTIONALITY Internal #> function New-M365DSCStubBlockOption { [CmdletBinding()] [OutputType('Microsoft365DSC.Relations.StubBlockOptions')] param() Initialize-M365DSCDllLoader -ErrorAction Stop # The type and any allowed values travel with each property, so a stub can be given a # placeholder of the right shape for every mandatory property instead of only for the # handful whose names happen to be recognised. $mandatoryProperties = @{} try { foreach ($resourceName in (Get-M365DSCAllResources)) { $definition = Get-M365DSCResourceDefinition -ResourceName $resourceName if ($null -eq $definition) { continue } $properties = @($definition.Properties.Where({ $_.IsMandatory }) | ForEach-Object { @{ Name = $_.Name PropertyType = $_.PropertyType Values = [System.String[]]@($_.Values) } }) if ($properties.Count -gt 0) { $mandatoryProperties[$resourceName] = $properties } } } catch { Write-Verbose -Message "Unable to load resource dictionary for stub generation: $_" } # ConnectionMode is only set once an export has authenticated; without that guard the # hashtable lookup below throws on a null key. $authenticationProperties = @() if (-not [System.String]::IsNullOrEmpty($Script:ConnectionMode)) { $authenticationProperties = $Script:M365DSCAuthenticationParameterSet.$($Script:ConnectionMode) } $options = [Microsoft365DSC.Relations.StubBlockOptions]::new() $options.MandatoryPropertiesByResource = $mandatoryProperties $options.AuthenticationProperties = [System.String[]]@($authenticationProperties) return $options } <# .SYNOPSIS Builds minimal DSC stub blocks for unresolved dependency targets. .DESCRIPTION Generates placeholder resource blocks with mandatory keys and authentication fields so unresolved dependency references can still compile. .PARAMETER UnresolvedTargets Specifies unresolved target definitions keyed by resource type and target key. .OUTPUTS System.String #> function Get-M365DSCMinimalExportBlocks { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.Collections.Hashtable] $UnresolvedTargets ) Initialize-M365DSCDllLoader -ErrorAction Stop return [Microsoft365DSC.Relations.DependsOnInjector]::RenderStubs($UnresolvedTargets.Values, (New-M365DSCStubBlockOption)) } <# .SYNOPSIS Maps each export collection cache key to the resources that consume it. .DESCRIPTION Returns the resource names whose Export() lists a collection through Get-M365DSCExportCachedCollection, keyed by collection. Used to register and release cache consumers during an export. .OUTPUTS System.Collections.Hashtable #> function Get-M365DSCExportCollectionConsumerMap { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param () return @{ deviceConfigurations = @( 'IntuneDeviceConfigurationCustomPolicyMacOS', 'IntuneDeviceConfigurationCustomPolicyWindows10', 'IntuneDeviceConfigurationCustomPolicyiOS', 'IntuneDeviceConfigurationDefenderOnboardingPolicyWindows10', 'IntuneDeviceConfigurationDomainJoinPolicyWindows10', 'IntuneDeviceConfigurationEmailProfilePolicyWindows10', 'IntuneDeviceConfigurationEndpointProtectionPolicyWindows10', 'IntuneDeviceConfigurationFirmwareInterfacePolicyWindows10', 'IntuneDeviceConfigurationHealthMonitoringPolicyWindows10', 'IntuneDeviceConfigurationIdentityProtectionPolicyWindows10', 'IntuneDeviceConfigurationImportedPfxCertificatePolicyWindows10', 'IntuneDeviceConfigurationKioskPolicyWindows10', 'IntuneDeviceConfigurationNetworkBoundaryPolicyWindows10', 'IntuneDeviceConfigurationPkcsCertificatePolicyWindows10', 'IntuneDeviceConfigurationPolicyAndroidDeviceOwner', 'IntuneDeviceConfigurationPolicyAndroidOpenSourceProject', 'IntuneDeviceConfigurationPolicyAndroidWorkProfile', 'IntuneDeviceConfigurationPolicyMacOS', 'IntuneDeviceConfigurationPolicyWindows10', 'IntuneDeviceConfigurationPolicyiOS', 'IntuneDeviceConfigurationSCEPCertificatePolicyWindows10', 'IntuneDeviceConfigurationSecureAssessmentPolicyWindows10', 'IntuneDeviceConfigurationSharedMultiDevicePolicyWindows10', 'IntuneDeviceConfigurationTrustedCertificatePolicyWindows10', 'IntuneDeviceConfigurationVpnPolicyWindows10', 'IntuneDeviceConfigurationWindowsTeamPolicyWindows10', 'IntuneDeviceConfigurationWiredNetworkPolicyMacOS', 'IntuneDeviceConfigurationWiredNetworkPolicyWindows10', 'IntuneDeviceFeaturesConfigurationPolicyIOS', 'IntuneDeviceFeaturesConfigurationPolicyMacOS', 'IntuneTrustedRootCertificateAndroidDeviceOwner', 'IntuneTrustedRootCertificateAndroidWork', 'IntuneTrustedRootCertificateIOS', 'IntuneVPNConfigurationPolicyAndroidDeviceOwner', 'IntuneVPNConfigurationPolicyAndroidWork', 'IntuneVPNConfigurationPolicyIOS', 'IntuneVPNConfigurationPolicyMacOS', 'IntuneWifiConfigurationPolicyAndroidEnterpriseDeviceOwner', 'IntuneWifiConfigurationPolicyAndroidEnterpriseWorkProfile', 'IntuneWifiConfigurationPolicyAndroidForWork', 'IntuneWifiConfigurationPolicyAndroidOpenSourceProject', 'IntuneWifiConfigurationPolicyIOS', 'IntuneWifiConfigurationPolicyMacOS', 'IntuneWifiConfigurationPolicyWindows10', 'IntuneWindowsUpdateForBusinessRingUpdateProfileWindows10' ) deviceCompliancePolicies = @( 'IntuneDeviceCompliancePolicyAndroidDeviceOwner', 'IntuneDeviceCompliancePolicyAndroidWorkProfile', 'IntuneDeviceCompliancePolicyMacOS', 'IntuneDeviceCompliancePolicyWindows10', 'IntuneDeviceCompliancePolicyiOs' ) deviceEnrollmentConfigurations = @( 'IntuneDeviceEnrollmentLimitRestriction', 'IntuneDeviceEnrollmentPlatformRestriction', 'IntuneDeviceEnrollmentStatusPageWindows10', 'IntuneWindowsBackupForOrganizationConfiguration', 'IntuneWindowsHelloForBusinessGlobalPolicy' ) exoMailboxes = @( 'EXOCalendarProcessing', 'EXOFocusedInbox', 'EXOMailboxAutoReplyConfiguration', 'EXOMailboxCalendarConfiguration', 'EXOMailboxCalendarFolder', 'EXOMailboxIRMAccess', 'EXOMailboxPermission', 'EXOMailboxSettings', 'EXOSweepRule' ) exoUsers = @( 'EXOCalendarProcessing', 'EXOMailboxPermission', 'EXORecipientPermission' ) pimGroups = @( 'AADGroupEligibilitySchedule', 'AADGroupEligibilityScheduleSettings', 'AADPIMGroupSetting' ) reusablePolicySettings = @( 'IntuneDeviceComplianceScriptLinux', 'IntuneDeviceControlPolicySetting', 'IntuneEpmCertificatePolicySetting', 'IntuneFirewallPolicySetting' ) } } <# .SYNOPSIS Clears and enables the export collection cache for a new export session. .DESCRIPTION Resets the process-wide export collection cache and enables it so that resources exported in this session share one download per Graph collection. #> function Initialize-M365DSCExportCollectionCache { [CmdletBinding()] param () [Microsoft365DSC.Cache.ExportCollectionCache]::Reset() [Microsoft365DSC.Intune.IntuneGroupCache]::Reset() [Microsoft365DSC.Intune.SettingTemplateCache]::Reset() [Microsoft365DSC.Intune.RoleScopeTagCache]::Reset() Clear-M365DSCIntuneAssignmentFilterCache [Microsoft365DSC.Cache.ExportCollectionCache]::Enable() } <# .SYNOPSIS Clears and disables the export collection cache. .DESCRIPTION Releases every cached collection and disables the cache so that resources use live Graph requests. #> function Reset-M365DSCExportCollectionCache { [CmdletBinding()] param () Clear-M365DSCIntuneAssignmentFilterCache if ($null -ne ('Microsoft365DSC.Cache.ExportCollectionCache' -as [System.Type])) { [Microsoft365DSC.Cache.ExportCollectionCache]::Reset() [Microsoft365DSC.Intune.IntuneGroupCache]::Reset() [Microsoft365DSC.Intune.SettingTemplateCache]::Reset() [Microsoft365DSC.Intune.RoleScopeTagCache]::Reset() [Microsoft365DSC.Intune.ConfigurationPolicyCache]::Reset() } } <# .SYNOPSIS Registers how many exported resources consume each cached collection. .DESCRIPTION Counts the resources in the export selection per collection key and registers those counts with the export collection cache so that a collection is released after its last consumer. .PARAMETER ResourceNames Specifies the names of the resources selected for the export. #> function Register-M365DSCExportCollectionConsumers { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [System.String[]] $ResourceNames ) $map = Get-M365DSCExportCollectionConsumerMap foreach ($key in $map.Keys) { $count = @($map[$key] | Where-Object -FilterScript { $ResourceNames -contains $_ }).Count if ($count -gt 0) { [Microsoft365DSC.Cache.ExportCollectionCache]::RegisterConsumers($key, $count) } } } <# .SYNOPSIS Releases the cached collections consumed by a resource once its export has completed. .DESCRIPTION Decrements the consumer count of every collection the resource consumes. The cache frees a collection when its count reaches zero. .PARAMETER ResourceName Specifies the name of the resource whose export has completed. #> function Complete-M365DSCExportCollectionConsumer { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.String] $ResourceName ) if ($null -eq ('Microsoft365DSC.Cache.ExportCollectionCache' -as [System.Type])) { return } $map = Get-M365DSCExportCollectionConsumerMap foreach ($key in $map.Keys) { if ($map[$key] -contains $ResourceName) { $null = [Microsoft365DSC.Cache.ExportCollectionCache]::Release($key) } } } <# .SYNOPSIS Returns the items of an Intune collection, served from the export collection cache when possible. .DESCRIPTION Lists a Graph collection once per export and filters the cached items client-side on '@odata.type'. Performs the resource's live filtered request when the cache is disabled or when a user filter is supplied. .PARAMETER Collection Specifies the Graph collection to list. .PARAMETER ODataType Specifies the OData types to return, written like the isof() argument (for example 'microsoft.graph.windowsKioskConfiguration'). Returns every type when omitted. .PARAMETER ExcludeODataType Specifies the OData types to exclude from the result. .PARAMETER PropertyName Specifies the top-level property to match, for collections whose consumers differ by a property rather than by OData type. Applied client-side on the cached items and server-side otherwise. .PARAMETER PropertyValue Specifies the values the property must match. Returns every item when omitted. .PARAMETER Filter Specifies the user-supplied OData filter. A non-empty filter bypasses the cache. .OUTPUTS System.Object[] #> function Get-M365DSCExportCachedCollection { [CmdletBinding()] [OutputType([System.Object[]])] param ( [Parameter(Mandatory = $true)] [ValidateSet('deviceConfigurations', 'deviceCompliancePolicies', 'deviceEnrollmentConfigurations', 'reusablePolicySettings', 'exoMailboxes', 'exoUsers', 'pimGroups')] [System.String] $Collection, [Parameter()] [System.String[]] $ODataType = @(), [Parameter()] [System.String[]] $ExcludeODataType = @(), [Parameter()] [System.String] $PropertyName, [Parameter()] [System.String[]] $PropertyValue = @(), [Parameter()] [System.String] $Filter ) $descriptors = @{ deviceConfigurations = @{ Expand = @('assignments'); ServerSideTypeFilter = $true } deviceCompliancePolicies = @{ Expand = @('scheduledActionsForRule($expand=scheduledActionConfigurations)', 'assignments'); ServerSideTypeFilter = $true } deviceEnrollmentConfigurations = @{ Expand = @('assignments'); ServerSideTypeFilter = $false } reusablePolicySettings = @{ Expand = @(); ServerSideTypeFilter = $false; Select = @('id', 'displayName', 'description', 'settingDefinitionId', 'settingInstance') } exoMailboxes = @{ Fetch = { Get-Mailbox -ResultSize 'Unlimited' -ErrorAction Stop } } exoUsers = @{ Fetch = { Get-User -ResultSize 'Unlimited' } } pimGroups = @{ Fetch = { Get-M365DSCPIMEnabledGroup } } } $descriptor = $descriptors[$Collection] $cacheAvailable = $null -ne ('Microsoft365DSC.Cache.ExportCollectionCache' -as [System.Type]) -and [Microsoft365DSC.Cache.ExportCollectionCache]::IsEnabled if ($null -ne $descriptor -and $null -ne $descriptor.Fetch) { if (-not $cacheAvailable) { return , [System.Object[]]@(& $descriptor.Fetch) } $items = $null if ([Microsoft365DSC.Cache.ExportCollectionCache]::TryGet($Collection, [ref] $items)) { return , [System.Object[]]@($items) } $fetched = @(& $descriptor.Fetch) $null = [Microsoft365DSC.Cache.ExportCollectionCache]::TrySet($Collection, [System.Object[]]$fetched) return , [System.Object[]]$fetched } if ($cacheAvailable -and [System.String]::IsNullOrEmpty($Filter)) { $cached = [Microsoft365DSC.Cache.ExportCollectionCache]::GetByODataType($Collection, [System.String[]]$ODataType, [System.String[]]$ExcludeODataType) if ($null -eq $cached) { $all = Invoke-M365DSCExportCollectionList -Collection $Collection -ExpandProperty $descriptor.Expand -Property $descriptor.Select $null = [Microsoft365DSC.Cache.ExportCollectionCache]::TrySet($Collection, [System.Object[]]$all) $cached = [Microsoft365DSC.Cache.ExportCollectionCache]::FilterByODataType([System.Object[]]$all, [System.String[]]$ODataType, [System.String[]]$ExcludeODataType) } return , [System.Object[]][Microsoft365DSC.Cache.ExportCollectionCache]::FilterByProperty([System.Object[]]$cached, $PropertyName, [System.String[]]$PropertyValue) } $typeFilter = '' if ($descriptor.ServerSideTypeFilter -and $ODataType.Count -gt 0) { $typeFilter = ($ODataType | ForEach-Object -Process { "isof('$_')" }) -join ' or ' if ($ODataType.Count -gt 1) { $typeFilter = "($typeFilter)" } foreach ($excluded in $ExcludeODataType) { $typeFilter += " and not isof('$excluded')" } } if (-not [System.String]::IsNullOrEmpty($PropertyName) -and $PropertyValue.Count -gt 0) { $propertyFilter = ($PropertyValue | ForEach-Object -Process { "$PropertyName eq '$_'" }) -join ' or ' if ($PropertyValue.Count -gt 1) { $propertyFilter = "($propertyFilter)" } $typeFilter = if ([System.String]::IsNullOrEmpty($typeFilter)) { $propertyFilter } else { "($typeFilter) and ($propertyFilter)" } } $mergedFilter = $typeFilter if (-not [System.String]::IsNullOrEmpty($Filter)) { $mergedFilter = if ([System.String]::IsNullOrEmpty($typeFilter)) { $Filter } else { "($typeFilter) and ($Filter)" } } $items = Invoke-M365DSCExportCollectionList -Collection $Collection -ExpandProperty $descriptor.Expand -Property $descriptor.Select -Filter $mergedFilter if (-not $descriptor.ServerSideTypeFilter -and $null -ne ('Microsoft365DSC.Cache.ExportCollectionCache' -as [System.Type])) { $items = [Microsoft365DSC.Cache.ExportCollectionCache]::FilterByODataType([System.Object[]]$items, [System.String[]]$ODataType, [System.String[]]$ExcludeODataType) } elseif (-not $descriptor.ServerSideTypeFilter -and $ODataType.Count -gt 0) { $wanted = @($ODataType | ForEach-Object -Process { $_.TrimStart('#') }) $items = @($items | Where-Object -FilterScript { $wanted -contains ([System.String]$_.'@odata.type').TrimStart('#') }) } return , [System.Object[]]$items } <# .SYNOPSIS Lists the groups enabled in Privileged Identity Management (PIM) for Groups. .DESCRIPTION Reads the group resources of PIM for Groups and resolves their display names in one batch. A group is listed once a PIM operation, such as a policy change or an eligible assignment, has enabled it. .OUTPUTS System.Object[] #> function Get-M365DSCPIMEnabledGroup { [CmdletBinding()] [OutputType([System.Object[]])] param () $resources = Get-M365DSCRawGraphCollection -Uri '/beta/identityGovernance/privilegedAccess/group/resources' -Property 'id' $requests = foreach ($resource in $resources) { @{ id = $resource.id method = 'GET' url = "/groups/$($resource.id)?`$select=id,displayName" } } foreach ($response in (Invoke-M365DSCGraphBatchRequest -Requests @($requests) -AsList)) { if ($response.status -eq 200) { [PSCustomObject]@{ Id = $response.body.id DisplayName = $response.body.displayName } } } } <# .SYNOPSIS Lists a Graph collection as raw JSON items, following the paging links. .DESCRIPTION Calls Invoke-MgGraphRequest on the collection and follows '@odata.nextLink' until every page has been read. Returns the items in the shape Graph sends them rather than in the SDK object model. .PARAMETER Uri Specifies the collection URI. .PARAMETER Property Specifies the properties to select. Required for properties the collection omits by default. .PARAMETER Filter Specifies the OData filter to apply. .OUTPUTS System.Object[] #> function Get-M365DSCRawGraphCollection { [CmdletBinding()] [OutputType([System.Object[]])] param ( [Parameter(Mandatory = $true)] [System.String] $Uri, [Parameter()] [System.String[]] $Property, [Parameter()] [System.String] $Filter ) $query = [System.Collections.Generic.List[System.String]]::new() if ($Property.Count -gt 0) { $query.Add('$select=' + ($Property -join ',')) } if (-not [System.String]::IsNullOrEmpty($Filter)) { $query.Add('$filter=' + $Filter) } $requestUri = $Uri if ($query.Count -gt 0) { $requestUri = $Uri + '?' + ($query -join '&') } $items = [System.Collections.Generic.List[System.Object]]::new() while (-not [System.String]::IsNullOrEmpty($requestUri)) { $response = Invoke-MgGraphRequest -Method GET -Uri $requestUri -ErrorAction Stop foreach ($item in $response.value) { if ($null -ne $item) { $items.Add($item) } } $requestUri = $response.'@odata.nextLink' } return , $items.ToArray() } <# .SYNOPSIS Lists a Graph collection with the given expand and filter. .DESCRIPTION Calls the collection's Get cmdlet with -All and returns the items as an array without null entries. .PARAMETER Collection Specifies the Graph collection to list. .PARAMETER ExpandProperty Specifies the navigation properties to expand. .PARAMETER Property Specifies the properties to select. Required for properties the collection omits by default. .PARAMETER Filter Specifies the OData filter to apply. .OUTPUTS System.Object[] #> function Invoke-M365DSCExportCollectionList { [CmdletBinding()] [OutputType([System.Object[]])] param ( [Parameter(Mandatory = $true)] [System.String] $Collection, [Parameter()] [System.String[]] $ExpandProperty, [Parameter()] [System.String[]] $Property, [Parameter()] [System.String] $Filter ) $params = @{ All = $true; ErrorAction = 'Stop' } if ($ExpandProperty.Count -gt 0) { $params.ExpandProperty = $ExpandProperty } if ($Property.Count -gt 0) { $params.Property = $Property } if (-not [System.String]::IsNullOrEmpty($Filter)) { $params.Filter = $Filter } $result = switch ($Collection) { 'deviceConfigurations' { Get-MgBetaDeviceManagementDeviceConfiguration @params } 'deviceCompliancePolicies' { Get-MgBetaDeviceManagementDeviceCompliancePolicy @params } 'deviceEnrollmentConfigurations' { Get-MgBetaDeviceManagementDeviceEnrollmentConfiguration @params } 'reusablePolicySettings' { Get-M365DSCRawGraphCollection -Uri '/beta/deviceManagement/reusablePolicySettings' -Property $Property -Filter $Filter } } $items = [System.Collections.Generic.List[System.Object]]::new() foreach ($item in $result) { if ($null -ne $item) { $items.Add($item) } } return , $items.ToArray() } Export-ModuleMember -Function @( 'Get-M365DSCExportCollectionConsumerMap', 'Initialize-M365DSCExportCollectionCache', 'Reset-M365DSCExportCollectionCache', 'Register-M365DSCExportCollectionConsumers', 'Complete-M365DSCExportCollectionConsumer', 'Get-M365DSCExportCachedCollection', 'Get-M365DSCPIMEnabledGroup', 'Get-M365DSCRawGraphCollection', 'Invoke-M365DSCExportCollectionList', 'Export-M365DSCConfiguration', 'Get-M365DSCExportContentForResource', 'Get-M365DSCResourcesByExportMode', 'Join-M365DSCConfiguration', 'Split-M365DSCConfiguration', 'Set-M365DSCStringReplacementMap', 'Get-M365DSCStringReplacementMap', 'Update-M365DSCExportAuthenticationResults', 'Register-M365DSCExportDependency', 'Resolve-M365DSCExportRelations', 'Add-M365DSCExportDependsOn', 'Get-M365DSCMinimalExportBlocks' ) |