Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
50 changes: 40 additions & 10 deletions src/Modules/SPSUserSync.Common/Public/ConvertTo-SPSUserRecord.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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')
Expand All @@ -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) {
Expand All @@ -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
Expand Down
27 changes: 20 additions & 7 deletions src/Modules/SPSUserSync.Common/Public/Export-SPSUserReport.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = "<div class=`"note`"><strong>$unresolvedCount unresolved $userWord</strong> highlighted below &mdash; 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 <code>RemoveUnresolvableUsers</code> is enabled.</div>"
}
if ($disabledCount -gt 0) {
$accWord = if ($disabledCount -eq 1) { 'account is' } else { 'accounts are' }
$detailsNote += "<div class=`"note`"><strong>$disabledCount disabled AD $accWord</strong> present in this snapshot (see the <em>AD Status</em> column). They resolve normally but are disabled in Active Directory &mdash; typically departed employees kept for permission history. Enable <code>SkipDisabledUsers</code> in <code>sync-settings.psd1</code> to keep them out of the User Profile Service Application.</div>"
}

$listsHtml = (Get-SPSReportTopListHtml -Title 'Top countries' -Groups $topCountries) +
(Get-SPSReportTopListHtml -Title 'Top AD domains' -Groups $topDomains)
Expand Down
46 changes: 25 additions & 21 deletions src/Modules/SPSUserSync.Common/Public/Resolve-SPSADUserBatch.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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*')
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/Modules/SPSUserSync.Common/SPSUserSync.Common.psd1
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
18 changes: 11 additions & 7 deletions src/SPSyncUserInfoList.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class SPSiteUser {
[System.String]$Email
[System.String]$Location
[System.String]$Country
[System.String]$AccountStatus
}

class SPSDeletedUser {
Expand Down Expand Up @@ -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]

Expand All @@ -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
})
}

Expand Down
Loading
Loading