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