I’m working on a use case in Identity Security Cloud where I need to block certain users from logging into Active Directory.
The approach I’m testing is to trigger a PowerShell script via IQService that updates the logonHours attribute in AD and sets all values to zero (no allowed hours), effectively preventing logon.
From what I understand, the logonHours attribute is a 21-byte (168-bit) array that represents each hour of the week, and to prevent logon, all bits must be set to 0.
I created the following Zero-LogonHours.ps1 script, but I’d really appreciate your feedback on the logic and whether there are best practices I should follow when manipulating the logonHours attribute.
PowerShell Script (Zero-LogonHours.ps1)
#Requires -Version 7.3
param (
[Parameter(Mandatory=$true)][System.String]$requestString
)
# Import SailPoint class library
Add-Type -Path "C:\SailPoint\IQService\Utils.dll"
# Import Active Directory module
Import-Module ActiveDirectory
# Logging setup
$logDate = Get-Date -UFormat "%Y%m%d"
$logFile = "C:\SailPoint\Scripts\Logs\ManageLogonHours_$logDate.log"
$enableDebug = $true
function LogToFile([String] $info) {
$timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
"[LOGS] $timestamp - $info" | Out-File $logFile -Append -Encoding UTF8
}
Try {
LogToFile("Starting Before Script execution")
##########################
# SailPoint protected code (do not modify)
$sReader = New-Object System.IO.StringReader([System.String]$env:Request)
$xmlReader = [System.xml.XmlTextReader]([SailPoint.Utils.xml.XmlUtil]::getReader($sReader))
$requestObject = New-Object SailPoint.Utils.objects.AccountRequest($xmlReader)
##########################
$nativeIdentity = $requestObject.NativeIdentity
LogToFile("Processing user: $nativeIdentity")
# Loop through attribute requests to check group membership changes
foreach ($attribute in $requestObject.AttributeRequests) {
if ($attribute.Name -eq "memberOf") {
# Normalize the attribute value to an array
$groups = @()
if ($attribute.Value -is [System.Collections.ArrayList]) {
$groups = $attribute.Value
} else {
$groups = @($attribute.Value)
}
LogToFile("Groups in request: $($groups -join ', ') | Operation: $($attribute.Operation)")
# If the group g_ferias_afastado is being added → disable user logon
if ($attribute.Operation -eq "Add" -and $groups -contains "g_ferias_afastado") {
LogToFile("Detected addition of group g_ferias_afastado → setting logonHours to zero")
$logonHours = [byte[]](0..20 | ForEach-Object { 0 })
$logonHoursAttribute = New-Object SailPoint.Utils.objects.AttributeRequest
$logonHoursAttribute.Name = "logonHours"
$logonHoursAttribute.Value = $logonHours
$logonHoursAttribute.Operation = "Set"
$requestObject.AttributeRequests.Add($logonHoursAttribute)
LogToFile("logonHours set to zero successfully for $nativeIdentity")
}
# If the group g_ferias_afastado is being removed → restore full access
if ($attribute.Operation -eq "Remove" -and $groups -contains "g_ferias_afastado") {
LogToFile("Detected removal of group g_ferias_afastado → restoring logonHours")
$logonHours = [byte[]](0..20 | ForEach-Object { 255 })
$logonHoursAttribute = New-Object SailPoint.Utils.objects.AttributeRequest
$logonHoursAttribute.Name = "logonHours"
$logonHoursAttribute.Value = $logonHours
$logonHoursAttribute.Operation = "Set"
$requestObject.AttributeRequests.Add($logonHoursAttribute)
LogToFile("logonHours restored successfully for $nativeIdentity")
}
}
}
LogToFile("Before Script execution completed successfully for $nativeIdentity")
} Catch {
$ErrorMessage = $_.Exception.Message
$ErrorType = $_.Exception.GetType().FullName
LogToFile("ERROR [$ErrorType]: $ErrorMessage")
exit 1
}
# Write the modified request XML back to file for IQService to process
LogToFile("Writing modified request XML to file: $($args[0])")
$requestObject.toxml() | Out-File $args[0] -Encoding UTF8
LogToFile("XML successfully written. Script finished.")
Will this script reliably ensure that the logonHours attribute is fully reset in AD?
The purpose of the script is to control logon access in Active Directory by resetting or restoring the user’s logonHours attribute based on the addition or removal of the Active Directory group: g_ferias_afastado.
Do you recommend any additional safeguards or best practices when executing this via IQService?
Short answer: your approach works, and yes—setting all 21 bytes to 0x00 reliably blocks logon (all 168 bits = “no hours allowed”). Flipping them to 0xFF permits 24×7. That’s consistent with how AD stores logonHours (a 21-byte, 168-bit bitmap for the week). (Microsoft Learn - logonhours, Manage AD User logon hours using Powershell)
That said, I’d make a few adjustments so it’s rock-solid with IQService and Active Directory quirks.
What to tighten up
#Requires and PowerShell host
IQService typically launches Windows PowerShell (5.x), not PowerShell 7. #Requires -Version 7.3 may cause the script to be skipped. I’d remove it or set -Version 5.1 unless you’ve explicitly wired IQService to pwsh.exe. SailPoint’s own examples assume classic Windows PowerShell. (SailPoint Documentation - Writing a Script, SailPoint Community - Running Powershell via IQService)
You don’t need Import-Module ActiveDirectory
You’re not calling AD cmdlets here—you’re only editing the AccountRequest XML for IQService. Loading AD isn’t required and can slow/fail the run if RSAT isn’t present. (If you ever move the “set bytes in AD” step to an after script, then yes, import it there.)
Be defensive about $args[0]
IQService hands the “output file path” as $args[0]. If it’s missing for any reason, your changes won’t be applied. Add a guard + clearer error. (There are threads where $args[0] being null led to provisioning with the original request.) (SailPoint Developer Community - Before Script)
Normalize and match memberOf safely
Depending on how the change was initiated, the value for memberOf may be:
a DN (e.g., CN=g_ferias_afastado,OU=…) rather than a plain name,
a single string or any IEnumerable.
Use case-insensitive matching against both short name and DN to be bulletproof.
Treat binary correctly (you are) byte[] is the right type for logonHours. IQService will serialize it as octet-string to AD. (This matches SailPoint’s “modify/add attribute in before script” pattern.)
Prefer “clear” on restore (optional)
Two valid “restore” strategies:
All 0xFF (what you do now) → 24×7 allowed.
Unset the attribute ($null) → revert to default (also 24×7).
Either is fine; clearing makes diffs/audits cleaner in some shops.
Time-zone & DST gotchas (context) logonHours bits are stored in UTC and tools (like ADUC) display in local time. For your use case (“block entirely”), zeros avoid all DST ambiguity. If you ever build “allow only office hours,” be mindful of UTC conversion & DST. (Microsoft Learn - How does logon hours impact, Stack Overflow)
A sturdier version (drop-in)
param(
[Parameter(Mandatory = $false)]
[string]$requestString
)
# SailPoint libs
Add-Type -Path "C:\SailPoint\IQService\Utils.dll"
# Logging
$logDate = Get-Date -UFormat "%Y%m%d"
$logFile = "C:\SailPoint\Scripts\Logs\ManageLogonHours_$logDate.log"
function LogToFile([string]$info) {
$ts = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
"[LOGS] $ts - $info" | Out-File $logFile -Append -Encoding UTF8
}
try {
LogToFile "Starting Before Script"
# Build AccountRequest from IQService env var
$sReader = New-Object System.IO.StringReader([string]$env:Request)
$xmlReader = [System.Xml.XmlTextReader]([SailPoint.Utils.Xml.XmlUtil]::getReader($sReader))
$requestObject = New-Object SailPoint.Utils.Objects.AccountRequest($xmlReader)
$nativeIdentity = $requestObject.NativeIdentity
LogToFile "Processing: $nativeIdentity"
# Helper: check group array for target
function ContainsTargetGroup($values) {
if ($null -eq $values) { return $false }
$arr = @()
if ($values -is [System.Collections.IEnumerable] -and -not ($values -is [string])) {
$arr = @($values)
} else {
$arr = @($values)
}
foreach ($g in $arr) {
if ([string]::IsNullOrWhiteSpace($g)) { continue }
$gStr = [string]$g
if ($gStr -like "*CN=g_ferias_afastado,*") { return $true } # DN match
if ($gStr.Trim().ToLower() -eq "g_ferias_afastado") { return $true } # simple name
}
return $false
}
foreach ($attr in $requestObject.AttributeRequests) {
if ($attr.Name -ne "memberOf") { continue }
$op = [string]$attr.Operation
$hasTarget = ContainsTargetGroup $attr.Value
LogToFile "memberOf op=$op; contains target=$hasTarget"
if ($hasTarget -and $op -eq "Add") {
LogToFile "Adding g_ferias_afastado → set logonHours = all zeros"
$bytes = New-Object 'System.Byte[]' 21
# already zeroed; set explicitly for clarity
for ($i=0; $i -lt 21; $i++) { $bytes[$i] = 0 }
$lr = New-Object SailPoint.Utils.Objects.AttributeRequest
$lr.Name = "logonHours"
$lr.Value = $bytes
$lr.Operation = "Set"
$requestObject.AttributeRequests.Add($lr) | Out-Null
}
if ($hasTarget -and $op -eq "Remove") {
LogToFile "Removing g_ferias_afastado → restore 24x7"
# Option A: full allow
$bytes = New-Object 'System.Byte[]' 21
for ($i=0; $i -lt 21; $i++) { $bytes[$i] = 255 }
$lr = New-Object SailPoint.Utils.Objects.AttributeRequest
$lr.Name = "logonHours"
$lr.Value = $bytes # or: $lr.Value = $null to clear instead
$lr.Operation = "Set"
$requestObject.AttributeRequests.Add($lr) | Out-Null
}
}
# Write modified XML back for IQService
if (-not $args -or [string]::IsNullOrWhiteSpace($args[0])) {
throw "IQService did not provide an output file path (args[0]). Cannot return modified request."
}
$outPath = $args[0]
LogToFile "Writing modified request XML to $outPath"
$requestObject.toxml() | Out-File $outPath -Encoding UTF8
LogToFile "Done."
} catch {
LogToFile ("ERROR [{0}]: {1}" -f $_.Exception.GetType().FullName, $_.Exception.Message)
exit 1
}
The property should show a 21-byte array. If you write all zeros, ADUC should show “Logon denied at all times.”
Audit trail
Log the request ID / identity / op / group DN and the first 16 chars of a base64 of the bytes to prove exactly what was sent.
Idempotency
If logonHours is already zeroed, skip re-adding the AttributeRequest (saves needless churn).
Error propagation throw on any write failure so IQService marks the provisioning as failed (don’t silently succeed with no effect).
Fallback plan
If anyone later decides to restrict to “business hours,” implement a helper that builds the 21-byte map in UTC, not local time, to avoid DST surprises. (For “block completely,” your zero map is already immune.) (Microsoft Learn)
Where to put this logic
For access control tied to group membership, a before script (what you’re doing) is ideal—the ACRequest that adds/removes the group also carries the logonHours change in the same provisioning transaction. SailPoint’s examples show precisely this pattern of adding a new AttributeRequest in the before script and returning the updated XML. (SailPoint Documentation)
Bottom line
Your core idea (flip logonHours bytes between all 0x00 and all 0xFF) is correct and will reliably block/allow AD logons. (Microsoft Learn - Logon Hours)
Make the host/runtime and $args[0] handling more defensive, normalize memberOf values robustly, and consider clearing the attribute on restore.
Keep it as a “before” script so the change travels with the same provisioning request.
First of all, thank you very much for your time and assistance.
These are the points you mentioned that I’ve added to the code. Could you please review and confirm if the script is now complete and correct?
Logging of RequestId and Identity (audit trail)
Idempotency function to avoid duplicate updates
Idempotency check before applying changes
Logging with Base64 preview of the byte array
Additionally, could you explain if there is any documentation similar to Javadoc, where I can look up the available methods and properties? For example, in the code we use $lr.Name and $lr.Value — how can I determine that .Name and .Value are the correct members to use?
param(
[Parameter(Mandatory = $false)]
[string]$requestString
)
# Optional parameter - not really used here, but allows passing the request as a string.
# SailPoint libs
Add-Type -Path "C:\SailPoint\IQService\Utils.dll"
# Utils.dll to work with AccountRequest and AttributeRequest objects.
# Logging
$logDate = Get-Date -UFormat "%Y%m%d"
$logFile = "C:\SailPoint\Scripts\Logs\ManageLogonHours_$logDate.log"
# Creates daily rotating log files.
function LogToFile([string]$info) {
$ts = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
"[MIZ] $ts - $info" | Out-File $logFile -Append -Encoding UTF8
}
# Helper function to write timestamped logs to file.
try {
LogToFile "Starting Before Script"
# Build AccountRequest from IQService env var
$sReader = New-Object System.IO.StringReader([string]$env:Request)
$xmlReader = [System.Xml.XmlTextReader]([SailPoint.Utils.Xml.XmlUtil]::getReader($sReader))
$requestObject = New-Object SailPoint.Utils.Objects.AccountRequest($xmlReader)
# Parse the environment variable "Request" (XML) into a SailPoint AccountRequest object.
$nativeIdentity = $requestObject.NativeIdentity
$requestId = $requestObject.RequestId
LogToFile "Processing RequestId=$requestId for Identity=$nativeIdentity"
# Extracts the target identity and request ID, logs them for audit.
# Helper: check group array for target
function ContainsTargetGroup($values) { ... }
# Returns true if the attribute values contain the "g_ferias_afastado" group
# (checks both DN and short name).
# Compare helper: check if an AttributeRequest with logonHours already matches desired bytes
function AlreadyHasLogonHours($requestObject, [byte[]]$desired) { ... }
# Prevents duplicate updates by checking if logonHours already equals the desired state.
foreach ($attr in $requestObject.AttributeRequests) {
if ($attr.Name -ne "memberOf") { continue }
# Process only "memberOf" attributes (group membership changes).
$op = [string]$attr.Operation
$hasTarget = ContainsTargetGroup $attr.Value
LogToFile "memberOf op=$op; contains target=$hasTarget"
if ($hasTarget -and $op -eq "Add") {
# If the user is being added to g_ferias_afastado → deny all logon hours
$bytes = New-Object 'System.Byte[]' 21
for ($i=0; $i -lt 21; $i++) { $bytes[$i] = 0 }
if (-not (AlreadyHasLogonHours $requestObject $bytes)) {
# Add new AttributeRequest to set logonHours = 0
$lr = New-Object SailPoint.Utils.Objects.AttributeRequest
$lr.Name = "logonHours"
$lr.Value = $bytes
$lr.Operation = "Set"
$requestObject.AttributeRequests.Add($lr) | Out-Null
# Log preview (first 16 chars of base64 of the bytes) for audit
$preview = [Convert]::ToBase64String($bytes)
if ($preview.Length -gt 16) { $preview = $preview.Substring(0,16) }
LogToFile "Adding g_ferias_afastado → logonHours=all zeros [base64=$preview...]"
} else {
LogToFile "Skip update: logonHours already all zeros"
}
}
if ($hasTarget -and $op -eq "Remove") {
# If the user is removed from g_ferias_afastado → restore 24x7 access
$bytes = New-Object 'System.Byte[]' 21
for ($i=0; $i -lt 21; $i++) { $bytes[$i] = 255 }
if (-not (AlreadyHasLogonHours $requestObject $bytes)) {
# Add new AttributeRequest to set logonHours = 255 (all hours allowed)
$lr = New-Object SailPoint.Utils.Objects.AttributeRequest
$lr.Name = "logonHours"
$lr.Value = $bytes
$lr.Operation = "Set"
$requestObject.AttributeRequests.Add($lr) | Out-Null
# Log preview (first 16 chars of base64) for audit
$preview = [Convert]::ToBase64String($bytes)
if ($preview.Length -gt 16) { $preview = $preview.Substring(0,16) }
LogToFile "Removing g_ferias_afastado → logonHours=24x7 [base64=$preview...]"
} else {
LogToFile "Skip update: logonHours already 24x7"
}
}
}
# Write modified XML back for IQService
if (-not $args -or [string]::IsNullOrWhiteSpace($args[0])) {
throw "IQService did not provide an output file path (args[0]). Cannot return modified request."
}
$outPath = $args[0]
LogToFile "Writing modified request XML to $outPath"
$requestObject.toxml() | Out-File $outPath -Encoding UTF8
LogToFile "Done."
# Save the modified request (with updated logonHours) back to a file so IQService can continue.
} catch {
LogToFile ("ERROR [{0}]: {1}" -f $_.Exception.GetType().FullName, $_.Exception.Message)
exit 1
}
# Exception handling: logs error type + message, exits with non-zero so IQService fails the operation.