From 03f4e3f04489b4c87d174e0c2b93249afdb6ce26 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Wed, 18 Mar 2026 10:33:57 +0000 Subject: [PATCH] Revert "Feature/itglue" --- .github/workflows/master_cippuznn2.yml | 40 - .../Invoke-ExecExtensionMapping.ps1 | 31 - .../Extensions/Invoke-ExecExtensionSync.ps1 | 4 - .../Extensions/Invoke-ExecExtensionTest.ps1 | 9 - .../Invoke-ExecExtensionsConfig.ps1 | 8 - ...voke-ExecITGlueCreateFlexibleAssetType.ps1 | 150 ---- .../Reports/Invoke-ListMailboxForwarding.ps1 | 5 +- .../ITGlue/Add-ITGlueFlexibleAssetFields.ps1 | 59 -- .../Private/ITGlue/Format-ITGlueCAPValue.ps1 | 13 - .../Private/ITGlue/Invoke-ITGlueRequest.ps1 | 134 ---- .../Sync-ITGlueConditionalAccessPolicies.ps1 | 202 ----- .../Push-CippExtensionData.ps1 | 6 - .../Register-CippExtensionScheduledTasks.ps1 | 2 +- .../Public/ITGlue/Connect-ITGlueAPI.ps1 | 31 - .../Public/ITGlue/Get-ITGlueFieldMapping.ps1 | 74 -- .../Public/ITGlue/Get-ITGlueMapping.ps1 | 53 -- .../ITGlue/Invoke-ITGlueExtensionSync.ps1 | 738 ------------------ .../ITGlue/New-ITGlueCAPolicyAssetType.ps1 | 194 ----- .../Public/ITGlue/Set-ITGlueMapping.ps1 | 36 - 19 files changed, 3 insertions(+), 1786 deletions(-) delete mode 100644 .github/workflows/master_cippuznn2.yml delete mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecITGlueCreateFlexibleAssetType.ps1 delete mode 100644 Modules/CippExtensions/Private/ITGlue/Add-ITGlueFlexibleAssetFields.ps1 delete mode 100644 Modules/CippExtensions/Private/ITGlue/Format-ITGlueCAPValue.ps1 delete mode 100644 Modules/CippExtensions/Private/ITGlue/Invoke-ITGlueRequest.ps1 delete mode 100644 Modules/CippExtensions/Private/ITGlue/Sync-ITGlueConditionalAccessPolicies.ps1 delete mode 100644 Modules/CippExtensions/Public/ITGlue/Connect-ITGlueAPI.ps1 delete mode 100644 Modules/CippExtensions/Public/ITGlue/Get-ITGlueFieldMapping.ps1 delete mode 100644 Modules/CippExtensions/Public/ITGlue/Get-ITGlueMapping.ps1 delete mode 100644 Modules/CippExtensions/Public/ITGlue/Invoke-ITGlueExtensionSync.ps1 delete mode 100644 Modules/CippExtensions/Public/ITGlue/New-ITGlueCAPolicyAssetType.ps1 delete mode 100644 Modules/CippExtensions/Public/ITGlue/Set-ITGlueMapping.ps1 diff --git a/.github/workflows/master_cippuznn2.yml b/.github/workflows/master_cippuznn2.yml deleted file mode 100644 index c667aff832f7..000000000000 --- a/.github/workflows/master_cippuznn2.yml +++ /dev/null @@ -1,40 +0,0 @@ -# Docs for the Azure Web Apps Deploy action: https://github.com/azure/functions-action -# More GitHub Actions for Azure: https://github.com/Azure/actions - -name: Build and deploy Powershell project to Azure Function App - cippuznn2 - -on: - push: - branches: - - master - workflow_dispatch: - -env: - AZURE_FUNCTIONAPP_PACKAGE_PATH: '.' # set this to the path to your web app project, defaults to the repository root - -jobs: - deploy: - runs-on: windows-latest - permissions: - id-token: write #This is required for requesting the JWT - contents: read #This is required for actions/checkout - - steps: - - name: 'Checkout GitHub Action' - uses: actions/checkout@v4 - - - name: Login to Azure - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_08B275A7749744FE9C9F30CE683D3185 }} - tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_281D18D4F146483191730A6D7B8C9FBC }} - subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_00C67E66C93742039B34B3E3E3CF2D1C }} - - - name: 'Run Azure Functions Action' - uses: Azure/functions-action@v1 - id: fa - with: - app-name: 'cippuznn2' - slot-name: 'Production' - package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }} - \ No newline at end of file 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 a6da1904733d..8cdaf3000eb9 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,12 +31,6 @@ 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 } @@ -82,14 +76,6 @@ 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 @@ -130,23 +116,6 @@ 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 8e0cc8171076..797bf04ec555 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,10 +84,6 @@ 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 8786abb2d25d..d89da3624338 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,15 +65,6 @@ 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 f9d8fd73f1af..79529e5b5291 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,14 +35,6 @@ 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 deleted file mode 100644 index d71bf1097686..000000000000 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecITGlueCreateFlexibleAssetType.ps1 +++ /dev/null @@ -1,150 +0,0 @@ -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 f28b6a32509b..2eae2f67dd97 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,9 +50,8 @@ function Invoke-ListMailboxForwarding { continue } - $ForwardingType = if ($HasExternalForwarding -and $HasInternalForwarding) { - 'Both' - } elseif ($HasExternalForwarding) { + # External takes precedence when both are configured + $ForwardingType = if ($HasExternalForwarding) { 'External' } else { 'Internal' diff --git a/Modules/CippExtensions/Private/ITGlue/Add-ITGlueFlexibleAssetFields.ps1 b/Modules/CippExtensions/Private/ITGlue/Add-ITGlueFlexibleAssetFields.ps1 deleted file mode 100644 index c16739e872f8..000000000000 --- a/Modules/CippExtensions/Private/ITGlue/Add-ITGlueFlexibleAssetFields.ps1 +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 4fbf4442217d..000000000000 --- a/Modules/CippExtensions/Private/ITGlue/Format-ITGlueCAPValue.ps1 +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 3cec7a360ce1..000000000000 --- a/Modules/CippExtensions/Private/ITGlue/Invoke-ITGlueRequest.ps1 +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index 63da96ab3ee2..000000000000 --- a/Modules/CippExtensions/Private/ITGlue/Sync-ITGlueConditionalAccessPolicies.ps1 +++ /dev/null @@ -1,202 +0,0 @@ -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 = @" -

State: $StateIcon

-

Created: $($CAP.createdDateTime)
-Modified: $($CAP.modifiedDateTime)

- -

Conditions

- - - - - - - - - - -
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 & Groups

- - - - - - - -
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)
- -

Grant Controls

- - - - - -
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 4062d62a10b0..d00d60337703 100644 --- a/Modules/CippExtensions/Public/Extension Functions/Push-CippExtensionData.ps1 +++ b/Modules/CippExtensions/Public/Extension Functions/Push-CippExtensionData.ps1 @@ -14,12 +14,6 @@ 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 3ac819a2fd35..1cef75d498fe 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', 'ITGlue') + [string[]]$Extensions = @('Hudu', 'NinjaOne', 'CustomData') ) # get extension configuration and mappings table diff --git a/Modules/CippExtensions/Public/ITGlue/Connect-ITGlueAPI.ps1 b/Modules/CippExtensions/Public/ITGlue/Connect-ITGlueAPI.ps1 deleted file mode 100644 index 4f0b84f77393..000000000000 --- a/Modules/CippExtensions/Public/ITGlue/Connect-ITGlueAPI.ps1 +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index dfa10707ef0a..000000000000 --- a/Modules/CippExtensions/Public/ITGlue/Get-ITGlueFieldMapping.ps1 +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 36593ba754ab..000000000000 --- a/Modules/CippExtensions/Public/ITGlue/Get-ITGlueMapping.ps1 +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 4fe30f465036..000000000000 --- a/Modules/CippExtensions/Public/ITGlue/Invoke-ITGlueExtensionSync.ps1 +++ /dev/null @@ -1,738 +0,0 @@ -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)

" }) -

View in CIPP   -View in Entra

-"@ - - # 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 + "`n

Last 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))

-

View in CIPP   -Open Intune

-"@ - - # 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 + "`n

Last 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 = @" -

State: $StateIcon

-

Created: $($CAP.createdDateTime)
-Modified: $($CAP.modifiedDateTime)

- -

Conditions

- - - - - - - - - - -
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 & Groups

- - - - - - - -
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)
- -

Grant Controls

- - - - - -
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) { - '' - } else { - '

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 } - "$FriendlyName$($License.consumedUnits) / $($License.prepaidUnits.enabled)" - } - } - $LicenseTable = if ($LicenseRows) { - "$($LicenseRows -join '')
LicenseUsed / Total
" - } else { - '

No license data available

' - } - - # CIPP managed section wrapped in a
with a class attribute. - # HTML comments () are stripped by ITGlue's sanitizer, so we use a real element as our marker instead. - $CippMarkerStart = '
' - $CippMarkerEnd = '
' - - $CippSection = @" -$CippMarkerStart -
-

Microsoft 365 Overview

-

Tenant: $($Tenant.displayName)
-Tenant ID: $($Tenant.customerId)
-Default Domain: $($Tenant.defaultDomainName)

- -

Verified Domains:

-$VerifiedDomains - - - - -
Licensed Users$($CompanyResult.Users)
Managed Devices$($CompanyResult.Devices)
- -

Licenses

-$LicenseTable - -

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 '') { - # CIPP section exists - replace ALL occurrences (handles duplicates from failed previous syncs) - # Use non-capturing group and match any whitespace after opening tag - $QuickNotes = $ExistingNotes -replace '(?s).*?
\s*', '' - # Append fresh CIPP section to cleaned notes - if ($QuickNotes.Trim()) { - $QuickNotes = $QuickNotes.TrimEnd() + "`n`n" + $CippSection - } else { - $QuickNotes = $CippSection -replace '
\s*', '' - } - } elseif ($ExistingNotes -and $ExistingNotes.Trim()) { - # No previous CIPP section found - append below existing user content - $QuickNotes = $ExistingNotes.TrimEnd() + "`n`n" + $CippSection - } else { - # No existing content, just use CIPP section (without leading hr) - $QuickNotes = $CippSection -replace '
\s*', '' - } - - $null = Invoke-ITGlueRequest -Method PATCH -Endpoint "/organizations/$OrgId" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'organizations' -ResourceId $OrgId -Attributes @{ - 'quick-notes' = $QuickNotes - } - $CompanyResult.Logs.Add("M365 Overview: Updated organisation quick-notes") - } catch { - $CompanyResult.Errors.Add("M365 Overview block failed: $_") - } - } - - $CompanyResult.Logs.Add('ITGlue Extension Sync complete') - Write-LogMessage -Message "ITGlue sync complete for $($Tenant.displayName): $($CompanyResult.Users) users, $($CompanyResult.Devices) devices, $($CompanyResult.Errors.Count) errors" -Level Info -tenant $TenantFilter -API 'ITGlueSync' - - return $CompanyResult - - } catch { - $Message = if ($_.ErrorDetails.Message) { - Get-NormalizedError -Message $_.ErrorDetails.Message - } else { - $_.Exception.message - } - Write-LogMessage -Message "ITGlue Extension Sync failed for $TenantFilter : $Message" -Level Error -tenant $TenantFilter -API 'ITGlueSync' - return "ITGlue sync failed: $Message" - } -} diff --git a/Modules/CippExtensions/Public/ITGlue/New-ITGlueCAPolicyAssetType.ps1 b/Modules/CippExtensions/Public/ITGlue/New-ITGlueCAPolicyAssetType.ps1 deleted file mode 100644 index 31019a218cd9..000000000000 --- a/Modules/CippExtensions/Public/ITGlue/New-ITGlueCAPolicyAssetType.ps1 +++ /dev/null @@ -1,194 +0,0 @@ -function New-ITGlueCAPolicyAssetType { - <# - .FUNCTIONALITY - Internal - .SYNOPSIS - Creates the Conditional Access Policy Flexible Asset Type in ITGlue with all required fields. - .DESCRIPTION - Creates a new flexible asset type in ITGlue with 50+ fields to store detailed - conditional access policy information from Microsoft 365. - #> - [CmdletBinding()] - param() - - $Table = Get-CIPPTable -TableName Extensionsconfig - try { - $Configuration = (Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -ErrorAction Stop - $Conn = Connect-ITGlueAPI -Configuration $Configuration - - # Define all the flexible asset fields for CA Policies - # Field positions are 1-based and sequential - $Fields = @( - # Policy name (required, use for title) - @{ name = 'Policy name'; kind = 'Text'; required = $true; 'use-for-title' = $true; 'show-in-list' = $true } - @{ name = 'Policy description'; kind = 'Text'; required = $false; 'show-in-list' = $false } - @{ name = 'Policy state'; kind = 'Select'; required = $false; 'show-in-list' = $true; 'default-value' = 'enabled,enabledForReportingButNotEnforced,disabled' } - - # Users section - @{ name = 'Users'; kind = 'Header' } - @{ name = 'Users include'; kind = 'Select'; required = $false; 'default-value' = 'All users,None,Select users and groups,All guest and external users' } - @{ name = 'Included guest or external roles'; kind = 'Textbox'; required = $false } - @{ name = 'Included directory roles'; kind = 'Textbox'; required = $false } - @{ name = 'Included users and groups'; kind = 'Textbox'; required = $false } - @{ name = 'Users exclude'; kind = 'Select'; required = $false; 'default-value' = 'None,Select users and groups,All guest and external users' } - @{ name = 'Excluded guest or external roles'; kind = 'Textbox'; required = $false } - @{ name = 'Excluded directory roles'; kind = 'Textbox'; required = $false } - @{ name = 'Excluded users and groups'; kind = 'Textbox'; required = $false } - - # Target resources section - @{ name = 'Target resources'; kind = 'Header' } - @{ name = 'Target resources include'; kind = 'Select'; required = $false; 'default-value' = 'All cloud apps,None,Select apps,User actions,Authentication context' } - @{ name = 'Select apps'; kind = 'Textbox'; required = $false } - @{ name = 'User actions'; kind = 'Textbox'; required = $false } - @{ name = 'Authentication context'; kind = 'Textbox'; required = $false } - @{ name = 'Target resources excluded cloud apps'; kind = 'Textbox'; required = $false } - - # Network section - @{ name = 'Network'; kind = 'Header' } - @{ name = 'Network include'; kind = 'Select'; required = $false; 'default-value' = 'Any network or location,All trusted networks and locations,Selected networks and locations' } - @{ name = 'Network include - selected networks and locations'; kind = 'Textbox'; required = $false } - @{ name = 'Network exclude'; kind = 'Select'; required = $false; 'default-value' = 'None,All trusted networks and locations,Selected networks and locations' } - @{ name = 'Network exclude - selected networks and locations'; kind = 'Textbox'; required = $false } - - # Conditions section - @{ name = 'Conditions'; kind = 'Header' } - - # User risk - @{ name = 'User risk - high'; kind = 'Checkbox'; required = $false } - @{ name = 'User risk - medium'; kind = 'Checkbox'; required = $false } - @{ name = 'User risk - low'; kind = 'Checkbox'; required = $false } - - # Sign-in risk - @{ name = 'Sign-in risk - high'; kind = 'Checkbox'; required = $false } - @{ name = 'Sign-in risk - medium'; kind = 'Checkbox'; required = $false } - @{ name = 'Sign-in risk - low'; kind = 'Checkbox'; required = $false } - @{ name = 'Sign-in risk - no risk'; kind = 'Checkbox'; required = $false } - - # Insider risk - @{ name = 'Insider risk - elevated'; kind = 'Checkbox'; required = $false } - @{ name = 'Insider risk - moderate'; kind = 'Checkbox'; required = $false } - @{ name = 'Insider risk - minor'; kind = 'Checkbox'; required = $false } - - # Device platforms - Include - @{ name = 'Device platforms'; kind = 'Header' } - @{ name = 'Include Android'; kind = 'Checkbox'; required = $false } - @{ name = 'Include iOS'; kind = 'Checkbox'; required = $false } - @{ name = 'Include Windows'; kind = 'Checkbox'; required = $false } - @{ name = 'Include macOS'; kind = 'Checkbox'; required = $false } - @{ name = 'Include Linux'; kind = 'Checkbox'; required = $false } - @{ name = 'Include Windows Phone'; kind = 'Checkbox'; required = $false } - - # Device platforms - Exclude - @{ name = 'Exclude Android'; kind = 'Checkbox'; required = $false } - @{ name = 'Exclude iOS'; kind = 'Checkbox'; required = $false } - @{ name = 'Exclude Windows'; kind = 'Checkbox'; required = $false } - @{ name = 'Exclude macOS'; kind = 'Checkbox'; required = $false } - @{ name = 'Exclude Linux'; kind = 'Checkbox'; required = $false } - @{ name = 'Exclude Windows Phone'; kind = 'Checkbox'; required = $false } - - # Client apps - @{ name = 'Client apps'; kind = 'Header' } - @{ name = 'Client apps configured'; kind = 'Checkbox'; required = $false } - @{ name = 'Browser'; kind = 'Checkbox'; required = $false } - @{ name = 'Mobile apps and desktop clients'; kind = 'Checkbox'; required = $false } - @{ name = 'Exchange ActiveSync clients'; kind = 'Checkbox'; required = $false } - @{ name = 'Other clients'; kind = 'Checkbox'; required = $false } - - # Device filters - @{ name = 'Filter for devices'; kind = 'Header' } - @{ name = 'Device filter mode'; kind = 'Select'; required = $false; 'default-value' = 'Not configured,Include,Exclude' } - @{ name = 'Device filter rule'; kind = 'Textbox'; required = $false } - - # Grant controls - @{ name = 'Grant controls'; kind = 'Header' } - @{ name = 'Grant or Block'; kind = 'Select'; required = $false; 'default-value' = 'Grant access,Block access' } - @{ name = 'Grant controls operator'; kind = 'Select'; required = $false; 'default-value' = 'AND,OR' } - @{ name = 'Require multifactor authentication'; kind = 'Checkbox'; required = $false } - @{ name = 'Require authentication strength'; kind = 'Text'; required = $false } - @{ name = 'Require device to be marked as compliant'; kind = 'Checkbox'; required = $false } - @{ name = 'Require Microsoft Entra hybrid joined device'; kind = 'Checkbox'; required = $false } - @{ name = 'Require approved client app'; kind = 'Checkbox'; required = $false } - @{ name = 'Require app protection policy'; kind = 'Checkbox'; required = $false } - @{ name = 'Require password change'; kind = 'Checkbox'; required = $false } - @{ name = 'Terms of use'; kind = 'Textbox'; required = $false } - - # Session controls - @{ name = 'Session controls'; kind = 'Header' } - @{ name = 'Use app enforced restrictions'; kind = 'Checkbox'; required = $false } - @{ name = 'Use Conditional Access App Control'; kind = 'Checkbox'; required = $false } - @{ name = 'Conditional Access App Control type'; kind = 'Text'; required = $false } - @{ name = 'Sign-in frequency enabled'; kind = 'Checkbox'; required = $false } - @{ name = 'Sign-in frequency value'; kind = 'Text'; required = $false } - @{ name = 'Sign-in frequency type'; kind = 'Select'; required = $false; 'default-value' = 'hours,days,everyTime' } - @{ name = 'Persistent browser session enabled'; kind = 'Checkbox'; required = $false } - @{ name = 'Persistent browser session mode'; kind = 'Select'; required = $false; 'default-value' = 'always,never' } - @{ name = 'Continuous access evaluation'; kind = 'Select'; required = $false; 'default-value' = 'Not configured,Disabled,Strictly enforced' } - @{ name = 'Disable resilience defaults'; kind = 'Checkbox'; required = $false } - @{ name = 'Secure sign-in session'; kind = 'Checkbox'; required = $false } - - # Metadata - @{ name = 'Metadata'; kind = 'Header' } - @{ name = 'Policy ID'; kind = 'Text'; required = $false } - @{ name = 'Created date'; kind = 'Text'; required = $false } - @{ name = 'Modified date'; kind = 'Text'; required = $false } - @{ name = 'CIPP link'; kind = 'Text'; required = $false } - @{ name = 'Entra link'; kind = 'Text'; required = $false } - @{ name = 'Last synced'; kind = 'Text'; required = $false } - ) - - # Add position to each field - $Position = 1 - $FieldsWithPosition = foreach ($Field in $Fields) { - $Field['position'] = $Position - $Position++ - $Field - } - - # Create the flexible asset type - $TypeBody = @{ - data = @{ - type = 'flexible-asset-types' - attributes = @{ - name = 'M365 Conditional Access Policy' - description = 'Microsoft 365 Conditional Access Policies synced from CIPP' - icon = 'shield-check' - enabled = $true - 'flexible-asset-fields' = @($FieldsWithPosition) - } - } - } | ConvertTo-Json -Depth 20 -Compress - - $Response = Invoke-RestMethod -Uri "$($Conn.BaseUrl)/flexible_asset_types" -Method POST -Headers $Conn.Headers -Body $TypeBody - - $CreatedTypeId = $Response.data.id - $CreatedTypeName = $Response.data.attributes.name - - Write-LogMessage -Message "Created ITGlue Conditional Access Policy flexible asset type: $CreatedTypeName (ID: $CreatedTypeId)" -Level Info -tenant 'CIPP' -API 'ITGlueMapping' - - return @{ - Success = $true - Message = "Successfully created flexible asset type '$CreatedTypeName' (ID: $CreatedTypeId). You can now select it in the field mapping dropdown." - TypeId = $CreatedTypeId - TypeName = $CreatedTypeName - } - - } catch { - $ErrorMessage = if ($_.ErrorDetails.Message) { - try { - $ErrorBody = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue - $ErrorBody.errors[0].detail ?? $ErrorBody.errors[0].title ?? $_.ErrorDetails.Message - } catch { - $_.ErrorDetails.Message - } - } else { - $_.Exception.Message - } - - Write-LogMessage -Message "Failed to create ITGlue CA Policy flexible asset type: $ErrorMessage" -Level Error -tenant 'CIPP' -API 'ITGlueMapping' - - return @{ - Success = $false - Message = "Failed to create flexible asset type: $ErrorMessage" - } - } -} diff --git a/Modules/CippExtensions/Public/ITGlue/Set-ITGlueMapping.ps1 b/Modules/CippExtensions/Public/ITGlue/Set-ITGlueMapping.ps1 deleted file mode 100644 index 111286189704..000000000000 --- a/Modules/CippExtensions/Public/ITGlue/Set-ITGlueMapping.ps1 +++ /dev/null @@ -1,36 +0,0 @@ -function Set-ITGlueMapping { - <# - .FUNCTIONALITY - Internal - .SYNOPSIS - Replaces all ITGlue tenant-to-organisation mappings in the CippMapping table. - $Request.Body is an array sent by the frontend, each item containing: - - TenantId : the CIPP tenant's customerId (used as RowKey) - - IntegrationId : the ITGlue organisation ID (value from the org dropdown) - - IntegrationName : the ITGlue organisation name (display name from the org dropdown) - #> - [CmdletBinding()] - param ( - $CIPPMapping, - $APIName, - $Request - ) - - # Remove all existing mappings for this extension - Get-CIPPAzDataTableEntity @CIPPMapping -Filter "PartitionKey eq 'ITGlueMapping'" | ForEach-Object { - Remove-AzDataTableEntity -Force @CIPPMapping -Entity $_ - } - - foreach ($Mapping in $Request.Body) { - $AddObject = @{ - PartitionKey = 'ITGlueMapping' - RowKey = "$($Mapping.TenantId)" - IntegrationId = "$($Mapping.IntegrationId)" - IntegrationName = "$($Mapping.IntegrationName)" - } - Add-CIPPAzDataTableEntity @CIPPMapping -Entity $AddObject -Force - Write-LogMessage -API $APIName -headers $Request.Headers -message "Added ITGlue mapping for $($Mapping.IntegrationName)." -Sev 'Info' - } - - return [PSCustomObject]@{ Results = 'Successfully edited ITGlue mapping table.' } -}