From 75dc28a3caff108f748477ef6ea553a12a044520 Mon Sep 17 00:00:00 2001
From: SamSCN <131897416+SamSCN@users.noreply.github.com>
Date: Fri, 9 Oct 2026 11:18:51 -0500
Subject: [PATCH 1/2] Group processing per source type
(ProcessGroupsAD/ProcessGroupsGoogle); one deactivate-strip gate that honors
WhatIf
New per-record [bool] flags on New-IDBridgeSourceRecord (default $true). A record with
ProcessGroups
= $false is left alone by that directory's group processing: no adds,
no removes, no deactivate strip. The flags only narrow enableGroupProcessing. Staff,
Skyward and Infinite Campus templates expose them (staff-wide / per grade) and bump their
TemplateVersion. Tests cover the diff skip, override-sheet strings, and factory defaults.
The deactivate group strip now requires enableGroupProcessing + enableGroupProcessingTrash
on and enableGroupProcessingWhatIf off in both directories (AD ignored the master switch,
and neither honored WhatIf), and skips opted-out users.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 29 +++++++++++++
CLAUDE.md | 6 ++-
docs/configuration.md | 15 +++++--
docs/functions.md | 21 ++++++---
docs/getting-started.md | 4 +-
docs/plugins.md | 15 +++++--
.../Private/AD/Disable-IDBridgeADUser.ps1 | 9 ++--
.../Private/AD/Get-ADUserGroupsToUpdate.ps1 | 15 ++++++-
.../Google/Get-GoogleUserGroupsToUpdate.ps1 | 17 +++++++-
src/IDBridge/Public/Core/Invoke-IDBridge.ps1 | 18 +++++---
.../Source/New-IDBridgeSourceRecord.ps1 | 16 ++++++-
.../Plugins/Invoke-PluginGSheetStaff.ps1 | 6 ++-
.../Invoke-PluginInfiniteCampusStudents.ps1 | 8 +++-
.../Invoke-PluginSkywardSMSStudents.ps1 | 8 +++-
.../AD/Get-ADUserGroupsToUpdate.Tests.ps1 | 43 +++++++++++++++++++
.../Get-GoogleUserGroupsToUpdate.Tests.ps1 | 41 ++++++++++++++++++
.../Source/New-IDBridgeSourceRecord.Tests.ps1 | 9 ++++
tests/TestHelper.psm1 | 4 ++
18 files changed, 252 insertions(+), 32 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51e4d17..7b04a67 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)).
+## [Unreleased]
+
+### 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/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
From f4193bca570b84c2c251e97369a71e6ae3de4e45 Mon Sep 17 00:00:00 2001
From: SamSCN <131897416+SamSCN@users.noreply.github.com>
Date: Fri, 9 Oct 2026 11:19:37 -0500
Subject: [PATCH 2/2] Release 26.10.9.0: fold changelog, bump ModuleVersion
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 2 +-
src/IDBridge/IDBridge.psd1 | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b04a67..2991bb4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@ All notable changes to IDBridge are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions use
a calendar scheme `YY.M.D.build` (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning--releases)).
-## [Unreleased]
+## [26.10.9.0] - 2026-10-09
### Added
- **Group processing per source type: `ProcessGroupsAD` / `ProcessGroupsGoogle`.** New
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'