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
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,60 @@ All notable changes to IDBridge are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions use
a calendar scheme `YY.M.D.build` (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning--releases)).

## [26.10.9.1] - 2026-10-09

### Changed
- **The change-volume guard counts the accounts IDBridge manages, not an OU, in both
directories; `Google.userRootOU` is retired and `AD.userRootOU` is now only the service
account's delegation OU.** Each directory's population was the users at or below its
`userRootOU`, which miscounted in both:
- **Google:** the key was used for nothing else, and its name suggested it controlled
placement or scope. It could name only one OU, so a district with students under
`/Students` and staff under `/Staff` had to undercount, or use `''` and count every admin,
kiosk, trashed and archived account. A root that doesn't exist silently turned the guard
off.
- **AD:** the shipped templates put the trash OU inside the root, so every disabled,
trashed account counted and the population grew every year. Accounts under the root that
IDBridge doesn't sync (e.g. staff at a students-only site) counted too, which diluted the
guard.

The population is now the active accounts carrying IDBridge's personID link, wherever they
sit:
- **AD:** enabled, with an `EmployeeID`.
- **Google:** not suspended or archived, with a personID in the `organization` externalId.

Nothing to configure, and it's read from directory state only, so a broken source feed can't
shrink it. New internal `Get-ADManagedUserCount` / `Get-GoogleManagedUserCount`, with tests.
The AD count assumes nothing else in the domain writes `EmployeeID` (e.g. an HR sync).
`AD.userRootOU` stays as `Initialize-IDBridgeADServiceAccount`'s default delegation OU. A
config that still sets `Google.userRootOU` keeps working (the key is ignored, with a Trace
line saying it can be removed); the config template drops it and re-describes
`AD.userRootOU` (`TemplateVersion: 4`).

**Behavior change:** the guard's percentages change at every site. Usually the guard gets
stricter, since trashed, unmanaged and unlinked accounts no longer pad the population. A
fresh tenant has nothing linked yet, so the guard skips with a `Warn` until the first run
links accounts. Onboarding a new source type is measured against the accounts already
linked: review it with `-Preview`, and run it once with `-SkipChangeThreshold` if it trips.

### Fixed
- **Linking no longer logs "will link EmployeeID" every run for accounts that are already
deactivated.** EmployeeID linking matches every unlinked source user by username+name, but
the link is only written by the update step (active users) or the deactivate step (accounts
still enabled / not yet archived). A user who is inactive or not provisioned in a directory
and whose account there is already disabled (AD) or suspended/archived (Google) — e.g. a
student in the AD trash OU — was re-matched with an Info "will link" line on every run, and
nothing ever linked it. `Get-ADUsersToSetEmployeeID` / `Get-GoogleUsersToSetEmployeeID` now
log that case at Trace as "nothing to reconcile"; the match is still returned, and every
other case keeps its Info line unchanged.
- **AD deactivation now persists the EmployeeID on name-matched accounts.** The Google
deactivate step already set the personID externalId on an account matched by name;
`Disable-IDBridgeADUser` disabled the AD account without writing `EmployeeID`, so the
account stayed unlinked forever after. The disable write now sets `EmployeeID` when the
account doesn't carry it yet, and `Get-ADUsersToDeactivate` logs it at plan time ("will
also set EmployeeID"), so ReadOnly runs show it. Accounts disabled before this change stay
unlinked; the fix above quiets them.

## [26.10.9.0] - 2026-10-09

### Added
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,11 @@ logic gets tests alongside it.
4. **Change-volume guard.** After the change lists are computed and before any writes, the
`ChangeThreshold` config block aborts the whole run if a directory's proposed lifecycle
changes (create/update/rename/move/deactivate) exceed a percentage (default `25`) of its
managed root-OU population — protection against a broken source feed mass-changing the
directory. Bypass with `ChangeThreshold.Enabled = $false` or `-SkipChangeThreshold`.
managed population — the active accounts IDBridge has linked (AD: enabled with an
`EmployeeID`; Google: not suspended/archived with a personID `organization` externalId), with
no OU setting in either directory (`AD.userRootOU` is only the service account's delegation
OU) — protection against a broken source feed mass-changing the directory. Bypass with
`ChangeThreshold.Enabled = $false` or `-SkipChangeThreshold`.
5. **Single-run lock.** `Invoke-IDBridge` takes a machine-wide mutex (per RootPath, before
initialization) and a second concurrent run aborts immediately — ReadOnly/Preview runs
included, since they still write the shared log and `Data` state files. Process-bound,
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ Invoke-IDBridge
Get-GoogleUserGroupsToUpdate (if group processing on) ┘
│
9b. Change-threshold guard (if ChangeThreshold.Enabled): per directory, count proposed
lifecycle changes (create/update/rename/move/deactivate) vs the managed root-OU
population via Test-IDBridgeChangeThreshold; Throw (abort before any writes) if any
lifecycle changes (create/update/rename/move/deactivate) vs the managed population
(the active accounts IDBridge has linked: Get-ADManagedUserCount /
Get-GoogleManagedUserCount) via Test-IDBridgeChangeThreshold; Throw (abort before any writes) if any
directory exceeds ChangeThreshold.Percentage. Bypass: -SkipChangeThreshold.
Under -Preview a breach logs a Warn and continues (a preview exists to review
exactly those changes).
Expand Down
27 changes: 20 additions & 7 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,31 @@ vault under `C:\IDBridge\Vault\` (see [Secrets](#secrets-not-in-the-config-file)
Optional block. After the change lists are computed (read-only) and **before any writes**,
`Invoke-IDBridge` compares each enabled directory's proposed lifecycle changes
(create/update/rename/move/deactivate — group churn excluded) against that directory's existing
**managed** population (users under `AD.userRootOU` / `Google.userRootOU`). If the percentage
exceeds `Percentage`, the run **aborts before writing anything**. Omit the whole block to leave
the guard off (older configs keep working).
**managed** population. If the percentage exceeds `Percentage`, the run **aborts before writing
anything**. Omit the whole block to leave the guard off (older configs keep working).

The managed population is the **active accounts IDBridge has linked**, wherever they sit — no OU
setting in either directory:

- **AD:** enabled accounts with an `EmployeeID` (`Get-ADManagedUserCount`). Assumes nothing else
in the domain writes `EmployeeID` (an HR sync, for example) — those accounts would count too.
- **Google:** accounts that are neither suspended nor archived and carry a personID in the
`organization` externalId (`Get-GoogleManagedUserCount`).

Accounts IDBridge never linked (admins, service accounts, kiosks, hand-made accounts) and
deactivated accounts (disabled in the AD trash OU, archived in Google) don't count, and the count
comes from directory state only, so a broken source feed can't shrink it. A new source type's
existing accounts join the population once their first run links them, so that onboarding run is
measured against the accounts already linked — review it with `-Preview` and run it once with
`-SkipChangeThreshold` if it trips.

| Key | Type | Effect | Read by |
|-----|------|--------|---------|
| `Enabled` | bool | Master switch for the guard. `$false` (or `-SkipChangeThreshold`) bypasses it. | `Invoke-IDBridge` |
| `Percentage` | number | Max allowed change % of the managed population, per directory. Required when `Enabled` — there is no code fallback (the shipped config sets `25`). | `Invoke-IDBridge` → `Test-IDBridgeChangeThreshold` |

> A directory whose managed population is **0** (fresh tenant / empty root OU) is skipped with a
> `Warn` rather than tripping the guard, so a legitimate first run isn't blocked by a zero
> A directory whose managed population is **0** (a fresh tenant — nothing linked yet) is skipped
> with a `Warn` rather than tripping the guard, so a legitimate first run isn't blocked by a zero
> denominator. The `-SkipChangeThreshold` switch sets `Enabled = $false` for that run. Under
> `Invoke-IDBridge -Preview` a breach logs a `Warn` and continues instead of aborting — a
> preview exists to review exactly those changes.
Expand All @@ -67,7 +81,6 @@ the guard off (older configs keep working).
|-----|------|--------|---------|
| `enabled` | bool | Master switch for Google processing (an auth failure throws — see behavioral notes). | `Initialize-IDBridge` (cascade), `Invoke-IDBridge` |
| `customerID` | string | Workspace customer ID. | Google target/API calls |
| `userRootOU` | string | Root OU path, e.g. `/YourDistrict`. Managed-population anchor for the change-volume guard. | `Invoke-IDBridge` (`ChangeThreshold`) |
| `enableGroupProcessing` | bool | Enable Google group sync. Per person, a source record with `ProcessGroupsGoogle = $false` is left out (see behavioral notes). | `Invoke-IDBridge` |
| `enableGroupProcessingWhatIf` | bool | While `$true`, group diffs are computed and logged but **no group writes happen** (even with `enableGroupProcessing = $true`). | `Invoke-IDBridge` |
| `enableGroupProcessingRemove` | bool | Allow removals (not just adds). | `Invoke-IDBridge` |
Expand All @@ -85,7 +98,7 @@ the guard off (older configs keep working).
| Key | Type | Effect | Read by |
|-----|------|--------|---------|
| `enabled` | bool | Master switch for AD processing. | `Initialize-IDBridge`, `Invoke-IDBridge` |
| `userRootOU` | string | Root OU DN, e.g. `OU=YourDistrict,DC=yourdomain,DC=local`. Managed-population anchor for the change-volume guard. | `Invoke-IDBridge` (`ChangeThreshold`) |
| `userRootOU` | string | Root OU DN, e.g. `OU=YourDistrict,DC=yourdomain,DC=local` — the OU the IDBridge service account is delegated rights on (`Initialize-IDBridgeADServiceAccount`'s default `-TargetOU`). Every OU your source plugins place users in, trash included, must sit under it, or the service account can't write there. Not read by the sync itself (the change-volume guard counts linked accounts, not an OU). | `Initialize-IDBridgeADServiceAccount` |
| `enableGroupProcessing` | bool | Enable AD group sync. Per person, a source record with `ProcessGroupsAD = $false` is left out (see behavioral notes). | `Invoke-IDBridge` |
| `enableGroupProcessingWhatIf` | bool | While `$true`, group diffs are computed and logged but **no group writes happen** (even with `enableGroupProcessing = $true`). | `Invoke-IDBridge` |
| `enableGroupProcessingRemove` | bool | Allow removals. | `Invoke-IDBridge` |
Expand Down
31 changes: 27 additions & 4 deletions docs/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,10 @@ and can then be deactivated. A name mismatch is an error and skipped, unless app
`Approve-IDBridgeNameMismatch` (honored only while the account still matches the approval;
drifted ⇒ Warn + skip). Unlinked users with no AD account at all are logged at Trace only
(inactive ones with an explicit "nothing to reconcile" message, mirroring
`Get-GoogleUsersToSetEmployeeID`). **Returns:** hashtable
`Get-GoogleUsersToSetEmployeeID`). A match for a user who is inactive or not AD-provisioned
whose account is **already disabled** is still returned but logged at Trace ("nothing to
reconcile") instead of the Info "will link" line — no step writes its EmployeeID (updates cover
active users, deactivation enabled accounts), so it would otherwise recur every run. **Returns:** hashtable
`personID → @{ ID(ObjectGUID); Groups; EnabledStatus; User }`. `Groups` reuses the target
snapshot's exclusion-filtered `CurrentGroups`, so `AD.groupsExcluded` applies to linked
users too (matching the Google side).
Expand Down Expand Up @@ -546,7 +549,10 @@ applied. **Returns:** `@{ UpdateList; RenameList; MoveList }` (items carry `CN`

### `Get-ADUsersToDeactivate` 🔒 🧮
**Params:** `-UserList`. **Predicate:** `(IDBActive=false OR ProvisionAD=false)` AND
`ADCurrentUserEnabledStatus=true`. **Returns:** user objects to disable.
`ADCurrentUserEnabledStatus=true`. Also logs when the disable step will set the EmployeeID:
accounts matched by username+name have none yet, and the update list only covers active users,
so the disable write persists the link (mirrors `Get-GoogleUsersToDeactivate`).
**Returns:** user objects to disable.

### `Get-ADUsersToResetPassword` 🔒 🧮🌐
**Params:** `-UserList` (AD user objects: SamAccountName + DistinguishedName), `-PassphraseAPI`
Expand All @@ -565,13 +571,21 @@ plaintext phrase rides on each item for the caller's optional export and is neve
and no removes (an empty diff would otherwise strip all their groups); the skipped count is
logged at Trace. **Returns:** `@{ Add; Remove }` (each `@{PersonID; ADCurrentUserID; Groups}`).

### `Get-ADManagedUserCount` 🔒 🧮
**Params:** `-Users` (nullable). The change-volume guard's AD denominator: counts enabled
accounts with an `EmployeeID` (the personID link IDBridge writes), anywhere in the domain —
`AD.userRootOU` plays no part. Disabled accounts in the trash OU and never-linked accounts don't
count; reads directory state only; 0 on a fresh domain (the guard then skips with a Warn).
Assumes nothing else writes `EmployeeID`. Mirrors `Get-GoogleManagedUserCount`. **Returns:** `[int]`.

### `New-IDBridgeADOrgUnit` 🔒 🌐
**Params:** `-OrgUnit` (DN). Parses DN → `New-ADOrganizationalUnit`. Throws on failure —
`Invoke-IDBridge` treats a failed OU creation as fatal and aborts the run.

### `Disable-IDBridgeADUser` 🔒 🌐
**Params:** `-User`, `-GroupRemovalProcessingStatus`. Disables account, stamps `Division`
with timestamp, moves to trash OU, and (if flag) removes all current groups. `Invoke-IDBridge`
with timestamp (and sets `EmployeeID` in the same write when the account doesn't carry it yet —
a name-matched account), moves to trash OU, and (if flag) removes all current groups. `Invoke-IDBridge`
sets the flag only when `enableGroupProcessing` + `enableGroupProcessingTrash` are on, WhatIf is
off, and the user's `ProcessGroupsAD` isn't `$false` (the same gate as Google). Each removal
recorded as its own `GroupRemove` write result; a failed group is logged and skipped.
Expand Down Expand Up @@ -613,7 +627,10 @@ to existing Google users by primaryEmail+name. A name mismatch is an error and s
approved via `Approve-IDBridgeNameMismatch` (honored only while the account still matches the
approval; drifted ⇒ Warn + skip). Unlinked users with no Google account at all are
logged at Trace only (inactive ones with an explicit "nothing to reconcile" message — inactive
source rows with no account are expected and recur until the row leaves the source feed).
source rows with no account are expected and recur until the row leaves the source feed). A
match for a user who is inactive or not Google-provisioned whose account is **already suspended
or archived** is still returned but logged at Trace ("nothing to reconcile") instead of the Info
"will link" line — no step writes its externalId, so it would otherwise recur every run.
**Returns:** hashtable `personID → @{ ID; Groups; SuspendedStatus; User }`.

### `Get-GoogleOrgUnitsForProcessing` 🔒 🧮
Expand Down Expand Up @@ -665,6 +682,12 @@ account would be re-matched every run). **Returns:** user objects.
`ProcessGroupsGoogle = $false` are skipped entirely — no adds and no removes; the skipped count
is logged at Trace. **Returns:** `@{ Add; Remove }`.

### `Get-GoogleManagedUserCount` 🔒 🧮
**Params:** `-Users` (nullable). The change-volume guard's Google denominator: counts accounts
that carry an IDBridge personID link (an `organization` externalId with a value) and are
neither suspended nor archived, in any OU. Reads directory state only — a broken source feed
can't shrink it; 0 on a fresh tenant (the guard then skips with a Warn). **Returns:** `[int]`.

### `Get-GoogleUsersOrphaned` 🧮
**Params:** `-UserList`, `-GoogleUsers`, `-TrashOU`. Finds Google users whose ID isn't in
source. **Returns:** `@{ GoogleCurrentUserID; GoogleOrganizationalUnitTrash; Groups }[]`.
Expand Down
11 changes: 6 additions & 5 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,15 +158,16 @@ Edit `C:\IDBridge\Config\IDBridgeConfig.psd1` (schema: [configuration.md](config
| `GoogleToken.Enabled` | `$true` — the key secret exists now (step 4) |
| `Google.enabled` | `$true` |
| `Google.customerID` | Workspace customer ID (Admin console → Account settings) |
| `Google.userRootOU` | the root OU IDBridge manages, e.g. `/YourDistrict` |
| `AD.enabled` | `$true` if syncing AD |
| `AD.userRootOU` | managed root OU DN, e.g. `OU=YourDistrict,DC=yourdomain,DC=local` |
| `Logging.*` | log-sheet ID from step 5, if using sheet logging |
| `Telemetry.Tier` | keep `'Basic'`, or `'Enhanced'`/`'Off'` (see [PRIVACY.md](../PRIVACY.md)) |

Leave `Debug.ReadOnly = $true` and both `enableGroupProcessingWhatIf = $true` — that's the
point of step 8. The `userRootOU` values matter beyond OU placement: they anchor the
`ChangeThreshold` guard's managed-population count.
point of step 8. `AD.userRootOU` is the OU the IDBridge service account is delegated rights on
(see [ad-bootstrap.md](ad-bootstrap.md)), so every OU your plugins place users in must sit under
it. The `ChangeThreshold` guard needs no OU setting in either directory — its population is the
active accounts IDBridge has linked.

## 8. First run (read-only)

Expand Down Expand Up @@ -199,8 +200,8 @@ Flip one brake at a time, with a read-only-style review (`-ReadOnly` or `-Previe
1. `Debug.ReadOnly = $false` — user lifecycle writes (create/update/deactivate/move/rename)
go live. Group changes are still log-only (`WhatIf`), and the `ChangeThreshold` guard
still aborts any run whose proposed changes exceed `Percentage` (default 25%) of a
directory's managed population. A fresh/empty root OU is skipped with a `Warn`, so a
legitimate first population isn't blocked.
directory's managed population (the active accounts IDBridge has linked). A directory with
nothing linked yet is skipped with a `Warn`, so a legitimate first population isn't blocked.
2. `enableGroupProcessing = $true` (per directory) with `WhatIf` still `$true` — review the
group diffs in the log. Add patterns to `groupsExcluded` for any group IDBridge must
never touch (manually curated clubs, committees). To keep a whole source type (e.g. staff)
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.10.9.0'
ModuleVersion = '26.10.9.1'
GUID = 'a0a0c664-888e-44e2-9c12-fc8647a520c0'
Author = 'Sam Cattanach'
CompanyName = 'SC Networks LLC'
Expand Down
Loading
Loading