From 0adc35bb3ee7fe61b2770faf1c22845d645aa4f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 02:13:30 +0000 Subject: [PATCH 1/2] The IDBridge Sync task imports IDBridge by name, so Update-Module is the whole update Register-IDBridgeScheduledTask wrote the loaded module's manifest path into the task, and for a Gallery install that is the version folder: Update-Module put the new release beside it and the task kept running the old one (a district on 26.9.9.0 with 26.9.21.0 installed, 2026-09-25). The task now runs `Import-Module IDBridge`, so every run loads the newest version installed for all users and no read grant on the module folder is needed. A per-user install, which the gMSA cannot see, is refused with the install-for-all-users steps; -ModulePath still pins the task to one manifest. Two pure helpers carry the decision and the command string (Test-IDBridgeSharedModulePath, Get-IDBridgeTaskCommand), each with tests. The update-available log line names -Scope AllUsers and the one-time re-registration a task from before this release needs; README, getting-started, google-bootstrap and the AD bootstrap doc install for all users. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0144DV5TZ5tuFoUefCSp549T --- CHANGELOG.md | 18 ++++++ README.md | 4 +- docs/ad-bootstrap.md | 8 ++- docs/architecture.md | 2 +- docs/functions.md | 43 +++++++++---- docs/getting-started.md | 8 ++- docs/google-bootstrap.md | 2 +- .../Private/Get-IDBridgeTaskCommand.ps1 | 41 +++++++++++++ .../Private/Test-IDBridgeSharedModulePath.ps1 | 56 +++++++++++++++++ .../Private/Test-IDBridgeUpdateAvailable.ps1 | 7 ++- .../Core/Register-IDBridgeScheduledTask.ps1 | 48 +++++++++++---- .../Private/Get-IDBridgeTaskCommand.Tests.ps1 | 29 +++++++++ .../Test-IDBridgeSharedModulePath.Tests.ps1 | 60 +++++++++++++++++++ 13 files changed, 290 insertions(+), 36 deletions(-) create mode 100644 src/IDBridge/Private/Get-IDBridgeTaskCommand.ps1 create mode 100644 src/IDBridge/Private/Test-IDBridgeSharedModulePath.ps1 create mode 100644 tests/Private/Get-IDBridgeTaskCommand.Tests.ps1 create mode 100644 tests/Private/Test-IDBridgeSharedModulePath.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 844b077..5e51d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to IDBridge are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions use a calendar scheme `YY.M.D.build` (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning--releases)). +## [Unreleased] + +### Changed +- **The `IDBridge Sync` scheduled task imports IDBridge by name, so `Update-Module` is the + whole update.** `Register-IDBridgeScheduledTask` used to write the loaded module's + manifest path into the task, and for a Gallery install that path is the version folder: + `Update-Module` installed the new release beside it and the task kept running the old one + forever (seen at a district running 26.9.9.0 with 26.9.21.0 installed). The task now runs + `Import-Module IDBridge`, so every run loads the newest version installed for all users, + and no read grant on the module folder is needed any more. Registration refuses a + per-user install, which the gMSA can never see, with a message saying how to install for + all users; `-ModulePath` still pins the task to one manifest (and grants read on its + folder) for anyone who wants that. The update-available log line now says + `Update-Module IDBridge -Scope AllUsers`, the README and getting-started install with + `-Scope AllUsers` (elevated), and the AD bootstrap doc gains the update step. A task registered before this release + still pins its version: after updating, run `Register-IDBridgeScheduledTask -Enabled` + (with your `-IntervalMinutes`) once and it follows new releases on its own. + ## [26.9.21.0] - 2026-09-21 ### Added diff --git a/README.md b/README.md index d716c9c..b23d79d 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ Repo: ## Quick start ```powershell -# Install from the PowerShell Gallery (Update-Module IDBridge to upgrade later) -Install-Module IDBridge -Scope CurrentUser +# Install from the PowerShell Gallery, elevated (Update-Module IDBridge -Scope AllUsers to upgrade later) +Install-Module IDBridge -Scope AllUsers Import-Module IDBridge diff --git a/docs/ad-bootstrap.md b/docs/ad-bootstrap.md index 0405772..dbb1185 100644 --- a/docs/ad-bootstrap.md +++ b/docs/ad-bootstrap.md @@ -62,8 +62,8 @@ single-DC lab). |------|-------| | Install + verify the gMSA on this host | `Install-ADServiceAccount` / `Test-ADServiceAccount`. Right after the account is created (or this host newly allowed), stale Kerberos tickets make this fail — the function purges the computer's tickets (`klist -li 0x3e7 purge`) and retries once on its own; only if that also fails does it ask for a reboot. | | Grant "Log on as a batch job" | `SeBatchLogonRight` in the local security policy (LSA API — no cmdlet exists), required to start a scheduled task. **GPO caveat:** if a GPO manages that right, the GPO's list overwrites the local grant on the next policy refresh — add the gMSA to the GPO instead. | -| Grant filesystem rights | Read on the module folder and the runtime root (config, plugins, vault); modify on `Logs`, `Exports`, `Data`. | -| Register the task | Every `-IntervalMinutes` (default 15, anchored to midnight so runs land on :00/:15/:30/:45), named `-TaskName` (default `IDBridge Sync`, replaced if present): `pwsh -NoProfile -NonInteractive -Command "Import-Module ''; Invoke-IDBridge -RootPath ''"` as the gMSA. A still-running run is never overlapped; a hung run is killed after 1 hour. **Created disabled** unless `-Enabled` is passed. | +| Grant filesystem rights | Read on the runtime root (config, plugins, vault) and, for a pinned `-ModulePath` only, on that module folder (an all-users install is already readable by every account); modify on `Logs`, `Exports`, `Data`. | +| Register the task | Every `-IntervalMinutes` (default 15, anchored to midnight so runs land on :00/:15/:30/:45), named `-TaskName` (default `IDBridge Sync`, replaced if present): `pwsh -NoProfile -NonInteractive -Command "Import-Module IDBridge; Invoke-IDBridge -RootPath ''"` as the gMSA — by name, so every run loads the newest version installed for all users; a per-user install is refused, and `-ModulePath` pins the task to one manifest instead. A still-running run is never overlapped; a hung run is killed after 1 hour. **Created disabled** unless `-Enabled` is passed. | Task-vs-task overlap is Task Scheduler's job (above); an **interactive run vs. the task** is covered by `Invoke-IDBridge` itself, which takes a machine-wide single-run @@ -100,6 +100,10 @@ The task is registered **disabled** so nothing runs before the config is reviewe - **Change the cadence:** re-run `Register-IDBridgeScheduledTask -IntervalMinutes ` — the task is replaced in place (pass `-Enabled` to keep it live, the replacement is otherwise disabled again). +- **Updating IDBridge:** `Update-Module IDBridge -Scope AllUsers` (elevated) and nothing + else — the task imports IDBridge by name. A task registered before 26.9.25.0 imports one + pinned version: run `Register-IDBridgeScheduledTask -IntervalMinutes -Enabled` once + after the update to stop pinning the old version. - **Offboarding:** disable or delete the task and the gMSA (`Remove-ADServiceAccount`); the OU ACEs name the account's SID and die with it (remove them from the OU's Security tab at leisure). diff --git a/docs/architecture.md b/docs/architecture.md index 504bef6..fa90557 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,7 +32,7 @@ overrides** (`-ReadOnly/-TestRun/-SkipADCheck/-TraceLogging/-SkipAD/-SkipGoogle/ (switches win over the config file; `-SkipADCheck` and `-SkipAD` are additionally forwarded into `Initialize-IDBridge` itself so they land before the AD module import), runs the **notify-only update check** (a newer Gallery release logs a `Warn` -to run `Update-Module IDBridge`; offline/blocked environments log a Trace skip — nothing +to run `Update-Module IDBridge -Scope AllUsers`; offline/blocked environments log a Trace skip — nothing is ever installed), and **acquires Google auth** (if `GoogleToken.Enabled`) via `Connect-IDBridgeGoogle`: reads the service-account key JSON from the secret vault (`Get-IDBridgeSecret -Name 'GoogleAuth-ServiceAccount'`; **no file fallback**), validates diff --git a/docs/functions.md b/docs/functions.md index efd5d66..8ff7d69 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -76,15 +76,19 @@ 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 module + runtime root, modify on `Logs`/`Exports`/`Data`), and -registers a Task Scheduler task running `Invoke-IDBridge -RootPath ` in pwsh as -the gMSA every `-IntervalMinutes` (`-LogonType Password`, no stored credential; no -overlap, hung runs killed after 1 h). **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` (def the loaded module's manifest). **Returns:** the -registered task. +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 ` 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. ### `Grant-IDBridgeBatchLogonRight` 🔒 **Params:** `-Identity` (mandatory account, e.g. `'DOMAIN\gMSA-IDBridge$'`). Grants @@ -93,6 +97,20 @@ built-in P/Invoke wrapper over `advapi32.dll` (`LsaOpenPolicy`/`LsaAddAccountRig no cmdlet exists for user rights). Idempotent; local only (a GPO managing the right overwrites it on refresh); elevated session. No return. +### `Test-IDBridgeSharedModulePath` 🔒 🧮 +**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]`. + +### `Get-IDBridgeTaskCommand` 🔒 🧮 +**Params:** `-RootPath` (mandatory), `-ModulePath`. The full pwsh argument string the +scheduled task runs: `-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command +"Import-Module IDBridge; Invoke-IDBridge -RootPath ''"`, or `Import-Module +''` in its place when pinned. **Returns:** `[string]`. + ### `Get-IDBridgeConfig` Accessor for `$script:IDBridgeConfig`. Throws if called before `Initialize-IDBridge`. @@ -150,9 +168,10 @@ the Pulse dashboard. Delete the file when cloning a config to a new install. ### `Test-IDBridgeUpdateAvailable` 🔒 🌐 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`"), Trace -when current. Notify-only — never installs anything. Failures throw to the caller -(`Invoke-IDBridge` swallows them at Trace). **Returns:** `[bool]`. +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]`. ### `Get-IDBridgeTemplateVersion` 🔒 **Params:** `-Path`. Reads the `# TemplateVersion: ` marker from a template file. diff --git a/docs/getting-started.md b/docs/getting-started.md index fdab288..3e1846f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,7 +14,8 @@ touching either directory. Step 9 turns writes on deliberately. - **Windows** with **PowerShell 7.5+** (`pwsh`). - **ActiveDirectory** module (RSAT) on a **domain-joined** machine — only if you're syncing AD. Google-only deployments skip this (run with `-SkipAD`, or leave `AD.enabled = $false`). -- An **elevated** session for step 3 (the secret certificate goes in the machine store). +- An **elevated** session for steps 2 and 3 (the module installs for all users, the secret + certificate goes in the machine store). - For step 4 (once, ever): sign-in as the district's **Google Workspace super admin**, and *Google Cloud Platform* turned **ON** for that admin (Admin console → Apps → Additional Google services). See [google-bootstrap.md](google-bootstrap.md#prerequisites). @@ -22,12 +23,15 @@ touching either directory. Step 9 turns writes on deliberately. ## 2. Install and scaffold ```powershell -Install-Module IDBridge -Scope CurrentUser # Update-Module IDBridge to upgrade later +Install-Module IDBridge -Scope AllUsers # elevated; Update-Module IDBridge -Scope AllUsers to upgrade later Import-Module IDBridge Install-IDBridge # -RootPath 'D:\IDBridge' to relocate everything ``` +Install for all users: the scheduled task runs as a gMSA, which only sees modules +installed for all users (a `-Scope CurrentUser` install lives in your own profile). + `Install-IDBridge` creates the runtime tree under `C:\IDBridge` (`Config/Logs/Exports/Plugins/Data/Vault`), writes a default `Config\IDBridgeConfig.psd1` — every feature disabled, all safety brakes on (`ReadOnly`, diff --git a/docs/google-bootstrap.md b/docs/google-bootstrap.md index a5beeec..558fb3b 100644 --- a/docs/google-bootstrap.md +++ b/docs/google-bootstrap.md @@ -6,7 +6,7 @@ tenant, signed in as their Google Workspace super admin**. Everything it creates the district's Google Cloud organization — SC Networks retains nothing afterward. ```powershell -Install-Module IDBridge -Scope CurrentUser # from the PowerShell Gallery +Install-Module IDBridge -Scope AllUsers # from the PowerShell Gallery, elevated Import-Module IDBridge Initialize-IDBridge # loads config/paths/logging; Google auth happens at run time, not here Initialize-IDBridgeGoogleServiceAccount -CreateProject diff --git a/src/IDBridge/Private/Get-IDBridgeTaskCommand.ps1 b/src/IDBridge/Private/Get-IDBridgeTaskCommand.ps1 new file mode 100644 index 0000000..25e6712 --- /dev/null +++ b/src/IDBridge/Private/Get-IDBridgeTaskCommand.ps1 @@ -0,0 +1,41 @@ +<# +.SYNOPSIS +Build the pwsh argument string the IDBridge scheduled task runs (internal). + +.DESCRIPTION +Internal helper for Register-IDBridgeScheduledTask: returns the full -Argument text for the +task's pwsh action. By default the task imports IDBridge by name, so every run loads the +newest version installed for all users and Update-Module alone picks up a new release. With +-ModulePath the task imports that one manifest instead - an explicit pin. Uses no module +state or Write-Log. + +.PARAMETER RootPath +Runtime root the task passes to Invoke-IDBridge -RootPath. + +.PARAMETER ModulePath +Path to an IDBridge.psd1 manifest to pin the task to. Omit it to import IDBridge by name. + +.OUTPUTS +[string] the pwsh argument string. + +.EXAMPLE +Get-IDBridgeTaskCommand -RootPath 'C:\IDBridge' + +.NOTES + Created by: Sam Cattanach + Modified: 2026-09-25 +#> +function Get-IDBridgeTaskCommand { + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string]$RootPath, + + [Parameter()] + [string]$ModulePath + ) + + $importCommand = if ($ModulePath) { "Import-Module '$ModulePath'" } else { "Import-Module IDBridge" } + return "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command `"$importCommand; Invoke-IDBridge -RootPath '$RootPath'`"" +} diff --git a/src/IDBridge/Private/Test-IDBridgeSharedModulePath.ps1 b/src/IDBridge/Private/Test-IDBridgeSharedModulePath.ps1 new file mode 100644 index 0000000..eb0c342 --- /dev/null +++ b/src/IDBridge/Private/Test-IDBridgeSharedModulePath.ps1 @@ -0,0 +1,56 @@ +<# +.SYNOPSIS +Test whether a module folder sits under an all-users module path (internal). + +.DESCRIPTION +Internal helper for Register-IDBridgeScheduledTask: returns $true when -ModuleBase is +under any of -SharedModulePaths, the module folders every account on the machine sees - so +the gMSA's pwsh finds the module by name. The comparison is case-insensitive, treats '\' +and '/' alike, ignores trailing separators, and matches whole path segments only +('...\PowerShell\ModulesX' is not under '...\PowerShell\Modules'). Uses no module state or +Write-Log. + +.PARAMETER ModuleBase +The loaded module's folder, e.g. 'C:\Program Files\PowerShell\Modules\IDBridge\26.9.21.0'. + +.PARAMETER SharedModulePaths +The all-users module folders. Defaults to the machine-scope PSModulePath plus +$env:ProgramFiles\PowerShell\Modules - pwsh's all-users folder, where +Install-Module -Scope AllUsers puts a module; pwsh adds it at startup and it is not in the +machine variable. + +.OUTPUTS +[bool] $true when ModuleBase is under a shared module path, otherwise $false. + +.EXAMPLE +Test-IDBridgeSharedModulePath -ModuleBase (Get-Module IDBridge).ModuleBase + +.NOTES + Created by: Sam Cattanach + Modified: 2026-09-25 +#> +function Test-IDBridgeSharedModulePath { + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [string]$ModuleBase, + + [Parameter()] + [string[]]$SharedModulePaths + ) + + if (-not $PSBoundParameters.ContainsKey('SharedModulePaths')) { + $SharedModulePaths = [Environment]::GetEnvironmentVariable('PSModulePath', 'Machine') -split [IO.Path]::PathSeparator + if ($env:ProgramFiles) { $SharedModulePaths += Join-Path $env:ProgramFiles 'PowerShell\Modules' } + } + + $base = $ModuleBase.Replace('\', '/').TrimEnd('/') + foreach ($sharedPath in $SharedModulePaths | Where-Object { $_ }) { + $shared = $sharedPath.Replace('\', '/').TrimEnd('/') + if ($base -eq $shared -or $base.StartsWith("$shared/", [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false +} diff --git a/src/IDBridge/Private/Test-IDBridgeUpdateAvailable.ps1 b/src/IDBridge/Private/Test-IDBridgeUpdateAvailable.ps1 index 9004149..0338e37 100644 --- a/src/IDBridge/Private/Test-IDBridgeUpdateAvailable.ps1 +++ b/src/IDBridge/Private/Test-IDBridgeUpdateAvailable.ps1 @@ -5,7 +5,8 @@ Check the PowerShell Gallery for a newer IDBridge release and log a warning if o .DESCRIPTION Internal helper: queries the PowerShell Gallery for the latest stable IDBridge version (prereleases excluded) and compares it to the running module version. A newer release logs -a Warn ("Update available... run Update-Module IDBridge") so it surfaces in the log file, +a Warn ("Update available... Run Update-Module IDBridge -Scope AllUsers", and the one-time +re-registration a task from before 26.9.25.0 needs) so it surfaces in the log file, console, and Google Sheet log; an up-to-date install logs at Trace. Notify-only — nothing is ever downloaded or installed. The gallery call has a 10 second timeout and any failure throws to the caller, which is expected to swallow it (offline or gallery-blocked @@ -19,7 +20,7 @@ try { Test-IDBridgeUpdateAvailable | Out-Null } catch { } .NOTES Created by: Sam Cattanach - Modified: 2026-07-13 + Modified: 2026-09-25 #> function Test-IDBridgeUpdateAvailable { [CmdletBinding()] @@ -34,7 +35,7 @@ function Test-IDBridgeUpdateAvailable { $latestVersion = [version](@($response)[0].properties.Version) if ($latestVersion -gt $installedVersion) { - Write-Log -Message "Update available: IDBridge $latestVersion is on the PowerShell Gallery (installed: $installedVersion). Run Update-Module IDBridge, then restart the session." -Level Warn + Write-Log -Message "Update available: IDBridge $latestVersion is on the PowerShell Gallery (installed: $installedVersion). Run Update-Module IDBridge -Scope AllUsers (elevated), then restart the session. A task registered before 26.9.25.0 imports one pinned version - run Register-IDBridgeScheduledTask -Enabled once after updating so it follows new releases on its own." -Level Warn return $true } diff --git a/src/IDBridge/Public/Core/Register-IDBridgeScheduledTask.ps1 b/src/IDBridge/Public/Core/Register-IDBridgeScheduledTask.ps1 index 4098911..249270b 100644 --- a/src/IDBridge/Public/Core/Register-IDBridgeScheduledTask.ps1 +++ b/src/IDBridge/Public/Core/Register-IDBridgeScheduledTask.ps1 @@ -16,17 +16,26 @@ interval) is safe. Steps: security policy — required to start a scheduled task. When a GPO manages that right, the GPO's list overwrites the local grant on the next policy refresh: add the gMSA to the GPO instead (the function reminds you). - 3. Grants the gMSA the filesystem rights a run needs: read on the module folder and - the runtime root (config, plugins, vault), modify on Logs, Exports, and Data. + 3. Grants the gMSA the filesystem rights a run needs: read on the runtime root + (config, plugins, vault) and, for a pinned -ModulePath only, on that module folder; + modify on Logs, Exports, and Data. 4. Registers (or replaces) a Task Scheduler task that runs - 'Invoke-IDBridge -RootPath ' in pwsh as the gMSA every -IntervalMinutes - (default 15, aligned to midnight so runs land on predictable clock times). The + 'Import-Module IDBridge; Invoke-IDBridge -RootPath ' in pwsh as the gMSA every + -IntervalMinutes (default 15, aligned to midnight so runs land on predictable clock + times). Importing by name loads the newest installed version at every run, so + Update-Module alone picks up a new release; the gMSA only sees an all-users install, + so a per-user install is refused (-ModulePath pins the task to one manifest + instead). The principal uses -LogonType Password — Task Scheduler retrieves the gMSA's password from AD, nothing is stored. A still-running run is never overlapped (Task Scheduler's default), and a hung run is killed after 1 hour so the schedule recovers. The task is created DISABLED unless -Enabled is passed — review the config (Debug.ReadOnly first!), then Enable-ScheduledTask when ready. +Updating: after Update-Module IDBridge -Scope AllUsers a task registered by this version +needs nothing. A task registered before it imports one pinned version - run +Register-IDBridgeScheduledTask -Enabled once to move it to the import by name. + Requires an initialized session (Initialize-IDBridge) and the ActiveDirectory RSAT module. @@ -51,8 +60,9 @@ Runtime root the task passes to Invoke-IDBridge -RootPath. Defaults to this sess root (Paths.Root). .PARAMETER ModulePath -Path to the IDBridge.psd1 manifest the task imports. Defaults to the currently loaded -module's manifest. +Path to an IDBridge.psd1 manifest to pin the task to - it imports that one copy and the +gMSA is granted read on its folder. Without it the task imports IDBridge by name, which +requires an all-users install (Install-Module IDBridge -Scope AllUsers). .EXAMPLE Register-IDBridgeScheduledTask @@ -62,7 +72,7 @@ Register-IDBridgeScheduledTask -IntervalMinutes 60 -Enabled .NOTES Created by: Sam Cattanach - Modified: 2026-08-27 + Modified: 2026-09-25 #> function Register-IDBridgeScheduledTask { [CmdletBinding()] @@ -92,8 +102,18 @@ function Register-IDBridgeScheduledTask { $IDConfig = Get-IDBridgeConfig if (-not $RootPath) { $RootPath = $IDConfig.Paths.Root } - if (-not $ModulePath) { $ModulePath = Join-Path (Get-Module -Name IDBridge).ModuleBase "IDBridge.psd1" } - if (-not (Test-Path $ModulePath)) { Throw "Module manifest not found at '$ModulePath' - pass -ModulePath explicitly." } + $loadedModule = $MyInvocation.MyCommand.Module + if ($ModulePath) { + if (-not (Test-Path $ModulePath)) { Throw "Module manifest not found at '$ModulePath'." } + $taskArgument = Get-IDBridgeTaskCommand -RootPath $RootPath -ModulePath $ModulePath + } + else { + #The gMSA's pwsh finds the module by name only when it is installed for all users. + if (-not (Test-IDBridgeSharedModulePath -ModuleBase $loadedModule.ModuleBase)) { + Throw "IDBridge is loaded from '$($loadedModule.ModuleBase)', a per-user install the gMSA cannot see. Install it for all users (elevated: Install-Module IDBridge -Scope AllUsers), Import-Module IDBridge -Force, and run this again - or pass -ModulePath to pin the task to this copy." + } + $taskArgument = Get-IDBridgeTaskCommand -RootPath $RootPath + } try { Import-Module -Name ActiveDirectory -ErrorAction Stop } catch { Throw "The ActiveDirectory PowerShell module (RSAT) is required: $($_)" } @@ -127,14 +147,15 @@ function Register-IDBridgeScheduledTask { Grant-IDBridgeBatchLogonRight -Identity $gmsaIdentity #region Grant the filesystem rights a run needs - #Read the module and the whole runtime root (config, plugins, vault); write where a run writes. + #Read a pinned module and the whole runtime root (config, plugins, vault); write where a run writes. $fileSystemGrants = @( - @{ Path = (Split-Path $ModulePath -Parent); Rights = 'ReadAndExecute' } + if ($ModulePath) { @{ Path = (Split-Path $ModulePath -Parent); Rights = 'ReadAndExecute' } } @{ Path = $RootPath; Rights = 'ReadAndExecute' } @{ Path = (Join-Path $RootPath "Logs"); Rights = 'Modify' } @{ Path = (Join-Path $RootPath "Exports"); Rights = 'Modify' } @{ Path = (Join-Path $RootPath "Data"); Rights = 'Modify' } ) + if (-not $ModulePath) { Write-Log -Message "Task: IDBridge is installed for all users ($($loadedModule.ModuleBase)) - every account reads it, no module grant needed." -Level Trace } foreach ($grant in $fileSystemGrants) { try { $acl = Get-Acl -Path $grant.Path @@ -150,7 +171,7 @@ function Register-IDBridgeScheduledTask { $pwsh = (Get-Command -Name pwsh.exe -ErrorAction SilentlyContinue).Source if (-not $pwsh) { Throw "pwsh.exe was not found on the PATH - IDBridge requires PowerShell 7.5+." } - $action = New-ScheduledTaskAction -Execute $pwsh -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command `"Import-Module '$ModulePath'; Invoke-IDBridge -RootPath '$RootPath'`"" + $action = New-ScheduledTaskAction -Execute $pwsh -Argument $taskArgument #Anchored to midnight so the runs land on predictable clock times; no repetition #duration = repeat indefinitely. $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).Date -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes) @@ -169,7 +190,8 @@ function Register-IDBridgeScheduledTask { $registeredVerb = if ($existing) { "Replaced" } else { "Registered" } $stateText = if ($Enabled) { "enabled" } else { "disabled" } - Write-Log -Message "Task: $registeredVerb scheduled task '$TaskName' ($stateText) - every $IntervalMinutes minutes as '$gmsaIdentity'." + $importText = if ($ModulePath) { "imports $ModulePath" } else { "imports IDBridge by name" } + Write-Log -Message "Task: $registeredVerb scheduled task '$TaskName' ($stateText) - every $IntervalMinutes minutes as '$gmsaIdentity' - $importText." #endregion Register the scheduled task Write-Host "Scheduled task '$TaskName' runs Invoke-IDBridge every $IntervalMinutes minutes as '$gmsaIdentity'." -ForegroundColor Green diff --git a/tests/Private/Get-IDBridgeTaskCommand.Tests.ps1 b/tests/Private/Get-IDBridgeTaskCommand.Tests.ps1 new file mode 100644 index 0000000..60f180a --- /dev/null +++ b/tests/Private/Get-IDBridgeTaskCommand.Tests.ps1 @@ -0,0 +1,29 @@ +<# +.SYNOPSIS +Unit tests for Get-IDBridgeTaskCommand (pure helper — no mocking needed). + +.DESCRIPTION +The string is what Task Scheduler runs as the gMSA, so both shapes are asserted whole, +quoting included - a change to either is a deliberate one. +#> + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..' 'TestHelper.psm1') -Force + Import-IDBridgeForTest +} + +Describe 'Get-IDBridgeTaskCommand' { + It 'imports IDBridge by name without -ModulePath' { + InModuleScope IDBridge { + Get-IDBridgeTaskCommand -RootPath 'C:\IDBridge' | + Should -BeExactly "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command `"Import-Module IDBridge; Invoke-IDBridge -RootPath 'C:\IDBridge'`"" + } + } + + It 'imports the pinned manifest with -ModulePath' { + InModuleScope IDBridge { + Get-IDBridgeTaskCommand -RootPath 'D:\IDBridge' -ModulePath 'C:\Program Files\PowerShell\Modules\IDBridge\26.9.21.0\IDBridge.psd1' | + Should -BeExactly "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command `"Import-Module 'C:\Program Files\PowerShell\Modules\IDBridge\26.9.21.0\IDBridge.psd1'; Invoke-IDBridge -RootPath 'D:\IDBridge'`"" + } + } +} diff --git a/tests/Private/Test-IDBridgeSharedModulePath.Tests.ps1 b/tests/Private/Test-IDBridgeSharedModulePath.Tests.ps1 new file mode 100644 index 0000000..a3c0c0b --- /dev/null +++ b/tests/Private/Test-IDBridgeSharedModulePath.Tests.ps1 @@ -0,0 +1,60 @@ +<# +.SYNOPSIS +Unit tests for Test-IDBridgeSharedModulePath (pure helper — no mocking needed). + +.DESCRIPTION +The answer decides whether Register-IDBridgeScheduledTask imports IDBridge by name or +refuses the install, so the match is pinned exactly: whole path segments only (a sibling +folder that merely starts with the shared path is not under it), case and separator +differences ignored. The tests always pass -SharedModulePaths - the default reads the +machine's PSModulePath, which is empty on the Linux runners. +#> + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..' 'TestHelper.psm1') -Force + Import-IDBridgeForTest +} + +Describe 'Test-IDBridgeSharedModulePath' { + It 'is shared under an all-users path: ' -ForEach @( + @{ ModuleBase = 'C:\Program Files\PowerShell\Modules\IDBridge\26.9.21.0' } + @{ ModuleBase = 'C:\Program Files\PowerShell\Modules\IDBridge\26.9.21.0\' } # trailing separator + @{ ModuleBase = 'c:\program files\powershell\modules\IDBridge\26.9.21.0' } # case + @{ ModuleBase = 'C:/Program Files/PowerShell/Modules/IDBridge/26.9.21.0' } # forward slashes + ) { + InModuleScope IDBridge -Parameters @{ ModuleBase = $ModuleBase } { + Test-IDBridgeSharedModulePath -ModuleBase $ModuleBase -SharedModulePaths 'C:\Program Files\PowerShell\Modules' | Should -BeTrue + } + } + + It 'matches a shared path given with a trailing separator' { + InModuleScope IDBridge { + Test-IDBridgeSharedModulePath -ModuleBase 'C:\Program Files\PowerShell\Modules\IDBridge\26.9.21.0' -SharedModulePaths 'C:\Program Files\PowerShell\Modules\' | Should -BeTrue + } + } + + It 'is not shared outside every all-users path: ' -ForEach @( + @{ ModuleBase = 'C:\Users\admin\Documents\PowerShell\Modules\IDBridge\26.9.21.0' } # a per-user install + @{ ModuleBase = 'C:\Program Files\PowerShell\ModulesX\IDBridge\26.9.21.0' } # sibling sharing the prefix + @{ ModuleBase = 'C:\Program Files\PowerShell' } # the shared path's parent + ) { + InModuleScope IDBridge -Parameters @{ ModuleBase = $ModuleBase } { + Test-IDBridgeSharedModulePath -ModuleBase $ModuleBase -SharedModulePaths 'C:\Program Files\PowerShell\Modules' | Should -BeFalse + } + } + + It 'checks every shared path' { + InModuleScope IDBridge { + $sharedPaths = @('C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') + Test-IDBridgeSharedModulePath -ModuleBase 'C:\Program Files\PowerShell\Modules\IDBridge\26.9.21.0' -SharedModulePaths $sharedPaths | Should -BeTrue + Test-IDBridgeSharedModulePath -ModuleBase 'C:\Users\admin\Documents\PowerShell\Modules\IDBridge\26.9.21.0' -SharedModulePaths $sharedPaths | Should -BeFalse + } + } + + It 'is not shared when there are no shared paths' { + InModuleScope IDBridge { + Test-IDBridgeSharedModulePath -ModuleBase 'C:\Program Files\PowerShell\Modules\IDBridge\26.9.21.0' -SharedModulePaths @() | Should -BeFalse + Test-IDBridgeSharedModulePath -ModuleBase 'C:\Program Files\PowerShell\Modules\IDBridge\26.9.21.0' -SharedModulePaths @('', '') | Should -BeFalse + } + } +} From 24e0574baea77003f928077d4d1091ed4d2204df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 02:13:39 +0000 Subject: [PATCH 2/2] Release 26.9.25.0 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0144DV5TZ5tuFoUefCSp549T --- CHANGELOG.md | 2 +- src/IDBridge/IDBridge.psd1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e51d83..51e4d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to IDBridge are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions use a calendar scheme `YY.M.D.build` (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning--releases)). -## [Unreleased] +## [26.9.25.0] - 2026-09-25 ### Changed - **The `IDBridge Sync` scheduled task imports IDBridge by name, so `Update-Module` is the diff --git a/src/IDBridge/IDBridge.psd1 b/src/IDBridge/IDBridge.psd1 index 30137ee..e3a30f7 100644 --- a/src/IDBridge/IDBridge.psd1 +++ b/src/IDBridge/IDBridge.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IDBridge.psm1' - ModuleVersion = '26.9.21.0' + ModuleVersion = '26.9.25.0' GUID = 'a0a0c664-888e-44e2-9c12-fc8647a520c0' Author = 'Sam Cattanach' CompanyName = 'SC Networks LLC'