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
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.3.0] - 2026-06-28
## [1.3.1] - 2026-07-08

### Fixed

- `SPSyncUserInfoList.ps1` no longer runs **silently** when the account executing it cannot enumerate the farm site collections (wrong service account / missing Shell Admin on a content database). Previously `Get-SPSite -Limit All` used `-ErrorAction SilentlyContinue` and the resulting `ACCESS_DENIED` (`E_ACCESSDENIED 0x80070005`), which the lazy collection only throws while enumerating, was caught and written **only** to the Windows Event Log — the operator saw an almost-empty screen while the script kept going and then emitted two confusing secondary errors (`Export-SPSUserReport` and `Copy-Item` on a JSON that was never written). The `SilentlyContinue` is removed, the error is now surfaced to the console/transcript **and** the Event Log with an actionable message (Shell Admin / correct service account), and the script fails fast. (#16)
- `SPSyncUserInfoList.ps1` no longer overwrites or copies an **empty** snapshot. When zero users are collected (almost always a rights problem, not a genuinely empty farm), the previous good `SPSyncUserInfoListUserList.json` is left untouched, an explicit error is raised, and the HTML report and the remote copy are **skipped** (the script exits `1`) instead of pushing stale or empty data to the User Profile farm. (#16)

### Added

- `Test-SPSUserSyncReadiness.ps1` now includes a **"Site collection enumeration"** check: it walks `Get-SPSite -Limit All` and **FAILs** on `ACCESS_DENIED`, pointing at the Shell Admin / service account prerequisite. This is the exact permission `SPSyncUserInfoList.ps1` depends on and was the gap that let a wrong account through — the previous `Get-SPFarm` check only proves config-database access, whereas the real run reads every content database. (#16)
- Pester regression tests for `Get-SPSUniqueUsers` covering the hardened behavior: a healthy farm writes the JSON and reports success; a zero-user result writes no JSON and raises an Error event; an `ACCESS_DENIED` while enumerating surfaces an actionable Error and writes no JSON. (#16)

### Added

Expand Down
71 changes: 38 additions & 33 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,46 @@
# SPSUserSync - Release Notes

## [1.3.0] - 2026-06-28

This release adds optional parallel Active Directory resolution to
`SPSyncUserInfoList.ps1`, for large multi-forest farms where the per-user LDAP
round-trip dominates the runtime. It is opt-in and off by default, and the
generated JSON is byte-for-byte identical whether parallel resolution is on or
off (verified on a SharePoint Subscription Edition farm).
## [1.3.1] - 2026-07-08

This is a hardening release for `SPSyncUserInfoList.ps1`. It does not change the
generated JSON for a correctly-permissioned account, but it makes a **wrong
service account** (or a missing Shell Admin) fail loudly and early instead of
silently. It follows a field case where the script was launched with an
under-privileged account and produced no output and no visible error at all.

### Fixed

- **No more silent ACCESS_DENIED runs** (#16). `Get-SPSite -Limit All` no longer
uses `-ErrorAction SilentlyContinue`. When the running account cannot enumerate
the farm site collections, the `ACCESS_DENIED` (`E_ACCESSDENIED 0x80070005`) is
now surfaced to the **console/transcript** as well as the Windows Event Log,
with an actionable message pointing at the Shell Admin / correct service
account. Previously the error reached only the Event Log, so the operator saw an
almost-empty screen while the script kept running and then emitted two confusing
secondary errors on a JSON file that was never written.
- **No more overwriting/copying an empty snapshot** (#16). When zero users are
collected — almost always a rights problem rather than a genuinely empty farm —
the previous good `SPSyncUserInfoListUserList.json` is left untouched, an
explicit error is raised, and the HTML report and the remote copy are **skipped**
(the script exits `1`) instead of pushing empty or stale data to the User
Profile farm.

### Added

- **Parallel AD resolution** (#14). With `ParallelADResolution = $true` (new
setting, default `$false`), the unique user logins are resolved against AD
concurrently through a RunspacePool — Windows PowerShell 5.1 compatible, no
`ForEach-Object -Parallel` required. `MaxParallelADQueries` sets the degree of
parallelism (0 = auto from the CPU count). Off by default because on small
farms the per-runspace module-import overhead is not amortized. Two new public
helpers back it: `Resolve-SPSADUserBatch` (the RunspacePool resolver) and
`Get-SPSThrottleLimit` (CPU-based default). A measured ~8x speedup on a 40-user
mock with 100 ms LDAP latency.
- `ConvertTo-SPSUserRecord` (#14) — the single `Get-SPSADUser` -> record
projection shared by both the sequential path and the parallel worker, which is
what guarantees the identical JSON.

### Changed

- `SPSyncUserInfoList.ps1` now resolves each **unique** login against AD exactly
once (previously once per web the user appeared in), by separating the
user-collection, AD-resolution and JSON-building passes. This also speeds up
the default sequential mode on farms where users span many sites. The
user-removal walk (`Set-SPUser` / `Remove-SPUser`) is unchanged and still runs
per web. (#14)
- **Readiness site-collection enumeration check** (#16). `Test-SPSUserSyncReadiness.ps1`
now walks `Get-SPSite -Limit All` and **FAILs** on `ACCESS_DENIED`, pointing at
the Shell Admin / service account prerequisite. This is the exact permission
`SPSyncUserInfoList.ps1` depends on, and it plugs the gap left by the previous
`Get-SPFarm`-only check (which only proves config-database access, while the
real run reads every content database). Run the readiness check as the service
account before enabling the scheduled task to catch a wrong account up front.
- **Regression tests** (#16) for `Get-SPSUniqueUsers`: healthy farm writes the JSON
and reports success; zero users writes no JSON and raises an Error event;
`ACCESS_DENIED` surfaces an actionable Error and writes no JSON.

### Upgrade notes

- No action required: `ParallelADResolution` and `MaxParallelADQueries` are
optional and default to the previous (sequential) behavior. Add them to
`sync-settings.psd1` only when you want to enable parallel resolution.

A full list of changes in each version can be found in the [change log](CHANGELOG.md)
Drop-in replacement for 1.3.0 — no configuration change required. If you rely on a
scheduled task, make sure the account it runs under is a **Shell Admin** on every
content database (`Add-SPShellAdmin`); with 1.3.1 a wrong account now fails the run
explicitly (exit code `1`) instead of silently producing nothing.
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.0'
ModuleVersion = '1.3.1'
GUID = '97e2ce6d-509f-4916-846e-da2d5780765e'
Author = 'Jean-Cyril DROUHIN'
CompanyName = 'luigilink'
Expand Down
90 changes: 85 additions & 5 deletions src/SPSyncUserInfoList.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ function Get-SPSUniqueUsers {
$tbSPSDeletedUser = New-Object -TypeName System.Collections.ArrayList
$funcStarted = Get-Date

# Success signal consumed by the Main region: set to $true only once a
# non-empty JSON snapshot has actually been written. The downstream HTML report
# and the remote copy are skipped when this stays $false, so a failed or empty
# run never overwrites or propagates the previous good file.
$script:GetSPSUniqueUsersSucceeded = $false

Write-Output '--------------------------------------------------------------'
Write-Output "Get Unique SPUser - Started at $funcStarted"

Expand All @@ -138,7 +144,7 @@ function Get-SPSUniqueUsers {
$getSPSites = Get-SPSite -Limit All | Where-Object -FilterScript { $_.Url -like "$FilterUrl" }
}
else {
$getSPSites = Get-SPSite -Limit All -ErrorAction SilentlyContinue
$getSPSites = Get-SPSite -Limit All
}

# Built-in safety exclusions: these classic system principals are never
Expand Down Expand Up @@ -306,8 +312,32 @@ Exception: $_

Write-Output "$spUsersFound users found in all SPSite object"
Write-Output "$($tbSPSiteUsers.Count) unique users added in PSObject variable"
$tbSPSiteUsers | ConvertTo-Json | Set-Content -Path $JsonFilePath -Force -Encoding UTF8
Write-Output "Saved Unique SPUser in file: $JsonFilePath"

# Anti-clobber guard: a zero-user result almost always means a site
# collection could not be read (wrong account / missing Shell Admin), not a
# genuinely empty farm. Never overwrite the previous good JSON with an empty
# snapshot: keep it in place, fail loudly, and leave the success flag $false
# so the Main region skips the report and the remote copy.
if ($tbSPSiteUsers.Count -eq 0) {
$emptyMessage = @"
No users were collected from the farm; the generated snapshot would be empty.
FilterUrl: $FilterUrl
This usually means the account running this script cannot read the site
collections (check Shell Admin / the service account), not that the farm is
empty. The existing JSON file was left untouched and nothing was copied
downstream.
"@
# -ErrorAction Continue keeps this non-terminating even when the caller
# sets $ErrorActionPreference = 'Stop': it must not be swallowed by this
# same try/catch, and the Main region owns the fail-fast Exit 1.
Write-Error -Message $emptyMessage -ErrorAction Continue
Add-SPSUserSyncEvent -Message $emptyMessage -Source 'Get-SPSUniqueUsers' -EntryType 'Error'
}
else {
$tbSPSiteUsers | ConvertTo-Json | Set-Content -Path $JsonFilePath -Force -Encoding UTF8
Write-Output "Saved Unique SPUser in file: $JsonFilePath"
$script:GetSPSUniqueUsersSucceeded = $true
}

if ($tbSPSDeletedUser.Count -ne 0) {
Write-Output "$($tbSPSDeletedUser.Count) deleted users added in PSObject variable"
Expand All @@ -316,11 +346,35 @@ Exception: $_
}
}
catch {
$catchMessage = @"
$errorDetail = $_
# ACCESS_DENIED while enumerating the site collections is the classic
# wrong-account / missing-Shell-Admin case (E_ACCESSDENIED 0x80070005). Give
# an actionable message instead of a raw stack trace.
$isAccessDenied = ($errorDetail.Exception -is [System.UnauthorizedAccessException]) -or
("$errorDetail" -match 'Access is denied|E_ACCESSDENIED|0x80070005|UnauthorizedAccess')
if ($isAccessDenied) {
$runAccount = try { ([Security.Principal.WindowsIdentity]::GetCurrent()).Name } catch { $env:USERNAME }
$catchMessage = @"
Access denied while enumerating the farm site collections in Get-SPSUniqueUsers.
FilterUrl: $FilterUrl
The account running this script ('$runAccount') cannot read every site collection.
Make sure it is a Shell Admin on every content database (Add-SPShellAdmin) and is
the correct SPSyncUserInfoList service account, then re-run.
Exception: $errorDetail
"@
}
else {
$catchMessage = @"
An error occurred during Get-SPSUniqueUsers
FilterUrl: $FilterUrl
Exception: $_
Exception: $errorDetail
"@
}
# Surface to the console/transcript as well as the Event Log, so an operator
# watching the run sees the failure immediately instead of an empty screen.
# -ErrorAction Continue keeps it non-terminating even under a caller's
# $ErrorActionPreference = 'Stop' (the Main region handles the Exit 1).
Write-Error -Message $catchMessage -ErrorAction Continue
Add-SPSUserSyncEvent -Message $catchMessage -Source 'Get-SPSUniqueUsers' -EntryType 'Error'
}

Expand Down Expand Up @@ -411,6 +465,32 @@ else {
Get-SPSUniqueUsers -FilterUrl $FilterUrl -Settings $settings -JsonFilePath $pathJsonFile -DeletedJsonFilePath $pathUserDeletedFile
}

# Guard: stop here when the snapshot was not produced (for example the running
# account cannot enumerate the site collections). Running the compare, report and
# copy steps against a missing snapshot only emits confusing secondary errors, and
# copying the previous file would push stale data to the User Profile farm. Fail
# loudly and skip the rest.
if (-not $script:GetSPSUniqueUsersSucceeded) {
$failMessage = @"
User snapshot was NOT generated for AppCode '$appCode'.
The most common cause is that the account running this script cannot enumerate the
farm site collections (ACCESS_DENIED). Make sure it is a Shell Admin on every
content database (Add-SPShellAdmin) and is the correct SPSyncUserInfoList service
account, then re-run. See the error above and the Windows Event Log 'SPSUserSync'
for details. The HTML report and the remote copy were skipped.
"@
Write-Error -Message $failMessage -ErrorAction Continue
Add-SPSUserSyncEvent -Message $failMessage -Source 'SPSyncUserInfoList' -EntryType 'Error'

Write-Output '-----------------------------------------------'
Write-Output '| Automated Script - Configuration SPSyncUserInfoList (FAILED)'
Write-Output "| Started on - $($ctx.DateStarted) |"
Write-Output "| Completed on - $(Get-Date) |"
Write-Output '-----------------------------------------------'
Stop-Transcript | Out-Null
Exit 1
}

# Compare the fresh snapshot against the previous one and warn on an abnormal drop
if ($null -ne $previousSnapshot) {
$comparison = Compare-SPSJsonSnapshots -CurrentPath $pathJsonFile -PreviousPath $previousSnapshot -DropThresholdPercent $dropThreshold
Expand Down
36 changes: 36 additions & 0 deletions src/Test-SPSUserSyncReadiness.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
(unless -SkipNetwork) that an LDAP bind succeeds.
- That the SharePoint snap-in is available and the farm is reachable
(unless -SkipSharePoint).
- That the current account can enumerate every site collection - the exact
permission SPSyncUserInfoList.ps1 needs. This catches a wrong service
account or a missing Shell Admin on a content database before the first run
(unless -SkipSharePoint).
- That the current account can read the User Profile Service Application
(a non-destructive profile-count read; UPA master only, unless
-SkipNetwork).
Expand Down Expand Up @@ -415,6 +419,38 @@ else {
Add-CheckResult -Section 'SharePoint' -Name 'Farm access' -Status 'FAIL' -Detail 'Get-SPFarm failed (not a farm member, or missing rights)'
}

# Site collection enumeration - the permission SPSyncUserInfoList.ps1
# actually depends on. Get-SPFarm above only proves config-database access;
# walking every SPSite forces a read of every CONTENT database, which is
# what throws ACCESS_DENIED (E_ACCESSDENIED 0x80070005) when the running
# account is not a Shell Admin on each content DB / is the wrong service
# account. Reproducing it here surfaces that mistake BEFORE the first real
# run instead of during it. Read-only: only each site Url is read, no web is
# opened and no user is touched.
try {
$allSiteUrls = @(Get-SPSite -Limit All -ErrorAction Stop | Select-Object -ExpandProperty Url)
$siteCount = $allSiteUrls.Count
if ($siteCount -eq 0) {
Add-CheckResult -Section 'SharePoint' -Name 'Site collection enumeration' -Status 'WARN' -Detail 'Get-SPSite -Limit All returned no site collection (unexpected on a populated farm)'
}
else {
Add-CheckResult -Section 'SharePoint' -Name 'Site collection enumeration' -Status 'PASS' -Detail "Can enumerate $siteCount site collection(s)"
}
}
catch {
$enumMessage = $_.Exception.Message
if ($null -ne $_.Exception.InnerException) {
$enumMessage = $_.Exception.InnerException.Message
}
if ($enumMessage -match 'Access is denied|denied|E_ACCESSDENIED|0x80070005|UnauthorizedAccess') {
$enumAccount = try { ([Security.Principal.WindowsIdentity]::GetCurrent()).Name } catch { $env:USERNAME }
Add-CheckResult -Section 'SharePoint' -Name 'Site collection enumeration' -Status 'FAIL' -Detail "Access denied enumerating site collections. The account '$enumAccount' must be a Shell Admin on every content database (Add-SPShellAdmin) and the correct SPSyncUserInfoList service account. This is the exact permission SPSyncUserInfoList.ps1 needs."
}
else {
Add-CheckResult -Section 'SharePoint' -Name 'Site collection enumeration' -Status 'FAIL' -Detail "Get-SPSite -Limit All failed: $enumMessage"
}
}

if (-not $SkipNetwork -and $null -ne $settings -and -not [string]::IsNullOrEmpty($settings.MySiteUrl)) {
$mySiteObject = $null
try {
Expand Down
Loading
Loading