Skip to content

Commit 920e3a1

Browse files
committed
user auth and sync logic
1 parent 942ff39 commit 920e3a1

3 files changed

Lines changed: 228 additions & 85 deletions

File tree

Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,66 @@ function Initialize-CIPPAuth {
159159
Write-Information "[Auth-Init] EasyAuth policy reconcile failed (non-fatal): $_"
160160
}
161161
}
162+
163+
# 3d. Reconcile API clients — ensure the EasyAuth config matches what the
164+
# "Save to Azure" action (Set-CippApiAuth) would produce for the currently
165+
# enabled API clients. That means BOTH lists must be checked, not just apps:
166+
# allowedApplications = SSO app + every enabled client
167+
# allowedAudiences = api://<id> for each of the above, plus the MCP host
168+
# URIs and bare client IDs for MCP-enabled clients
169+
# Config drifts when a client is enabled but "Save to Azure" was never run (or a
170+
# prior save partially applied — e.g. apps set but audiences missing), which
171+
# silently breaks API authentication for that client.
172+
if ($AuthState.HasSAMCredentials -and -not $env:CIPP_SSO_MIGRATION_APPID -and $env:WEBSITE_AUTH_V2_CONFIG_JSON) {
173+
try {
174+
$ApiClientsTable = Get-CippTable -tablename 'ApiClients'
175+
$EnabledClients = @(Get-CIPPAzDataTableEntity @ApiClientsTable -Filter 'Enabled eq true' | Where-Object { ![string]::IsNullOrEmpty($_.RowKey) })
176+
177+
if ($EnabledClients.Count -gt 0) {
178+
$EnabledClientIds = @($EnabledClients.RowKey)
179+
# MCPAllowed can round-trip as a bool or string; compare on string form (matches SaveToAzure)
180+
$McpClientIds = @($EnabledClients | Where-Object { "$($_.MCPAllowed)" -eq 'True' } | ForEach-Object { $_.RowKey })
181+
182+
$ApiAuthConfig = $env:WEBSITE_AUTH_V2_CONFIG_JSON | ConvertFrom-Json -ErrorAction Stop
183+
$AADConfig = $ApiAuthConfig.identityProviders.azureActiveDirectory
184+
185+
# Desired state — keep in sync with Set-CippApiAuth's CIPPNG branch.
186+
$DesiredApps = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
187+
if ($AADConfig.registration.clientId) { [void]$DesiredApps.Add($AADConfig.registration.clientId) }
188+
foreach ($Id in $EnabledClientIds) { if (-not [string]::IsNullOrEmpty($Id)) { [void]$DesiredApps.Add($Id) } }
189+
190+
$DesiredAudiences = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
191+
foreach ($Id in $DesiredApps) { [void]$DesiredAudiences.Add("api://$Id") }
192+
if ($McpClientIds.Count -gt 0 -and $env:WEBSITE_HOSTNAME) {
193+
[void]$DesiredAudiences.Add("https://$($env:WEBSITE_HOSTNAME)")
194+
[void]$DesiredAudiences.Add("https://$($env:WEBSITE_HOSTNAME)/api/ExecMcp")
195+
foreach ($McpId in $McpClientIds) { if (-not [string]::IsNullOrEmpty($McpId)) { [void]$DesiredAudiences.Add($McpId) } }
196+
}
197+
198+
# Current state from the platform-injected config
199+
$CurrentApps = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
200+
foreach ($App in @($AADConfig.validation.defaultAuthorizationPolicy.allowedApplications)) { if ($App) { [void]$CurrentApps.Add($App) } }
201+
$CurrentAudiences = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
202+
foreach ($Aud in @($AADConfig.validation.allowedAudiences)) { if ($Aud) { [void]$CurrentAudiences.Add($Aud) } }
203+
204+
# Drift when anything the endpoint would set is missing from the live config
205+
$AppsOk = $DesiredApps.IsSubsetOf($CurrentApps)
206+
$AudiencesOk = $DesiredAudiences.IsSubsetOf($CurrentAudiences)
207+
208+
if (-not $AppsOk -or -not $AudiencesOk) {
209+
$MissingApps = @($DesiredApps | Where-Object { -not $CurrentApps.Contains($_) })
210+
$MissingAudiences = @($DesiredAudiences | Where-Object { -not $CurrentAudiences.Contains($_) })
211+
Write-Information "[Auth-Init] API client drift detected — missing apps: [$($MissingApps -join ', ')]; missing audiences: [$($MissingAudiences -join ', ')] — reconciling EasyAuth"
212+
Set-CippApiAuth -TenantId $env:TenantID -ClientIds $EnabledClientIds -McpClientIds $McpClientIds
213+
Write-Information '[Auth-Init] EasyAuth allowedApplications + allowedAudiences reconciled with enabled API clients'
214+
} else {
215+
Write-Information "[Auth-Init] EasyAuth already matches $($EnabledClients.Count) enabled API client(s) — no update needed"
216+
}
217+
}
218+
} catch {
219+
Write-Information "[Auth-Init] API client reconcile failed (non-fatal): $_"
220+
}
221+
}
162222
} elseif ($AuthState.HasSAMCredentials) {
163223
# EasyAuth NOT configured but we DO have SAM credentials — try to auto-configure
164224
Write-Information '[Auth-Init] EasyAuth not configured but SAM credentials available — attempting auto-configuration'

Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1

Lines changed: 95 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ function Start-UserSyncTimer {
5757
$Upn = $Upn.Trim().ToLower()
5858

5959
if (-not $UserRoleMap.ContainsKey($Upn)) {
60-
$UserRoleMap[$Upn] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
60+
$UserRoleMap[$Upn] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
6161
}
6262
foreach ($Role in $RolesForGroup) {
6363
[void]$UserRoleMap[$Upn].Add($Role)
@@ -79,54 +79,56 @@ function Start-UserSyncTimer {
7979
$UsersTable = Get-CippTable -tablename 'allowedUsers'
8080
$ExistingUsers = @(Get-CIPPAzDataTableEntity @UsersTable | Where-Object { -not $_.RowKey.StartsWith('_') })
8181

82-
# Build lookup of existing users
82+
# Group existing rows by lowercased UPN so case-variant duplicate rows
83+
# are reconciled into one canonical row.
8384
$ExistingLookup = @{}
8485
foreach ($Existing in $ExistingUsers) {
85-
$ExistingLookup[$Existing.RowKey.ToLower()] = $Existing
86+
$Key = $Existing.RowKey.ToLower()
87+
if (-not $ExistingLookup.ContainsKey($Key)) {
88+
$ExistingLookup[$Key] = [System.Collections.Generic.List[object]]::new()
89+
}
90+
$ExistingLookup[$Key].Add($Existing)
8691
}
8792

8893
$Now = (Get-Date).ToUniversalTime().ToString('o')
8994
$UpsertCount = 0
9095
$RemoveCount = 0
9196
$EntitiesToUpsert = [System.Collections.Generic.List[object]]::new()
97+
$EntitiesToRemove = [System.Collections.Generic.List[object]]::new()
9298

93-
# Upsert users from Graph
99+
# Upsert users that are members of a mapped role group
94100
foreach ($Upn in $UserRoleMap.Keys) {
95101
$AutoRoles = @($UserRoleMap[$Upn] | Sort-Object)
96102

97-
$ManualRoles = @()
98-
$Source = 'Auto'
99-
103+
# Merge manual roles from every case-variant of this user (case-sensitive dedupe)
104+
$ManualRoles = [System.Collections.Generic.List[string]]::new()
100105
if ($ExistingLookup.ContainsKey($Upn)) {
101-
$Existing = $ExistingLookup[$Upn]
102-
103-
# Preserve manual roles if they exist
104-
if ($Existing.ManualRoles) {
105-
try {
106-
$ManualRoles = @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop)
107-
} catch {
108-
$ManualRoles = @()
106+
foreach ($Existing in $ExistingLookup[$Upn]) {
107+
if ($Existing.ManualRoles) {
108+
try {
109+
foreach ($R in @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop)) {
110+
if (-not $ManualRoles.Contains($R)) { $ManualRoles.Add($R) }
111+
}
112+
} catch {}
109113
}
110-
}
111-
112-
# If user was previously manual-only and now also auto, mark as Both
113-
if ($ManualRoles.Count -gt 0) {
114-
$Source = 'Both'
114+
# Any row that isn't the canonical lowercase key is a duplicate to remove
115+
if ($Existing.RowKey -cne $Upn) { $EntitiesToRemove.Add($Existing) }
115116
}
116117
}
118+
$Source = if ($ManualRoles.Count -gt 0) { 'Both' } else { 'Auto' }
117119

118-
# Compute effective roles = union of auto + manual
119-
$EffectiveRoles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
120-
foreach ($Role in $AutoRoles) { [void]$EffectiveRoles.Add($Role) }
121-
foreach ($Role in $ManualRoles) { [void]$EffectiveRoles.Add($Role) }
120+
# Compute effective roles = auto manual (case-sensitive dedupe)
121+
$EffectiveRoles = [System.Collections.Generic.List[string]]::new()
122+
foreach ($Role in $AutoRoles) { if (-not $EffectiveRoles.Contains($Role)) { $EffectiveRoles.Add($Role) } }
123+
foreach ($Role in $ManualRoles) { if (-not $EffectiveRoles.Contains($Role)) { $EffectiveRoles.Add($Role) } }
122124
$EffectiveRolesArray = @($EffectiveRoles | Sort-Object)
123125

124126
$Entity = @{
125127
PartitionKey = 'User'
126128
RowKey = $Upn
127129
Roles = [string]($EffectiveRolesArray | ConvertTo-Json -Compress -AsArray)
128-
AutoRoles = [string]($AutoRoles | ConvertTo-Json -Compress -AsArray)
129-
ManualRoles = [string](($ManualRoles.Count -gt 0 ? $ManualRoles : @()) | ConvertTo-Json -Compress -AsArray)
130+
AutoRoles = [string](@($AutoRoles) | ConvertTo-Json -Compress -AsArray)
131+
ManualRoles = [string]((($ManualRoles.Count -gt 0) ? @($ManualRoles) : @()) | ConvertTo-Json -Compress -AsArray)
130132
Source = $Source
131133
LastSync = $Now
132134
}
@@ -135,57 +137,87 @@ function Start-UserSyncTimer {
135137
$UpsertCount++
136138
}
137139

138-
# Handle users that were auto-provisioned but are no longer in any role group
139-
foreach ($Existing in $ExistingUsers) {
140-
$ExistingUpn = $Existing.RowKey.ToLower()
141-
if ($UserRoleMap.ContainsKey($ExistingUpn)) { continue } # Still in a group, already handled
142-
143-
if ($Existing.Source -eq 'Auto') {
144-
# Purely auto-provisioned user no longer in any group — remove
145-
Remove-AzDataTableEntity -Force @UsersTable -Entity $Existing
146-
$RemoveCount++
147-
} elseif ($Existing.Source -eq 'Both') {
148-
# Was both auto + manual — clear auto roles, keep manual only
149-
$ManualRoles = @()
140+
# Reconcile existing users that are NOT in any mapped role group
141+
foreach ($Key in $ExistingLookup.Keys) {
142+
if ($UserRoleMap.ContainsKey($Key)) { continue } # Still in a group, already handled
143+
144+
$Variants = $ExistingLookup[$Key]
145+
$NeedsNormalize = ($Variants.Count -gt 1) -or ($Variants[0].RowKey -cne $Key)
146+
147+
# Merge manual roles across all case-variants (case-sensitive dedupe)
148+
$ManualRoles = [System.Collections.Generic.List[string]]::new()
149+
foreach ($Existing in $Variants) {
150150
if ($Existing.ManualRoles) {
151151
try {
152-
$ManualRoles = @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop)
153-
} catch {
154-
$ManualRoles = @()
155-
}
152+
foreach ($R in @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop)) {
153+
if (-not $ManualRoles.Contains($R)) { $ManualRoles.Add($R) }
154+
}
155+
} catch {}
156156
}
157+
}
157158

158-
if ($ManualRoles.Count -gt 0) {
159-
$Entity = @{
160-
PartitionKey = 'User'
161-
RowKey = $Existing.RowKey
162-
Roles = [string]($ManualRoles | ConvertTo-Json -Compress -AsArray)
163-
AutoRoles = '[]'
164-
ManualRoles = [string]($ManualRoles | ConvertTo-Json -Compress -AsArray)
165-
Source = 'Manual'
166-
LastSync = $Now
159+
if (-not $NeedsNormalize) {
160+
# Single clean lowercase row — apply the original cleanup rules
161+
$Existing = $Variants[0]
162+
if ($Existing.Source -eq 'Auto') {
163+
# Purely auto-provisioned user no longer in any group — remove
164+
$EntitiesToRemove.Add($Existing)
165+
} elseif ($Existing.Source -eq 'Both') {
166+
if ($ManualRoles.Count -gt 0) {
167+
# Was both auto + manual — clear auto roles, keep manual only
168+
$ManualArray = @($ManualRoles | Sort-Object)
169+
$EntitiesToUpsert.Add(@{
170+
PartitionKey = 'User'
171+
RowKey = $Key
172+
Roles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray)
173+
AutoRoles = '[]'
174+
ManualRoles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray)
175+
Source = 'Manual'
176+
LastSync = $Now
177+
})
178+
} else {
179+
$EntitiesToRemove.Add($Existing)
167180
}
168-
$EntitiesToUpsert.Add($Entity)
169-
} else {
170-
# No manual roles either — remove
171-
Remove-AzDataTableEntity -Force @UsersTable -Entity $Existing
172-
$RemoveCount++
173181
}
182+
# Source = 'Manual' (or unset) — leave untouched, these are purely manual entries
183+
continue
174184
}
175-
# Source = 'Manual' (or unset) — leave untouched, these are purely manual entries
176-
}
177185

178-
# Batch upsert
179-
if ($EntitiesToUpsert.Count -gt 0) {
180-
foreach ($Entity in $EntitiesToUpsert) {
181-
Add-CIPPAzDataTableEntity @UsersTable -Entity $Entity -Force
186+
# Duplicates or non-lowercase casing present — collapse to one canonical lowercase row
187+
if ($ManualRoles.Count -gt 0) {
188+
$ManualArray = @($ManualRoles | Sort-Object)
189+
$EntitiesToUpsert.Add(@{
190+
PartitionKey = 'User'
191+
RowKey = $Key
192+
Roles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray)
193+
AutoRoles = '[]'
194+
ManualRoles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray)
195+
Source = 'Manual'
196+
LastSync = $Now
197+
})
198+
# Remove every case-variant except the canonical one (overwritten by the upsert)
199+
foreach ($Existing in $Variants) {
200+
if ($Existing.RowKey -cne $Key) { $EntitiesToRemove.Add($Existing) }
201+
}
202+
} else {
203+
# No manual roles anywhere — purely auto-provisioned; remove all variants
204+
foreach ($Existing in $Variants) { $EntitiesToRemove.Add($Existing) }
182205
}
183206
}
184207

208+
# Apply upserts first (write canonical rows), then removals (drop duplicates/stale rows)
209+
foreach ($Entity in $EntitiesToUpsert) {
210+
Add-CIPPAzDataTableEntity @UsersTable -Entity $Entity -Force
211+
}
212+
foreach ($Entity in $EntitiesToRemove) {
213+
Remove-AzDataTableEntity -Force @UsersTable -Entity $Entity
214+
$RemoveCount++
215+
}
216+
185217
# Invalidate CRAFT's in-memory user cache so changes apply
186218
try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {}
187219

188-
Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $UpsertCount users synced, $RemoveCount auto-only users removed." -sev Info
220+
Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $UpsertCount users synced, $RemoveCount duplicate/stale rows removed." -sev Info
189221

190222
} catch {
191223
$ErrorData = Get-CippException -Exception $_

0 commit comments

Comments
 (0)