Skip to content

SPSUserSync: v1.2.0 — Subscription Edition support, readiness check, opt-in user removal - #9

Merged
luigilink merged 7 commits into
mainfrom
release/1.2.0
Jun 28, 2026
Merged

SPSUserSync: v1.2.0 — Subscription Edition support, readiness check, opt-in user removal#9
luigilink merged 7 commits into
mainfrom
release/1.2.0

Conversation

@luigilink

Copy link
Copy Markdown
Owner

Pull Request (PR) description

Ships SPSUserSync v1.2.0. This release makes the toolkit deployable and
verifiable on a real SharePoint server — including Subscription Edition
without the SharePoint Management Shell, adds a pre-flight readiness check, an
unresolved-user audit in the HTML report, and makes user removal opt-in so a
sync never prunes accounts from a live farm unless explicitly asked.

Behavior change — user removal is now opt-in. On a claims-based farm,
earlier versions removed classic-format and system principals (e.g.
NT AUTHORITY\authenticated users) when Set-SPUser -SyncFromAD could not
resolve them. SPSUserSync now reports those users and leaves them in place by
default. Set RemoveUnresolvableUsers = $true in sync-settings.psd1 to
restore the previous pruning behavior.

Added

Changed

Fixed

Validation (local, macOS + PowerShell 7)

  • Invoke-Pester ./tests49/49 passed
  • Invoke-ScriptAnalyzer ./src -Recurse -Settings ./PSScriptAnalyzerSettings.psd1clean
  • Release ZIP built with the workflow command and verified: src/ contents at the
    root (no wrapper), config/ carries only *.example.psd1, packaged module
    imports as 1.2.0 with 13 exports, unresolved-user flag renders.
  • End-to-end run of SPSyncUserInfoList.ps1 on a SharePoint Subscription Edition
    farm (SharePointServer module loaded, JSON + HTML report generated, copied to the
    master VM).

This Pull Request (PR) fixes the following issues

Task list

  • Added an entry to the change log under the Unreleased section of the
    file CHANGELOG.md. Entry should say what was changed and how that
    affects users (if applicable), and reference the issue being resolved
    (if applicable).
  • Added/updated documentation and descriptions where appropriate?
  • New/changed code adheres to Style Guidelines?

…odule

Initialize-SPSScript resolved its default Logs folder with
`Get-Variable -Name MyInvocation -Scope 1`. Because module functions run in
the module session state, that lookup never reached the caller script and
resolved to the module directory instead, so transcripts, rotation logs,
deleted-user snapshots and HTML reports were written under
`Modules\SPSUserSync.Common\Logs` — where they are wiped on every module
redeploy.

Initialize-SPSScript now takes an explicit `-ScriptRoot` parameter, and both
entry-point scripts pass their `$PSScriptRoot`. When `-ScriptRoot`/`-LogFolder`
are omitted, the function falls back to a `Get-PSCallStack` walk that skips
frames inside the module directory, which correctly crosses the module
boundary.

- Initialize-SPSScript.ps1: add -ScriptRoot param, robust call-stack fallback,
  updated comment-based help.
- SPSyncUserInfoList.ps1 / SPSyncUserProfile.ps1: pass -ScriptRoot $PSScriptRoot.

Validated on macOS: PSScriptAnalyzer clean, Pester 43/43, module imports 13
functions, -ScriptRoot resolves via Get-Command.

Fixes #3
…check

Make the toolkit deployable and verifiable on a real SharePoint server,
including Subscription Edition, without the SharePoint Management Shell.

SharePoint command surface (#6)
- Get-SPSInstalledProductVersion: reads the installed SharePoint version from
  Microsoft.SharePoint.dll, returning $null silently when SharePoint is absent.
- Import-SPSSharePointCommand: edition-aware loader. Add-PSSnapin
  Microsoft.SharePoint.PowerShell on 2013/2016/2019; Import-Module
  SharePointServer on Subscription Edition (which no longer ships the snap-in).
  Idempotent. Both entry-point scripts call it at startup, so they run from a
  plain powershell.exe (a scheduled task) instead of requiring the Management
  Shell.

Pre-flight readiness (#7)
- Test-SPSADConnection: proves an AD domain is usable, not just that a searcher
  can be built. Binds (validating a Credential domain's stored secret actually
  works), reads one user (generic "any user" probe or a specific -SampleAccount),
  and reports which attributes SPSUserSync relies on (givenName, sn, mail, co, l,
  displayName) are populated. Returns a result object; never throws on a failed
  bind.
- Test-SPSUserSyncReadiness.ps1: operator-facing, read-only, non-destructive
  pre-flight check (Host, Module, Config, Secrets, AD, SharePoint, Event Log,
  Share) with colored PASS/WARN/FAIL/SKIP output and exit code 0/1. It reuses
  Get-SPSInstalledProductVersion + Import-SPSSharePointCommand for the
  edition-aware SharePoint section and Test-SPSADConnection for the AD section,
  hence shipping together with them. Switches -SkipNetwork / -SkipSharePoint
  allow a config-only pass from a workstation.

- SPSUserSync.Common.psd1: export the three new public functions (now 13).
- tests/SPSUserSync.Common.Tests.ps1: expected-exports list + contracts for the
  three functions.
- wiki/Getting-Started.md: a "Verify readiness" step before the first run.

Validated on macOS: PSScriptAnalyzer clean, Pester 49/49, module imports 13
functions.

Fixes #6
Fixes #7
…pals

On a claims-based farm, classic-format principals never resolve via
Set-SPUser -SyncFromAD, so the legacy cleanup removed them with Remove-SPUser
on the live farm. A first real run pruned 18 entries, including
'NT AUTHORITY\authenticated users' and the classic 'DOMAIN\user' duplicate of a
claims account. Two changes make this safe by default:

- RemoveUnresolvableUsers setting (default $false) now gates the Remove-SPUser
  cleanup in SPSyncUserInfoList.ps1. When disabled, an unresolvable user is
  reported and left in place and only the benign Set-SPUser -SyncFromAD refresh
  runs. Set it to $true to restore the previous pruning behavior.
- The classic system principals 'NT AUTHORITY\*', 'BUILTIN\*' and 'SHAREPOINT\*'
  are now always excluded from processing, in addition to the configured
  ExcludedUserLogins / ExcludedUserLoginPatterns. Previously only the claims
  forms (e.g. c:0!.s|windows) were excluded, so the classic forms slipped
  through and were pruned.

Test-SPSUserSyncReadiness.ps1 surfaces RemoveUnresolvableUsers explicitly: WARN
when enabled (destructive), PASS when disabled (safe default).

- SPSyncUserInfoList.ps1: built-in exclusions, RemoveUnresolvableUsers gate.
- config/sync-settings.example.psd1: documented RemoveUnresolvableUsers + the
  built-in system-principal exclusions.

The readiness script's matching RemoveUnresolvableUsers safety check ships with
Test-SPSUserSyncReadiness.ps1 in the readiness commit.

Validated on macOS: PSScriptAnalyzer clean, Pester 43/43, example config parses
with RemoveUnresolvableUsers = $false.

Fixes #4
…udit)

The report now makes it obvious which users did not resolve from AD - exactly
the accounts that will not sync to the user profile and that Remove-SPUser would
prune when RemoveUnresolvableUsers is enabled.

UserInfoList report:
- A row is "unresolved" when it has no display name, or its display name equals
  the de-claimed login (the signature of a failed Set-SPUser -SyncFromAD, e.g.
  both 'ZEBES\jcdrouhin' and 'i:0#.w|zebes\jcdrouhin' whose display name is just
  'ZEBES\jcdrouhin'). The comparison strips the claim prefix and is
  case-insensitive, so it catches both the classic and claims forms.
- Unresolved rows are highlighted in amber with a gold left accent.
- A new "Unresolved" summary card (amber when > 0) shows the count.
- A legend above the table explains the highlight and points to
  RemoveUnresolvableUsers.

UserProfile report:
- UNKNOWN_USER rows are highlighted the same way (amber card + rows + legend),
  reusing the shared per-row flag mechanism.

Implementation:
- Detection runs in PowerShell (testable); flagged rows carry a "_flag" field in
  the embedded JSON. The table JS adds that value as the row CSS class; the field
  is not a column, so it is never rendered as a cell, searched or sorted.
- Get-SPSReportCardHtml gained an optional -Tone 'warn' for the warning palette.
- All values stay HTML-encoded / textContent-rendered; the XSS-safety test is
  unchanged and still passes.

Tests: Export-SPSUserReport.Tests.ps1 extended (resolved set emits no flag;
unresolved set yields an amber card, a "_flag":"unresolved" payload, the count
including a de-claimed match, and the legend; UserProfile flags UNKNOWN_USER).
Full suite 49/49, PSScriptAnalyzer clean.

- Export-SPSUserReport.ps1: unresolved detection, Unresolved card, legend, indexed
  row build with _flag; UNKNOWN_USER flagging for UserProfile.
- Get-SPSReportAssets.ps1: -Tone param, .card.warn and tr.unresolved CSS, JS row
  class from _flag.

Fixes #5
The release workflow zipped the src/ folder itself, so the archive contained a
top-level src/ wrapper and deploying meant extracting and then manually moving
config/, Modules/ and the scripts up one level into the deployment folder.

Zip the contents of src/ instead (cd src && zip -r ../$zip_name .), so the
archive extracts straight into the deployment folder with no manual move.

- .github/workflows/release.yml: zip src contents; updated header comment.

Fixes #8
Document under [Unreleased] the SharePoint SE command loader, the AD connection
test, the pre-flight readiness check, the opt-in user removal with built-in
system-principal exclusions, the log-folder fix, the unresolved-user report
audit and the release-ZIP packaging change. Each entry references its issue.

Refs #3, #4, #5, #6, #7, #8
Bump the module manifest to 1.2.0, date the CHANGELOG [1.2.0] section, and
rewrite RELEASE-NOTES.md (the GitHub Release body) for this version.

Highlights: SharePoint Subscription Edition support without the Management
Shell, a pre-flight readiness check, an unresolved-user audit in the HTML
report, and opt-in user removal (the sync no longer prunes accounts from a
live farm by default).

Refs #3, #4, #5, #6, #7, #8
@github-actions

Copy link
Copy Markdown

Pester Test Results

49 tests   49 ✅  1s ⏱️
11 suites   0 💤
 1 files     0 ❌

Results for commit 94022ef.

@luigilink
luigilink merged commit 925970d into main Jun 28, 2026
3 checks passed
@luigilink
luigilink deleted the release/1.2.0 branch June 28, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment