Help validating PowerShell script to set Logon Hours to zero in AD: Powershell BeforeModify Rule

Sukanta Biswas
Fri, 05 Sep 2025 06:41:26 +0000

@thiagogosantanasi -

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

1. #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)

2. 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.)

3. 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)

4. 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.

5. 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.)

6. 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.

7. 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
}

Operational safeguards (highly recommended)

Test on a lab user first and verify with:

(Get-ADUser <sam> -Properties logonHours).logonHours

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.

Cheers!!!