diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 index 8cdaf3000eb9..a6da1904733d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 @@ -31,6 +31,12 @@ Function Invoke-ExecExtensionMapping { 'HuduFields' { $Result = Get-HuduFieldMapping -CIPPMapping $Table } + 'ITGlue' { + $Result = Get-ITGlueMapping -CIPPMapping $Table + } + 'ITGlueFields' { + $Result = Get-ITGlueFieldMapping -CIPPMapping $Table + } 'Sherweb' { $Result = Get-SherwebMapping -CIPPMapping $Table } @@ -76,6 +82,14 @@ Function Invoke-ExecExtensionMapping { $Result = Set-ExtensionFieldMapping -CIPPMapping $Table -APIName $APIName -Request $Request -Extension 'Hudu' Register-CIPPExtensionScheduledTasks } + 'ITGlue' { + $Result = Set-ITGlueMapping -CIPPMapping $Table -APIName $APIName -Request $Request + Register-CIPPExtensionScheduledTasks + } + 'ITGlueFields' { + $Result = Set-ExtensionFieldMapping -CIPPMapping $Table -APIName $APIName -Request $Request -Extension 'ITGlue' + Register-CIPPExtensionScheduledTasks + } } } $StatusCode = [HttpStatusCode]::OK @@ -116,6 +130,23 @@ Function Invoke-ExecExtensionMapping { $StatusCode = [HttpStatusCode]::InternalServerError } + try { + if ($Request.Query.CreateCAType) { + switch ($Request.Query.CreateCAType) { + 'ITGlue' { + $Result = New-ITGlueCAPolicyAssetType + } + } + } + $StatusCode = [HttpStatusCode]::OK + } + catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Create CA Type API failed. $($ErrorMessage.NormalizedError)" + Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + return ([HttpResponseContext]@{ StatusCode = $StatusCode Body = $Result diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionSync.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionSync.ps1 index 797bf04ec555..8e0cc8171076 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionSync.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionSync.ps1 @@ -84,6 +84,10 @@ Function Invoke-ExecExtensionSync { Register-CIPPExtensionScheduledTasks -Reschedule -Extensions 'Hudu' $Results = [pscustomobject]@{'Results' = 'Extension sync tasks have been rescheduled and will start within 15 minutes' } } + 'ITGlue' { + Register-CIPPExtensionScheduledTasks -Reschedule -Extensions 'ITGlue' + $Results = [pscustomobject]@{'Results' = 'ITGlue extension sync tasks have been rescheduled and will start within 15 minutes' } + } } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 index d89da3624338..8786abb2d25d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 @@ -65,6 +65,15 @@ Function Invoke-ExecExtensionTest { $Results = [pscustomobject]@{'Results' = 'Failed to connect to Hudu, check your API credentials and try again.' } } } + 'ITGlue' { + $Conn = Connect-ITGlueAPI -Configuration $Configuration + $Orgs = Invoke-ITGlueRequest -Method GET -Endpoint '/organizations' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -PageSize 1 -FirstPageOnly + if ($Orgs) { + $Results = [pscustomobject]@{'Results' = "Successfully connected to ITGlue. Found $($Orgs.Count) organisation(s) on first page." } + } else { + $Results = [pscustomobject]@{'Results' = 'Failed to connect to ITGlue. Check your API key and region setting.' } + } + } 'Sherweb' { $token = Get-SherwebAuthentication if ($token) { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionsConfig.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionsConfig.ps1 index 79529e5b5291..f9d8fd73f1af 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionsConfig.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionsConfig.ps1 @@ -35,6 +35,14 @@ function Invoke-ExecExtensionsConfig { $Body.Hudu.NextSync = '' } + if ($Body.ITGlue.NextSync) { + #parse unixtime for addedtext + $Timestamp = [datetime]::UnixEpoch.AddSeconds([int]$Body.ITGlue.NextSync).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + Register-CIPPExtensionScheduledTasks -Reschedule -NextSync $Body.ITGlue.NextSync -Extensions 'ITGlue' + $AddedText = " Next sync will be at $Timestamp." + $Body.ITGlue.NextSync = '' + } + $Table = Get-CIPPTable -TableName Extensionsconfig foreach ($APIKey in $Body.PSObject.Properties.Name) { Write-Information "Working on $apikey" diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecITGlueCreateFlexibleAssetType.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecITGlueCreateFlexibleAssetType.ps1 new file mode 100644 index 000000000000..d71bf1097686 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecITGlueCreateFlexibleAssetType.ps1 @@ -0,0 +1,150 @@ +Function Invoke-ExecITGlueCreateFlexibleAssetType { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.Extension.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $AssetType = $Request.Body.AssetType + + if ([string]::IsNullOrEmpty($AssetType)) { + throw "AssetType parameter is required" + } + + # Only support ConditionalAccessPolicies for now + if ($AssetType -ne 'ConditionalAccessPolicies') { + throw "Unsupported asset type: $AssetType" + } + + # Get ITGlue configuration + $Table = Get-CIPPTable -TableName Extensionsconfig + $Configuration = (Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -ErrorAction Stop + $Conn = Connect-ITGlueAPI -Configuration $Configuration + + if (-not $Conn) { + throw "Failed to connect to ITGlue API. Please check your configuration." + } + + # Search for existing type matching "Conditional Access" + $AllFlexibleAssetTypes = Invoke-ITGlueRequest -Method GET -Endpoint '/flexible_asset_types' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl + $ExistingCAPType = $AllFlexibleAssetTypes | Where-Object { $_.name -like '*Conditional Access*' } | Select-Object -First 1 + + if ($ExistingCAPType) { + $CAPTypeId = $ExistingCAPType.id + $Message = "Found existing Conditional Access flexible asset type: $($ExistingCAPType.name) (ID: $CAPTypeId)" + } else { + # Create flexible asset type with all fields using relationships structure + $NewTypeBody = @{ + data = @{ + type = 'flexible-asset-types' + attributes = @{ + name = 'Conditional Access Policy' + description = 'Microsoft 365 Conditional Access Policies synced from CIPP' + icon = 'shield-alt' + enabled = $true + } + relationships = @{ + 'flexible-asset-fields' = @{ + data = @( + @{ + type = 'flexible-asset-fields' + attributes = @{ + order = 1 + name = 'Policy Name' + kind = 'Text' + required = $true + 'show-in-list' = $true + } + } + @{ + type = 'flexible-asset-fields' + attributes = @{ + order = 2 + name = 'Policy ID' + kind = 'Text' + required = $false + 'show-in-list' = $false + } + } + @{ + type = 'flexible-asset-fields' + attributes = @{ + order = 3 + name = 'State' + kind = 'Text' + required = $false + 'show-in-list' = $true + } + } + @{ + type = 'flexible-asset-fields' + attributes = @{ + order = 4 + name = 'Policy Details' + kind = 'Textbox' + required = $false + 'show-in-list' = $false + } + } + @{ + type = 'flexible-asset-fields' + attributes = @{ + order = 5 + name = 'Raw JSON' + kind = 'Textbox' + required = $false + 'show-in-list' = $false + } + } + ) + } + } + } + } | ConvertTo-Json -Depth 20 -Compress + + $NewType = Invoke-RestMethod -Uri "$($Conn.BaseUrl)/flexible_asset_types" -Method POST -Headers $Conn.Headers -Body $NewTypeBody + $CAPTypeId = $NewType.data.id + $Message = "Created new Conditional Access Policy flexible asset type (ID: $CAPTypeId)" + } + + # Save mapping to database + $MappingTable = Get-CIPPTable -TableName CippMapping + $AddMapping = @{ + PartitionKey = 'ITGlueFieldMapping' + RowKey = 'ConditionalAccessPolicies' + IntegrationId = "$CAPTypeId" + IntegrationName = 'Conditional Access Policy' + } + Add-CIPPAzDataTableEntity @MappingTable -Entity $AddMapping -Force + + Write-LogMessage -API $APIName -headers $Headers -message $Message -Sev 'Info' + + $Result = @{ + Success = $true + Message = $Message + TypeId = $CAPTypeId + } + $StatusCode = [HttpStatusCode]::OK + } + catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = @{ + Success = $false + Message = "Failed to create flexible asset type: $($ErrorMessage.NormalizedError)" + } + Write-LogMessage -API $APIName -headers $Headers -message $Result.Message -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Result + }) +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListMailboxForwarding.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListMailboxForwarding.ps1 index 2eae2f67dd97..f28b6a32509b 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListMailboxForwarding.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListMailboxForwarding.ps1 @@ -50,8 +50,9 @@ function Invoke-ListMailboxForwarding { continue } - # External takes precedence when both are configured - $ForwardingType = if ($HasExternalForwarding) { + $ForwardingType = if ($HasExternalForwarding -and $HasInternalForwarding) { + 'Both' + } elseif ($HasExternalForwarding) { 'External' } else { 'Internal' diff --git a/Modules/CippExtensions/Private/ITGlue/Add-ITGlueFlexibleAssetFields.ps1 b/Modules/CippExtensions/Private/ITGlue/Add-ITGlueFlexibleAssetFields.ps1 new file mode 100644 index 000000000000..c16739e872f8 --- /dev/null +++ b/Modules/CippExtensions/Private/ITGlue/Add-ITGlueFlexibleAssetFields.ps1 @@ -0,0 +1,59 @@ +function Add-ITGlueFlexibleAssetFields { + <# + .FUNCTIONALITY + Internal + .SYNOPSIS + Ensures required fields exist in an ITGlue Flexible Asset Type. + #> + param( + $TypeId, + [array]$FieldsToAdd, # Array of @{ Name = ''; Kind = 'Textbox'; ShowInList = $false } + $Conn + ) + + # GET type with its fields included (one call for all fields) + $TypeResponse = Invoke-RestMethod -Uri "$($Conn.BaseUrl)/flexible_asset_types/$TypeId`?include=flexible_asset_fields" -Method GET -Headers $Conn.Headers + $IncludedFields = $TypeResponse.included | Where-Object { $_.type -eq 'flexible-asset-fields' } + $ExistingNames = $IncludedFields | ForEach-Object { $_.attributes.name } + + # Filter to only fields that don't exist + $NewFields = $FieldsToAdd | Where-Object { $_.Name -notin $ExistingNames } + + if ($NewFields.Count -eq 0) { + return # All fields already exist + } + + # Build complete field list: existing (with IDs) + new fields + $AllFields = [System.Collections.Generic.List[object]]::new() + foreach ($F in $IncludedFields) { + $AllFields.Add([ordered]@{ + id = $F.id + name = $F.attributes.name + kind = $F.attributes.kind + required = $F.attributes.required + 'show-in-list' = $F.attributes.'show-in-list' + position = $F.attributes.position + }) + } + + foreach ($NewField in $NewFields) { + $AllFields.Add([ordered]@{ + name = $NewField.Name + kind = $NewField.Kind + required = $false + 'show-in-list' = $NewField.ShowInList + }) + } + + $PatchBody = @{ + data = @{ + type = 'flexible-asset-types' + id = $TypeId + attributes = @{ + 'flexible-asset-fields' = @($AllFields) + } + } + } | ConvertTo-Json -Depth 20 -Compress + + $null = Invoke-RestMethod -Uri "$($Conn.BaseUrl)/flexible_asset_types/$TypeId" -Method PATCH -Headers $Conn.Headers -Body $PatchBody +} diff --git a/Modules/CippExtensions/Private/ITGlue/Format-ITGlueCAPValue.ps1 b/Modules/CippExtensions/Private/ITGlue/Format-ITGlueCAPValue.ps1 new file mode 100644 index 000000000000..4fbf4442217d --- /dev/null +++ b/Modules/CippExtensions/Private/ITGlue/Format-ITGlueCAPValue.ps1 @@ -0,0 +1,13 @@ +function Format-ITGlueCAPValue { + <# + .FUNCTIONALITY + Internal + .SYNOPSIS + Converts Out-String output (newline-separated) to comma-separated format. + Used for formatting CAP values from Invoke-ListConditionalAccessPolicies. + #> + param($Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + ($Value.Trim() -split "`n" | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() }) -join ', ' +} diff --git a/Modules/CippExtensions/Private/ITGlue/Invoke-ITGlueRequest.ps1 b/Modules/CippExtensions/Private/ITGlue/Invoke-ITGlueRequest.ps1 new file mode 100644 index 000000000000..3cec7a360ce1 --- /dev/null +++ b/Modules/CippExtensions/Private/ITGlue/Invoke-ITGlueRequest.ps1 @@ -0,0 +1,134 @@ +function Invoke-ITGlueRequest { + <# + .FUNCTIONALITY + Internal + .SYNOPSIS + Core HTTP helper for all ITGlue API calls. Handles JSON:API wrapping/unwrapping, + pagination, and rate limiting. No third-party module required. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('GET', 'POST', 'PATCH', 'DELETE')] + [string]$Method, + + [Parameter(Mandatory = $true)] + [string]$Endpoint, + + [Parameter(Mandatory = $true)] + [hashtable]$Headers, + + [Parameter(Mandatory = $true)] + [string]$BaseUrl, + + # For POST/PATCH: the attributes hashtable (will be wrapped in JSON:API envelope) + [Parameter(Mandatory = $false)] + [hashtable]$Attributes, + + # The JSON:API resource type (e.g. 'flexible-assets', 'contacts', 'configurations') + [Parameter(Mandatory = $false)] + [string]$ResourceType, + + # For PATCH: the ID of the resource to update + [Parameter(Mandatory = $false)] + [string]$ResourceId, + + # Query string parameters as a hashtable (e.g. @{ 'filter[organization_id]' = 123 }) + [Parameter(Mandatory = $false)] + [hashtable]$QueryParams, + + # Page size for GET requests (max 1000, default 50) + [Parameter(Mandatory = $false)] + [int]$PageSize = 50, + + # If set, only retrieve the first page (useful for connection tests) + [Parameter(Mandatory = $false)] + [switch]$FirstPageOnly + ) + + $Uri = '{0}{1}' -f $BaseUrl.TrimEnd('/'), $Endpoint + + # Build query string + $AllQueryParams = @{} + if ($QueryParams) { + foreach ($Key in $QueryParams.Keys) { + $AllQueryParams[$Key] = $QueryParams[$Key] + } + } + + # Build JSON:API request body for POST/PATCH + $Body = $null + if ($Method -in @('POST', 'PATCH') -and $Attributes) { + $DataObject = @{ + type = $ResourceType + attributes = $Attributes + } + if ($ResourceId) { + $DataObject['id'] = $ResourceId + } + $Body = @{ data = $DataObject } | ConvertTo-Json -Depth 20 -Compress + } + + $Results = [System.Collections.Generic.List[object]]::new() + + $CurrentPage = 1 + do { + $PageQueryParams = $AllQueryParams.Clone() + if ($Method -eq 'GET') { + $PageQueryParams['page[size]'] = $PageSize + $PageQueryParams['page[number]'] = $CurrentPage + } + + # Build URI with query string + $QueryString = ($PageQueryParams.GetEnumerator() | ForEach-Object { + '{0}={1}' -f [System.Uri]::EscapeDataString($_.Key), [System.Uri]::EscapeDataString($_.Value) + }) -join '&' + $FullUri = if ($QueryString) { '{0}?{1}' -f $Uri, $QueryString } else { $Uri } + + $InvokeParams = @{ + Uri = $FullUri + Method = $Method + Headers = $Headers + } + if ($Body) { + $InvokeParams['Body'] = $Body + } + + try { + $Response = Invoke-RestMethod @InvokeParams + } catch { + $StatusCode = $_.Exception.Response.StatusCode.value__ + $ErrorDetail = $null + try { + $ErrorBody = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue + $ErrorDetail = $ErrorBody.errors[0].detail ?? $ErrorBody.errors[0].title ?? $_.ErrorDetails.Message + } catch {} + $ErrorMessage = if ($ErrorDetail) { $ErrorDetail } else { $_.Exception.Message } + throw "ITGlue API error ($StatusCode) on $Method $Endpoint : $ErrorMessage" + } + + # Unwrap JSON:API response — flatten attributes onto a new object and add the top-level id + if ($Response.data) { + $DataItems = if ($Response.data -is [array]) { $Response.data } else { @($Response.data) } + foreach ($Item in $DataItems) { + # Select-Object * copies all NoteProperty members into a fresh PSCustomObject + $Obj = $Item.attributes | Select-Object * + $Obj | Add-Member -NotePropertyName 'id' -NotePropertyValue $Item.id -Force + $Results.Add($Obj) + } + } + + # Pagination — only continue for GET requests collecting all pages + if ($Method -ne 'GET' -or $FirstPageOnly) { break } + + $TotalPages = $Response.meta.'total-pages' + if (-not $TotalPages -or $CurrentPage -ge $TotalPages) { break } + + $CurrentPage++ + # Respect ITGlue rate limits between paginated calls + Start-Sleep -Milliseconds 250 + + } while ($true) + + return $Results.ToArray() +} diff --git a/Modules/CippExtensions/Private/ITGlue/Sync-ITGlueConditionalAccessPolicies.ps1 b/Modules/CippExtensions/Private/ITGlue/Sync-ITGlueConditionalAccessPolicies.ps1 new file mode 100644 index 000000000000..63da96ab3ee2 --- /dev/null +++ b/Modules/CippExtensions/Private/ITGlue/Sync-ITGlueConditionalAccessPolicies.ps1 @@ -0,0 +1,202 @@ +function Sync-ITGlueConditionalAccessPolicies { + <# + .FUNCTIONALITY + Internal + .SYNOPSIS + Syncs Conditional Access Policies to ITGlue Flexible Assets. + #> + param( + $CAPTypeId, + $OrgId, + $Conn, + $ConditionalAccessPolicies, + $ITGlueAssetCache, + $TenantFilter, + $CIPPURL, + $Tenant + ) + + $Result = @{ + UpdatedCount = 0 + SkippedCount = 0 + Errors = [System.Collections.Generic.List[string]]@() + Logs = [System.Collections.Generic.List[string]]@() + } + + try { + Add-ITGlueFlexibleAssetFields -TypeId $CAPTypeId -FieldsToAdd @( + @{ Name = 'Policy Name'; Kind = 'Text'; ShowInList = $true } + @{ Name = 'Policy ID'; Kind = 'Text'; ShowInList = $false } + @{ Name = 'State'; Kind = 'Text'; ShowInList = $true } + @{ Name = 'Policy Details'; Kind = 'Textbox'; ShowInList = $false } + @{ Name = 'Raw JSON'; Kind = 'Textbox'; ShowInList = $false } + ) -Conn $Conn + + $ExistingCAPAssets = Invoke-ITGlueRequest -Method GET -Endpoint '/flexible_assets' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -QueryParams @{ + 'filter[flexible_asset_type_id]' = $CAPTypeId + 'filter[organization_id]' = $OrgId + } + + foreach ($CAP in $ConditionalAccessPolicies) { + try { + $StateIcon = switch ($CAP.state) { + 'enabled' { '✓ Enabled' } + 'disabled' { '✗ Disabled' } + 'enabledForReportingButNotEnforced' { '⚠ Report-Only' } + default { $CAP.state } + } + + # Build content for hash - ONLY actual policy settings (exclude dates/timestamps) + $ContentForHash = @" +State: $StateIcon +Client App Types: $($CAP.clientAppTypes) +Platforms (Include): $($CAP.includePlatforms) +Platforms (Exclude): $($CAP.excludePlatforms) +Locations (Include): $($CAP.includeLocations) +Locations (Exclude): $($CAP.excludeLocations) +Applications (Include): $($CAP.includeApplications) +Applications (Exclude): $($CAP.excludeApplications) +User Actions: $(Format-ITGlueCAPValue $CAP.includeUserActions) +Auth Context: $(Format-ITGlueCAPValue $CAP.includeAuthenticationContextClassReferences) +Users (Include): $(Format-ITGlueCAPValue $CAP.includeUsers) +Users (Exclude): $(Format-ITGlueCAPValue $CAP.excludeUsers) +Groups (Include): $(Format-ITGlueCAPValue $CAP.includeGroups) +Groups (Exclude): $(Format-ITGlueCAPValue $CAP.excludeGroups) +Roles (Include): $(Format-ITGlueCAPValue $CAP.includeRoles) +Roles (Exclude): $(Format-ITGlueCAPValue $CAP.excludeRoles) +Operator: $($CAP.grantControlsOperator) +Built-in Controls: $($CAP.builtInControls) +Custom Auth Factors: $($CAP.customAuthenticationFactors) +Terms of Use: $($CAP.termsOfUse) +"@ + + # Hash-based change detection - hash ONLY policy content (not dates or display timestamps) + $ContentToHash = "$($CAP.displayName)|$($CAP.state)|$ContentForHash" + $NewHash = Get-StringHash -String $ContentToHash + + # Build full HTML with dates for display (dates NOT in hash) + $DetailsHtml = @" +
Created: $($CAP.createdDateTime)
+Modified: $($CAP.modifiedDateTime)
| Client App Types | $($CAP.clientAppTypes) |
| Platforms (Include) | $($CAP.includePlatforms) |
| Platforms (Exclude) | $($CAP.excludePlatforms) |
| Locations (Include) | $($CAP.includeLocations) |
| Locations (Exclude) | $($CAP.excludeLocations) |
| Applications (Include) | $($CAP.includeApplications) |
| Applications (Exclude) | $($CAP.excludeApplications) |
| User Actions | $(Format-ITGlueCAPValue $CAP.includeUserActions) |
| Auth Context | $(Format-ITGlueCAPValue $CAP.includeAuthenticationContextClassReferences) |
| Users (Include) | $(Format-ITGlueCAPValue $CAP.includeUsers) |
| Users (Exclude) | $(Format-ITGlueCAPValue $CAP.excludeUsers) |
| Groups (Include) | $(Format-ITGlueCAPValue $CAP.includeGroups) |
| Groups (Exclude) | $(Format-ITGlueCAPValue $CAP.excludeGroups) |
| Roles (Include) | $(Format-ITGlueCAPValue $CAP.includeRoles) |
| Roles (Exclude) | $(Format-ITGlueCAPValue $CAP.excludeRoles) |
| Operator | $($CAP.grantControlsOperator) |
| Built-in Controls | $($CAP.builtInControls) |
| Custom Auth Factors | $($CAP.customAuthenticationFactors) |
| Terms of Use | $($CAP.termsOfUse) |
Last updated: $(Get-Date -Format 'yyyy-MM-dd HH:mm') UTC
+"@ + + $CAPTraits = @{ + 'policy-name' = $CAP.displayName + 'policy-id' = $CAP.id + 'state' = $CAP.state + 'policy-details' = $DetailsHtml + 'raw-json' = $CAP.rawjson + } + + $ExistingAsset = $ExistingCAPAssets | Where-Object { $_.traits.'policy-id' -eq $CAP.id } | Select-Object -First 1 + + # Check if content has changed by comparing hashes + $NeedsUpdate = $true + if ($ExistingAsset) { + $CachedAsset = Get-CIPPAzDataTableEntity @ITGlueAssetCache -Filter "PartitionKey eq 'ITGlueCAP' and RowKey eq '$($ExistingAsset.id)'" + if ($CachedAsset -and $CachedAsset.Hash -eq $NewHash) { + $NeedsUpdate = $false + $Result.SkippedCount++ + } else { + # Debug: Log why hash changed + if ($CachedAsset) { + Write-LogMessage -API 'ITGlueSync' -tenant $TenantFilter -message "CAP hash mismatch for $($CAP.displayName): Cached=$($CachedAsset.Hash.Substring(0,8))... New=$($NewHash.Substring(0,8))..." -sev Debug + } else { + Write-LogMessage -API 'ITGlueSync' -tenant $TenantFilter -message "CAP no cache found for $($CAP.displayName) (AssetID: $($ExistingAsset.id))" -sev Debug + } + } + } + + if ($NeedsUpdate) { + $AssetAttribs = @{ + 'organization-id' = $OrgId + 'flexible-asset-type-id' = $CAPTypeId + traits = $CAPTraits + } + + if ($ExistingAsset) { + $null = Invoke-ITGlueRequest -Method PATCH -Endpoint "/flexible_assets/$($ExistingAsset.id)" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'flexible-assets' -ResourceId $ExistingAsset.id -Attributes $AssetAttribs + $AssetId = $ExistingAsset.id + } else { + $CreatedAsset = Invoke-ITGlueRequest -Method POST -Endpoint '/flexible_assets' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'flexible-assets' -Attributes $AssetAttribs + $AssetId = $CreatedAsset[0].id + } + + # Cache the hash to avoid unnecessary updates on next sync + $CacheEntry = @{ + PartitionKey = 'ITGlueCAP' + RowKey = [string]$AssetId + OrgId = [string]$OrgId + PolicyId = $CAP.id + Hash = $NewHash + } + Add-CIPPAzDataTableEntity @ITGlueAssetCache -Entity $CacheEntry -Force + + $Result.UpdatedCount++ + } + } catch { + $Result.Errors.Add("CAP FA [$($CAP.displayName)]: $_") + } + } + + # Delete CAP assets that no longer exist in M365 + $CurrentCAPIds = $ConditionalAccessPolicies | ForEach-Object { $_.id } + $OrphanedAssets = $ExistingCAPAssets | Where-Object { $_.traits.'policy-id' -notin $CurrentCAPIds } + foreach ($Orphan in $OrphanedAssets) { + try { + $PolicyName = if ($Orphan.traits.'policy-name') { $Orphan.traits.'policy-name' } else { "ID: $($Orphan.traits.'policy-id')" } + $null = Invoke-ITGlueRequest -Method DELETE -Endpoint "/flexible_assets/$($Orphan.id)" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl + $Result.Logs.Add("Deleted orphaned CAP: $PolicyName") + + # Remove from cache + $CachedAsset = Get-CIPPAzDataTableEntity @ITGlueAssetCache -Filter "PartitionKey eq 'ITGlueCAP' and RowKey eq '$($Orphan.id)'" + if ($CachedAsset) { + Remove-AzDataTableEntity @ITGlueAssetCache -Entity $CachedAsset -Force + } + } catch { + $PolicyName = if ($Orphan.traits.'policy-name') { $Orphan.traits.'policy-name' } else { "ID: $($Orphan.traits.'policy-id')" } + $Result.Errors.Add("Failed to delete orphaned CAP [$PolicyName]: $_") + } + } + + $Result.Logs.Add("Conditional Access Policies: $($Result.UpdatedCount) updated, $($Result.SkippedCount) unchanged") + } catch { + $Result.Errors.Add("Conditional Access Policies block failed: $_") + } + + return $Result +} diff --git a/Modules/CippExtensions/Public/Extension Functions/Push-CippExtensionData.ps1 b/Modules/CippExtensions/Public/Extension Functions/Push-CippExtensionData.ps1 index d00d60337703..4062d62a10b0 100644 --- a/Modules/CippExtensions/Public/Extension Functions/Push-CippExtensionData.ps1 +++ b/Modules/CippExtensions/Public/Extension Functions/Push-CippExtensionData.ps1 @@ -14,6 +14,12 @@ function Push-CippExtensionData { Invoke-HuduExtensionSync -Configuration $Config -TenantFilter $TenantFilter } } + 'ITGlue' { + if ($Config.ITGlue.Enabled) { + Write-Host 'Performing ITGlue Extension Sync...' + Invoke-ITGlueExtensionSync -Configuration $Config -TenantFilter $TenantFilter + } + } 'CustomData' { Write-Host 'Perfoming Custom Data Extension Sync...' Invoke-CustomDataSync -TenantFilter $TenantFilter diff --git a/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 b/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 index 1cef75d498fe..3ac819a2fd35 100644 --- a/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 +++ b/Modules/CippExtensions/Public/Extension Functions/Register-CippExtensionScheduledTasks.ps1 @@ -2,7 +2,7 @@ function Register-CIPPExtensionScheduledTasks { param( [switch]$Reschedule, [int64]$NextSync = (([datetime]::UtcNow.AddMinutes(30)) - (Get-Date '1/1/1970')).TotalSeconds, - [string[]]$Extensions = @('Hudu', 'NinjaOne', 'CustomData') + [string[]]$Extensions = @('Hudu', 'NinjaOne', 'CustomData', 'ITGlue') ) # get extension configuration and mappings table diff --git a/Modules/CippExtensions/Public/ITGlue/Connect-ITGlueAPI.ps1 b/Modules/CippExtensions/Public/ITGlue/Connect-ITGlueAPI.ps1 new file mode 100644 index 000000000000..4f0b84f77393 --- /dev/null +++ b/Modules/CippExtensions/Public/ITGlue/Connect-ITGlueAPI.ps1 @@ -0,0 +1,31 @@ +function Connect-ITGlueAPI { + <# + .FUNCTIONALITY + Internal + .SYNOPSIS + Retrieves the ITGlue API key from KeyVault and builds the connection object + (headers + base URL) used by all subsequent ITGlue API calls. + #> + [CmdletBinding()] + param ( + $Configuration + ) + + $APIKey = Get-ExtensionAPIKey -Extension 'ITGlue' + + # Resolve base URL from region setting; default to US if not set + $Region = $Configuration.ITGlue.Region + $BaseUrl = if ($Region -and $Region -ne '') { + 'https://{0}' -f $Region.TrimStart('https://').TrimEnd('/') + } else { + 'https://api.itglue.com' + } + + return [PSCustomObject]@{ + Headers = @{ + 'x-api-key' = $APIKey + 'Content-Type' = 'application/vnd.api+json' + } + BaseUrl = $BaseUrl + } +} diff --git a/Modules/CippExtensions/Public/ITGlue/Get-ITGlueFieldMapping.ps1 b/Modules/CippExtensions/Public/ITGlue/Get-ITGlueFieldMapping.ps1 new file mode 100644 index 000000000000..dfa10707ef0a --- /dev/null +++ b/Modules/CippExtensions/Public/ITGlue/Get-ITGlueFieldMapping.ps1 @@ -0,0 +1,74 @@ +function Get-ITGlueFieldMapping { + <# + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param ( + $CIPPMapping + ) + + $Mappings = Get-ExtensionMapping -Extension 'ITGlueField' + + $CIPPFieldHeaders = @( + [PSCustomObject]@{ + Title = 'ITGlue Flexible Asset Types' + FieldType = 'FlexibleAssetTypes' + Description = 'Map your ITGlue Flexible Asset Types to the CIPP data type. A "Microsoft 365" rich-text field will be added to the layout if it does not already exist. Native Contacts (users) and Configurations (devices) are controlled separately by the toggle settings.' + } + ) + + $CIPPFields = @( + [PSCustomObject]@{ + FieldName = 'Users' + FieldLabel = 'Flexible Asset Type for M365 Users' + FieldType = 'FlexibleAssetTypes' + } + [PSCustomObject]@{ + FieldName = 'Devices' + FieldLabel = 'Flexible Asset Type for M365 Devices' + FieldType = 'FlexibleAssetTypes' + } + [PSCustomObject]@{ + FieldName = 'ConditionalAccessPolicies' + FieldLabel = 'Flexible Asset Type for M365 Conditional Access Policies' + FieldType = 'FlexibleAssetTypes' + } + ) + + $Table = Get-CIPPTable -TableName Extensionsconfig + try { + $Configuration = (Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -ea stop + $Conn = Connect-ITGlueAPI -Configuration $Configuration + + try { + $RawTypes = Invoke-ITGlueRequest -Method GET -Endpoint '/flexible_asset_types' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl + $FlexibleAssetTypes = $RawTypes | Select-Object @{Name = 'FieldType'; Expression = { 'FlexibleAssetTypes' } }, + @{Name = 'value'; Expression = { "$($_.id)" } }, + @{Name = 'name'; Expression = { $_.name } } + } catch { + $Message = $_.Exception.Message + Write-Warning "Could not get ITGlue Flexible Asset Types, error: $Message" + Write-LogMessage -Message "Could not get ITGlue Flexible Asset Types, error: $Message" -Level Error -tenant 'CIPP' -API 'ITGlueMapping' + $FlexibleAssetTypes = @([PSCustomObject]@{ FieldType = 'FlexibleAssetTypes'; name = "Could not get Flexible Asset Types: $Message"; value = '-1' }) + } + } catch { + $Message = $_.Exception.Message + Write-Warning "Could not connect to ITGlue, error: $Message" + Write-LogMessage -Message "Could not connect to ITGlue, error: $Message" -Level Error -tenant 'CIPP' -API 'ITGlueMapping' + $FlexibleAssetTypes = @([PSCustomObject]@{ FieldType = 'FlexibleAssetTypes'; name = "Could not connect to ITGlue: $Message"; value = '-1' }) + } + + $Unset = [PSCustomObject]@{ + name = '--- Do not synchronize ---' + value = $null + type = 'unset' + } + + return [PSCustomObject]@{ + CIPPFields = $CIPPFields + CIPPFieldHeaders = $CIPPFieldHeaders + IntegrationFields = @($Unset) + @($FlexibleAssetTypes) + Mappings = @($Mappings) + } +} diff --git a/Modules/CippExtensions/Public/ITGlue/Get-ITGlueMapping.ps1 b/Modules/CippExtensions/Public/ITGlue/Get-ITGlueMapping.ps1 new file mode 100644 index 000000000000..36593ba754ab --- /dev/null +++ b/Modules/CippExtensions/Public/ITGlue/Get-ITGlueMapping.ps1 @@ -0,0 +1,53 @@ +function Get-ITGlueMapping { + <# + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param ( + $CIPPMapping + ) + + $ExtensionMappings = Get-ExtensionMapping -Extension 'ITGlue' + $Tenants = Get-Tenants -IncludeErrors + + $Mappings = foreach ($Mapping in $ExtensionMappings) { + $Tenant = $Tenants | Where-Object { $_.RowKey -eq $Mapping.RowKey } + if ($Tenant) { + [PSCustomObject]@{ + TenantId = $Tenant.customerId + Tenant = $Tenant.displayName + TenantDomain = $Tenant.defaultDomainName + IntegrationId = $Mapping.IntegrationId + IntegrationName = $Mapping.IntegrationName + } + } + } + + $Table = Get-CIPPTable -TableName Extensionsconfig + try { + $Configuration = (Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -ea stop + $Conn = Connect-ITGlueAPI -Configuration $Configuration + $ITGlueOrgs = Invoke-ITGlueRequest -Method GET -Endpoint '/organizations' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl + } catch { + $Message = if ($_.ErrorDetails.Message) { + Get-NormalizedError -Message $_.ErrorDetails.Message + } else { + $_.Exception.message + } + Write-LogMessage -Message "Could not get ITGlue Organizations, error: $Message" -Level Error -tenant 'CIPP' -API 'ITGlueMapping' + $ITGlueOrgs = @([PSCustomObject]@{ name = "Could not get ITGlue Organizations, error: $Message"; id = '-1' }) + } + + $Companies = $ITGlueOrgs | ForEach-Object { + [PSCustomObject]@{ + name = $_.name + value = "$($_.id)" + } + } + + return [PSCustomObject]@{ + Companies = @($Companies) + Mappings = @($Mappings) + } +} diff --git a/Modules/CippExtensions/Public/ITGlue/Invoke-ITGlueExtensionSync.ps1 b/Modules/CippExtensions/Public/ITGlue/Invoke-ITGlueExtensionSync.ps1 new file mode 100644 index 000000000000..4fe30f465036 --- /dev/null +++ b/Modules/CippExtensions/Public/ITGlue/Invoke-ITGlueExtensionSync.ps1 @@ -0,0 +1,738 @@ +function Invoke-ITGlueExtensionSync { + <# + .FUNCTIONALITY + Internal + #> + param( + $Configuration, + $TenantFilter + ) + + try { + $Conn = Connect-ITGlueAPI -Configuration $Configuration + $ITGlueConfig = $Configuration.ITGlue + + $Tenant = Get-Tenants -TenantFilter $TenantFilter -IncludeErrors + $CompanyResult = [PSCustomObject]@{ + Name = $Tenant.displayName + Users = 0 + Devices = 0 + Errors = [System.Collections.Generic.List[string]]@() + Logs = [System.Collections.Generic.List[string]]@() + } + + # Resolve org mapping and field mappings + $MappingTable = Get-CIPPTable -TableName 'CippMapping' + $Mappings = Get-CIPPAzDataTableEntity @MappingTable -Filter "PartitionKey eq 'ITGlueMapping' or PartitionKey eq 'ITGlueFieldMapping'" + $TenantMap = $Mappings | Where-Object { $_.PartitionKey -eq 'ITGlueMapping' -and $_.RowKey -eq $Tenant.customerId } + + if (!$TenantMap) { + return 'Tenant not found in ITGlue mapping table' + } + + $OrgId = $TenantMap.IntegrationId + $PeopleTypeId = ($Mappings | Where-Object { $_.PartitionKey -eq 'ITGlueFieldMapping' -and $_.RowKey -eq 'Users' }).IntegrationId + $DeviceTypeId = ($Mappings | Where-Object { $_.PartitionKey -eq 'ITGlueFieldMapping' -and $_.RowKey -eq 'Devices' }).IntegrationId + $CAPTypeId = ($Mappings | Where-Object { $_.PartitionKey -eq 'ITGlueFieldMapping' -and $_.RowKey -eq 'ConditionalAccessPolicies' }).IntegrationId + + # Get M365 cached data + $ExtensionCache = Get-CippExtensionReportingData -TenantFilter $Tenant.defaultDomainName -IncludeMailboxes + + # License friendly-name table + $ModuleBase = Get-Module -Name CippExtensions | Select-Object -ExpandProperty ModuleBase + $LicTable = Import-Csv (Join-Path $ModuleBase 'ConversionTable.csv') + + # CIPP URL for deep links + $ConfigTable = Get-CIPPTable -tablename 'Config' + $CIPPConfigRow = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'InstanceProperties' and RowKey eq 'CIPPURL'" + $CIPPURL = 'https://{0}' -f $CIPPConfigRow.Value + + $CompanyResult.Logs.Add('Starting ITGlue Extension Sync') + + # Get asset cache table for hash-based change detection + $ITGlueAssetCache = Get-CIPPTable -tablename 'CacheITGlueAssets' + + # Flatten M365 data + $Users = $ExtensionCache.Users + $LicensedUsers = $Users | Where-Object { $null -ne $_.assignedLicenses.skuId } | Sort-Object userPrincipalName + $Devices = $ExtensionCache.Devices + $AllRoles = $ExtensionCache.AllRoles + $AllGroups = $ExtensionCache.Groups + $Licenses = $ExtensionCache.Licenses + $Domains = $ExtensionCache.Domains + $Mailboxes = $ExtensionCache.Mailboxes + + # Get formatted CAPs with human-readable names (uses Invoke-ListConditionalAccessPolicies logic) + if ($ITGlueConfig.SyncConditionalAccessPolicies -eq $true -and ![string]::IsNullOrEmpty($CAPTypeId)) { + try { + $CAPResult = Invoke-ListConditionalAccessPolicies -Request @{ Query = @{ tenantFilter = $TenantFilter } } + $ConditionalAccessPolicies = $CAPResult.Body.Results + } catch { + $CompanyResult.Errors.Add("Failed to fetch formatted CAPs: $_") + $ConditionalAccessPolicies = @() + } + } else { + $ConditionalAccessPolicies = @() + } + + $CompanyResult.Users = ($LicensedUsers | Measure-Object).count + $CompanyResult.Devices = ($Devices | Measure-Object).count + + # Note: Conditional Access Policy flexible asset type is now created manually via Field Mapping UI + + # Serial exclusion list + $DefaultSerials = [System.Collections.Generic.List[string]]@( + 'SystemSerialNumber', 'System Serial Number', + '0123456789', '123456789' + ) + if ($ITGlueConfig.ExcludeSerials) { + $DefaultSerials.AddRange(($ITGlueConfig.ExcludeSerials -split ',').Trim()) + } + $ExcludeSerials = $DefaultSerials + + # Helper: ensure required fields exist in an ITGlue Flexible Asset Type. + function Add-ITGlueFlexibleAssetFields { + param( + $TypeId, + [array]$FieldsToAdd, # Array of @{ Name = ''; Kind = 'Textbox'; ShowInList = $false } + $Conn + ) + + # GET type with its fields included (one call for all fields) + $TypeResponse = Invoke-RestMethod -Uri "$($Conn.BaseUrl)/flexible_asset_types/$TypeId`?include=flexible_asset_fields" -Method GET -Headers $Conn.Headers + $IncludedFields = $TypeResponse.included | Where-Object { $_.type -eq 'flexible-asset-fields' } + $ExistingNames = $IncludedFields | ForEach-Object { $_.attributes.name } + + # Filter to only fields that don't exist + $NewFields = $FieldsToAdd | Where-Object { $_.Name -notin $ExistingNames } + + if ($NewFields.Count -eq 0) { + return # All fields already exist + } + + # Build complete field list: existing (with IDs) + new fields + $AllFields = [System.Collections.Generic.List[object]]::new() + foreach ($F in $IncludedFields) { + $AllFields.Add([ordered]@{ + id = $F.id + name = $F.attributes.name + kind = $F.attributes.kind + required = $F.attributes.required + 'show-in-list' = $F.attributes.'show-in-list' + position = $F.attributes.position + }) + } + + foreach ($NewField in $NewFields) { + $AllFields.Add([ordered]@{ + name = $NewField.Name + kind = $NewField.Kind + required = $false + 'show-in-list' = $NewField.ShowInList + }) + } + + $PatchBody = @{ + data = @{ + type = 'flexible-asset-types' + id = $TypeId + attributes = @{ + 'flexible-asset-fields' = @($AllFields) + } + } + } | ConvertTo-Json -Depth 20 -Compress + + $null = Invoke-RestMethod -Uri "$($Conn.BaseUrl)/flexible_asset_types/$TypeId" -Method PATCH -Headers $Conn.Headers -Body $PatchBody + } + + # Helper: Convert Out-String output (newline-separated) to comma-separated + # Used for formatting CAP values from Invoke-ListConditionalAccessPolicies + function Format-CAPValue($Value) { + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + ($Value.Trim() -split "`n" | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() }) -join ', ' + } + + # USERS — FLEXIBLE ASSETS + if (![string]::IsNullOrEmpty($PeopleTypeId)) { + try { + # Batch field additions into single API call + Add-ITGlueFlexibleAssetFields -TypeId $PeopleTypeId -FieldsToAdd @( + @{ Name = 'Email Address'; Kind = 'Text'; ShowInList = $true } + @{ Name = 'Microsoft 365'; Kind = 'Textbox'; ShowInList = $false } + ) -Conn $Conn + + $ExistingPeopleAssets = Invoke-ITGlueRequest -Method GET -Endpoint '/flexible_assets' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -QueryParams @{ + 'filter[flexible_asset_type_id]' = $PeopleTypeId + 'filter[organization_id]' = $OrgId + } + + $UserUpdatedCount = 0 + $UserSkippedCount = 0 + + foreach ($User in $LicensedUsers) { + try { + $UserLicenseNames = foreach ($Lic in $User.assignedLicenses) { + $FriendlyName = ($LicTable | Where-Object { $_.SkuId -eq $Lic.skuId }).ProductName + if ($FriendlyName) { $FriendlyName } else { $Lic.skuId } + } + $UserGroups = ($AllGroups | Where-Object { $_.members.id -contains $User.id }).displayName -join ', ' + $UserRoles = ($AllRoles | Where-Object { $_.members.userPrincipalName -contains $User.userPrincipalName }).displayName -join ', ' + $Mailbox = $Mailboxes | Where-Object { $_.UPN -eq $User.userPrincipalName } | Select-Object -First 1 + + # Build HTML WITHOUT timestamp first (for hash calculation) + $M365HtmlCore = @" +Licenses: $($UserLicenseNames -join ', ')
+Groups: $(if ($UserGroups) { $UserGroups } else { 'None' })
+Admin Roles: $(if ($UserRoles) { $UserRoles } else { 'None' })
+Account Enabled: $($User.accountEnabled)
+Job Title: $($User.jobTitle)
+Department: $($User.department)
+$(if ($Mailbox) { "Mailbox Size: $($Mailbox.TotalItemSize)
" }) + +"@ + + # Hash-based change detection - hash content WITHOUT timestamp + $ContentToHash = "$($User.displayName)|$($User.userPrincipalName)|$($User.accountEnabled)|$M365HtmlCore" + $NewHash = Get-StringHash -String $ContentToHash + + # Add timestamp AFTER hashing (for display only) + $M365Html = $M365HtmlCore + "`nLast updated: $(Get-Date -Format 'yyyy-MM-dd HH:mm') UTC
" + + $Traits = @{ + 'name' = $User.displayName + 'email-address' = $User.userPrincipalName + 'microsoft-365' = $M365Html + } + + $ExistingAsset = $ExistingPeopleAssets | Where-Object { $_.traits.'email-address' -eq $User.userPrincipalName } | Select-Object -First 1 + + # Check if content has changed by comparing hashes + $NeedsUpdate = $true + if ($ExistingAsset) { + $CachedAsset = Get-CIPPAzDataTableEntity @ITGlueAssetCache -Filter "PartitionKey eq 'ITGlueUser' and RowKey eq '$($ExistingAsset.id)'" + if ($CachedAsset -and $CachedAsset.Hash -eq $NewHash) { + $NeedsUpdate = $false + $UserSkippedCount++ + } + } + + if ($NeedsUpdate) { + $AssetAttribs = @{ + 'organization-id' = $OrgId + 'flexible-asset-type-id' = $PeopleTypeId + traits = $Traits + } + + if ($ExistingAsset) { + $null = Invoke-ITGlueRequest -Method PATCH -Endpoint "/flexible_assets/$($ExistingAsset.id)" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'flexible-assets' -ResourceId $ExistingAsset.id -Attributes $AssetAttribs + $AssetId = $ExistingAsset.id + } else { + $CreatedAsset = Invoke-ITGlueRequest -Method POST -Endpoint '/flexible_assets' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'flexible-assets' -Attributes $AssetAttribs + $AssetId = $CreatedAsset[0].id + } + + # Cache the hash to avoid unnecessary updates on next sync + $CacheEntry = @{ + PartitionKey = 'ITGlueUser' + RowKey = [string]$AssetId + OrgId = [string]$OrgId + UserUPN = $User.userPrincipalName + Hash = $NewHash + } + Add-CIPPAzDataTableEntity @ITGlueAssetCache -Entity $CacheEntry -Force + + $UserUpdatedCount++ + } + } catch { + $CompanyResult.Errors.Add("User FA [$($User.userPrincipalName)]: $_") + } + } + + $CompanyResult.Logs.Add("Users Flexible Assets: $UserUpdatedCount updated, $UserSkippedCount unchanged") + } catch { + $CompanyResult.Errors.Add("Users Flexible Assets block failed: $_") + } + } + + # USERS — NATIVE CONTACTS + if ($ITGlueConfig.CreateMissingContacts -eq $true) { + try { + $ExistingContacts = Invoke-ITGlueRequest -Method GET -Endpoint '/contacts' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -QueryParams @{ + 'filter[organization_id]' = $OrgId + } + + foreach ($User in $LicensedUsers) { + try { + # Match by primary email — contacts store emails in a nested array + $ExistingContact = $ExistingContacts | Where-Object { + ($_.'contact-emails' | Where-Object { $_.value -eq $User.userPrincipalName }) -ne $null + } | Select-Object -First 1 + + $ContactAttribs = @{ + 'organization-id' = $OrgId + 'first-name' = if ($User.givenName) { $User.givenName } else { $User.displayName } + 'last-name' = $User.surname + title = $User.jobTitle + 'contact-emails' = @(@{ value = $User.userPrincipalName; primary = $true; 'label-name' = 'Work' }) + } + + if ($ExistingContact) { + $null = Invoke-ITGlueRequest -Method PATCH -Endpoint "/contacts/$($ExistingContact.id)" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'contacts' -ResourceId $ExistingContact.id -Attributes $ContactAttribs + } else { + $null = Invoke-ITGlueRequest -Method POST -Endpoint '/contacts' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'contacts' -Attributes $ContactAttribs + } + } catch { + $CompanyResult.Errors.Add("Contact [$($User.userPrincipalName)]: $_") + } + } + + $CompanyResult.Logs.Add("Native Contacts: Processed $($LicensedUsers.Count) users") + } catch { + $CompanyResult.Errors.Add("Native Contacts block failed: $_") + } + } + + # DEVICES — FLEXIBLE ASSETS + if (![string]::IsNullOrEmpty($DeviceTypeId)) { + try { + Add-ITGlueFlexibleAssetFields -TypeId $DeviceTypeId -FieldsToAdd @( + @{ Name = 'Microsoft 365'; Kind = 'Textbox'; ShowInList = $false } + ) -Conn $Conn + + $ExistingDeviceAssets = Invoke-ITGlueRequest -Method GET -Endpoint '/flexible_assets' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -QueryParams @{ + 'filter[flexible_asset_type_id]' = $DeviceTypeId + 'filter[organization_id]' = $OrgId + } + + $SyncDevices = $Devices | Where-Object { + $_.serialNumber -notin $ExcludeSerials -and + ![string]::IsNullOrWhiteSpace($_.serialNumber) -and + $_.managedDeviceOwnerType -eq 'company' + } + + $DeviceUpdatedCount = 0 + $DeviceSkippedCount = 0 + + foreach ($Device in $SyncDevices) { + try { + # Build HTML WITHOUT timestamp first (for hash calculation) + $M365DeviceHtmlCore = @" +Serial: $($Device.serialNumber)
+OS: $($Device.operatingSystem) $($Device.osVersion)
+Manufacturer / Model: $($Device.manufacturer) $($Device.model)
+Compliance: $($Device.complianceState)
+Enrolled: $($Device.enrolledDateTime)
+Last Device Sync: $($Device.lastSyncDateTime)
+Primary User: $($Device.userDisplayName) ($($Device.userPrincipalName))
+ +"@ + + # Hash-based change detection - hash content WITHOUT timestamp + $ContentToHash = "$($Device.deviceName)|$($Device.complianceState)|$($Device.lastSyncDateTime)|$M365DeviceHtmlCore" + $NewHash = Get-StringHash -String $ContentToHash + + # Add timestamp AFTER hashing (for display only) + $M365DeviceHtml = $M365DeviceHtmlCore + "`nLast updated: $(Get-Date -Format 'yyyy-MM-dd HH:mm') UTC
" + + $DeviceTraits = @{ + 'name' = $Device.deviceName + 'microsoft-365' = $M365DeviceHtml + } + + $ExistingAsset = $ExistingDeviceAssets | Where-Object { $_.traits.name -eq $Device.deviceName } | Select-Object -First 1 + + # Check if content has changed by comparing hashes + $NeedsUpdate = $true + if ($ExistingAsset) { + $CachedAsset = Get-CIPPAzDataTableEntity @ITGlueAssetCache -Filter "PartitionKey eq 'ITGlueDevice' and RowKey eq '$($ExistingAsset.id)'" + if ($CachedAsset -and $CachedAsset.Hash -eq $NewHash) { + $NeedsUpdate = $false + $DeviceSkippedCount++ + } + } + + if ($NeedsUpdate) { + $AssetAttribs = @{ + 'organization-id' = $OrgId + 'flexible-asset-type-id' = $DeviceTypeId + traits = $DeviceTraits + } + + if ($ExistingAsset) { + $null = Invoke-ITGlueRequest -Method PATCH -Endpoint "/flexible_assets/$($ExistingAsset.id)" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'flexible-assets' -ResourceId $ExistingAsset.id -Attributes $AssetAttribs + $AssetId = $ExistingAsset.id + } else { + $CreatedAsset = Invoke-ITGlueRequest -Method POST -Endpoint '/flexible_assets' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'flexible-assets' -Attributes $AssetAttribs + $AssetId = $CreatedAsset[0].id + } + + # Cache the hash to avoid unnecessary updates on next sync + $CacheEntry = @{ + PartitionKey = 'ITGlueDevice' + RowKey = [string]$AssetId + OrgId = [string]$OrgId + DeviceName = $Device.deviceName + Hash = $NewHash + } + Add-CIPPAzDataTableEntity @ITGlueAssetCache -Entity $CacheEntry -Force + + $DeviceUpdatedCount++ + } + } catch { + $CompanyResult.Errors.Add("Device FA [$($Device.deviceName)]: $_") + } + } + + $CompanyResult.Logs.Add("Device Flexible Assets: $DeviceUpdatedCount updated, $DeviceSkippedCount unchanged") + } catch { + $CompanyResult.Errors.Add("Device Flexible Assets block failed: $_") + } + } + + # DEVICES — NATIVE CONFIGURATIONS + if ($ITGlueConfig.CreateMissingConfigurations -eq $true) { + try { + # Cache configuration types for the whole sync run + $ConfigTypes = Invoke-ITGlueRequest -Method GET -Endpoint '/configuration_types' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl + + $ExistingConfigs = Invoke-ITGlueRequest -Method GET -Endpoint '/configurations' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -QueryParams @{ + 'filter[organization_id]' = $OrgId + } + + $SyncDevices = $Devices | Where-Object { + $_.serialNumber -notin $ExcludeSerials -and + ![string]::IsNullOrWhiteSpace($_.serialNumber) -and + $_.managedDeviceOwnerType -eq 'company' + } + + foreach ($Device in $SyncDevices) { + try { + # Map Intune OS to a common ITGlue configuration type name + $ConfigTypeName = switch -Wildcard ($Device.operatingSystem) { + 'Windows*' { 'Workstation' } + 'macOS*' { 'Mac' } + 'iOS*' { 'Mobile Device' } + 'Android*' { 'Mobile Device' } + default { 'Workstation' } + } + $ConfigType = $ConfigTypes | Where-Object { $_.name -like "*$ConfigTypeName*" } | Select-Object -First 1 + if (!$ConfigType) { $ConfigType = $ConfigTypes | Select-Object -First 1 } + + $ConfigAttribs = @{ + 'organization-id' = $OrgId + 'configuration-type-id' = $ConfigType.id + name = $Device.deviceName + hostname = $Device.deviceName + 'serial-number' = $Device.serialNumber + 'operating-system' = "$($Device.operatingSystem) $($Device.osVersion)" + notes = "Manufacturer: $($Device.manufacturer)`nModel: $($Device.model)`nCompliance: $($Device.complianceState)`nEnrolled: $($Device.enrolledDateTime)`nLast Sync: $($Device.lastSyncDateTime)`nUser: $($Device.userDisplayName) ($($Device.userPrincipalName))" + } + + # Prefer serial-number match; fall back to device name + $ExistingConfig = $ExistingConfigs | Where-Object { $_.'serial-number' -eq $Device.serialNumber } | Select-Object -First 1 + if (!$ExistingConfig) { + $ExistingConfig = $ExistingConfigs | Where-Object { $_.name -eq $Device.deviceName } | Select-Object -First 1 + } + + if ($ExistingConfig) { + $null = Invoke-ITGlueRequest -Method PATCH -Endpoint "/configurations/$($ExistingConfig.id)" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'configurations' -ResourceId $ExistingConfig.id -Attributes $ConfigAttribs + } else { + $null = Invoke-ITGlueRequest -Method POST -Endpoint '/configurations' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'configurations' -Attributes $ConfigAttribs + } + } catch { + $CompanyResult.Errors.Add("Config [$($Device.deviceName)]: $_") + } + } + + $CompanyResult.Logs.Add("Native Configurations: Processed $($SyncDevices.Count) devices") + } catch { + $CompanyResult.Errors.Add("Native Configurations block failed: $_") + } + } + + # CONDITIONAL ACCESS POLICIES — FLEXIBLE ASSETS + if ($ITGlueConfig.SyncConditionalAccessPolicies -eq $true -and ![string]::IsNullOrEmpty($CAPTypeId) -and $ConditionalAccessPolicies -and $ConditionalAccessPolicies.Count -gt 0) { + try { + Add-ITGlueFlexibleAssetFields -TypeId $CAPTypeId -FieldsToAdd @( + @{ Name = 'Policy Name'; Kind = 'Text'; ShowInList = $true } + @{ Name = 'Policy ID'; Kind = 'Text'; ShowInList = $false } + @{ Name = 'State'; Kind = 'Text'; ShowInList = $true } + @{ Name = 'Policy Details'; Kind = 'Textbox'; ShowInList = $false } + @{ Name = 'Raw JSON'; Kind = 'Textbox'; ShowInList = $false } + ) -Conn $Conn + + $ExistingCAPAssets = Invoke-ITGlueRequest -Method GET -Endpoint '/flexible_assets' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -QueryParams @{ + 'filter[flexible_asset_type_id]' = $CAPTypeId + 'filter[organization_id]' = $OrgId + } + + $UpdatedCount = 0 + $SkippedCount = 0 + + foreach ($CAP in $ConditionalAccessPolicies) { + try { + $StateIcon = switch ($CAP.state) { + 'enabled' { '✓ Enabled' } + 'disabled' { '✗ Disabled' } + 'enabledForReportingButNotEnforced' { '⚠ Report-Only' } + default { $CAP.state } + } + + # Build content for hash - ONLY actual policy settings (exclude dates/timestamps) + $ContentForHash = @" +State: $StateIcon +Client App Types: $($CAP.clientAppTypes) +Platforms (Include): $($CAP.includePlatforms) +Platforms (Exclude): $($CAP.excludePlatforms) +Locations (Include): $($CAP.includeLocations) +Locations (Exclude): $($CAP.excludeLocations) +Applications (Include): $($CAP.includeApplications) +Applications (Exclude): $($CAP.excludeApplications) +User Actions: $(Format-CAPValue $CAP.includeUserActions) +Auth Context: $(Format-CAPValue $CAP.includeAuthenticationContextClassReferences) +Users (Include): $(Format-CAPValue $CAP.includeUsers) +Users (Exclude): $(Format-CAPValue $CAP.excludeUsers) +Groups (Include): $(Format-CAPValue $CAP.includeGroups) +Groups (Exclude): $(Format-CAPValue $CAP.excludeGroups) +Roles (Include): $(Format-CAPValue $CAP.includeRoles) +Roles (Exclude): $(Format-CAPValue $CAP.excludeRoles) +Operator: $($CAP.grantControlsOperator) +Built-in Controls: $($CAP.builtInControls) +Custom Auth Factors: $($CAP.customAuthenticationFactors) +Terms of Use: $($CAP.termsOfUse) +"@ + + # Hash-based change detection - hash ONLY policy content (not dates or display timestamps) + $ContentToHash = "$($CAP.displayName)|$($CAP.state)|$ContentForHash" + $NewHash = Get-StringHash -String $ContentToHash + + # Build full HTML with dates for display (dates NOT in hash) + $DetailsHtml = @" +Created: $($CAP.createdDateTime)
+Modified: $($CAP.modifiedDateTime)
| Client App Types | $($CAP.clientAppTypes) |
| Platforms (Include) | $($CAP.includePlatforms) |
| Platforms (Exclude) | $($CAP.excludePlatforms) |
| Locations (Include) | $($CAP.includeLocations) |
| Locations (Exclude) | $($CAP.excludeLocations) |
| Applications (Include) | $($CAP.includeApplications) |
| Applications (Exclude) | $($CAP.excludeApplications) |
| User Actions | $(Format-CAPValue $CAP.includeUserActions) |
| Auth Context | $(Format-CAPValue $CAP.includeAuthenticationContextClassReferences) |
| Users (Include) | $(Format-CAPValue $CAP.includeUsers) |
| Users (Exclude) | $(Format-CAPValue $CAP.excludeUsers) |
| Groups (Include) | $(Format-CAPValue $CAP.includeGroups) |
| Groups (Exclude) | $(Format-CAPValue $CAP.excludeGroups) |
| Roles (Include) | $(Format-CAPValue $CAP.includeRoles) |
| Roles (Exclude) | $(Format-CAPValue $CAP.excludeRoles) |
| Operator | $($CAP.grantControlsOperator) |
| Built-in Controls | $($CAP.builtInControls) |
| Custom Auth Factors | $($CAP.customAuthenticationFactors) |
| Terms of Use | $($CAP.termsOfUse) |
Last updated: $(Get-Date -Format 'yyyy-MM-dd HH:mm') UTC
+"@ + + $CAPTraits = @{ + 'policy-name' = $CAP.displayName + 'policy-id' = $CAP.id + 'state' = $CAP.state + 'policy-details' = $DetailsHtml + 'raw-json' = $CAP.rawjson + } + + $ExistingAsset = $ExistingCAPAssets | Where-Object { $_.traits.'policy-id' -eq $CAP.id } | Select-Object -First 1 + + # Check if content has changed by comparing hashes + $NeedsUpdate = $true + if ($ExistingAsset) { + $CachedAsset = Get-CIPPAzDataTableEntity @ITGlueAssetCache -Filter "PartitionKey eq 'ITGlueCAP' and RowKey eq '$($ExistingAsset.id)'" + if ($CachedAsset -and $CachedAsset.Hash -eq $NewHash) { + $NeedsUpdate = $false + $SkippedCount++ + } else { + # Debug: Log why hash changed + if ($CachedAsset) { + Write-LogMessage -API 'ITGlueSync' -tenant $TenantFilter -message "CAP hash mismatch for $($CAP.displayName): Cached=$($CachedAsset.Hash.Substring(0,8))... New=$($NewHash.Substring(0,8))..." -sev Debug + } else { + Write-LogMessage -API 'ITGlueSync' -tenant $TenantFilter -message "CAP no cache found for $($CAP.displayName) (AssetID: $($ExistingAsset.id))" -sev Debug + } + } + } + + if ($NeedsUpdate) { + $AssetAttribs = @{ + 'organization-id' = $OrgId + 'flexible-asset-type-id' = $CAPTypeId + traits = $CAPTraits + } + + if ($ExistingAsset) { + $null = Invoke-ITGlueRequest -Method PATCH -Endpoint "/flexible_assets/$($ExistingAsset.id)" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'flexible-assets' -ResourceId $ExistingAsset.id -Attributes $AssetAttribs + $AssetId = $ExistingAsset.id + } else { + $CreatedAsset = Invoke-ITGlueRequest -Method POST -Endpoint '/flexible_assets' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'flexible-assets' -Attributes $AssetAttribs + $AssetId = $CreatedAsset[0].id + } + + # Cache the hash to avoid unnecessary updates on next sync + $CacheEntry = @{ + PartitionKey = 'ITGlueCAP' + RowKey = [string]$AssetId + OrgId = [string]$OrgId + PolicyId = $CAP.id + Hash = $NewHash + } + Add-CIPPAzDataTableEntity @ITGlueAssetCache -Entity $CacheEntry -Force + + $UpdatedCount++ + } + } catch { + $CompanyResult.Errors.Add("CAP FA [$($CAP.displayName)]: $_") + } + } + + # Delete CAP assets that no longer exist in M365 + $CurrentCAPIds = $ConditionalAccessPolicies | ForEach-Object { $_.id } + $OrphanedAssets = $ExistingCAPAssets | Where-Object { $_.traits.'policy-id' -notin $CurrentCAPIds } + foreach ($Orphan in $OrphanedAssets) { + try { + $PolicyName = if ($Orphan.traits.'policy-name') { $Orphan.traits.'policy-name' } else { "ID: $($Orphan.traits.'policy-id')" } + $null = Invoke-ITGlueRequest -Method DELETE -Endpoint "/flexible_assets/$($Orphan.id)" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl + $CompanyResult.Logs.Add("Deleted orphaned CAP: $PolicyName") + + # Remove from cache + $CachedAsset = Get-CIPPAzDataTableEntity @ITGlueAssetCache -Filter "PartitionKey eq 'ITGlueCAP' and RowKey eq '$($Orphan.id)'" + if ($CachedAsset) { + Remove-AzDataTableEntity @ITGlueAssetCache -Entity $CachedAsset -Force + } + } catch { + $PolicyName = if ($Orphan.traits.'policy-name') { $Orphan.traits.'policy-name' } else { "ID: $($Orphan.traits.'policy-id')" } + $CompanyResult.Errors.Add("Failed to delete orphaned CAP [$PolicyName]: $_") + } + } + + $CompanyResult.Logs.Add("Conditional Access Policies: $UpdatedCount updated, $SkippedCount unchanged") + } catch { + $CompanyResult.Errors.Add("Conditional Access Policies block failed: $_") + } + } + + # M365 OVERVIEW — update organisation quick-notes (preserving existing content) + if ($ITGlueConfig.ImportDomains -eq $true -and $Domains) { + try { + $VerifiedDomainList = ($Domains | Where-Object { $_.isVerified -eq $true }).id + $VerifiedDomains = if ($VerifiedDomainList) { + 'None
' + } + + # Build license table rows + $LicenseRows = if ($Licenses) { + foreach ($License in ($Licenses | Where-Object { $_.prepaidUnits.enabled -gt 0 } | Sort-Object -Property skuPartNumber)) { + $FriendlyName = ($LicTable | Where-Object { $_.SkuId -eq $License.skuId }).ProductName + if (-not $FriendlyName) { $FriendlyName = $License.skuPartNumber } + "| License | Used / Total |
|---|
No license data available
' + } + + # CIPP managed section wrapped in aTenant: $($Tenant.displayName)
+Tenant ID: $($Tenant.customerId)
+Default Domain: $($Tenant.defaultDomainName)
Verified Domains:
+$VerifiedDomains + +| Licensed Users | $($CompanyResult.Users) |
| Managed Devices | $($CompanyResult.Devices) |
View in CIPP | +M365 Admin | +Entra Admin
+ +Last updated: $(Get-Date -Format 'yyyy-MM-dd HH:mm') UTC (CIPP Managed)
+$CippMarkerEnd +"@ + + # Get existing quick-notes from the organization + $ExistingOrg = Invoke-ITGlueRequest -Method GET -Endpoint "/organizations/$OrgId" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -FirstPageOnly + $ExistingNotes = $ExistingOrg.'quick-notes' + + # ITGlue reformats HTML, so use flexible regex that handles whitespace variations + if ($ExistingNotes -and $ExistingNotes -match '