Native AD Disable - Enable - ARS Capture

Hi. 

I have a workflow that starts on changes to edsaAccountISDisabled. In the workflow i have an IF account changes to enable run this script and if set to disabled run this script. 

All good there when everything is performed via ARS.

There are a few situations where the disable or enable is coming from AD directly. 

How can i capture that the change happened in AD but still kick the workflow off. I know its something around the $dirobj but everything i have so far seems to make seem more complicated than i am sure it is. 

Thanks in advance

Parents Reply Children
  • Hi. 

    Sorry i have tried to put the code below in the correct format but no matter how many PC or browser it just never takes it. 

    Anyway i have knocked something up that works but i would like to get your thoughts on it. 

    $ADS_UF_ACCOUNTDISABLE = 0x0002
    $BaseDir = "C:\Temp"
    $DebounceWindowSeconds = 15

    function Get-SafeFileNameComponent {
    param([string]$Value)
    if ([string]::IsNullOrEmpty($Value)) { return $null }
    return ($Value -replace '[\\/:\*\?"<>\|@\s]', '_')
    }

    function Logit {
    param(
    [string]$LogPath,
    [string]$Text
    )
    $Timestamp = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
    $maxAttempts = 3
    for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
    try {
    Add-Content -Path $LogPath -Value "$Timestamp $Text" -ErrorAction Stop
    return
    } catch {
    if ($attempt -lt $maxAttempts) {
    Start-Sleep -Milliseconds (Get-Random -Minimum 50 -Maximum 250)
    }
    }
    }
    }

    function Test-RecentDuplicate {
    # File-based debounce: remembers "we already wrote this DN to this value
    # a few seconds ago" so a burst of redundant DirSync redeliveries for the
    # same real AD change only results in a single write (and a single
    # Workflow start), not several.
    #
    # IMPORTANT: the read-modify-write of the state file is wrapped in a
    # named Mutex. Testing showed that without this, concurrent invocations
    # arriving within the same second could collide on the file (visible as
    # "WARN: could not write debounce state file: Stream was not readable"),
    # causing one invocation to lose its own duplicate-record - which then
    # let the *next* invocation see no record at all and treat a genuinely
    # duplicate DirSync notification as a brand-new change, triggering the
    # Workflow again. The Mutex forces these to run one at a time instead of
    # racing on the file.
    param(
    [string]$DebounceStatePath,
    [string]$LogPath,
    [string]$DN,
    [bool]$Value
    )

    $mutex = New-Object System.Threading.Mutex($false, 'Global\NativeADEnableDisableDebounce')
    $acquired = $false
    try {
    $acquired = $mutex.WaitOne(5000)
    if (-not $acquired) {
    Logit $LogPath "WARN: could not acquire debounce lock within 5 seconds - proceeding without debounce protection for this invocation."
    return $false
    }

    $state = @{}
    try {
    if (Test-Path -Path $DebounceStatePath) {
    $raw = Get-Content -Path $DebounceStatePath -Raw -ErrorAction Stop
    if ($raw) {
    $parsed = $raw | ConvertFrom-Json
    foreach ($prop in $parsed.PSObject.Properties) {
    $state[$prop.Name] = $prop.Value
    }
    }
    }
    } catch {
    Logit $LogPath "WARN: could not read debounce state file, proceeding without it: $($_.Exception.Message)"
    }

    $isDuplicate = $false
    if ($state.ContainsKey($DN)) {
    $entry = $state[$DN]
    $lastValue = [bool]$entry.Value
    $lastTime = [datetime]$entry.Timestamp
    if ($lastValue -eq $Value -and ((Get-Date) - $lastTime).TotalSeconds -lt $DebounceWindowSeconds) {
    $isDuplicate = $true
    }
    }

    $state[$DN] = @{ Value = $Value; Timestamp = (Get-Date).ToString('o') }

    try {
    ($state | ConvertTo-Json -Depth 3) | Set-Content -Path $DebounceStatePath -ErrorAction Stop
    } catch {
    Logit $LogPath "WARN: could not write debounce state file: $($_.Exception.Message)"
    }

    return $isDuplicate
    }
    finally {
    if ($acquired) { $mutex.ReleaseMutex() }
    $mutex.Dispose()
    }
    }

    function onPostModify($Request)
    {
    if ($Request.Class -ne 'user') { return }

    # Walk the properties actually present in THIS modify request (not the
    # whole object) and only continue if userAccountControl is one of them.
    $uacFound = $false
    $newUAC = $null

    for ($i = 0; $i -lt $Request.PropertyCount; $i++) {
    $item = $Request.Item($i)
    if ($item.Name -ieq 'userAccountControl') {
    $uacFound = $true
    if ($item.Values.Count -gt 0) {
    $rawValue = $item.Values.Item(0)
    try { if ($rawValue.Value -ne $null) { $newUAC = $rawValue.Value } } catch {}
    if ($newUAC -eq $null) {
    try { $newUAC = $rawValue.Integer } catch {}
    }
    if ($newUAC -eq $null) {
    try { $newUAC = [int]$rawValue } catch {}
    }
    }
    break
    }
    }

    # userAccountControl wasn't part of this modify at all - this includes
    # both unrelated attribute changes AND our own recursive VA-only write
    # below. Bail out immediately in either case, before we've resolved any
    # per-user file paths.
    if (-not $uacFound) { return }

    # Resolve a name to build per-user file paths from: UPN first, then
    # sAMAccountName, then a sanitized DN fragment as a last resort.
    $safeName = $null
    try { $safeName = Get-SafeFileNameComponent $DirObj.Get('userPrincipalName') } catch {}
    if (-not $safeName) {
    try { $safeName = Get-SafeFileNameComponent $DirObj.Get('sAMAccountName') } catch {}
    }
    if (-not $safeName) {
    $safeName = Get-SafeFileNameComponent ($Request.DN.Split(',')[0])
    }
    if (-not $safeName) { $safeName = 'unknown' }

    # Files are organized under $BaseDir\YYYY\MMM\dd (e.g. \2026\Jul\07),
    # using the date at the time this change is processed. The folder is
    # created on demand if it doesn't exist yet - if that creation fails for
    # any reason (e.g. a permissions issue), fall back to writing directly
    # into $BaseDir rather than losing the log entry entirely.
    $now = Get-Date
    $DatedDir = Join-Path $BaseDir (Join-Path ($now.ToString('yyyy')) (Join-Path ($now.ToString('MMM')) ($now.ToString('dd'))))
    try {
    if (-not (Test-Path -Path $DatedDir)) {
    New-Item -ItemType Directory -Path $DatedDir -Force -ErrorAction Stop | Out-Null
    }
    } catch {
    $DatedDir = $BaseDir
    }

    $LogPath = Join-Path $DatedDir "NativeAD-Disabled_OR_Enabled_$safeName.log"
    $DebounceStatePath = Join-Path $DatedDir "NativeAD-Disabled_OR_Enabled_$safeName.state"

    if ($newUAC -eq $null) {
    Logit $LogPath "$($Request.DN): userAccountControl was in the request but its value could not be read - skipping."
    return
    }

    $newUAC = [int]$newUAC
    $isDisabledNow = (($newUAC -band $ADS_UF_ACCOUNTDISABLE) -eq $ADS_UF_ACCOUNTDISABLE)

    if (Test-RecentDuplicate -DebounceStatePath $DebounceStatePath -LogPath $LogPath -DN $Request.DN -Value $isDisabledNow) {
    Logit $LogPath "$($Request.DN): userAccountControl changed (=$newUAC) but a matching write was already recorded within the last $DebounceWindowSeconds seconds - skipping (likely a duplicate DirSync notification for the same event)."
    return
    }

    Logit $LogPath "$($Request.DN): native AD change detected. userAccountControl=$newUAC -> setting edsaAccountIsDisabled=$isDisabledNow"

    try {
    $DirObj.Put('edsaAccountIsDisabled', $isDisabledNow)
    $DirObj.SetInfo()
    }
    catch {
    Logit $LogPath "ERROR setting edsaAccountIsDisabled for $($Request.DN): $($_.Exception.Message)"
    }
    }