AI Preview
Is this content worth your time? Use the quick preview to decide.
Welcome to Techaptic!
In this custom solution, we build a centralized way to control which users or security groups can sign in locally to specific Active Directory-joined workstations. Instead of creating separate GPOs for each user or computer, the policy state and allowed principals are stored on computer objects through custom Active Directory attributes and managed with a PowerShell-based admin console.
On the client side, a GPO-deployed startup script installs a scheduled task that periodically reads the latest settings from Active Directory, keeps a local policy cache and recovery baseline, and applies the required Allow log on locally configuration. We also verify the scheduled synchronization process, test denied and support-group sign-ins, remove previous access, and demonstrate the rebaseline mode used for exceptional recovery scenarios.
🔗 Helpful Links
- There are no helpful links in this one.
Video Tutorial
Used Commands
Refresh ADUC Schema Cache
Get-Process mmc -ErrorAction SilentlyContinue | Stop-Process -Force
$UserCache = "$env:LOCALAPPDATAMicrosoftWindowsSchCache"
if (Test-Path $UserCache) {
Rename-Item `
-Path $UserCache `
-NewName ("SchCache.backup-{0}" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
}Deploy Workstation Access Engine
<#
.SYNOPSIS
Techaptic - Workstation Access Engine
.DESCRIPTION
This Techaptic script manages local interactive logon access on domain-joined Windows
workstations by reading two custom Active Directory computer attributes:
- techapticLogonPolicyEnabled
- techapticLogonPrincipals
The engine is designed to be deployed as a Group Policy PowerShell startup script.
Install mode copies the engine to the protected local Techaptic directory, captures the
pre-Techaptic Allow log on locally baseline when needed, creates the scheduled sync task,
and performs an initial synchronization.
Sync mode reads the current computer policy from Active Directory, resolves configured
users and security groups to SIDs, refreshes the local policy cache, and applies the
required SeInteractiveLogonRight state. If Active Directory cannot be reached, the engine
uses the last successfully cached policy.
Rebaseline mode explicitly replaces the stored baseline with the computer's current
Allow log on locally state. Rebaseline is blocked while the Techaptic workstation policy
is enabled.
.PARAMETER Mode
Install
Installs or repairs the local engine, protects the working directory, captures the
initial baseline if required, registers the scheduled sync task, and performs a sync.
Sync
Reads the current Active Directory policy and reconciles the local
SeInteractiveLogonRight state. This is the normal scheduled-task mode.
Rebaseline
Re-captures the current local SeInteractiveLogonRight state as the new baseline.
The Techaptic workstation policy must not be enabled.
.EXAMPLE
.Techaptic-Workstation-Access-Engine.ps1 -Mode Install
.EXAMPLE
.Techaptic-Workstation-Access-Engine.ps1 -Mode Sync
.EXAMPLE
.Techaptic-Workstation-Access-Engine.ps1 -Mode Rebaseline
.NOTES
Author : Techaptic
Maintainer : Techaptic
Script : Workstation Access Engine
Execution : Group Policy PowerShell Startup Script / LocalSystem scheduled task
Local Path : C:ProgramDataTechapticWorkstationAccessTechaptic-Workstation-Access-Engine.ps1
State Files : baseline.json, policy-cache.json, engine.log
IMPORTANT OPERATIONAL NOTES:
- Deploy this script only to the intended workstation computer OU.
- The engine manages the Windows "Allow log on locally" user right
(SeInteractiveLogonRight). Avoid competing GPO, MDM, or script configuration for the
same right.
- "Deny log on locally" takes precedence over "Allow log on locally" and should be reviewed
for conflicts.
- BUILTINAdministrators is always retained while the Techaptic policy is enabled.
- A successful transition from Enabled to Disabled/Not Set restores the captured baseline
once. While the policy remains disabled, Techaptic no longer continuously enforces that
baseline.
- The baseline preserves the exported SeInteractiveLogonRight entries so SID-based and
account-name entries can be restored without changing their security-template format.
- If Active Directory policy reading fails, the last successful local policy cache is used.
- If a current AD principal cannot be resolved, only a cached SID for that same current DN
may be reused. Principals removed from the current AD attribute are never resurrected from
an older cache.
#>
param(
[ValidateSet('Install','Sync','Rebaseline')]
[string]$Mode = 'Sync'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2.0
$WorkDir = 'C:ProgramDataTechapticWorkstationAccess'
$LocalScript = Join-Path $WorkDir 'Techaptic-Workstation-Access-Engine.ps1'
$LogFile = Join-Path $WorkDir 'engine.log'
$CacheFile = Join-Path $WorkDir 'policy-cache.json'
$BaselineFile = Join-Path $WorkDir 'baseline.json'
$SyncTaskName = 'Techaptic Workstation Access - Policy Sync'
$LegacyTempPrefix = 'Techaptic Workstation Access - Temporary - '
$SyncMinutes = 30
$MaxLogBytes = 5MB
$LogArchiveCount = 3
$MutexName = 'GlobalTechapticWorkstationAccessEngine'
$AdministratorsSid = 'S-1-5-32-544'
$LocalSystemSid = 'S-1-5-18'
$script:Mutex = $null
$script:MutexHeld = $false
function Assert-Elevated {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object System.Security.Principal.WindowsPrincipal($identity)
$isSystem = ($identity.User -and $identity.User.Value -eq $LocalSystemSid)
$isAdmin = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not ($isSystem -or $isAdmin)) {
throw 'The engine must run as LocalSystem or from an elevated administrator session.'
}
}
function Initialize-WorkDirectory {
New-Item -Path $WorkDir -ItemType Directory -Force | Out-Null
}
function Protect-WorkDirectory {
$acl = New-Object System.Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
$systemSid = New-Object System.Security.Principal.SecurityIdentifier($LocalSystemSid)
$adminsSid = New-Object System.Security.Principal.SecurityIdentifier($AdministratorsSid)
$rights = [System.Security.AccessControl.FileSystemRights]::FullControl
$inherit = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit
$propagation = [System.Security.AccessControl.PropagationFlags]::None
$allow = [System.Security.AccessControl.AccessControlType]::Allow
[void]$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($systemSid,$rights,$inherit,$propagation,$allow)))
[void]$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($adminsSid,$rights,$inherit,$propagation,$allow)))
$acl.SetOwner($adminsSid)
Set-Acl -Path $WorkDir -AclObject $acl
}
function Enter-EngineMutex {
$script:Mutex = New-Object System.Threading.Mutex($false, $MutexName)
try {
$script:MutexHeld = $script:Mutex.WaitOne([TimeSpan]::FromMinutes(2))
}
catch [System.Threading.AbandonedMutexException] {
$script:MutexHeld = $true
}
if (-not $script:MutexHeld) {
throw 'Another engine instance is still running.'
}
}
function Exit-EngineMutex {
if ($script:Mutex) {
if ($script:MutexHeld) {
try { $script:Mutex.ReleaseMutex() } catch { }
}
try { $script:Mutex.Dispose() } catch { }
}
}
function Rotate-LogIfNeeded {
if (-not (Test-Path $LogFile)) { return }
try {
if ((Get-Item $LogFile).Length -lt $MaxLogBytes) { return }
for ($i = $LogArchiveCount; $i -ge 1; $i--) {
$current = "$LogFile.$i"
if ($i -eq $LogArchiveCount) {
Remove-Item $current -Force -ErrorAction SilentlyContinue
}
elseif (Test-Path $current) {
Move-Item $current "$LogFile.$($i + 1)" -Force
}
}
Move-Item $LogFile "$LogFile.1" -Force
}
catch { }
}
function Write-Log {
param([Parameter(Mandatory)][string]$Message)
Rotate-LogIfNeeded
$line = '{0} [{1}] [{2}] {3}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz'), $env:COMPUTERNAME, $Mode, $Message
Add-Content -Path $LogFile -Value $line -Encoding UTF8
$color = [ConsoleColor]::Gray
if ($Message -like 'ERROR:*') {
$color = [ConsoleColor]::Red
}
elseif ($Message -like 'WARNING:*') {
$color = [ConsoleColor]::Yellow
}
elseif (
$Message -like 'Applied *' -or
$Message -like 'Restored *' -or
$Message -like 'Captured *' -or
$Message -like 'Installed *' -or
$Message -like 'Registered *' -or
$Message -like '*completed successfully*'
) {
$color = [ConsoleColor]::Green
}
Write-Host $line -ForegroundColor $color
}
function Write-JsonAtomic {
param(
[Parameter(Mandatory)]$InputObject,
[Parameter(Mandatory)][string]$Path,
[int]$Depth = 6
)
$temp = Join-Path (Split-Path $Path -Parent) ('.{0}.{1}.tmp' -f (Split-Path $Path -Leaf), [guid]::NewGuid().ToString('N'))
try {
$InputObject | ConvertTo-Json -Depth $Depth | Set-Content -Path $temp -Encoding UTF8
if (Test-Path $Path) {
try { [System.IO.File]::Replace($temp, $Path, $null) }
catch {
Remove-Item $Path -Force
Move-Item $temp $Path -Force
}
}
else {
Move-Item $temp $Path -Force
}
}
finally {
Remove-Item $temp -Force -ErrorAction SilentlyContinue
}
}
function Close-Disposable {
param($Object)
if ($null -eq $Object) { return }
try {
if ($Object -is [System.DirectoryServices.DirectoryEntry]) {
try { $Object.Close() } catch { }
}
([System.IDisposable]$Object).Dispose()
}
catch { }
}
function Escape-LdapFilterValue {
param([Parameter(Mandatory)][string]$Value)
$Value.Replace('','5c').Replace('*','2a').Replace('(','28').Replace(')','29').Replace(([char]0).ToString(),'0')
}
function Convert-ToBoolean {
param($Value)
if ($Value -is [bool]) { return $Value }
return ([string]$Value).Trim().Equals('TRUE',[System.StringComparison]::OrdinalIgnoreCase)
}
function Get-PrincipalFromDn {
param([Parameter(Mandatory)][string]$DistinguishedName)
$entry = $null
try {
$entry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$DistinguishedName")
$entry.AuthenticationType = [System.DirectoryServices.AuthenticationTypes]::Secure
$entry.RefreshCache(@('objectSid','sAMAccountName'))
$sidBytes = [byte[]]$entry.Properties['objectSid'].Value
if (-not $sidBytes) { throw "objectSid is missing for $DistinguishedName" }
$sid = New-Object System.Security.Principal.SecurityIdentifier($sidBytes,0)
[pscustomobject]@{
DistinguishedName = $DistinguishedName
SamAccountName = [string]$entry.Properties['sAMAccountName'].Value
Sid = $sid.Value
ObjectClass = [string]$entry.SchemaClassName
}
}
finally {
Close-Disposable $entry
}
}
function Get-TechapticComputerPolicy {
$root = $null
$searchRoot = $null
$searcher = $null
try {
$root = New-Object System.DirectoryServices.DirectoryEntry('LDAP://RootDSE')
$root.AuthenticationType = [System.DirectoryServices.AuthenticationTypes]::Secure
$defaultNamingContext = [string]$root.Properties['defaultNamingContext'].Value
if ([string]::IsNullOrWhiteSpace($defaultNamingContext)) {
throw 'Unable to determine defaultNamingContext from RootDSE.'
}
$searchRoot = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$defaultNamingContext")
$searchRoot.AuthenticationType = [System.DirectoryServices.AuthenticationTypes]::Secure
$searcher = New-Object System.DirectoryServices.DirectorySearcher($searchRoot)
$sam = Escape-LdapFilterValue("$($env:COMPUTERNAME)`$")
$searcher.Filter = "(&(objectCategory=computer)(sAMAccountName=$sam))"
$searcher.PageSize = 1
@('distinguishedName','techapticLogonPolicyEnabled','techapticLogonPrincipals') |
ForEach-Object { [void]$searcher.PropertiesToLoad.Add($_) }
$result = $searcher.FindOne()
if (-not $result) {
throw "Computer object for $env:COMPUTERNAME was not found in Active Directory."
}
$p = $result.Properties
$enabled = $false
if ($p['techapticlogonpolicyenabled'].Count -gt 0) {
$enabled = Convert-ToBoolean $p['techapticlogonpolicyenabled'][0]
}
$principalDns = @()
if ($p['techapticlogonprincipals'].Count -gt 0) {
$principalDns = @($p['techapticlogonprincipals'] | ForEach-Object { [string]$_ })
}
[pscustomobject]@{
ComputerDn = [string]$p['distinguishedname'][0]
Enabled = $enabled
PrincipalDns = $principalDns
}
}
finally {
Close-Disposable $searcher
Close-Disposable $searchRoot
Close-Disposable $root
}
}
function Get-SeInteractiveLogonEntries {
$tmp = Join-Path $WorkDir ('rights-{0}.inf' -f [guid]::NewGuid().ToString('N'))
try {
& secedit.exe /export /cfg $tmp /areas USER_RIGHTS /quiet | Out-Null
if ($LASTEXITCODE -ne 0) { throw "secedit export failed with exit code $LASTEXITCODE" }
$line = Get-Content $tmp | Where-Object { $_ -match '^SeInteractiveLogonRights*=' } | Select-Object -First 1
if (-not $line) { return @() }
$value = ($line -split '=',2)[1].Trim()
if ([string]::IsNullOrWhiteSpace($value)) { return @() }
return @(
$value -split ',' |
ForEach-Object { $_.Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
}
finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
function Convert-SeInteractiveLogonEntryToComparableValue {
param([Parameter(Mandatory)][string]$Entry)
$value = $Entry.Trim()
if ([string]::IsNullOrWhiteSpace($value)) { return $null }
if ($value.StartsWith('*')) {
$sidCandidate = $value.Substring(1)
if ($sidCandidate -match '^S-d-(?:d+-)*d+$') {
return $sidCandidate.ToUpperInvariant()
}
# A leading asterisk denotes a SID-form security-template entry. If the
# remaining value is not a SID, keep it distinct rather than treating it
# as an account name during comparison.
return ('RAW:{0}' -f $value.ToUpperInvariant())
}
if ($value -match '^S-d-(?:d+-)*d+$') {
return $value.ToUpperInvariant()
}
try {
$account = New-Object -TypeName System.Security.Principal.NTAccount -ArgumentList $value
$sid = $account.Translate([System.Security.Principal.SecurityIdentifier])
return $sid.Value.ToUpperInvariant()
}
catch {
return ('NAME:{0}' -f $value.ToUpperInvariant())
}
}
function Get-SeInteractiveLogonComparableValues {
param([Parameter(Mandatory)][string[]]$Entries)
$values = @()
foreach ($entry in @($Entries)) {
if ([string]::IsNullOrWhiteSpace([string]$entry)) { continue }
$comparable = Convert-SeInteractiveLogonEntryToComparableValue -Entry ([string]$entry)
if (-not [string]::IsNullOrWhiteSpace([string]$comparable)) {
$values += $comparable
}
}
return @($values | Sort-Object -Unique)
}
function Write-Baseline {
param(
[Parameter(Mandatory)][string[]]$Entries,
[switch]$ReplaceExisting
)
$cleanEntries = @(
$Entries |
ForEach-Object { ([string]$_).Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -Unique
)
if ($cleanEntries.Count -eq 0) {
throw 'Refusing to save an empty SeInteractiveLogonRight baseline.'
}
if ((Test-Path $BaselineFile) -and $ReplaceExisting) {
$backup = '{0}.bak-{1}' -f $BaselineFile,(Get-Date -Format 'yyyyMMdd-HHmmss')
Copy-Item -Path $BaselineFile -Destination $backup -Force
Write-Log "Backed up previous baseline to $backup"
}
$baseline = [ordered]@{
SchemaVersion = 2
ComputerName = $env:COMPUTERNAME
CapturedUtc = [datetime]::UtcNow.ToString('o')
Entries = $cleanEntries
}
Write-JsonAtomic -InputObject $baseline -Path $BaselineFile -Depth 4
Write-Log "Captured baseline SeInteractiveLogonRight in $BaselineFile"
}
function Save-BaselineIfNeeded {
if (Test-Path $BaselineFile) {
try {
$existing = Get-Content $BaselineFile -Raw | ConvertFrom-Json
$computerProperty = $existing.PSObject.Properties['ComputerName']
if ($computerProperty -and $computerProperty.Value -and ([string]$computerProperty.Value -ne $env:COMPUTERNAME)) {
throw "Baseline belongs to '$($computerProperty.Value)', not '$env:COMPUTERNAME'."
}
}
catch {
throw "Existing baseline file is invalid or unsafe to use: $($_.Exception.Message)"
}
return
}
$entries = @(Get-SeInteractiveLogonEntries)
Write-Baseline -Entries $entries
}
function Apply-SeInteractiveLogonEntries {
param([Parameter(Mandatory)][string[]]$Entries)
$desiredEntries = @(
$Entries |
ForEach-Object { ([string]$_).Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -Unique
)
if ($desiredEntries.Count -eq 0) {
throw 'Refusing to apply an empty SeInteractiveLogonRight.'
}
$currentEntries = @(Get-SeInteractiveLogonEntries)
$desiredComparable = @(Get-SeInteractiveLogonComparableValues -Entries $desiredEntries)
$currentComparable = @(Get-SeInteractiveLogonComparableValues -Entries $currentEntries)
$diff = @(Compare-Object -ReferenceObject $currentComparable -DifferenceObject $desiredComparable)
if ($diff.Count -eq 0) {
Write-Log ("SeInteractiveLogonRight already matches desired state ({0} entry/entries)." -f $desiredEntries.Count)
return
}
$id = [guid]::NewGuid().ToString('N')
$cfg = Join-Path $WorkDir "apply-rights-$id.inf"
$db = Join-Path $WorkDir "apply-rights-$id.sdb"
$entryText = $desiredEntries -join ','
$inf = @"
[Unicode]
Unicode=yes
[Version]
signature="`$CHICAGO`$"
Revision=1
[Privilege Rights]
SeInteractiveLogonRight = $entryText
"@
try {
Set-Content -Path $cfg -Value $inf -Encoding Unicode
& secedit.exe /configure /db $db /cfg $cfg /areas USER_RIGHTS /quiet | Out-Null
if ($LASTEXITCODE -ne 0) { throw "secedit configure failed with exit code $LASTEXITCODE" }
$actualEntries = @(Get-SeInteractiveLogonEntries)
$actualComparable = @(Get-SeInteractiveLogonComparableValues -Entries $actualEntries)
$verifyDiff = @(Compare-Object -ReferenceObject $actualComparable -DifferenceObject $desiredComparable)
if ($verifyDiff.Count -ne 0) {
throw 'SeInteractiveLogonRight verification failed after secedit configuration.'
}
Write-Log ("Applied and verified SeInteractiveLogonRight with {0} entry/entries." -f $desiredEntries.Count)
}
finally {
Get-ChildItem -LiteralPath $WorkDir -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "apply-rights-$id*" } |
Remove-Item -Force -ErrorAction SilentlyContinue
}
}
function Apply-SeInteractiveLogonRight {
param([Parameter(Mandatory)][string[]]$Sids)
$desiredSids = @($Sids | Where-Object { $_ } | Sort-Object -Unique)
if ($desiredSids.Count -eq 0) { throw 'Refusing to apply an empty SeInteractiveLogonRight.' }
foreach ($sid in $desiredSids) {
if ([string]$sid -notmatch '^S-d-(?:d+-)*d+$') {
throw "Invalid SID supplied for Techaptic-managed SeInteractiveLogonRight: $sid"
}
}
$entries = @($desiredSids | ForEach-Object { "*$_" })
Apply-SeInteractiveLogonEntries -Entries $entries
}
function Restore-Baseline {
if (-not (Test-Path $BaselineFile)) {
Write-Log 'Policy disabled, but no baseline exists. No local change made.'
return
}
$baseline = Get-Content $BaselineFile -Raw | ConvertFrom-Json
$computerProperty = $baseline.PSObject.Properties['ComputerName']
if ($computerProperty -and $computerProperty.Value -and ([string]$computerProperty.Value -ne $env:COMPUTERNAME)) {
throw "Refusing to restore baseline captured for '$($computerProperty.Value)' on '$env:COMPUTERNAME'."
}
$entries = @()
$entriesProperty = $baseline.PSObject.Properties['Entries']
if ($entriesProperty) {
$entries = @($entriesProperty.Value)
}
else {
# Backward compatibility for baselines created by the earlier engine.
# The old property was named "Sids", but secedit can export account-name
# entries as well. Preserve names as names and add '*' only to real SIDs.
$legacySidsProperty = $baseline.PSObject.Properties['Sids']
if ($legacySidsProperty) {
foreach ($item in @($legacySidsProperty.Value)) {
$value = ([string]$item).Trim()
if ([string]::IsNullOrWhiteSpace($value)) { continue }
if ($value.StartsWith('*')) {
$entries += $value
}
elseif ($value -match '^S-d-(?:d+-)*d+$') {
$entries += "*$value"
}
else {
$entries += $value
}
}
}
}
if ($entries.Count -eq 0) {
throw 'Baseline entry list is empty. Refusing to restore.'
}
Apply-SeInteractiveLogonEntries -Entries $entries
Write-Log 'Restored and verified baseline SeInteractiveLogonRight.'
}
function Load-Cache {
if (-not (Test-Path $CacheFile)) { return $null }
try { $cache = Get-Content $CacheFile -Raw | ConvertFrom-Json }
catch { throw "Local policy cache is unreadable: $($_.Exception.Message)" }
$computerProperty = $cache.PSObject.Properties['ComputerName']
if ($computerProperty -and $computerProperty.Value -and ([string]$computerProperty.Value -ne $env:COMPUTERNAME)) {
throw "Local policy cache belongs to '$($computerProperty.Value)', not '$env:COMPUTERNAME'."
}
return $cache
}
function Get-CachedPrincipalForDn {
param(
$Cache,
[Parameter(Mandatory)][string]$DistinguishedName
)
if ($null -eq $Cache) { return $null }
foreach ($principal in @($Cache.PermanentPrincipals)) {
if ($null -eq $principal) { continue }
$dnProperty = $principal.PSObject.Properties['DistinguishedName']
$sidProperty = $principal.PSObject.Properties['Sid']
if (-not $dnProperty -or -not $sidProperty) { continue }
$cachedDn = [string]$dnProperty.Value
$cachedSid = [string]$sidProperty.Value
if (
-not [string]::IsNullOrWhiteSpace($cachedDn) -and
-not [string]::IsNullOrWhiteSpace($cachedSid) -and
$cachedDn.Equals($DistinguishedName,[System.StringComparison]::OrdinalIgnoreCase)
) {
return $principal
}
}
return $null
}
function Get-EffectiveSidsFromCache {
param([Parameter(Mandatory)]$Cache)
$sids = @($AdministratorsSid)
foreach ($p in @($Cache.PermanentPrincipals)) {
if ($p.Sid) { $sids += [string]$p.Sid }
}
return @($sids | Sort-Object -Unique)
}
function Reconcile-FromCache {
$cache = Load-Cache
if (-not $cache) {
Write-Log 'No local policy cache exists. No local changes made.'
return
}
if (-not [bool]$cache.PolicyEnabled) {
Write-Log 'Cached policy is disabled. Techaptic is not managing SeInteractiveLogonRight.'
return
}
Apply-SeInteractiveLogonRight -Sids (Get-EffectiveSidsFromCache -Cache $cache)
}
function Get-NextHalfHourSlot {
$now = Get-Date
$hour = $now.Date.AddHours($now.Hour)
if ($now.Minute -lt 30) { return $hour.AddMinutes(30) }
return $hour.AddHours(1)
}
function Get-TaskPrincipal {
New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
}
function Get-TaskSettings {
New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-MultipleInstances IgnoreNew `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5)
}
function Register-SyncTask {
$next = Get-NextHalfHourSlot
$repeat = New-ScheduledTaskTrigger -Once -At $next -RepetitionInterval (New-TimeSpan -Minutes $SyncMinutes) -RepetitionDuration (New-TimeSpan -Days 3650)
$startup = New-ScheduledTaskTrigger -AtStartup
$ps = "$env:SystemRootSystem32WindowsPowerShellv1.0powershell.exe"
$action = New-ScheduledTaskAction -Execute $ps -Argument "-NoProfile -NonInteractive -File `"$LocalScript`" -Mode Sync"
Register-ScheduledTask `
-TaskName $SyncTaskName `
-Action $action `
-Trigger @($startup,$repeat) `
-Principal (Get-TaskPrincipal) `
-Settings (Get-TaskSettings) `
-Description "Techaptic assigned-workstation policy sync. Startup + :00/:30." `
-Force | Out-Null
Write-Log ("Registered policy sync task. Next clock-aligned sync: {0}" -f $next)
}
function Remove-LegacyTemporaryTasks {
$removed = 0
foreach ($suffix in @('Start','Warn15','Warn5','Expire','HardLogoff')) {
$name = $LegacyTempPrefix + $suffix
if (Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
$removed++
}
}
if ($removed -gt 0) {
Write-Log "Removed $removed legacy temporary-access scheduled task(s)."
}
}
function Invoke-Sync {
Save-BaselineIfNeeded
Remove-LegacyTemporaryTasks
try {
$policy = Get-TechapticComputerPolicy
}
catch {
Write-Log ("WARNING: AD policy read failed: {0}. Falling back to the last successful local cache." -f $_.Exception.Message)
Reconcile-FromCache
return
}
$previousCache = $null
try {
$previousCache = Load-Cache
}
catch {
Write-Log ("WARNING: Existing local policy cache could not be read and will not be used for principal fallback: {0}" -f $_.Exception.Message)
}
if (-not $policy.Enabled) {
$wasPreviouslyEnabled = $false
if ($null -ne $previousCache) {
$enabledProperty = $previousCache.PSObject.Properties['PolicyEnabled']
if ($enabledProperty) {
$wasPreviouslyEnabled = [bool]$enabledProperty.Value
}
}
if ($wasPreviouslyEnabled) {
Restore-Baseline
Write-Log 'Policy transitioned to disabled. Baseline restored once.'
}
else {
Write-Log 'Policy is disabled. Techaptic is not managing SeInteractiveLogonRight.'
}
$cache = [ordered]@{
SchemaVersion = 1
ComputerName = $env:COMPUTERNAME
ComputerDn = $policy.ComputerDn
LastSuccessfulSyncUtc = [datetime]::UtcNow.ToString('o')
PolicyEnabled = $false
PermanentPrincipals = @()
}
Write-JsonAtomic -InputObject $cache -Path $CacheFile -Depth 5
Write-Log "Policy cache updated: $CacheFile"
return
}
$principals = @()
foreach ($dn in @($policy.PrincipalDns)) {
try {
$principal = Get-PrincipalFromDn -DistinguishedName $dn
$principals += $principal
Write-Log ("Allowed principal resolved: {0} ({1}) -> {2}" -f $principal.SamAccountName,$principal.ObjectClass,$principal.Sid)
}
catch {
$cachedPrincipal = Get-CachedPrincipalForDn -Cache $previousCache -DistinguishedName $dn
if ($null -ne $cachedPrincipal) {
$principals += $cachedPrincipal
Write-Log ("WARNING: Current principal DN could not be resolved from AD; reusing the cached SID only for the same current DN: {0} -> {1}" -f $dn,[string]$cachedPrincipal.Sid)
}
else {
Write-Log ("WARNING: Current principal DN could not be resolved and no same-DN cached SID exists. The principal will not be applied during this sync: {0}. Error: {1}" -f $dn,$_.Exception.Message)
}
}
}
if ($principals.Count -eq 0) {
Write-Log 'WARNING: Policy is enabled with no resolved principals. Only BUILTINAdministrators will retain local interactive logon.'
}
$cache = [ordered]@{
SchemaVersion = 1
ComputerName = $env:COMPUTERNAME
ComputerDn = $policy.ComputerDn
LastSuccessfulSyncUtc = [datetime]::UtcNow.ToString('o')
PolicyEnabled = $true
PermanentPrincipals = @($principals)
}
Write-JsonAtomic -InputObject $cache -Path $CacheFile -Depth 6
Write-Log "Policy cache updated: $CacheFile"
Reconcile-FromCache
Write-Log 'AD policy sync completed successfully.'
}
function Invoke-Rebaseline {
$policy = Get-TechapticComputerPolicy
if ($policy.Enabled) {
throw 'Rebaseline is blocked while Techaptic workstation policy is enabled. Run Rebaseline only while the policy is disabled or not set.'
}
$entries = @(Get-SeInteractiveLogonEntries)
Write-Baseline -Entries $entries -ReplaceExisting
Write-Log 'Baseline was explicitly re-captured while Techaptic policy was disabled.'
}
function Invoke-Install {
if ($PSCommandPath -ne $LocalScript) {
Copy-Item -Path $PSCommandPath -Destination $LocalScript -Force
Unblock-File -Path $LocalScript -ErrorAction SilentlyContinue
Write-Log "Installed engine locally to $LocalScript"
}
else {
Write-Log "Engine install/repair invoked from local path."
}
Protect-WorkDirectory
Save-BaselineIfNeeded
Remove-LegacyTemporaryTasks
Register-SyncTask
Invoke-Sync
}
$exitCode = 0
try {
Assert-Elevated
Initialize-WorkDirectory
Protect-WorkDirectory
Enter-EngineMutex
switch ($Mode) {
'Install' { Invoke-Install }
'Sync' { Invoke-Sync }
'Rebaseline' { Invoke-Rebaseline }
}
}
catch {
try { Write-Log ("ERROR: {0}" -f $_.Exception.Message) }
catch { Write-Error $_.Exception.Message }
$exitCode = 1
}
finally {
Exit-EngineMutex
}
exit $exitCodeManage Local Sign-In Access
<#
.SYNOPSIS
Techaptic - Workstation Access Admin Console
.DESCRIPTION
This Techaptic administration console manages workstation sign-in assignments stored in
Active Directory computer objects.
The console works with these custom Active Directory attributes:
- techapticLogonPolicyEnabled
- techapticLogonPrincipals
It provides an interactive interface for checking the current workstation-access state,
enabling or disabling the Techaptic workstation policy, and adding or removing allowed
Active Directory principals.
User and security-group input is resolved automatically to the required Active Directory
distinguished name before it is stored on the computer object. The console accepts users
and security-enabled groups only.
This console changes Active Directory policy data only. It does not push changes directly
to client computers. Endpoints apply the current policy through the Techaptic Workstation
Access Engine at startup or during the scheduled synchronization cycle.
.REQUIREMENTS
- Windows PowerShell with the ActiveDirectory module available.
- Sufficient Active Directory permissions to read and modify the Techaptic custom
attributes on the target computer objects.
- The Techaptic schema attributes must already exist and be available on the computer class.
- The Techaptic Workstation Access Engine must be deployed to the intended endpoints for
policy enforcement.
.OPERATION
Status
Shows whether the Techaptic workstation policy is Enabled, Disabled, or Not set and
lists the principals currently stored on the selected computer object.
Enable Policy
Sets techapticLogonPolicyEnabled to TRUE for the selected computer. If no principals
are configured, the console requires explicit confirmation because the endpoint engine
will retain only BUILTINAdministrators for local interactive logon.
Disable Policy
Sets techapticLogonPolicyEnabled to FALSE. The endpoint engine handles the corresponding
local policy transition during its next successful synchronization.
Add Allowed Principal
Resolves an Active Directory user or security-enabled group and stores its distinguished
name in techapticLogonPrincipals.
Remove Allowed Principal
Removes one selected distinguished name from techapticLogonPrincipals.
Clear All Allowed Principals
Removes all configured principals from the selected computer object. If the policy is
enabled, the console warns that only BUILTINAdministrators will remain after endpoint
synchronization.
.NOTES
Author : Techaptic
Maintainer : Techaptic
Script : Workstation Access Admin Console
Execution : Interactive administrative PowerShell session
IMPORTANT OPERATIONAL NOTES:
- Run the console with an account that has the required Active Directory permissions.
- This console manages Active Directory configuration only; it does not directly modify the
local "Allow log on locally" setting on endpoints.
- Endpoint changes are applied by the Techaptic Workstation Access Engine at startup or the
next scheduled synchronization.
- Only Active Directory users and security-enabled groups are accepted as allowed principals.
- Disabling the policy does not delete stored principal assignments from Active Directory;
they remain stored but inactive until the policy is enabled again.
- Review "Deny log on locally" and any other GPO, MDM, or script that can conflict with local
interactive logon rights on the managed endpoints.
#>
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2.0
Import-Module ActiveDirectory -ErrorAction Stop
$PolicyAttribute = 'techapticLogonPolicyEnabled'
$PrincipalsAttribute = 'techapticLogonPrincipals'
function Write-Rule {
param([ConsoleColor]$Color = [ConsoleColor]::DarkGray)
Write-Host ('-' * 68) -ForegroundColor $Color
}
function Write-Title {
Clear-Host
Write-Host ('=' * 68) -ForegroundColor Cyan
Write-Host ' TECHAPTIC WORKSTATION ACCESS' -ForegroundColor Cyan
Write-Host ' Admin Console' -ForegroundColor DarkCyan
Write-Host ('=' * 68) -ForegroundColor Cyan
Write-Host 'Principal = Active Directory user or security group' -ForegroundColor DarkGray
Write-Host 'AD changes apply on the endpoint at startup or the next :00/:30 sync.' -ForegroundColor DarkGray
Write-Host 'User/group input is resolved to the required AD distinguished name automatically.' -ForegroundColor DarkGray
Write-Host 'At an input prompt, press Enter twice to return to the main menu.' -ForegroundColor DarkGray
Write-Host ''
}
function Write-Section {
param([Parameter(Mandatory)][string]$Text)
Write-Host ''
Write-Host ("[ {0} ]" -f $Text) -ForegroundColor Cyan
}
function Write-Success {
param([Parameter(Mandatory)][string]$Text)
Write-Host $Text -ForegroundColor Green
}
function Write-WarningText {
param([Parameter(Mandatory)][string]$Text)
Write-Host $Text -ForegroundColor Yellow
}
function Write-ErrorText {
param([Parameter(Mandatory)][string]$Text)
Write-Host ("ERROR: {0}" -f $Text) -ForegroundColor Red
}
function Wait-ForMenu {
Write-Host ''
Write-Rule
[void](Read-Host 'Press Enter to return to the main menu')
}
function Escape-LdapFilterValue {
param([Parameter(Mandatory)][string]$Value)
$Value.Replace('','5c').Replace('*','2a').Replace('(','28').Replace(')','29').Replace(([char]0).ToString(),'0')
}
function Confirm-DefaultYes {
param([Parameter(Mandatory)][string]$Prompt)
while ($true) {
Write-Host -NoNewline ("{0} [Y/n]: " -f $Prompt) -ForegroundColor Yellow
$answer = (Read-Host).Trim()
if ([string]::IsNullOrWhiteSpace($answer) -or $answer -match '^(?i:y|yes)$') { return $true }
if ($answer -match '^(?i:n|no)$') { return $false }
Write-WarningText 'Please enter Y or N.'
}
}
function Confirm-DefaultNo {
param([Parameter(Mandatory)][string]$Prompt)
while ($true) {
Write-Host -NoNewline ("{0} [y/N]: " -f $Prompt) -ForegroundColor Yellow
$answer = (Read-Host).Trim()
if ([string]::IsNullOrWhiteSpace($answer) -or $answer -match '^(?i:n|no)$') { return $false }
if ($answer -match '^(?i:y|yes)$') { return $true }
Write-WarningText 'Please enter Y or N.'
}
}
function Request-MainMenu {
if (Confirm-DefaultYes 'Return to the main menu?') {
throw [System.OperationCanceledException]::new('ReturnToMainMenu')
}
}
function Read-InputOrBack {
param([Parameter(Mandatory)][string]$Prompt)
while ($true) {
Write-Host -NoNewline ("{0}: " -f $Prompt) -ForegroundColor Yellow
$value = (Read-Host).Trim()
if (-not [string]::IsNullOrWhiteSpace($value)) { return $value }
Request-MainMenu
}
}
function Resolve-Computer {
param([Parameter(Mandatory)][string]$InputValue)
$escaped = Escape-LdapFilterValue $InputValue.Trim().TrimEnd('$')
$matches = @(Get-ADComputer -LDAPFilter "(|(name=$escaped)(sAMAccountName=$escaped`$))" -Properties $PolicyAttribute,$PrincipalsAttribute)
if ($matches.Count -eq 0) { throw "Computer '$InputValue' was not found." }
if ($matches.Count -gt 1) { throw "Computer '$InputValue' is ambiguous." }
return $matches[0]
}
function Resolve-Principal {
param([Parameter(Mandatory)][string]$InputValue)
$escaped = Escape-LdapFilterValue $InputValue.Trim()
$filter = "(|(&(objectCategory=person)(objectClass=user)(|(sAMAccountName=$escaped)(userPrincipalName=$escaped)(displayName=$escaped)(name=$escaped)))(&(objectCategory=group)(groupType:1.2.840.113556.1.4.803:=2147483648)(|(sAMAccountName=$escaped)(displayName=$escaped)(name=$escaped))))"
$matches = @(Get-ADObject -LDAPFilter $filter -Properties sAMAccountName,displayName,objectClass)
if ($matches.Count -eq 0) { throw "User or security group '$InputValue' was not found." }
if ($matches.Count -eq 1) { return $matches[0] }
Write-Section 'MULTIPLE MATCHES'
for ($i = 0; $i -lt $matches.Count; $i++) {
Write-Host ('[{0}] {1} [{2}] ({3})' -f ($i + 1),$matches[$i].Name,$matches[$i].ObjectClass,$matches[$i].sAMAccountName)
Write-Host (' {0}' -f $matches[$i].DistinguishedName) -ForegroundColor DarkGray
}
while ($true) {
$choice = Read-InputOrBack 'Select a number'
$number = 0
if ([int]::TryParse($choice,[ref]$number) -and $number -ge 1 -and $number -le $matches.Count) {
return $matches[$number - 1]
}
Write-WarningText 'Invalid selection.'
}
}
function Get-ConfiguredDns {
param([Parameter(Mandatory)]$Computer)
$value = $Computer.$PrincipalsAttribute
if ($null -eq $value) { return @() }
$result = New-Object System.Collections.Generic.List[string]
foreach ($item in @($value)) {
if ($null -ne $item) {
$text = [string]$item
if (-not [string]::IsNullOrWhiteSpace($text)) { $result.Add($text) }
}
}
return @($result.ToArray())
}
function Get-FreshComputer {
param([Parameter(Mandatory)]$Computer)
return Get-ADComputer -Identity $Computer.DistinguishedName -Properties $PolicyAttribute,$PrincipalsAttribute
}
function Show-ComputerState {
param([Parameter(Mandatory)]$Computer)
$fresh = Get-FreshComputer $Computer
$rawPolicy = $fresh.$PolicyAttribute
$policyState = if ($null -eq $rawPolicy) { 'Not set' } elseif ([bool]$rawPolicy) { 'Enabled' } else { 'Disabled' }
Write-Section 'CURRENT WORKSTATION ACCESS'
Write-Host -NoNewline 'Computer : ' -ForegroundColor DarkGray
Write-Host $fresh.Name -ForegroundColor White
Write-Host -NoNewline 'Policy : ' -ForegroundColor DarkGray
switch ($policyState) {
'Enabled' { Write-Host 'Enabled' -ForegroundColor Green }
'Disabled' { Write-Host 'Disabled' -ForegroundColor Red }
'Not set' { Write-Host 'Not set' -ForegroundColor Yellow }
}
Write-Host 'Configured principals:' -ForegroundColor DarkGray
$dns = @(Get-ConfiguredDns $fresh)
if ($policyState -ne 'Enabled' -and $dns.Count -gt 0) {
Write-Host ' Note: these assignments are stored in AD but are inactive while the policy is not enabled.' -ForegroundColor Yellow
}
if ($dns.Count -eq 0) {
Write-WarningText ' (none)'
if ($policyState -eq 'Enabled') {
Write-WarningText ' Policy is enabled: only BUILTINAdministrators will retain local interactive logon.'
}
return
}
foreach ($dn in $dns) {
try {
$obj = Get-ADObject -Identity $dn -Properties sAMAccountName,objectClass
$type = if ($obj.ObjectClass -eq 'group') { 'security group' } else { 'user' }
Write-Host (' - {0} [{1}] ({2})' -f $obj.Name,$type,$obj.sAMAccountName) -ForegroundColor White
}
catch {
Write-Host (' - UNRESOLVED: {0}' -f $dn) -ForegroundColor Red
}
}
}
function Select-Computer {
$name = Read-InputOrBack 'Computer name'
return Resolve-Computer $name
}
function Add-Principal {
Write-Section 'ADD ALLOWED PRINCIPAL'
$computer = Select-Computer
$inputValue = Read-InputOrBack 'AD user or security group (name, sAMAccountName, UPN, or display name)'
$principal = Resolve-Principal $inputValue
$fresh = Get-FreshComputer $computer
$current = @(Get-ConfiguredDns $fresh)
$principalDn = [string]$principal.DistinguishedName
if ($current -contains $principalDn) {
Write-WarningText 'That principal is already configured.'
Show-ComputerState $computer
return
}
Set-ADComputer -Identity $computer.DistinguishedName -Add @{ $PrincipalsAttribute = $principalDn }
Write-Success ("Added '{0}' to '{1}'." -f $principal.Name,$computer.Name)
Show-ComputerState $computer
}
function Remove-Principal {
Write-Section 'REMOVE ALLOWED PRINCIPAL'
$computer = Select-Computer
$fresh = Get-FreshComputer $computer
$current = @(Get-ConfiguredDns $fresh)
if ($current.Count -eq 0) {
Write-WarningText 'No configured principals exist.'
return
}
Write-Host ''
for ($i = 0; $i -lt $current.Count; $i++) {
try {
$obj = Get-ADObject -Identity $current[$i] -Properties sAMAccountName,objectClass
$type = if ($obj.ObjectClass -eq 'group') { 'security group' } else { 'user' }
$label = '{0} [{1}] ({2})' -f $obj.Name,$type,$obj.sAMAccountName
}
catch { $label = 'UNRESOLVED: {0}' -f $current[$i] }
Write-Host ('[{0}] {1}' -f ($i + 1),$label)
}
while ($true) {
$choice = Read-InputOrBack 'Principal number to remove'
$number = 0
if ([int]::TryParse($choice,[ref]$number) -and $number -ge 1 -and $number -le $current.Count) { break }
Write-WarningText 'Invalid selection.'
}
$dnToRemove = [string]$current[$number - 1]
Set-ADComputer -Identity $computer.DistinguishedName -Remove @{ $PrincipalsAttribute = $dnToRemove }
Write-Success 'Principal removed.'
Show-ComputerState $computer
}
function Set-PolicyState {
param([Parameter(Mandatory)][bool]$Enabled)
Write-Section $(if ($Enabled) { 'ENABLE POLICY' } else { 'DISABLE POLICY' })
$computer = Select-Computer
if ($Enabled) {
$fresh = Get-FreshComputer $computer
$current = @(Get-ConfiguredDns $fresh)
if ($current.Count -eq 0) {
Write-WarningText 'No allowed principals are configured.'
Write-WarningText 'If enabled, only BUILTINAdministrators will retain local interactive logon.'
$confirm = Read-InputOrBack 'Type ENABLE to continue'
if ($confirm -cne 'ENABLE') {
Write-WarningText 'Policy was not enabled.'
return
}
}
}
Set-ADComputer -Identity $computer.DistinguishedName -Replace @{ $PolicyAttribute = $Enabled }
Write-Success ("Policy {0} for '{1}'." -f $(if ($Enabled) { 'enabled' } else { 'disabled' }),$computer.Name)
Show-ComputerState $computer
}
function Clear-Principals {
Write-Section 'CLEAR ALL ALLOWED PRINCIPALS'
$computer = Select-Computer
$fresh = Get-FreshComputer $computer
$enabled = if ($null -eq $fresh.$PolicyAttribute) { $false } else { [bool]$fresh.$PolicyAttribute }
if (@(Get-ConfiguredDns $fresh).Count -eq 0) {
Write-WarningText 'No configured principals exist.'
return
}
Write-WarningText ("This will remove every configured principal from '{0}'." -f $computer.Name)
if ($enabled) {
Write-WarningText 'Policy is enabled: after endpoint sync, only BUILTINAdministrators will retain local interactive logon.'
}
$confirm = Read-InputOrBack 'Type YES to continue'
if ($confirm -cne 'YES') {
Write-WarningText 'No principals were removed.'
return
}
Set-ADComputer -Identity $computer.DistinguishedName -Clear $PrincipalsAttribute
Write-Success 'All configured principals were cleared.'
Show-ComputerState $computer
}
function Show-Menu {
Write-Title
Write-Host 'STATUS' -ForegroundColor DarkCyan
Write-Host ' 1 Show workstation access'
Write-Host ''
Write-Host 'POLICY' -ForegroundColor DarkCyan
Write-Host ' 2 Enable policy'
Write-Host ' 3 Disable policy'
Write-Host ''
Write-Host 'ASSIGNMENTS' -ForegroundColor DarkCyan
Write-Host ' 4 Add allowed principal (user/security group)'
Write-Host ' 5 Remove allowed principal'
Write-Host ' 6 Clear all allowed principals'
Write-Host ''
Write-Rule
Write-Host ' 0 Exit'
Write-Rule
}
while ($true) {
Show-Menu
Write-Host -NoNewline 'Select: ' -ForegroundColor Yellow
$selection = (Read-Host).Trim()
if ($selection -eq '0') {
if (Confirm-DefaultNo 'Exit Techaptic Workstation Access?') {
Write-Host 'Exiting Techaptic Workstation Access.' -ForegroundColor DarkGray
return
}
continue
}
if ([string]::IsNullOrWhiteSpace($selection)) { continue }
$skipPause = $false
try {
switch ($selection) {
'1' { Write-Section 'SHOW WORKSTATION ACCESS'; Show-ComputerState (Select-Computer) }
'2' { Set-PolicyState -Enabled $true }
'3' { Set-PolicyState -Enabled $false }
'4' { Add-Principal }
'5' { Remove-Principal }
'6' { Clear-Principals }
default { Write-WarningText 'Invalid selection.' }
}
}
catch [System.OperationCanceledException] { $skipPause = $true }
catch { Write-ErrorText $_.Exception.Message }
if (-not $skipPause) { Wait-ForMenu }
}Quiz
Full Transcript
Rating




