#Requires -Version 7.2 #Requires -RunAsAdministrator <# .SYNOPSIS Installs the Entra Policy Suite hybrid worker on a domain-joined Windows server (an Azure VM or an Azure Arc-enabled on-premises server) - run ON the worker, elevated (DESIGN §6; one worker per product, D5). .DESCRIPTION The worker writes the tags of AD-synced users in on-premises AD and reads AD group membership for the rules. It talks to Entra Policy Suite only through its SQL database, with the MACHINE's managed identity; nothing inbound, no password, no certificate, no gallery module (the in-box Windows ScheduledTasks module and icacls / secedit are used - owner-approved for the hybrid worker). AD is written AS THE gMSA you give (create it and delegate it first: Initialize-EpsHybridWorkerAd.ps1), and the worker's identity is given access to the database with Grant-EpsHybridWorkerAccess.ps1. It: copies the worker's code to \versions\ and points the junction \app at it (2.0.102); locks down (only SYSTEM and Administrators write the code the gMSA runs, the gMSA reads it and writes only \logs); writes \worker.json (the database, the worker's name - no secret); on an Arc server adds the gMSA to the local group 'Hybrid agent extension applications' (it reads the Arc agent's identity key); grants the gMSA 'Log on as a batch job'; registers the task 'EntraPolicySuite Hybrid Worker' (every 5 minutes, as the gMSA) and runs it once; registers the task 'EntraPolicySuite Hybrid Worker Update' (daily 05:30, as SYSTEM: Update-EpsHybridWorker.ps1 - the worker follows the version the install runs, only from Invardia's signed release, switched back automatically when the new version fails its self-test). -WhatIf: every step it would take, nothing changed. -Remove: the two tasks, the group membership, \app and \versions go (the logs stay; the gMSA, the machine and its domain membership are not touched). -AsGuestOf pim (owner 2026-10-30: "we must support both, in internal we reuse the pim hybrid worker"; framework §12.26 SHARED-WORKER): on PIM's worker HOST, never under PIM's identity - Entra Policy Suite's own gMSA, its own task (task folder \EPS\), its own root C:\EPS beside C:\PIM, its own USER-ASSIGNED managed identity (-ManagedIdentityClientId, required: the VM's system identity belongs to the host's product), and the guest registration file C:\ProgramData\HybridWorkerHost\guests\eps.json (PIM's remove refuses to delete the machine while it exists). Refused on an Azure Arc host (only the system identity exists there) and where PIM's worker is not installed. .EXAMPLE .\Install-EpsHybridWorker.ps1 -GmsaName 'CORP\gmsa-eps$' -SqlServer sql-eps-1a2b3c4d.database.windows.net -WhatIf .LINK https://invardia.com/docs/eps/scripts/Install-EpsHybridWorker #> # PUBLISHED as a self-contained launcher of Install-EpsHybridWorker.ps1 (EntraPolicySuite 2.0.107, Publish-EpsHandover): it runs release 2.0.107 unless -EpsVersion picks another. [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][ValidatePattern('^[^\\]+\\[^\\]+\$$')][string]$GmsaName, [string]$SqlServer, [string]$Database = 'eps', [string]$WorkerName = $env:COMPUTERNAME, [string]$AdServer = '', [string]$ManagedIdentityClientId = '', [ValidateSet('Auto', 'AzureVm', 'Arc')][string]$HostType = 'Auto', [string]$Root = 'C:\EntraPolicySuite', [ValidateSet('', 'pim')][string]$AsGuestOf = '', [ValidatePattern('^$|^[A-Za-z0-9_-]{1,50}$')][string]$Environment = '', [string]$SourcePath = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)), [switch]$Remove, [ValidatePattern('^$|^2\.\d+\.\d+$')][string]$EpsVersion = '2.0.107' ) $ErrorActionPreference = 'Stop' # A self-contained launcher of Install-EpsHybridWorker.ps1 (invardia.com/support/eps/): it downloads the EntraPolicySuite release this script # was handed over for (2.0.107 - the version of your install's ring; -EpsVersion picks another, -EpsVersion '' the # latest), checks it against the release's published SHA-256 digest and runs the release's own Install-EpsHybridWorker.ps1 with the # parameters you gave. Nothing is installed on this machine; the download goes to a temporary folder. $what = [bool]$WhatIfPreference; $WhatIfPreference = $false $repo = 'KnudsenMorten/EntraPolicySuite'; $h = @{ 'User-Agent' = 'EntraPolicySuite-support' } $rel = Invoke-RestMethod -Uri $(if ($EpsVersion) { "https://api.github.com/repos/$repo/releases/tags/v$EpsVersion" } else { "https://api.github.com/repos/$repo/releases?per_page=30" }) -Headers $h $rel = @($rel | Where-Object { $_.tag_name -match '^v2\.' -and -not $_.draft -and -not $_.prerelease } | Sort-Object { [version]($_.tag_name.TrimStart('v') -replace '[^0-9.].*$', '') } -Descending)[0] if (-not $rel) { Write-Host 'No EntraPolicySuite v2 release found.' -ForegroundColor Red; exit 3 } $asset = @($rel.assets | Where-Object { $_.name -match '^EntraPolicySuite-v2[0-9A-Za-z.+-]*\.zip$' })[0] if (-not $asset) { Write-Host "Release $($rel.tag_name) has no EntraPolicySuite-v2*.zip." -ForegroundColor Red; exit 3 } $dir = Join-Path ([IO.Path]::GetTempPath()) "eps-$($rel.tag_name)-$([guid]::NewGuid().ToString('N').Substring(0, 6))" New-Item -ItemType Directory -Path $dir | Out-Null $zip = Join-Path $dir $asset.name Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $zip -Headers $h $got = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant() $published = if ("$($asset.digest)" -match '^(?i)sha256:([0-9a-f]{64})$') { $Matches[1].ToLowerInvariant() } else { '' } if (-not $published -or $got -ne $published) { [IO.Directory]::Delete($dir, $true); Write-Host "The download does not match the release's SHA-256 - deleted, nothing run." -ForegroundColor Red; exit 3 } Write-Host "Release $($rel.tag_name), SHA-256 $got (matches the release)" Expand-Archive -LiteralPath $zip -DestinationPath $dir -Force $script = Get-ChildItem -LiteralPath $dir -Recurse -Filter 'Install-EpsHybridWorker.ps1' -File | Select-Object -First 1 if (-not $script) { Write-Host 'The release has no Install-EpsHybridWorker.ps1.' -ForegroundColor Red; exit 3 } $pass = @{} foreach ($k in @($PSBoundParameters.Keys)) { if ($k -notin 'EpsVersion', 'WhatIf', 'Confirm') { $pass[$k] = $PSBoundParameters[$k] } } if ($what) { $pass.WhatIf = $true } & $script.FullName @pass exit $LASTEXITCODE