diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e4d17..2991bb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ 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.0] - 2026-10-09 + +### Added +- **Group processing per source type: `ProcessGroupsAD` / `ProcessGroupsGoogle`.** New + per-record `[bool]` flags on `New-IDBridgeSourceRecord` (default `$true`). When a record's + flag is `$false`, that directory's group processing leaves the person alone entirely — no + adds, no removes, and no deactivate strip (an empty proposed list would otherwise strip + everything). The flags only narrow `enableGroupProcessing`; they can never turn group + processing on. `Get-ADUserGroupsToUpdate` / `Get-GoogleUserGroupsToUpdate` log the skipped + count at Trace, and the groups-not-processed trace ignores opted-out users. Plugin + templates expose it: staff (TemplateVersion 2) via `$ADProcessGroups` / + `$GoogleProcessGroups`; Skyward (TemplateVersion 3) and Infinite Campus (TemplateVersion 2) + per grade via `AD/Google.ProcessGroups` in `GradeDefaultSettings` / `GradeOverrides`. + Installed plugins need no change — records built without the flags stay opted in. Covered + by new cases in the AD/Google group-diff and source-record tests (`New-TestSourceRecord` + gains the two flags). + +### Fixed +- **The deactivate group strip now has one gate in both directories and honors WhatIf.** + AD stripped groups on deactivate whenever `enableGroupProcessingTrash` was on, even with + `AD.enableGroupProcessing = $false`, while Google also required `enableGroupProcessing`. + Neither checked `enableGroupProcessingWhatIf`, so with Trash on, deactivations stripped + groups for real in WhatIf mode — contradicting the documented "no group writes happen". + Both directories now strip only when `enableGroupProcessing` and + `enableGroupProcessingTrash` are on, `enableGroupProcessingWhatIf` is off, and the user's + `ProcessGroups` flag isn't `$false`. **Behavior change:** a config with Trash on but + WhatIf on (either directory) or AD group processing off no longer strips groups on + deactivate. + ## [26.9.25.0] - 2026-09-25 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 3613243..d030b20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,11 @@ logic gets tests alongside it. `Debug.readOnly = $false`. The shipped config defaults `ReadOnly = $true`. 2. **Group writes are double-gated** by `enableGroupProcessing`, plus `enableGroupProcessingWhatIf` (log-only), `enableGroupProcessingRemove` - (allow removals), and `enableGroupProcessingTrash` (strip groups on deactivate). + (allow removals), and `enableGroupProcessingTrash` (strip groups on deactivate — only + with `enableGroupProcessing` on and WhatIf off, same gate in AD and Google). + Per person, a source record's `ProcessGroupsAD` / `ProcessGroupsGoogle = $false` (set by + the source plugin — per source type or per grade) leaves that user's groups in that + directory untouched: no adds, removes, or deactivate strips. `Google.groupsExcluded` / `AD.groupsExcluded` list wildcard patterns (email / name) for groups IDBridge must never touch — dropped at target-data retrieval, so no adds, removes, or deactivate strips ever reach them. diff --git a/docs/configuration.md b/docs/configuration.md index 3bd808e..4cdd2c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,10 +68,10 @@ 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. | `Invoke-IDBridge` | +| `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` | -| `enableGroupProcessingTrash` | bool | Strip group memberships when deactivating. | `Invoke-IDBridge` | +| `enableGroupProcessingTrash` | bool | Strip group memberships when deactivating. Only takes effect with `enableGroupProcessing = $true` and `enableGroupProcessingWhatIf = $false`; never strips a user with `ProcessGroupsGoogle = $false`. | `Invoke-IDBridge` | | `groupsExcluded` | array | Group **email** wildcard patterns IDBridge never touches — matching groups are dropped at target-data retrieval, so no adds, removes, or deactivate strips ever reach them (exclusion wins even over a proposed group). Key absent = `@('classroom_teachers@*')` (the previous hardcoded behavior); if you set the key, include that pattern yourself. | `Get-TargetDataGoogle` | | `enableLicenseRemoval` | bool | Remove a user's discovered **paid** license assignments on the **full deactivate (trash) step only** — never on a `ForceDisable` update. The base Education Fundamentals license self-releases when the deactivate step archives the user. **On when the key is absent** (code default), but the shipped config template sets `$false` — so a scaffolded install starts with it off; setting `$false` also drops the `apps.licensing` scope from token requests. | `Invoke-IDBridge`, `Get-TargetDataGoogle`, `Get-IDBridgeGoogleScope` | | `licenseProductIds` | array | Products searched for a user's assignments (SKUs are discovered, not configured). Default `@('101031','101037')` (Education Standard/Plus + Teaching and Learning Upgrade) — paid products only; the base license is not touched by API (archiving releases it, and API removal fights OU auto-licensing). IDs: [Google's product list](https://developers.google.com/workspace/admin/licensing/how-to/products). | `Get-TargetDataGoogle` | @@ -86,10 +86,10 @@ the guard off (older configs keep working). |-----|------|--------|---------| | `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`) | -| `enableGroupProcessing` | bool | Enable AD group sync. | `Invoke-IDBridge` | +| `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` | -| `enableGroupProcessingTrash` | bool | Strip groups on deactivate (passed to `Disable-IDBridgeADUser`). | `Invoke-IDBridge` | +| `enableGroupProcessingTrash` | bool | Strip groups on deactivate (passed to `Disable-IDBridgeADUser`). Only takes effect with `enableGroupProcessing = $true` and `enableGroupProcessingWhatIf = $false`; never strips a user with `ProcessGroupsAD = $false`. | `Invoke-IDBridge` | | `groupsExcluded` | array | Group **name** wildcard patterns IDBridge never touches — matching groups are dropped at target-data retrieval, so no adds, removes, or deactivate strips ever reach them (exclusion wins even over a proposed group). Default `@()`. | `Get-TargetDataAD` | ### `Logging` @@ -242,6 +242,13 @@ key. **Only names/locations are documented here — never values.** See [secrets (config or `-SkipGoogle`/`-SkipAD`) also disables that side's `enableGroupProcessing`. - **WhatIf vs. ReadOnly:** `Debug.ReadOnly` blocks *all* writes; `enableGroupProcessingWhatIf` scopes only group changes to log-only while other writes still happen (when not ReadOnly). + That includes the deactivate strip (`enableGroupProcessingTrash`). +- **Group processing per source type:** the `AD`/`Google` switches above are directory-wide; + each source record's `ProcessGroupsAD` / `ProcessGroupsGoogle` (default `$true`, set by the + source plugin) narrows them per person. `$false` leaves that person's memberships in that + directory untouched — no adds, removes, or deactivate strips. The shipped staff plugin sets + it for all staff (`$ADProcessGroups`/`$GoogleProcessGroups`); the student plugins set it per + grade (`GradeSettings..AD/Google.ProcessGroups`). See [plugins.md](plugins.md). - **Safe default:** the shipped config has `Debug.ReadOnly = $true` and AD/Google group `WhatIf = $true` — a fresh run reports intended changes without modifying anything. - **Change-volume guard:** `ChangeThreshold` aborts the whole run (before any writes) if a diff --git a/docs/functions.md b/docs/functions.md index 8ff7d69..5c4dfc1 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -427,9 +427,11 @@ Canonical factory for a source record — the shape plugins must emit. **Params: `JobTitle`, `Company`, `PersonType`, `PersonTypeID` [ValidateSet `1`/`2`/`3`], `IDBActive` [bool], `ProvisionAD`/`ProvisionGoogle` [bool]); optional/defaulted everything else (`Department`/`InternalID` → `$null`, `GroupsProposed` → `@()`, the AD/Google OU/password -fields). **Returns:** an ordered `PSCustomObject` with the full 35-field contract (incl. optional -AD attributes `Description`/`TelephoneNumber`/`EmailAddress`/`PasswordNeverExpires`/ -`ExtensionAttribute2-4`, and the override flags `ForceDisable`/`GoogleOUOverride`). Construction +fields, `ProcessGroupsAD`/`ProcessGroupsGoogle` → `$true`). **Returns:** an ordered +`PSCustomObject` with the full 37-field contract (incl. optional AD attributes +`Description`/`TelephoneNumber`/`EmailAddress`/`PasswordNeverExpires`/`ExtensionAttribute2-4`, +the per-user group-processing scope `ProcessGroupsAD`/`ProcessGroupsGoogle`, and the override +flags `ForceDisable`/`GoogleOUOverride`). Construction enforces presence + type; cross-field rules live in `Test-IDBridgeSourceData`. ### `Test-IDBridgeSourceData` 🧮 @@ -559,8 +561,9 @@ plaintext phrase rides on each item for the caller's optional export and is neve ### `Get-ADUserGroupsToUpdate` 🔒 🧮 **Params:** `-UserList`, `-CurrentADGroups`. Diffs `GroupsProposed` vs `ADCurrentGroups` -(adds must exist in AD). **Returns:** `@{ Add; Remove }` (each `@{PersonID; ADCurrentUserID; -Groups}`). +(adds must exist in AD). Users with `ProcessGroupsAD = $false` are skipped entirely — no adds +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}`). ### `New-IDBridgeADOrgUnit` 🔒 🌐 **Params:** `-OrgUnit` (DN). Parses DN → `New-ADOrganizationalUnit`. Throws on failure — @@ -568,7 +571,9 @@ Groups}`). ### `Disable-IDBridgeADUser` 🔒 🌐 **Params:** `-User`, `-GroupRemovalProcessingStatus`. Disables account, stamps `Division` -with timestamp, moves to trash OU, and (if flag) removes all current groups — each removal +with timestamp, 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. Disable/move failures return the ErrorRecord (the caller records the `Deactivate` result). @@ -656,7 +661,9 @@ account would be re-matched every run). **Returns:** user objects. ### `Get-GoogleUserGroupsToUpdate` 🔒 🧮 **Params:** `-UserList`, `-GoogleGroups` (nullable). Diffs proposed vs current groups (adds must exist in Google; membership compared by each group's real email from -`GoogleGroups` — a group's email does not always match its name). **Returns:** `@{ Add; Remove }`. +`GoogleGroups` — a group's email does not always match its name). Users with +`ProcessGroupsGoogle = $false` are skipped entirely — no adds and no removes; the skipped count +is logged at Trace. **Returns:** `@{ Add; Remove }`. ### `Get-GoogleUsersOrphaned` 🧮 **Params:** `-UserList`, `-GoogleUsers`, `-TrashOU`. Finds Google users whose ID isn't in diff --git a/docs/getting-started.md b/docs/getting-started.md index 3e1846f..6e4a138 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -203,7 +203,9 @@ Flip one brake at a time, with a read-only-style review (`-ReadOnly` or `-Previe 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). + never touch (manually curated clubs, committees). To keep a whole source type (e.g. staff) + or a grade out of a directory's group processing, set that plugin's `ProcessGroups` flag + to `$false` (see [plugins.md](plugins.md#source-plugin-output-schema)). 3. `enableGroupProcessingWhatIf = $false` — group **adds** go live. 4. Later, as trust builds: `enableGroupProcessingRemove`, `enableGroupProcessingTrash`, and `Google.enableLicenseRemoval` (see [configuration.md](configuration.md#google-workspace-processing)). diff --git a/docs/plugins.md b/docs/plugins.md index 00a2041..f7a82cc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -62,8 +62,9 @@ Source plugins **must build each record with the `New-IDBridgeSourceRecord` fact your field hashtable: `New-IDBridgeSourceRecord @recordFields`) rather than hand-rolling a `PSCustomObject`. The factory guarantees one canonical, ordered shape and enforces required fields + types at construction (core fields are `Mandatory`; `PersonTypeID` is a -`ValidateSet`; `IDBActive`/`ProvisionAD`/`ProvisionGoogle` are `[bool]`; `GroupsProposed` null is -normalized to `@()`). Downstream `Get-*To*` functions depend on these exact property names: +`ValidateSet`; `IDBActive`/`ProvisionAD`/`ProvisionGoogle`/`ProcessGroupsAD`/`ProcessGroupsGoogle` +are `[bool]`; `GroupsProposed` null is normalized to `@()`). Downstream `Get-*To*` functions +depend on these exact property names: | Property | Notes | |----------|-------| @@ -82,6 +83,7 @@ normalized to `@()`). Downstream `Get-*To*` functions depend on these exact prop | `GoogleOUOverride` | `[bool]` (def `$false`) — override-driven skip of the Google OU move. | | `IDBActive` | Master "active in source" flag. `$false` ⇒ deactivate in **every** directory. | | `ProvisionAD` / `ProvisionGoogle` | Per-user directory targeting `[bool]`. With `IDBActive`: create/update when both true; deactivate when either is false. (A young student = `ProvisionAD=$false`.) | +| `ProcessGroupsAD` / `ProcessGroupsGoogle` | Per-user group-processing scope `[bool]` (def `$true`). `$false` leaves that directory's group memberships untouched for the person — no adds, no removes, no deactivate strip. Only narrows the directory's `enableGroupProcessing`; it never turns group processing on. This is how group processing is enabled per source type (staff vs. students) or per grade. | | `ADOrganizationalUnit` / `GoogleOrganizationalUnit` | Target OU (DN / path). | | `ADOrganizationalUnitTrash` / `GoogleOrganizationalUnitTrash` | Deactivation OU. | | `ADChangePasswordAtLogon` / `GoogleChangePasswordAtLogon` | Force change flag. | @@ -203,6 +205,9 @@ File: `C:\IDBridge\Plugins\Invoke-PluginGSheetStaff.ps1`. Pulls staff from a Goo comma-split `ApplicationGroups` **+** `EmailGroups`, de-duplicated. The bundled helper is a starting point (`All Staff`, ` Staff`, and `All Professional Staff` via a person-type allow-list) — extend it to encode your district's group policy. +- **Group-processing scope:** `$ADProcessGroups` / `$GoogleProcessGroups` (default `$true`) set + `ProcessGroupsAD` / `ProcessGroupsGoogle` on every staff record — set one `$false` to leave + staff group memberships untouched in that directory while students are still processed. ### `Invoke-PluginStaffOverride` — Override *(disabled in config)* File: `C:\IDBridge\Plugins\Invoke-PluginStaffOverride.ps1`. Reads `Get-GoogleSheetData` @@ -228,6 +233,10 @@ File: `C:\IDBridge\Plugins\Invoke-PluginSkywardSMSStudents.ps1`. Pulls students - **Per-directory provisioning:** `ProvisionAD = [bool]$GradeSettings..AD.Provision` and `ProvisionGoogle = [bool]…Google.Provision` — so "younger grades = Google only" is set by adding a `GradeOverrides` entry with `AD = @{ Provision = $false }`. +- **Per-directory group scope:** `ProcessGroupsAD/Google = .AD/Google.ProcessGroups -ne $false` + (default `$true`) — so students can be left out of a directory's group processing entirely + (set it in `GradeDefaultSettings`) or per grade (`'01' = @{ AD = @{ ProcessGroups = $false } }`). + Read as `-ne $false` rather than `[bool]` so a settings block without the key stays opted in. - **Name casing:** `NameFirst`/`NameLast` are run through the module's `Format-IDBridgeName` (Skyward returns ALL-CAPS → Title Case). Because the update functions compare names case-sensitively (`-cne`), existing accounts get the casing fix too, not just new ones. @@ -248,7 +257,7 @@ secret `ApiKey-InfiniteCampus`; optional school exclusion by identifier; safety - **Per-grade settings** work exactly as in the Skyward plugin (`GradeDefaultSettings` + `GradeOverrides` merged across `ValidGrades`), but password types are limited to `RANDOM`/`API-PASSPHRASE` — Infinite Campus OneRoster exposes no food-service PIN or - password word. + password word. Group-processing scope (`AD/Google.ProcessGroups`) works the same way too. - `IDBActive`: false if not seen within `DaysLastSeen` (14), if IC reports the student as not `active`/account-disabled (`Status`, `ActiveUserAccount`), if the primary enrollment's `RoleEndDate` is in the past, or if the grade is missing/disabled in settings. Grade-12 diff --git a/src/IDBridge/IDBridge.psd1 b/src/IDBridge/IDBridge.psd1 index e3a30f7..7582a34 100644 --- a/src/IDBridge/IDBridge.psd1 +++ b/src/IDBridge/IDBridge.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IDBridge.psm1' - ModuleVersion = '26.9.25.0' + ModuleVersion = '26.10.9.0' 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 72d3b8b..60f090f 100644 --- a/src/IDBridge/Private/AD/Disable-IDBridgeADUser.ps1 +++ b/src/IDBridge/Private/AD/Disable-IDBridgeADUser.ps1 @@ -16,15 +16,16 @@ The source record for the user to deactivate (uses ADCurrentUserID, ADOrganizati ADCurrentGroups, and PersonID). .PARAMETER GroupRemovalProcessingStatus -When $true, remove the user from all current groups as part of deactivation (driven by the AD -enableGroupProcessingTrash setting). +When $true, remove the user from all current groups as part of deactivation. Invoke-IDBridge passes +$true only when AD enableGroupProcessing and enableGroupProcessingTrash are on, WhatIf is off, and +the user's ProcessGroupsAD is not $false. .EXAMPLE -Disable-IDBridgeADUser -User $item -GroupRemovalProcessingStatus $IDConfig.AD.enableGroupProcessingTrash +Disable-IDBridgeADUser -User $item -GroupRemovalProcessingStatus ($adGroupTrashEnabled -and $item.ProcessGroupsAD -ne $false) .NOTES Created by: Sam Cattanach - Modified: 2026-06-26 + Modified: 2026-10-09 #> function Disable-IDBridgeADUser { [CmdletBinding()] diff --git a/src/IDBridge/Private/AD/Get-ADUserGroupsToUpdate.ps1 b/src/IDBridge/Private/AD/Get-ADUserGroupsToUpdate.ps1 index 180773b..64ad4d0 100644 --- a/src/IDBridge/Private/AD/Get-ADUserGroupsToUpdate.ps1 +++ b/src/IDBridge/Private/AD/Get-ADUserGroupsToUpdate.ps1 @@ -6,6 +6,8 @@ Diff proposed vs current AD group membership for active linked users. For each active, AD-provisioned, linked user, compares GroupsProposed against ADCurrentGroups and builds an Add list (proposed groups that exist in AD and the user isn't already in) and a Remove list (current groups no longer proposed). Adds are limited to groups present in CurrentADGroups. +Users with ProcessGroupsAD = $false are skipped entirely (no adds, no removes) - an empty proposed +list would otherwise strip all of their groups - and the skipped count is logged at Trace. .PARAMETER UserList The enriched source records. @@ -21,7 +23,7 @@ $groups = Get-ADUserGroupsToUpdate -UserList $sourceData -CurrentADGroups $adDat .NOTES Created by: Sam Cattanach - Modified: 2026-06-26 + Modified: 2026-10-09 #> function Get-ADUserGroupsToUpdate { [CmdletBinding()] @@ -36,8 +38,15 @@ function Get-ADUserGroupsToUpdate { $itemListAdd = @() $itemListRemove = @() + $skippedCount = 0 foreach ($item in $UserList | Where-Object {$_.IDBActive -eq $true -and $_.ProvisionAD -eq $true -and $_.ADCurrentUserID}) { + #Opted out of AD group processing - leave the user's groups untouched (no adds, no removes) + if ($item.ProcessGroupsAD -eq $false) { + $skippedCount++ + continue + } + #Create list for adding groups $userGroupsAdd = @() @@ -75,6 +84,10 @@ function Get-ADUserGroupsToUpdate { } } + if ($skippedCount -gt 0) { + Write-Log -Message "AD: Group processing skipped for $($skippedCount) user(s) with ProcessGroupsAD = `$false." -Level Trace + } + return [PSCustomObject]@{ Add = $itemListAdd Remove = $itemListRemove diff --git a/src/IDBridge/Private/Google/Get-GoogleUserGroupsToUpdate.ps1 b/src/IDBridge/Private/Google/Get-GoogleUserGroupsToUpdate.ps1 index ef79137..2cfde8a 100644 --- a/src/IDBridge/Private/Google/Get-GoogleUserGroupsToUpdate.ps1 +++ b/src/IDBridge/Private/Google/Get-GoogleUserGroupsToUpdate.ps1 @@ -8,7 +8,9 @@ GoogleCurrentGroups and builds an Add list (proposed groups that exist in Google already in) and a Remove list (current memberships no longer proposed). Membership is compared by each group's real email from GoogleGroups (a group's email does not always match its name), so a group named Grade-PK with email studentsgradepk@domain diffs correctly. Adds are limited to groups -present in GoogleGroups. +present in GoogleGroups. Users with ProcessGroupsGoogle = $false are skipped entirely (no adds, no +removes) - an empty proposed list would otherwise strip all of their groups - and the skipped +count is logged at Trace. .PARAMETER UserList The enriched source records. @@ -24,7 +26,7 @@ $groups = Get-GoogleUserGroupsToUpdate -UserList $sourceData -GoogleGroups $goog .NOTES Created by: Sam Cattanach - Modified: 2026-07-21 + Modified: 2026-10-09 #> function Get-GoogleUserGroupsToUpdate { [CmdletBinding()] @@ -39,6 +41,7 @@ function Get-GoogleUserGroupsToUpdate { $itemListAdd = @() $itemListRemove = @() + $skippedCount = 0 #Map group names to emails (and back) so membership is compared by each group's real email $groupEmailByName = @{} @@ -49,6 +52,12 @@ function Get-GoogleUserGroupsToUpdate { } foreach ($item in $UserList | Where-Object {$_.IDBActive -eq $true -and $_.ProvisionGoogle -eq $true -and $_.GoogleCurrentUserID}) { + #Opted out of Google group processing - leave the user's groups untouched (no adds, no removes) + if ($item.ProcessGroupsGoogle -eq $false) { + $skippedCount++ + continue + } + #Create list for adding groups $userGroupsAdd = @() @@ -90,6 +99,10 @@ function Get-GoogleUserGroupsToUpdate { } } + if ($skippedCount -gt 0) { + Write-Log -Message "Google: Group processing skipped for $($skippedCount) user(s) with ProcessGroupsGoogle = `$false." -Level Trace + } + return [PSCustomObject]@{ Add = $itemListAdd Remove = $itemListRemove diff --git a/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 b/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 index 1812e3d..6b39b72 100644 --- a/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 +++ b/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 @@ -87,7 +87,7 @@ Invoke-IDBridge -Preview -ShowPasswords | Where-Object Action -eq 'Create' | For .NOTES Created by: Sam Cattanach - Modified: 2026-08-21 + Modified: 2026-10-09 #> function Invoke-IDBridge { [CmdletBinding()] @@ -193,17 +193,19 @@ function Invoke-IDBridge { #region Groups Not Processed + # Users opted out of a directory's group processing (ProcessGroups = $false) are left out - + # their proposed groups are never processed there, so a missing group is not worth reporting. if ($IDConfig.Debug.TraceLogging -eq $true) { #AD Checks if ($IDConfig.AD.enabled -eq $true -and ($IDConfig.AD.enableGroupProcessing -eq $true -or $IDConfig.AD.enableGroupProcessingWhatIf -eq $true)) { Write-Log -Message "AD: Checking for Groups Proposed for Processing that do not exist in the Target Data." -Level Trace - Show-GroupsNotProcessed -ProposedGroups $sourceData.GroupsProposed -TargetGroups $adData.Groups -Directory 'AD' + Show-GroupsNotProcessed -ProposedGroups ($sourceData | Where-Object { $_.ProcessGroupsAD -ne $false }).GroupsProposed -TargetGroups $adData.Groups -Directory 'AD' } #Google Checks if ($IDConfig.Google.enabled -eq $true -and ($IDConfig.Google.enableGroupProcessing -eq $true -or $IDConfig.Google.enableGroupProcessingWhatIf -eq $true)) { Write-Log -Message "Google: Checking for Groups Proposed for Processing that do not exist in the Target Data." -Level Trace - Show-GroupsNotProcessed -ProposedGroups $sourceData.GroupsProposed -TargetGroups $googleData.Groups.name -Directory 'Google' + Show-GroupsNotProcessed -ProposedGroups ($sourceData | Where-Object { $_.ProcessGroupsGoogle -ne $false }).GroupsProposed -TargetGroups $googleData.Groups.name -Directory 'Google' } } #endregion Groups Not Processed @@ -386,10 +388,13 @@ function Invoke-IDBridge { } #Disable Users + #Groups are stripped on deactivate only while group processing is live (enabled + Trash, not WhatIf) + #and the user hasn't opted out (ProcessGroupsAD = $false) - same gate as Google + $adGroupTrashEnabled = ($IDConfig.AD.enableGroupProcessing -eq $true -and $IDConfig.AD.enableGroupProcessingTrash -eq $true -and $IDConfig.AD.enableGroupProcessingWhatIf -ne $true) foreach ($item in $ADUsersToDeactivate) { try { #Disable-IDBridgeADUser returns the ErrorRecord on failure instead of throwing - $disableResult = Disable-IDBridgeADUser -User $item -GroupRemovalProcessingStatus $IDConfig.AD.enableGroupProcessingTrash + $disableResult = Disable-IDBridgeADUser -User $item -GroupRemovalProcessingStatus ($adGroupTrashEnabled -and $item.ProcessGroupsAD -ne $false) Add-IDBridgeWriteResult -Directory AD -Action Deactivate -PersonID $item.PersonID -Target $item.UPN -Success ($null -eq $disableResult) -ErrorMessage "$($disableResult)" } catch { @@ -561,10 +566,13 @@ function Invoke-IDBridge { } #Strip group memberships on deactivate (batched) and remove licenses + #Groups are stripped only while group processing is live (enabled + Trash, not WhatIf) + #and the user hasn't opted out (ProcessGroupsGoogle = $false) - same gate as AD + $googleGroupTrashEnabled = ($IDConfig.Google.enableGroupProcessing -eq $true -and $IDConfig.Google.enableGroupProcessingTrash -eq $true -and $IDConfig.Google.enableGroupProcessingWhatIf -ne $true) $googleBatchRequests = @() $identityMap = @{} foreach ($item in $GoogleUsersToDeactivate) { - if ($IDConfig.Google.enableGroupProcessing -eq $true -and $IDConfig.Google.enableGroupProcessingTrash -eq $true) { + if ($googleGroupTrashEnabled -and $item.ProcessGroupsGoogle -ne $false) { foreach ($group in $item.GoogleCurrentGroups) { try { Write-Log -Message ("Google: Applying: Removing Group: $group from $($item.personID)") diff --git a/src/IDBridge/Public/Source/New-IDBridgeSourceRecord.ps1 b/src/IDBridge/Public/Source/New-IDBridgeSourceRecord.ps1 index 74a6f55..f12d4a7 100644 --- a/src/IDBridge/Public/Source/New-IDBridgeSourceRecord.ps1 +++ b/src/IDBridge/Public/Source/New-IDBridgeSourceRecord.ps1 @@ -56,10 +56,20 @@ Override-driven flag (boolean) to skip the Google OU move. Defaults to $false. Whether this person should have an Active Directory account (per-user targeting). With IDBActive, drives create/update vs. deactivate for AD. Distinct from the global config switch. +.PARAMETER ProcessGroupsAD +Whether AD group processing applies to this person (boolean). Defaults to $true. When $false the +user's AD group memberships are left untouched - no adds, no removes, and no strip on deactivate. +Only narrows AD.enableGroupProcessing; it can never turn group processing on. + .PARAMETER ProvisionGoogle Whether this person should have a Google Workspace account (per-user targeting). With IDBActive, drives create/update vs. deactivate for Google. Distinct from the global config switch. +.PARAMETER ProcessGroupsGoogle +Whether Google group processing applies to this person (boolean). Defaults to $true. When $false +the user's Google group memberships are left untouched - no adds, no removes, and no strip on +deactivate. Only narrows Google.enableGroupProcessing; it can never turn group processing on. + .PARAMETER ADKey AD account password as a SecureString, or $null when using ADPassphraseAPI. @@ -71,7 +81,7 @@ $record = New-IDBridgeSourceRecord @recordFields .NOTES Created by: Sam Cattanach - Modified: 2026-06-25 + Modified: 2026-10-09 #> function New-IDBridgeSourceRecord { [CmdletBinding()] @@ -104,6 +114,7 @@ function New-IDBridgeSourceRecord { # --- Active Directory --- [Parameter(Mandatory)][bool]$ProvisionAD, + [bool]$ProcessGroupsAD = $true, [string]$ADOrganizationalUnit = '', [string]$ADOrganizationalUnitTrash = '', [bool]$ADChangePasswordAtLogon = $false, @@ -116,6 +127,7 @@ function New-IDBridgeSourceRecord { # --- Google Workspace --- [Parameter(Mandatory)][bool]$ProvisionGoogle, + [bool]$ProcessGroupsGoogle = $true, [string]$GoogleOrganizationalUnit = '', [string]$GoogleOrganizationalUnitTrash = '', [bool]$GoogleChangePasswordAtLogon = $false, @@ -151,6 +163,7 @@ function New-IDBridgeSourceRecord { ForceDisable = $ForceDisable GoogleOUOverride = $GoogleOUOverride ProvisionAD = $ProvisionAD + ProcessGroupsAD = $ProcessGroupsAD ADOrganizationalUnit = $ADOrganizationalUnit ADOrganizationalUnitTrash = $ADOrganizationalUnitTrash ADChangePasswordAtLogon = $ADChangePasswordAtLogon @@ -161,6 +174,7 @@ function New-IDBridgeSourceRecord { ADPassphraseAPI = $ADPassphraseAPI ADKey = $ADKey ProvisionGoogle = $ProvisionGoogle + ProcessGroupsGoogle = $ProcessGroupsGoogle GoogleOrganizationalUnit = $GoogleOrganizationalUnit GoogleOrganizationalUnitTrash = $GoogleOrganizationalUnitTrash GoogleChangePasswordAtLogon = $GoogleChangePasswordAtLogon diff --git a/src/IDBridge/Templates/Plugins/Invoke-PluginGSheetStaff.ps1 b/src/IDBridge/Templates/Plugins/Invoke-PluginGSheetStaff.ps1 index 76cf855..7773d1c 100644 --- a/src/IDBridge/Templates/Plugins/Invoke-PluginGSheetStaff.ps1 +++ b/src/IDBridge/Templates/Plugins/Invoke-PluginGSheetStaff.ps1 @@ -1,5 +1,5 @@ #Spreadsheet Data Staff — IDBridge plugin template -# TemplateVersion: 1 +# TemplateVersion: 2 <# Shipped with the IDBridge module and copied to \Plugins by Install-IDBridge. Every placeholder value below must be edited for your district before enabling the plugin @@ -43,6 +43,7 @@ function Invoke-PluginGSheetStaff { #AD Config $ADProvision = $true + $ADProcessGroups = $true #Set $false to leave staff AD group memberships untouched (no adds, removes, or deactivate strips) $ADUserRootOU = "OU=YourDistrict,DC=yourdomain,DC=local" $ADPassPrefix = 'Temp-' $ADChangePasswordAtLogon = $true @@ -50,6 +51,7 @@ function Invoke-PluginGSheetStaff { #Google Config $GoogleProvision = $true + $GoogleProcessGroups = $true #Set $false to leave staff Google group memberships untouched (no adds, removes, or deactivate strips) $GoogleUserRootOU = "/YourDistrict" $GooglePassPrefix = 'Temp-' $GoogleChangePasswordAtLogon = $false @@ -162,6 +164,7 @@ function Invoke-PluginGSheetStaff { PersonType = $item.PersonType ProvisionAD = $ADProvision + ProcessGroupsAD = $ADProcessGroups ADOrganizationalUnit = "OU=$($item.PersonType),OU=$($PersonTypeGeneric),$($ADUserRootOU)" ADOrganizationalUnitTrash = "OU=$(Get-Date -Format yyyy),OU=$($PersonTypeGeneric),OU=Trash,$($ADUserRootOU)" ADChangePasswordAtLogon = $ADChangePasswordAtLogon @@ -169,6 +172,7 @@ function Invoke-PluginGSheetStaff { ADKey = if ($keyAD) { ConvertTo-SecureString -String $keyAD -AsPlainText -Force } else { $null } ProvisionGoogle = $GoogleProvision + ProcessGroupsGoogle = $GoogleProcessGroups GoogleOrganizationalUnit = "$($GoogleUserRootOU)/$($PersonTypeGeneric)/$($item.PersonType)" GoogleOrganizationalUnitTrash = "/Trash/$($PersonTypeGeneric)/$(Get-Date -Format yyyy)" GoogleChangePasswordAtLogon = $GoogleChangePasswordAtLogon diff --git a/src/IDBridge/Templates/Plugins/Invoke-PluginInfiniteCampusStudents.ps1 b/src/IDBridge/Templates/Plugins/Invoke-PluginInfiniteCampusStudents.ps1 index c2b5771..7eb33f0 100644 --- a/src/IDBridge/Templates/Plugins/Invoke-PluginInfiniteCampusStudents.ps1 +++ b/src/IDBridge/Templates/Plugins/Invoke-PluginInfiniteCampusStudents.ps1 @@ -1,5 +1,5 @@ #Infinite Campus Student Plugin — IDBridge plugin template -# TemplateVersion: 1 +# TemplateVersion: 2 <# Shipped with the IDBridge module and copied to \Plugins by Install-IDBridge. A minimal starting point for pulling students from the Infinite Campus OneRoster API. Edit every @@ -81,12 +81,14 @@ function Invoke-PluginInfiniteCampusStudents { Provision = $true AD = @{ Provision = $true + ProcessGroups = $true # $false leaves the grade's AD group memberships untouched (no adds, removes, or deactivate strips) passPrefix = 'Temp' ChangePasswordAtLogon = $false PasswordType = 'RANDOM' } Google = @{ Provision = $true + ProcessGroups = $true # $false leaves the grade's Google group memberships untouched passPrefix = 'Temp' ChangePasswordAtLogon = $false PasswordType = 'RANDOM' @@ -105,6 +107,8 @@ function Invoke-PluginInfiniteCampusStudents { # } # Example: younger grades Google-only # 'KG' = @{ AD = @{ Provision = $false } } + # Example: leave a grade's AD groups alone (Google groups still processed) + # '01' = @{ AD = @{ ProcessGroups = $false } } # Example: if 9-12 used passphrases instead # '09' = @{ AD = @{ PasswordType = 'API-PASSPHRASE' }; Google = @{ PasswordType = 'API-PASSPHRASE' } } } @@ -272,6 +276,7 @@ function Invoke-PluginInfiniteCampusStudents { PersonType = "Student - Grade $($Grade)" ProvisionAD = [bool]$GradeSettings.$($Grade).AD.Provision + ProcessGroupsAD = ($GradeSettings.$($Grade).AD.ProcessGroups -ne $false) ADOrganizationalUnit = "OU=Grade-$($Grade),OU=$($PersonTypeGeneric),$($ADUserRootOU)" ADOrganizationalUnitTrash = "OU=$(Get-Date -Format yyyy),OU=$($PersonTypeGeneric),OU=Trash,$($ADUserRootOU)" ADChangePasswordAtLogon = $GradeSettings.$($Grade).AD.ChangePasswordAtLogon @@ -279,6 +284,7 @@ function Invoke-PluginInfiniteCampusStudents { ADKey = if ($keyAD) { ConvertTo-SecureString -String $keyAD -AsPlainText -Force } else { $null } ProvisionGoogle = [bool]$GradeSettings.$($Grade).Google.Provision + ProcessGroupsGoogle = ($GradeSettings.$($Grade).Google.ProcessGroups -ne $false) GoogleOrganizationalUnit = "$($GoogleUserRootOU)/$($PersonTypeGeneric)/Grade-$($Grade)" GoogleOrganizationalUnitTrash = "/Trash/$($PersonTypeGeneric)/$(Get-Date -Format yyyy)" GoogleChangePasswordAtLogon = $GradeSettings.$($Grade).Google.ChangePasswordAtLogon diff --git a/src/IDBridge/Templates/Plugins/Invoke-PluginSkywardSMSStudents.ps1 b/src/IDBridge/Templates/Plugins/Invoke-PluginSkywardSMSStudents.ps1 index 0d102a8..7174941 100644 --- a/src/IDBridge/Templates/Plugins/Invoke-PluginSkywardSMSStudents.ps1 +++ b/src/IDBridge/Templates/Plugins/Invoke-PluginSkywardSMSStudents.ps1 @@ -1,5 +1,5 @@ #Skyward SMS Student Plugin — IDBridge plugin template -# TemplateVersion: 2 +# TemplateVersion: 3 <# Shipped with the IDBridge module and copied to \Plugins by Install-IDBridge. A minimal starting point for pulling students from the Skyward SMS OneRoster API. Edit every @@ -76,12 +76,14 @@ function Invoke-PluginSkywardSMSStudents { Provision = $true AD = @{ Provision = $true + ProcessGroups = $true # $false leaves the grade's AD group memberships untouched (no adds, removes, or deactivate strips) passPrefix = 'Temp' ChangePasswordAtLogon = $false PasswordType = 'RANDOM' } Google = @{ Provision = $true + ProcessGroups = $true # $false leaves the grade's Google group memberships untouched passPrefix = 'Temp' ChangePasswordAtLogon = $false PasswordType = 'RANDOM' @@ -99,6 +101,8 @@ function Invoke-PluginSkywardSMSStudents { # } # Example: younger grades Google-only # 'KG' = @{ AD = @{ Provision = $false } } + # Example: leave a grade's AD groups alone (Google groups still processed) + # '01' = @{ AD = @{ ProcessGroups = $false } } # Example: if 9-12 used passphrases instead # '09' = @{ AD = @{ PasswordType = 'API-PASSPHRASE' }; Google = @{ PasswordType = 'API-PASSPHRASE' } } } @@ -277,6 +281,7 @@ function Invoke-PluginSkywardSMSStudents { PersonType = "Student - Grade $($Grade)" ProvisionAD = [bool]$GradeSettings.$($Grade).AD.Provision + ProcessGroupsAD = ($GradeSettings.$($Grade).AD.ProcessGroups -ne $false) ADOrganizationalUnit = "OU=Grade-$($Grade),OU=$($PersonTypeGeneric),$($ADUserRootOU)" ADOrganizationalUnitTrash = "OU=$(Get-Date -Format yyyy),OU=$($PersonTypeGeneric),OU=Trash,$($ADUserRootOU)" ADChangePasswordAtLogon = $GradeSettings.$($Grade).AD.ChangePasswordAtLogon @@ -284,6 +289,7 @@ function Invoke-PluginSkywardSMSStudents { ADKey = if ($keyAD) { ConvertTo-SecureString -String $keyAD -AsPlainText -Force } else { $null } ProvisionGoogle = [bool]$GradeSettings.$($Grade).Google.Provision + ProcessGroupsGoogle = ($GradeSettings.$($Grade).Google.ProcessGroups -ne $false) GoogleOrganizationalUnit = "$($GoogleUserRootOU)/$($PersonTypeGeneric)/Grade-$($Grade)" GoogleOrganizationalUnitTrash = "/Trash/$($PersonTypeGeneric)/$(Get-Date -Format yyyy)" GoogleChangePasswordAtLogon = $GradeSettings.$($Grade).Google.ChangePasswordAtLogon diff --git a/tests/Private/AD/Get-ADUserGroupsToUpdate.Tests.ps1 b/tests/Private/AD/Get-ADUserGroupsToUpdate.Tests.ps1 index 26cca9f..7ab7b46 100644 --- a/tests/Private/AD/Get-ADUserGroupsToUpdate.Tests.ps1 +++ b/tests/Private/AD/Get-ADUserGroupsToUpdate.Tests.ps1 @@ -101,4 +101,47 @@ Describe 'Get-ADUserGroupsToUpdate' { @($result.Remove).Count | Should -Be 0 } } + + It 'leaves a user with ProcessGroupsAD = $false untouched - no adds and no removes' { + # Both records would add 'Math Dept' and remove 'Old Team'; only the opted-in one may. + $records = @( + (New-TestSourceRecord -PersonID '1' -ProcessGroupsAD $false -GroupsProposed 'Staff', 'Math Dept' -ADCurrentGroups 'Staff', 'Old Team') + (New-TestSourceRecord -PersonID '2' -GroupsProposed 'Staff', 'Math Dept' -ADCurrentGroups 'Staff', 'Old Team') + ) + + InModuleScope IDBridge -Parameters @{ records = $records } { + Mock Write-Log {} + $result = Get-ADUserGroupsToUpdate -UserList $records -CurrentADGroups @('Staff', 'Math Dept', 'Old Team') + + @($result.Add.PersonID) | Should -Be @('2') + @($result.Remove.PersonID) | Should -Be @('2') + Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Message -like 'AD: Group processing skipped for 1 user(s)*' -and $Level -eq 'Trace' } + } + } + + It 'treats an override-sheet string FALSE as opted out' { + $records = @(New-TestSourceRecord -ProcessGroupsAD 'FALSE' -GroupsProposed 'Staff' -ADCurrentGroups 'Old Team') + + InModuleScope IDBridge -Parameters @{ records = $records } { + Mock Write-Log {} + $result = Get-ADUserGroupsToUpdate -UserList $records -CurrentADGroups @('Staff', 'Old Team') + + @($result.Add).Count | Should -Be 0 + @($result.Remove).Count | Should -Be 0 + } + } + + It 'still processes a record with no ProcessGroupsAD property (built outside the factory)' { + $record = New-TestSourceRecord -GroupsProposed 'Staff' -ADCurrentGroups 'Old Team' + $record.PSObject.Properties.Remove('ProcessGroupsAD') + + InModuleScope IDBridge -Parameters @{ records = @($record) } { + Mock Write-Log {} + $result = Get-ADUserGroupsToUpdate -UserList $records -CurrentADGroups @('Staff', 'Old Team') + + @($result.Add).Count | Should -Be 1 + @($result.Remove).Count | Should -Be 1 + Should -Invoke Write-Log -Times 0 -ParameterFilter { $Message -like '*skipped*' } + } + } } diff --git a/tests/Private/Google/Get-GoogleUserGroupsToUpdate.Tests.ps1 b/tests/Private/Google/Get-GoogleUserGroupsToUpdate.Tests.ps1 index 7abe1d0..d1121f4 100644 --- a/tests/Private/Google/Get-GoogleUserGroupsToUpdate.Tests.ps1 +++ b/tests/Private/Google/Get-GoogleUserGroupsToUpdate.Tests.ps1 @@ -117,4 +117,45 @@ Describe 'Get-GoogleUserGroupsToUpdate' { @($result.Remove).Count | Should -Be 0 } } + + It 'leaves a user with ProcessGroupsGoogle = $false untouched - no adds and no removes' { + # Both records would add 'Staff' and remove the Grade-PK membership; only the opted-in one may. + $records = @( + (New-TestSourceRecord -PersonID '1' -ProcessGroupsGoogle $false -GroupsProposed 'Staff' -GoogleCurrentGroups 'studentsgradepk@example.org') + (New-TestSourceRecord -PersonID '2' -GroupsProposed 'Staff' -GoogleCurrentGroups 'studentsgradepk@example.org') + ) + + InModuleScope IDBridge -Parameters @{ records = $records; groups = $Groups } { + Mock Write-Log {} + $result = Get-GoogleUserGroupsToUpdate -UserList $records -GoogleGroups $groups + + @($result.Add.PersonID) | Should -Be @('2') + @($result.Remove.PersonID) | Should -Be @('2') + Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Message -like 'Google: Group processing skipped for 1 user(s)*' -and $Level -eq 'Trace' } + } + } + + It 'treats an override-sheet string FALSE as opted out' { + $records = @(New-TestSourceRecord -ProcessGroupsGoogle 'FALSE' -GroupsProposed 'Staff' -GoogleCurrentGroups 'studentsgradepk@example.org') + + InModuleScope IDBridge -Parameters @{ records = $records; groups = $Groups } { + Mock Write-Log {} + $result = Get-GoogleUserGroupsToUpdate -UserList $records -GoogleGroups $groups + + @($result.Add).Count | Should -Be 0 + @($result.Remove).Count | Should -Be 0 + } + } + + It 'is independent of ProcessGroupsAD - an AD-only opt-out is still processed in Google' { + $records = @(New-TestSourceRecord -ProcessGroupsAD $false -GroupsProposed 'Staff') + + InModuleScope IDBridge -Parameters @{ records = $records; groups = $Groups } { + Mock Write-Log {} + $result = Get-GoogleUserGroupsToUpdate -UserList $records -GoogleGroups $groups + + @($result.Add).Count | Should -Be 1 + @($result.Add[0].Groups) | Should -Be @('Staff') + } + } } diff --git a/tests/Public/Source/New-IDBridgeSourceRecord.Tests.ps1 b/tests/Public/Source/New-IDBridgeSourceRecord.Tests.ps1 index 481a754..ba9c751 100644 --- a/tests/Public/Source/New-IDBridgeSourceRecord.Tests.ps1 +++ b/tests/Public/Source/New-IDBridgeSourceRecord.Tests.ps1 @@ -37,6 +37,15 @@ Describe 'New-IDBridgeSourceRecord' { $record.ADOrganizationalUnit | Should -Be '' $record.ADKey | Should -BeNullOrEmpty $record.GroupsProposed | Should -Be @() + $record.ProcessGroupsAD | Should -BeTrue + $record.ProcessGroupsGoogle | Should -BeTrue + } + + It 'carries a per-directory group-processing opt-out through' { + $record = New-IDBridgeSourceRecord @BaseFields -ProcessGroupsAD $false + + $record.ProcessGroupsAD | Should -BeFalse + $record.ProcessGroupsGoogle | Should -BeTrue } It 'normalizes a null GroupsProposed to an empty string array' { diff --git a/tests/TestHelper.psm1 b/tests/TestHelper.psm1 index 3764b20..a7f6046 100644 --- a/tests/TestHelper.psm1 +++ b/tests/TestHelper.psm1 @@ -83,6 +83,7 @@ function New-TestSourceRecord { # AD provisioning + attached AD target state [bool]$ProvisionAD = $true, + $ProcessGroupsAD = $true, # untyped: tests also pass an override-sheet string ('FALSE') [AllowNull()][string]$ADCurrentUserID = 'tuser', [bool]$ADCurrentUserEnabledStatus = $true, [AllowNull()][string[]]$ADCurrentGroups = @(), @@ -94,6 +95,7 @@ function New-TestSourceRecord { # Google provisioning + attached Google target state [bool]$ProvisionGoogle = $true, + $ProcessGroupsGoogle = $true, [AllowNull()][string]$GoogleCurrentUserID = 'g-tuser', [bool]$GoogleCurrentUserSuspendedStatus = $false, [AllowNull()][string[]]$GoogleCurrentGroups = @(), @@ -130,6 +132,7 @@ function New-TestSourceRecord { ForceDisable = $ForceDisable GroupsProposed = $GroupsProposed ProvisionAD = $ProvisionAD + ProcessGroupsAD = $ProcessGroupsAD ADCurrentUserID = $ADCurrentUserID ADCurrentUserEnabledStatus = $ADCurrentUserEnabledStatus ADCurrentGroups = $ADCurrentGroups @@ -139,6 +142,7 @@ function New-TestSourceRecord { ADPassphraseAPI = $ADPassphraseAPI ADKey = $ADKey ProvisionGoogle = $ProvisionGoogle + ProcessGroupsGoogle = $ProcessGroupsGoogle GoogleCurrentUserID = $GoogleCurrentUserID GoogleCurrentUserSuspendedStatus = $GoogleCurrentUserSuspendedStatus GoogleCurrentGroups = $GoogleCurrentGroups