Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dir>` 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
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 11 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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`
Expand Down Expand Up @@ -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.<grade>.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
Expand Down
21 changes: 14 additions & 7 deletions docs/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 🧮
Expand Down Expand Up @@ -559,16 +561,19 @@ 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 —
`Invoke-IDBridge` treats a failed OU creation as fatal and aborts the run.

### `Disable-IDBridgeADUser` 🔒 🌐
**Params:** `-User`, `-GroupRemovalProcessingStatus`. Disables account, stamps `Division`
with timestamp, moves to trash OU, and (if flag) removes all current groups — 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).

Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
15 changes: 12 additions & 3 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|----------|-------|
Expand All @@ -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. |
Expand Down Expand Up @@ -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`, `<building> 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`
Expand All @@ -228,6 +233,10 @@ File: `C:\IDBridge\Plugins\Invoke-PluginSkywardSMSStudents.ps1`. Pulls students
- **Per-directory provisioning:** `ProvisionAD = [bool]$GradeSettings.<grade>.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 = <grade>.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.
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/IDBridge/IDBridge.psd1
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
9 changes: 5 additions & 4 deletions src/IDBridge/Private/AD/Disable-IDBridgeADUser.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand Down
Loading
Loading