diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ee0992..66a62cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.3] - 2026-07-09 + +### Added + +- **AD account status detection** (#21). `ConvertTo-SPSUserRecord` now reads the + universal `userAccountControl` attribute and exposes two additive fields on every + resolved record — `AccountStatus` (`Active` / `Disabled` / `NotFound`) and + `Enabled` — and `SPSyncUserInfoList.ps1` carries `AccountStatus` into the JSON + snapshot. The classification relies only on `userAccountControl` bit `0x2` + (ACCOUNTDISABLE) and the null-lookup result, so it works for every customer with + no dependency on their leaver process (no dedicated OU, naming convention, HR + feed or retention policy). A resolved account whose entry has no + `userAccountControl` (some non-AD LDAP directories) is treated as `Active`, + unchanged from before. +- **`SkipDisabledUsers`** (optional, in `sync-settings.psd1`, default `$false`). + When `$true`, `SPSyncUserProfile.ps1` no longer creates or updates a User Profile + for an account flagged `Disabled` in the snapshot; the account is written to the + Not-Added report instead. This handles departed employees kept as *disabled* AD + accounts (and retained in the SharePoint User Information List for permission + history) without provisioning profiles for them. Default `$false` preserves the + previous behaviour. (#21) +- **Not-Added reason** (#21). Each entry in the `SPSyncUserNotAddedInUSPList*.json` + is now tagged with a `NotAddedReason` — `AD_NOT_FOUND` (the account no longer + resolves in AD: deleted/departed, or a forest not declared in `ad-domains.psd1`), + `MISSING_ATTRIBUTES` (resolved but `FirstName`/`LastName`/`Email` is empty) or + `DISABLED` — so an expected miss (a departed account) can be told apart from an + actionable one, and the run summary prints the breakdown. +- The `SPSyncUserInfoList` **HTML report** now shows an *AD Status* column and a + *Disabled in AD* summary card, with a note pointing at `SkipDisabledUsers`, so the + disabled (departed-but-retained) accounts are visible at a glance. (#21) + ### Changed - Bump `actions/checkout` to `v7` across all workflows (`pester.yml`, `release.yml`, `wiki.yml`), replacing the previous `v4` pins that relied on the deprecated Node.js 20 runtime. (#22) - Bump `actions/upload-artifact` to `v7` (in `pester.yml`) and `softprops/action-gh-release` to `v3` (in `release.yml`) for the same reason. CI-only change with no impact on the packaged module, scripts, or release artifact. (#22) +### Notes + +- The `AccountStatus` field is additive; existing consumers ignore it. `SkipDisabledUsers` + acts on the `AccountStatus` written by `SPSyncUserInfoList.ps1` 1.3.3+, so regenerate the + JSON snapshot after upgrading for the flag to take effect (a pre-1.3.3 snapshot has no + status, and no user is skipped as disabled until then). + ## [1.3.2] - 2026-07-08 ### Fixed diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 3914f97..5c28dc9 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,52 @@ # SPSUserSync - Release Notes +## [1.3.3] - 2026-07-09 + +This release adds **AD account status detection** so departed employees are handled +correctly, and it works for **every customer** because it relies only on universal +Active Directory signals — never on a customer-specific leaver process. + +Two kinds of departed accounts are now distinguished and handled: + +- **Disabled** accounts (kept in AD, `userAccountControl` bit `0x2`): detected and, + with the new opt-in `SkipDisabledUsers`, reported instead of being given a profile. +- **Deleted** accounts (no longer in AD): already not provisioned, now explicitly + tagged so they can be told apart from an actionable configuration gap. + +### Added + +- **Account status on every record** — `ConvertTo-SPSUserRecord` reads + `userAccountControl` and exposes `AccountStatus` (`Active` / `Disabled` / + `NotFound`) and `Enabled`; `SPSyncUserInfoList.ps1` writes `AccountStatus` into the + JSON snapshot. Universal by design: no dedicated OU, naming convention, HR feed or + retention assumption. A resolved account with no `userAccountControl` (some non-AD + LDAP directories) stays `Active`, exactly as before. (#21) +- **`SkipDisabledUsers`** (`sync-settings.psd1`, default `$false`) — when `$true`, + `SPSyncUserProfile.ps1` reports `Disabled` accounts as Not Added instead of creating + or updating their profile. Default preserves the previous behaviour. (#21) +- **`NotAddedReason`** on each Not-Added entry — `AD_NOT_FOUND`, `MISSING_ATTRIBUTES` + or `DISABLED` — with a per-reason breakdown in the run summary, so an expected miss + (a departed account) is easy to tell apart from an actionable one (e.g. a forest + missing from `ad-domains.psd1`). (#21) +- The **`SPSyncUserInfoList` HTML report** gains an *AD Status* column and a + *Disabled in AD* card (with a note pointing at `SkipDisabledUsers`), so disabled — + departed-but-retained — accounts are visible before the profile sync runs. (#21) + +### Changed + +- CI: bump the GitHub Actions that ran on the deprecated Node.js 20 runtime — + `actions/checkout` `v4`→`v7`, `actions/upload-artifact` `v4`→`v7`, + `softprops/action-gh-release` `v2`→`v3`. CI-only; the packaged module and scripts + are unchanged. (#22) + +### Upgrade note + +`SkipDisabledUsers` acts on the `AccountStatus` written by `SPSyncUserInfoList.ps1` +1.3.3+. **Regenerate the JSON snapshot after upgrading** for the flag to take effect; +a pre-1.3.3 snapshot carries no status, so no user is skipped as disabled until then. +There is no behaviour change for a correctly-deployed farm when `SkipDisabledUsers` +is left at its default `$false`. + ## [1.3.2] - 2026-07-08 This is a hardening release that makes a broken or mis-deployed `secrets.psd1` diff --git a/src/Modules/SPSUserSync.Common/Public/ConvertTo-SPSUserRecord.ps1 b/src/Modules/SPSUserSync.Common/Public/ConvertTo-SPSUserRecord.ps1 index e77d488..f2a51ae 100644 --- a/src/Modules/SPSUserSync.Common/Public/ConvertTo-SPSUserRecord.ps1 +++ b/src/Modules/SPSUserSync.Common/Public/ConvertTo-SPSUserRecord.ps1 @@ -16,6 +16,16 @@ display name falls back to "givenName sn". When the entry is $null (user not found in AD), every attribute is left $null and Resolved is $false. + The account state is exposed via two additive fields, derived only from the + universal userAccountControl attribute so the classification is identical + for every directory (no customer-specific convention required): + - AccountStatus : 'Active' (found, ACCOUNTDISABLE bit not set), 'Disabled' + (found, userAccountControl 0x2 set) or 'NotFound' (null AD entry). + - Enabled : $true only for an 'Active' account. + A resolved account whose entry carries no userAccountControl (e.g. some + non-AD LDAP directories) is treated as Active/Enabled, preserving the + pre-1.3.3 behaviour. + .PARAMETER UserLogin The SharePoint user login this record represents (carried through as-is). @@ -25,7 +35,7 @@ .OUTPUTS A PSCustomObject: UserLogin, DisplayName, FirstName, LastName, Email, - Country, Location, Resolved, Error. + Country, Location, Resolved, Enabled, AccountStatus, Error. .EXAMPLE $record = ConvertTo-SPSUserRecord -UserLogin 'i:0#.w|CONTOSO\jdoe' -AdUser (Get-SPSADUser -UserLogin 'i:0#.w|CONTOSO\jdoe') @@ -44,15 +54,17 @@ ) $record = [PSCustomObject]@{ - UserLogin = $UserLogin - DisplayName = $null - FirstName = $null - LastName = $null - Email = $null - Country = $null - Location = $null - Resolved = $false - Error = $null + UserLogin = $UserLogin + DisplayName = $null + FirstName = $null + LastName = $null + Email = $null + Country = $null + Location = $null + Resolved = $false + Enabled = $false + AccountStatus = 'NotFound' + Error = $null } if ($null -eq $AdUser) { @@ -70,6 +82,24 @@ -not [string]::IsNullOrEmpty($record.LastName)) { $record.DisplayName = "$($record.FirstName) $($record.LastName)" } + + # Account status from userAccountControl (bit 0x2 = ACCOUNTDISABLE). This is a + # universal AD attribute, so the active/disabled distinction works for every + # directory without any customer-specific convention. A resolved entry that + # carries no userAccountControl (some non-AD LDAP directories) stays Active, + # the pre-1.3.3 behaviour. NB: [Int32]::TryParse (2-arg) is used rather than a + # wider [Enum] overload, for Windows PowerShell 5.1 / .NET Framework support. + $record.AccountStatus = 'Active' + $record.Enabled = $true + $uacRaw = $AdUser.Properties['useraccountcontrol'] + if ($null -ne $uacRaw) { + $uacInt = 0 + if ([System.Int32]::TryParse("$(@($uacRaw)[0])", [ref] $uacInt) -and (($uacInt -band 0x2) -ne 0)) { + $record.AccountStatus = 'Disabled' + $record.Enabled = $false + } + } + $record.Resolved = $true return $record diff --git a/src/Modules/SPSUserSync.Common/Public/Export-SPSUserReport.ps1 b/src/Modules/SPSUserSync.Common/Public/Export-SPSUserReport.ps1 index 4094a18..7cb36e4 100644 --- a/src/Modules/SPSUserSync.Common/Public/Export-SPSUserReport.ps1 +++ b/src/Modules/SPSUserSync.Common/Public/Export-SPSUserReport.ps1 @@ -8,9 +8,10 @@ works offline) summarizing one of the two SPSUserSync datasets: - ReportType 'UserInfoList' : the snapshot written by SPSyncUserInfoList.ps1 - (UserLogin / DisplayName / Email / Country ...). The summary shows the total - user count, the email coverage, and the top countries and Active Directory - domains. + (UserLogin / DisplayName / Email / Country / AccountStatus ...). The summary + shows the total user count, the email coverage, the unresolved count, the + number of accounts disabled in AD, and the top countries and Active + Directory domains. - ReportType 'UserProfile' : the reconciliation log written by SPSyncUserProfile.ps1 (AccountName / Status / WorkEmail / Date). The summary breaks the run down by Status (CREATE / UPDATE / INFO / UNKNOWN_USER). @@ -128,15 +129,22 @@ if ($ReportType -eq 'UserInfoList') { if ([string]::IsNullOrEmpty($Title)) { $Title = 'SPSUserSync - User Information List Report' } $columns = @( - @{ field = 'UserLogin'; label = 'User Login' } - @{ field = 'DisplayName'; label = 'Display Name' } - @{ field = 'Email'; label = 'Email' } - @{ field = 'Country'; label = 'Country' } + @{ field = 'UserLogin'; label = 'User Login' } + @{ field = 'DisplayName'; label = 'Display Name' } + @{ field = 'Email'; label = 'Email' } + @{ field = 'Country'; label = 'Country' } + @{ field = 'AccountStatus'; label = 'AD Status' } ) $total = $records.Count $withEmail = @($records | Where-Object { -not [string]::IsNullOrEmpty($_.Email) }).Count $withoutEmail = $total - $withEmail + # Accounts found in AD but disabled (userAccountControl 0x2), recorded by + # SPSyncUserInfoList 1.3.3+. They resolve cleanly (so they are NOT counted as + # unresolved) but are the departed-but-retained accounts SkipDisabledUsers + # keeps out of the User Profile. A pre-1.3.3 snapshot has no AccountStatus, + # so this is simply 0. + $disabledCount = @($records | Where-Object { "$($_.AccountStatus)" -eq 'Disabled' }).Count # Flag users whose identity did not resolve from AD. A record is # "unresolved" when it has no display name, or its display name is just @@ -172,12 +180,17 @@ (Get-SPSReportCardHtml -Value $withEmail -Label 'With email') (Get-SPSReportCardHtml -Value $withoutEmail -Label 'Without email') (Get-SPSReportCardHtml -Value $unresolvedCount -Label 'Unresolved' -Tone $(if ($unresolvedCount -gt 0) { 'warn' } else { '' })) + (Get-SPSReportCardHtml -Value $disabledCount -Label 'Disabled in AD' -Tone $(if ($disabledCount -gt 0) { 'warn' } else { '' })) ) -join '' if ($unresolvedCount -gt 0) { $userWord = if ($unresolvedCount -eq 1) { 'user' } else { 'users' } $detailsNote = "
$unresolvedCount unresolved $userWord highlighted below — their identity did not resolve from Active Directory (no display name, or the display name is just the login), so they will not sync to the user profile and would be removed if RemoveUnresolvableUsers is enabled.
" } + if ($disabledCount -gt 0) { + $accWord = if ($disabledCount -eq 1) { 'account is' } else { 'accounts are' } + $detailsNote += "
$disabledCount disabled AD $accWord present in this snapshot (see the AD Status column). They resolve normally but are disabled in Active Directory — typically departed employees kept for permission history. Enable SkipDisabledUsers in sync-settings.psd1 to keep them out of the User Profile Service Application.
" + } $listsHtml = (Get-SPSReportTopListHtml -Title 'Top countries' -Groups $topCountries) + (Get-SPSReportTopListHtml -Title 'Top AD domains' -Groups $topDomains) diff --git a/src/Modules/SPSUserSync.Common/Public/Resolve-SPSADUserBatch.ps1 b/src/Modules/SPSUserSync.Common/Public/Resolve-SPSADUserBatch.ps1 index a083360..da1b83e 100644 --- a/src/Modules/SPSUserSync.Common/Public/Resolve-SPSADUserBatch.ps1 +++ b/src/Modules/SPSUserSync.Common/Public/Resolve-SPSADUserBatch.ps1 @@ -58,7 +58,7 @@ .OUTPUTS System.Collections.Generic.List[System.Management.Automation.PSObject] One object per input login: UserLogin, DisplayName, FirstName, LastName, - Email, Country, Location, Resolved, Error. + Email, Country, Location, Resolved, Enabled, AccountStatus, Error. .EXAMPLE $resolved = Resolve-SPSADUserBatch -UserLogin $uniqueLogins @@ -117,16 +117,18 @@ } catch { [PSCustomObject]@{ - UserLogin = $UserLogin - DisplayName = $null - FirstName = $null - LastName = $null - Email = $null - Country = $null - Location = $null - Resolved = $false - Error = $_.Exception.Message - ConfigError = ($_.FullyQualifiedErrorId -like 'SPSADConfigError*') + UserLogin = $UserLogin + DisplayName = $null + FirstName = $null + LastName = $null + Email = $null + Country = $null + Location = $null + Resolved = $false + Enabled = $false + AccountStatus = 'NotFound' + Error = $_.Exception.Message + ConfigError = ($_.FullyQualifiedErrorId -like 'SPSADConfigError*') } } } @@ -170,16 +172,18 @@ } catch { $results.Add([PSCustomObject]@{ - UserLogin = $job.Login - DisplayName = $null - FirstName = $null - LastName = $null - Email = $null - Country = $null - Location = $null - Resolved = $false - Error = $_.Exception.Message - ConfigError = ($_.FullyQualifiedErrorId -like 'SPSADConfigError*') + UserLogin = $job.Login + DisplayName = $null + FirstName = $null + LastName = $null + Email = $null + Country = $null + Location = $null + Resolved = $false + Enabled = $false + AccountStatus = 'NotFound' + Error = $_.Exception.Message + ConfigError = ($_.FullyQualifiedErrorId -like 'SPSADConfigError*') }) } finally { diff --git a/src/Modules/SPSUserSync.Common/SPSUserSync.Common.psd1 b/src/Modules/SPSUserSync.Common/SPSUserSync.Common.psd1 index a565081..6858e9a 100644 --- a/src/Modules/SPSUserSync.Common/SPSUserSync.Common.psd1 +++ b/src/Modules/SPSUserSync.Common/SPSUserSync.Common.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'SPSUserSync.Common.psm1' - ModuleVersion = '1.3.2' + ModuleVersion = '1.3.3' GUID = '97e2ce6d-509f-4916-846e-da2d5780765e' Author = 'Jean-Cyril DROUHIN' CompanyName = 'luigilink' diff --git a/src/SPSyncUserInfoList.ps1 b/src/SPSyncUserInfoList.ps1 index 2238662..e33c0a1 100644 --- a/src/SPSyncUserInfoList.ps1 +++ b/src/SPSyncUserInfoList.ps1 @@ -61,6 +61,7 @@ class SPSiteUser { [System.String]$Email [System.String]$Location [System.String]$Country + [System.String]$AccountStatus } class SPSDeletedUser { @@ -300,6 +301,8 @@ Exception: $_ # Phase 3: build one JSON record per unique user (no SharePoint calls). # DisplayName falls back to the SharePoint display name, and the SharePoint # email wins over the AD mail, exactly as the sequential version did. + # AccountStatus (Active / Disabled / NotFound) is carried straight from the + # AD resolution so SPSyncUserProfile.ps1 can act on it downstream. foreach ($snapshot in $uniqueUsers.Values) { $resolved = $resolvedByLogin[$snapshot.UserLogin] @@ -316,13 +319,14 @@ Exception: $_ } [void]$tbSPSiteUsers.Add([SPSiteUser]@{ - UserLogin = $snapshot.UserLogin - DisplayName = $recordDisplayName - FirstName = $resolved.FirstName - LastName = $resolved.LastName - Email = $recordEmail - Location = $resolved.Location - Country = $resolved.Country + UserLogin = $snapshot.UserLogin + DisplayName = $recordDisplayName + FirstName = $resolved.FirstName + LastName = $resolved.LastName + Email = $recordEmail + Location = $resolved.Location + Country = $resolved.Country + AccountStatus = $resolved.AccountStatus }) } diff --git a/src/SPSyncUserProfile.ps1 b/src/SPSyncUserProfile.ps1 index a2694bd..076c116 100644 --- a/src/SPSyncUserProfile.ps1 +++ b/src/SPSyncUserProfile.ps1 @@ -14,7 +14,11 @@ - When the profile does not exist, the script creates it provided the account can still be resolved in Active Directory (via Test-SPSADUser). - Users missing FirstName, LastName or Email in the input JSON are - written to a separate JSON for follow-up. + written to a separate JSON for follow-up, each tagged with a + NotAddedReason (AD_NOT_FOUND, MISSING_ATTRIBUTES or DISABLED). + - When SkipDisabledUsers is enabled in sync-settings.psd1, accounts flagged + Disabled in the snapshot (AD userAccountControl) are reported as Not Added + instead of being provisioned, so departed-but-retained accounts are skipped. Before processing, the script pre-flights the Active Directory configuration once per forest present in the input: if a forest's secrets.psd1 entry cannot @@ -229,6 +233,83 @@ Exception: $_ Write-Output "Manage $UserLogin in User Profile Service App - Ended at $(Get-Date)" Write-Output '--------------------------------------------------------------' } + +function Split-SPSProfileUser { + <# + .SYNOPSIS + Partitions the input users into the profiles to provision and the ones to + leave out, tagging every excluded user with a NotAddedReason. + + .DESCRIPTION + A user is eligible for a User Profile only when it has the required + attributes (FirstName, LastName and Email) AND is not being deliberately + skipped for being disabled in Active Directory. + + Excluded users are annotated with a NotAddedReason so the operator can tell + an expected miss from an actionable one: + - 'AD_NOT_FOUND' : the account no longer resolves in AD (deleted / + departed, or a forest not declared in ad-domains.psd1) + - it has no attributes AND AccountStatus 'NotFound'. + - 'MISSING_ATTRIBUTES': resolved but FirstName, LastName or Email is empty. + - 'DISABLED' : has all attributes but the AD account is disabled and + SkipDisabledUsers is on. + + The disabled gate relies only on the universal AccountStatus tag written by + SPSyncUserInfoList.ps1 (from userAccountControl), so it behaves identically + for every customer. Snapshots produced before 1.3.3 carry no AccountStatus, + so no user is skipped as disabled until a fresh snapshot is generated. + + .PARAMETER User + The users loaded from the input JSON. + + .PARAMETER SkipDisabledUsers + When $true, users whose AccountStatus is 'Disabled' are excluded from + provisioning. Default $false preserves the pre-1.3.3 behaviour. + + .OUTPUTS + A hashtable with two ArrayLists: Eligible and NotAdded. + #> + [CmdletBinding()] + [OutputType([System.Collections.Hashtable])] + param + ( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [System.Object[]] + $User, + + [Parameter()] + [System.Boolean] + $SkipDisabledUsers = $false + ) + + $eligible = New-Object System.Collections.ArrayList + $notAdded = New-Object System.Collections.ArrayList + + foreach ($u in $User) { + $missingAttributes = [string]::IsNullOrEmpty($u.FirstName) -or + [string]::IsNullOrEmpty($u.LastName) -or + [string]::IsNullOrEmpty($u.Email) + $isDisabled = ($u.AccountStatus -eq 'Disabled') + + if (-not $missingAttributes -and -not ($SkipDisabledUsers -and $isDisabled)) { + [void]$eligible.Add($u) + continue + } + + if ($missingAttributes) { + $reason = if ($u.AccountStatus -eq 'NotFound') { 'AD_NOT_FOUND' } else { 'MISSING_ATTRIBUTES' } + } + else { + # Reached only via the SkipDisabledUsers gate (attributes are present). + $reason = 'DISABLED' + } + + [void]$notAdded.Add(($u | Add-Member -NotePropertyName 'NotAddedReason' -NotePropertyValue $reason -PassThru -Force)) + } + + return @{ Eligible = $eligible; NotAdded = $notAdded } +} #endregion #region Main @@ -318,9 +399,18 @@ if ($null -eq $getMysiteHost) { Throw $catchMessage } -# Filter users with prerequisites -$usersWithPrerequisites = $psoSPSiteUsers | Where-Object -FilterScript { -not ([string]::IsNullOrEmpty($_.FirstName) -or [string]::IsNullOrEmpty($_.LastName) -or [string]::IsNullOrEmpty($_.Email)) } -$usersWithoutPrerequisites = $psoSPSiteUsers | Where-Object -FilterScript { [string]::IsNullOrEmpty($_.FirstName) -or [string]::IsNullOrEmpty($_.LastName) -or [string]::IsNullOrEmpty($_.Email) } +# Filter users with prerequisites. AD-disabled accounts are optionally excluded +# (opt-in SkipDisabledUsers, default $false = pre-1.3.3 behaviour), so a departed +# employee whose account is disabled-but-retained does not get a profile. The gate +# reads only the universal AccountStatus tag from the input JSON, so it works for +# every customer regardless of their leaver process. +$skipDisabled = if ($null -ne $settings.SkipDisabledUsers) { [System.Boolean]$settings.SkipDisabledUsers } else { $false } +if ($skipDisabled) { + Write-Output 'SkipDisabledUsers is enabled: AD-disabled accounts will be reported as Not Added instead of provisioned.' +} +$splitUsers = Split-SPSProfileUser -User @($psoSPSiteUsers) -SkipDisabledUsers $skipDisabled +$usersWithPrerequisites = $splitUsers.Eligible +$usersWithoutPrerequisites = $splitUsers.NotAdded # Pre-flight the AD configuration once per forest BEFORE the loop. Profiles are only # created for users that still resolve in AD (Test-SPSADUser), so a secrets.psd1 that @@ -369,7 +459,10 @@ Exception: $_ } if ($usersWithoutPrerequisites.Count -ne 0) { - Write-Output "$($usersWithoutPrerequisites.Count) users do not meet Prerequisites: First Name, Last Name and Email aren't filled." + $missingCount = @($usersWithoutPrerequisites | Where-Object { $_.NotAddedReason -eq 'MISSING_ATTRIBUTES' }).Count + $notFoundCount = @($usersWithoutPrerequisites | Where-Object { $_.NotAddedReason -eq 'AD_NOT_FOUND' }).Count + $disabledCount = @($usersWithoutPrerequisites | Where-Object { $_.NotAddedReason -eq 'DISABLED' }).Count + Write-Output "$($usersWithoutPrerequisites.Count) user(s) not added to the User Profile Service Application (missing attributes: $missingCount, not found in AD: $notFoundCount, disabled: $disabledCount)." $usersWithoutPrerequisites | ConvertTo-Json | Set-Content -Path $pathUserNotAddedInUSPFile -Force -Encoding UTF8 Write-Output "Saved User Profile Management in file: $pathUserNotAddedInUSPFile" } diff --git a/src/config/sync-settings.example.psd1 b/src/config/sync-settings.example.psd1 index 3308717..0a073fd 100644 --- a/src/config/sync-settings.example.psd1 +++ b/src/config/sync-settings.example.psd1 @@ -41,6 +41,19 @@ # from the farm. RemoveUnresolvableUsers = $false + # SkipDisabledUsers : when $true, SPSyncUserProfile.ps1 does NOT create/update a + # User Profile for an account flagged Disabled in the snapshot (SPSyncUserInfoList + # records this from the AD userAccountControl attribute). Disabled accounts are + # written to the Not-Added report with reason 'DISABLED' instead. Use this when + # departed employees are kept as *disabled* AD accounts (and retained in the + # SharePoint User Information List for permission history) and you do not want + # profiles created for them. When $false (default), behaviour is unchanged: a + # disabled-but-resolvable account still gets a profile. Relies only on the + # universal userAccountControl bit, so it works for any directory. Note: it acts + # on the AccountStatus written by SPSyncUserInfoList 1.3.3+, so regenerate the + # JSON snapshot after upgrading for this flag to take effect. + SkipDisabledUsers = $false + MasterVM = 'YOUR-MASTER-SERVER' MySiteUrl = 'https://mysite.contoso.com' RemoteJsonPath = '\\{0}\d$\Tools\SCRIPTS\JOBS\SPSyncUserProfile\SPSyncUserInfoListUserList-{1}.json' diff --git a/tests/ConvertTo-SPSUserRecord.Tests.ps1 b/tests/ConvertTo-SPSUserRecord.Tests.ps1 index 887e51c..860bfd5 100644 --- a/tests/ConvertTo-SPSUserRecord.Tests.ps1 +++ b/tests/ConvertTo-SPSUserRecord.Tests.ps1 @@ -33,6 +33,40 @@ Describe 'ConvertTo-SPSUserRecord' { $r.Error | Should -BeNullOrEmpty } + It 'treats a resolved user with no userAccountControl as active (backward compatible)' { + $ad = New-FakeAdUser -Properties @{ givenname = 'Adam'; sn = 'Becker'; mail = 'a@x'; displayname = 'Adam Becker' } + $r = ConvertTo-SPSUserRecord -UserLogin 'ZEBES\adambecker' -AdUser $ad + $r.AccountStatus | Should -Be 'Active' + $r.Enabled | Should -BeTrue + } + + It 'marks the account active when userAccountControl has no disable bit (512)' { + $ad = New-FakeAdUser -Properties @{ givenname = 'A'; sn = 'B'; mail = 'a@x'; displayname = 'A B'; useraccountcontrol = 512 } + $r = ConvertTo-SPSUserRecord -UserLogin 'ZEBES\ab' -AdUser $ad + $r.AccountStatus | Should -Be 'Active' + $r.Enabled | Should -BeTrue + } + + It 'marks the account disabled when userAccountControl has the ACCOUNTDISABLE bit (514)' { + $ad = New-FakeAdUser -Properties @{ givenname = 'A'; sn = 'B'; mail = 'a@x'; displayname = 'A B'; useraccountcontrol = 514 } + $r = ConvertTo-SPSUserRecord -UserLogin 'ZEBES\ab' -AdUser $ad + $r.AccountStatus | Should -Be 'Disabled' + $r.Enabled | Should -BeFalse + $r.Resolved | Should -BeTrue + } + + It 'detects the disable bit when combined with other flags (66050 = 0x10202)' { + $ad = New-FakeAdUser -Properties @{ givenname = 'A'; sn = 'B'; mail = 'a@x'; displayname = 'A B'; useraccountcontrol = 66050 } + $r = ConvertTo-SPSUserRecord -UserLogin 'ZEBES\ab' -AdUser $ad + $r.AccountStatus | Should -Be 'Disabled' + } + + It 'parses a string userAccountControl (SearchResult values arrive as strings)' { + $ad = New-FakeAdUser -Properties @{ givenname = 'A'; sn = 'B'; mail = 'a@x'; displayname = 'A B'; useraccountcontrol = '514' } + $r = ConvertTo-SPSUserRecord -UserLogin 'ZEBES\ab' -AdUser $ad + $r.AccountStatus | Should -Be 'Disabled' + } + It 'upper-cases country and location' { $ad = New-FakeAdUser -Properties @{ givenname = 'A'; sn = 'B'; co = 'fr'; l = 'lyon'; displayname = 'A B' } $r = ConvertTo-SPSUserRecord -UserLogin 'ZEBES\ab' -AdUser $ad @@ -55,11 +89,13 @@ Describe 'ConvertTo-SPSUserRecord' { It 'returns an unresolved record for a null AD entry' { $r = ConvertTo-SPSUserRecord -UserLogin 'ZEBES\ghost' -AdUser $null - $r.UserLogin | Should -Be 'ZEBES\ghost' - $r.Resolved | Should -BeFalse - $r.DisplayName | Should -BeNullOrEmpty - $r.FirstName | Should -BeNullOrEmpty - $r.Email | Should -BeNullOrEmpty + $r.UserLogin | Should -Be 'ZEBES\ghost' + $r.Resolved | Should -BeFalse + $r.Enabled | Should -BeFalse + $r.AccountStatus | Should -Be 'NotFound' + $r.DisplayName | Should -BeNullOrEmpty + $r.FirstName | Should -BeNullOrEmpty + $r.Email | Should -BeNullOrEmpty } } diff --git a/tests/Export-SPSUserReport.Tests.ps1 b/tests/Export-SPSUserReport.Tests.ps1 index 3a8c3aa..1c826cb 100644 --- a/tests/Export-SPSUserReport.Tests.ps1 +++ b/tests/Export-SPSUserReport.Tests.ps1 @@ -88,6 +88,59 @@ Describe 'Export-SPSUserReport unresolved flagging (UserInfoList)' { } } +Describe 'Export-SPSUserReport account status (UserInfoList, 1.3.3)' { + BeforeAll { + $records = @( + [pscustomobject]@{ UserLogin = 'i:0#.w|CONTOSO\jdoe'; DisplayName = 'DOE John'; Email = 'john.doe@contoso.com'; Country = 'FR'; AccountStatus = 'Active' } + [pscustomobject]@{ UserLogin = 'i:0#.w|CONTOSO\gone'; DisplayName = 'GONE Greg'; Email = 'greg@contoso.com'; Country = 'FR'; AccountStatus = 'NotFound' } + [pscustomobject]@{ UserLogin = 'i:0#.w|CONTOSO\dis'; DisplayName = 'DIS Dana'; Email = 'dana@contoso.com'; Country = 'FR'; AccountStatus = 'Disabled' } + [pscustomobject]@{ UserLogin = 'i:0#.w|CONTOSO\dis2'; DisplayName = 'DIS Dan'; Email = 'dan@contoso.com'; Country = 'US'; AccountStatus = 'Disabled' } + ) + $output = Join-Path $TestDrive 'status.html' + $null = Export-SPSUserReport -InputObject $records -ReportType 'UserInfoList' -OutputFile $output -ClaimPrefix 'i:0#.w|' + $html = Get-Content -Path $output -Raw + } + + It 'adds a Disabled in AD summary card with the count' { + $html | Should -BeLike '*>2
Disabled in AD
*' + } + + It 'renders the Disabled card with the warn tone when any account is disabled' { + $html | Should -BeLike '*class="card warn"*' + } + + It 'exposes the AD Status column and the status values in the payload' { + $html | Should -BeLike '*AD Status*' + $html | Should -BeLike '*"AccountStatus":"Disabled"*' + $html | Should -BeLike '*"AccountStatus":"NotFound"*' + } + + It 'shows the disabled note referencing SkipDisabledUsers' { + $html | Should -BeLike '*SkipDisabledUsers*' + } +} + +Describe 'Export-SPSUserReport backward compatibility (UserInfoList without AccountStatus)' { + BeforeAll { + # A pre-1.3.3 snapshot has no AccountStatus property at all. + $records = @( + [pscustomobject]@{ UserLogin = 'i:0#.w|CONTOSO\jdoe'; DisplayName = 'DOE John'; Email = 'john.doe@contoso.com'; Country = 'FR' } + ) + $output = Join-Path $TestDrive 'legacy.html' + $null = Export-SPSUserReport -InputObject $records -ReportType 'UserInfoList' -OutputFile $output + $html = Get-Content -Path $output -Raw + } + + It 'still renders and reports zero disabled accounts' { + Test-Path $output | Should -BeTrue + $html | Should -BeLike '*>0
Disabled in AD
*' + } + + It 'does not add the disabled note when there are no disabled accounts' { + $html | Should -Not -BeLike '*SkipDisabledUsers*' + } +} + Describe 'Export-SPSUserReport (UserProfile)' { It 'renders a card per status' { $records = @( diff --git a/tests/SPSyncUserProfile.Tests.ps1 b/tests/SPSyncUserProfile.Tests.ps1 index a5dd3f7..698abf4 100644 --- a/tests/SPSyncUserProfile.Tests.ps1 +++ b/tests/SPSyncUserProfile.Tests.ps1 @@ -9,12 +9,27 @@ BeforeAll { $tokens = $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$errors) - $fnAst = $ast.Find({ - param($node) - $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $node.Name -eq 'Add-SPSUserProfile' - }, $true) - . ([scriptblock]::Create($fnAst.Extent.Text)) + foreach ($fnName in 'Add-SPSUserProfile', 'Split-SPSProfileUser') { + $fnAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $fnName + }, $true) + . ([scriptblock]::Create($fnAst.Extent.Text)) + } + + function New-JsonUser { + param($UserLogin, $FirstName, $LastName, $Email, $AccountStatus) + $o = [PSCustomObject]@{ + UserLogin = $UserLogin; DisplayName = "$FirstName $LastName" + FirstName = $FirstName; LastName = $LastName; Email = $Email + Location = 'PARIS'; Country = 'FR' + } + if ($PSBoundParameters.ContainsKey('AccountStatus')) { + $o | Add-Member -NotePropertyName 'AccountStatus' -NotePropertyValue $AccountStatus + } + $o + } } Describe 'Add-SPSUserProfile parameter contract' { @@ -34,3 +49,61 @@ Describe 'Add-SPSUserProfile parameter contract' { $hasAllowEmpty | Should -BeTrue } } + +Describe 'Split-SPSProfileUser' { + It 'routes a fully-resolved active user to Eligible' { + $res = Split-SPSProfileUser -User @(New-JsonUser 'ZEBES\a' 'Ann' 'Fox' 'ann@x' 'Active') + $res.Eligible.Count | Should -Be 1 + $res.NotAdded.Count | Should -Be 0 + } + + It 'routes a user missing Email to NotAdded with reason MISSING_ATTRIBUTES' { + $res = Split-SPSProfileUser -User @(New-JsonUser 'ZEBES\a' 'Ann' 'Fox' '' 'Active') + $res.Eligible.Count | Should -Be 0 + $res.NotAdded.Count | Should -Be 1 + $res.NotAdded[0].NotAddedReason | Should -Be 'MISSING_ATTRIBUTES' + } + + It 'tags an unresolved (empty-attribute, NotFound) user as AD_NOT_FOUND' { + $res = Split-SPSProfileUser -User @(New-JsonUser 'ZEBES\ghost' '' '' '' 'NotFound') + $res.NotAdded.Count | Should -Be 1 + $res.NotAdded[0].NotAddedReason | Should -Be 'AD_NOT_FOUND' + } + + It 'provisions a disabled-but-complete user by default (SkipDisabledUsers = $false)' { + $res = Split-SPSProfileUser -User @(New-JsonUser 'ZEBES\d' 'Dan' 'Vega' 'dan@x' 'Disabled') + $res.Eligible.Count | Should -Be 1 + $res.NotAdded.Count | Should -Be 0 + } + + It 'skips a disabled user and tags it DISABLED when SkipDisabledUsers = $true' { + $res = Split-SPSProfileUser -User @(New-JsonUser 'ZEBES\d' 'Dan' 'Vega' 'dan@x' 'Disabled') -SkipDisabledUsers $true + $res.Eligible.Count | Should -Be 0 + $res.NotAdded.Count | Should -Be 1 + $res.NotAdded[0].NotAddedReason | Should -Be 'DISABLED' + } + + It 'still provisions active users when SkipDisabledUsers = $true' { + $res = Split-SPSProfileUser -User @( + New-JsonUser 'ZEBES\a' 'Ann' 'Fox' 'ann@x' 'Active' + New-JsonUser 'ZEBES\d' 'Dan' 'Vega' 'dan@x' 'Disabled' + ) -SkipDisabledUsers $true + $res.Eligible.Count | Should -Be 1 + $res.Eligible[0].UserLogin | Should -Be 'ZEBES\a' + $res.NotAdded[0].NotAddedReason | Should -Be 'DISABLED' + } + + It 'is backward compatible with pre-1.3.3 snapshots (no AccountStatus) even with SkipDisabledUsers = $true' { + # Old JSON has no AccountStatus property: a complete user must still be + # provisioned (never mistaken for disabled). + $res = Split-SPSProfileUser -User @(New-JsonUser 'ZEBES\a' 'Ann' 'Fox' 'ann@x') -SkipDisabledUsers $true + $res.Eligible.Count | Should -Be 1 + $res.NotAdded.Count | Should -Be 0 + } + + It 'accepts an empty input set' { + $res = Split-SPSProfileUser -User @() -SkipDisabledUsers $true + $res.Eligible.Count | Should -Be 0 + $res.NotAdded.Count | Should -Be 0 + } +} diff --git a/wiki/Configuration.md b/wiki/Configuration.md index ee7a390..19d42d7 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -140,6 +140,9 @@ Environment-specific knobs read by both scripts. # Parallel AD resolution (1.3.0+) ParallelADResolution = $false MaxParallelADQueries = 0 + + # AD account status handling (1.3.3+) + SkipDisabledUsers = $false } ``` @@ -162,6 +165,7 @@ Environment-specific knobs read by both scripts. | `GenerateHtmlReport` | both | **(1.1.0+)** When `$true`, each run also writes a self-contained HTML report under `Logs\`. Defaults to `$true` when absent. | | `ParallelADResolution` | `SPSyncUserInfoList` | **(1.3.0+)** When `$true`, the unique user logins are resolved against AD concurrently via a RunspacePool. Worth enabling on large multi-forest farms where the LDAP round-trip dominates; leave `$false` on small farms (the per-runspace module-import overhead is not amortized). The generated JSON is identical either way. Defaults to `$false` when absent. | | `MaxParallelADQueries` | `SPSyncUserInfoList` | **(1.3.0+)** Maximum concurrent AD lookups when `ParallelADResolution` is `$true`. `0` (or absent) lets the toolkit pick a value from the CPU count (cap 10 on 8+ logical CPUs). | +| `SkipDisabledUsers` | `SPSyncUserProfile` | **(1.3.3+)** When `$true`, an account flagged `Disabled` in the snapshot (from the AD `userAccountControl` bit) is **not** given a User Profile — it is written to the Not-Added report with reason `DISABLED`. Use it when departed employees are kept as *disabled* AD accounts (and retained in the SharePoint User Information List for permission history). Defaults to `$false` (a disabled-but-resolvable account still gets a profile), unchanged from 1.3.2. Acts on the `AccountStatus` written by `SPSyncUserInfoList` 1.3.3+, so regenerate the JSON snapshot after upgrading for it to take effect. | > **Backward compatibility:** the three `1.1.0+` keys are optional. An existing `sync-settings.psd1` created for 1.0.0 keeps working unchanged; the documented defaults apply until you add the keys. diff --git a/wiki/Usage.md b/wiki/Usage.md index b7279b7..25c9e56 100644 --- a/wiki/Usage.md +++ b/wiki/Usage.md @@ -217,14 +217,39 @@ The JSON snapshot is an array of records, one per unique `SPUser`: "LastName": "DOE", "Email": "john.doe@contoso.com", "Location": "PARIS", - "Country": "FR" + "Country": "FR", + "AccountStatus": "Active" } ] ``` +`AccountStatus` **(1.3.3+)** is derived from the AD `userAccountControl` attribute and +is one of `Active`, `Disabled` (the account exists but is disabled — e.g. a departed +employee kept for permission history) or `NotFound` (the account no longer resolves in +AD). It is additive: older consumers ignore it. It works for every directory because it +reads only the universal `userAccountControl` bit, with no dependency on how a customer +disables or deletes leavers. + Convert back to PowerShell objects with: ```powershell $users = Get-Content -Path '.\SPSyncUserInfoListUserList.json' -Raw -Encoding UTF8 | ConvertFrom-Json $users | Where-Object Country -eq 'FR' | Measure-Object +$users | Where-Object AccountStatus -eq 'Disabled' | Measure-Object # departed-but-retained accounts ``` + +## The Not-Added report (SPSyncUserProfile) + +`SPSyncUserProfile.ps1` writes the users it did **not** provision to +`SPSyncUserNotAddedInUSPList.json`. **(1.3.3+)** each entry carries a +`NotAddedReason` and the run prints a per-reason breakdown, so an *expected* miss is +easy to tell apart from an *actionable* one: + +| `NotAddedReason` | Meaning | Action | +|---|---|---| +| `AD_NOT_FOUND` | The account no longer resolves in AD (`AccountStatus` `NotFound`): a deleted/departed user, **or** a login whose domain is not declared in `ad-domains.psd1` and fell back to `Default`. | Expected for genuine leavers. If a whole forest is affected, declare it in `ad-domains.psd1` (see *Domain '...' not found*). | +| `MISSING_ATTRIBUTES` | The account resolved but `FirstName`, `LastName` or `Email` is empty in the snapshot. | Populate the missing AD attributes, or exclude the login if it is a service/system account. | +| `DISABLED` | The account has all attributes but is **disabled** in AD, and `SkipDisabledUsers = $true`. | Expected when you keep departed employees as disabled accounts. Set `SkipDisabledUsers = $false` to provision them anyway. | + +`SkipDisabledUsers` acts on the `AccountStatus` produced by `SPSyncUserInfoList` 1.3.3+, +so **regenerate the JSON snapshot after upgrading** for the flag to take effect.