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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ 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.9.21.0] - 2026-09-21

### Added
- **`Data\LastRun.json` — an end-of-run summary file a reader on the box can consume.**
Written by `Write-IDBridgeRunSummary` from the `finally` block of `Invoke-IDBridge`, after
telemetry and before the PostRun plugins, on every run that is not a `-Preview`, replacing
the file whole. Versioned (`schemaVersion` 1) and grown additively only: 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, so a reader can
tell a sync that just broke from one that has been broken for a week. A failed run
contributes the exception class and throwing function name only, never the message; no
name, id, UPN, DN or group name is ever written (PRIVACY.md names the file and its keys).
Self-contained like telemetry: a write failure logs a `Warn` and never affects the run.

### Removed
- **`Invoke-PluginPostRunBeacon` — the Beacon heartbeat PostRun plugin template**, its
descriptor in the config template and its tests. Beacon reads IDBridge from the box now:
its collector takes the `IDBridge Sync` scheduled task and the `Data\LastRun.json` summary
above, so no plugin, no ingest URL and no site key are needed. A district that copied the
template can delete `Plugins\Invoke-PluginPostRunBeacon.ps1` and its descriptor from the
config, and remove the `ApiKey-Beacon` vault secret if one was set. The config template
moves to `TemplateVersion: 3`.

## [26.9.9.0] - 2026-09-09

### Added
Expand Down
20 changes: 20 additions & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ Telemetry is fire-and-forget with a 10-second timeout and no retries: an unreach
endpoint (blocked egress, air-gapped network) is logged locally and never delays or
fails a sync.

## Not telemetry: the run summary file

At the end of every run that is not a `-Preview`, IDBridge writes
`<RootPath>\Data\LastRun.json` — a small local file describing how the run went, replaced
whole each time. It holds exactly these keys and nothing else: `schemaVersion`,
`moduleVersion`, `runStart`, `runEnd`, `durationSeconds`, `success`, `readOnly`, `testRun`,
`directories`, `managed`, `created`, `updated`, `deactivated`, `groupAdds`, `groupRemoves`,
`writeFailures`, `thresholdExceeded`, `errorType`, `errorFunction`, and `lastSuccessAt`.
That is counts, mode flags, timing and the module version; on a failed run the exception
**class** name and the throwing **function** name — never the exception message, which can
contain UPNs/DNs. No name, username, email address, person ID, distinguished name or group
name is ever written to it.

IDBridge never sends this file anywhere: it is written to your own `Data` directory and
read by whatever you point at it. Where a district runs [Beacon](https://github.com/SCNetworksLLC/Beacon),
SC Networks' status dashboard, Beacon's collector on the same box reads it there — that
collector, not IDBridge, is what leaves the machine. The file is written regardless of the
telemetry tier (it isn't telemetry and sends nothing to SC Networks); `Preview` runs never
write it, and a write failure logs a `Warn` and never affects the run.

## Not telemetry: the update check

Separately from telemetry, `Invoke-IDBridge` queries the **PowerShell Gallery**
Expand Down
8 changes: 6 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,17 @@ Invoke-IDBridge
13. Send-IDBridgeTelemetry (unless Telemetry.Tier = 'Off') → one anonymous usage event
(self-contained try/catch, 10s timeout, no retries — can never affect the run;
counts are APPLIED work so ReadOnly runs report zeros; see PRIVACY.md)
13b. Write-IDBridgeRunSummary → Data\LastRun.json, the end-of-run summary a reader on the
box consumes (counts, mode flags, timing, lastSuccessAt carried forward across
failures, and on a failure the error class/function only — never the message;
self-contained try/catch — can never affect the run; see PRIVACY.md)
14. Invoke-PostRunPlugins → runs Type = 'PostRun' plugins with the RunResult
(SecureStrings scrubbed first; per-plugin try/catch — can never affect the run;
fires on failed and ReadOnly runs too; user list CSV exports live here in
Invoke-PluginPostRunExport; see plugins.md)
15. Push-LogsToSheet (if Logging.GoogleSheetLoggingEnabled) → writes $script:Logs to sheet
Preview runs stay quiet: steps 13-15 are all skipped under -Preview (the file log
still writes).
Preview runs stay quiet: steps 13-15 (13b included) are all skipped under -Preview
(the file log still writes).
```

## Data-object lifecycle
Expand Down
5 changes: 2 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ unrecognized `Tier` value fails safe to `Off`. `-DisableTelemetry` silences a si
Array of plugin descriptors. `Source`/`Override` entries are executed in order by
`Invoke-SourcePlugins` at the start of the run; `PostRun` entries by `Invoke-PostRunPlugins`
at the end of the run (in the `finally` block, after telemetry — they fire on failed and
ReadOnly runs too). The shipped config template lists all nine descriptors with
ReadOnly runs too). The shipped config template lists all eight descriptors with
`Enabled = $false`; a configured site looks like:

```powershell
Expand All @@ -124,7 +124,6 @@ ReadOnly runs too). The shipped config template lists all nine descriptors with
@{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunWebhook' }
@{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunExport' }
@{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunOrphanReport' }
@{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunBeacon' }
```

| Key | Effect |
Expand Down Expand Up @@ -212,7 +211,7 @@ Derived from `-RootPath` (default `C:\IDBridge`); missing directories are create
| `LogsRoot` | `<Root>\Logs` | `IDBridge.log` (rotated at 5 MB) |
| `ExportsRoot` | `<Root>\Exports` | Run reports and `UserList-<PersonType>.csv` exports |
| `PluginsRoot` | `<Root>\Plugins` | Plugin `.ps1` files |
| `DataRoot` | `<Root>\Data` | Plugin state (e.g. Skyward / Infinite Campus `LastSeen` CSVs) and name-mismatch approvals (`ApprovedNameMismatches.csv`) |
| `DataRoot` | `<Root>\Data` | Plugin state (e.g. Skyward / Infinite Campus `LastSeen` CSVs), name-mismatch approvals (`ApprovedNameMismatches.csv`) and `LastRun.json` (the end-of-run summary) |
| `VaultRoot` | `<Root>\Vault` | Secret vault (`*.secret.json` envelope files) |
| `LogFile` | `<LogsRoot>\IDBridge.log` | Active log file |

Expand Down
13 changes: 13 additions & 0 deletions docs/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,19 @@ the run's per-write results), `-RunError` (ErrorRecord — Enhanced tier extract
(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.

### `Write-IDBridgeRunSummary` 🔒
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](../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.

### `Invoke-PostRunPlugins` 🔒 🌐
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
Expand Down
7 changes: 4 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,13 @@ Flip one brake at a time, with a read-only-style review (`-ReadOnly` or `-Previe
OneRoster plugins in
[plugins.md](plugins.md#invoke-pluginskywardsmsstudents--source-disabled-in-config) are
worked examples).
- **Run reports, CSV exports, alerting:** the five PostRun templates consume each run's
- **Run reports, CSV exports, alerting:** the four PostRun templates consume each run's
results — `Invoke-PluginPostRunReport` (JSON run summary), `Invoke-PluginPostRunExport`
(user-list CSVs), and `Invoke-PluginPostRunOrphanReport` (enabled accounts no source
feeds anymore) work as-is once their descriptors are enabled;
`Invoke-PluginPostRunWebhook` POSTs a compact summary to your own endpoint, and
`Invoke-PluginPostRunBeacon` sends a heartbeat to SC Networks' Beacon dashboard.
`Invoke-PluginPostRunWebhook` POSTs a compact summary to your own endpoint.
See [plugins.md](plugins.md#the-postrun-contract-invoke-postrunplugins).
SC Networks' Beacon dashboard needs no plugin — its collector reads each run from the
box, out of the `Data\LastRun.json` summary every run writes.
- **Run-history dashboard:** `Telemetry.Tier = 'Enhanced'`, then claim your SiteID
(`Get-IDBridgeSiteID`) at [IDBridge Pulse](https://pulse.scnlabs.net).
17 changes: 0 additions & 17 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,23 +288,6 @@ throws until the placeholder URL is edited. Fire-and-forget: 10 s timeout, send
a `Warn` and never affect the run. The starting point for shipping your own telemetry or
alerting ("tell me when the run breaks").

### `Invoke-PluginPostRunBeacon` — PostRun *(disabled in config)*
File: `C:\IDBridge\Plugins\Invoke-PluginPostRunBeacon.ps1`. POSTs one heartbeat envelope to
[Beacon](https://github.com/SCNetworksLLC/Beacon), SC Networks' status dashboard, after every
run — failed and ReadOnly runs included: `sourceType` `automation`, `sourceId`
`idbridge-sync`, and a `data` block of `result` (`Success`; `Warning` when a write did not
stick; `Failed` when the run threw), a one-line `message` built from the counts
("412 managed: 3 created, 5 updated, 1 deactivated, 2 group changes, 0 write failures, 47s",
prefixed `ReadOnly:` or `test run:` in those modes), and the counts, flags, duration and
module version themselves. No per-user data. Beacon's staleness model does the rest: an
automation source is expected every ~12 hours, so a nightly sync that stops reporting goes
amber on its own and a `Failed` result goes red at once. Edit the ingest URL and the
district's Beacon site id in the file (it throws until you do), store the site key Beacon
minted for this automation with `Set-IDBridgeSecret -Name 'ApiKey-Beacon'`, then enable the
descriptor. Fire-and-forget: 30 s timeout, a rejection or a send failure logs a `Warn` and
never affects the run. The envelope builder (`ConvertTo-BeaconHeartbeat`, a helper in the
same file) is pure and tested under `tests\Templates\Plugins`.

### `Invoke-PluginPostRunExport` — PostRun *(works as-is)*
File: `C:\IDBridge\Plugins\Invoke-PluginPostRunExport.ps1`. Writes the user list CSVs
defined in its `$exportFiles` map (file name → the `PersonTypeID`s it carries; one file
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.9.0'
ModuleVersion = '26.9.21.0'
GUID = 'a0a0c664-888e-44e2-9c12-fc8647a520c0'
Author = 'Sam Cattanach'
CompanyName = 'SC Networks LLC'
Expand Down
126 changes: 126 additions & 0 deletions src/IDBridge/Private/Core/Write-IDBridgeRunSummary.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<#
.SYNOPSIS
Write the end-of-run summary file (Data\LastRun.json) a reader on the box can consume.

.DESCRIPTION
Called from the finally block of Invoke-IDBridge after telemetry and before the PostRun
plugins, on every run that is not a Preview. Replaces <DataRoot>\LastRun.json whole with
the run's counts, mode flags, timing and module version, so a monitor on the same box can
see how the last sync went without reading the log.

The schema is versioned (schemaVersion 1) and grows additively only - a reader typed to
this schema refuses a key it does not know. Counts are APPLIED work, so a ReadOnly run
writes zeros alongside readOnly = true. lastSuccessAt is runEnd on a successful run and
CARRIED FORWARD from the previous file on a failed one (empty when there has never been
one) - the one field a single run cannot supply. A failed run contributes only the
exception CLASS name and the throwing FUNCTION name, never the message, which can contain
UPNs/DNs; no name, id, UPN, DN or group name is ever written.

The reader is SC Networks' Beacon collector on the same box where a district runs Beacon;
nothing here leaves the machine by IDBridge's doing (see PRIVACY.md). The write is
self-contained: a failure logs a Warn and never affects the run.

.PARAMETER RunResult
The run report built in the finally block of Invoke-IDBridge.

.OUTPUTS
None. Side effects: one file write and log output.

.EXAMPLE
Write-IDBridgeRunSummary -RunResult $runResult

.NOTES
Created by: Sam Cattanach
Modified: 2026-09-21
#>
function Write-IDBridgeRunSummary {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[pscustomobject]$RunResult
)

$IDConfig = Get-IDBridgeConfig

$path = "$($IDConfig.Paths.DataRoot)\LastRun.json"

#region Carry Forward
# A failed run has no success time of its own, so the previous file's survives it - a
# reader can tell a sync that just broke from one that has been broken for a week. No
# previous file, or one that won't parse, means no known success.
$previousSuccessAt = ''
if (Test-Path $path) {
try {
$previous = Get-Content -Path $path -Raw | ConvertFrom-Json
# ConvertFrom-Json turns the ISO-8601 stamp back into a [datetime]; it goes out
# again the way it was written.
$previousSuccessAt = if ($previous.lastSuccessAt -is [datetime]) {
$previous.lastSuccessAt.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
}
else { "$($previous.lastSuccessAt)" }
}
catch {
Write-Log -Message "Run summary: Previous $path unreadable ($($_.Exception.GetType().Name)) - no carried-forward success time." -Level Trace
}
}
#endregion Carry Forward

#region Build Payload
$directories = @(
if ($IDConfig.AD.enabled -eq $true) { 'AD' }
if ($IDConfig.Google.enabled -eq $true) { 'Google' }
) -join '+'
if (-not $directories) { $directories = 'None' }

$success = [bool]$RunResult.Success
$runEnd = $RunResult.RunEnd.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")

$payload = [ordered]@{
schemaVersion = 1
moduleVersion = "$($MyInvocation.MyCommand.Module.Version)"
runStart = $RunResult.RunStart.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
runEnd = $runEnd
durationSeconds = [int]$RunResult.DurationSeconds
success = $success
readOnly = [bool]$RunResult.ReadOnly
testRun = [bool]$RunResult.TestRun
directories = $directories
managed = [int]$RunResult.Counts.Managed
created = [int]$RunResult.Counts.Create
updated = [int]$RunResult.Counts.Update
deactivated = [int]$RunResult.Counts.Deactivate
groupAdds = [int]$RunResult.Counts.GroupAdd
groupRemoves = [int]$RunResult.Counts.GroupRemove
writeFailures = [int]$RunResult.Counts.Failed
thresholdExceeded = [bool](@($RunResult.ThresholdResults).Where({ $null -ne $_ -and $_.Exceeded }).Count -gt 0)
errorType = ''
errorFunction = ''
lastSuccessAt = if ($success) { $runEnd } else { $previousSuccessAt }
}

if ($RunResult.RunError) {
# Exception CLASS and FUNCTION name only - message text can contain UPNs/DNs and never leaves the box.
$payload.errorType = $RunResult.RunError.Exception.GetType().Name

$errorFunction = "$($RunResult.RunError.InvocationInfo.MyCommand)"
if (-not $errorFunction -and $RunResult.RunError.ScriptStackTrace) {
# A Throw statement has no MyCommand; take the function name from the first stack
# frame ("at Function-Name, <file>: line N") and drop the file path after the comma.
$errorFunction = ($RunResult.RunError.ScriptStackTrace -split "`n")[0] -replace '^at ', '' -replace ',.*$', ''
}
$payload.errorFunction = $errorFunction
}
#endregion Build Payload

#region Write
try {
# -ErrorAction Stop so an unwritable Data directory reaches the catch below instead
# of the error stream: the run is over and nothing here is worth failing it for.
$payload | ConvertTo-Json -Depth 3 | Set-Content -Path $path -Encoding utf8 -ErrorAction Stop
Write-Log -Message "Run summary: Wrote $path" -Level Trace
}
catch {
Write-Log -Message "Run summary: Write failed (non-fatal, run unaffected): $($_.Exception.GetType().Name)" -Level Warn
}
#endregion Write
}
23 changes: 19 additions & 4 deletions src/IDBridge/Public/Core/Invoke-IDBridge.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ rows), matches person IDs to existing accounts, and computes every change list (
deactivations, updates/renames/moves, creates, and group membership) read-only. Before any
writes it runs the change-volume safety guard (ChangeThreshold). It then executes the AD and
Google changes only when the directory is enabled and Debug.readOnly is $false, and (in the
finally block) sends usage telemetry, runs the configured PostRun plugins with the RunResult
object (user list CSV exports live in the Invoke-PluginPostRunExport plugin), and pushes the
run log to a Google Sheet when configured. Per-user write errors are
logged and skipped; startup/OU-creation failures and a tripped change threshold abort the run.
finally block) sends usage telemetry, writes the run summary file (Data\LastRun.json), runs
the configured PostRun plugins with the RunResult object (user list CSV exports live in the
Invoke-PluginPostRunExport plugin), and pushes the run log to a Google Sheet when
configured. Per-user write errors are logged and skipped; startup/OU-creation failures and
a tripped change threshold abort the run.

Only one run per RootPath executes at a time: a machine-wide mutex is taken before
initialization and a second run aborts immediately, telling you a run is already in
Expand Down Expand Up @@ -847,6 +848,20 @@ function Invoke-IDBridge {
}
#endregion Telemetry

#region Run Summary File
# The end-of-run summary a reader on the box consumes (Beacon's collector; see
# PRIVACY.md) - self-contained like telemetry, can never affect the run.
# Preview runs stay quiet.
try {
if ($runResult -and -not $Preview) {
Write-IDBridgeRunSummary -RunResult $runResult
}
}
catch {
Write-Log -Message "Run summary: Skipped ($($_.Exception.GetType().Name))." -Level Warn
}
#endregion Run Summary File

#region PostRun Plugins
# SecureStrings (account keys, passphrase API secrets) are scrubbed from the report
# before any plugin sees it - the run is over, nothing downstream needs them. Isolated
Expand Down
Loading
Loading