Skip to content

Commit bf96ab4

Browse files
authored
Merge pull request #32 from TecharyJames/feature/itglue
Add Conditional Access Policy sync and helpers
2 parents ec72e4d + 3d0f3e1 commit bf96ab4

3 files changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
function Add-ITGlueFlexibleAssetFields {
2+
<#
3+
.FUNCTIONALITY
4+
Internal
5+
.SYNOPSIS
6+
Ensures required fields exist in an ITGlue Flexible Asset Type.
7+
#>
8+
param(
9+
$TypeId,
10+
[array]$FieldsToAdd, # Array of @{ Name = ''; Kind = 'Textbox'; ShowInList = $false }
11+
$Conn
12+
)
13+
14+
# GET type with its fields included (one call for all fields)
15+
$TypeResponse = Invoke-RestMethod -Uri "$($Conn.BaseUrl)/flexible_asset_types/$TypeId`?include=flexible_asset_fields" -Method GET -Headers $Conn.Headers
16+
$IncludedFields = $TypeResponse.included | Where-Object { $_.type -eq 'flexible-asset-fields' }
17+
$ExistingNames = $IncludedFields | ForEach-Object { $_.attributes.name }
18+
19+
# Filter to only fields that don't exist
20+
$NewFields = $FieldsToAdd | Where-Object { $_.Name -notin $ExistingNames }
21+
22+
if ($NewFields.Count -eq 0) {
23+
return # All fields already exist
24+
}
25+
26+
# Build complete field list: existing (with IDs) + new fields
27+
$AllFields = [System.Collections.Generic.List[object]]::new()
28+
foreach ($F in $IncludedFields) {
29+
$AllFields.Add([ordered]@{
30+
id = $F.id
31+
name = $F.attributes.name
32+
kind = $F.attributes.kind
33+
required = $F.attributes.required
34+
'show-in-list' = $F.attributes.'show-in-list'
35+
position = $F.attributes.position
36+
})
37+
}
38+
39+
foreach ($NewField in $NewFields) {
40+
$AllFields.Add([ordered]@{
41+
name = $NewField.Name
42+
kind = $NewField.Kind
43+
required = $false
44+
'show-in-list' = $NewField.ShowInList
45+
})
46+
}
47+
48+
$PatchBody = @{
49+
data = @{
50+
type = 'flexible-asset-types'
51+
id = $TypeId
52+
attributes = @{
53+
'flexible-asset-fields' = @($AllFields)
54+
}
55+
}
56+
} | ConvertTo-Json -Depth 20 -Compress
57+
58+
$null = Invoke-RestMethod -Uri "$($Conn.BaseUrl)/flexible_asset_types/$TypeId" -Method PATCH -Headers $Conn.Headers -Body $PatchBody
59+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
function Format-ITGlueCAPValue {
2+
<#
3+
.FUNCTIONALITY
4+
Internal
5+
.SYNOPSIS
6+
Converts Out-String output (newline-separated) to comma-separated format.
7+
Used for formatting CAP values from Invoke-ListConditionalAccessPolicies.
8+
#>
9+
param($Value)
10+
11+
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
12+
($Value.Trim() -split "`n" | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() }) -join ', '
13+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
function Sync-ITGlueConditionalAccessPolicies {
2+
<#
3+
.FUNCTIONALITY
4+
Internal
5+
.SYNOPSIS
6+
Syncs Conditional Access Policies to ITGlue Flexible Assets.
7+
#>
8+
param(
9+
$CAPTypeId,
10+
$OrgId,
11+
$Conn,
12+
$ConditionalAccessPolicies,
13+
$ITGlueAssetCache,
14+
$TenantFilter,
15+
$CIPPURL,
16+
$Tenant
17+
)
18+
19+
$Result = @{
20+
UpdatedCount = 0
21+
SkippedCount = 0
22+
Errors = [System.Collections.Generic.List[string]]@()
23+
Logs = [System.Collections.Generic.List[string]]@()
24+
}
25+
26+
try {
27+
Add-ITGlueFlexibleAssetFields -TypeId $CAPTypeId -FieldsToAdd @(
28+
@{ Name = 'Policy Name'; Kind = 'Text'; ShowInList = $true }
29+
@{ Name = 'Policy ID'; Kind = 'Text'; ShowInList = $false }
30+
@{ Name = 'State'; Kind = 'Text'; ShowInList = $true }
31+
@{ Name = 'Policy Details'; Kind = 'Textbox'; ShowInList = $false }
32+
@{ Name = 'Raw JSON'; Kind = 'Textbox'; ShowInList = $false }
33+
) -Conn $Conn
34+
35+
$ExistingCAPAssets = Invoke-ITGlueRequest -Method GET -Endpoint '/flexible_assets' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -QueryParams @{
36+
'filter[flexible_asset_type_id]' = $CAPTypeId
37+
'filter[organization_id]' = $OrgId
38+
}
39+
40+
foreach ($CAP in $ConditionalAccessPolicies) {
41+
try {
42+
$StateIcon = switch ($CAP.state) {
43+
'enabled' { '✓ Enabled' }
44+
'disabled' { '✗ Disabled' }
45+
'enabledForReportingButNotEnforced' { '⚠ Report-Only' }
46+
default { $CAP.state }
47+
}
48+
49+
# Build content for hash - ONLY actual policy settings (exclude dates/timestamps)
50+
$ContentForHash = @"
51+
State: $StateIcon
52+
Client App Types: $($CAP.clientAppTypes)
53+
Platforms (Include): $($CAP.includePlatforms)
54+
Platforms (Exclude): $($CAP.excludePlatforms)
55+
Locations (Include): $($CAP.includeLocations)
56+
Locations (Exclude): $($CAP.excludeLocations)
57+
Applications (Include): $($CAP.includeApplications)
58+
Applications (Exclude): $($CAP.excludeApplications)
59+
User Actions: $(Format-ITGlueCAPValue $CAP.includeUserActions)
60+
Auth Context: $(Format-ITGlueCAPValue $CAP.includeAuthenticationContextClassReferences)
61+
Users (Include): $(Format-ITGlueCAPValue $CAP.includeUsers)
62+
Users (Exclude): $(Format-ITGlueCAPValue $CAP.excludeUsers)
63+
Groups (Include): $(Format-ITGlueCAPValue $CAP.includeGroups)
64+
Groups (Exclude): $(Format-ITGlueCAPValue $CAP.excludeGroups)
65+
Roles (Include): $(Format-ITGlueCAPValue $CAP.includeRoles)
66+
Roles (Exclude): $(Format-ITGlueCAPValue $CAP.excludeRoles)
67+
Operator: $($CAP.grantControlsOperator)
68+
Built-in Controls: $($CAP.builtInControls)
69+
Custom Auth Factors: $($CAP.customAuthenticationFactors)
70+
Terms of Use: $($CAP.termsOfUse)
71+
"@
72+
73+
# Hash-based change detection - hash ONLY policy content (not dates or display timestamps)
74+
$ContentToHash = "$($CAP.displayName)|$($CAP.state)|$ContentForHash"
75+
$NewHash = Get-StringHash -String $ContentToHash
76+
77+
# Build full HTML with dates for display (dates NOT in hash)
78+
$DetailsHtml = @"
79+
<h4>State: $StateIcon</h4>
80+
<p><strong>Created:</strong> $($CAP.createdDateTime)<br/>
81+
<strong>Modified:</strong> $($CAP.modifiedDateTime)</p>
82+
83+
<h4>Conditions</h4>
84+
<table>
85+
<tr><td><strong>Client App Types</strong></td><td>$($CAP.clientAppTypes)</td></tr>
86+
<tr><td><strong>Platforms (Include)</strong></td><td>$($CAP.includePlatforms)</td></tr>
87+
<tr><td><strong>Platforms (Exclude)</strong></td><td>$($CAP.excludePlatforms)</td></tr>
88+
<tr><td><strong>Locations (Include)</strong></td><td>$($CAP.includeLocations)</td></tr>
89+
<tr><td><strong>Locations (Exclude)</strong></td><td>$($CAP.excludeLocations)</td></tr>
90+
<tr><td><strong>Applications (Include)</strong></td><td>$($CAP.includeApplications)</td></tr>
91+
<tr><td><strong>Applications (Exclude)</strong></td><td>$($CAP.excludeApplications)</td></tr>
92+
<tr><td><strong>User Actions</strong></td><td>$(Format-ITGlueCAPValue $CAP.includeUserActions)</td></tr>
93+
<tr><td><strong>Auth Context</strong></td><td>$(Format-ITGlueCAPValue $CAP.includeAuthenticationContextClassReferences)</td></tr>
94+
</table>
95+
96+
<h4>Users & Groups</h4>
97+
<table>
98+
<tr><td><strong>Users (Include)</strong></td><td>$(Format-ITGlueCAPValue $CAP.includeUsers)</td></tr>
99+
<tr><td><strong>Users (Exclude)</strong></td><td>$(Format-ITGlueCAPValue $CAP.excludeUsers)</td></tr>
100+
<tr><td><strong>Groups (Include)</strong></td><td>$(Format-ITGlueCAPValue $CAP.includeGroups)</td></tr>
101+
<tr><td><strong>Groups (Exclude)</strong></td><td>$(Format-ITGlueCAPValue $CAP.excludeGroups)</td></tr>
102+
<tr><td><strong>Roles (Include)</strong></td><td>$(Format-ITGlueCAPValue $CAP.includeRoles)</td></tr>
103+
<tr><td><strong>Roles (Exclude)</strong></td><td>$(Format-ITGlueCAPValue $CAP.excludeRoles)</td></tr>
104+
</table>
105+
106+
<h4>Grant Controls</h4>
107+
<table>
108+
<tr><td><strong>Operator</strong></td><td>$($CAP.grantControlsOperator)</td></tr>
109+
<tr><td><strong>Built-in Controls</strong></td><td>$($CAP.builtInControls)</td></tr>
110+
<tr><td><strong>Custom Auth Factors</strong></td><td>$($CAP.customAuthenticationFactors)</td></tr>
111+
<tr><td><strong>Terms of Use</strong></td><td>$($CAP.termsOfUse)</td></tr>
112+
</table>
113+
114+
<p><em>Last updated: $(Get-Date -Format 'yyyy-MM-dd HH:mm') UTC</em></p>
115+
"@
116+
117+
$CAPTraits = @{
118+
'policy-name' = $CAP.displayName
119+
'policy-id' = $CAP.id
120+
'state' = $CAP.state
121+
'policy-details' = $DetailsHtml
122+
'raw-json' = $CAP.rawjson
123+
}
124+
125+
$ExistingAsset = $ExistingCAPAssets | Where-Object { $_.traits.'policy-id' -eq $CAP.id } | Select-Object -First 1
126+
127+
# Check if content has changed by comparing hashes
128+
$NeedsUpdate = $true
129+
if ($ExistingAsset) {
130+
$CachedAsset = Get-CIPPAzDataTableEntity @ITGlueAssetCache -Filter "PartitionKey eq 'ITGlueCAP' and RowKey eq '$($ExistingAsset.id)'"
131+
if ($CachedAsset -and $CachedAsset.Hash -eq $NewHash) {
132+
$NeedsUpdate = $false
133+
$Result.SkippedCount++
134+
} else {
135+
# Debug: Log why hash changed
136+
if ($CachedAsset) {
137+
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
138+
} else {
139+
Write-LogMessage -API 'ITGlueSync' -tenant $TenantFilter -message "CAP no cache found for $($CAP.displayName) (AssetID: $($ExistingAsset.id))" -sev Debug
140+
}
141+
}
142+
}
143+
144+
if ($NeedsUpdate) {
145+
$AssetAttribs = @{
146+
'organization-id' = $OrgId
147+
'flexible-asset-type-id' = $CAPTypeId
148+
traits = $CAPTraits
149+
}
150+
151+
if ($ExistingAsset) {
152+
$null = Invoke-ITGlueRequest -Method PATCH -Endpoint "/flexible_assets/$($ExistingAsset.id)" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'flexible-assets' -ResourceId $ExistingAsset.id -Attributes $AssetAttribs
153+
$AssetId = $ExistingAsset.id
154+
} else {
155+
$CreatedAsset = Invoke-ITGlueRequest -Method POST -Endpoint '/flexible_assets' -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl -ResourceType 'flexible-assets' -Attributes $AssetAttribs
156+
$AssetId = $CreatedAsset[0].id
157+
}
158+
159+
# Cache the hash to avoid unnecessary updates on next sync
160+
$CacheEntry = @{
161+
PartitionKey = 'ITGlueCAP'
162+
RowKey = [string]$AssetId
163+
OrgId = [string]$OrgId
164+
PolicyId = $CAP.id
165+
Hash = $NewHash
166+
}
167+
Add-CIPPAzDataTableEntity @ITGlueAssetCache -Entity $CacheEntry -Force
168+
169+
$Result.UpdatedCount++
170+
}
171+
} catch {
172+
$Result.Errors.Add("CAP FA [$($CAP.displayName)]: $_")
173+
}
174+
}
175+
176+
# Delete CAP assets that no longer exist in M365
177+
$CurrentCAPIds = $ConditionalAccessPolicies | ForEach-Object { $_.id }
178+
$OrphanedAssets = $ExistingCAPAssets | Where-Object { $_.traits.'policy-id' -notin $CurrentCAPIds }
179+
foreach ($Orphan in $OrphanedAssets) {
180+
try {
181+
$PolicyName = if ($Orphan.traits.'policy-name') { $Orphan.traits.'policy-name' } else { "ID: $($Orphan.traits.'policy-id')" }
182+
$null = Invoke-ITGlueRequest -Method DELETE -Endpoint "/flexible_assets/$($Orphan.id)" -Headers $Conn.Headers -BaseUrl $Conn.BaseUrl
183+
$Result.Logs.Add("Deleted orphaned CAP: $PolicyName")
184+
185+
# Remove from cache
186+
$CachedAsset = Get-CIPPAzDataTableEntity @ITGlueAssetCache -Filter "PartitionKey eq 'ITGlueCAP' and RowKey eq '$($Orphan.id)'"
187+
if ($CachedAsset) {
188+
Remove-AzDataTableEntity @ITGlueAssetCache -Entity $CachedAsset -Force
189+
}
190+
} catch {
191+
$PolicyName = if ($Orphan.traits.'policy-name') { $Orphan.traits.'policy-name' } else { "ID: $($Orphan.traits.'policy-id')" }
192+
$Result.Errors.Add("Failed to delete orphaned CAP [$PolicyName]: $_")
193+
}
194+
}
195+
196+
$Result.Logs.Add("Conditional Access Policies: $($Result.UpdatedCount) updated, $($Result.SkippedCount) unchanged")
197+
} catch {
198+
$Result.Errors.Add("Conditional Access Policies block failed: $_")
199+
}
200+
201+
return $Result
202+
}

0 commit comments

Comments
 (0)