# Temporarily grants a pre-existing local Windows account membership in the # built-in Administrators group. Designed to run through FileWave as Local System. # # Exit 0: Temporary access was granted/renewed, or the account was already a # permanent administrator and was intentionally left unchanged. # Exit 1: The account was not found, was disabled, or automatic removal could # not be secured. param( [ValidatePattern('^[A-Za-z0-9._-]{1,20}$')] [string]$UserName = 'Filewave', [ValidateRange(1, 1440)] [int]$DurationMinutes = 10 ) $ErrorActionPreference = 'Stop' $AdminGroupSid = 'S-1-5-32-544' $SystemSid = 'S-1-5-18' $TrustedBaseDirectory = Join-Path $env:WINDIR 'System32\config\systemprofile\AppData\Local\FileWave' $WorkingDirectory = Join-Path $TrustedBaseDirectory 'TempAdmin' $StateFile = Join-Path $WorkingDirectory ($UserName + '.temporary-admin.json') $CleanupScript = Join-Path $WorkingDirectory ('Remove-' + $UserName + '-Temporary-Admin.ps1') $TaskName = 'FileWave-Remove-Temporary-Admin-' + $UserName $LogFile = Join-Path $WorkingDirectory 'Temporary-Admin.log' $MutexName = 'Global\FileWave-Temporary-Admin-Workspace' $WorkspaceTrusted = $false $HadValidState = $false $MembershipOwned = $false $Mutex = $null $MutexAcquired = $false $User = $null $ExitCode = 1 function Write-TempAdminLog { param([string]$Message) $Line = [DateTimeOffset]::UtcNow.ToString("yyyy-MM-dd HH:mm:ss 'UTC'") + ' ' + $Message Write-Output $Line try { if (-not $script:WorkspaceTrusted) { return } $Line | Out-File -FilePath $LogFile -Append -Encoding ASCII -ErrorAction Stop } catch { # FileWave captures standard script output if file logging is unavailable. $null = $_ } } function Test-DirectAdministratorMembership { param([string]$UserSid) $Members = @(Get-LocalGroupMember -SID $AdminGroupSid -ErrorAction Stop) foreach ($Member in $Members) { if ($Member.SID.Value -eq $UserSid) { return $true } } return $false } function Get-TrustedDirectoryAcl { $SystemIdentity = New-Object System.Security.Principal.SecurityIdentifier($SystemSid) $AdministratorsIdentity = New-Object System.Security.Principal.SecurityIdentifier($AdminGroupSid) $Inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit $Propagation = [System.Security.AccessControl.PropagationFlags]::None $Allow = [System.Security.AccessControl.AccessControlType]::Allow $Acl = New-Object System.Security.AccessControl.DirectorySecurity $Acl.SetAccessRuleProtection($true, $false) $Acl.SetOwner($SystemIdentity) $Acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($SystemIdentity, [System.Security.AccessControl.FileSystemRights]::FullControl, $Inheritance, $Propagation, $Allow)) $Acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($AdministratorsIdentity, [System.Security.AccessControl.FileSystemRights]::ReadAndExecute, $Inheritance, $Propagation, $Allow)) return $Acl } function Assert-TrustedDirectory { param([string]$LiteralPath) $Item = Get-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop if (-not $Item.PSIsContainer) { throw ('Expected a directory: ' + $LiteralPath) } if (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw ('Refusing reparse-point directory: ' + $LiteralPath) } $Acl = Get-Acl -LiteralPath $LiteralPath -ErrorAction Stop if (-not $Acl.AreAccessRulesProtected) { throw ('Directory still inherits permissions: ' + $LiteralPath) } $OwnerSidValue = $Acl.Owner if ($OwnerSidValue -ne $SystemSid) { $OwnerSidValue = (New-Object System.Security.Principal.NTAccount($Acl.Owner)).Translate([System.Security.Principal.SecurityIdentifier]).Value } if ($OwnerSidValue -ne $SystemSid) { throw ('Local System does not own directory: ' + $LiteralPath) } $Rules = @($Acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])) $AllowedRuleSids = @($SystemSid, $AdminGroupSid) $HasSystemFullControl = $false foreach ($Rule in $Rules) { if (($Rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) -or ($AllowedRuleSids -notcontains $Rule.IdentityReference.Value)) { throw ('Unexpected access rule on ' + $LiteralPath + ': ' + $Rule.IdentityReference.Value) } if (($Rule.IdentityReference.Value -eq $SystemSid) -and (($Rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl)) { $HasSystemFullControl = $true } } if (-not $HasSystemFullControl) { throw ('Local System lacks full control of directory: ' + $LiteralPath) } } function Get-TrustedFileAcl { $SystemIdentity = New-Object System.Security.Principal.SecurityIdentifier($SystemSid) $AdministratorsIdentity = New-Object System.Security.Principal.SecurityIdentifier($AdminGroupSid) $Allow = [System.Security.AccessControl.AccessControlType]::Allow $Acl = New-Object System.Security.AccessControl.FileSecurity $Acl.SetAccessRuleProtection($true, $false) $Acl.SetOwner($SystemIdentity) $Acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($SystemIdentity, [System.Security.AccessControl.FileSystemRights]::FullControl, $Allow)) $Acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($AdministratorsIdentity, [System.Security.AccessControl.FileSystemRights]::ReadAndExecute, $Allow)) return $Acl } function Assert-TrustedFile { param([string]$LiteralPath) $Item = Get-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop if ($Item.PSIsContainer -or (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { throw ('Refusing unsafe artifact path: ' + $LiteralPath) } $HardLinks = @(& fsutil.exe hardlink list $LiteralPath 2>$null) if (($LASTEXITCODE -ne 0) -or ($HardLinks.Count -ne 1)) { throw ('Artifact must have exactly one hard link: ' + $LiteralPath) } $Acl = Get-Acl -LiteralPath $LiteralPath -ErrorAction Stop if (-not $Acl.AreAccessRulesProtected) { throw ('Artifact still inherits permissions: ' + $LiteralPath) } $OwnerSidValue = $Acl.Owner if ($OwnerSidValue -ne $SystemSid) { $OwnerSidValue = (New-Object System.Security.Principal.NTAccount($Acl.Owner)).Translate([System.Security.Principal.SecurityIdentifier]).Value } if ($OwnerSidValue -ne $SystemSid) { throw ('Local System does not own artifact: ' + $LiteralPath) } $Rules = @($Acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])) foreach ($Rule in $Rules) { if (($Rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) -or (@($SystemSid, $AdminGroupSid) -notcontains $Rule.IdentityReference.Value)) { throw ('Unexpected artifact access rule: ' + $Rule.IdentityReference.Value) } } } function Initialize-TrustedWorkingDirectory { $ProtectedParent = Split-Path -Parent $TrustedBaseDirectory $ParentItem = Get-Item -LiteralPath $ProtectedParent -Force -ErrorAction Stop if (($ParentItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw ('Refusing reparse-point protected parent: ' + $ProtectedParent) } foreach ($Directory in @($TrustedBaseDirectory, $WorkingDirectory)) { if (-not (Test-Path -LiteralPath $Directory)) { New-Item -Path $Directory -ItemType Directory -ErrorAction Stop | Out-Null } Set-Acl -LiteralPath $Directory -AclObject (Get-TrustedDirectoryAcl) -ErrorAction Stop Assert-TrustedDirectory -LiteralPath $Directory } if (Test-Path -LiteralPath $LogFile) { Assert-TrustedFile -LiteralPath $LogFile Set-Acl -LiteralPath $LogFile -AclObject (Get-TrustedFileAcl) -ErrorAction Stop } else { Write-AtomicAsciiFile -LiteralPath $LogFile -Content '' } $script:WorkspaceTrusted = $true } function Write-AtomicAsciiFile { param( [Parameter(Mandatory = $true)] [string]$LiteralPath, [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Content ) $Directory = Split-Path -Parent $LiteralPath $LeafName = Split-Path -Leaf $LiteralPath $TemporaryPath = Join-Path $Directory ('.' + $LeafName + '.' + [Guid]::NewGuid().ToString('N') + '.tmp') $BackupPath = $TemporaryPath + '.bak' try { $Bytes = [Text.Encoding]::ASCII.GetBytes($Content) $Options = [IO.FileOptions]::WriteThrough $Stream = New-Object IO.FileStream($TemporaryPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None, 4096, $Options) try { $Stream.Write($Bytes, 0, $Bytes.Length) $Stream.Flush($true) } finally { $Stream.Dispose() } Set-Acl -LiteralPath $TemporaryPath -AclObject (Get-TrustedFileAcl) -ErrorAction Stop Assert-TrustedFile -LiteralPath $TemporaryPath if (Test-Path -LiteralPath $LiteralPath) { Assert-TrustedFile -LiteralPath $LiteralPath [IO.File]::Replace($TemporaryPath, $LiteralPath, $BackupPath) } else { [IO.File]::Move($TemporaryPath, $LiteralPath) } Set-Acl -LiteralPath $LiteralPath -AclObject (Get-TrustedFileAcl) -ErrorAction Stop Assert-TrustedFile -LiteralPath $LiteralPath } finally { if (Test-Path -LiteralPath $TemporaryPath) { Remove-Item -LiteralPath $TemporaryPath -Force -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $BackupPath) { Remove-Item -LiteralPath $BackupPath -Force -ErrorAction SilentlyContinue } } } function Assert-RegisteredCleanupTask { param( [string]$ExpectedPowerShell, [string]$ExpectedArguments ) $Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop $TriggerTypes = @($Task.Triggers | ForEach-Object { $_.CimClass.CimClassName }) if (($Task.Actions.Count -ne 1) -or ($Task.Actions[0].Execute -ine $ExpectedPowerShell) -or ($Task.Actions[0].Arguments -ine $ExpectedArguments) -or (($Task.Principal.UserId -ine 'SYSTEM') -and ($Task.Principal.UserId -ne $SystemSid)) -or ($Task.Principal.RunLevel.ToString() -ne 'Highest') -or ($Task.Settings.StartWhenAvailable -ne $true) -or ($Task.Settings.RestartCount -ne 3) -or ($Task.Settings.MultipleInstances.ToString() -ne 'IgnoreNew') -or ($Task.State.ToString() -eq 'Disabled') -or ($TriggerTypes -notcontains 'MSFT_TaskTimeTrigger') -or ($TriggerTypes -notcontains 'MSFT_TaskBootTrigger') -or ($TriggerTypes -notcontains 'MSFT_TaskDailyTrigger')) { throw 'The registered cleanup task failed structural verification.' } [xml]$TaskXml = Export-ScheduledTask -TaskName $TaskName -ErrorAction Stop $TaskRoot = "/*[local-name()='Task']" $TimeTrigger = $TaskXml.SelectSingleNode($TaskRoot + "/*[local-name()='Triggers']/*[local-name()='TimeTrigger']") $BootTrigger = $TaskXml.SelectSingleNode($TaskRoot + "/*[local-name()='Triggers']/*[local-name()='BootTrigger']") $DailyTrigger = $TaskXml.SelectSingleNode($TaskRoot + "/*[local-name()='Triggers']/*[local-name()='CalendarTrigger']/*[local-name()='ScheduleByDay']/*[local-name()='DaysInterval']") if (($null -eq $TimeTrigger) -or ($null -eq $BootTrigger) -or ($null -eq $DailyTrigger) -or ($DailyTrigger.InnerText -ne '1') -or ($TimeTrigger.Repetition.Interval -ne 'PT5M') -or ($TimeTrigger.Repetition.Duration -ne 'P7D')) { throw 'The registered cleanup task failed trigger verification.' } } # Run the grant path in native 64-bit Windows PowerShell 5.1 so the grant and # generated cleanup path use the same modules and object semantics. $NeedsNativeRelaunch = ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) -or ($PSVersionTable.PSEdition -ne 'Desktop') -or ($PSVersionTable.PSVersion.Major -ne 5) if ($NeedsNativeRelaunch) { if ([string]::IsNullOrWhiteSpace($PSCommandPath)) { Write-Output 'ERROR: The script path is unavailable for the required Windows PowerShell 5.1 relaunch.' exit 1 } $NativeFolder = if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) { 'SysNative' } else { 'System32' } $NativePowerShell = Join-Path $env:WINDIR ($NativeFolder + '\WindowsPowerShell\v1.0\powershell.exe') & $NativePowerShell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $PSCommandPath -UserName $UserName -DurationMinutes $DurationMinutes exit $LASTEXITCODE } try { $CurrentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent() if ($CurrentIdentity.User.Value -ne $SystemSid) { throw 'This script must run as Local System.' } $RequiredCommands = @( 'Get-LocalUser', 'Get-LocalGroupMember', 'Add-LocalGroupMember', 'Remove-LocalGroupMember', 'New-ScheduledTaskAction', 'New-ScheduledTaskTrigger', 'New-ScheduledTaskPrincipal', 'New-ScheduledTaskSettingsSet', 'Register-ScheduledTask', 'Get-ScheduledTask', 'Export-ScheduledTask', 'Unregister-ScheduledTask' ) foreach ($CommandName in $RequiredCommands) { if ($null -eq (Get-Command -Name $CommandName -ErrorAction SilentlyContinue)) { throw ('Required PowerShell command is unavailable: ' + $CommandName) } } Initialize-TrustedWorkingDirectory $UserMatches = @(Get-LocalUser -Name $UserName -ErrorAction Stop) $User = $UserMatches | Where-Object { $_.Name -ceq $UserName } | Select-Object -First 1 if (($null -eq $User) -or (($UserMatches | Where-Object { $_.Name -ceq $UserName }).Count -ne 1)) { throw ('Expected exactly one local account named ' + $UserName + '.') } if (-not $User.Enabled) { throw ('The local account ' + $UserName + ' is disabled.') } $UserSid = $User.SID.Value $Mutex = New-Object System.Threading.Mutex($false, $MutexName) try { $MutexAcquired = $Mutex.WaitOne([TimeSpan]::FromSeconds(30)) } catch [System.Threading.AbandonedMutexException] { $MutexAcquired = $true } if (-not $MutexAcquired) { throw 'Timed out waiting for another temporary-administrator operation to finish.' } $ExistingState = $null if (Test-Path -LiteralPath $StateFile) { try { Assert-TrustedFile -LiteralPath $StateFile $ExistingState = Get-Content -LiteralPath $StateFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop if (($ExistingState.Version -ne 2) -or ($ExistingState.UserSid -ne $UserSid) -or ($ExistingState.AddedByScript -ne $true)) { throw 'The state content does not match this local account.' } [void][DateTimeOffset]::Parse($ExistingState.ExpiresAtUtc) [void][Guid]::ParseExact($ExistingState.LeaseId, 'N') $HadValidState = $true $MembershipOwned = $true Assert-TrustedFile -LiteralPath $CleanupScript } catch { throw ('The existing temporary-administrator state is invalid. Refusing to change membership: ' + $_.Exception.Message) } } $ExistingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if (($null -ne $ExistingTask) -and -not $HadValidState) { throw ('A scheduled task named ' + $TaskName + ' exists without valid script-owned state. Refusing to overwrite it.') } $IsAdministrator = Test-DirectAdministratorMembership -UserSid $UserSid if ($IsAdministrator -and -not $HadValidState) { Write-TempAdminLog ($UserName + ' was already an administrator without script-owned temporary state. No removal task was created.') $ExitCode = 0 } else { $MembershipOwned = $true $LeaseId = [Guid]::NewGuid().ToString('N') $RemovalTime = (Get-Date).AddMinutes($DurationMinutes) $State = [ordered]@{ Version = 2 UserSid = $UserSid LeaseId = $LeaseId ExpiresAtUtc = $RemovalTime.ToUniversalTime().ToString('o') AddedByScript = $true LastRenewedUtc = [DateTimeOffset]::UtcNow.ToString('o') } $StateJson = $State | ConvertTo-Json -Compress Write-AtomicAsciiFile -LiteralPath $StateFile -Content $StateJson $PersistedState = Get-Content -LiteralPath $StateFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop if (($PersistedState.Version -ne 2) -or ($PersistedState.UserSid -ne $UserSid) -or ($PersistedState.LeaseId -ne $LeaseId) -or ($PersistedState.ExpiresAtUtc -ne $State.ExpiresAtUtc) -or ($PersistedState.AddedByScript -ne $true)) { throw 'The temporary-administrator state did not persist exactly as expected.' } $CleanupTemplate = @' $ErrorActionPreference = 'Stop' $AdminGroupSid = 'S-1-5-32-544' $SystemSid = 'S-1-5-18' $UserSid = '__USER_SID__' $TaskName = '__TASK_NAME__' $StateFile = '__STATE_FILE__' $CleanupScript = '__CLEANUP_SCRIPT__' $LogFile = '__LOG_FILE__' $MutexName = 'Global\FileWave-Temporary-Admin-Workspace' $Mutex = $null $MutexAcquired = $false $WorkspaceTrusted = $false $ExitCode = 1 function Write-CleanupLog { param([string]$Message) $Line = [DateTimeOffset]::UtcNow.ToString("yyyy-MM-dd HH:mm:ss 'UTC'") + ' ' + $Message Write-Output $Line try { if ($script:WorkspaceTrusted) { $Line | Out-File -FilePath $LogFile -Append -Encoding ASCII -ErrorAction Stop } } catch { $null = $_ } } function Test-DirectAdministratorMembership { $Members = @(Get-LocalGroupMember -SID $AdminGroupSid -ErrorAction Stop) foreach ($Member in $Members) { if ($Member.SID.Value -eq $UserSid) { return $true } } return $false } try { $CurrentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent() if ($CurrentIdentity.User.Value -ne $SystemSid) { throw 'Cleanup must run as Local System.' } if (-not [Environment]::Is64BitProcess -or $PSVersionTable.PSEdition -ne 'Desktop' -or $PSVersionTable.PSVersion.Major -ne 5) { throw 'Cleanup must run in native 64-bit Windows PowerShell 5.1.' } foreach ($CommandName in @('Get-LocalUser', 'Get-LocalGroupMember', 'Remove-LocalGroupMember', 'Unregister-ScheduledTask')) { if ($null -eq (Get-Command -Name $CommandName -ErrorAction SilentlyContinue)) { throw ('Required cleanup command is unavailable: ' + $CommandName) } } $Workspace = Split-Path -Parent $StateFile foreach ($Path in @($Workspace, $StateFile, $CleanupScript, $LogFile)) { if (-not (Test-Path -LiteralPath $Path)) { if ($Path -eq $StateFile) { continue } throw ('Required cleanup artifact is missing: ' + $Path) } $Item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw ('Refusing reparse-point cleanup artifact: ' + $Path) } $Acl = Get-Acl -LiteralPath $Path -ErrorAction Stop if (-not $Acl.AreAccessRulesProtected) { throw ('Cleanup artifact inherits permissions: ' + $Path) } $OwnerSidValue = $Acl.Owner if ($OwnerSidValue -ne $SystemSid) { $OwnerSidValue = (New-Object System.Security.Principal.NTAccount($Acl.Owner)).Translate([System.Security.Principal.SecurityIdentifier]).Value } if ($OwnerSidValue -ne $SystemSid) { throw ('Local System does not own cleanup artifact: ' + $Path) } } $script:WorkspaceTrusted = $true $Mutex = New-Object System.Threading.Mutex($false, $MutexName) try { $MutexAcquired = $Mutex.WaitOne([TimeSpan]::FromSeconds(30)) } catch [System.Threading.AbandonedMutexException] { $MutexAcquired = $true } if (-not $MutexAcquired) { throw 'Timed out waiting for another temporary-administrator operation to finish.' } if (-not (Test-Path -LiteralPath $StateFile)) { if (Test-DirectAdministratorMembership) { throw 'State is missing while direct administrator membership remains. Refusing an untracked removal.' } Write-CleanupLog 'State was already absent and administrator membership was confirmed absent. Removing orphaned cleanup artifacts.' Remove-Item -LiteralPath $CleanupScript -Force -ErrorAction Stop if (Test-Path -LiteralPath $CleanupScript) { throw 'The orphaned cleanup script could not be removed.' } Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop if ($null -ne (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue)) { throw 'The orphaned cleanup task could not be unregistered.' } $ExitCode = 0 } else { $State = Get-Content -LiteralPath $StateFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop if (($State.Version -ne 2) -or ($State.UserSid -ne $UserSid) -or ($State.AddedByScript -ne $true)) { throw 'The temporary-administrator state is invalid or does not match this task.' } [void][Guid]::ParseExact($State.LeaseId, 'N') $ExpiresAt = [DateTimeOffset]::Parse($State.ExpiresAtUtc) if ([DateTimeOffset]::UtcNow -lt $ExpiresAt.ToUniversalTime()) { Write-CleanupLog ('Lease ' + $State.LeaseId + ' remains valid until ' + $ExpiresAt.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") + '.') $ExitCode = 0 } else { $Users = @(Get-LocalUser -ErrorAction Stop | Where-Object { $_.SID.Value -eq $UserSid }) if ($Users.Count -gt 1) { throw ('Multiple local accounts unexpectedly matched SID ' + $UserSid + '.') } $IsAdministrator = Test-DirectAdministratorMembership if ($Users.Count -eq 1) { if ($IsAdministrator) { Remove-LocalGroupMember -SID $AdminGroupSid -Member $Users[0] -Confirm:$false -ErrorAction Stop if (Test-DirectAdministratorMembership) { throw 'Administrator membership was still present after removal returned successfully.' } Write-CleanupLog ('Removed temporary administrator access from ' + $Users[0].Name + ' for lease ' + $State.LeaseId + '.') } else { Write-CleanupLog ('Temporary administrator membership was already absent for lease ' + $State.LeaseId + '.') } } elseif ($IsAdministrator) { throw ('The local account is gone but SID ' + $UserSid + ' remains in Administrators. Manual cleanup is required.') } else { Write-CleanupLog ('The temporary account with SID ' + $UserSid + ' no longer exists and membership is absent.') } Remove-Item -LiteralPath $StateFile -Force -ErrorAction Stop if (Test-Path -LiteralPath $StateFile) { throw 'The completed lease state could not be removed.' } Remove-Item -LiteralPath $CleanupScript -Force -ErrorAction Stop if (Test-Path -LiteralPath $CleanupScript) { throw 'The completed cleanup script could not be removed.' } Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop if ($null -ne (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue)) { throw 'The completed cleanup task could not be unregistered.' } $ExitCode = 0 } } } catch { Write-CleanupLog ('ERROR removing temporary administrator access: ' + $_.Exception.Message) Write-CleanupLog 'The scheduled task will retry every five minutes for seven days, daily thereafter, and at startup.' $ExitCode = 1 } finally { if ($MutexAcquired -and ($null -ne $Mutex)) { try { $Mutex.ReleaseMutex() } catch { $null = $_ } } if ($null -ne $Mutex) { $Mutex.Dispose() } } exit $ExitCode '@ $CleanupContent = $CleanupTemplate.Replace('__USER_SID__', $UserSid) $CleanupContent = $CleanupContent.Replace('__TASK_NAME__', $TaskName) $CleanupContent = $CleanupContent.Replace('__STATE_FILE__', $StateFile) $CleanupContent = $CleanupContent.Replace('__CLEANUP_SCRIPT__', $CleanupScript) $CleanupContent = $CleanupContent.Replace('__LOG_FILE__', $LogFile) Write-AtomicAsciiFile -LiteralPath $CleanupScript -Content $CleanupContent if ([IO.File]::ReadAllText($CleanupScript) -cne $CleanupContent) { throw 'The cleanup script did not persist exactly as expected.' } $PowerShellExe = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' $ActionArguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + $CleanupScript + '"' $Action = New-ScheduledTaskAction -Execute $PowerShellExe -Argument $ActionArguments # The five-minute trigger handles transient failures for seven days. # The daily and startup triggers continue reconciliation on long-running # or powered-off devices until membership removal succeeds. $ExpirationTrigger = New-ScheduledTaskTrigger -Once -At $RemovalTime -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 7) $DailyTrigger = New-ScheduledTaskTrigger -Daily -At '00:00' $StartupTrigger = New-ScheduledTaskTrigger -AtStartup $Principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest $Settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -MultipleInstances IgnoreNew -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5) Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger @($ExpirationTrigger, $DailyTrigger, $StartupTrigger) -Principal $Principal -Settings $Settings -Description ('Removes script-owned temporary administrator access for ' + $UserName + ', lease ' + $LeaseId + '.') -Force | Out-Null Assert-RegisteredCleanupTask -ExpectedPowerShell $PowerShellExe -ExpectedArguments $ActionArguments $CurrentUsers = @(Get-LocalUser -ErrorAction Stop | Where-Object { $_.SID.Value -eq $UserSid }) if (($CurrentUsers.Count -ne 1) -or (-not $CurrentUsers[0].Enabled) -or ($CurrentUsers[0].Name -cne $User.Name)) { throw 'The target local account changed before the membership transition.' } if (-not $IsAdministrator) { if (Test-DirectAdministratorMembership -UserSid $UserSid) { throw 'Administrator membership appeared outside this workflow before the grant transition.' } Add-LocalGroupMember -SID $AdminGroupSid -Member $CurrentUsers[0] -ErrorAction Stop if (-not (Test-DirectAdministratorMembership -UserSid $UserSid)) { throw 'Administrator membership was not present after the add operation returned successfully.' } Write-TempAdminLog ('Granted temporary administrator access to ' + $env:COMPUTERNAME + '\' + $UserName + ' for lease ' + $LeaseId + '.') } else { Write-TempAdminLog ('Renewed script-owned temporary administrator access for ' + $env:COMPUTERNAME + '\' + $UserName + ' with lease ' + $LeaseId + '.') } Assert-TrustedFile -LiteralPath $StateFile Assert-TrustedFile -LiteralPath $CleanupScript Assert-RegisteredCleanupTask -ExpectedPowerShell $PowerShellExe -ExpectedArguments $ActionArguments $FinalState = Get-Content -LiteralPath $StateFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop if (($FinalState.UserSid -ne $UserSid) -or ($FinalState.LeaseId -ne $LeaseId) -or ($FinalState.ExpiresAtUtc -ne $State.ExpiresAtUtc)) { throw 'Final lease-state verification failed after the membership transition.' } Write-TempAdminLog ('Lease ' + $LeaseId + ' expires at ' + $RemovalTime.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") + '. Cleanup retries every 5 minutes for 7 days, daily thereafter, and at system startup.') $ExitCode = 0 } } catch { Write-TempAdminLog ('ERROR: ' + $_.Exception.Message) # Fail closed for any membership this workflow owns. If removal fails, keep # the state and cleanup artifacts so an existing task or administrator can # still diagnose and retry the cleanup. if ($MembershipOwned -and ($null -ne $User)) { $RollbackSucceeded = $false try { if (Test-DirectAdministratorMembership -UserSid $User.SID.Value) { Remove-LocalGroupMember -SID $AdminGroupSid -Member $User -Confirm:$false -ErrorAction Stop if (Test-DirectAdministratorMembership -UserSid $User.SID.Value) { throw 'Administrator membership was still present after rollback returned successfully.' } Write-TempAdminLog ('Rolled back administrator access for ' + $UserName + '.') } else { Write-TempAdminLog ('Administrator access was already absent for ' + $UserName + '.') } $RollbackSucceeded = $true } catch { Write-TempAdminLog ('CRITICAL: Rollback failed: ' + $_.Exception.Message) } if ($RollbackSucceeded) { try { if ($null -ne (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue)) { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop } if ($null -ne (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue)) { throw 'Rollback could not unregister the cleanup task.' } foreach ($Artifact in @($StateFile, $CleanupScript)) { if (Test-Path -LiteralPath $Artifact) { Remove-Item -LiteralPath $Artifact -Force -ErrorAction Stop } if (Test-Path -LiteralPath $Artifact) { throw ('Rollback could not remove artifact: ' + $Artifact) } } } catch { Write-TempAdminLog ('CRITICAL: Membership was removed, but rollback artifacts remain: ' + $_.Exception.Message) } } } $ExitCode = 1 } finally { if ($MutexAcquired -and ($null -ne $Mutex)) { try { $Mutex.ReleaseMutex() } catch { $null = $_ } } if ($null -ne $Mutex) { $Mutex.Dispose() } } exit $ExitCode