From a9b9c4b71639fdfc255f9cc8c993a486cc212bc8 Mon Sep 17 00:00:00 2001 From: SamSCN <131897416+SamSCN@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:22:14 -0500 Subject: [PATCH 1/2] Change-volume guard counts linked accounts, not an OU; quieter linking for deactivated accounts; AD deactivate writes EmployeeID Guard: each directory's population is now the active accounts carrying IDBridge's personID link, wherever they sit - AD: enabled with an EmployeeID (Get-ADManagedUserCount); Google: not suspended/archived with an organization externalId (Get-GoogleManagedUserCount). The old root-OU count included disabled accounts in the AD trash OU (inside the root) and unsynced accounts, and Google.userRootOU could name only one OU (a nonexistent one silently disabled the guard). Google.userRootOU is retired (ignored with a Trace line; dropped from the config template, TemplateVersion 4); AD.userRootOU is now only the service account's delegation OU. Linking: a user who is inactive or not provisioned in a directory and whose account there is already disabled/suspended/archived was logged "will link EmployeeID" at Info every run while nothing ever wrote the link; it now logs at Trace as "nothing to reconcile". AD deactivate: Disable-IDBridgeADUser sets EmployeeID on name-matched accounts in the disable write (Google's deactivate already persisted its link), and Get-ADUsersToDeactivate logs it at plan time. Tests for all decide-phase changes; the AD disable write was verified with stubbed cmdlets. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 54 +++++++++++++++ CLAUDE.md | 7 +- docs/architecture.md | 5 +- docs/configuration.md | 27 ++++++-- docs/functions.md | 31 +++++++-- docs/getting-started.md | 11 ++-- .../Private/AD/Disable-IDBridgeADUser.ps1 | 20 ++++-- .../Private/AD/Get-ADManagedUserCount.ps1 | 40 ++++++++++++ .../Private/AD/Get-ADUsersToDeactivate.ps1 | 11 +++- .../Private/AD/Get-ADUsersToSetEmployeeID.ps1 | 20 ++++-- .../Google/Get-GoogleManagedUserCount.ps1 | 42 ++++++++++++ .../Google/Get-GoogleUsersToSetEmployeeID.ps1 | 20 ++++-- src/IDBridge/Public/Core/Invoke-IDBridge.ps1 | 13 ++-- .../Templates/Config/IDBridgeConfig.psd1 | 5 +- .../AD/Get-ADManagedUserCount.Tests.ps1 | 52 +++++++++++++++ .../AD/Get-ADUsersToDeactivate.Tests.ps1 | 24 +++++++ .../AD/Get-ADUsersToSetEmployeeID.Tests.ps1 | 38 +++++++++++ .../Get-GoogleManagedUserCount.Tests.ps1 | 65 +++++++++++++++++++ .../Get-GoogleUsersToSetEmployeeID.Tests.ps1 | 38 +++++++++++ tests/TestHelper.psm1 | 2 + 20 files changed, 484 insertions(+), 41 deletions(-) create mode 100644 src/IDBridge/Private/AD/Get-ADManagedUserCount.ps1 create mode 100644 src/IDBridge/Private/Google/Get-GoogleManagedUserCount.ps1 create mode 100644 tests/Private/AD/Get-ADManagedUserCount.Tests.ps1 create mode 100644 tests/Private/Google/Get-GoogleManagedUserCount.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2991bb4..55ed3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,60 @@ All notable changes to IDBridge are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions use a calendar scheme `YY.M.D.build` (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning--releases)). +## [Unreleased] + +### Changed +- **The change-volume guard counts the accounts IDBridge manages, not an OU, in both + directories; `Google.userRootOU` is retired and `AD.userRootOU` is now only the service + account's delegation OU.** Each directory's population was the users at or below its + `userRootOU`, which miscounted in both: + - **Google:** the key was used for nothing else, and its name suggested it controlled + placement or scope. It could name only one OU, so a district with students under + `/Students` and staff under `/Staff` had to undercount, or use `''` and count every admin, + kiosk, trashed and archived account. A root that doesn't exist silently turned the guard + off. + - **AD:** the shipped templates put the trash OU inside the root, so every disabled, + trashed account counted and the population grew every year. Accounts under the root that + IDBridge doesn't sync (e.g. staff at a students-only site) counted too, which diluted the + guard. + + The population is now the active accounts carrying IDBridge's personID link, wherever they + sit: + - **AD:** enabled, with an `EmployeeID`. + - **Google:** not suspended or archived, with a personID in the `organization` externalId. + + Nothing to configure, and it's read from directory state only, so a broken source feed can't + shrink it. New internal `Get-ADManagedUserCount` / `Get-GoogleManagedUserCount`, with tests. + The AD count assumes nothing else in the domain writes `EmployeeID` (e.g. an HR sync). + `AD.userRootOU` stays as `Initialize-IDBridgeADServiceAccount`'s default delegation OU. A + config that still sets `Google.userRootOU` keeps working (the key is ignored, with a Trace + line saying it can be removed); the config template drops it and re-describes + `AD.userRootOU` (`TemplateVersion: 4`). + + **Behavior change:** the guard's percentages change at every site. Usually the guard gets + stricter, since trashed, unmanaged and unlinked accounts no longer pad the population. A + fresh tenant has nothing linked yet, so the guard skips with a `Warn` until the first run + links accounts. Onboarding a new source type is measured against the accounts already + linked: review it with `-Preview`, and run it once with `-SkipChangeThreshold` if it trips. + +### Fixed +- **Linking no longer logs "will link EmployeeID" every run for accounts that are already + deactivated.** EmployeeID linking matches every unlinked source user by username+name, but + the link is only written by the update step (active users) or the deactivate step (accounts + still enabled / not yet archived). A user who is inactive or not provisioned in a directory + and whose account there is already disabled (AD) or suspended/archived (Google) — e.g. a + student in the AD trash OU — was re-matched with an Info "will link" line on every run, and + nothing ever linked it. `Get-ADUsersToSetEmployeeID` / `Get-GoogleUsersToSetEmployeeID` now + log that case at Trace as "nothing to reconcile"; the match is still returned, and every + other case keeps its Info line unchanged. +- **AD deactivation now persists the EmployeeID on name-matched accounts.** The Google + deactivate step already set the personID externalId on an account matched by name; + `Disable-IDBridgeADUser` disabled the AD account without writing `EmployeeID`, so the + account stayed unlinked forever after. The disable write now sets `EmployeeID` when the + account doesn't carry it yet, and `Get-ADUsersToDeactivate` logs it at plan time ("will + also set EmployeeID"), so ReadOnly runs show it. Accounts disabled before this change stay + unlinked; the fix above quiets them. + ## [26.10.9.0] - 2026-10-09 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index d030b20..832d621 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,8 +115,11 @@ logic gets tests alongside it. 4. **Change-volume guard.** After the change lists are computed and before any writes, the `ChangeThreshold` config block aborts the whole run if a directory's proposed lifecycle changes (create/update/rename/move/deactivate) exceed a percentage (default `25`) of its - managed root-OU population — protection against a broken source feed mass-changing the - directory. Bypass with `ChangeThreshold.Enabled = $false` or `-SkipChangeThreshold`. + managed population — the active accounts IDBridge has linked (AD: enabled with an + `EmployeeID`; Google: not suspended/archived with a personID `organization` externalId), with + no OU setting in either directory (`AD.userRootOU` is only the service account's delegation + OU) — protection against a broken source feed mass-changing the directory. Bypass with + `ChangeThreshold.Enabled = $false` or `-SkipChangeThreshold`. 5. **Single-run lock.** `Invoke-IDBridge` takes a machine-wide mutex (per RootPath, before initialization) and a second concurrent run aborts immediately — ReadOnly/Preview runs included, since they still write the shared log and `Data` state files. Process-bound, diff --git a/docs/architecture.md b/docs/architecture.md index fa90557..97707a2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,8 +91,9 @@ Invoke-IDBridge Get-GoogleUserGroupsToUpdate (if group processing on) ┘ │ 9b. Change-threshold guard (if ChangeThreshold.Enabled): per directory, count proposed - lifecycle changes (create/update/rename/move/deactivate) vs the managed root-OU - population via Test-IDBridgeChangeThreshold; Throw (abort before any writes) if any + lifecycle changes (create/update/rename/move/deactivate) vs the managed population + (the active accounts IDBridge has linked: Get-ADManagedUserCount / + Get-GoogleManagedUserCount) via Test-IDBridgeChangeThreshold; Throw (abort before any writes) if any directory exceeds ChangeThreshold.Percentage. Bypass: -SkipChangeThreshold. Under -Preview a breach logs a Warn and continues (a preview exists to review exactly those changes). diff --git a/docs/configuration.md b/docs/configuration.md index 4cdd2c4..fe6118f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,17 +32,31 @@ vault under `C:\IDBridge\Vault\` (see [Secrets](#secrets-not-in-the-config-file) Optional block. After the change lists are computed (read-only) and **before any writes**, `Invoke-IDBridge` compares each enabled directory's proposed lifecycle changes (create/update/rename/move/deactivate — group churn excluded) against that directory's existing -**managed** population (users under `AD.userRootOU` / `Google.userRootOU`). If the percentage -exceeds `Percentage`, the run **aborts before writing anything**. Omit the whole block to leave -the guard off (older configs keep working). +**managed** population. If the percentage exceeds `Percentage`, the run **aborts before writing +anything**. Omit the whole block to leave the guard off (older configs keep working). + +The managed population is the **active accounts IDBridge has linked**, wherever they sit — no OU +setting in either directory: + +- **AD:** enabled accounts with an `EmployeeID` (`Get-ADManagedUserCount`). Assumes nothing else + in the domain writes `EmployeeID` (an HR sync, for example) — those accounts would count too. +- **Google:** accounts that are neither suspended nor archived and carry a personID in the + `organization` externalId (`Get-GoogleManagedUserCount`). + +Accounts IDBridge never linked (admins, service accounts, kiosks, hand-made accounts) and +deactivated accounts (disabled in the AD trash OU, archived in Google) don't count, and the count +comes from directory state only, so a broken source feed can't shrink it. A new source type's +existing accounts join the population once their first run links them, so that onboarding run is +measured against the accounts already linked — review it with `-Preview` and run it once with +`-SkipChangeThreshold` if it trips. | Key | Type | Effect | Read by | |-----|------|--------|---------| | `Enabled` | bool | Master switch for the guard. `$false` (or `-SkipChangeThreshold`) bypasses it. | `Invoke-IDBridge` | | `Percentage` | number | Max allowed change % of the managed population, per directory. Required when `Enabled` — there is no code fallback (the shipped config sets `25`). | `Invoke-IDBridge` → `Test-IDBridgeChangeThreshold` | -> A directory whose managed population is **0** (fresh tenant / empty root OU) is skipped with a -> `Warn` rather than tripping the guard, so a legitimate first run isn't blocked by a zero +> A directory whose managed population is **0** (a fresh tenant — nothing linked yet) is skipped +> with a `Warn` rather than tripping the guard, so a legitimate first run isn't blocked by a zero > denominator. The `-SkipChangeThreshold` switch sets `Enabled = $false` for that run. Under > `Invoke-IDBridge -Preview` a breach logs a `Warn` and continues instead of aborting — a > preview exists to review exactly those changes. @@ -67,7 +81,6 @@ the guard off (older configs keep working). |-----|------|--------|---------| | `enabled` | bool | Master switch for Google processing (an auth failure throws — see behavioral notes). | `Initialize-IDBridge` (cascade), `Invoke-IDBridge` | | `customerID` | string | Workspace customer ID. | Google target/API calls | -| `userRootOU` | string | Root OU path, e.g. `/YourDistrict`. Managed-population anchor for the change-volume guard. | `Invoke-IDBridge` (`ChangeThreshold`) | | `enableGroupProcessing` | bool | Enable Google group sync. Per person, a source record with `ProcessGroupsGoogle = $false` is left out (see behavioral notes). | `Invoke-IDBridge` | | `enableGroupProcessingWhatIf` | bool | While `$true`, group diffs are computed and logged but **no group writes happen** (even with `enableGroupProcessing = $true`). | `Invoke-IDBridge` | | `enableGroupProcessingRemove` | bool | Allow removals (not just adds). | `Invoke-IDBridge` | @@ -85,7 +98,7 @@ the guard off (older configs keep working). | Key | Type | Effect | Read by | |-----|------|--------|---------| | `enabled` | bool | Master switch for AD processing. | `Initialize-IDBridge`, `Invoke-IDBridge` | -| `userRootOU` | string | Root OU DN, e.g. `OU=YourDistrict,DC=yourdomain,DC=local`. Managed-population anchor for the change-volume guard. | `Invoke-IDBridge` (`ChangeThreshold`) | +| `userRootOU` | string | Root OU DN, e.g. `OU=YourDistrict,DC=yourdomain,DC=local` — the OU the IDBridge service account is delegated rights on (`Initialize-IDBridgeADServiceAccount`'s default `-TargetOU`). Every OU your source plugins place users in, trash included, must sit under it, or the service account can't write there. Not read by the sync itself (the change-volume guard counts linked accounts, not an OU). | `Initialize-IDBridgeADServiceAccount` | | `enableGroupProcessing` | bool | Enable AD group sync. Per person, a source record with `ProcessGroupsAD = $false` is left out (see behavioral notes). | `Invoke-IDBridge` | | `enableGroupProcessingWhatIf` | bool | While `$true`, group diffs are computed and logged but **no group writes happen** (even with `enableGroupProcessing = $true`). | `Invoke-IDBridge` | | `enableGroupProcessingRemove` | bool | Allow removals. | `Invoke-IDBridge` | diff --git a/docs/functions.md b/docs/functions.md index 5c4dfc1..3b2cab5 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -515,7 +515,10 @@ and can then be deactivated. A name mismatch is an error and skipped, unless app `Approve-IDBridgeNameMismatch` (honored only while the account still matches the approval; drifted ⇒ Warn + skip). Unlinked users with no AD account at all are logged at Trace only (inactive ones with an explicit "nothing to reconcile" message, mirroring -`Get-GoogleUsersToSetEmployeeID`). **Returns:** hashtable +`Get-GoogleUsersToSetEmployeeID`). A match for a user who is inactive or not AD-provisioned +whose account is **already disabled** is still returned but logged at Trace ("nothing to +reconcile") instead of the Info "will link" line — no step writes its EmployeeID (updates cover +active users, deactivation enabled accounts), so it would otherwise recur every run. **Returns:** hashtable `personID → @{ ID(ObjectGUID); Groups; EnabledStatus; User }`. `Groups` reuses the target snapshot's exclusion-filtered `CurrentGroups`, so `AD.groupsExcluded` applies to linked users too (matching the Google side). @@ -546,7 +549,10 @@ applied. **Returns:** `@{ UpdateList; RenameList; MoveList }` (items carry `CN` ### `Get-ADUsersToDeactivate` 🔒 🧮 **Params:** `-UserList`. **Predicate:** `(IDBActive=false OR ProvisionAD=false)` AND -`ADCurrentUserEnabledStatus=true`. **Returns:** user objects to disable. +`ADCurrentUserEnabledStatus=true`. Also logs when the disable step will set the EmployeeID: +accounts matched by username+name have none yet, and the update list only covers active users, +so the disable write persists the link (mirrors `Get-GoogleUsersToDeactivate`). +**Returns:** user objects to disable. ### `Get-ADUsersToResetPassword` 🔒 🧮🌐 **Params:** `-UserList` (AD user objects: SamAccountName + DistinguishedName), `-PassphraseAPI` @@ -565,13 +571,21 @@ plaintext phrase rides on each item for the caller's optional export and is neve and no removes (an empty diff would otherwise strip all their groups); the skipped count is logged at Trace. **Returns:** `@{ Add; Remove }` (each `@{PersonID; ADCurrentUserID; Groups}`). +### `Get-ADManagedUserCount` 🔒 🧮 +**Params:** `-Users` (nullable). The change-volume guard's AD denominator: counts enabled +accounts with an `EmployeeID` (the personID link IDBridge writes), anywhere in the domain — +`AD.userRootOU` plays no part. Disabled accounts in the trash OU and never-linked accounts don't +count; reads directory state only; 0 on a fresh domain (the guard then skips with a Warn). +Assumes nothing else writes `EmployeeID`. Mirrors `Get-GoogleManagedUserCount`. **Returns:** `[int]`. + ### `New-IDBridgeADOrgUnit` 🔒 🌐 **Params:** `-OrgUnit` (DN). Parses DN → `New-ADOrganizationalUnit`. Throws on failure — `Invoke-IDBridge` treats a failed OU creation as fatal and aborts the run. ### `Disable-IDBridgeADUser` 🔒 🌐 **Params:** `-User`, `-GroupRemovalProcessingStatus`. Disables account, stamps `Division` -with timestamp, moves to trash OU, and (if flag) removes all current groups. `Invoke-IDBridge` +with timestamp (and sets `EmployeeID` in the same write when the account doesn't carry it yet — +a name-matched account), moves to trash OU, and (if flag) removes all current groups. `Invoke-IDBridge` sets the flag only when `enableGroupProcessing` + `enableGroupProcessingTrash` are on, WhatIf is off, and the user's `ProcessGroupsAD` isn't `$false` (the same gate as Google). Each removal recorded as its own `GroupRemove` write result; a failed group is logged and skipped. @@ -613,7 +627,10 @@ to existing Google users by primaryEmail+name. A name mismatch is an error and s approved via `Approve-IDBridgeNameMismatch` (honored only while the account still matches the approval; drifted ⇒ Warn + skip). Unlinked users with no Google account at all are logged at Trace only (inactive ones with an explicit "nothing to reconcile" message — inactive -source rows with no account are expected and recur until the row leaves the source feed). +source rows with no account are expected and recur until the row leaves the source feed). A +match for a user who is inactive or not Google-provisioned whose account is **already suspended +or archived** is still returned but logged at Trace ("nothing to reconcile") instead of the Info +"will link" line — no step writes its externalId, so it would otherwise recur every run. **Returns:** hashtable `personID → @{ ID; Groups; SuspendedStatus; User }`. ### `Get-GoogleOrgUnitsForProcessing` 🔒 🧮 @@ -665,6 +682,12 @@ account would be re-matched every run). **Returns:** user objects. `ProcessGroupsGoogle = $false` are skipped entirely — no adds and no removes; the skipped count is logged at Trace. **Returns:** `@{ Add; Remove }`. +### `Get-GoogleManagedUserCount` 🔒 🧮 +**Params:** `-Users` (nullable). The change-volume guard's Google denominator: counts accounts +that carry an IDBridge personID link (an `organization` externalId with a value) and are +neither suspended nor archived, in any OU. Reads directory state only — a broken source feed +can't shrink it; 0 on a fresh tenant (the guard then skips with a Warn). **Returns:** `[int]`. + ### `Get-GoogleUsersOrphaned` 🧮 **Params:** `-UserList`, `-GoogleUsers`, `-TrashOU`. Finds Google users whose ID isn't in source. **Returns:** `@{ GoogleCurrentUserID; GoogleOrganizationalUnitTrash; Groups }[]`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 6e4a138..92e715a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -158,15 +158,16 @@ Edit `C:\IDBridge\Config\IDBridgeConfig.psd1` (schema: [configuration.md](config | `GoogleToken.Enabled` | `$true` — the key secret exists now (step 4) | | `Google.enabled` | `$true` | | `Google.customerID` | Workspace customer ID (Admin console → Account settings) | -| `Google.userRootOU` | the root OU IDBridge manages, e.g. `/YourDistrict` | | `AD.enabled` | `$true` if syncing AD | | `AD.userRootOU` | managed root OU DN, e.g. `OU=YourDistrict,DC=yourdomain,DC=local` | | `Logging.*` | log-sheet ID from step 5, if using sheet logging | | `Telemetry.Tier` | keep `'Basic'`, or `'Enhanced'`/`'Off'` (see [PRIVACY.md](../PRIVACY.md)) | Leave `Debug.ReadOnly = $true` and both `enableGroupProcessingWhatIf = $true` — that's the -point of step 8. The `userRootOU` values matter beyond OU placement: they anchor the -`ChangeThreshold` guard's managed-population count. +point of step 8. `AD.userRootOU` is the OU the IDBridge service account is delegated rights on +(see [ad-bootstrap.md](ad-bootstrap.md)), so every OU your plugins place users in must sit under +it. The `ChangeThreshold` guard needs no OU setting in either directory — its population is the +active accounts IDBridge has linked. ## 8. First run (read-only) @@ -199,8 +200,8 @@ Flip one brake at a time, with a read-only-style review (`-ReadOnly` or `-Previe 1. `Debug.ReadOnly = $false` — user lifecycle writes (create/update/deactivate/move/rename) go live. Group changes are still log-only (`WhatIf`), and the `ChangeThreshold` guard still aborts any run whose proposed changes exceed `Percentage` (default 25%) of a - directory's managed population. A fresh/empty root OU is skipped with a `Warn`, so a - legitimate first population isn't blocked. + directory's managed population (the active accounts IDBridge has linked). A directory with + nothing linked yet is skipped with a `Warn`, so a legitimate first population isn't blocked. 2. `enableGroupProcessing = $true` (per directory) with `WhatIf` still `$true` — review the group diffs in the log. Add patterns to `groupsExcluded` for any group IDBridge must never touch (manually curated clubs, committees). To keep a whole source type (e.g. staff) diff --git a/src/IDBridge/Private/AD/Disable-IDBridgeADUser.ps1 b/src/IDBridge/Private/AD/Disable-IDBridgeADUser.ps1 index 60f090f..302719c 100644 --- a/src/IDBridge/Private/AD/Disable-IDBridgeADUser.ps1 +++ b/src/IDBridge/Private/AD/Disable-IDBridgeADUser.ps1 @@ -3,7 +3,8 @@ Disable an AD user, stamp it, move it to trash, and optionally strip its groups. .DESCRIPTION -Disables the account (recording the timestamp in Division), moves it to its +Disables the account (recording the timestamp in Division, and setting EmployeeID when the account +was matched by name and doesn't carry it yet, so the link persists), moves it to its ADOrganizationalUnitTrash, and logs its current group memberships. When GroupRemovalProcessingStatus is $true, also removes the user from all of its current groups — each removal is recorded as its own GroupRemove write result (Add-IDBridgeWriteResult), and a @@ -12,8 +13,8 @@ returns the error record rather than throwing (the caller records it as the Deac write result). .PARAMETER User -The source record for the user to deactivate (uses ADCurrentUserID, ADOrganizationalUnitTrash, -ADCurrentGroups, and PersonID). +The source record for the user to deactivate (uses ADCurrentUserID, ADObject.EmployeeID, +ADOrganizationalUnitTrash, ADCurrentGroups, and PersonID). .PARAMETER GroupRemovalProcessingStatus When $true, remove the user from all current groups as part of deactivation. Invoke-IDBridge passes @@ -40,7 +41,18 @@ function Disable-IDBridgeADUser { #Disable the account try { Write-Log -Message ("AD: Applying: Disabling account for " + $User.PersonID) - Set-ADUser -Identity $User.ADCurrentUserID -Division (Get-Date -format yyyy-MM-dd-HH:mm) -Enabled $false + $disableSplat = @{ + Identity = $User.ADCurrentUserID + Division = (Get-Date -format yyyy-MM-dd-HH:mm) + Enabled = $false + } + + #Persist the EmployeeID link on accounts matched by name - the update list only covers active users, so without this the account is re-matched every run + if ($User.ADObject.EmployeeID -ne $User.PersonID) { + $disableSplat['EmployeeID'] = $User.PersonID + } + + Set-ADUser @disableSplat } catch { return $_ diff --git a/src/IDBridge/Private/AD/Get-ADManagedUserCount.ps1 b/src/IDBridge/Private/AD/Get-ADManagedUserCount.ps1 new file mode 100644 index 0000000..ece0dbb --- /dev/null +++ b/src/IDBridge/Private/AD/Get-ADManagedUserCount.ps1 @@ -0,0 +1,40 @@ +<# +.SYNOPSIS +Count the enabled AD accounts IDBridge has linked - the change-volume guard's population. + +.DESCRIPTION +The change-volume guard's denominator for AD (Invoke-IDBridge, ChangeThreshold). A user counts +when it carries an EmployeeID - the personID link IDBridge writes on create, link and deactivate - +and is enabled. Where the account sits in the domain doesn't matter: disabled accounts in the +trash OU and accounts IDBridge never linked (service, admin, hand-made accounts) never count, and +AD.userRootOU plays no part (it is only the service account's delegation OU). The count reads +directory state only: a broken source feed cannot shrink its own denominator. Nothing is linked on +a fresh domain, so the count is 0 and Test-IDBridgeChangeThreshold skips the check with a Warn. +Assumes nothing else populates EmployeeID in the domain (e.g. an HR sync) - those accounts would +count too. Mirrors Get-GoogleManagedUserCount. + +.PARAMETER Users +The AD users (from Get-TargetDataAD .Users). Null or empty allowed. + +.OUTPUTS +[int] the managed user count. + +.EXAMPLE +$population = Get-ADManagedUserCount -Users $adData.Users + +.NOTES + Created by: Sam Cattanach + Modified: 2026-10-09 +#> +function Get-ADManagedUserCount { + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyCollection()] + $Users + ) + + return @($Users | Where-Object { $_.Enabled -eq $true -and $_.EmployeeID }).Count +} diff --git a/src/IDBridge/Private/AD/Get-ADUsersToDeactivate.ps1 b/src/IDBridge/Private/AD/Get-ADUsersToDeactivate.ps1 index 46c9f15..962487f 100644 --- a/src/IDBridge/Private/AD/Get-ADUsersToDeactivate.ps1 +++ b/src/IDBridge/Private/AD/Get-ADUsersToDeactivate.ps1 @@ -5,7 +5,10 @@ Select linked AD users that should be deactivated. .DESCRIPTION Returns each source record that is inactive or no longer AD-provisioned (IDBActive = $false OR ProvisionAD = $false) while its current AD account is still enabled. Each is logged (with its -current groups) for the subsequent disable + move-to-trash step. +current groups) for the subsequent disable + move-to-trash step. Accounts that were matched by +username+name (no EmployeeID on the AD account yet) are also flagged: the disable step will set the +EmployeeID so the link persists - the update list only covers active users, so without this the +disabled account would be re-matched every run. .PARAMETER UserList The enriched source records. @@ -18,7 +21,7 @@ $toDeactivate = Get-ADUsersToDeactivate -UserList $sourceData .NOTES Created by: Sam Cattanach - Modified: 2026-06-26 + Modified: 2026-10-09 #> function Get-ADUsersToDeactivate { [CmdletBinding()] @@ -37,6 +40,10 @@ function Get-ADUsersToDeactivate { if ($item.ADCurrentGroups) { Write-Log -Message ("AD: Current groups for " + $item.PersonID + ": " + ($item.ADCurrentGroups -join ", ")) } + + if ($item.ADObject.EmployeeID -ne $item.PersonID) { + Write-Log -Message "AD: Proposed: Deactivation will also set EmployeeID $($item.PersonID) on the AD account (matched by name, EmployeeID not set yet)" + } } return $itemList diff --git a/src/IDBridge/Private/AD/Get-ADUsersToSetEmployeeID.ps1 b/src/IDBridge/Private/AD/Get-ADUsersToSetEmployeeID.ps1 index e17a7ec..86ab87d 100644 --- a/src/IDBridge/Private/AD/Get-ADUsersToSetEmployeeID.ps1 +++ b/src/IDBridge/Private/AD/Get-ADUsersToSetEmployeeID.ps1 @@ -10,7 +10,10 @@ previously unlinked or deprovisioned accounts be reconciled and, if inactive, de username that matches a different name is logged as an error and skipped — unless that exact mismatch was approved via Approve-IDBridgeNameMismatch (persisted in \ApprovedNameMismatches.csv), in which case it links; an approval whose recorded -account/name no longer matches the current AD account is logged as a warning and skipped. +account/name no longer matches the current AD account is logged as a warning and skipped. A match +for a user who is inactive or not AD-provisioned whose account is already disabled is still +returned, but logged at Trace ("nothing to reconcile") instead of Info: nothing writes the link for +it (updates cover active users, the deactivate step enabled accounts), so it recurs every run. .PARAMETER UserList The source records (the in-progress user list). @@ -26,7 +29,7 @@ $matches = Get-ADUsersToSetEmployeeID -UserList $sourceData -CurrentADUsers $adD .NOTES Created by: Sam Cattanach - Modified: 2026-08-20 + Modified: 2026-10-09 #> function Get-ADUsersToSetEmployeeID { [CmdletBinding()] @@ -57,9 +60,10 @@ function Get-ADUsersToSetEmployeeID { $ADUser = ($CurrentADUsers | Where-Object {$_.SamAccountName -eq $item.username}) $link = $false + $matchedBy = $null if ($ADUser.Surname -eq $item.NameLast -and $ADUser.GivenName -eq $item.NameFirst) { - Write-Log -Message ("AD: No user with EmployeeID: $($item.personID) - matched existing $($ADUser.UserPrincipalName) by username+name; will link EmployeeID.") + $matchedBy = "by username+name" $link = $true } else { #Name differs - honor a recorded approval only while both sides still match @@ -67,7 +71,7 @@ function Get-ADUsersToSetEmployeeID { $approval = $approvedMismatches["AD|$($item.personID)"] if ($approval -and $approval.Account -eq $item.username -and $approval.DirectoryName -eq ($ADUser.GivenName + " " + $ADUser.Surname)) { - Write-Log -Message ("AD: No user with EmployeeID: $($item.personID) - matched existing $($ADUser.UserPrincipalName) by username with name mismatch approved on $($approval.ApprovedDate); will link EmployeeID.") + $matchedBy = "by username with name mismatch approved on $($approval.ApprovedDate)" $link = $true } elseif ($approval) { Write-Log -Message ("AD: Username " + $item.username + " for " + $item.personID + " has an approval from " + $approval.ApprovedDate + " but the account no longer matches it (approved: " + $approval.Account + " / " + $approval.DirectoryName + ", current: " + $item.username + " / " + $ADUser.GivenName + " " + $ADUser.Surname + ") - not linked; re-approve with Approve-IDBridgeNameMismatch.") -Level Warn @@ -77,6 +81,14 @@ function Get-ADUsersToSetEmployeeID { } if ($link) { + #Not active in AD and the account is already disabled: neither the update step (active users) + #nor the deactivate step (enabled accounts) writes the link, so it re-matches every run - Trace, not Info + if (($item.IDBActive -eq $false -or $item.ProvisionAD -eq $false) -and $ADUser.Enabled -eq $false) { + Write-Log -Message ("AD: No user with EmployeeID: $($item.personID) - matched existing $($ADUser.UserPrincipalName) $matchedBy, but the source user is inactive or not AD-provisioned and the account is already disabled - nothing to reconcile.") -Level Trace + } else { + Write-Log -Message ("AD: No user with EmployeeID: $($item.personID) - matched existing $($ADUser.UserPrincipalName) $matchedBy; will link EmployeeID.") + } + $itemUpdateList[$item.personID] = [PSCustomObject]@{ ID = $ADUser.ObjectGUID #CurrentGroups is the AD.groupsExcluded-filtered name list built by diff --git a/src/IDBridge/Private/Google/Get-GoogleManagedUserCount.ps1 b/src/IDBridge/Private/Google/Get-GoogleManagedUserCount.ps1 new file mode 100644 index 0000000..edab1b6 --- /dev/null +++ b/src/IDBridge/Private/Google/Get-GoogleManagedUserCount.ps1 @@ -0,0 +1,42 @@ +<# +.SYNOPSIS +Count the active Google accounts IDBridge has linked - the change-volume guard's population. + +.DESCRIPTION +The change-volume guard's denominator for Google (Invoke-IDBridge, ChangeThreshold). A user +counts when it carries an IDBridge personID link - an 'organization' externalId with a value, +which IDBridge writes on create, link and deactivate - and is neither suspended nor archived. +Where the account sits in the OU tree doesn't matter, so no OU setting can drift from where the +source plugins actually place users, and accounts IDBridge never linked (admins, kiosks, +hand-made accounts) never count. The count reads directory state only: a broken source feed +cannot shrink its own denominator. Nothing is linked on a fresh tenant, so the count is 0 and +Test-IDBridgeChangeThreshold skips the check with a Warn. + +.PARAMETER Users +The Google users (from Get-TargetDataGoogle .Users). Null or empty allowed. + +.OUTPUTS +[int] the managed user count. + +.EXAMPLE +$population = Get-GoogleManagedUserCount -Users $googleData.Users + +.NOTES + Created by: Sam Cattanach + Modified: 2026-10-09 +#> +function Get-GoogleManagedUserCount { + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyCollection()] + $Users + ) + + return @($Users | Where-Object { + $_.suspended -ne $true -and $_.archived -ne $true -and + @($_.externalIds | Where-Object { $_.type -eq 'organization' -and $_.value }).Count -gt 0 + }).Count +} diff --git a/src/IDBridge/Private/Google/Get-GoogleUsersToSetEmployeeID.ps1 b/src/IDBridge/Private/Google/Get-GoogleUsersToSetEmployeeID.ps1 index cddeeaf..f3456ef 100644 --- a/src/IDBridge/Private/Google/Get-GoogleUsersToSetEmployeeID.ps1 +++ b/src/IDBridge/Private/Google/Get-GoogleUsersToSetEmployeeID.ps1 @@ -10,7 +10,10 @@ deprovisioned accounts be reconciled and, if inactive, deactivated. A primaryEma different name is logged as an error and skipped — unless that exact mismatch was approved via Approve-IDBridgeNameMismatch (persisted in \ApprovedNameMismatches.csv), in which case it links; an approval whose recorded account/name no longer matches the current Google account is -logged as a warning and skipped. +logged as a warning and skipped. A match for a user who is inactive or not Google-provisioned whose +account is already suspended or archived is still returned, but logged at Trace ("nothing to +reconcile") instead of Info: nothing writes the link for it (updates cover active users, the +deactivate step not-yet-deactivated accounts), so it recurs every run. .PARAMETER UserList The source records (the in-progress user list). @@ -26,7 +29,7 @@ $matches = Get-GoogleUsersToSetEmployeeID -UserList $sourceData -GoogleUsers $go .NOTES Created by: Sam Cattanach - Modified: 2026-08-19 + Modified: 2026-10-09 #> function Get-GoogleUsersToSetEmployeeID { [CmdletBinding()] @@ -56,9 +59,10 @@ function Get-GoogleUsersToSetEmployeeID { $googleUser = ($GoogleUsers | Where-Object {$_.primaryEmail -eq $item.UPN}) $link = $false + $matchedBy = $null if ($googleUser.Name.familyName -eq $item.NameLast -and $googleUser.Name.givenName -eq $item.NameFirst) { - Write-Log -Message ("Google: No user with EmployeeID: $($item.personID) - matched existing $($googleUser.primaryEmail) by username+name; will link EmployeeID.") + $matchedBy = "by username+name" $link = $true } else { #Name differs - honor a recorded approval only while both sides still match @@ -66,7 +70,7 @@ function Get-GoogleUsersToSetEmployeeID { $approval = $approvedMismatches["Google|$($item.personID)"] if ($approval -and $approval.Account -eq $item.UPN -and $approval.DirectoryName -eq ($googleUser.Name.givenName + " " + $googleUser.Name.familyName)) { - Write-Log -Message ("Google: No user with EmployeeID: $($item.personID) - matched existing $($googleUser.primaryEmail) by username with name mismatch approved on $($approval.ApprovedDate); will link EmployeeID.") + $matchedBy = "by username with name mismatch approved on $($approval.ApprovedDate)" $link = $true } elseif ($approval) { Write-Log -Message ("Google: Username: " + $item.UPN + " for " + $item.personID + " has an approval from " + $approval.ApprovedDate + " but the account no longer matches it (approved: " + $approval.Account + " / " + $approval.DirectoryName + ", current: " + $item.UPN + " / " + $googleUser.Name.givenName + " " + $googleUser.Name.familyName + ") - not linked; re-approve with Approve-IDBridgeNameMismatch.") -Level Warn @@ -76,6 +80,14 @@ function Get-GoogleUsersToSetEmployeeID { } if ($link) { + #Not active in Google and the account is already suspended/archived: neither the update step (active + #users) nor the deactivate step (not-yet-deactivated accounts) writes the link, so it re-matches every run - Trace, not Info + if (($item.IDBActive -eq $false -or $item.ProvisionGoogle -eq $false) -and ($googleUser.Suspended -or $googleUser.Archived)) { + Write-Log -Message ("Google: No user with EmployeeID: $($item.personID) - matched existing $($googleUser.primaryEmail) $matchedBy, but the source user is inactive or not Google-provisioned and the account is already deactivated - nothing to reconcile.") -Level Trace + } else { + Write-Log -Message ("Google: No user with EmployeeID: $($item.personID) - matched existing $($googleUser.primaryEmail) $matchedBy; will link EmployeeID.") + } + $itemUpdateList[$item.personID] = [PSCustomObject]@{ ID = $googleUser.ID Groups = $googleUser.CurrentGroups diff --git a/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 b/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 index 6b39b72..ef8492d 100644 --- a/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 +++ b/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 @@ -291,13 +291,15 @@ function Invoke-IDBridge { #region Change Threshold Safety Check # Guard against a broken source feed mass-changing a directory: if the proposed lifecycle - # changes exceed a percentage of the existing managed (root-OU) population, abort before any - # writes. Bypassed by ChangeThreshold.Enabled = $false in config or the -SkipChangeThreshold switch. + # changes exceed a percentage of the existing managed population (the active accounts IDBridge + # has linked - AD: enabled with an EmployeeID; Google: not suspended/archived with a personID + # externalId), abort before any writes. Bypassed by ChangeThreshold.Enabled = $false in + # config or the -SkipChangeThreshold switch. if ($IDConfig.ContainsKey('ChangeThreshold') -and $IDConfig.ChangeThreshold.Enabled -eq $true) { $thresholdResults = [System.Collections.Generic.List[object]]::new() if ($IDConfig.AD.enabled -eq $true) { - $adManagedPopulation = @($adData.Users | Where-Object { $_.DistinguishedName -like "*,$($IDConfig.AD.userRootOU)" }).Count + $adManagedPopulation = Get-ADManagedUserCount -Users $adData.Users # Count distinct affected users: a single user needing update+rename+move is one change, # not three (so the ratio is comparable to Google's per-user count). CN uniquely identifies the user. # Wrap each list in @() so a single-element list (scalar string) concatenates as an @@ -310,7 +312,10 @@ function Invoke-IDBridge { } if ($IDConfig.Google.enabled -eq $true) { - $googleManagedPopulation = @($googleData.Users | Where-Object { $_.orgUnitPath -eq $IDConfig.Google.userRootOU -or $_.orgUnitPath -like "$($IDConfig.Google.userRootOU)/*" }).Count + if ($IDConfig.Google.ContainsKey('userRootOU')) { + Write-Log -Message "Google: Config key Google.userRootOU is no longer used - the change threshold counts the active Google accounts IDBridge has linked. It can be removed from IDBridgeConfig.psd1." -Level Trace + } + $googleManagedPopulation = Get-GoogleManagedUserCount -Users $googleData.Users $googleChangeCount = @($GoogleUsersToCreate).Count + @($GoogleUsersToDeactivate).Count + @($GoogleUsersToUpdate).Count $thresholdResults.Add( (Test-IDBridgeChangeThreshold -Directory 'Google' -ChangeCount $googleChangeCount -PopulationCount $googleManagedPopulation -ThresholdPercent $IDConfig.ChangeThreshold.Percentage) ) } diff --git a/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 b/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 index 6c232f3..742467e 100644 --- a/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 +++ b/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 @@ -1,5 +1,5 @@ # IDBridgeConfig.psd1 — generated by Install-IDBridge -# TemplateVersion: 3 +# TemplateVersion: 4 # Every feature is disabled. Replace the placeholder values, then enable features one at a time. @{ @@ -26,7 +26,6 @@ Google = @{ enabled = $false customerID = 'C00000000' # Workspace customer ID - userRootOU = '/YourDistrict' # Managed root OU path; anchors the ChangeThreshold safety guard's population count enableGroupProcessing = $false enableGroupProcessingWhatIf = $true # Log-only while $true; set $false to actually apply group changes enableGroupProcessingRemove = $false # Set $true to allow group membership removals @@ -39,7 +38,7 @@ # ── Active Directory ────────────────────────────────────────────────────── AD = @{ enabled = $false - userRootOU = 'OU=YourDistrict,DC=yourdomain,DC=local' # Managed root OU DN; anchors the ChangeThreshold safety guard's population count + userRootOU = 'OU=YourDistrict,DC=yourdomain,DC=local' # OU the IDBridge service account is delegated rights on (Initialize-IDBridgeADServiceAccount); every OU your plugins use, trash included, must sit under it enableGroupProcessing = $false enableGroupProcessingWhatIf = $true # Log-only while $true; set $false to actually apply group changes enableGroupProcessingRemove = $false # Set $true to allow group membership removals diff --git a/tests/Private/AD/Get-ADManagedUserCount.Tests.ps1 b/tests/Private/AD/Get-ADManagedUserCount.Tests.ps1 new file mode 100644 index 0000000..e707b8c --- /dev/null +++ b/tests/Private/AD/Get-ADManagedUserCount.Tests.ps1 @@ -0,0 +1,52 @@ +<# +.SYNOPSIS +Unit tests for the AD managed-population count (Get-ADManagedUserCount) - the change-volume +guard's denominator. + +.DESCRIPTION +The population is the enabled AD accounts IDBridge has linked (EmployeeID set), anywhere in the +domain - never a count derived from the source feed, and not tied to AD.userRootOU (that key is +only the service account's delegation OU). +#> + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..' '..' 'TestHelper.psm1') -Force + Import-IDBridgeForTest +} + +Describe 'Get-ADManagedUserCount' { + It 'counts enabled linked accounts in any OU, and ignores accounts IDBridge never linked' { + $users = @( + (New-TestADUser -SamAccountName 's1' -EmployeeID '1' -DistinguishedName 'CN=S1,OU=Grade-05,OU=Students,OU=District,DC=example,DC=org') + (New-TestADUser -SamAccountName 't1' -EmployeeID '2' -DistinguishedName 'CN=T1,OU=Teacher,OU=Staff,OU=District,DC=example,DC=org') + (New-TestADUser -SamAccountName 'x1' -EmployeeID '3' -DistinguishedName 'CN=X1,OU=Elsewhere,DC=example,DC=org') + (New-TestADUser -SamAccountName 'svc' -EmployeeID $null -DistinguishedName 'CN=svc,OU=Service Accounts,DC=example,DC=org') + (New-TestADUser -SamAccountName 'admin' -EmployeeID '' -DistinguishedName 'CN=admin,CN=Users,DC=example,DC=org') + ) + + InModuleScope IDBridge -Parameters @{ users = $users } { + Get-ADManagedUserCount -Users $users | Should -Be 3 + } + } + + It 'excludes disabled linked accounts - e.g. deactivated users in the trash OU' { + $users = @( + (New-TestADUser -SamAccountName 'active' -EmployeeID '1') + (New-TestADUser -SamAccountName 'trashed' -EmployeeID '2' -Enabled $false -DistinguishedName 'CN=T,OU=2026,OU=Students,OU=Trash,OU=District,DC=example,DC=org') + ) + + InModuleScope IDBridge -Parameters @{ users = $users } { + Get-ADManagedUserCount -Users $users | Should -Be 1 + } + } + + It 'returns 0 when nothing is linked yet (a fresh domain) or there are no users' { + $users = @(New-TestADUser -EmployeeID $null) + + InModuleScope IDBridge -Parameters @{ users = $users } { + Get-ADManagedUserCount -Users $users | Should -Be 0 + Get-ADManagedUserCount -Users $null | Should -Be 0 + Get-ADManagedUserCount -Users @() | Should -Be 0 + } + } +} diff --git a/tests/Private/AD/Get-ADUsersToDeactivate.Tests.ps1 b/tests/Private/AD/Get-ADUsersToDeactivate.Tests.ps1 index 807a3a2..febd076 100644 --- a/tests/Private/AD/Get-ADUsersToDeactivate.Tests.ps1 +++ b/tests/Private/AD/Get-ADUsersToDeactivate.Tests.ps1 @@ -60,4 +60,28 @@ Describe 'Get-ADUsersToDeactivate' { Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Message -like '*Current groups for 10001: Staff, Math Dept*' } } } + + It 'flags a name-matched account whose EmployeeID is not set yet' { + # Matched by username+name: the AD account has no EmployeeID, so the deactivate step + # must also set it or the disabled account is re-matched every run. + $records = @(New-TestSourceRecord -IDBActive $false -ADObject (New-TestADUser -EmployeeID $null)) + + InModuleScope IDBridge -Parameters @{ records = $records } { + Mock Write-Log {} + Get-ADUsersToDeactivate -UserList $records | Out-Null + + Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Message -like '*will also set EmployeeID 10001*' } + } + } + + It 'does not flag an account already carrying the EmployeeID' { + $records = @(New-TestSourceRecord -IDBActive $false -ADObject (New-TestADUser)) + + InModuleScope IDBridge -Parameters @{ records = $records } { + Mock Write-Log {} + Get-ADUsersToDeactivate -UserList $records | Out-Null + + Should -Invoke Write-Log -Times 0 -ParameterFilter { $Message -like '*will also set EmployeeID*' } + } + } } diff --git a/tests/Private/AD/Get-ADUsersToSetEmployeeID.Tests.ps1 b/tests/Private/AD/Get-ADUsersToSetEmployeeID.Tests.ps1 index a6a2987..a7d4e95 100644 --- a/tests/Private/AD/Get-ADUsersToSetEmployeeID.Tests.ps1 +++ b/tests/Private/AD/Get-ADUsersToSetEmployeeID.Tests.ps1 @@ -32,6 +32,44 @@ Describe 'Get-ADUsersToSetEmployeeID' { } } + It 'logs at Trace (nothing to reconcile) when the user is not active in AD and the matched account is already disabled' { + # Neither the update step (active users) nor the deactivate step (enabled accounts) will + # write the link, so an Info "will link" line would repeat on every run. + $records = @( + (New-TestSourceRecord -PersonID '1' -Username 'inactive' -IDBActive $false -ADCurrentUserID $null) + (New-TestSourceRecord -PersonID '2' -Username 'unprovisioned' -ProvisionAD $false -ADCurrentUserID $null) + ) + $adUsers = @( + (New-TestADUser -SamAccountName 'inactive' -EmployeeID $null -Enabled $false) + (New-TestADUser -SamAccountName 'unprovisioned' -EmployeeID $null -Enabled $false) + ) + + InModuleScope IDBridge -Parameters @{ records = $records; adUsers = $adUsers } { + Mock Write-Log {} + Mock Get-IDBridgeApprovedNameMismatches { @{} } + $result = Get-ADUsersToSetEmployeeID -UserList $records -CurrentADUsers $adUsers + + $result.Count | Should -Be 2 + Should -Invoke Write-Log -Times 2 -Exactly -ParameterFilter { $Level -eq 'Trace' -and $Message -like '*already disabled - nothing to reconcile*' } + Should -Invoke Write-Log -Times 0 -ParameterFilter { $Message -like '*will link EmployeeID*' } + } + } + + It 'keeps the Info "will link" line for an inactive user whose matched account is still enabled' { + # The deactivate step will disable this account and write the link. + $records = @(New-TestSourceRecord -IDBActive $false -ADCurrentUserID $null) + $adUsers = @(New-TestADUser -EmployeeID $null -Enabled $true) + + InModuleScope IDBridge -Parameters @{ records = $records; adUsers = $adUsers } { + Mock Write-Log {} + Mock Get-IDBridgeApprovedNameMismatches { @{} } + Get-ADUsersToSetEmployeeID -UserList $records -CurrentADUsers $adUsers | Out-Null + + # Write-Log is called without -Level (Info default), so assert "not demoted to Trace" + Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Level -ne 'Trace' -and $Message -like '*will link EmployeeID*' } + } + } + It 'does not link when the username matches but the name differs (no approval) - logs an error' { $records = @(New-TestSourceRecord -ADCurrentUserID $null) $adUsers = @(New-TestADUser -EmployeeID $null -GivenName 'Somebody' -Surname 'Else') diff --git a/tests/Private/Google/Get-GoogleManagedUserCount.Tests.ps1 b/tests/Private/Google/Get-GoogleManagedUserCount.Tests.ps1 new file mode 100644 index 0000000..d16ebd9 --- /dev/null +++ b/tests/Private/Google/Get-GoogleManagedUserCount.Tests.ps1 @@ -0,0 +1,65 @@ +<# +.SYNOPSIS +Unit tests for the Google managed-population count (Get-GoogleManagedUserCount) - the +change-volume guard's denominator. + +.DESCRIPTION +The population is the active Google accounts IDBridge has linked (a personID in the +'organization' externalId), wherever they sit in the OU tree - never a count derived from the +source feed, so a broken feed cannot shrink its own denominator. +#> + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..' '..' 'TestHelper.psm1') -Force + Import-IDBridgeForTest +} + +Describe 'Get-GoogleManagedUserCount' { + It 'counts active linked accounts in any OU, and ignores accounts IDBridge never linked' { + $users = @( + (New-TestGoogleUser -primaryEmail 's1@example.org' -orgUnitPath '/Students/Grade-05' -ExternalIdValue '1') + (New-TestGoogleUser -primaryEmail 's2@example.org' -orgUnitPath '/Students/Grade-12' -ExternalIdValue '2') + (New-TestGoogleUser -primaryEmail 't1@example.org' -orgUnitPath '/Staff/Teacher' -ExternalIdValue '3') + (New-TestGoogleUser -primaryEmail 'admin@example.org' -orgUnitPath '/Administrators' -ExternalIdValue $null) + (New-TestGoogleUser -primaryEmail 'kiosk@example.org' -orgUnitPath '/Kiosk' -ExternalIdValue $null) + ) + + InModuleScope IDBridge -Parameters @{ users = $users } { + Get-GoogleManagedUserCount -Users $users | Should -Be 3 + } + } + + It 'excludes linked accounts that are suspended or archived' { + $users = @( + (New-TestGoogleUser -primaryEmail 'active@example.org' -ExternalIdValue '1') + (New-TestGoogleUser -primaryEmail 'archived@example.org' -ExternalIdValue '2' -archived $true) + (New-TestGoogleUser -primaryEmail 'suspended@example.org' -ExternalIdValue '3' -suspended $true) + ) + + InModuleScope IDBridge -Parameters @{ users = $users } { + Get-GoogleManagedUserCount -Users $users | Should -Be 1 + } + } + + It 'only counts an organization externalId with a value - other types and blank values are not a link' { + $otherType = New-TestGoogleUser -primaryEmail 'other@example.org' -ExternalIdValue $null + $otherType.externalIds = @([PSCustomObject]@{ type = 'custom'; value = '99' }) + $blank = New-TestGoogleUser -primaryEmail 'blank@example.org' -ExternalIdValue $null + $blank.externalIds = @([PSCustomObject]@{ type = 'organization'; value = '' }) + $users = @($otherType, $blank, (New-TestGoogleUser -primaryEmail 'linked@example.org' -ExternalIdValue '1')) + + InModuleScope IDBridge -Parameters @{ users = $users } { + Get-GoogleManagedUserCount -Users $users | Should -Be 1 + } + } + + It 'returns 0 when nothing is linked yet (a fresh tenant) or there are no users' { + $users = @(New-TestGoogleUser -ExternalIdValue $null) + + InModuleScope IDBridge -Parameters @{ users = $users } { + Get-GoogleManagedUserCount -Users $users | Should -Be 0 + Get-GoogleManagedUserCount -Users $null | Should -Be 0 + Get-GoogleManagedUserCount -Users @() | Should -Be 0 + } + } +} diff --git a/tests/Private/Google/Get-GoogleUsersToSetEmployeeID.Tests.ps1 b/tests/Private/Google/Get-GoogleUsersToSetEmployeeID.Tests.ps1 index 67b892e..7aa85cd 100644 --- a/tests/Private/Google/Get-GoogleUsersToSetEmployeeID.Tests.ps1 +++ b/tests/Private/Google/Get-GoogleUsersToSetEmployeeID.Tests.ps1 @@ -32,6 +32,44 @@ Describe 'Get-GoogleUsersToSetEmployeeID' { } } + It 'logs at Trace (nothing to reconcile) when the user is not active in Google and the matched account is already deactivated' { + # Neither the update step (active users) nor the deactivate step (not-yet-deactivated + # accounts) will write the link, so an Info "will link" line would repeat on every run. + $records = @( + (New-TestSourceRecord -PersonID '1' -UPN 'inactive@example.org' -IDBActive $false -GoogleCurrentUserID $null) + (New-TestSourceRecord -PersonID '2' -UPN 'unprovisioned@example.org' -ProvisionGoogle $false -GoogleCurrentUserID $null) + ) + $googleUsers = @( + (New-TestGoogleUser -PrimaryEmail 'inactive@example.org' -ExternalIdValue $null -archived $true) + (New-TestGoogleUser -PrimaryEmail 'unprovisioned@example.org' -ExternalIdValue $null -suspended $true) + ) + + InModuleScope IDBridge -Parameters @{ records = $records; googleUsers = $googleUsers } { + Mock Write-Log {} + Mock Get-IDBridgeApprovedNameMismatches { @{} } + $result = Get-GoogleUsersToSetEmployeeID -UserList $records -GoogleUsers $googleUsers + + $result.Count | Should -Be 2 + Should -Invoke Write-Log -Times 2 -Exactly -ParameterFilter { $Level -eq 'Trace' -and $Message -like '*already deactivated - nothing to reconcile*' } + Should -Invoke Write-Log -Times 0 -ParameterFilter { $Message -like '*will link EmployeeID*' } + } + } + + It 'keeps the Info "will link" line for an inactive user whose matched account is not yet deactivated' { + # The deactivate step will archive this account and write the externalId. + $records = @(New-TestSourceRecord -IDBActive $false -GoogleCurrentUserID $null) + $googleUsers = @(New-TestGoogleUser -ExternalIdValue $null) + + InModuleScope IDBridge -Parameters @{ records = $records; googleUsers = $googleUsers } { + Mock Write-Log {} + Mock Get-IDBridgeApprovedNameMismatches { @{} } + Get-GoogleUsersToSetEmployeeID -UserList $records -GoogleUsers $googleUsers | Out-Null + + # Write-Log is called without -Level (Info default), so assert "not demoted to Trace" + Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Level -ne 'Trace' -and $Message -like '*will link EmployeeID*' } + } + } + It 'does not link when the primaryEmail matches but the name differs (no approval) - logs an error' { $records = @(New-TestSourceRecord -GoogleCurrentUserID $null) $googleUsers = @(New-TestGoogleUser -GivenName 'Somebody' -FamilyName 'Else') diff --git a/tests/TestHelper.psm1 b/tests/TestHelper.psm1 index a7f6046..4f2ce6f 100644 --- a/tests/TestHelper.psm1 +++ b/tests/TestHelper.psm1 @@ -92,6 +92,7 @@ function New-TestSourceRecord { [bool]$ADChangePasswordAtLogon = $true, $ADPassphraseAPI = $null, $ADKey = $null, + $ADObject = $null, # Google provisioning + attached Google target state [bool]$ProvisionGoogle = $true, @@ -141,6 +142,7 @@ function New-TestSourceRecord { ADChangePasswordAtLogon = $ADChangePasswordAtLogon ADPassphraseAPI = $ADPassphraseAPI ADKey = $ADKey + ADObject = $ADObject ProvisionGoogle = $ProvisionGoogle ProcessGroupsGoogle = $ProcessGroupsGoogle GoogleCurrentUserID = $GoogleCurrentUserID From a641aa0af67b16ba60f42d4dc6ac2b0ae6271eb3 Mon Sep 17 00:00:00 2001 From: SamSCN <131897416+SamSCN@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:22:39 -0500 Subject: [PATCH 2/2] Release 26.10.9.1: fold changelog, bump ModuleVersion Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/IDBridge/IDBridge.psd1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ed3ac..2b7b87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to IDBridge are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions use a calendar scheme `YY.M.D.build` (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning--releases)). -## [Unreleased] +## [26.10.9.1] - 2026-10-09 ### Changed - **The change-volume guard counts the accounts IDBridge manages, not an OU, in both diff --git a/src/IDBridge/IDBridge.psd1 b/src/IDBridge/IDBridge.psd1 index 7582a34..48136ad 100644 --- a/src/IDBridge/IDBridge.psd1 +++ b/src/IDBridge/IDBridge.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IDBridge.psm1' - ModuleVersion = '26.10.9.0' + ModuleVersion = '26.10.9.1' GUID = 'a0a0c664-888e-44e2-9c12-fc8647a520c0' Author = 'Sam Cattanach' CompanyName = 'SC Networks LLC'