diff --git a/CHANGELOG.md b/CHANGELOG.md index 2991bb4..2b7b87f 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)). +## [26.10.9.1] - 2026-10-09 + +### 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/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' 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