Every module function, grouped by layer. One entry each: purpose, key params, return shape, notable deps/external calls. For the overall flow see architecture.md.
Functions marked ๐ are internal (module Private\, not in FunctionsToExport):
the sync pipeline's planners, target-data readers, and directory writers, called only by
Invoke-IDBridge and free to change between releases. Everything unmarked is the
supported public surface โ what an admin or plugin author calls directly. (To reach an
internal function for testing: & (Get-Module IDBridge) { <function> ... }.)
Legend: ๐ = makes external API/cmdlet calls ยท ๐งฎ = pure decision/compute (no writes) ยท ๐ = internal, not exported.
Top-level orchestrator. Params: -RootPath (def C:\IDBridge), switches -ReadOnly -TestRun -SkipADCheck -TraceLogging -SkipAD -SkipGoogle -SkipChangeThreshold -DisableTelemetry -Preview -ShowPasswords. Only one run per RootPath executes at a
time: a machine-wide mutex is taken before initialization (ReadOnly/Preview runs hold it
too โ they share the log and Data state files) and a second run throws immediately;
the OS releases the lock if a run dies holding it, so it never goes stale. Calls
Initialize-IDBridge, applies switch overrides, runs the notify-only Gallery update
check (a newer release logs a Warn; failures are skipped at Trace), then runs the full
pipeline: the shared gather phase (Get-IDBridgePipelineData โ includes the Google auth
from the vault secret GoogleAuth-ServiceAccount โ $script:GoogleHeaders), personID
matching, plan, guard, apply (see architecture.md). -Preview forces ReadOnly and emits
the proposed changes as flat rows (via ConvertTo-IDBridgePreviewRow) for
Format-Table review โ quiet: no telemetry, no PostRun plugins, no sheet log push, and a
tripped change threshold warns instead of aborting; -ShowPasswords fills the Password
column for pending creates (emitted only, never logged). Returns: nothing (side
effects + log push; user-list CSV exports live in the PostRun export plugin), or the
preview rows under -Preview.
Loads config, builds/validates Paths.*, sets up logging (+5 MB rotation), imports AD
module, applies feature cascade. No Google auth โ that happens in Invoke-IDBridge, so a
fresh install initializes cleanly before any secrets exist. Params: -RootPath,
-SkipADCheck/-SkipAD (applied to the config before the AD module import โ forwarded by
the matching Invoke-IDBridge switches, whose other overrides land after initialization).
Returns: nothing; sets $script:IDBridgeConfig, $script:Logs.
Install scaffold. Creates the runtime folder tree (Config/Logs/Exports/Plugins/Data/ Vault) under -RootPath (def C:\IDBridge), copies the shipped config template
(default IDBridgeConfig.psd1 โ every feature disabled, placeholder site values, safety
brakes on: ReadOnly, group WhatIf, ChangeThreshold), and copies the shipped plugin
templates (module Templates\Plugins\) into <RootPath>\Plugins. Never overwrites
anything (no -Force) โ existing config and plugin files are skipped with a notice, so
re-running it on an existing install is safe and is how you pick up NEW plugin templates
after a module update. Templates carry a # TemplateVersion: <n> marker; when a skipped
file's shipped template is newer than the installed copy, the skip notice adds a
"newer template available" pointer (compare and port changes by hand โ site edits are
never touched). Needs no initialized state (Write-Host only, no Write-Log);
run it before Initialize-IDBridge on a fresh install. Params: -RootPath.
Returns: nothing; prints what it created.
AD-side bootstrap for unattended runs (see ad-bootstrap.md). Creates
the gMSA-IDBridge group Managed Service Account (password retrievable by this
computer; requires a usable KDS root key โ explained, never created silently), delegates
least-privilege rights on the managed root OU (create OUs only; create + full control
over descendant user objects โ Delete included, required for OU moves; write the
member attribute of descendant group objects only), and grants the gMSA private-key
read on the Cms certificate via Grant-IDBridgeCertificatePrivateKeyAccess
(auto-resolved like Set-IDBridgeSecret). Idempotent; elevated session. Params:
-AccountName (def gMSA-IDBridge), -TargetOU (def AD.userRootOU), -ComputerName
(def this computer), -CertThumbprint, -SkipCertificateAccess (DpapiNG/AzKeyVault
sites). Returns: the gMSA.
Host-side follow-up: installs + verifies the gMSA on this computer (auto-purging the
computer's Kerberos tickets and retrying once when it was only just allowed), grants it
'Log on as a batch job' (Grant-IDBridgeBatchLogonRight) and the filesystem rights a
run needs (read on the runtime root and a pinned -ModulePath's folder, modify on
Logs/Exports/Data), and registers a Task Scheduler task running
Import-Module IDBridge; Invoke-IDBridge -RootPath <root> in pwsh as the gMSA every
-IntervalMinutes (-LogonType Password, no stored credential; no overlap, hung runs
killed after 1 h). The import is by name (Get-IDBridgeTaskCommand), so every run loads
the newest version installed for all users and Update-Module IDBridge -Scope AllUsers
alone updates the task; a per-user install is refused (Test-IDBridgeSharedModulePath).
A task registered before 26.9.25.0 pins one version โ re-register it once. Created
disabled unless -Enabled โ verify, then Enable-ScheduledTask. Idempotent โ
re-running replaces the task. Elevated session. Params: -AccountName, -TaskName
(def IDBridge Sync), -IntervalMinutes (def 15, midnight-aligned), -Enabled,
-RootPath (def Paths.Root), -ModulePath (pins the task to that manifest; def import
by name). Returns: the registered task.
Params: -Identity (mandatory account, e.g. 'DOMAIN\gMSA-IDBridge$'). Grants
SeBatchLogonRight ('Log on as a batch job') in the local security policy through a
built-in P/Invoke wrapper over advapi32.dll (LsaOpenPolicy/LsaAddAccountRights โ
no cmdlet exists for user rights). Idempotent; local only (a GPO managing the right
overwrites it on refresh); elevated session. No return.
Params: -ModuleBase (mandatory), -SharedModulePaths (def the machine-scope
PSModulePath plus $env:ProgramFiles\PowerShell\Modules, pwsh's all-users folder).
Whether the module folder sits under an all-users module path โ case-insensitive, \
and / alike, trailing separators ignored, whole path segments only (...\ModulesX is
not under ...\Modules). Decides between the import by name and the per-user refusal in
Register-IDBridgeScheduledTask. Returns: [bool].
Params: -RootPath (mandatory), -ModulePath. The full pwsh argument string the
scheduled task runs: -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Import-Module IDBridge; Invoke-IDBridge -RootPath '<root>'", or Import-Module '<ModulePath>' in its place when pinned. Returns: [string].
Accessor for $script:IDBridgeConfig. Throws if called before Initialize-IDBridge.
Params: -Message (alias -LogContent, mandatory), -Path/-LogPath (def
Paths.LogFile), -Level {Error|Warn|Info|Trace} (def Info), -NoClobber. Writes to
file + $script:Logs + console. Drops Trace unless Debug.TraceLogging.
Accessor for the in-memory $script:Logs buffer (used by Push-LogsToSheet). Throws if
uninitialized.
Accessor for $script:GoogleHeaders (the bearer auth headers). Throws if Google auth
wasn't set up.
Posts one anonymous usage event per run to the IDBridge Pulse ingest endpoint from the
finally block of Invoke-IDBridge (see PRIVACY.md). Params:
-Success (mandatory bool), -DurationSeconds -ManagedCount -CreateCount -UpdateCount -DeactivateCount -GroupAddCount -GroupRemoveCount -WriteFailureCount (ints; the action
counts are actual applied successes, the failure count is failures โ all aggregated from
the run's per-write results), -RunError (ErrorRecord โ Enhanced tier extracts exception
class + throwing function name only, never the message). Tier from Telemetry.Tier
(missing = Basic, unrecognized = Off); logs the exact payload at Trace; sends with a
10 s timeout, no retries, all errors swallowed โ can never affect the run. Returns: nothing.
Writes <DataRoot>\LastRun.json, the end-of-run summary a reader on the same box consumes
(SC Networks' Beacon collector where a district runs Beacon โ see PRIVACY.md),
from the finally block of Invoke-IDBridge after telemetry and before the PostRun plugins,
on every run that is not a -Preview. Params: -RunResult (mandatory). Replaces the
file whole with a versioned, additively-grown schema (schemaVersion 1): module version,
run start/end and duration, success/readOnly/testRun, the enabled directories, the
APPLIED counts (zeros in ReadOnly), thresholdExceeded, and lastSuccessAt โ runEnd on a
successful run, carried forward from the previous file on a failed one. A failed run adds the
exception class and throwing function name only, never the message; no name, id, UPN, DN
or group name is ever written. A write failure logs a Warn โ it can never affect the run.
Returns: nothing.
Discovers\runs the Type = 'PostRun' plugins from $IDConfig.Plugins, called from the
finally block of Invoke-IDBridge after telemetry and before the log push โ on every
run, including failed and ReadOnly runs. Same load steps as Invoke-SourcePlugins
(file check, dot-source, Get-Command, warn + disable on failure) but invokes each plugin
as & <Function> -RunResult <RunResult> (SecureStrings already scrubbed to $null), and
each invocation is isolated in its own try/catch (Warn, next plugin still runs). Never
throws. Params: -RunResult (mandatory). Returns: nothing. See plugins.md.
Returns the install's telemetry SiteID from <ConfigRoot>\IDBridgeSiteID.json, generating
a random GUID (plain-text file, deliberately unencrypted/non-secret) on first use or when
the file is invalid. Only transmitted at the Enhanced tier; used to claim the install in
the Pulse dashboard. Delete the file when cloning a config to a new install.
No params. Queries the PowerShell Gallery for the latest stable IDBridge release (10 s
timeout) and logs a Warn when a newer one exists ("run
Update-Module IDBridge -Scope AllUsers", and re-register a task from before 26.9.25.0
once), Trace when current. Notify-only โ never installs anything. Failures throw to the
caller (Invoke-IDBridge swallows them at Trace). Returns: [bool].
Params: -Path. Reads the # TemplateVersion: <n> marker from a template file.
Returns: [int], or $null when the file has no marker. Powers Install-IDBridge's
"newer template available" notice; uses no module state or Write-Log (it runs before
initialization).
Params: -Directory {AD|Google}, -Action {Create|Update|Rename|Move|Deactivate| GroupAdd|GroupRemove}, -PersonID, -Target, -Success (bool), -ErrorMessage.
Appends one structured record to the run's write-result buffer ($script:WriteResults,
reset by Initialize-IDBridge) โ the source of the RunResult Applied list and the
actual-outcome telemetry counts. No return.
Params: -Action, -Requests, -Responses, -IdentityMap (ContentId โ
@{ PersonID; Target }). Matches each Google batch request to its response part by
ContentId and records one write result per request via Add-IDBridgeWriteResult (missing
response part or status โฅ 400 โ failure). No return.
Params: -InputObject. Walks the RunResult object graph (hashtables, lists,
PSCustomObjects; cycle-safe) and replaces every [SecureString] with $null, in place โ
type-driven, so new credential fields are covered automatically. Called by
Invoke-IDBridge just before Invoke-PostRunPlugins. No return.
Deterministic passphrase generator backed by Keysmith (see keysmith.md).
Params: -Nonce (SecureString), -Username (string/array), -Mode {words|verbnoun},
-WordCount (2โ6, def 3), -AuthToken (SecureString, required โ throws when absent; sent
as the x-api-key header โ the Static Web App proxy overwrites Authorization), -Rev
(1โ99, optional โ pin a word-list revision; omit for the server's latest), -FunctionUrl
(def https://keysmith.scnlabs.net). POSTs to <FunctionUrl>/api/generate and logs the
word-list rev used on every call. Returns: phrase string(s).
Params: -gradYear (2000โ2099), -gradeAdvanceDate (MM-dd rollover date). Returns:
grade code (12..01, KG, K4, PK, or GD) from graduation year vs. the school-year
rollover; $null when the year falls outside the computed grade window (a year outside
2000โ2099 throws at parameter binding).
Params: -Name (pipeline-friendly). Title-cases a name, capitalizing after spaces,
hyphens, and apostrophes (JOSHUA MOINโJoshua Moin, O'BRIENโO'Brien); null/empty passed
through. Can't infer intentional internal caps (McDonaldโMcdonald). The Skyward plugin uses
it on NameFirst/NameLast; paired with the update functions' case-sensitive name compare, the
casing fix reaches existing accounts too.
Params: -SourceData (nullable/empty allowed). Two passes: drop records flagged
ADDuplicateIDStatus/GoogleDuplicateIDStatus, then drop all records sharing a
duplicate personID. Returns: array (guaranteed).
Params: -Directory (AD/Google, mandatory โ log prefix), -ProposedGroups,
-TargetGroups. Trace-logs one line listing the proposed groups missing from the target.
No return.
Params: -Directory (AD/Google, log context), -ChangeCount, -PopulationCount,
-ThresholdPercent (0โ100). Computes ChangeCount / PopulationCount as a percentage and flags
whether it exceeds ThresholdPercent; a PopulationCount of 0 is skipped (logged Warn, not
a breach). Pure compute + log โ the caller (Invoke-IDBridge) decides whether to abort.
Returns: [pscustomobject]@{ Directory; ChangeCount; PopulationCount; Percent; Exceeded; Skipped }. Used by the change-volume safety guard between the compute and execute regions.
The shared gather phase, called by Invoke-IDBridge and Approve-IDBridgeNameMismatch so
the sequence lives in one place: Google auth (Connect-IDBridgeGoogle, when
GoogleToken.Enabled) โ Invoke-SourcePlugins โ Get-TargetDataGoogle/Get-TargetDataAD
(per enabled directory) โ Add-TargetDataAD/Add-TargetDataGoogle โ
Remove-IDBridgeDuplicateID โ Merge-IDBridgeOverrideData. Reads the initialized config
(callers run Initialize-IDBridge and their overrides first); read-only; personID matching
is not done here. No params. Returns: @{ SourceData; ADData; GoogleData }
(ADData/GoogleData are $null when that directory is disabled).
Flattens the computed change lists into the uniform rows Invoke-IDBridge -Preview emits.
Params: -SourceData (mandatory; resolves Name/Account/Building via personID for rows
whose list item doesn't carry them), -ShowPasswords, and one optional param per change
list (-ADUsersToCreate/-ADUsersToUpdate/-ADUsersToDeactivate/-ADUserGroupsToUpdate/ -ADOrgUnitsToCreate and the Google equivalents) โ omitted/$null lists contribute no
rows. Rows follow the apply order per directory; group changes emit one row per
user+group; update splats render compactly minus bookkeeping keys (Identity, Division,
GoogleUserID); the Password column is filled for Create rows only when -ShowPasswords
(decoded at emit time, never logged โ a SecureString anywhere else renders (secure)).
Returns: [object[]] of @{ Directory; Action; PersonID; Name; Account; Building; OrgUnit; Password; Changes } with Action one of
CreateOU/Deactivate/Update/Rename/Move/Create/GroupAdd/GroupRemove.
One-time onboarding tool: seeds the staff source sheet from the current directory state.
Params: -SpreadsheetId (mandatory), -SheetName (def StaffSeed-<yyyy-MM-dd>),
-GroupsSheetName (def GroupsSeed-<yyyy-MM-dd>) โ throws if either tab already exists,
never appends/overwrites โ -ADSearchBase / -GoogleOrgUnitPath
(one or more subtree scopes each โ a user under any of them is included; trailing slashes on
Google OU paths are normalized away; no config default โ a directory is fetched only when its scope is named,
and an omitted scope is skipped entirely rather than failing, so a Google-only run never touches
or requires AD and vice versa; at least one is required). Pulls Get-TargetDataAD and/or
Get-TargetDataGoogle, merges per person by UPN=primaryEmail, and writes one row per person in the staff sheet
layout plus review-helper columns (InAD/InGoogle/ADEnabled/GoogleSuspended/
ADOrgUnit/GoogleOrgUnit). All rows get Process=FALSE; PersonType/Word are left
blank (nothing derived from OU names); ApplicationGroups is the de-duped dump of current
AD group names and EmailGroups the same for Google group names; users disabled/suspended
in every directory they exist in get yesterday's date as TerminationDate (mixed state
โ Warn, treated active). PersonID = AD EmployeeID, falling back to the Google externalId
(mismatch โ Warn, AD wins); an optional -PersonIDCsv (CSV with ID and Username columns,
e.g. a SIS export, matched case-insensitively on Username) fills in PersonID when the
directories carry none โ a directory ID wins a CSV mismatch (โ Warn). Also writes a
GroupsSeed-<yyyy-MM-dd> tab listing the distinct
group names in use โ Google groups under Email in column A, AD groups under Application
in column C โ as the source range for multi-select group dropdowns on the staff sheet.
Requires Initialize-IDBridge + Connect-IDBridgeGoogle first. Returns:
@{ SpreadsheetId; SheetName; GroupsSheetName; RowsWritten }.
Onboarding tool: interactive console review of source/directory name mismatches, run by hand โ
never called by the pipeline. Params: -RootPath (def C:\IDBridge), -SkipAD,
-SkipGoogle. Gathers the same source and directory data the pipeline would
(Initialize-IDBridge โ the shared Get-IDBridgePipelineData gather phase), finds every
unlinked source user whose username is taken by an account with a different name, and walks
them one at a time showing both names side by side ([A]pprove / [S]kip / [Q]uit). Approving
records the decision to <DataRoot>\ApprovedNameMismatches.csv (PersonID, Directory, Account,
SourceName, DirectoryName, ApprovedDate) โ no directory writes happen here; the next
Invoke-IDBridge run links the approved account via the Get-*UsersToSetEmployeeID functions
and the normal update pass then sets the EmployeeID and renames the account to the source
(SIS) name under all the usual gates (ReadOnly, ChangeThreshold). Each approval saves as it is
made (quitting loses nothing); AD and Google are approved independently; an approval is honored
only while the account's username and directory name still match what was approved โ a drifted
account shows up for re-approval (replacing its old row). Warns when Debug.testRun is on
(capped source data โ incomplete mismatch set). Returns:
@{ Reviewed; Approved; Skipped; FilePath }.
No params. Loads <DataRoot>\ApprovedNameMismatches.csv (the decisions recorded by
Approve-IDBridgeNameMismatch). Returns: hashtable "<Directory>|<PersonID>" โ approval
row; empty when the file doesn't exist. Consumed by the two Get-*UsersToSetEmployeeID
functions.
Operations tool: Windows Forms GUI to bulk-reset AD passwords to their deterministic Keysmith
passphrases, run by hand โ never called by the pipeline. Params: -RootPath (def
C:\IDBridge). Shows the domain's OU tree with checkboxes (checking an OU checks everything
under it), the Keysmith settings (nonce and API token by vault secret name โ defaulting to the
shipped-plugin names ApiKey-PassphraseNonceStudent/Staff and ApiKey-Passphrase, editable,
with masked manual entry as a fallback โ plus mode, word count, and an optional word-list rev;
blank rev = server's latest), and run options (include sub-OUs, enabled accounts only,
ChangePasswordAtLogon, passphrase-CSV export โ off by default). "Load Users" lists the matching
accounts for review; "Reset Passwords" confirms with the exact count, fetches every passphrase
up front (Get-ADUsersToResetPassword โ any Keysmith failure aborts before a single write),
then applies Set-ADAccountPassword -Reset per account. "Export Passphrases" instead writes
the loaded users' regenerated passphrases as a name/username/passphrase/OU CSV to Exports
without touching AD (matches current passwords only when nonce/mode/word count/rev match
what they were last set with โ pin the rev for older accounts). Writes to AD when confirmed
regardless of Debug.readOnly โ interactive, gated by its own confirmation dialog.
Passphrases are never logged; the optional CSV lands in Exports. Requires Windows, an STA
session, and the ActiveDirectory module. Returns: @{ Total; Succeeded; Failed; ExportPath } for the last run.
For a deployment with no directory data to seed from, start from the published source-sheet template (copy link: https://docs.google.com/spreadsheets/d/1OUlm-5WGce_x2z0L1dM2kD8Ejk_f3RNF3EC8sa6uHhE/copy) instead โ it ships the source and override tabs pre-built with tables, Process checkboxes, a TerminationDate date column, a Groups reference tab, and multi-select group dropdowns. Point the source plugin's sheet range at the copied spreadsheet.
Params: -Name (mandatory), -AsPlainText. Reads a secret's envelope file
(<Name>.secret.json) from the vault folder (Paths.VaultRoot) and decrypts it with the
provider the envelope records (Cms via Unprotect-CmsMessage, DpapiNG via the built-in
DPAPI-NG wrapper); when Secrets.Provider is AzKeyVault, fetches from Azure Key Vault over
REST instead. Throws if the secret is missing (pointing at Set-IDBridgeSecret) or can't be
decrypted/read (naming the required key/principal/role). Returns: [SecureString] (or
[string] with -AsPlainText). See secrets.md.
Params: -Name (mandatory), -Secret (optional [SecureString]; prompts when omitted),
-InFile (optional; store a file's raw content, e.g. the Google service-account JSON).
Encrypts with the Secrets.Provider from config (Cms default; DpapiNG uses
Secrets.DpapiNG.ProtectionDescriptor, else current-user SID with a Warn) and writes the
envelope file to Paths.VaultRoot; when the provider is AzKeyVault, PUTs the value to the
Key Vault under the same name instead. Re-running with the same -Name overwrites.
Returns: nothing. See secrets.md.
Params: -Subject (def 'CN=IDBridge Secrets'), -StoreLocation {LocalMachine|CurrentUser}
(def LocalMachine, needs elevation), -ValidityYears (def 10), -GrantRead (optional account,
e.g. the gMSA, granted private-key read via Grant-IDBridgeCertificatePrivateKeyAccess; machine
store only). Creates the self-signed Document Encryption certificate the Cms provider needs
(non-exportable RSA 3072). Returns: the certificate; put its thumbprint in
Secrets.Cms.Thumbprint.
Params: -Thumbprint (mandatory; certificate in Cert:\LocalMachine\My), -Identity
(mandatory account, e.g. 'DOMAIN\gMSA-IDBridge$'). Adds a read ACE for the account on the
certificate's private-key file (under %ProgramData%\Microsoft\Crypto) โ what an account needs
to decrypt Cms secrets or authenticate to Azure Key Vault with the cert. Same grant
New-IDBridgeSecretCertificate -GrantRead does at creation time, for a certificate that
already exists. Machine store only; needs elevation. Returns: nothing.
No params. Lists every vault envelope as Name / Provider / ProtectedTo / Created / Path โ
never values. With the AzKeyVault provider, lists the Key Vault's secrets instead (paged
via nextLink; includes everything the app registration can see in that vault).
Params: -Name (mandatory). Deletes the secret's envelope file โ or, with the
AzKeyVault provider, DELETEs it from the Key Vault. Throws if absent.
No params. Resolves the Document Encryption certificate the Cms provider encrypts with:
Secrets.Cms.Thumbprint when configured, else exactly one unexpired CN=IDBridge Secrets
certificate in the LocalMachine/CurrentUser My stores. Throws pointing at
New-IDBridgeSecretCertificate (none found) or at configuring the thumbprint (ambiguous
match). Returns: the certificate.
No params. Compiles the [IDBridge.DpapiNG] P/Invoke wrapper over ncrypt.dll
(NCryptCreateProtectionDescriptor/NCryptProtectSecret/NCryptUnprotectSecret) used by
the DpapiNG provider โ once per session; later calls are no-ops. No return.
No params. Validates the Secrets.AzKeyVault config block and returns
@{ VaultUri; ApiVersion; Headers } for the Key Vault REST calls. The bearer token (from
Get-IDBridgeAzureAuthToken) is cached script-scoped for the session and refreshed 5
minutes before expiry.
Params: -ClientId, -TenantId, -CertThumbprint, -Scope (all mandatory). OAuth2
client-credentials flow with a certificate credential: builds a signed JWT client
assertion (RS256, x5t from the cert hash) and exchanges it at the tenant's v2.0 token
endpoint. Returns: the token response (access token + expiry).
Discovers/runs plugins from $IDConfig.Plugins (skipping Type = 'PostRun' entries โ
those run via Invoke-PostRunPlugins at end of run). For each enabled entry: verifies
<PluginsRoot>\<Function>.ps1 exists, dot-sources it, confirms the function via
Get-Command, invokes with no args. Source results are passed through
Test-IDBridgeSourceData before collection; when Debug.testRun is set, each source
plugin's validated output is capped at the first 10 records. Returns: @{ SourceData; OverrideData }
(split by each plugin's Type). Throws if no source data gathered. See plugins.md.
Canonical factory for a source record โ the shape plugins must emit. Params: typed,
Mandatory core fields (PersonID, NameFirst, NameLast, Username, UPN, Building,
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, 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.
Params: -InputObject (records, null/empty ok), -PluginName. Validates each record
(safety-net PersonID/IDBActive; cross-field: if ProvisionAD โ target + trash OUs +
ADKey-or-ADPassphraseAPI, same for Google*); drops failures with a Warn (plugin + reasons),
keeps the rest. Returns: the valid records as an array. Called per source plugin inside
Invoke-SourcePlugins.
Params: -sheetID, -sheetRange, -userCount, -userCountSafetyPercentage (def 75).
Reads a sheet via Get-GoogleSheetData, validates required columns, enforces a count
safety floor (counting only PersonID-populated rows), returns rows where Process='TRUE'
that carry every required value (only TerminationDate may be blank; incomplete rows are
dropped with a log line). Returns: array of row objects.
Params: -ClientId, -ClientSecret, -TokenUrl, -BaseUrl, -ExcludeEntityIDs,
-SafetyCheckCount, -SafetyCheckPercentage. OAuth2 client-credentials โ OneRoster
/schools/{id}/students (paginated). Dedupes by NameID, enriches with school names +
LastSeen, merges prior-run state CSV in DataRoot. Returns: student records.
Params: -ClientId, -ClientSecret, -TokenUrl, -BaseUrl,
-ExcludeSchoolIdentifiers (optional), -SafetyCheckCount, -SafetyCheckPercentage.
OAuth2 โ OneRoster /schools + /students (paginated). Normalizes to SourcedId/LocalID/ InternalID/NameFirst/NameLast/Email/Role/SchoolName/Grade/Status/..., drops excluded
schools, enforces the count safety floor, stamps LastSeen, merges prior-run state CSV
in DataRoot (keyed by SourcedId). Returns: student records.
Params: -SourceData, -OverrideData. Applies override rows by personID: non-empty
scalar values overwrite; AddGroup/RemoveGroup mutate GroupsProposed; PersonID and
null/blank values skipped. Returns: mutated source array.
No params. Pulls all AD users (rich property set incl. EmployeeID, MemberOf,
extensionAttribute1-5), groups (minus AD.groupsExcluded name patterns โ excluded groups
are invisible to all group processing), OUs; resolves MemberOfโnames into CurrentGroups;
detects duplicate EmployeeIDs; builds LookupByID keyed by EmployeeID. Returns:
@{ Users; Groups; OrgUnits; DuplicateUsers; LookupByID }.
No params. Pulls all Google users (via Get-GoogleData), groups (minus
Google.groupsExcluded email patterns, default classroom_teachers@* โ excluded groups
are invisible to all group processing), and OUs; fetches group members in parallel (throttle 10) into
CurrentGroups; enumerates license assignments per Google.licenseProductIds product into
CurrentLicenses (skipped when enableLicenseRemoval = $false); detects duplicate
externalIds; builds LookupByID keyed by externalID.
Returns: @{ Users; Groups; OrgUnits; DuplicateUsers; LookupByID }.
Params: -SourceData, -ADData. For each source record, if LookupByID[personID]
hits, attaches ADObject, ADCurrentUserID, ADCurrentUserEnabledStatus,
ADCurrentGroups; flags ADDuplicateIDStatus when applicable. Returns: enriched array.
Params: -SourceData, -GoogleData. As above for Google: GoogleObject,
GoogleCurrentUserID, GoogleCurrentUserSuspendedStatus (true when suspended or archived;
$null when there is no Google account), GoogleCurrentGroups,
GoogleCurrentLicenses, GoogleDuplicateIDStatus. Returns: enriched array.
Identity key:
EmployeeID=personID. CN convention:FirstName LastName personID. Per-user targeting:ProvisionAD(withIDBActive) gates these โ create/update/groups requireIDBActive=true AND ProvisionAD=true; deactivate fires onIDBActive=false OR ProvisionAD=false. So a Google-only user (ProvisionAD=false) is never created in AD, and an existing AD account is deactivated. SettingIDBActive=falsealone deactivates everywhere.
Params: -UserList, -CurrentADUsers. For any unlinked source user (active or not),
matches an existing AD user by SamAccountName and name โ so deprovisioned users get linked
and can then be deactivated. A name mismatch is an error and skipped, unless approved via
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). 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).
Params: -UserList, -CurrentOrgUnits. Collects needed OU DNs (+trash) for active
(IDBActive=true) users, expands ancestors, removes existing, sorts parents-first.
Returns: ordered OU DN array.
Params: -UserList, -CurrentADUsers, -Nonce (unused โ passphrase nonces come from
each record's ADPassphraseAPI). Predicate: IDBActive=true AND
ProvisionAD=true AND no ADCurrentUserID AND UPN absent from AD. Builds a New-ADUser splat
(password from ADKey or ADPassphraseAPIโNew-Passphrase). Returns: @{ PersonID; Splat }[].
Params: -UserList, -LookupByID, -CurrentADUsers. Predicate: IDBActive=true AND
ProvisionAD=true AND has ADCurrentUserID + any delta (name, username โ the UPN is written
only as part of a username change, never on its own โ EmployeeID, EmployeeNumber/InternalID,
office/title/company/dept, description/phone/email, enabled state, employeeType/ext-attr,
passwordNeverExpires, CN, OU). On a username change, a new username/UPN already held by a
different account in the CurrentADUsers snapshot is logged as an error and the user is
skipped that run (mirrors
Get-GoogleUsersToUpdate's primaryEmail collision check). Name comparisons are
case-sensitive (-cne) so a casing fix from the plugin (e.g. ALL-CAPSโTitle-Case) is
applied. Returns: @{ UpdateList; RenameList; MoveList } (items carry CN +
PersonID for write-result attribution).
Params: -UserList. Predicate: (IDBActive=false OR ProvisionAD=false) AND
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.
Params: -UserList (AD user objects: SamAccountName + DistinguishedName), -PassphraseAPI
(hashtable โ Nonce/AuthToken SecureStrings, Mode, WordCount, optional Rev; the ADPassphraseAPI
shape, forwarded to New-Passphrase), -ExportOnly (logs the proposed lines as exports, not
resets; output identical). Decide phase for Reset-IDBridgeADPassword: fetches a
deterministic passphrase per SamAccountName (one batched call per 500 users, the Keysmith
per-request max; phrases pair with users by position) and builds a Set-ADAccountPassword -Reset
splat each. An API failure or returned-count mismatch throws โ no partial reset list. The
plaintext phrase rides on each item for the caller's optional export and is never logged.
Returns: @{ SamAccountName; GivenName; Surname; DistinguishedName; Passphrase; Splat }[].
Params: -UserList, -CurrentADGroups. Diffs GroupsProposed vs ADCurrentGroups
(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}).
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].
Params: -OrgUnit (DN). Parses DN โ New-ADOrganizationalUnit. Throws on failure โ
Invoke-IDBridge treats a failed OU creation as fatal and aborts the run.
Params: -User, -GroupRemovalProcessingStatus. Disables account, stamps Division
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.
Disable/move failures return the ErrorRecord (the caller records the Deactivate result).
Identity key:
externalIds(typeorganization).value =personID. All write functions get auth viaGet-GoogleHeadersand hit the Admin SDK Directory API basehttps://admin.googleapis.com/admin/directory/v1/(exceptions:Remove-IDBridgeGoogleUserLicensecalls the Licensing API,Push-LogsToSheetthe Sheets API). Per-user targeting mirrors AD viaProvisionGoogle: create/update/groups needIDBActive=true AND ProvisionGoogle=true; deactivate fires onIDBActive=false OR ProvisionGoogle=false.
Params: -GoogleHeaders, -APIUri. Generic paginated GET (follows nextPageToken,
appended with ?/& as the URI requires), consolidates the primary collection.
Returns: combined array.
Params: -ServiceAccountKeyPath or -ServiceAccountKeyJson, -Scope. Builds +
RS256-signs a JWT issued to the service account itself (no sub claim / impersonation),
exchanges it at https://oauth2.googleapis.com/token. Returns:
@{ Authorization='Bearer โฆ'; Accept='application/json' }.
No params. Returns the space-separated OAuth scope string for the token request โ Admin
SDK directory user/orgunit/group + Sheets, plus apps.licensing unless
Google.enableLicenseRemoval = $false. The scope list is a property of the module's code
(which APIs it calls), not site configuration.
Params: -UserList, -GoogleUsers. Matches any unlinked source user (active or not)
to existing Google users by primaryEmail+name. A name mismatch is an error and skipped, unless
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). 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 }.
Params: -UserList, -CurrentOrgUnits. Collects needed OU paths (+trash) for
active (IDBActive=true) users, expands ancestors (/A/B/Cโ/A,/A/B,/A/B/C),
removes existing, sorts so parents precede children (lexicographic โ a parent path
prefixes its children). Returns: ordered OU path array.
Params: -UserList, -GoogleUsers. Predicate: IDBActive=true AND
ProvisionGoogle=true AND no GoogleCurrentUserID AND UPN absent from Google. Builds create
splat (password from GooglePassphraseAPIโNew-Passphrase or GoogleKey; skips if neither;
honors GoogleChangePasswordAtLogon). Returns: @{ UPN; PersonID; Splat }[].
Params: -UserList, -LookupByID, -GoogleUsers. Predicate: IDBActive=true AND
ProvisionGoogle=true AND linked + delta in primaryEmail (with alias-conflict handling โ
RemoveAlias), externalId, name, dept/title, suspended/archived state (archived rehires are
unarchived; ForceDisable suspends โ the temporary block, never an archive), or OU
(unless GoogleOUOverride). Returns: @{ UPN; PersonID; Splat }[] only for changed users.
Alias removal on renames โ exact scope.
RemoveAliasis set only when ALL of these hold: the user is being renamed (desired UPN โ current primaryEmail), the new UPN is not anyone's primary email (that case logs an error and skips the user's entire update for the run, not just the rename), and the new UPN is currently on some user as an alias. At execute time exactly that one alias is deleted from its holder (refused if it's actually their primary) so the rename PUT doesn't 409 โ no other aliases on anyone are ever touched. The conflict is logged as a warning at decide time, so ReadOnly runs show which alias would be stripped from whom. Note Google automatically keeps the old primary email as an alias on a renamed account; IDBridge does not clean those up, and they are the usual source of these conflicts (e.g. a name-change rename freeing an address a later hire should get). The alias holder can be the renamed user themselves (renaming back to an address Google kept as their own alias) โ that works.
Params: -UserList. Predicate: (IDBActive=false OR ProvisionGoogle=false) AND
GoogleCurrentUserSuspendedStatus=false (that property is true when the account is suspended
or archived, so pre-archive suspends are grandfathered). The deactivate step archives
the account (base Education license self-releases). Logs the paid licenses the step will remove
(from GoogleCurrentLicenses) โ visible in ReadOnly runs. Also logs when the deactivate step
will set the personID externalId: accounts matched by UPN+name have no externalId yet, and the
update list only covers active users, so the deactivate write persists the link (otherwise the
account would be re-matched every run). Returns: user objects.
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). Users with
ProcessGroupsGoogle = $false are skipped entirely โ no adds and no removes; the skipped count
is logged at Trace. Returns: @{ Add; Remove }.
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].
Params: -UserList, -GoogleUsers, -TrashOU. Finds Google users whose ID isn't in
source. Returns: @{ GoogleCurrentUserID; GoogleOrganizationalUnitTrash; Groups }[].
(Not currently called by Invoke-IDBridge; available for orphan cleanup.)
Params: -PrimaryEmail, -PersonID, -FirstName, -LastName, -Building,
-JobTitle, -OrgUnitPath, -Password (SecureString), -ChangeAtNextLogin,
-AsBatchRequest. POST /users/. Returns: API user object (has .ID), or with
-AsBatchRequest the request descriptor for Invoke-GoogleBatchRequest (ContentId =
primaryEmail, so the new ID can be matched back from the batch response).
Params: -GoogleUserID + any of -PrimaryEmail -Suspended -Archived -PersonID -FirstName -LastName -Building -JobTitle -OrgUnitPath -Password -ChangeAtNextLogin -RemoveAlias -AsBatchRequest. PUT /users/{id} with only changed fields; RemoveAlias does a lookup +
DELETE on the alias (always immediately, even with -AsBatchRequest). With
-AsBatchRequest returns the request descriptor for Invoke-GoogleBatchRequest instead of
calling the API. Used for updates, moves, renames, and archive-to-trash deactivations
(-Suspended remains for the temporary ForceDisable block).
Params: -Requests (@{ Method; Path; Body; ContentId }[]), -BatchUri (def the
Directory API batch endpoint). Sends the requests as multipart/mixed batch POSTs, 50 per
round trip, and parses the multipart response manually. Per-item failures are logged and
returned, never thrown. Google doesn't guarantee execution order inside a batch, so callers
must not mix order-dependent calls (Invoke-IDBridge sends deactivates, updates, and
creates as three sequential batches). Batching cuts round trips, not quota. Returns:
@{ ContentId; StatusCode; Body }[].
Params: -OrgUnit (path). POST
/customer/<Google.customerID>/orgunits with name + parentOrgUnitPath (the real customer
ID from config โ my_customer doesn't resolve for the service account's own token).
Logs then throws on failure โ Invoke-IDBridge treats a failed OU creation as fatal.
Params: -GroupEmail, -PersonID, -UpdateType {Add|Remove}, -AsBatchRequest,
-ContentId (def <PersonID>|<GroupEmail>). Add โ POST /groups/{email}/members (role
MEMBER); Remove โ DELETE /groups/{email}/members/{id}. With -AsBatchRequest returns a
descriptor for Invoke-GoogleBatchRequest instead of calling the API โ Invoke-IDBridge
sends all membership adds, removes, and deactivate group-strips as three separate batches
(order inside one batch isn't guaranteed, so change types don't share a batch).
No params. Reads the GoogleAuth-ServiceAccount vault secret, validates the private_key,
exchanges a JWT issued to the service account itself (no impersonation โ authorization
comes from the SA's IDBridge Workspace admin role) via Get-GoogleApiAccessToken, and
sets $script:GoogleHeaders plus the script-scoped SA email and GCP project ID
(Get-IDBridgeGoogleServiceAccountEmail / Get-IDBridgeGoogleProjectId). Called by
Invoke-IDBridge at run start (when
GoogleToken.Enabled); run it standalone to verify the auth chain after seeding the key
or assigning the role (a later API 403 โ role missing/not yet propagated; Sheets 403
โ sheet not shared with the SA). Throws on any failure.
No params. Returns the service account's email (client_email). Uses the value stashed by
Connect-IDBridgeGoogle; before a connect it falls back to parsing the
GoogleAuth-ServiceAccount vault secret (initialized session required, no token needed) โ
handy to know which address to share a sheet with.
No params. Returns the service account's GCP project ID (project_id). Same sourcing as
Get-IDBridgeGoogleServiceAccountEmail: stashed by Connect-IDBridgeGoogle, with a
pre-connect fallback to parsing the GoogleAuth-ServiceAccount vault secret โ handy to
find the install's Cloud project or to re-run the bootstrap with -ProjectId.
Params: -ProjectId (default idbridge-<random> with -CreateProject), -ProjectName
(def 'IDBridge'), -CreateProject, -ServiceAccountName (def 'idbridge'),
-AccessToken (SecureString, skips the interactive tiers). One-command Google-side
bootstrap run in the district's tenant: tiered sign-in (token โ gcloud โ OAuth Playground)
โ org discovery (orchestrates the first-console-visit terms acceptance) โ project โ enable
APIs โ service account โ key seeded straight into the vault (never on disk), with a
self-grant/exempt/revoke org-policy dance only if key creation is blocked โ creates the
IDBridge custom admin role (privileges resolved from privileges.list) and assigns it
to the SA (prompting for an admin.directory.rolemanagement-scoped token if the bootstrap
token lacks it). Prints the share-the-sheets checklist. Returns:
@{ ProjectId; ServiceAccountEmail; ClientId; RoleId }. See
google-bootstrap.md.
Params: -UserEmail (the Licensing API user key), -Assignments (the user's license
assignments from the target snapshot, GoogleCurrentLicenses; empty = no-op). One DELETE
per assignment, logging every removal by SKU name; per-assignment errors are logged and
don't stop the rest. Called by Invoke-IDBridge on the full deactivate (trash) step (on when the config key is
absent; the shipped config template sets enableLicenseRemoval = $false) โ never on
ForceDisable updates.
License discovery happens in Get-TargetDataGoogle, so ReadOnly runs show what would be
removed.
Params: -spreadsheetId, -sheetName. Pulls Get-IDBridgeLogs, creates the sheet if
missing, then one atomic batchUpdate: header (new sheets), insert rows, write newest-first,
and trim the bottom past 50,000 rows (keeps the newest 25,000).
Params: -GoogleSheetID, -GoogleSheetRange. GET Sheets v4 values (majorDimension= ROWS); first row = headers; trims strings. Returns: array of row objects.
Params: -TokenInformation, -sheetName, -spreadSheetID, -values, plus parameter
sets -append (POST :append) or -rangeA1 (PUT โฆ!A1), -valueInputOption (def RAW),
-contenttype. Writes rows.
Params: -spreadSheetID, -sheetName, -TokenInformation. Returns: numeric
sheetId (throws if not found).
Params: -ColumnNumber (0-based). Returns: Excel column letter(s) (0โA, 25โZ, 26โAA).
Params: -cell (e.g. B2). Returns: @{ row; column } (0-based).
Params: -cells, -spreadSheetID, -sheetName, -TokenInformation. batchUpdate
repeatCell setting each cell's bool to false. Uses Get-SheetIdByName +
Convert-CellToIndex.