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
  • Hi Craig, 

    This wouldn't be via workflow, as you've correctly identified, they are trigger by changes in ARS.

    However you could have create an administration policy, with a custom script, where the "Handle changes from DirSync control" is selected, that script might then set some VA in ARS to trigger the workflow to do something.

    I've linked you to another post Terrance and Johnny discuss this.

    (+) Detect changes that occur in Active Directory (not AR) and trigger an action? - Forum - Active Roles Community - One Identity Community

    Its important to remember to make sure you check that the change is what you're looking for, in you're case you're looking for an update to userAccountControl specifically bit value 2

  • 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)"
    }
    }

Reply
  • 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)"
    }
    }

Children
No Data