Modules/M365DSCReport.psm1

<#
.Description
This function creates a new HTML document from the specified exported configuration
 
.Functionality
Internal, Hidden
#>

function New-M365DSCConfigurationToHTML
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter()]
        [Array]
        $ParsedContent,

        [Parameter()]
        [System.String]
        $OutputPath,

        [Parameter()]
        [System.String]
        $TemplateName,

        [Parameter()]
        [Switch]
        $SortProperties
    )

    if ([System.String]::IsNullOrEmpty($TemplateName))
    {
        $TemplateName = 'Configuration Report'
    }

    Write-Output 'Generating HTML report'
    $fullHTML = '<!DOCTYPE html>'
    $fullHTML += '<html>'
    $fullHTML += '<body>'
    $fullHTML += '<h1>' + $TemplateName + '</h1>'
    $fullHTML += "<div style='width:100%;text-align:center;'>"
    $fullHTML += '<h2>Template Details</h2>'

    $totalCount = $parsedContent.Count
    $currentCount = 0
    foreach ($resource in $parsedContent)
    {
        $percentage = [math]::Round(($currentCount / $totalCount) * 100, 2)
        Write-Progress -Activity 'Processing generated DSC Object' -Status ("{0:N2} completed - $($resource.ResourceName)" -f $percentage) -PercentComplete $percentage

        $partHTML = "<div width='100%' style='text-align:center;'><table width='80%' style='margin-left:auto; margin-right:auto;'>"
        $partHTML += "<tr><th rowspan='" + ($resource.Keys.Count) + "' width='20%'>"
        try
        {
            $partHTML += "<img src='" + (Get-IconPath -ResourceName $resource.ResourceName) + "' />"
        }
        catch
        {
            Write-Verbose -Message $_
        }
        $partHTML += '</th>'

        $partHTML += "<th colspan='2' style='background-color:silver;text-align:center;' width='80%'>"
        $partHTML += '<strong>' + $resource.ResourceName + '</strong>'
        $partHTML += '</th></tr>'

        if ($SortProperties)
        {
            $properties = $resource.Keys | Sort-Object
        }
        else
        {
            $properties = $resource.Keys
        }

        foreach ($property in $properties)
        {
            if ($property -ne 'ResourceName' -and $property -ne 'Credential')
            {
                $partHTML += "<tr><td width='40%' style='padding:5px;text-align:right;border:1px solid black;'><strong>" + $property + '</strong></td>'
                $value = "`$Null"
                if ($null -ne $resource.$property)
                {
                    if ($resource.$property.GetType().Name -eq 'Object[]')
                    {
                        if ($resource.$property -and ($resource.$property[0].GetType().Name -eq 'Hashtable' -or
                                $resource.$property[0].GetType().Name -eq 'OrderedDictionary'))
                        {
                            $value = ''
                            foreach ($entry in $resource.$property)
                            {
                                foreach ($key in $entry.Keys)
                                {
                                    $value += "<li>$key = $($entry.$key)</li>"
                                }
                                $value += '<hr />'
                            }
                        }
                        else
                        {
                            $temp = $resource.$property -join ','
                            [array]$components = $temp.Split(',')
                            if ($components.Length -gt 0 -and
                                -not [System.String]::IsNullOrEmpty($temp))
                            {
                                $Value = '<ul>'
                                foreach ($comp in $components)
                                {
                                    $value += "<li>$comp</li>"
                                }
                                $value += '</ul>'
                            }
                        }
                    }
                    else
                    {
                        if (-not [System.String]::IsNullOrEmpty($resource.$property))
                        {
                            $value = ($resource.$property).ToString()
                        }
                    }
                }
                $partHTML += "<td width='40%' style='padding:5px;border:1px solid black;'>" + $value + '</td></tr>'
            }
        }

        $partHTML += '</table></div><br />'
        $fullHTML += $partHTML
        $fullHTML += '</body>'
        $fullHTML += '</html>'

        $currentCount++
    }

    if (-not [System.String]::IsNullOrEmpty($OutputPath))
    {
        Write-Output 'Saving HTML report'
        $fullHtml | Out-File $OutputPath
    }

    Write-Output 'Completed generating HTML report'
}

<#
.Description
This function creates a new JSON file from the specified exported configuration
 
.Functionality
Internal, Hidden
#>

function New-M365DSCConfigurationToJSON
{
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [Array]
        $ParsedContent,

        [Parameter(Mandatory = $true)]
        [System.String]
        $OutputPath
    )

    $jsonContent = $ParsedContent | ConvertTo-Json -Depth 25
    $jsonContent | Out-File -FilePath $OutputPath
}


<#
.Description
This function gets the URL to the logo of the workload of the specified resource
 
.Functionality
Internal, Hidden
#>

function Get-IconPath
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ResourceName
    )

    if ($ResourceName.StartsWith('AAD'))
    {
        return 'http://microsoft365dsc.com/Images/AzureAD.jpg'
    }
    elseif ($ResourceName.StartsWith('EXO'))
    {
        return 'http://microsoft365dsc.com/Images/Exchange.jpg'
    }
    elseif ($ResourceName.StartsWith('O365'))
    {
        return 'http://microsoft365dsc.com/Images/Office365.jpg'
    }
    elseif ($ResourceName.StartsWith('OD'))
    {
        return 'http://microsoft365dsc.com/Images/OneDrive.jpg'
    }
    elseif ($ResourceName.StartsWith('PP'))
    {
        return 'http://microsoft365dsc.com/Images/PowerApps.jpg'
    }
    elseif ($ResourceName.StartsWith('SC'))
    {
        return 'http://microsoft365dsc.com/Images/SecurityAndCompliance.png'
    }
    elseif ($ResourceName.StartsWith('SPO'))
    {
        return 'http://microsoft365dsc.com/Images/SharePoint.jpg'
    }
    elseif ($ResourceName.StartsWith('Teams'))
    {
        return 'http://microsoft365dsc.com/Images/Teams.jpg'
    }
    return $null
}

<#
.Description
This function creates a new Excel document from the specified exported configuration
 
.Functionality
Internal, Hidden
#>

function New-M365DSCConfigurationToExcel
{
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [Array]
        $ParsedContent,

        [Parameter(Mandatory = $true)]
        [System.String]
        $OutputPath
    )

    $excel = New-Object -ComObject excel.application
    $excel.visible = $True
    $workbook = $excel.Workbooks.Add()
    $report = $workbook.Worksheets.Item(1)
    $report.Name = 'Report'
    $report.Cells.Item(1, 1) = 'Component Name'
    $report.Cells.Item(1, 1).Font.Size = 18
    $report.Cells.Item(1, 1).Font.Bold = $True
    $report.Cells.Item(1, 2) = 'Property'
    $report.Cells.Item(1, 2).Font.Size = 18
    $report.Cells.Item(1, 2).Font.Bold = $True
    $report.Cells.Item(1, 3) = 'Value'
    $report.Cells.Item(1, 3).Font.Size = 18
    $report.Cells.Item(1, 3).Font.Bold = $True
    $report.Range('A1:C1').Borders.Weight = -4138
    $row = 2

    foreach ($resource in $parsedContent)
    {
        $beginRow = $row
        foreach ($property in $resource.Keys)
        {
            if ($property -ne 'ResourceName' -and $property -ne 'Credential')
            {
                $report.Cells.Item($row, 1) = $resource.ResourceName
                $report.Cells.Item($row, 2) = $property
                try
                {
                    if ([System.String]::IsNullOrEmpty($resource.$property))
                    {
                        $report.Cells.Item($row, 3) = "`$Null"
                    }
                    else
                    {
                        if ($resource.$property.GetType().Name -eq 'Object[]')
                        {
                            $value = $resource.$property -join ','
                            $report.Cells.Item($row, 3) = $value
                        }
                        else
                        {
                            $value = ($resource.$property).ToString().Replace('$', '')
                            $value = $value.Replace('@', '')
                            $value = $value.Replace('(', '')
                            $value = $value.Replace(')', '')
                            $report.Cells.Item($row, 3) = $value
                        }
                    }

                    $report.Cells.Item($row, 3).HorizontalAlignment = -4131
                }
                catch
                {
                    New-M365DSCLogEntry -Message 'Error during conversion to Excel:' `
                        -Exception $_ `
                        -Source $($MyInvocation.MyCommand.Source) `
                        -TenantId $TenantId `
                        -Credential $Credential
                }

                if ($property -in @('Identity', 'Name', 'IsSingleInstance', 'DisplayName'))
                {
                    $OriginPropertyName = $report.Cells.Item($beginRow, 2).Text
                    $OriginPropertyValue = $report.Cells.Item($beginRow, 3).Text
                    $CurrentPropertyName = $report.Cells.Item($row, 2).Text
                    $CurrentPropertyValue = $report.Cells.Item($row, 3).Text

                    $report.Cells.Item($beginRow, 2) = $CurrentPropertyName
                    $report.Cells.Item($beginRow, 3) = $CurrentPropertyValue
                    $report.Cells.Item($row, 2) = $OriginPropertyName
                    $report.Cells.Item($row, 3) = $OriginPropertyValue

                    $report.Cells($beginRow, 1).Font.ColorIndex = 10
                    $report.Cells($beginRow, 2).Font.ColorIndex = 10
                    $report.Cells($beginRow, 3).Font.ColorIndex = 10
                    $report.Cells($beginRow, 1).Font.Bold = $true
                    $report.Cells($beginRow, 2).Font.Bold = $true
                    $report.Cells($beginRow, 3).Font.Bold = $true
                }
                $row++
            }
        }
        $rangeValue = "A$beginRow" + ':' + "C$row"
        $report.Range($rangeValue).Borders[8].Weight = -4138
    }
    $usedRange = $report.UsedRange
    $usedRange.EntireColumn.AutoFit() | Out-Null
    $workbook.SaveAs($OutputPath)
    $excel.Quit()
}

<#
.Description
This function creates a report from the specified exported configuration,
either in HTML or Excel format
 
.Parameter Type
The type of report that should be created: Excel or HTML.
 
.Parameter ConfigurationPath
The path to the exported DSC configuration that the report should be created for.
 
.Parameter OutputPath
The output path of the report.
 
.Example
New-M365DSCReportFromConfiguration -Type 'HTML' -ConfigurationPath 'C:\DSC\ConfigName.ps1' -OutputPath 'C:\Dsc\M365Report.html'
 
.Example
New-M365DSCReportFromConfiguration -Type 'Excel' -ConfigurationPath 'C:\DSC\ConfigName.ps1' -OutputPath 'C:\Dsc\M365Report.xlsx'
 
.Example
New-M365DSCReportFromConfiguration -Type 'JSON' -ConfigurationPath 'C:\DSC\ConfigName.ps1' -OutputPath 'C:\Dsc\M365Report.json'
 
.Functionality
Public
#>

function New-M365DSCReportFromConfiguration
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [ValidateSet('Excel', 'HTML', 'JSON')]
        [System.String]
        $Type,

        [Parameter(Mandatory = $true)]
        [System.String]
        $ConfigurationPath,

        [Parameter(Mandatory = $true)]
        [System.String]
        $OutputPath
    )

    # Validate that the latest version of the module is installed.
    Test-M365DSCModuleValidity

    #Ensure the proper dependencies are installed in the current environment.
    Confirm-M365DSCDependencies

    #region Telemetry
    $data = [System.Collections.Generic.Dictionary[[String], [String]]]::new()
    $data.Add('Event', 'Report')
    $data.Add('Type', $Type)
    Add-M365DSCTelemetryEvent -Data $data
    #endregion

    [Array] $parsedContent = Initialize-M365DSCReporting -ConfigurationPath $ConfigurationPath

    switch ($Type)
    {
        'Excel'
        {
            New-M365DSCConfigurationToExcel -ParsedContent $parsedContent -OutputPath $OutputPath
        }
        'HTML'
        {
            $template = Get-Item $ConfigurationPath
            $templateName = $Template.Name.Split('.')[0]
            New-M365DSCConfigurationToHTML -ParsedContent $parsedContent -OutputPath $OutputPath -TemplateName $templateName
        }
        'JSON'
        {
            New-M365DSCConfigurationToJSON -ParsedContent $parsedContent -OutputPath $OutputPath
        }
    }
}

<#
.Description
This function compares two provided DSC configuration to determine the delta.
 
.Parameter Source
Local path of the source configuration.
 
.Parameter Destination
Local path of the destination configuraton.
 
.Parameter SourceObject
Array that contains the list of configuration components for the source.
 
.Parameter DestinationObject
Array that contains the list of configuration components for the destination.
 
.Parameter ExcludedProperties
Array that contains the list of parameters to exclude.
 
.Example
Compare-M365DSCConfigurations -Source 'C:\DSC\source.ps1' -Destination 'C:\DSC\destination.ps1'
 
.Functionality
Public
#>

function Compare-M365DSCConfigurations
{
    [CmdletBinding()]
    [OutputType([System.Array])]
    param
    (
        [Parameter()]
        [System.String]
        $Source,

        [Parameter()]
        [System.String]
        $Destination,

        [Parameter()]
        [System.Boolean]
        $CaptureTelemetry = $true,

        [Parameter()]
        [Array]
        $SourceObject,

        [Parameter()]
        [Array]
        $DestinationObject,

        [Parameter()]
        [Array]
        $ExcludedProperties
    )

    if ($CaptureTelemetry)
    {
        #Ensure the proper dependencies are installed in the current environment.
        Confirm-M365DSCDependencies

        #region Telemetry
        $data = [System.Collections.Generic.Dictionary[[String], [String]]]::new()
        $data.Add('Event', 'Compare')
        Add-M365DSCTelemetryEvent -Data $data
        #endregion
    }

    [Array] $Delta = @()

    if (-not $SourceObject)
    {
        [Array] $SourceObject = Initialize-M365DSCReporting -ConfigurationPath $Source
    }
    if (-not $DestinationObject)
    {
        [Array] $DestinationObject = Initialize-M365DSCReporting -ConfigurationPath $Destination
    }

    # Loop through all items in the source array
    $i = 1
    foreach ($sourceResource in $SourceObject)
    {
        try
        {
            [array]$key = Get-M365DSCResourceKey -Resource $sourceResource
            Write-Progress -Activity "Scanning Source $Source...[$i/$($SourceObject.Count)]" -PercentComplete ($i / ($SourceObject.Count) * 100)
            [array]$destinationResource = $DestinationObject | Where-Object -FilterScript { $_.ResourceName -eq $sourceResource.ResourceName -and $_.($key[0]) -eq $sourceResource.($key[0]) }

            $keyname = $key[0..1] -join '\'
            $SourceKeyValue = $sourceResource.($key[0])
            # Filter on the second key
            if ($key.Count -gt 1)
            {
                [array]$destinationResource = $destinationResource | Where-Object -FilterScript { $_.ResourceName -eq $sourceResource.ResourceName -and $_.($key[1]) -eq $sourceResource.($key[1]) }
                $SourceKeyValue = $sourceResource.($key[0]), $sourceResource.($key[1]) -join '\'
            }
            if ($null -eq $destinationResource)
            {
                $drift = @{
                    ResourceName = $sourceResource.ResourceName
                    Key          = $keyName
                    KeyValue     = $SourceKeyValue
                    Properties   = @(@{
                            ParameterName      = '_IsInConfiguration_'
                            ValueInSource      = 'Present'
                            ValueInDestination = 'Absent'
                        })
                }
                $Delta += , $drift
                $drift = $null
            }
            else
            {
                $filteredProperties = @(
                    'ResourceName',
                    'ResourceId',
                    'Credential',
                    'CertificatePath',
                    'CertificatePassword',
                    'TenantId',
                    'ApplicationId',
                    'CertificateThumbprint',
                    'ApplicationSecret',
                    'ManagedIdentity'
                )

                $filteredProperties = ($filteredProperties + $ExcludedProperties) | Select-Object -Unique

                [System.Collections.Hashtable]$destinationResource = $destinationResource[0]
                # The resource instance exists in both the source and the destination. Compare each property;
                foreach ($propertyName in $sourceResource.Keys)
                {
                    if ($propertyName -notin $filteredProperties)
                    {
                        $destinationPropertyName = $destinationResource.Keys | Where-Object -FilterScript { $_ -eq $propertyName }
                        if ([System.String]::IsNullOrEmpty($destinationPropertyName))
                        {
                            $destinationPropertyName = $propertyName
                        }

                        # Needs to be a separate nested if statement otherwise the ReferenceObject an be null and it will error out;
                        if ($destinationResource.ContainsKey($destinationPropertyName) -eq $false -or (-not [System.String]::IsNullOrEmpty($propertyName) -and
                                $null -ne (Compare-Object -ReferenceObject ($sourceResource.$propertyName)`
                                        -DifferenceObject ($destinationResource.$destinationPropertyName))) -and
                            -not ([System.String]::IsNullOrEmpty($destinationResource.$destinationPropertyName) -and [System.String]::IsNullOrEmpty($sourceResource.$propertyName)))
                        {
                            if ($null -eq $drift)
                            {
                                $drift = @{
                                    ResourceName = $sourceResource.ResourceName
                                    Key          = $keyname
                                    KeyValue     = $SourceKeyValue
                                    Properties   = @(@{
                                            ParameterName      = $propertyName
                                            ValueInSource      = $sourceResource.$propertyName
                                            ValueInDestination = $destinationResource.$destinationPropertyName
                                        })
                                }

                                if ($destinationResource.Contains("_metadata_$($destinationPropertyName)"))
                                {
                                    $Metadata = $destinationResource."_metadata_$($destinationPropertyName)"
                                    $Level = $Metadata.Split('|')[0].Replace('### ', '')
                                    $Information = $Metadata.Split('|')[1]
                                    $drift.Properties[0].Add('_Metadata_Level', $Level)
                                    $drift.Properties[0].Add('_Metadata_Info', $Information)
                                }
                            }
                            else
                            {
                                $newDrift = @{
                                    ParameterName      = $propertyName
                                    ValueInSource      = $sourceResource.$propertyName
                                    ValueInDestination = $destinationResource.$destinationPropertyName
                                }
                                if ($destinationResource.Contains("_metadata_$($destinationPropertyName)"))
                                {
                                    $Metadata = $destinationResource."_metadata_$($destinationPropertyName)"
                                    $Level = $Metadata.Split('|')[0].Replace('### ', '')
                                    $Information = $Metadata.Split('|')[1]
                                    $newDrift.Add('_Metadata_Level', $Level)
                                    $newDrift.Add('_Metadata_Info', $Information)
                                }
                                $drift.Properties += $newDrift
                            }
                        }
                    }
                }

                # Do the scan the other way around because there's a chance that the property, if null, wasn't part of the source
                # object. By scanning against the destination we will catch properties that are not null on the source but not null in destination;
                foreach ($propertyName in $destinationResource.Keys)
                {
                    if ($propertyName -notin $filteredProperties)
                    {
                        $sourcePropertyName = $destinationResource.Keys | Where-Object -FilterScript { $_ -eq $propertyName }
                        if ([System.String]::IsNullOrEmpty($sourcePropertyName))
                        {
                            $sourcePropertyName = $propertyName
                        }
                        if (-not [System.String]::IsNullOrEmpty($propertyName) -and
                            -not $sourceResource.Contains($sourcePropertyName))
                        {
                            if ($null -eq $drift)
                            {
                                $drift = @{
                                    ResourceName = $sourceResource.ResourceName
                                    Key          = $keyName
                                    KeyValue     = $SourceKeyValue
                                    Properties   = @(@{
                                            ParameterName      = $sourcePropertyName
                                            ValueInSource      = $null
                                            ValueInDestination = $destinationResource.$propertyName
                                        })
                                }
                            }
                            else
                            {
                                $drift.Properties += @{
                                    ParameterName      = $sourcePropertyName
                                    ValueInSource      = $null
                                    ValueInDestination = $destinationResource.$propertyName
                                }
                            }
                        }
                    }
                }

                if ($null -ne $drift)
                {
                    $Delta += , $drift
                    $drift = $null
                }
            }
        }
        catch
        {
            Write-Verbose -Message "Error: $_"
        }
        $i++
    }
    Write-Progress -Activity 'Scanning Source...' -Completed

    # Loop through all items in the destination array
    $i = 1
    try
    {
        foreach ($destinationResource in $DestinationObject)
        {
            try
            {
                [System.Collections.HashTable]$currentDestinationResource = ([array]$destinationResource)[0]
                $key = Get-M365DSCResourceKey -Resource $currentDestinationResource
                Write-Progress -Activity "Scanning Destination $Destination...[$i/$($DestinationObject.Count)]" -PercentComplete ($i / ($DestinationObject.Count) * 100)
                $sourceResource = $SourceObject | Where-Object -FilterScript { $_.ResourceName -eq $currentDestinationResource.ResourceName -and $_.($key[0]) -eq $currentDestinationResource.($key[0]) }
                $currentDestinationKeyValue = $currentDestinationResource.($key[0])

                # Filter on the second key
                if ($key.Count -gt 1)
                {
                    [array]$sourceResource = $sourceResource | Where-Object -FilterScript { $_.ResourceName -eq $currentDestinationResource.ResourceName -and $_.($key[1]) -eq $currentDestinationResource.($key[1]) }
                    $currentDestinationKeyValue = $currentDestinationResource.($key[0]), $currentDestinationResource.($key[1]) -join '\'
                }
                if ($null -eq $sourceResource)
                {
                    $drift = @{
                        ResourceName = $currentDestinationResource.ResourceName
                        Key          = $keyName
                        KeyValue     = $currentDestinationKeyValue
                        Properties   = @(@{
                                ParameterName      = 'Ensure'
                                ValueInSource      = 'Absent'
                                ValueInDestination = 'Present'
                            })
                    }
                    $Delta += , $drift
                    $drift = $null
                }
            }
            catch
            {
                Write-Verbose -Message "Error: $_"
            }
            $i++
        }
    }
    catch
    {
        Write-Verbose -Message "Error: $_"
    }
    Write-Progress -Activity 'Scanning Destination...' -Completed

    return $Delta
}

<#
.Description
This function gets the key parameter for the specified resource
 
.Functionality
Internal, Hidden
#>

function Get-M365DSCResourceKey
{
    [CmdletBinding()]
    [OutputType([System.Object[]])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Collections.Hashtable]
        $Resource
    )

    $resourceInfo = Get-DscResource ("MSFT_$($Resource.ResourceName)") -Module 'Microsoft365DSC'
    [Array]$mandatoryParameters = $resourceInfo.Properties | Where-Object -FilterScript { $_.IsMandatory }
    if ($Resource.Contains('IsSingleInstance') -and $mandatoryParameters.Name.Contains('IsSingleInstance'))
    {
        return @('IsSingleInstance')
    }
    elseif ($Resource.Contains('DisplayName') -and $mandatoryParameters.Name.Contains('DisplayName'))
    {
        if ($Resource.ResourceName -eq 'AADMSGroup' -and -not [System.String]::IsNullOrEmpty($Resource.Id))
        {
            return @('Id')
        }
        if ($Resource.ResourceName -eq 'TeamsChannel' -and -not [System.String]::IsNullOrEmpty($Resource.TeamName))
        {
            # Teams Channel displaynames are not tenant-unique (e.g. "General" is almost in every team), but should be unique per team
            return @('TeamName', 'DisplayName')
        }
        if ($Resource.ResourceName -eq 'TeamsTeam' -and -not [System.String]::IsNullOrEmpty($Resource.MailNickName))
        {
            # Teams names are not unique
            return @('MailNickName', 'DisplayName')
        }
        return @('DisplayName')
    }
    elseif ($Resource.Contains('Identity') -and $mandatoryParameters.Name.Contains('Identity'))
    {
        return @('Identity')
    }
    elseif ($Resource.Contains('Name') -and $mandatoryParameters.Name.Contains('Name'))
    {
        return @('Name')
    }
    elseif ($Resource.Contains('Url') -and $mandatoryParameters.Name.Contains('Url'))
    {
        return @('Url')
    }
    elseif ($Resource.Contains('Organization') -and $mandatoryParameters.Name.Contains('Organization'))
    {
        return @('Organization')
    }
    elseif ($Resource.Contains('CDNType') -and $mandatoryParameters.Name.Contains('CDNType'))
    {
        return @('CDNType')
    }
    elseif ($Resource.Contains('Action') -and $Resource.ResourceName -eq 'SCComplianceSearchAction' -and $mandatoryParameters.Name.Contains('Action'))
    {
        return @('SearchName', 'Action')
    }
    elseif ($Resource.Contains('Workload') -and $Resource.ResourceName -eq 'SCAuditConfigurationPolicy' -and $mandatoryParameters.Name.Contains('Workload'))
    {
        return @('Workload')
    }
    elseif ($Resource.Contains('Title') -and $Resource.ResourceName -eq 'SPOSiteDesign' -and $mandatoryParameters.Name.Contains('Title'))
    {
        return @('Title')
    }
    elseif ($Resource.Contains('SiteDesignTitle') -and $mandatoryParameters.Name.Contains('SiteDesignTitle'))
    {
        return @('SiteDesignTitle')
    }
    elseif ($Resource.Contains('Key') -and $Resource.ResourceName -eq 'SPOStorageEntity' -and $mandatoryParameters.Name.Contains('Key'))
    {
        return @('Key')
    }
    elseif ($Resource.Contains('Usage') -and $mandatoryParameters.Name.Contains('Usage'))
    {
        return @('Usage')
    }
    elseif ($Resource.Contains('OrgWideAccount') -and $mandatoryParameters.Name.Contains('OrgWideAccount'))
    {
        return @('OrgWideAccount')
    }
    elseif ($mandatoryParameters.count -eq 1)
    {
        # returning the only mandatory parameter name
        return @($mandatoryParameters[0].Name)
    }
    elseif ($mandatoryParameters.count -eq 0)
    {
        throw "No mandatory parameters found for $($Resource.ResourceName)"
    }
    else
    {
        # the function failed to find any key params
        throw "Multiple mandatory parameters found for $($Resource.ResourceName) but none of them are returned by the function"
    }
}

<#
.Description
This function creates a delta HTML report between two provided exported
DSC configurations
 
.Parameter Source
The source DSC configuration to compare from.
 
.Parameter Destination
The destination DSC configuration to compare with.
 
.Parameter OutputPath
The output path of the delta report.
 
.Parameter DriftOnly
Specifies that only difference should be in the report.
 
.Parameter IsBlueprintAssessment
Specifies that the report is a comparison with a Blueprint.
 
.Parameter HeaderFilePath
Specifies that file that contains a custom header for the report.
 
.Parameter Delta
An array with difference, already compiled from another source.
 
.Parameter ExcludedProperties
Array that contains the list of parameters to exclude.
 
.Example
New-M365DSCDeltaReport -Source 'C:\DSC\Source.ps1' -Destination 'C:\DSC\Destination.ps1' -OutputPath 'C:\Dsc\DeltaReport.html'
 
.Example
New-M365DSCDeltaReport -Source 'C:\DSC\Source.ps1' -Destination 'C:\DSC\Destination.ps1' -OutputPath 'C:\Dsc\DeltaReport.html' -DriftOnly $true
 
.Functionality
Public
#>

function New-M365DSCDeltaReport
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Source,

        [Parameter(Mandatory = $true)]
        [System.String]
        $Destination,

        [Parameter()]
        [System.String]
        $OutputPath,

        [Parameter()]
        [System.Boolean]
        $DriftOnly = $false,

        [Parameter()]
        [System.Boolean]
        $IsBlueprintAssessment = $false,

        [Parameter()]
        [System.String]
        $HeaderFilePath,

        [Parameter()]
        [Array]
        $Delta,

        [Parameter()]
        [System.String]
        [ValidateSet('HTML', 'JSON')]
        $Type = 'HTML',

        [Parameter()]
        [Array]
        $ExcludedProperties
    )

    # Validate that the latest version of the module is installed.
    Test-M365DSCModuleValidity

    if ((Test-Path -Path $Source) -eq $false)
    {
        Write-Error "Cannot find file specified in parameter Source: $Source. Please make sure the file exists!"
        return
    }

    if ((Test-Path -Path $Destination) -eq $false)
    {
        Write-Error "Cannot find file specified in parameter Destination: $Destination. Please make sure the file exists!"
        return
    }

    if ($OutputPath -and (Test-Path -Path $OutputPath) -eq $false)
    {
        Write-Warning "File specified in parameter OutputPath already exists and will be overwritten: $OutputPath"
        Write-Warning "Make sure you specify a file that not exists, if you don't want the file to be overwritten!"
    }

    if ($PSBoundParameters.ContainsKey('HeaderFilePath') -and -not [System.String]::IsNullOrEmpty($HeaderFilePath) -and `
        (Test-Path -Path $HeaderFilePath) -eq $false)
    {
        Write-Error "Cannot find file specified in parameter HeaderFilePath: $HeaderFilePath. Please make sure the file exists!"
        return
    }

    #Ensure the proper dependencies are installed in the current environment.
    Confirm-M365DSCDependencies

    #region Telemetry
    $data = [System.Collections.Generic.Dictionary[[String], [String]]]::new()
    $data.Add('Event', 'DeltaReport')
    Add-M365DSCTelemetryEvent -Data $data
    #endregion

    # Excluding authentication properties by default.
    $authParameters = @("Credential", "ManagedIdentity", "ApplicationId", "TenantId", "CertificatePath", "CertificatePassword", "CertificateThumbprint", "ApplicationSecret")
    $ExcludedProperties = $ExcludedProperties + $authParameters | Select-Object -Unique

    Write-Verbose -Message 'Obtaining Delta between the source and destination configurations'
    if (-not $Delta)
    {
        if ($IsBlueprintAssessment)
        {
            # Parse the blueprint file, pass to Compare-M365DSCConfigurations as object (including comments aka metadata)
            [Array] $ParsedBlueprintWithMetadata = Initialize-M365DSCReporting -ConfigurationPath $Destination -IncludeComments:$true
            $Delta = Compare-M365DSCConfigurations -Source $Source `
                -DestinationObject $ParsedBlueprintWithMetadata `
                -CaptureTelemetry $false `
                -ExcludedProperties $ExcludedProperties
        }
        Else
        {
            $Delta = Compare-M365DSCConfigurations `
                -Source $Source `
                -Destination $Destination `
                -CaptureTelemetry $false `
                -ExcludedProperties $ExcludedProperties
        }
    }

    if ($Type -eq 'HTML')
    {
        $reportSB = [System.Text.StringBuilder]::new()
        #region Custom Header
        if (-not [System.String]::IsNullOrEmpty($HeaderFilePath))
        {
            try
            {
                $headerContent = Get-Content $HeaderFilePath
                [void]$reportSB.AppendLine($headerContent)
            }
            catch
            {
                New-M365DSCLogEntry -Message 'Error while reading DSC configuration:' `
                    -Exception $_ `
                    -Source $($MyInvocation.MyCommand.Source) `
                    -TenantId $TenantId `
                    -Credential $Credential
            }
        }
        #endregion

        $ReportTitle = 'Microsoft365DSC - Delta Report'
        if ($IsBlueprintAssessment)
        {
            $ReportTitle = 'Microsoft365DSC - Blueprint Assessment Report'
            [void]$reportSB.AppendLine('<h1>Blueprint Assessment Report</h1>')
        }
        else
        {
            [void]$reportSB.AppendLine('<h1>Delta Report</h1>')
            [void]$reportSB.AppendLine("<p><strong>Comparing </strong>$Source <strong>to</strong> $Destination</p>")
        }
        [void]$reportSB.AppendLine("<html><head><meta charset='utf-8'><title>$ReportTitle</title></head><body>")
        [void]$reportSB.AppendLine("<div style='width:100%;text-align:center;'>")
        [void]$reportSB.AppendLine("<img src='http://Microsoft365DSC.com/Images/Promo.png' alt='Microsoft365DSC Slogan' width='500' />")
        [void]$ReportSB.AppendLine('</div>')

        [array]$resourcesMissingInSource = $Delta | Where-Object -FilterScript { $_.Properties.ParameterName -eq '_IsInConfiguration_' -and `
                $_.Properties.ValueInSource -eq 'Absent' }
        [array]$resourcesMissingInDestination = $Delta | Where-Object -FilterScript { $_.Properties.ParameterName -eq '_IsInConfiguration_' -and `
                $_.Properties.ValueInDestination -eq 'Absent' }
        [array]$resourcesInDrift = $Delta | Where-Object -FilterScript { $_.Properties.ParameterName -ne '_IsInConfiguration_' }

        if ($resourcesMissingInSource.Count -eq 0 -and $resourcesMissingInDestination.Count -eq 0 -and `
                $resourcesInDrift.Count -eq 0)
        {
            [void]$reportSB.AppendLine('<p><strong>No discrepancies have been found!</strong></p>')
        }
        elseif (-not $DriftOnly)
        {
            [void]$reportSB.AppendLine('<h2>Table of Contents</h2>')
            [void]$reportSB.AppendLine('<ul>')
            if ($resourcesMissingInSource.Count -gt 0)
            {
                [void]$reportSB.AppendLine("<li><a href='#Source'>Resources Missing in the Source</a>")
                [void]$reportSB.AppendLine(" <strong>(</strong>$($resourcesMissingInSource.Count)<strong>)</strong></li>")
            }
            if ($resourcesMissingInDestination.Count -gt 0)
            {
                [void]$reportSB.AppendLine("<li><a href='#Destination'>Resources Missing in the Destination</a>")
                [void]$reportSB.AppendLine(" <strong>(</strong>$($resourcesMissingInDestination.Count)<strong>)</strong></li>")
            }
            if ($resourcesInDrift.Count -gt 0)
            {
                [void]$reportSB.AppendLine("<li><a href='#Drift'>Resources Configured Differently</a>")
                [void]$reportSB.AppendLine(" <strong>(</strong>$($resourcesInDrift.Count)<strong>)</strong></li>")
            }
            [void]$reportSB.AppendLine('</ul>')
        }

        if ($resourcesMissingInSource.Count -gt 0 -and -not $DriftOnly)
        {
            [void]$reportSB.AppendLine('<br /><hr /><br />')
            [void]$reportSB.AppendLine("<a id='Source'></a><h2>Resources that are Missing in the Source</h2>")
            foreach ($resource in $resourcesMissingInSource)
            {
                [void]$reportSB.AppendLine("<table width='100%' cellspacing='0' cellpadding='5'>")
                [void]$reportSB.AppendLine('<tr>')
                [void]$reportSB.Append("<th style='width:25%;text-align:center;vertical-align:middle;border-left:1px solid black;")
                [void]$reportSB.Append("border-top:1px solid black;border-bottom:1px solid black;'>")
                $iconPath = Get-IconPath -ResourceName $resource.ResourceName
                [void]$reportSB.AppendLine("<img src='$iconPath' />")
                [void]$reportSB.AppendLine('</th>')
                [void]$reportSB.AppendLine("<th style='border:1px solid black;text-align:center;'>")
                [void]$reportSB.AppendLine("<h3>$($resource.ResourceName) - $($resource.Key) = $($resource.KeyValue)</h3>")
                [void]$reportSB.AppendLine('</th>')
                [void]$reportSB.AppendLine('</tr>')
                [void]$reportSB.AppendLine('</table>')
            }
        }

        if ($resourcesMissingInDestination.Count -gt 0 -and -not $DriftOnly)
        {
            [void]$reportSB.AppendLine('<br /><hr /><br />')
            [void]$reportSB.AppendLine("<a id='Destination'></a><h2>Resources that are Missing in the Destination</h2>")
            foreach ($resource in $resourcesMissingInDestination)
            {
                [void]$reportSB.AppendLine("<table width='100%' cellspacing='0' cellpadding='5'>")
                [void]$reportSB.AppendLine('<tr>')
                [void]$reportSB.Append("<th style='width:25%;text-align:center;vertical-align:middle;border-left:1px solid black;")
                [void]$reportSB.Append("border-top:1px solid black;border-bottom:1px solid black;'>")
                $iconPath = Get-IconPath -ResourceName $resource.ResourceName
                [void]$reportSB.AppendLine("<img src='$iconPath' />")
                [void]$reportSB.AppendLine('</th>')
                [void]$reportSB.AppendLine("<th style='border:1px solid black;text-align:center;'>")
                [void]$reportSB.AppendLine("<h3>$($resource.ResourceName) - $($resource.Key) = $($resource.KeyValue)</h3>")
                [void]$reportSB.AppendLine('</th>')
                [void]$reportSB.AppendLine('</tr>')
                [void]$reportSB.AppendLine('</table>')
            }
        }

        if ($resourcesInDrift.Count -gt 0)
        {
            [void]$reportSB.AppendLine('<br /><hr /><br />')
            [void]$reportSB.AppendLine("<a id='Drift'></a><h2>Resources that are Configured Differently</h2>")
            foreach ($resource in $resourcesInDrift)
            {
                [void]$reportSB.AppendLine("<table width='100%' cellspacing='0' cellpadding='5'>")
                [void]$reportSB.AppendLine('<tr>')
                [void]$reportSB.Append("<th style='width:25%;text-align:center;vertical-align:middle;border:1px solid black;;' ")
                [void]$reportSB.Append("rowspan='" + ($resource.Properties.Count + 2) + "'>")
                $iconPath = Get-IconPath -ResourceName $resource.ResourceName
                [void]$reportSB.AppendLine("<img src='$iconPath' />")
                [void]$reportSB.AppendLine('</th>')
                [void]$reportSB.AppendLine("<th style='border:1px solid black;text-align:center;vertical-align:middle;background-color:#CCC' colspan='3'>")
                [void]$reportSB.AppendLine("<h3>$($resource.ResourceName) - $($resource.Key) = $($resource.KeyValue)</h3>")
                [void]$reportSB.AppendLine('</th></tr>')
                [void]$reportSB.AppendLine('<tr>')

                $SourceLabel = 'Source Value'
                $DestinationLabel = 'Destination Value'
                if ($IsBlueprintAssessment)
                {
                    $SourceLabel = "Tenant's Current Value"
                    $DestinationLabel = "Blueprint's Value"
                }
                [void]$reportSB.AppendLine("<td style='text-align:center;border:1px solid black;background-color:#EEE;' width='45%'><strong>Property</strong></td>")
                [void]$reportSB.AppendLine("<td style='text-align:center;border:1px solid black;background-color:#EEE;' width='15%'><strong>$SourceLabel</strong></td>")
                [void]$reportSB.AppendLine("<td style='text-align:center;border:1px solid black;background-color:#EEE;' width='15%'><strong>$DestinationLabel</strong></td>")
                [void]$reportSB.AppendLine('</tr>')
                foreach ($drift in $resource.Properties)
                {
                    if ($drift.ParameterName -notlike '_metadata_*')
                    {
                        $cellStyle = ''
                        $emoticon = ''
                        if ($drift._Metadata_Level -eq 'L1')
                        {
                            $cellStyle = 'background-color:#F6CECE;'
                            $emoticon = '&#x1F7E5;'
                        }
                        elseif ($drift._Metadata_Level -eq 'L2')
                        {
                            $cellStyle = 'background-color:#F7F8E0;'
                            $emoticon = '&#x1F7E8;'
                        }
                        elseif ($drift._Metadata_Level -eq 'L3')
                        {
                            $cellStyle = 'background-color:#FFFFFF;'
                            $emoticon = '&#x1F7E6;'
                        }

                        $sourceValue = $drift.ValueInSource
                        $destinationValue = $drift.ValueInDestination
                        $CIMType = $drift.ValueInSource[0].CimInstance

                        if ($sourceValue.GetType().Name -eq 'Object[]' -and -not [System.String]::IsNullOrEmpty($CIMType))
                        {
                            $sourceValue = ''
                            $orderedKeys = $drift.ValueInSource.Key | Sort-Object

                            foreach ($key in $orderedKeys)
                            {
                                $currentValue = ($drift.ValueInSource | Where-Object -FilterScript { $_.Key -eq $key }).Value
                                $sourceValue += "<table width='100%'>"
                                $sourceValue += "<tr><th colspan='2' width='100%' style='border:1px solid black; text-align:middle;'>$CIMType</th></tr>"
                                $sourceValue += "<tr><td width='100%' style='border:1px solid black; text-align:right;'>$key = $currentValue</td></tr>"
                                $sourceValue += '</table><br/>'
                            }
                            $sourceValue = $sourceValue.Substring(0, $sourceValue.Length - 5)
                            $cellStyle = 'vertical-align:top;'
                        }

                        if (-not [System.String]::IsNullOrEmpty($destinationValue) `
                                -and $destinationValue.GetType().Name -eq 'Object[]' `
                                -and -not [System.String]::IsNullOrEmpty($CIMType))
                        {
                            $destinationValue = ''
                            $orderedKeys = $drift.ValueInDestination.Key | Sort-Object
                            $CIMType = $drift.ValueInDestination[0].CimInstance

                            foreach ($key in $orderedKeys)
                            {
                                $currentValue = ($drift.ValueInDestination | Where-Object -FilterScript { $_.Key -eq $key }).Value
                                $destinationValue += "<table width='100%'>"
                                $destinationValue += "<tr><th colspan='2' width='100%' style='border:1px solid black; text-align:middle;'>$CIMType</th></tr>"
                                $destinationValue += "<tr><td width='100%' style='border:1px solid black; text-align:right;'>$key = $currentValue</td></tr>"
                                $destinationValue += '</table><br/>'
                            }
                            $destinationValue = $destinationValue.Substring(0, $destinationValue.Length - 5)
                            $cellDestinationStyle = 'vertical-align:top;'
                        }

                        [void]$reportSB.AppendLine('<tr>')
                        [void]$reportSB.AppendLine("<td style='border:1px solid black;text-align:right;' width='45%'>")
                        [void]$reportSB.AppendLine("$($drift.ParameterName)</td>")
                        [void]$reportSB.AppendLine("<td style='border:1px solid black;$cellStyle' width='15%'>")
                        [void]$reportSB.AppendLine("$($sourceValue)</td>")
                        [void]$reportSB.AppendLine("<td style='border:1px solid black;$cellDestinationStyle' width='15%'>")
                        [void]$reportSB.AppendLine("$($destinationValue)</td>")
                        [void]$reportSB.AppendLine('</tr>')

                        if ($null -ne $drift._Metadata_Level)
                        {
                            [void]$reportSB.AppendLine("<tr><td colspan='3' style='border:1px solid black;'>$emoticon $($drift._Metadata_Info)</td></tr>")
                        }
                    }
                }
                [void]$reportSB.AppendLine('</table><hr/>')
            }
        }
        [void]$reportSB.AppendLine('</body></html>')
        if (-not [System.String]::IsNullOrEmpty($OutputPath))
        {
            $reportSB.ToString() | Out-File $OutputPath
        }
        else
        {
            return $reportSB.ToString()
        }
    }
    elseif ($Type -eq 'JSON')
    {
        if (-not [System.String]::IsNullOrEmpty($OutputPath))
        {
            ConvertTo-Json $Delta -Depth 25 | Out-File $OutputPath
        }
        else
        {
            return (ConvertTo-Json $Delta -Depth 25)
        }
    }
}

<#
.Description
This function prepares the configuration for further parsing of the data
 
.Functionality
Internal, Hidden
#>

function Initialize-M365DSCReporting
{
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ConfigurationPath,

        [Parameter()]
        [Switch]
        $IncludeComments
    )

    if ((Test-Path -Path $ConfigurationPath) -eq $false)
    {
        Write-Error "Cannot find file specified in parameter Source: $ConfigurationPath. Please make sure the file exists!"
        return
    }

    Write-Verbose -Message "Loading file '$ConfigurationPath'"

    $fileContent = Get-Content $ConfigurationPath -Raw
    try
    {
        $startPosition = $fileContent.IndexOf(' -ModuleVersion')
        if ($startPosition -gt 0)
        {
            $endPosition = $fileContent.IndexOf("`r", $startPosition)
            $fileContent = $fileContent.Remove($startPosition, $endPosition - $startPosition)
        }
    }
    catch
    {
        Write-Verbose 'Error trying to remove Module Version'
    }

    if ($IncludeComments)
    {
        $parsedContent = ConvertTo-DSCObject -Content $fileContent -IncludeComments:$True
    }
    else
    {
        $parsedContent = ConvertTo-DSCObject -Content $fileContent
    }

    return $parsedContent

}

Export-ModuleMember -Function @(
    'Compare-M365DSCConfigurations',
    'New-M365DSCDeltaReport',
    'New-M365DSCReportFromConfiguration'
)