From f17d3d8c989f0454893070339a6ab69c2c4dda5c Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:17:15 +0100 Subject: [PATCH 01/24] feat: added whenCreated and whenUpdated columns with datetime2 precision to blacklist table --- createTableBlacklist.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/createTableBlacklist.sql b/createTableBlacklist.sql index 39fbf86..6844e22 100644 --- a/createTableBlacklist.sql +++ b/createTableBlacklist.sql @@ -11,7 +11,9 @@ CREATE TABLE [dbo].[blacklist]( [attributeName] [nvarchar](50) NOT NULL, [attributeValue] [nvarchar](50) NOT NULL, [employeeId] [nvarchar](50) NOT NULL, - [whenDeleted] [date] NULL + [whenCreated] [datetime2](7) NULL, + [whenUpdated] [datetime2](7) NULL, + [whenDeleted] [datetime2](7) NULL ) ON [PRIMARY] GO From 2baf5543444582f026f8205599e24b0c1684f7ba Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:17:56 +0100 Subject: [PATCH 02/24] feat: added retentionPeriod configuration parameter for controlling value reuse after deletion --- configuration.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/configuration.json b/configuration.json index 7620d13..9d1e9e4 100644 --- a/configuration.json +++ b/configuration.json @@ -43,5 +43,17 @@ "description": "Optional: The username of the SQL user to use in the connection string", "required": false } + }, + { + "key": "retentionPeriod", + "type": "input", + "defaultValue": "365", + "templateOptions": { + "label": "Retention Period (days)", + "type": "number", + "placeholder": "365", + "description": "The number of days a deleted value should remain blocked. After this period, the value can be reused. Use 999999 for no retention limit.", + "required": true + } } ] \ No newline at end of file From 1951a8b4d82ef1efc6a25b5686592e1808db9673 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:48:31 +0100 Subject: [PATCH 03/24] Remove type field from retentionPeriod configuration --- configuration.json | 1 - create.ps1 | 49 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/configuration.json b/configuration.json index 9d1e9e4..0a4afcc 100644 --- a/configuration.json +++ b/configuration.json @@ -50,7 +50,6 @@ "defaultValue": "365", "templateOptions": { "label": "Retention Period (days)", - "type": "number", "placeholder": "365", "description": "The number of days a deleted value should remain blocked. After this period, the value can be reused. Use 999999 for no retention limit.", "required": true diff --git a/create.ps1 b/create.ps1 index 542d334..0a2720c 100644 --- a/create.ps1 +++ b/create.ps1 @@ -74,8 +74,9 @@ function Invoke-SQLQuery { try { $table = $actionContext.configuration.table + $retentionPeriod = $actionContext.configuration.retentionPeriod - $attributeNames = $($actionContext.Data | Select-Object * -ExcludeProperty employeeId, whenDeleted).PSObject.Properties.Name + $attributeNames = $($actionContext.Data | Select-Object * -ExcludeProperty employeeId, whenDeleted, whenCreated, whenUpdated).PSObject.Properties.Name foreach ($attributeName in $attributeNames) { try { @@ -105,11 +106,36 @@ try { if ($selectRowCount -eq 1) { $correlatedAccount = $querySelectResult - if ($correlatedAccount.employeeId -ne $account.employeeId -or (-not([string]::IsNullOrEmpty($correlatedAccount.whenDeleted)))) { - $action = "UpdateAccount" + + # Check if value belongs to someone else + if ($correlatedAccount.employeeId -ne $account.employeeId) { + # Check retention period if value is deleted + if (-NOT [string]::IsNullOrEmpty($correlatedAccount.whenDeleted)) { + $whenDeletedDate = [datetime]($correlatedAccount.whenDeleted) + $daysDiff = (New-TimeSpan -Start $whenDeletedDate -End (Get-Date)).Days + + if ($daysDiff -lt $retentionPeriod) { + $action = "OtherEmployeeId" + } + else { + # Retention period expired, can reuse + $action = "UpdateAccount" + } + } + else { + # Value belongs to someone else and not deleted + $action = "OtherEmployeeId" + } } else { - $action = "NoChanges" + # Value belongs to current employee + if (-not([string]::IsNullOrEmpty($correlatedAccount.whenDeleted))) { + # Clear whenDeleted to reactivate + $action = "UpdateAccount" + } + else { + $action = "NoChanges" + } } } elseif ($selectRowCount -eq 0) { @@ -122,10 +148,14 @@ try { # Update blacklist database switch ($action) { "CreateAccount" { + # Add timestamps + $account | Add-Member -NotePropertyName 'whenCreated' -NotePropertyValue (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") -Force + $account | Add-Member -NotePropertyName 'whenUpdated' -NotePropertyValue $null -Force + $account | Add-Member -NotePropertyName 'whenDeleted' -NotePropertyValue $null -Force # Enclose Property Names with brackets [] & Enclose Property Values with single quotes '' - $queryInsertProperties = $("[" + ($account.PSObject.Properties.Name -join "],[") + "]") - $queryInsertValues = $(($account.PSObject.Properties.Value | ForEach-Object { if ($_ -ne 'null') { "'$_'" } else { 'null' } }) -join ',') + $queryInsertProperties = $("[" + ($account.PSObject.Properties.Name -join "],[" + "]")) + $queryInsertValues = $(($account.PSObject.Properties.Value | ForEach-Object { if ($_ -ne 'null' -and $null -ne $_) { "'$_'" } else { 'null' } }) -join ',') $queryInsert = "INSERT INTO $table ($($queryInsertProperties)) VALUES ($($queryInsertValues))" $queryInsertSplatParams = @{ @@ -152,7 +182,7 @@ try { break } "UpdateAccount" { - $queryUpdateSet = "SET [employeeId]='$($account.employeeId)', [whenDeleted]=null" + $queryUpdateSet = "SET [employeeId]='$($account.employeeId)', [whenDeleted]=null, [whenUpdated]=GETDATE()" $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName'" $queryUpdateSplatParams = @{ @@ -184,6 +214,11 @@ try { Write-Information "Value in table [$table] for attribute [$attributeName] and value [$attributeValue] already exists, action skipped" break } + "OtherEmployeeId" { + # Throw terminal error + throw "A row was found where [$attributeName] = [$attributeValue]. However the EmployeeID [$($correlatedAccount.employeeId)] doesn't match the current person (expected: [$($account.employeeId)]). Additionally, [whenDeleted] = [$($correlatedAccount.whenDeleted)] is still within the allowed threshold [$retentionPeriod days]. This should not be possible. Please check the database for inconsistencies." + break + } } } catch { From b08516bf830fa1b01110bf8f6a558da76cb63913 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:48:39 +0100 Subject: [PATCH 04/24] Update field mapping to match CSV connector structure --- fieldMapping.json | 57 +++++++++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/fieldMapping.json b/fieldMapping.json index 2ec32b2..8b56f6b 100644 --- a/fieldMapping.json +++ b/fieldMapping.json @@ -2,82 +2,75 @@ "Version": "v1", "MappingFields": [ { - "Name": "SamAccountName", - "Description": "", + "Name": "employeeId", + "Description": "[Required]", "Type": "Text", "MappingActions": [ { "MapForActions": [ - "Create", - "Update" + "Create" ], - "MappingMode": "Complex", - "Value": "\"function getSamAccountName() {\\r\\n let samAccountName = '';\\r\\n\\r\\n if (typeof Person.Accounts.MicrosoftActiveDirectory.sAMAccountName !== 'undefined' && Person.Accounts.MicrosoftActiveDirectory.sAMAccountName) {\\r\\n samAccountName = Person.Accounts.MicrosoftActiveDirectory.sAMAccountName;\\r\\n }\\r\\n return samAccountName;\\r\\n}\\r\\n\\r\\ngetSamAccountName()\"", + "MappingMode": "Field", + "Value": "\"Person.ExternalId\"", "UsedInNotifications": false, - "StoreInAccountData": false + "StoreInAccountData": true } ] }, { - "Name": "UserPrincipalName", - "Description": "", + "Name": "Mail", + "Description": "[Optional]\nIf provided, this field should always be selected for all actions: Create, Update and Delete.", "Type": "Text", "MappingActions": [ { "MapForActions": [ "Create", - "Update" + "Update", + "Delete" ], "MappingMode": "Complex", - "Value": "\"function getUserPrincipalName() {\\r\\n let userPrincipalName = '';\\r\\n\\r\\n if (typeof Person.Accounts.MicrosoftActiveDirectory.userPrincipalName !== 'undefined' && Person.Accounts.MicrosoftActiveDirectory.userPrincipalName) {\\r\\n userPrincipalName = Person.Accounts.MicrosoftActiveDirectory.userPrincipalName;\\r\\n }\\r\\n return userPrincipalName;\\r\\n}\\r\\n\\r\\ngetUserPrincipalName()\"", + "Value": "\"function getMail() {\\r\\n let mail = '';\\r\\n \\r\\n if (typeof Person.Accounts.MicrosoftActiveDirectory !== 'undefined' && typeof Person.Accounts.MicrosoftActiveDirectory.mail !== 'undefined') {\\r\\n mail = Person.Accounts.MicrosoftActiveDirectory.mail;\\r\\n }\\r\\n \\r\\n return mail;\\r\\n}\\r\\n\\r\\n// Check if Person data is available\\r\\nif(Person.ExternalId){\\r\\n getMail();\\r\\n}\"", "UsedInNotifications": false, - "StoreInAccountData": false + "StoreInAccountData": true } ] }, { - "Name": "employeeId", - "Description": "[Required]", + "Name": "SamAccountName", + "Description": "[Optional]\nIf provided, this field should always be selected for all actions: Create, Update and Delete.", "Type": "Text", "MappingActions": [ { "MapForActions": [ "Create", - "Update" + "Update", + "Delete" ], - "MappingMode": "Field", - "Value": "\"Person.ExternalId\"", + "MappingMode": "Complex", + "Value": "\"function getSAMAccountName() {\\r\\n let sAMAccountName = '';\\r\\n \\r\\n if (typeof Person.Accounts.MicrosoftActiveDirectory !== 'undefined' && typeof Person.Accounts.MicrosoftActiveDirectory.sAMAccountName !== 'undefined') {\\r\\n sAMAccountName = Person.Accounts.MicrosoftActiveDirectory.sAMAccountName;\\r\\n }\\r\\n \\r\\n return sAMAccountName;\\r\\n}\\r\\n\\r\\n// Check if Person data is available\\r\\nif(Person.ExternalId){\\r\\n getSAMAccountName();\\r\\n}\"", "UsedInNotifications": false, "StoreInAccountData": true } ] }, { - "Name": "whenDeleted", - "Description": "[Required]", + "Name": "UserPrincipalName", + "Description": "[Optional]\nIf provided, this field should always be selected for all actions: Create, Update and Delete.", "Type": "Text", "MappingActions": [ { "MapForActions": [ + "Create", + "Update", "Delete" ], "MappingMode": "Complex", - "Value": "\"function getDate() {\\n const date = new Date();\\n return date;\\n}\\n\\ngetDate();\"", + "Value": "\"function getUserPrincipalName() {\\r\\n let userPrincipalName = '';\\r\\n \\r\\n if (typeof Person.Accounts.MicrosoftActiveDirectory !== 'undefined' && typeof Person.Accounts.MicrosoftActiveDirectory.userPrincipalName !== 'undefined') {\\r\\n userPrincipalName = Person.Accounts.MicrosoftActiveDirectory.userPrincipalName;\\r\\n }\\r\\n \\r\\n return userPrincipalName;\\r\\n}\\r\\n\\r\\n// Check if Person data is available\\r\\nif(Person.ExternalId){\\r\\n getUserPrincipalName();\\r\\n}\"", "UsedInNotifications": false, - "StoreInAccountData": false - }, - { - "MapForActions": [ - "Create", - "Update" - ], - "MappingMode": "Fixed", - "Value": "\"NULL\"", - "UsedInNotifications": false, - "StoreInAccountData": false + "StoreInAccountData": true } ] } ], "UniqueFieldNames": [] -} \ No newline at end of file +} From 351fcab755d719c75b40abc6d829500a90526a06 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:48:46 +0100 Subject: [PATCH 05/24] Restructure create script with retention period logic and improved error handling --- create.ps1 | 306 +++++++++++++++++++++++++++++------------------------ 1 file changed, 168 insertions(+), 138 deletions(-) diff --git a/create.ps1 b/create.ps1 index 0a2720c..aaf0ce0 100644 --- a/create.ps1 +++ b/create.ps1 @@ -3,6 +3,14 @@ # Use data from dependent system ##################################################### +$table = $actionContext.configuration.table +$retentionPeriod = $actionContext.configuration.retentionPeriod + +$attributeNames = $($actionContext.Data | Select-Object * -ExcludeProperty employeeId, whenDeleted, whenCreated, whenUpdated).PSObject.Properties.Name + +# Set AccountReference to employeeId at the top level, since it's always the current person's employeeId — no need to set it within a specific action +$outputContext.AccountReference = $actionContext.Data.employeeId + function Invoke-SQLQuery { param( [parameter(Mandatory = $true)] @@ -73,179 +81,201 @@ function Invoke-SQLQuery { } try { - $table = $actionContext.configuration.table - $retentionPeriod = $actionContext.configuration.retentionPeriod + foreach ($attributeName in $attributeNames) { + # Check if attribute is in table + $actionMessage = "querying row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" - $attributeNames = $($actionContext.Data | Select-Object * -ExcludeProperty employeeId, whenDeleted, whenCreated, whenUpdated).PSObject.Properties.Name + $attributeValue = $actionContext.Data.$attributeName -Replace "'", "''" + $account = $actionContext.Data | Select-Object * -ExcludeProperty $attributeNames - foreach ($attributeName in $attributeNames) { - try { - - $attributeValue = $actionContext.Data.$attributeName -Replace "'", "''" - $account = $actionContext.Data | Select-Object * -ExcludeProperty $attributeNames - $account | Add-Member -NotePropertyName 'attributeName' -NotePropertyValue $attributeName - $account | Add-Member -NotePropertyName 'attributeValue' -NotePropertyValue $attributeValue - - # Enclose Property Names with brackets [] - $querySelectProperties = $("[" + ($($account.PSObject.Properties.Name) -join "],[") + "]") - $querySelect = "SELECT $($querySelectProperties) FROM [$table] WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$attributeValue'" - Write-Information "Querying data from table [$table]. Query: $($querySelect)" - - $querySelectSplatParams = @{ - ConnectionString = $actionContext.configuration.connectionString - Username = $actionContext.configuration.username - Password = $actionContext.configuration.password - SqlQuery = $querySelect - ErrorAction = "Stop" - } - $querySelectResult = [System.Collections.ArrayList]::new() - Invoke-SQLQuery @querySelectSplatParams -Data ([ref]$querySelectResult) -verbose:$false + $querySelect = "SELECT * FROM [$table] WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$attributeValue'" + + $querySelectSplatParams = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $querySelect + ErrorAction = "Stop" + } + $querySelectResult = [System.Collections.ArrayList]::new() + Invoke-SQLQuery @querySelectSplatParams -Data ([ref]$querySelectResult) -verbose:$false + + $selectRowCount = ($querySelectResult | measure-object).count + Write-Information "Queried data from table [$table] for attribute [$attributeName]. Result count: $selectRowCount" - $selectRowCount = ($querySelectResult | measure-object).count - Write-Information "Successfully queried data from table [$table] for attribute [$attributeName]. Query: $($querySelect). Returned rows: $selectRowCount" + # Calculate action + $actionMessage = "calculating action" - if ($selectRowCount -eq 1) { - $correlatedAccount = $querySelectResult + # If multiple rows are found, filter additionally for employeeId + if ($selectRowCount -gt 1) { + $correlatedAccount = $querySelectResult | Where-Object { $_.employeeId -eq $account.employeeId } + $selectRowCount = ($correlatedAccount | Measure-Object).count + + Write-Information "Multiple rows found where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Filtered additionally for employeeId. Result count: $selectRowCount" + } + + if ($selectRowCount -eq 1) { + $correlatedAccount = $querySelectResult - # Check if value belongs to someone else - if ($correlatedAccount.employeeId -ne $account.employeeId) { - # Check retention period if value is deleted - if (-NOT [string]::IsNullOrEmpty($correlatedAccount.whenDeleted)) { - $whenDeletedDate = [datetime]($correlatedAccount.whenDeleted) - $daysDiff = (New-TimeSpan -Start $whenDeletedDate -End (Get-Date)).Days + # Check if value belongs to someone else + if ($correlatedAccount.employeeId -ne $account.employeeId) { + # Check retention period if value is deleted + if (-NOT [string]::IsNullOrEmpty($correlatedAccount.whenDeleted)) { + $whenDeletedDate = [datetime]($correlatedAccount.whenDeleted) + $daysDiff = (New-TimeSpan -Start $whenDeletedDate -End (Get-Date)).Days - if ($daysDiff -lt $retentionPeriod) { - $action = "OtherEmployeeId" - } - else { - # Retention period expired, can reuse - $action = "UpdateAccount" - } + if ($daysDiff -lt $retentionPeriod) { + $action = "OtherEmployeeId" } else { - # Value belongs to someone else and not deleted - $action = "OtherEmployeeId" + # Retention period expired, can reuse + $action = "Update" } } else { - # Value belongs to current employee - if (-not([string]::IsNullOrEmpty($correlatedAccount.whenDeleted))) { - # Clear whenDeleted to reactivate - $action = "UpdateAccount" - } - else { - $action = "NoChanges" - } + # Value belongs to someone else and not deleted + $action = "OtherEmployeeId" } } - elseif ($selectRowCount -eq 0) { - $action = "CreateAccount" - } else { - Throw "multiple ($selectRowCount) rows found with attribute [$attributeName]" + Write-Warning ($correlatedAccount | ConvertTo-Json) + # Value belongs to current employee + if (-not([string]::IsNullOrEmpty($correlatedAccount.whenDeleted))) { + # Clear whenDeleted to reactivate + $action = "Update" + } + else { + $action = "NoChanges" + } } + } + elseif ($selectRowCount -eq 0) { + $action = "Create" + } + elseif ($selectRowCount -gt 1) { + $action = "MultipleFound" + } - # Update blacklist database - switch ($action) { - "CreateAccount" { - # Add timestamps - $account | Add-Member -NotePropertyName 'whenCreated' -NotePropertyValue (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") -Force - $account | Add-Member -NotePropertyName 'whenUpdated' -NotePropertyValue $null -Force - $account | Add-Member -NotePropertyName 'whenDeleted' -NotePropertyValue $null -Force - - # Enclose Property Names with brackets [] & Enclose Property Values with single quotes '' - $queryInsertProperties = $("[" + ($account.PSObject.Properties.Name -join "],[" + "]")) - $queryInsertValues = $(($account.PSObject.Properties.Value | ForEach-Object { if ($_ -ne 'null' -and $null -ne $_) { "'$_'" } else { 'null' } }) -join ',') - $queryInsert = "INSERT INTO $table ($($queryInsertProperties)) VALUES ($($queryInsertValues))" - - $queryInsertSplatParams = @{ - ConnectionString = $actionContext.configuration.connectionString - Username = $actionContext.configuration.username - Password = $actionContext.configuration.password - SqlQuery = $queryInsert - ErrorAction = "Stop" - } + # Update blacklist database + switch ($action) { + "Create" { + # Create row + $actionMessage = "creating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]" + + # Add attribute name and value to the account object + $account | Add-Member -NotePropertyName 'attributeName' -NotePropertyValue $attributeName + $account | Add-Member -NotePropertyName 'attributeValue' -NotePropertyValue $attributeValue + + # Add timestamps + $account | Add-Member -NotePropertyName 'whenCreated' -NotePropertyValue (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") -Force + $account | Add-Member -NotePropertyName 'whenUpdated' -NotePropertyValue $null -Force + $account | Add-Member -NotePropertyName 'whenDeleted' -NotePropertyValue $null -Force + + # Enclose Property Names with brackets [] & Enclose Property Values with single quotes '' + $queryInsertProperties = $("[" + (($account.PSObject.Properties.Name) -join "],[") + "]") + $queryInsertValues = $(($account.PSObject.Properties.Value | ForEach-Object { if ($_ -ne 'null' -and $null -ne $_) { "'$_'" } else { 'null' } }) -join ',') + $queryInsert = "INSERT INTO $table ($($queryInsertProperties)) VALUES ($($queryInsertValues))" + + $queryInsertSplatParams = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $queryInsert + ErrorAction = "Stop" + } + + $queryInsertResult = [System.Collections.ArrayList]::new() + if (-not($actioncontext.dryRun -eq $true)) { + Invoke-SQLQuery @queryInsertSplatParams -Data ([ref]$queryInsertResult) - $queryInsertResult = [System.Collections.ArrayList]::new() - if (-not($actioncontext.dryRun -eq $true)) { - Write-Information "Inserting row in table [$table] for attribute [$attributeName] and value [$attributeValue]. Query: $($queryInsert)" - Invoke-SQLQuery @queryInsertSplatParams -Data ([ref]$queryInsertResult) - } - else { - Write-Warning "DryRun: Would insert row in table [$table] for attribute [$attributeName] and value [$attributeValue]. Query: $($queryInsert)" - } - $outputContext.AccountReference = $account.employeeId $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "Successfully inserted row in table [$table] for attribute [$attributeName] and value [$attributeValue]" + # Action = "" # Optional + Message = "Created row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]." IsError = $false }) - break } - "UpdateAccount" { - $queryUpdateSet = "SET [employeeId]='$($account.employeeId)', [whenDeleted]=null, [whenUpdated]=GETDATE()" - $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName'" - - $queryUpdateSplatParams = @{ - ConnectionString = $actionContext.configuration.connectionString - Username = $actionContext.configuration.username - Password = $actionContext.configuration.password - SqlQuery = $queryUpdate - ErrorAction = "Stop" - } + else { + Write-Warning "DryRun: Would create row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]." + } + + break + } + + "Update" { + # Update row - clear whenDeleted + $actionMessage = "clearing [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]" + + $queryUpdateSet = "SET [whenDeleted]=null, [whenUpdated]=GETDATE()" + $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName' AND [employeeId] = '$($account.employeeId)'" + + $queryUpdateSplatParams = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $queryUpdate + ErrorAction = "Stop" + } + + $queryUpdateResult = [System.Collections.ArrayList]::new() + if (-not($actioncontext.dryRun -eq $true)) { + Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) - $queryUpdateResult = [System.Collections.ArrayList]::new() - if (-not($actioncontext.dryRun -eq $true)) { - Write-Information "Updating row from table [$table]. Query: $($queryUpdate)" - Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) - } - else { - Write-Warning "DryRun: Would update updated row in table [$table] for attribute [$attributeName] and value [$attributeValue]. Query: $($queryUpdate)" - } - $outputContext.AccountReference = $account.employeeId $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "Successfully updated row in table [$table] for attribute [$attributeName] and value [$attributeValue]" + # Action = "" # Optional + Message = "Cleared [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]." IsError = $false }) - break - } - "NoChanges" { - $outputContext.AccountReference = $account.employeeId - $noChanges = $true - Write-Information "Value in table [$table] for attribute [$attributeName] and value [$attributeValue] already exists, action skipped" - break } - "OtherEmployeeId" { - # Throw terminal error - throw "A row was found where [$attributeName] = [$attributeValue]. However the EmployeeID [$($correlatedAccount.employeeId)] doesn't match the current person (expected: [$($account.employeeId)]). Additionally, [whenDeleted] = [$($correlatedAccount.whenDeleted)] is still within the allowed threshold [$retentionPeriod days]. This should not be possible. Please check the database for inconsistencies." - break + else { + Write-Warning "DryRun: Would clear [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]." } + + break + } + + "NoChanges" { + $actionMessage = "skipping updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]" + + $outputContext.auditlogs.Add([PSCustomObject]@{ + # Action = "" # Optional + Message = "Skipped updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]. reason: No changes." + IsError = $false + }) + + break + } + + "OtherEmployeeId" { + $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" + + # Throw terminal error + throw "A row was found where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. However the EmployeeID [$($correlatedAccount.employeeId)] doesn't match the current person (expected: [$($actionContext.Data.employeeId)]). Additionally, [whenDeleted] = [$($correlatedAccount.whenDeleted)] is still within the allowed threshold [$retentionPeriod days]. This should not be possible. Please check the database for inconsistencies." + + break + } + + "MultipleFound" { + $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" + + # Throw terminal error + throw "Multiple rows were found in the database where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]. This should not be possible. Please check the database for inconsistencies." + + break } } - catch { - $ex = $PSItem - $auditErrorMessage = $ex.Exception.Message - Write-Warning "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($auditErrorMessage)" - $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "Failed to insert data into table [$table] for attribute [$attributeName] with value [$attributeValue]: $($auditErrorMessage)" - IsError = $true - }) - } - } - # To prevent the audit message 'Account create successful' from being displayed in the create script. This audit message will be ignored in the update script, because 'Data' and 'PreviousData' will have the same values. - if (([string]::IsNullOrEmpty($outputContext.auditlogs.Message)) -and $noChanges) { - $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "No updates required in table [$table] for attributes [$($attributeNames -join ', ')]" - IsError = $false - }) } } catch { $ex = $PSItem - $auditErrorMessage = $ex.Exception.Message - Write-Information "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($auditErrorMessage)" + + $auditMessage = "Error $($actionMessage). Error: $($ex.Exception.Message)" + $warningMessage = "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" + + Write-Warning $warningMessage $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "Generic error: $($auditErrorMessage)" + # Action = "" # Optional + Message = $auditMessage IsError = $true }) } From 8ffaffc1128621d8f047977cde93513338298b73 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:48:52 +0100 Subject: [PATCH 06/24] Restructure update script to match create logic with retention period support --- update.ps1 | 281 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 170 insertions(+), 111 deletions(-) diff --git a/update.ps1 b/update.ps1 index 3442fdd..56524af 100644 --- a/update.ps1 +++ b/update.ps1 @@ -3,6 +3,9 @@ # Use data from dependent system ##################################################### +$table = $actionContext.configuration.table +$retentionPeriod = $actionContext.configuration.retentionPeriod + function Invoke-SQLQuery { param( [parameter(Mandatory = $true)] @@ -73,147 +76,203 @@ function Invoke-SQLQuery { } try { - $table = $actionContext.configuration.table - - $attributeNames = $($actionContext.Data | Select-Object * -ExcludeProperty employeeId, whenDeleted).PSObject.Properties.Name + # Verify account reference + $actionMessage = "verifying account reference" + if ([string]::IsNullOrEmpty($($actionContext.References.Account))) { + throw "The account reference could not be found" + } foreach ($attributeName in $attributeNames) { - try { - - $attributeValue = $actionContext.Data.$attributeName -Replace "'", "''" - $account = $actionContext.Data | Select-Object * -ExcludeProperty $attributeNames - $account | Add-Member -NotePropertyName 'attributeName' -NotePropertyValue $attributeName - $account | Add-Member -NotePropertyName 'attributeValue' -NotePropertyValue $attributeValue - - # Enclose Property Names with brackets [] - $querySelectProperties = $("[" + ($($account.PSObject.Properties.Name) -join "],[") + "]") - $querySelect = "SELECT $($querySelectProperties) FROM [$table] WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$attributeValue'" - Write-Information "Querying data from table [$table]. Query: $($querySelect)" - - $querySelectSplatParams = @{ - ConnectionString = $actionContext.configuration.connectionString - Username = $actionContext.configuration.username - Password = $actionContext.configuration.password - SqlQuery = $querySelect - ErrorAction = "Stop" - } - $querySelectResult = [System.Collections.ArrayList]::new() - Invoke-SQLQuery @querySelectSplatParams -Data ([ref]$querySelectResult) -verbose:$false + # Check if attribute is in table + $actionMessage = "querying row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" - $selectRowCount = ($querySelectResult | measure-object).count - Write-Information "Successfully queried data from table [$table] for attribute [$attributeName]. Query: $($querySelect). Returned rows: $selectRowCount" + $attributeValue = $actionContext.Data.$attributeName -Replace "'", "''" + $account = $actionContext.Data | Select-Object * -ExcludeProperty $attributeNames + + $querySelect = "SELECT * FROM [$table] WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$attributeValue'" + + + $querySelectSplatParams = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $querySelect + ErrorAction = "Stop" + } + $querySelectResult = [System.Collections.ArrayList]::new() + Invoke-SQLQuery @querySelectSplatParams -Data ([ref]$querySelectResult) -verbose:$false - if ($selectRowCount -eq 1) { - $correlatedAccount = $querySelectResult - if ($correlatedAccount.employeeId -ne $account.employeeId -or (-not([string]::IsNullOrEmpty($correlatedAccount.whenDeleted)))) { - $action = "UpdateAccount" + $selectRowCount = ($querySelectResult | measure-object).count + Write-Information "Queried data from table [$table] for attribute [$attributeName]. Result count: $selectRowCount" + + # Calculate action + $actionMessage = "calculating action" + + # If multiple rows are found, filter additionally for employeeId + if ($selectRowCount -gt 1) { + $correlatedAccount = $querySelectResult | Where-Object { $_.employeeId -eq $account.employeeId } + $selectRowCount = ($correlatedAccount | Measure-Object).count + + Write-Information "Multiple rows found where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Filtered additionally for employeeId. Result count: $selectRowCount" + } + + if ($selectRowCount -eq 1) { + $correlatedAccount = $querySelectResult + + # Check if value belongs to someone else + if ($correlatedAccount.employeeId -ne $account.employeeId) { + # Check retention period if value is deleted + if (-NOT [string]::IsNullOrEmpty($correlatedAccount.whenDeleted)) { + $whenDeletedDate = [datetime]($correlatedAccount.whenDeleted) + $daysDiff = (New-TimeSpan -Start $whenDeletedDate -End (Get-Date)).Days + + if ($daysDiff -lt $retentionPeriod) { + $action = "OtherEmployeeId" + } + else { + # Retention period expired, can reuse + $action = "Update" + } } else { - $action = "NoChanges" + # Value belongs to someone else and not deleted + $action = "OtherEmployeeId" } } - elseif ($selectRowCount -eq 0) { - $action = "CreateAccount" - } else { - Throw "multiple ($selectRowCount) rows found with attribute [$attributeName]" + # Value belongs to current employee + if (-not([string]::IsNullOrEmpty($correlatedAccount.whenDeleted))) { + # Clear whenDeleted to reactivate + $action = "Update" + } + else { + $action = "NoChanges" + } } + } + elseif ($selectRowCount -eq 0) { + $action = "Create" + } + elseif ($selectRowCount -gt 1) { + $action = "MultipleFound" + } - # Update blacklist database - switch ($action) { - "CreateAccount" { - - # Enclose Property Names with brackets [] & Enclose Property Values with single quotes '' - $queryInsertProperties = $("[" + ($account.PSObject.Properties.Name -join "],[") + "]") - $queryInsertValues = $(($account.PSObject.Properties.Value | ForEach-Object { if ($_ -ne 'null') { "'$_'" } else { 'null' } }) -join ',') - $queryInsert = "INSERT INTO $table ($($queryInsertProperties)) VALUES ($($queryInsertValues))" - - $queryInsertSplatParams = @{ - ConnectionString = $actionContext.configuration.connectionString - Username = $actionContext.configuration.username - Password = $actionContext.configuration.password - SqlQuery = $queryInsert - ErrorAction = "Stop" - } + # Update blacklist database + switch ($action) { + "Create" { + # Create row + $actionMessage = "creating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]" + + # Add attribute name and value to the account object + $account | Add-Member -NotePropertyName 'attributeName' -NotePropertyValue $attributeName + $account | Add-Member -NotePropertyName 'attributeValue' -NotePropertyValue $attributeValue + + # Add timestamps + $account | Add-Member -NotePropertyName 'whenCreated' -NotePropertyValue (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") -Force + $account | Add-Member -NotePropertyName 'whenUpdated' -NotePropertyValue $null -Force + $account | Add-Member -NotePropertyName 'whenDeleted' -NotePropertyValue $null -Force + + # Enclose Property Names with brackets [] & Enclose Property Values with single quotes '' + $queryInsertProperties = $("[" + (($account.PSObject.Properties.Name) -join "],[") + "]") + $queryInsertValues = $(($account.PSObject.Properties.Value | ForEach-Object { if ($_ -ne 'null' -and $null -ne $_) { "'$_'" } else { 'null' } }) -join ',') + $queryInsert = "INSERT INTO $table ($($queryInsertProperties)) VALUES ($($queryInsertValues))" + + $queryInsertSplatParams = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $queryInsert + ErrorAction = "Stop" + } + + $queryInsertResult = [System.Collections.ArrayList]::new() + if (-not($actioncontext.dryRun -eq $true)) { + Invoke-SQLQuery @queryInsertSplatParams -Data ([ref]$queryInsertResult) - $queryInsertResult = [System.Collections.ArrayList]::new() - if (-not($actioncontext.dryRun -eq $true)) { - Write-Information "Inserting row in table [$table] for attribute [$attributeName] and value [$attributeValue]. Query: $($queryInsert)" - Invoke-SQLQuery @queryInsertSplatParams -Data ([ref]$queryInsertResult) - } - else { - Write-Warning "DryRun: Would insert row in table [$table] for attribute [$attributeName] and value [$attributeValue]. Query: $($queryInsert)" - } - $outputContext.AccountReference = $account.employeeId - $outputContext.PreviousData.$attributeName = $null $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "Successfully inserted row in table [$table] for attribute [$attributeName] and value [$attributeValue]" + # Action = "" # Optional + Message = "Created row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]." IsError = $false }) - break } - "UpdateAccount" { - $queryUpdateSet = "SET [employeeId]='$($account.employeeId)', [whenDeleted]=null" - $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName'" - - $queryUpdateSplatParams = @{ - ConnectionString = $actionContext.configuration.connectionString - Username = $actionContext.configuration.username - Password = $actionContext.configuration.password - SqlQuery = $queryUpdate - ErrorAction = "Stop" - } + else { + Write-Warning "DryRun: Would create row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]." + } + + break + } + "Update" { + # Update row - clear whenDeleted + $actionMessage = "clearing [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]" + + $queryUpdateSet = "SET [whenDeleted]=null, [whenUpdated]=GETDATE()" + $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName' AND [employeeId] = '$($account.employeeId)'" + + $queryUpdateSplatParams = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $queryUpdate + ErrorAction = "Stop" + } + + $queryUpdateResult = [System.Collections.ArrayList]::new() + if (-not($actioncontext.dryRun -eq $true)) { + Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) - $queryUpdateResult = [System.Collections.ArrayList]::new() - if (-not($actioncontext.dryRun -eq $true)) { - Write-Information "Updating row from table [$table]. Query: $($queryUpdate)" - Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) - } - else { - Write-Warning "DryRun: Would update updated row in table [$table] for attribute [$attributeName] and value [$attributeValue]. Query: $($queryUpdate)" - } - $outputContext.AccountReference = $account.employeeId - $outputContext.PreviousData.employeeId = $correlatedAccount.employeeId - $outputContext.PreviousData.whenDeleted = $correlatedAccount.whenDeleted $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "Successfully updated row in table [$table] for attribute [$attributeName] and value [$attributeValue]" + # Action = "" # Optional + Message = "Cleared [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]." IsError = $false }) - break } - "NoChanges" { - $outputContext.AccountReference = $account.employeeId - $noChanges = $true - Write-Information "Value in table [$table] for attribute [$attributeName] and value [$attributeValue] already exists, action skipped" - break + else { + Write-Warning "DryRun: Would clear [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]." } + + break + } + "NoChanges" { + $actionMessage = "skipping updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]" + + $outputContext.auditlogs.Add([PSCustomObject]@{ + # Action = "" # Optional + Message = "Skipped updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]. reason: No changes." + IsError = $false + }) + + break + } + "OtherEmployeeId" { + $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" + + # Throw terminal error + throw "A row was found where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. However the EmployeeID [$($correlatedAccount.employeeId)] doesn't match the current person (expected: [$($actionContext.References.Account)]). Additionally, [whenDeleted] = [$($correlatedAccount.whenDeleted)] is still within the allowed threshold [$retentionPeriod days]. This should not be possible. Please check the database for inconsistencies." + + break + } + "MultipleFound" { + $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" + + # Throw terminal error + throw "Multiple rows were found in the database where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]. This should not be possible. Please check the database for inconsistencies." + + break } - } - catch { - $ex = $PSItem - $auditErrorMessage = $ex.Exception.Message - Write-Warning "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($auditErrorMessage)" - $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "Failed to insert data into table [$table] for attribute [$attributeName] with value [$attributeValue]: $($auditErrorMessage)" - IsError = $true - }) } } - # To prevent the audit message 'Account create successful' from being displayed in the create script. This audit message will be ignored in the update script, because 'Data' and 'PreviousData' will have the same values. - if (([string]::IsNullOrEmpty($outputContext.auditlogs.Message)) -and $noChanges) { - $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "No updates required in table [$table] for attributes [$($attributeNames -join ', ')]" - IsError = $false - }) - } } catch { $ex = $PSItem - $auditErrorMessage = $ex.Exception.Message - Write-Information "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($auditErrorMessage)" + + $auditMessage = "Error $($actionMessage). Error: $($ex.Exception.Message)" + $warningMessage = "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" + + Write-Warning $warningMessage $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "Generic error: $($auditErrorMessage)" + # Action = "" # Optional + Message = $auditMessage IsError = $true }) } From f0fe736ecf13ba0b639de269f28598b6db981cdc Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:48:57 +0100 Subject: [PATCH 07/24] Rewrite delete script to match CSV structure with per-attribute processing --- delete.ps1 | 146 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 87 insertions(+), 59 deletions(-) diff --git a/delete.ps1 b/delete.ps1 index 4570887..43b6832 100644 --- a/delete.ps1 +++ b/delete.ps1 @@ -3,6 +3,11 @@ # Use data from dependent system ##################################################### +$table = $actionContext.configuration.table + +$attributeNames = $($actionContext.Data | Select-Object * -ExcludeProperty employeeId, whenDeleted, whenCreated, whenUpdated).PSObject.Properties.Name + +#region functions function Invoke-SQLQuery { param( [parameter(Mandatory = $true)] @@ -71,84 +76,107 @@ function Invoke-SQLQuery { } } } +#endregion functions try { - $table = $actionContext.configuration.table - $querySelect = "SELECT * FROM $table WHERE [employeeId] = '$($actionContext.References.Account)' AND [WhenDeleted] IS NULL" - - Write-Information "Querying data from table [$table]. Query: $($querySelect)" - - $querySelectSplatParams = @{ - ConnectionString = $actionContext.configuration.connectionString - Username = $actionContext.configuration.username - Password = $actionContext.configuration.password - SqlQuery = $querySelect - ErrorAction = "Stop" + # Verify account reference + $actionMessage = "verifying account reference" + if ([string]::IsNullOrEmpty($($actionContext.References.Account))) { + throw "The account reference could not be found" } - $querySelectResult = [System.Collections.ArrayList]::new() - Invoke-SQLQuery @querySelectSplatParams -Data ([ref]$querySelectResult) -verbose:$false + # Import data from table + $actionMessage = "importing data from table [$table]" - $selectRowCount = ($querySelectResult | measure-object).count - Write-Information "Successfully queried data from table [$table]. Query: $($querySelect). Returned rows: $selectRowCount" + Write-Information "Imported data from table [$table]" - if ($selectRowCount -gt 0) { - $action = "UpdateAccount" - } - else { - $action = "Skip" - } + foreach ($attributeName in $attributeNames) { + # Check if attribute is in table + $actionMessage = "querying row in table [$table] where [attributeName] = [$($attributeName)] AND [employeeID] = [$($actionContext.References.Account)]" - # Update blacklist database - switch ($action) { - "Skip" { - Write-Information "Skipping action query select returned 0 results for table [$table]. Query: $($querySelect)" + $querySelect = "SELECT * FROM [$table] WHERE [attributeName] = '$attributeName' AND [employeeId] = '$($actionContext.References.Account)'" - $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "No row without a WhenDeleted date found in blacklist for attribute [$table] and employeeId [$($actionContext.References.Account)]" - IsError = $false - }) - break + $querySelectSplatParams = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $querySelect + ErrorAction = "Stop" } - "UpdateAccount" { - $queryUpdateSet = "[whenDeleted]='$($actionContext.data.whenDeleted)'" - $queryUpdate = "UPDATE [$table] SET $queryUpdateSet WHERE [employeeId] = '$($actionContext.References.Account)' AND [WhenDeleted] IS NULL" - $auditMessage = "Successfully set [whenDeleted] for rows with employeeId [$($actionContext.References.Account)]" - - $queryUpdateSplatParams = @{ - ConnectionString = $actionContext.configuration.connectionString - Username = $actionContext.configuration.username - Password = $actionContext.configuration.password - SqlQuery = $queryUpdate - ErrorAction = "Stop" - } - if (-not($actioncontext.dryRun -eq $true)) { - Write-Information "Updating row from table [$table]. Query: $($queryUpdate)" - $queryUpdateResult = [System.Collections.ArrayList]::new() - Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) + $querySelectResult = [System.Collections.ArrayList]::new() + Invoke-SQLQuery @querySelectSplatParams -Data ([ref]$querySelectResult) -verbose:$false + + $selectRowCount = ($querySelectResult | measure-object).count + Write-Information "Queried row in table [$table] where [attributeName] = [$($attributeName)] AND [employeeID] = [$($actionContext.References.Account)]. Result count: $selectRowCount" + + foreach ($dbCurrentRow in $querySelectResult) { + if ([string]::IsNullOrEmpty($dbCurrentRow.whenDeleted)) { + $action = "Update" } else { - Write-Warning "DryRun: Would update row from table [$table]. Query: $($queryUpdate)" + $action = "WhenDeletedAlreadySet" + } + + switch ($action) { + "Update" { + # Update row + $actionMessage = "updating [whenDeleted] and [whenUpdated] for row in table [$table] where [$($attributeName)] = [$($dbCurrentRow.attributeValue)] AND [employeeID] = [$($actionContext.References.Account)]" + $now = Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff" + $queryUpdateSet = "[whenDeleted]='$now', [whenUpdated]='$now'" + $queryUpdate = "UPDATE [$table] SET $queryUpdateSet WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$($dbCurrentRow.attributeValue)' AND [employeeId] = '$($actionContext.References.Account)'" + + $queryUpdateSplatParams = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $queryUpdate + ErrorAction = "Stop" + } + + if (-not($actioncontext.dryRun -eq $true)) { + $queryUpdateResult = [System.Collections.ArrayList]::new() + Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) + + $outputContext.auditlogs.Add([PSCustomObject]@{ + # Action = "" # Optional + Message = "Updated [whenDeleted] to [$($now)] for row in table [$table] where [$($attributeName)] = [$($dbCurrentRow.attributeValue)] AND [employeeID] = [$($actionContext.References.Account)]." + IsError = $false + }) + } + else { + Write-Warning "DryRun: Would update [whenDeleted] to [$($now)] for row in table [$table] where [$($attributeName)] = [$($dbCurrentRow.attributeValue)] AND [employeeID] = [$($actionContext.References.Account)]." + } + + break + } + + "WhenDeletedAlreadySet" { + $actionMessage = "skipping updating row in table [$table] where [$($attributeName)] = [$($dbCurrentRow.attributeValue)] AND [employeeID] = [$($actionContext.References.Account)]" + + $outputContext.auditlogs.Add([PSCustomObject]@{ + # Action = "" # Optional + Message = "Skipped updating row in table [$table] where [$($attributeName)] = [$($dbCurrentRow.attributeValue)] AND [employeeID] = [$($actionContext.References.Account)]. reason: [whenDeleted] is already set." + IsError = $false + }) + + break + } } - - $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = $auditMessage - IsError = $false - }) - - break } } } catch { $ex = $PSItem - $auditErrorMessage = $ex.Exception.Message - Write-Warning "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($auditErrorMessage)" - Write-Warning "QuerySelect: $querySelect" - Write-Warning "QueryUpdate: $queryUpdate" + + $auditMessage = "Error $($actionMessage). Error: $($ex.Exception.Message)" + $warningMessage = "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" + + Write-Warning $warningMessage + $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "Failed to update data in table [$table] : $($auditErrorMessage)" + # Action = "" # Optional + Message = $auditMessage IsError = $true }) } From 24df50a6470b1dcf6ee02f98bb1c84857b398225 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:49:41 +0100 Subject: [PATCH 08/24] Add cross-check validation and keepInSyncWith functionality --- checkOnExternalSystemsAd.ps1 | 250 +++++++++++++++++++++++------------ 1 file changed, 162 insertions(+), 88 deletions(-) diff --git a/checkOnExternalSystemsAd.ps1 b/checkOnExternalSystemsAd.ps1 index 0731faf..90080fe 100644 --- a/checkOnExternalSystemsAd.ps1 +++ b/checkOnExternalSystemsAd.ps1 @@ -2,35 +2,63 @@ # HelloID-Conn-Prov-Target-Blacklist-Check-On-External-Systems-AD-SQL ######################################################################### -# Initialize default values -$success = $false # Set to false at start, at the end, only when no error occurs it is set to true -$auditLogs = [System.Collections.Generic.List[PSCustomObject]]::new() -$NonUniqueFields = [System.Collections.Generic.List[PSCustomObject]]::new() +# Initialize default properties +$a = $account | ConvertFrom-Json; +$aRef = $accountReference | ConvertFrom-Json +# The entitlementContext contains the configuration +# - configuration: The configuration that is set in the Custom PowerShell configuration $eRef = $entitlementContext | ConvertFrom-Json -$c = $eRef.configuration -$a = $account | ConvertFrom-Json -$p = $person | ConvertFrom-Json -# Used to connect to SQL server. -$connectionString = $c.connectionString -$username = $c.username -$password = $c.password -$table = $c.table +$table = $eRef.configuration.table +$retentionPeriod = $eRef.configuration.retentionPeriod -#region Change mapping here +# Operation is a script parameter which contains the action HelloID wants to perform for this entitlement +# It has one of the following values: "create", "enable", "update", "disable", "delete" +$o = $operation | ConvertFrom-Json -# select which attributes should be checked -$attributeNames = @('SamAccountName', 'UserPrincipalName') +# Set Success to false at start, at the end, only when no error occurs it is set to true +$success = $false -# Raise iteration of all configured fields when one is not unique -$syncIterations = $true +# Initiate empty list for Non Unique Fields +$nonUniqueFields = [System.Collections.Generic.List[PSCustomObject]]::new() -# Select which attributes should iterate when syncIterations = $true; this usually mirrors the AD field mapping uniqueness configuration -$syncIterationsAttributeNames = @('SamAccountName', 'UserPrincipalName', 'commonName', 'mail', "proxyAddresses") +# Define fields to check +$fieldsToCheck = [PSCustomObject]@{ + "userPrincipalName" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields. + systemFieldName = 'userPrincipalName' # Name of the field in the system itself, to be used in the query to the system. + accountValue = $a.userPrincipalName + keepInSyncWith = @("mail", "proxyAddresses") # Properties to synchronize with. If this property isn't unique, these properties will also be treated as non-unique. + crossCheckOn = @("mail") # Properties to cross-check for uniqueness. + } + "mail" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields. + systemFieldName = 'mail' # Name of the field in the system itself, to be used in the query to the system. + accountValue = $a.mail + keepInSyncWith = @("userPrincipalName", "proxyAddresses") # Properties to synchronize with. If this property isn't unique, these properties will also be treated as non-unique. + crossCheckOn = @("userPrincipalName") # Properties to cross-check for uniqueness. + } + "proxyAddresses" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields. + systemFieldName = 'mail' # Name of the field in the system itself, to be used in the query to the system. + accountValue = $a.proxyAddresses + keepInSyncWith = @("userPrincipalName", "mail") # Properties to synchronize with. If this property isn't unique, these properties will also be treated as non-unique. + crossCheckOn = @("userPrincipalName") # Properties to cross-check for uniqueness. + } + "sAMAccountName" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields. + systemFieldName = 'sAMAccountName' # Name of the field in the system itself, to be used in the query to the system. + accountValue = $a.sAMAccountName + keepInSyncWith = @("commonName") # Properties to synchronize with. If this property isn't unique, these properties will also be treated as non-unique. + crossCheckOn = $null # Properties to cross-check for uniqueness. + } + "commonName" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields. + systemFieldName = 'cn' # Name of the field in the system itself, to be used in the query to the system. + accountValue = $a.commonName + keepInSyncWith = @("sAMAccountName") # Properties to synchronize with. If this property isn't unique, these properties will also be treated as non-unique. + crossCheckOn = $null # Properties to cross-check for uniqueness. + } +} -# Exclude self from query -$excludeSelf = $true +# Define correlation attribute +$correlationAttribute = "employeeID" #endregion Change mapping here @@ -55,6 +83,18 @@ function Invoke-SQLQuery { try { $Data.value = $null + # Initialize connection and execute query + if (-not[String]::IsNullOrEmpty($Username) -and -not[String]::IsNullOrEmpty($Password)) { + # First create the PSCredential object + $securePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force + $credential = [System.Management.Automation.PSCredential]::new($Username, $securePassword) + + # Set the password as read only + $credential.Password.MakeReadOnly() + + # Create the SqlCredential object + $sqlCredential = [System.Data.SqlClient.SqlCredential]::new($credential.username, $credential.password) + } # Connect to the SQL server $SqlConnection = [System.Data.SqlClient.SqlConnection]::new() $SqlConnection.ConnectionString = $ConnectionString @@ -62,7 +102,7 @@ function Invoke-SQLQuery { $SqlConnection.Credential = $sqlCredential } $SqlConnection.Open() - Write-Information "Successfully connected to SQL database" + Write-Verbose "Successfully connected to SQL database" # Set the query $SqlCmd = [System.Data.SqlClient.SqlCommand]::new() @@ -87,99 +127,133 @@ function Invoke-SQLQuery { finally { if ($SqlConnection.State -eq "Open") { $SqlConnection.close() - Write-Information "Successfully disconnected from SQL database" + Write-Verbose "Successfully disconnected from SQL database" } } } #endregion functions try { - $valuesToCheck = [PSCustomObject]@{} - foreach ($attributeName in $attributeNames) { - if ($a.PsObject.Properties.Name -contains $attributeName) { - $valuesToCheck | Add-Member -MemberType NoteProperty -Name $attributeName -Value $a.$attributeName + # Query current data in database + foreach ($fieldToCheck in $fieldsToCheck.PsObject.Properties | Where-Object { -not[String]::IsNullOrEmpty($_.Value.accountValue) }) { + # Skip if this field is already marked as non-unique + if ($nonUniqueFields -contains $fieldToCheck.Name) { + Write-Verbose "Skipping uniqueness check for property [$($fieldToCheck.Name)] with value(s) [$($fieldToCheck.Value.accountValue -join ', ')] because it is already marked as non-unique (either directly or through keepInSyncWith configuration)." + continue } - } - if (-not[String]::IsNullOrEmpty($valuesToCheck)) { - - # Query current data in database - foreach ($attribute in $valuesToCheck.PSObject.Properties) { - try { - $querySelect = "SELECT * FROM [$table] WHERE [attributeName] = '$($attribute.Name)' AND [attributeValue] = '$($attribute.Value)'" - if ($excludeSelf) { - $querySelect = "$querySelect AND NOT [EmployeeId] = '$($p.ExternalId)'" - } - $querySelectSplatParams = @{ - ConnectionString = $connectionString - Username = $username - Password = $password - SqlQuery = $querySelect - ErrorAction = "Stop" + foreach ($fieldToCheckAccountValue in $fieldToCheck.Value.accountValue) { + # Remove smtp: prefix for proxyAddresses + $fieldToCheckAccountValue = $fieldToCheckAccountValue -replace '(?i)^smtp:', '' + + # Build WHERE clause starting with the primary field + $whereClause = "[attributeName] = '$($fieldToCheck.Value.systemFieldName)' AND [attributeValue] = '$fieldToCheckAccountValue'" + + # Add cross-check conditions if configured + if (@($fieldToCheck.Value.crossCheckOn).Count -ge 1) { + foreach ($fieldToCrossCheckOn in $fieldToCheck.Value.crossCheckOn) { + # Get the system field name for the cross-check field + $crossCheckSystemFieldName = $fieldsToCheck.$fieldToCrossCheckOn.systemFieldName + + # Custom check for proxyAddresses to prefix value with 'smtp:' + if ($fieldToCrossCheckOn -eq 'proxyAddresses') { + $whereClause = $whereClause + " OR ([attributeName] = '$crossCheckSystemFieldName' AND [attributeValue] = 'smtp:$fieldToCheckAccountValue')" + } + else { + $whereClause = $whereClause + " OR ([attributeName] = '$crossCheckSystemFieldName' AND [attributeValue] = '$fieldToCheckAccountValue')" + } } + } + + $querySelect = "SELECT * FROM [$table] WHERE $whereClause" - $querySelectResult = [System.Collections.ArrayList]::new() - Invoke-SQLQuery @querySelectSplatParams -Data ([ref]$querySelectResult) - $selectRowCount = ($querySelectResult | measure-object).count - Write-Information "Successfully queried data from table [$table] for attribute [$($attribute.Name)]. Query: $($querySelect). Returned rows: $selectRowCount)" + $querySelectSplatParams = @{ + ConnectionString = $eRef.configuration.connectionString + Username = $eRef.configuration.username + Password = $eRef.configuration.password + SqlQuery = $querySelect + ErrorAction = "Stop" + } - if ($selectRowCount -ne 0) { - Write-Warning "$($attribute.Name) value [$($attribute.Value)] is NOT unique in blacklist table [$table]" - [void]$NonUniqueFields.Add($attribute.Name) - } - else { - Write-Information "(Unique). [$($attribute.Name)] value [$($attribute.Value)] is not found in table [$table]" + $querySelectResult = [System.Collections.ArrayList]::new() + Invoke-SQLQuery @querySelectSplatParams -Data ([ref]$querySelectResult) + $selectRowCount = ($querySelectResult | measure-object).count + Write-Verbose "Queried data from table [$table] for attribute [$($fieldToCheck.Name)] with cross-check. Query: $($querySelect). Returned rows: $selectRowCount" + + # Check property uniqueness with retention period logic + if (@($querySelectResult).count -gt 0) { + foreach ($dbRow in $querySelectResult) { + if ($dbRow.employeeId -eq $a.$correlationAttribute) { + Write-Information "Person is using property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] themselves." + } + else { + # Check retention period if whenDeleted is set + if (-NOT [string]::IsNullOrEmpty($dbRow.whenDeleted)) { + $whenDeletedDate = [datetime]($dbRow.whenDeleted) + $daysDiff = (New-TimeSpan -Start $whenDeletedDate -End (Get-Date)).Days + } + else { + $daysDiff = 0 + } + + if ($daysDiff -lt $retentionPeriod) { + # Check if this is a direct match or cross-check match + if ($dbRow.attributeName -eq $fieldToCheck.Value.systemFieldName) { + Write-Warning "Property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] is not unique. It is currently in use by [$correlationAttribute]: [$($dbRow.$correlationAttribute)]. The associated [whenDeleted] timestamp [$($dbRow.whenDeleted)] is still within the allowed retention period of [$($retentionPeriod) days]." + } + else { + Write-Warning "Property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] is not unique due to cross-check. The value exists as [$($dbRow.attributeName)] = [$($dbRow.attributeValue)] in use by [$correlationAttribute]: [$($dbRow.$correlationAttribute)]. The associated [whenDeleted] timestamp [$($dbRow.whenDeleted)] is still within the allowed retention period of [$($retentionPeriod) days]." + } + [void]$NonUniqueFields.Add($fieldToCheck.Name) + + # Add related fields from keepInSyncWith + if (@($fieldToCheck.Value.keepInSyncWith).Count -ge 1) { + foreach ($fieldToKeepInSyncWith in $fieldToCheck.Value.keepInSyncWith | Where-Object { $_ -in $a.PsObject.Properties.Name }) { + Write-Warning "Property [$fieldToKeepInSyncWith] is marked as non-unique because it is configured to keepInSyncWith [$($fieldToCheck.Name)], which is not unique." + [void]$NonUniqueFields.Add($fieldToKeepInSyncWith) + } + } + + # Break out of the loop as we only need to find one non-unique field + break + } + else { + Write-Information "Property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] is considered unique. Although it was previously used by [$correlationAttribute]: [$($dbRow.$correlationAttribute)], the [whenDeleted] timestamp [$($dbRow.whenDeleted)] exceeds the allowed retention period of [$($retentionPeriod) days] and the value will be reused." + } + } } } - catch { - $ex = $PSItem - # Set Verbose error message - $verboseErrorMessage = $ex.Exception.Message - # Set Audit error message - $auditErrorMessage = $ex.Exception.Message - - Write-Information "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($verboseErrorMessage)" - $auditLogs.Add([PSCustomObject]@{ - # Action = "" # Optional - Message = "Error checking mapped values against database data. Error Message: $($auditErrorMessage)" - IsError = $True - }) - - # Use throw, as auditLogs are not available in check on external system - throw "Error checking mapped values against database data. Error Message: $($auditErrorMessage)" + elseif (@($querySelectResult).count -eq 0) { + Write-Information "Property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] is unique." } } } + + # Set Success to true + $success = $true } catch { $ex = $PSItem - # Set Verbose error message - $verboseErrorMessage = $ex.Exception.Message + + $auditMessage = "Error $($actionMessage). Error: $($ex.Exception.Message)" + $warningMessage = "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" - # Set Audit error message - $auditErrorMessage = $ex.Exception.Message + # Set Success to false + $success = $false - Write-Information "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($verboseErrorMessage)" - # Use throw, as auditLogs are not available in check on external system - throw "Error performing uniqueness check on external systems. Error Message: $($auditErrorMessage)" + Write-Warning $warningMessage + + # Required to write an error as uniqueness check doesn't show auditlog + Write-Error $auditMessage } finally { - # Check if auditLogs contains errors, if no errors are found, set success to true - if (-not($auditLogs.IsError -contains $true)) { - $success = $true - } - - # When syncIterations is set to true, set NonUniqueFields to all configured fields - if (($NonUniqueFields | Measure-Object).Count -ge 1 -and $syncIterations -eq $true) { - $NonUniqueFields = $attributeNames + $syncIterationsAttributeNames | Sort-Object -Unique - } + $nonUniqueFields = @($nonUniqueFields | Sort-Object -Unique) # Send results $result = [PSCustomObject]@{ Success = $success - - # Add field name as string when field is not unique - NonUniqueFields = $NonUniqueFields + NonUniqueFields = $nonUniqueFields } + Write-Output ($result | ConvertTo-Json -Depth 10) } \ No newline at end of file From d28d0cbfd1616fff3de71f6ad4aa3f1447fc16b4 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:50:40 +0100 Subject: [PATCH 09/24] Restructure documentation to match V2 template with enhanced use cases and feature descriptions --- README.md | 191 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 139 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 27d8753..e1a918c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # HelloID-Conn-Prov-Target-Blacklist-SQL -Repository for HelloID Provisioning Target Connector to SQL Blacklist - Forks Badge Pull Requests Badge -Issues Badge +Issues Badge GitHub contributors > [!IMPORTANT] @@ -14,85 +12,174 @@ Repository for HelloID Provisioning Target Connector to SQL Blacklist

-## Table of Contents +## Table of contents - [HelloID-Conn-Prov-Target-Blacklist-SQL](#helloid-conn-prov-target-blacklist-sql) - - [Table of Contents](#table-of-contents) + - [Table of contents](#table-of-contents) - [Introduction](#introduction) - - [Requirements](#requirements) - - [Repository contents](#repository-contents) - - [Connection settings](#connection-settings) - - [Correlation configuration](#correlation-configuration) - - [Settings in AD uniqueness script](#settings-in-ad-uniqueness-script) + - [Use Cases](#use-cases) + - [Supported features](#supported-features) + - [Getting started](#getting-started) + - [HelloID Icon URL](#helloid-icon-url) + - [Requirements](#requirements) + - [Connection settings](#connection-settings) + - [Correlation configuration](#correlation-configuration) + - [Field mapping](#field-mapping) + - [Account Reference](#account-reference) - [Remarks](#remarks) + - [Development resources](#development-resources) + - [Available lifecycle actions](#available-lifecycle-actions) + - [Additional scripts](#additional-scripts) + - [Database table structure](#database-table-structure) - [Getting help](#getting-help) - [HelloID docs](#helloid-docs) ## Introduction -This connector allows for the storage of attribute values that must remain unique, such as SamAccountName and/or UserPrincipalName, in a blacklist database. When a new account is created, this database is checked alongside the primary target system to verify the uniqueness of these account attributes. +HelloID-Conn-Prov-Target-Blacklist-SQL is a target connector that writes user attribute values to an SQL database-based blacklist. These values can later be used to prevent reuse, for example of `sAMAccountName`, `email`, or `userPrincipalName`. The blacklist is used in combination with the uniqueness check feature of other connectors (e.g., Active Directory) to ensure attribute values remain unique across the organization. + +### Use Cases + +This connector is designed to solve common identity management challenges: + +1. **Preventing attribute reuse**: When an employee leaves the organization, their email address, username, or UPN is blocked from being immediately reassigned. This prevents confusion, misdirected emails, and security issues. + +2. **Organizational uniqueness enforcement**: Even if your HR system doesn't track historical employees, the blacklist maintains a record of all previously used values, ensuring no two people (past or present) can have the same identifier. + +3. **Controlled value recycling**: After a configurable retention period (e.g., 365 days), values can be made available for reuse, balancing security with practical namespace management. + +4. **Cross-system validation**: Works seamlessly with HelloID's built-in connectors (like Active Directory) to validate uniqueness before account creation, preventing provisioning errors. + +5. **Temporary departures**: When an employee returns after a leave of absence, their original values can be automatically restored if still within the retention period. + +6. **Multi-attribute validation**: Supports checking multiple attributes simultaneously (email, UPN, proxy addresses) with cross-checking capabilities to catch conflicts across different attribute types. + +## Supported features + +The following features are available: + +| Feature | Supported | Notes | +| ---------------------------------- | --------- | ------------------------------------------------------------------------------- | +| Account Lifecycle | ✅ | Create, Update, Delete (soft-delete with retention period) | +| Permissions | ❌ | Not applicable for blacklist connector | +| Resources | ❌ | Not applicable for blacklist connector | +| Entitlement Import: Accounts | ❌ | Not applicable for blacklist connector | +| Entitlement Import: Permissions | ❌ | Not applicable for blacklist connector | +| Governance Reconciliation Resolutions | ❌ | Not applicable for blacklist connector | + +## Getting started + +### HelloID Icon URL + +URL of the icon used for the HelloID Provisioning target system. + +``` +https://raw.githubusercontent.com/Tools4everBV/HelloID-Conn-Prov-Target-Blacklist-SQL/refs/heads/main/Icon.png +``` + +### Requirements -## Requirements +- HelloID Provisioning agent (cloud or on-premises) +- Available MS SQL Server database (external server or local SQL Express instance) +- Database table created using the `createTableBlacklist.sql` script +- Database access rights for the agent's service account or SQL-authenticated account +- The client is responsible for populating the blacklist database with any previous data. HelloID will only manage and add the data for the persons handled by provisioning. -- HelloID Provisioning agent (cloud or on-prem). -- Available MSSQL database (External server or local SQL(express) instance). -- SQL database setup containing a table created with the query in the createTableBlacklist.sql file. -- Rights to database for the agent's service account or use a SQL-authenticated account. -- (Optional) Database table is filled with the current AD data. +### Connection settings -## Repository contents +The following settings are required to connect to the SQL database. -The HelloID connector consists of the template scripts shown in the following table. +| Setting | Description | Mandatory | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| Connection string | String value of the connection string used to connect to the SQL database | Yes | +| Table | String value of the table name in which the blacklist values reside | Yes | +| Username | String value of the username of the SQL user to use in the connection string | No | +| Password | String value of the password of the SQL user to use in the connection string | No | +| RetentionPeriod (days) | **Critical setting**: Number of days a deleted value remains blocked before it can be reused. Common values: `365` (1 year), `730` (2 years), or `999999` (permanent blocking). This protects against immediate reuse while allowing eventual recycling of namespace values. | Yes | -| Action | Action(s) Performed | Comment | -| --------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| create.ps1 | Write account data to SQL DB table | Uses account data from another system | -| create.ps1 | Write account data to SQL DB table | Uses account data from another system | -| delete.ps1 | Write whenDeleted date to SQL DB table | Uses account data from another system. Can also be used as an update script | -| configuration.json | Default configuration file || -| fieldMapping.json | Default field mapping file || -| checkOnExternalSystemsAd.ps1 | Check mapped fields against the SQL database | This is configured in the built-in Active Directory connector | -| createTableBlacklist.sql | Script to create the SQL table in the SQL database |Run this within the SQL Management Studio| -| /GenerateUniqueData/example.create.ps1 | Generate unique value and write to SQL DB table | Checks the current data in SQL and generates a value that doesn't exist yet. Use this when generating a random number and use this as input for your AD or Azure AD system. Please be aware this is an example build for the legacy PowerShell connector. | +### Correlation configuration -## Connection settings +The correlation configuration is not used or required in this connector. -The following settings are required to connect to SQL DB. +### Field mapping -| Setting | Description | Mandatory | -| ----------------- | ---------------------------------------------------------------------------- | --------- | -| Connection string | String value of the connection string used to connect to the SQL database | Yes | -| Table | String value of the table name in which the blacklist values reside | Yes | -| Username | String value of the username of the SQL user to use in the connection string | No | -| Password | String value of the password of the SQL user to use in the connection string | No | +The field mapping can be imported by using the `fieldMapping.json` file. -## Correlation configuration +- `employeeId` is only mapped for the **Create** action +- Attributes (Mail, SamAccountName, UserPrincipalName) are mapped for **Create**, **Update**, and **Delete** actions +- All fields use `StoreInAccountData: true` -The correlation configuration is not used or required in this connector +### Account Reference -## Settings in AD uniqueness script -The following settings can and should be set in the AD uniqueness script +The account reference is populated with the `employeeId` property during the Create action. -| Setting | Description | Default value | -| ----------------- | ---------------------------------------------------------------------- | ---------------------------------------- | -| $attributeNames | Array list of the attributes to check | @('SamAccountName', 'UserPrincipalName') | -| $syncIterations | Raise iteration of all configured fields when one is not unique | $true | -| $syncIterationsAttributeNames | Array list of the extra attributes to return when at least one attribute is not unique. Usually mirrors the AD field mapping configuration. Only active when $syncIterations = $true | @('SamAccountName', 'UserPrincipalName','commonName', 'mail',"proxyAddresses") | -| $excludeSelf | Exclude the records bound to the externalId of the user from the query | $true | +**Why employeeId is important**: The `employeeId` serves as the unique identifier linking blacklist entries to specific individuals. This is critical for: + +- **Ownership tracking**: Determines who "owns" each blocked value +- **Automatic restoration**: When a person is re-enabled, their previous values can be restored because the system knows which values belonged to them +- **Conflict prevention**: If a value is already in use by another employeeId (and within retention period), the system prevents reassignment +- **Multi-value support**: One person can have multiple blocked attributes (email, UPN, proxy addresses) all tied to their employeeId +- **Audit trail**: Provides clear history of which values were assigned to which employees and when ## Remarks -- This connector is designed to connect to an MS-SQL DB. Optionally you can also configure this to use another DB, such as SQLite or Oracle. However, the connector currently isn't designed for this and requires additional configuration. -- Make sure the attribute names in the mapping correspond with the attribute names in the primary source system. -- If updating the values is not required, the account update script can be omitted. Ensure that the mapping is updated accordingly. -- The mapping field employeeId should only be configured to the create & update event. -- The mapping field whenDeleted should only be mapped to the delete event. +> [!NOTE] +> This connector is designed to work in combination with the uniqueness check feature of other connectors (like Active Directory) to ensure attribute values remain unique across the organization. + +- **Soft-delete with retention**: When a person is deleted, the `whenDeleted` timestamp is set. The value remains blocked for the configured retention period. +- **Automatic restore**: If a person is re-enabled and their previous attribute value is still blocked, the Create action automatically restores it by clearing the `whenDeleted` timestamp. +- **Retention period configuration**: Use `RetentionPeriod (days)` to specify how long values remain blocked after deletion. Setting this to `999999` effectively makes the retention permanent. +- **Multiple records handling**: The Update action will issue a warning if multiple records with the same `attributeName` and `attributeValue` are found. +- **Cross-check validation**: The `checkOnExternalSystemsAd.ps1` script supports `crossCheckOn` configuration to validate uniqueness across different attribute types (e.g., checking if an email address already exists as a proxy address). +- **keepInSyncWith functionality**: When configured, non-unique status cascades across related fields automatically. +- **Skip optimization**: Once a field is marked non-unique, redundant database queries are automatically skipped. +- **SQL query safety**: All scripts use proper SQL escaping for single quotes to prevent SQL injection. + +## Development resources + +### Available lifecycle actions + +The following lifecycle actions are available: + +| Action | Description | +| ------------- | --------------------------------------------------------------------------------------------------------- | +| Create | Creates or restores a blacklist record in the table (if found, clears the `whenDeleted` timestamp). | +| Update | Updates the `whenUpdated` timestamp but not the `attributeValue`. | +| Delete | Soft-deletes a blacklist record by setting the `whenDeleted` and `whenUpdated` timestamp. | + +### Additional scripts + +Beyond the standard lifecycle scripts, this connector includes specialized scripts: + +| Script | Purpose | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `checkOnExternalSystemsAd.ps1` | **Uniqueness validation script** - Configured in the HelloID built-in Active Directory connector to check if proposed values exist in the blacklist before account creation. Includes advanced features like cross-checking (e.g., checking if an email exists as a proxy address) and field synchronization (`keepInSyncWith`). Prevents provisioning errors by validating uniqueness before attempting to create AD accounts. | +| `createTableBlacklist.sql` | **Database setup script** - Creates the required SQL table structure with proper column types (NVARCHAR, DATETIME2) and constraints. Must be executed in SQL Server Management Studio or similar tool before using the connector. Sets up the foundation for all blacklist operations. | +| `GenerateUniqueData/example.create.ps1` | **Legacy example script** - Demonstrates how to generate unique values by querying the SQL blacklist database in older PowerShell v1 connectors. While this is legacy code, it can be adapted for scenarios requiring custom unique value generation (e.g., employee numbers, random identifiers). Not required for standard V2 connector operation. | + +### Database table structure + +The table includes the following columns: + +| Column Name | Data Type | Description | +| --------------- | ------------- | -------------------------------------------------------------------------------- | +| employeeId | NVARCHAR(100) | Unique identifier for an employee (HelloID person) | +| attributeName | NVARCHAR(100) | Name of the attribute (e.g., Mail, SamAccountName, UserPrincipalName) | +| attributeValue | NVARCHAR(250) | Value of the attribute (e.g., john.doe@company.com) | +| whenCreated | DATETIME2(7) | Timestamp of when the record was originally created | +| whenUpdated | DATETIME2(7) | Timestamp of the last update (can be used to track last activity) | +| whenDeleted | DATETIME2(7) | Timestamp when the record was soft-deleted; `NULL` for active records | + +Use the `createTableBlacklist.sql` script to create the required table structure in your database. ## Getting help + > [!TIP] > _For more information on how to configure a HelloID PowerShell connector, please refer to our [documentation](https://docs.helloid.com/en/provisioning/target-systems/powershell-v2-target-systems.html) pages_. + > [!TIP] > _If you need help, feel free to ask questions on our [forum](https://forum.helloid.com)_. ## HelloID docs + The official HelloID documentation can be found at: https://docs.helloid.com/. From 90003cb56f19b8f22c52f03fff2a9d96f5f75323 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:53:21 +0100 Subject: [PATCH 10/24] Added workflows --- .github/workflows/createRelease.yaml | 82 ++++++++++++++++++++++++++ .github/workflows/verifyChangelog.yaml | 35 +++++++++++ 2 files changed, 117 insertions(+) create mode 100644 .github/workflows/createRelease.yaml create mode 100644 .github/workflows/verifyChangelog.yaml diff --git a/.github/workflows/createRelease.yaml b/.github/workflows/createRelease.yaml new file mode 100644 index 0000000..9e4ab5d --- /dev/null +++ b/.github/workflows/createRelease.yaml @@ -0,0 +1,82 @@ +############################## +# Workflow: Create Release +# Version: 0.0.2 +############################## + +name: Create Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version number (e.g., v1.0.0). Leave blank to use the latest version from CHANGELOG.md.' + required: false + pull_request: + types: + - closed + +permissions: + contents: write + +jobs: + create-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Determine Version + id: determine_version + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + VERSION="${{ github.event.inputs.version }}" + else + if [ -f CHANGELOG.md ]; then + VERSION=$(grep -oP '^## \[\K[^]]+' CHANGELOG.md | head -n 1) + if [ -z "$VERSION" ]; then + echo "No versions found in CHANGELOG.md." + exit 1 + fi + else + echo "CHANGELOG.md not found. Cannot determine version." + exit 1 + fi + fi + [[ "$VERSION" != v* ]] && VERSION="v$VERSION" + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "VERSION_NO_V=${VERSION#v}" >> $GITHUB_ENV + + - name: Extract Release Notes from CHANGELOG.md + id: extract_notes + if: ${{ github.event.inputs.version == '' }} + run: | + if [ -f CHANGELOG.md ]; then + awk '/## \['"${VERSION_NO_V}"'\]/{flag=1; next} /## \[/{flag=0} flag' CHANGELOG.md > release_notes.txt + if [ ! -s release_notes.txt ]; then + echo "No release notes found for version ${VERSION_NO_V} in CHANGELOG.md." + exit 1 + fi + else + echo "CHANGELOG.md not found in the repository." + exit 1 + fi + echo "RELEASE_NOTES<> $GITHUB_ENV + cat release_notes.txt >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Default Release Notes + if: ${{ github.event.inputs.version != '' }} + run: | + echo "RELEASE_NOTES=Release notes not provided for version ${VERSION}." >> $GITHUB_ENV + + - name: Debug Release Notes + run: | + echo "Extracted Release Notes:" + echo "${RELEASE_NOTES}" + + - name: Create GitHub Release + run: | + gh release create "${VERSION}" --title "${VERSION}" --notes-file release_notes.txt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/verifyChangelog.yaml b/.github/workflows/verifyChangelog.yaml new file mode 100644 index 0000000..7496fd7 --- /dev/null +++ b/.github/workflows/verifyChangelog.yaml @@ -0,0 +1,35 @@ +#################################### +# Workflow: Verify CHANGELOG Updated +# Version: 0.0.1 +#################################### + +name: Verify CHANGELOG Updated + +on: + pull_request: + types: [opened, synchronize] + +jobs: + check-changelog: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Ensure CHANGELOG.md is updated + run: | + # Fetch the base branch to compare against + git fetch origin ${{ github.base_ref }} --depth=1 + + # Compare changes between the current branch and the base branch + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}) + + # Check if CHANGELOG.md is included in the list of changed files + if echo "$CHANGED_FILES" | grep -q 'CHANGELOG.md'; then + echo "CHANGELOG.md is updated." + else + echo "ERROR: Please update the CHANGELOG.md file with your changes." && exit 1 + fi \ No newline at end of file From 337fefda5a9c45391ab5db0163196241bad1aeeb Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:53:34 +0100 Subject: [PATCH 11/24] Removed old referenences --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index e1a918c..4ea06d4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,5 @@ # HelloID-Conn-Prov-Target-Blacklist-SQL -Forks Badge -Pull Requests Badge -Issues Badge -GitHub contributors - > [!IMPORTANT] > This repository contains the connector and configuration code only. The implementer is responsible to acquire the connection details such as username, password, certificate, etc. You might even need to sign a contract or agreement with the supplier before implementing this connector. Please contact the client's application manager to coordinate the connector requirements. From 02013a08fac69834ca9149cda72670329775d100 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:56:03 +0100 Subject: [PATCH 12/24] Add comprehensive CHANGELOG documenting v2.0.0 release --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..30b570a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Change Log + +All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). + +## [2.0.0] - 2025-12-24 + +This is a major release of HelloID-Conn-Prov-Target-Blacklist-SQL with significant enhancements to match the CSV blacklist connector functionality and Tools4ever V2 connector standards. + +### Added + +- **Retention period support**: Configurable retention period for deleted values with automatic expiration logic +- **Cross-check validation**: Support for `crossCheckOn` configuration to validate uniqueness across different attribute types (e.g., checking if an email exists as a proxy address) +- **keepInSyncWith functionality**: Automatic cascading of non-unique status across related fields +- **Skip optimization**: Redundant database queries are automatically skipped once a field is marked non-unique +- **Multiple records handling**: Improved logic to filter by employeeId when multiple rows are found +- **Enhanced error handling**: New action types `OtherEmployeeId` and `MultipleFound` with detailed error messages +- **Timestamp tracking**: Added `whenCreated`, `whenUpdated`, and `whenDeleted` columns with proper datetime2(7) precision +- **Comprehensive documentation**: Restructured README with use cases, supported features table, and V2 template compliance +- **Credential support**: Full SQL authentication support with secure credential initialization + +### Changed + +- **Create script**: Restructured to match CSV connector format with improved action calculation logic +- **Update script**: Aligned with create script logic including retention period validation +- **Delete script**: Rewritten to process per-attribute instead of bulk updates, matching CSV structure +- **checkOnExternalSystemsAd.ps1**: Complete rewrite with advanced field checking configuration and retention period awareness +- **fieldMapping.json**: Updated to match CSV structure exactly (employeeId only for Create, attributes for Create/Update/Delete) +- **Logging**: Changed from Write-Information intentions to result-based logging; adjusted log levels (unique=Information, non-unique=Warning) +- **Audit logs**: Moved inside non-dryRun blocks to prevent audit entries during preview mode +- **SQL queries**: Simplified UPDATE queries to only modify `whenDeleted` and `whenUpdated` fields +- **Account reference**: Moved to absolute top of create script for consistency + +### Fixed + +- **SQL syntax errors**: Fixed bracket joining in SELECT queries that caused "missing or empty column name" errors +- **UPDATE query logic**: Removed employeeId from SET clause and added to WHERE clause for proper record targeting +- **Credential initialization**: Fixed missing credential code in checkOnExternalSystemsAd.ps1's Invoke-SQLQuery function +- **Configuration**: Removed invalid type field from retentionPeriod configuration + +### Deprecated + +- Legacy syncIterations and syncIterationsAttributeNames approach replaced by keepInSyncWith configuration + +### Removed + +- `whenDeleted` field from fieldMapping.json (managed internally by scripts) +- Unnecessary Write-Information statements for action intentions From 71226d8a7ca535417dfa4acc2f2d514c2bdc8c0e Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:56:58 +0100 Subject: [PATCH 13/24] Add complete version history to CHANGELOG --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b570a..e5ca913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,3 +45,39 @@ This is a major release of HelloID-Conn-Prov-Target-Blacklist-SQL with significa - `whenDeleted` field from fieldMapping.json (managed internally by scripts) - Unnecessary Write-Information statements for action intentions + +## [1.1.0] - 2024-12-12 + +### Added + +- PowerShell V2 connector support with improved structure +- Enhanced field mapping configuration +- Improved error handling and logging + +### Changed + +- Migrated from legacy PowerShell V1 to PowerShell V2 connector format +- Updated connector structure to follow V2 standards +- Improved code organization and maintainability + +## [1.0.0] - 2024-05-17 + +### Added + +- Initial release of HelloID-Conn-Prov-Target-Blacklist-SQL +- Basic create, update, and delete lifecycle actions +- SQL database integration for blacklist management +- Support for tracking employeeId, attributeName, and attributeValue +- Configuration for connection string and table settings +- Field mapping for SamAccountName, UserPrincipalName, and employeeId +- Basic uniqueness checking script for Active Directory integration +- Example script for generating unique data +- SQL table creation script + +### Changed + +- N/A (initial release) + +### Fixed + +- N/A (initial release) From 0794297757d815f4c88d87e8f09b688adf67a23a Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 15:33:09 +0100 Subject: [PATCH 14/24] Update logo size --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4ea06d4..db79680 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > This repository contains the connector and configuration code only. The implementer is responsible to acquire the connection details such as username, password, certificate, etc. You might even need to sign a contract or agreement with the supplier before implementing this connector. Please contact the client's application manager to coordinate the connector requirements.

- +

## Table of contents From 731176299944b25709aa50255ff5e6fde9f4fb62 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:30:16 +0100 Subject: [PATCH 15/24] Fixed issue of misisng attributes --- create.ps1 | 29 +++++++++++++++-------------- update.ps1 | 31 +++++++++++++++++-------------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/create.ps1 b/create.ps1 index aaf0ce0..6951a57 100644 --- a/create.ps1 +++ b/create.ps1 @@ -11,6 +11,7 @@ $attributeNames = $($actionContext.Data | Select-Object * -ExcludeProperty emplo # Set AccountReference to employeeId at the top level, since it's always the current person's employeeId — no need to set it within a specific action $outputContext.AccountReference = $actionContext.Data.employeeId +#region functions function Invoke-SQLQuery { param( [parameter(Mandatory = $true)] @@ -79,6 +80,7 @@ function Invoke-SQLQuery { } } } +#endregion functions try { foreach ($attributeName in $attributeNames) { @@ -86,7 +88,6 @@ try { $actionMessage = "querying row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" $attributeValue = $actionContext.Data.$attributeName -Replace "'", "''" - $account = $actionContext.Data | Select-Object * -ExcludeProperty $attributeNames $querySelect = "SELECT * FROM [$table] WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$attributeValue'" @@ -108,7 +109,7 @@ try { # If multiple rows are found, filter additionally for employeeId if ($selectRowCount -gt 1) { - $correlatedAccount = $querySelectResult | Where-Object { $_.employeeId -eq $account.employeeId } + $correlatedAccount = $querySelectResult | Where-Object { $_.employeeId -eq $actionContext.Data.employeeId } $selectRowCount = ($correlatedAccount | Measure-Object).count Write-Information "Multiple rows found where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Filtered additionally for employeeId. Result count: $selectRowCount" @@ -118,7 +119,7 @@ try { $correlatedAccount = $querySelectResult # Check if value belongs to someone else - if ($correlatedAccount.employeeId -ne $account.employeeId) { + if ($correlatedAccount.employeeId -ne $actionContext.Data.employeeId) { # Check retention period if value is deleted if (-NOT [string]::IsNullOrEmpty($correlatedAccount.whenDeleted)) { $whenDeletedDate = [datetime]($correlatedAccount.whenDeleted) @@ -138,7 +139,6 @@ try { } } else { - Write-Warning ($correlatedAccount | ConvertTo-Json) # Value belongs to current employee if (-not([string]::IsNullOrEmpty($correlatedAccount.whenDeleted))) { # Clear whenDeleted to reactivate @@ -162,18 +162,19 @@ try { # Create row $actionMessage = "creating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]" - # Add attribute name and value to the account object - $account | Add-Member -NotePropertyName 'attributeName' -NotePropertyValue $attributeName - $account | Add-Member -NotePropertyName 'attributeValue' -NotePropertyValue $attributeValue - - # Add timestamps - $account | Add-Member -NotePropertyName 'whenCreated' -NotePropertyValue (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") -Force - $account | Add-Member -NotePropertyName 'whenUpdated' -NotePropertyValue $null -Force - $account | Add-Member -NotePropertyName 'whenDeleted' -NotePropertyValue $null -Force + # Create new object for insert + $insertObject = [PSCustomObject]@{ + employeeId = $actionContext.Data.employeeId + attributeName = $attributeName + attributeValue = $attributeValue + whenCreated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") + whenUpdated = $null + whenDeleted = $null + } # Enclose Property Names with brackets [] & Enclose Property Values with single quotes '' - $queryInsertProperties = $("[" + (($account.PSObject.Properties.Name) -join "],[") + "]") - $queryInsertValues = $(($account.PSObject.Properties.Value | ForEach-Object { if ($_ -ne 'null' -and $null -ne $_) { "'$_'" } else { 'null' } }) -join ',') + $queryInsertProperties = $("[" + (($insertObject.PSObject.Properties.Name) -join "],[") + "]") + $queryInsertValues = $(($insertObject.PSObject.Properties.Value | ForEach-Object { if ($_ -ne 'null' -and $null -ne $_) { "'$_'" } else { 'null' } }) -join ',') $queryInsert = "INSERT INTO $table ($($queryInsertProperties)) VALUES ($($queryInsertValues))" $queryInsertSplatParams = @{ diff --git a/update.ps1 b/update.ps1 index 56524af..996f165 100644 --- a/update.ps1 +++ b/update.ps1 @@ -6,6 +6,9 @@ $table = $actionContext.configuration.table $retentionPeriod = $actionContext.configuration.retentionPeriod +$attributeNames = $($actionContext.Data | Select-Object * -ExcludeProperty employeeId, whenDeleted, whenCreated, whenUpdated).PSObject.Properties.Name + +#region functions function Invoke-SQLQuery { param( [parameter(Mandatory = $true)] @@ -74,6 +77,7 @@ function Invoke-SQLQuery { } } } +#endregion functions try { # Verify account reference @@ -87,11 +91,9 @@ try { $actionMessage = "querying row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" $attributeValue = $actionContext.Data.$attributeName -Replace "'", "''" - $account = $actionContext.Data | Select-Object * -ExcludeProperty $attributeNames $querySelect = "SELECT * FROM [$table] WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$attributeValue'" - $querySelectSplatParams = @{ ConnectionString = $actionContext.configuration.connectionString Username = $actionContext.configuration.username @@ -110,7 +112,7 @@ try { # If multiple rows are found, filter additionally for employeeId if ($selectRowCount -gt 1) { - $correlatedAccount = $querySelectResult | Where-Object { $_.employeeId -eq $account.employeeId } + $correlatedAccount = $querySelectResult | Where-Object { $_.employeeId -eq $actionContext.References.Account } $selectRowCount = ($correlatedAccount | Measure-Object).count Write-Information "Multiple rows found where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Filtered additionally for employeeId. Result count: $selectRowCount" @@ -120,7 +122,7 @@ try { $correlatedAccount = $querySelectResult # Check if value belongs to someone else - if ($correlatedAccount.employeeId -ne $account.employeeId) { + if ($correlatedAccount.employeeId -ne $actionContext.References.Account) { # Check retention period if value is deleted if (-NOT [string]::IsNullOrEmpty($correlatedAccount.whenDeleted)) { $whenDeletedDate = [datetime]($correlatedAccount.whenDeleted) @@ -163,18 +165,19 @@ try { # Create row $actionMessage = "creating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]" - # Add attribute name and value to the account object - $account | Add-Member -NotePropertyName 'attributeName' -NotePropertyValue $attributeName - $account | Add-Member -NotePropertyName 'attributeValue' -NotePropertyValue $attributeValue - - # Add timestamps - $account | Add-Member -NotePropertyName 'whenCreated' -NotePropertyValue (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") -Force - $account | Add-Member -NotePropertyName 'whenUpdated' -NotePropertyValue $null -Force - $account | Add-Member -NotePropertyName 'whenDeleted' -NotePropertyValue $null -Force + # Create new object for insert + $insertObject = [PSCustomObject]@{ + employeeId = $actionContext.References.Account + attributeName = $attributeName + attributeValue = $attributeValue + whenCreated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") + whenUpdated = $null + whenDeleted = $null + } # Enclose Property Names with brackets [] & Enclose Property Values with single quotes '' - $queryInsertProperties = $("[" + (($account.PSObject.Properties.Name) -join "],[") + "]") - $queryInsertValues = $(($account.PSObject.Properties.Value | ForEach-Object { if ($_ -ne 'null' -and $null -ne $_) { "'$_'" } else { 'null' } }) -join ',') + $queryInsertProperties = $("[" + (($insertObject.PSObject.Properties.Name) -join "],[") + "]") + $queryInsertValues = $(($insertObject.PSObject.Properties.Value | ForEach-Object { if ($_ -ne 'null' -and $null -ne $_) { "'$_'" } else { 'null' } }) -join ',') $queryInsert = "INSERT INTO $table ($($queryInsertProperties)) VALUES ($($queryInsertValues))" $queryInsertSplatParams = @{ From 076042294d3fd3b57e805d025fae225f90400db7 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:00:21 +0100 Subject: [PATCH 16/24] Update employeeId when reusing expired rows or reactivating deleted rows --- create.ps1 | 12 ++++++------ update.ps1 | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/create.ps1 b/create.ps1 index 6951a57..7be4187 100644 --- a/create.ps1 +++ b/create.ps1 @@ -203,11 +203,11 @@ try { } "Update" { - # Update row - clear whenDeleted - $actionMessage = "clearing [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]" + # Update row - clear whenDeleted and update employeeId (either for current employee or reusing expired row) + $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" - $queryUpdateSet = "SET [whenDeleted]=null, [whenUpdated]=GETDATE()" - $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName' AND [employeeId] = '$($account.employeeId)'" + $queryUpdateSet = "SET [employeeId]='$($actionContext.Data.employeeId)', [whenDeleted]=null, [whenUpdated]=GETDATE()" + $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName'" $queryUpdateSplatParams = @{ ConnectionString = $actionContext.configuration.connectionString @@ -223,12 +223,12 @@ try { $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional - Message = "Cleared [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]." + Message = "Updated row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Set [employeeId] to [$($actionContext.Data.employeeId)] and cleared [whenDeleted]." IsError = $false }) } else { - Write-Warning "DryRun: Would clear [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]." + Write-Warning "DryRun: Would update row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Would set [employeeId] to [$($actionContext.Data.employeeId)] and clear [whenDeleted]." } break diff --git a/update.ps1 b/update.ps1 index 996f165..1b5b495 100644 --- a/update.ps1 +++ b/update.ps1 @@ -2,7 +2,7 @@ # HelloID-Conn-Prov-Target-Blacklist-SQL-Update # Use data from dependent system ##################################################### - +$actionContext.DryRun = $false $table = $actionContext.configuration.table $retentionPeriod = $actionContext.configuration.retentionPeriod @@ -48,7 +48,7 @@ function Invoke-SQLQuery { $SqlConnection.Credential = $sqlCredential } $SqlConnection.Open() - Write-Information "Successfully connected to SQL database" + Write-Verbose "Successfully connected to SQL database" # Set the query $SqlCmd = [System.Data.SqlClient.SqlCommand]::new() @@ -73,7 +73,7 @@ function Invoke-SQLQuery { finally { if ($SqlConnection.State -eq "Open") { $SqlConnection.close() - Write-Information "Successfully disconnected from SQL database" + Write-Verbose "Successfully disconnected from SQL database" } } } @@ -105,7 +105,7 @@ try { Invoke-SQLQuery @querySelectSplatParams -Data ([ref]$querySelectResult) -verbose:$false $selectRowCount = ($querySelectResult | measure-object).count - Write-Information "Queried data from table [$table] for attribute [$attributeName]. Result count: $selectRowCount" + Write-Verbose "Queried data from table [$table] for attribute [$attributeName]. Result count: $selectRowCount" # Calculate action $actionMessage = "calculating action" @@ -205,11 +205,11 @@ try { break } "Update" { - # Update row - clear whenDeleted - $actionMessage = "clearing [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]" + # Update row - clear whenDeleted and update employeeId (either for current employee or reusing expired row) + $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" - $queryUpdateSet = "SET [whenDeleted]=null, [whenUpdated]=GETDATE()" - $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName' AND [employeeId] = '$($account.employeeId)'" + $queryUpdateSet = "SET [employeeId]='$($actionContext.References.Account)', [whenDeleted]=null, [whenUpdated]=GETDATE()" + $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName'" $queryUpdateSplatParams = @{ ConnectionString = $actionContext.configuration.connectionString @@ -225,12 +225,12 @@ try { $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional - Message = "Cleared [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]." + Message = "Updated row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Set [employeeId] to [$($actionContext.References.Account)] and cleared [whenDeleted]." IsError = $false }) } else { - Write-Warning "DryRun: Would clear [whenDeleted] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]." + Write-Warning "DryRun: Would update row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Would set [employeeId] to [$($actionContext.References.Account)] and clear [whenDeleted]." } break From ac2d1d38c869eaa99f3c1b22136eda2b62e8c70e Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:08:11 +0100 Subject: [PATCH 17/24] removed dryrun false --- update.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/update.ps1 b/update.ps1 index 1b5b495..a16907b 100644 --- a/update.ps1 +++ b/update.ps1 @@ -2,7 +2,7 @@ # HelloID-Conn-Prov-Target-Blacklist-SQL-Update # Use data from dependent system ##################################################### -$actionContext.DryRun = $false + $table = $actionContext.configuration.table $retentionPeriod = $actionContext.configuration.retentionPeriod From 5e8f982cef6ab7fe4c664fa3281e22fe54936184 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:23:49 +0100 Subject: [PATCH 18/24] Set outputContext Data and PreviousData to show actual changes for each attribute --- update.ps1 | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/update.ps1 b/update.ps1 index a16907b..6e8835a 100644 --- a/update.ps1 +++ b/update.ps1 @@ -86,6 +86,10 @@ try { throw "The account reference could not be found" } + # Initialize output context objects to accumulate data for each attribute + $outputContext.Data = @{} + $outputContext.PreviousData = @{} + foreach ($attributeName in $attributeNames) { # Check if attribute is in table $actionMessage = "querying row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" @@ -208,6 +212,9 @@ try { # Update row - clear whenDeleted and update employeeId (either for current employee or reusing expired row) $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" + # Set attribute with old database state + $outputContext.PreviousData[$attributeName] = $correlatedAccount + $queryUpdateSet = "SET [employeeId]='$($actionContext.References.Account)', [whenDeleted]=null, [whenUpdated]=GETDATE()" $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName'" @@ -219,6 +226,16 @@ try { ErrorAction = "Stop" } + # Set attribute with new database state + $outputContext.Data[$attributeName] = [PSCustomObject]@{ + employeeId = $actionContext.References.Account + attributeName = $attributeName + attributeValue = $attributeValue + whenCreated = $correlatedAccount.whenCreated + whenUpdated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") + whenDeleted = $null + } + $queryUpdateResult = [System.Collections.ArrayList]::new() if (-not($actioncontext.dryRun -eq $true)) { Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) @@ -238,6 +255,10 @@ try { "NoChanges" { $actionMessage = "skipping updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]" + # Set both to the same correlated account object since there are no changes + $outputContext.PreviousData[$attributeName] = $correlatedAccount + $outputContext.Data[$attributeName] = $correlatedAccount + $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional Message = "Skipped updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]. reason: No changes." From 5f4422ee3de1e4ba78415062db80bd773318bc27 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:33:29 +0100 Subject: [PATCH 19/24] Add outputContext Data and PreviousData for Create action --- create.ps1 | 21 +++++++++++++++++++++ update.ps1 | 8 ++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/create.ps1 b/create.ps1 index 7be4187..9b08506 100644 --- a/create.ps1 +++ b/create.ps1 @@ -189,6 +189,10 @@ try { if (-not($actioncontext.dryRun -eq $true)) { Invoke-SQLQuery @queryInsertSplatParams -Data ([ref]$queryInsertResult) + # Set Data to the newly created object, PreviousData is null since it didn't exist + $outputContext.PreviousData[$attributeName] = $null + $outputContext.Data[$attributeName] = $insertObject + $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional Message = "Created row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]." @@ -206,6 +210,9 @@ try { # Update row - clear whenDeleted and update employeeId (either for current employee or reusing expired row) $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" + # Set attribute with old database state + $outputContext.PreviousData[$attributeName] = $correlatedAccount + $queryUpdateSet = "SET [employeeId]='$($actionContext.Data.employeeId)', [whenDeleted]=null, [whenUpdated]=GETDATE()" $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName'" @@ -221,6 +228,16 @@ try { if (-not($actioncontext.dryRun -eq $true)) { Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) + # Set attribute with new database state + $outputContext.Data[$attributeName] = [PSCustomObject]@{ + employeeId = $actionContext.Data.employeeId + attributeName = $attributeName + attributeValue = $attributeValue + whenCreated = $correlatedAccount.whenCreated + whenUpdated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") + whenDeleted = $null + } + $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional Message = "Updated row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Set [employeeId] to [$($actionContext.Data.employeeId)] and cleared [whenDeleted]." @@ -237,6 +254,10 @@ try { "NoChanges" { $actionMessage = "skipping updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]" + # Set both to the same correlated account object since there are no changes + $outputContext.PreviousData[$attributeName] = $correlatedAccount + $outputContext.Data[$attributeName] = $correlatedAccount + $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional Message = "Skipped updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]. reason: No changes." diff --git a/update.ps1 b/update.ps1 index 6e8835a..0c9ce0d 100644 --- a/update.ps1 +++ b/update.ps1 @@ -86,10 +86,6 @@ try { throw "The account reference could not be found" } - # Initialize output context objects to accumulate data for each attribute - $outputContext.Data = @{} - $outputContext.PreviousData = @{} - foreach ($attributeName in $attributeNames) { # Check if attribute is in table $actionMessage = "querying row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" @@ -196,6 +192,10 @@ try { if (-not($actioncontext.dryRun -eq $true)) { Invoke-SQLQuery @queryInsertSplatParams -Data ([ref]$queryInsertResult) + # Set Data to the newly created object, PreviousData is null since it didn't exist + $outputContext.PreviousData[$attributeName] = $null + $outputContext.Data[$attributeName] = $insertObject + $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional Message = "Created row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]." From 976df32e22c9591dcc54bdab82231cdef2be0222 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:07:22 +0100 Subject: [PATCH 20/24] Implement object-based query building and enhanced audit logging --- create.ps1 | 59 ++++++++++++++++++++++++++-------------------------- delete.ps1 | 22 ++++++++++++++++---- update.ps1 | 61 ++++++++++++++++++++++++++++++++---------------------- 3 files changed, 84 insertions(+), 58 deletions(-) diff --git a/create.ps1 b/create.ps1 index 9b08506..83cd536 100644 --- a/create.ps1 +++ b/create.ps1 @@ -51,7 +51,7 @@ function Invoke-SQLQuery { $SqlConnection.Credential = $sqlCredential } $SqlConnection.Open() - Write-Information "Successfully connected to SQL database" + Write-Verbose "Successfully connected to SQL database" # Set the query $SqlCmd = [System.Data.SqlClient.SqlCommand]::new() @@ -76,7 +76,7 @@ function Invoke-SQLQuery { finally { if ($SqlConnection.State -eq "Open") { $SqlConnection.close() - Write-Information "Successfully disconnected from SQL database" + Write-Verbose "Successfully disconnected from SQL database" } } } @@ -102,7 +102,7 @@ try { Invoke-SQLQuery @querySelectSplatParams -Data ([ref]$querySelectResult) -verbose:$false $selectRowCount = ($querySelectResult | measure-object).count - Write-Information "Queried data from table [$table] for attribute [$attributeName]. Result count: $selectRowCount" + Write-Verbose "Queried data FROM [$table] WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$attributeValue'. Result count: $selectRowCount" # Calculate action $actionMessage = "calculating action" @@ -185,14 +185,12 @@ try { ErrorAction = "Stop" } - $queryInsertResult = [System.Collections.ArrayList]::new() + $outputContext.Data | Add-Member -NotePropertyName $attributeName -NotePropertyValue $attributeValue -Force + if (-not($actioncontext.dryRun -eq $true)) { + $queryInsertResult = [System.Collections.ArrayList]::new() Invoke-SQLQuery @queryInsertSplatParams -Data ([ref]$queryInsertResult) - # Set Data to the newly created object, PreviousData is null since it didn't exist - $outputContext.PreviousData[$attributeName] = $null - $outputContext.Data[$attributeName] = $insertObject - $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional Message = "Created row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]." @@ -205,15 +203,27 @@ try { break } - + "Update" { # Update row - clear whenDeleted and update employeeId (either for current employee or reusing expired row) - $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" + $actionMessage = "updating [employeeId] to [$($updateObject.employeeId)] and [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" - # Set attribute with old database state - $outputContext.PreviousData[$attributeName] = $correlatedAccount + # Create new object for update + $updateObject = [PSCustomObject]@{ + employeeId = $actionContext.Data.employeeId + whenDeleted = $null + whenUpdated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") + } - $queryUpdateSet = "SET [employeeId]='$($actionContext.Data.employeeId)', [whenDeleted]=null, [whenUpdated]=GETDATE()" + # Build SET clause from updateObject properties + $queryUpdateSet = "SET " + (($updateObject.PSObject.Properties | ForEach-Object { + if ($_.Value -eq $null) { + "[$($_.Name)]=null" + } + else { + "[$($_.Name)]='$($_.Value)'" + } + }) -join ', ') $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName'" $queryUpdateSplatParams = @{ @@ -224,28 +234,21 @@ try { ErrorAction = "Stop" } - $queryUpdateResult = [System.Collections.ArrayList]::new() + $outputContext.Data | Add-Member -NotePropertyName $attributeName -NotePropertyValue $attributeValue -Force + + if (-not($actioncontext.dryRun -eq $true)) { + $queryUpdateResult = [System.Collections.ArrayList]::new() Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) - # Set attribute with new database state - $outputContext.Data[$attributeName] = [PSCustomObject]@{ - employeeId = $actionContext.Data.employeeId - attributeName = $attributeName - attributeValue = $attributeValue - whenCreated = $correlatedAccount.whenCreated - whenUpdated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") - whenDeleted = $null - } - $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional - Message = "Updated row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Set [employeeId] to [$($actionContext.Data.employeeId)] and cleared [whenDeleted]." + Message = "Updated [employeeId] to [$($updateObject.employeeId)] and [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]." IsError = $false }) } else { - Write-Warning "DryRun: Would update row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Would set [employeeId] to [$($actionContext.Data.employeeId)] and clear [whenDeleted]." + Write-Warning "DryRun: Would update [employeeId] to [$($updateObject.employeeId)] and [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]." } break @@ -254,9 +257,7 @@ try { "NoChanges" { $actionMessage = "skipping updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.Data.employeeId)]" - # Set both to the same correlated account object since there are no changes - $outputContext.PreviousData[$attributeName] = $correlatedAccount - $outputContext.Data[$attributeName] = $correlatedAccount + $outputContext.Data | Add-Member -NotePropertyName $attributeName -NotePropertyValue $correlatedAccount.attributeValue -Force $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional diff --git a/delete.ps1 b/delete.ps1 index 43b6832..7d832e4 100644 --- a/delete.ps1 +++ b/delete.ps1 @@ -122,8 +122,22 @@ try { "Update" { # Update row $actionMessage = "updating [whenDeleted] and [whenUpdated] for row in table [$table] where [$($attributeName)] = [$($dbCurrentRow.attributeValue)] AND [employeeID] = [$($actionContext.References.Account)]" - $now = Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff" - $queryUpdateSet = "[whenDeleted]='$now', [whenUpdated]='$now'" + + # Create new object for update + $updateObject = [PSCustomObject]@{ + whenDeleted = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") + whenUpdated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") + } + + # Build SET clause from updateObject properties + $queryUpdateSet = (($updateObject.PSObject.Properties | ForEach-Object { + if ($_.Value -eq $null) { + "[$($_.Name)]=null" + } + else { + "[$($_.Name)]='$($_.Value)'" + } + }) -join ', ') $queryUpdate = "UPDATE [$table] SET $queryUpdateSet WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$($dbCurrentRow.attributeValue)' AND [employeeId] = '$($actionContext.References.Account)'" $queryUpdateSplatParams = @{ @@ -140,12 +154,12 @@ try { $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional - Message = "Updated [whenDeleted] to [$($now)] for row in table [$table] where [$($attributeName)] = [$($dbCurrentRow.attributeValue)] AND [employeeID] = [$($actionContext.References.Account)]." + Message = "Updated [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($dbCurrentRow.attributeValue)] AND [employeeID] = [$($actionContext.References.Account)]." IsError = $false }) } else { - Write-Warning "DryRun: Would update [whenDeleted] to [$($now)] for row in table [$table] where [$($attributeName)] = [$($dbCurrentRow.attributeValue)] AND [employeeID] = [$($actionContext.References.Account)]." + Write-Warning "DryRun: Would update [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($dbCurrentRow.attributeValue)] AND [employeeID] = [$($actionContext.References.Account)]." } break diff --git a/update.ps1 b/update.ps1 index 0c9ce0d..9c74a06 100644 --- a/update.ps1 +++ b/update.ps1 @@ -105,7 +105,7 @@ try { Invoke-SQLQuery @querySelectSplatParams -Data ([ref]$querySelectResult) -verbose:$false $selectRowCount = ($querySelectResult | measure-object).count - Write-Verbose "Queried data from table [$table] for attribute [$attributeName]. Result count: $selectRowCount" + Write-Verbose "Queried data FROM [$table] WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$attributeValue'. Result count: $selectRowCount" # Calculate action $actionMessage = "calculating action" @@ -188,14 +188,15 @@ try { ErrorAction = "Stop" } - $queryInsertResult = [System.Collections.ArrayList]::new() + # Always set previousData to $null as the entire object isn't stored in HelloId, but previousData and data need to differ to log the change + $outputContext.PreviousData | Add-Member -NotePropertyName $attributeName -NotePropertyValue $null -Force + $outputContext.Data | Add-Member -NotePropertyName $attributeName -NotePropertyValue $attributeValue -Force + + if (-not($actioncontext.dryRun -eq $true)) { + $queryInsertResult = [System.Collections.ArrayList]::new() Invoke-SQLQuery @queryInsertSplatParams -Data ([ref]$queryInsertResult) - # Set Data to the newly created object, PreviousData is null since it didn't exist - $outputContext.PreviousData[$attributeName] = $null - $outputContext.Data[$attributeName] = $insertObject - $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional Message = "Created row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]." @@ -208,14 +209,27 @@ try { break } + "Update" { # Update row - clear whenDeleted and update employeeId (either for current employee or reusing expired row) - $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" + $actionMessage = "updating [employeeId] to [$($updateObject.employeeId)] and [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" - # Set attribute with old database state - $outputContext.PreviousData[$attributeName] = $correlatedAccount + # Create new object for update + $updateObject = [PSCustomObject]@{ + employeeId = $actionContext.References.Account + whenDeleted = $null + whenUpdated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") + } - $queryUpdateSet = "SET [employeeId]='$($actionContext.References.Account)', [whenDeleted]=null, [whenUpdated]=GETDATE()" + # Build SET clause from updateObject properties + $queryUpdateSet = "SET " + (($updateObject.PSObject.Properties | ForEach-Object { + if ($_.Value -eq $null) { + "[$($_.Name)]=null" + } + else { + "[$($_.Name)]='$($_.Value)'" + } + }) -join ', ') $queryUpdate = "UPDATE [$table] $queryUpdateSet WHERE [attributeValue] = '$attributeValue' AND [attributeName] = '$attributeName'" $queryUpdateSplatParams = @{ @@ -226,38 +240,33 @@ try { ErrorAction = "Stop" } - # Set attribute with new database state - $outputContext.Data[$attributeName] = [PSCustomObject]@{ - employeeId = $actionContext.References.Account - attributeName = $attributeName - attributeValue = $attributeValue - whenCreated = $correlatedAccount.whenCreated - whenUpdated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") - whenDeleted = $null - } + # Always set previousData to $null as the entire object isn't stored in HelloId, but previousData and data need to differ to log the change + $outputContext.PreviousData | Add-Member -NotePropertyName $attributeName -NotePropertyValue $null -Force + $outputContext.Data | Add-Member -NotePropertyName $attributeName -NotePropertyValue $attributeValue -Force - $queryUpdateResult = [System.Collections.ArrayList]::new() if (-not($actioncontext.dryRun -eq $true)) { + $queryUpdateResult = [System.Collections.ArrayList]::new() Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional - Message = "Updated row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Set [employeeId] to [$($actionContext.References.Account)] and cleared [whenDeleted]." + Message = "Updated [employeeId] to [$($updateObject.employeeId)] and [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]." IsError = $false }) } else { - Write-Warning "DryRun: Would update row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]. Would set [employeeId] to [$($actionContext.References.Account)] and clear [whenDeleted]." + Write-Warning "DryRun: Would update [employeeId] to [$($updateObject.employeeId)] and [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]." } break } + "NoChanges" { $actionMessage = "skipping updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)] AND [employeeID] = [$($actionContext.References.Account)]" - # Set both to the same correlated account object since there are no changes - $outputContext.PreviousData[$attributeName] = $correlatedAccount - $outputContext.Data[$attributeName] = $correlatedAccount + # Set both previousData and data to the correlatedAccount object to show no changes + $outputContext.PreviousData | Add-Member -NotePropertyName $attributeName -NotePropertyValue $correlatedAccount.attributeValue -Force + $outputContext.Data | Add-Member -NotePropertyName $attributeName -NotePropertyValue $correlatedAccount.attributeValue -Force $outputContext.auditlogs.Add([PSCustomObject]@{ # Action = "" # Optional @@ -267,6 +276,7 @@ try { break } + "OtherEmployeeId" { $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" @@ -275,6 +285,7 @@ try { break } + "MultipleFound" { $actionMessage = "updating row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" From a70e6717fae515844f222419e15b2fe6fe7c29eb Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:11:37 +0100 Subject: [PATCH 21/24] Add fieldsToCheck configuration and allowSelfUsage option --- checkOnExternalSystemsAd.ps1 | 115 ++++++++++++++++++++++++----------- 1 file changed, 79 insertions(+), 36 deletions(-) diff --git a/checkOnExternalSystemsAd.ps1 b/checkOnExternalSystemsAd.ps1 index 90080fe..8011eec 100644 --- a/checkOnExternalSystemsAd.ps1 +++ b/checkOnExternalSystemsAd.ps1 @@ -23,43 +23,68 @@ $success = $false # Initiate empty list for Non Unique Fields $nonUniqueFields = [System.Collections.Generic.List[PSCustomObject]]::new() -# Define fields to check +#region Change mapping here + +# Correlation Attribute +# Identifies and matches persons between the account object (from HelloID) and the blacklist database +# Used to determine ownership of values: does a blacklisted value belong to the current person or someone else? +# Required for: Self-usage checks, retention period validation, and ownership determination +$correlationAttribute = [PSCustomObject]@{ + accountFieldName = "employeeId" # Property name in the account object received from HelloID + systemFieldName = "employeeId" # Corresponding column name in the blacklist database table +} + +# Allow Self-Usage Configuration +# Determines whether a person can reuse values they already own in the blacklist database +# - $true (recommended): Person's own values are treated as unique +# Example: Person can keep their existing email address without triggering non-unique warnings +# This is the normal behavior for most scenarios +# - $false (strict mode): Person's own values are also treated as non-unique +# Example: Forces regeneration of all values, even if the person already owns them +# Use case: When implementing a complete value refresh or migration scenario +# Note: Works in conjunction with $correlationAttribute to determine value ownership +$allowSelfUsage = $true + +# Fields to Check for Uniqueness +# Defines which account properties should be validated against the blacklist database +# Each field configuration includes: +# - systemFieldName: The database column name to query (attributeName field in the blacklist table) +# - accountValue: The actual value from the account object to validate +# - keepInSyncWith: Related properties that share uniqueness status (if one is non-unique, all are marked non-unique) +# - crossCheckOn: Additional properties to check for conflicts (searches across multiple attributeName values) +# Example: If userPrincipalName="user@domain.com", also check if mail="user@domain.com" exists $fieldsToCheck = [PSCustomObject]@{ - "userPrincipalName" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields. - systemFieldName = 'userPrincipalName' # Name of the field in the system itself, to be used in the query to the system. + "userPrincipalName" = [PSCustomObject]@{ + systemFieldName = 'userPrincipalName' accountValue = $a.userPrincipalName - keepInSyncWith = @("mail", "proxyAddresses") # Properties to synchronize with. If this property isn't unique, these properties will also be treated as non-unique. - crossCheckOn = @("mail") # Properties to cross-check for uniqueness. + keepInSyncWith = @("mail", "proxyAddresses") + crossCheckOn = @("mail") } - "mail" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields. - systemFieldName = 'mail' # Name of the field in the system itself, to be used in the query to the system. + "mail" = [PSCustomObject]@{ + systemFieldName = 'mail' accountValue = $a.mail - keepInSyncWith = @("userPrincipalName", "proxyAddresses") # Properties to synchronize with. If this property isn't unique, these properties will also be treated as non-unique. - crossCheckOn = @("userPrincipalName") # Properties to cross-check for uniqueness. + keepInSyncWith = @("userPrincipalName", "proxyAddresses") + crossCheckOn = @("userPrincipalName") } - "proxyAddresses" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields. - systemFieldName = 'mail' # Name of the field in the system itself, to be used in the query to the system. + "proxyAddresses" = [PSCustomObject]@{ + systemFieldName = 'mail' # Note: proxyAddresses normally isn't in the blacklist database, only the primary SMTP address (mail attribute) is checked accountValue = $a.proxyAddresses - keepInSyncWith = @("userPrincipalName", "mail") # Properties to synchronize with. If this property isn't unique, these properties will also be treated as non-unique. - crossCheckOn = @("userPrincipalName") # Properties to cross-check for uniqueness. + keepInSyncWith = @("userPrincipalName", "mail") + crossCheckOn = @("userPrincipalName") } - "sAMAccountName" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields. - systemFieldName = 'sAMAccountName' # Name of the field in the system itself, to be used in the query to the system. + "sAMAccountName" = [PSCustomObject]@{ + systemFieldName = 'sAMAccountName' accountValue = $a.sAMAccountName - keepInSyncWith = @("commonName") # Properties to synchronize with. If this property isn't unique, these properties will also be treated as non-unique. - crossCheckOn = $null # Properties to cross-check for uniqueness. + keepInSyncWith = @("commonName") + crossCheckOn = $null } - "commonName" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields. - systemFieldName = 'cn' # Name of the field in the system itself, to be used in the query to the system. + "commonName" = [PSCustomObject]@{ + systemFieldName = 'cn' accountValue = $a.commonName - keepInSyncWith = @("sAMAccountName") # Properties to synchronize with. If this property isn't unique, these properties will also be treated as non-unique. - crossCheckOn = $null # Properties to cross-check for uniqueness. + keepInSyncWith = @("sAMAccountName") + crossCheckOn = $null } } - -# Define correlation attribute -$correlationAttribute = "employeeID" - #endregion Change mapping here #region functions @@ -183,8 +208,27 @@ try { # Check property uniqueness with retention period logic if (@($querySelectResult).count -gt 0) { foreach ($dbRow in $querySelectResult) { - if ($dbRow.employeeId -eq $a.$correlationAttribute) { - Write-Information "Person is using property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] themselves." + # Check if the person is using the value themselves (based on correlation attribute) + if ($dbRow.($correlationAttribute.systemFieldName) -eq $a.($correlationAttribute.accountFieldName)) { + if ($allowSelfUsage) { + Write-Information "Person is using property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] themselves." + } + else { + # Self-usage is not allowed - treat as non-unique + Write-Warning "Property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] is not unique. Person is using this value themselves, but self-usage is disabled (allowSelfUsage = false). [$($correlationAttribute.systemFieldName)]: [$($dbRow.($correlationAttribute.systemFieldName))]." + [void]$NonUniqueFields.Add($fieldToCheck.Name) + + # Add related fields from keepInSyncWith + if (@($fieldToCheck.Value.keepInSyncWith).Count -ge 1) { + foreach ($fieldToKeepInSyncWith in $fieldToCheck.Value.keepInSyncWith | Where-Object { $_ -in $a.PsObject.Properties.Name }) { + Write-Warning "Property [$fieldToKeepInSyncWith] is marked as non-unique because it is configured to keepInSyncWith [$($fieldToCheck.Name)], which is not unique." + [void]$NonUniqueFields.Add($fieldToKeepInSyncWith) + } + } + + # Break out of the loop as we only need to find one non-unique field + break + } } else { # Check retention period if whenDeleted is set @@ -199,10 +243,10 @@ try { if ($daysDiff -lt $retentionPeriod) { # Check if this is a direct match or cross-check match if ($dbRow.attributeName -eq $fieldToCheck.Value.systemFieldName) { - Write-Warning "Property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] is not unique. It is currently in use by [$correlationAttribute]: [$($dbRow.$correlationAttribute)]. The associated [whenDeleted] timestamp [$($dbRow.whenDeleted)] is still within the allowed retention period of [$($retentionPeriod) days]." + Write-Warning "Property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] is not unique. It is currently in use by [$($correlationAttribute.systemFieldName)]: [$($dbRow.($correlationAttribute.systemFieldName))]. The associated [whenDeleted] timestamp [$($dbRow.whenDeleted)] is still within the allowed retention period of [$($retentionPeriod) days]." } else { - Write-Warning "Property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] is not unique due to cross-check. The value exists as [$($dbRow.attributeName)] = [$($dbRow.attributeValue)] in use by [$correlationAttribute]: [$($dbRow.$correlationAttribute)]. The associated [whenDeleted] timestamp [$($dbRow.whenDeleted)] is still within the allowed retention period of [$($retentionPeriod) days]." + Write-Warning "Property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] is not unique due to cross-check. The value exists as [$($dbRow.attributeName)] = [$($dbRow.attributeValue)] in use by [$($correlationAttribute.systemFieldName)]: [$($dbRow.($correlationAttribute.systemFieldName))]. The associated [whenDeleted] timestamp [$($dbRow.whenDeleted)] is still within the allowed retention period of [$($retentionPeriod) days]." } [void]$NonUniqueFields.Add($fieldToCheck.Name) @@ -218,7 +262,7 @@ try { break } else { - Write-Information "Property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] is considered unique. Although it was previously used by [$correlationAttribute]: [$($dbRow.$correlationAttribute)], the [whenDeleted] timestamp [$($dbRow.whenDeleted)] exceeds the allowed retention period of [$($retentionPeriod) days] and the value will be reused." + Write-Information "Property [$($fieldToCheck.Name)] with value [$fieldToCheckAccountValue] is considered unique. Although it was previously used by [$($correlationAttribute.systemFieldName)]: [$($dbRow.($correlationAttribute.systemFieldName))], the [whenDeleted] timestamp [$($dbRow.whenDeleted)] exceeds the allowed retention period of [$($retentionPeriod) days] and the value will be reused." } } } @@ -228,9 +272,6 @@ try { } } } - - # Set Success to true - $success = $true } catch { $ex = $PSItem @@ -238,15 +279,17 @@ catch { $auditMessage = "Error $($actionMessage). Error: $($ex.Exception.Message)" $warningMessage = "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" - # Set Success to false - $success = $false - Write-Warning $warningMessage # Required to write an error as uniqueness check doesn't show auditlog Write-Error $auditMessage } finally { + # Check if auditLogs contains errors, if no errors are found, set success to true + if (-not($auditLogs.IsError -contains $true)) { + $success = $true + } + $nonUniqueFields = @($nonUniqueFields | Sort-Object -Unique) # Send results From 8eecca7f31c8b25ed08d60e58793d90ad12bac93 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:13:08 +0100 Subject: [PATCH 22/24] Update field mapping to match CSV connector structure --- fieldMapping.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/fieldMapping.json b/fieldMapping.json index 8b56f6b..e32fb04 100644 --- a/fieldMapping.json +++ b/fieldMapping.json @@ -29,7 +29,7 @@ "Delete" ], "MappingMode": "Complex", - "Value": "\"function getMail() {\\r\\n let mail = '';\\r\\n \\r\\n if (typeof Person.Accounts.MicrosoftActiveDirectory !== 'undefined' && typeof Person.Accounts.MicrosoftActiveDirectory.mail !== 'undefined') {\\r\\n mail = Person.Accounts.MicrosoftActiveDirectory.mail;\\r\\n }\\r\\n \\r\\n return mail;\\r\\n}\\r\\n\\r\\n// Check if Person data is available\\r\\nif(Person.ExternalId){\\r\\n getMail();\\r\\n}\"", + "Value": "\"function getMail() {\\r\\n let mail = Person.Accounts.MicrosoftActiveDirectory.mail;\\r\\n \\r\\n return mail;\\r\\n}\\r\\n\\r\\n// Check if Person data is available\\r\\nif(Person.ExternalId){\\r\\n getMail();\\r\\n}\"", "UsedInNotifications": false, "StoreInAccountData": true } @@ -47,7 +47,7 @@ "Delete" ], "MappingMode": "Complex", - "Value": "\"function getSAMAccountName() {\\r\\n let sAMAccountName = '';\\r\\n \\r\\n if (typeof Person.Accounts.MicrosoftActiveDirectory !== 'undefined' && typeof Person.Accounts.MicrosoftActiveDirectory.sAMAccountName !== 'undefined') {\\r\\n sAMAccountName = Person.Accounts.MicrosoftActiveDirectory.sAMAccountName;\\r\\n }\\r\\n \\r\\n return sAMAccountName;\\r\\n}\\r\\n\\r\\n// Check if Person data is available\\r\\nif(Person.ExternalId){\\r\\n getSAMAccountName();\\r\\n}\"", + "Value": "\"function getSAMAccountName() {\\r\\n let sAMAccountName = Person.Accounts.MicrosoftActiveDirectory.sAMAccountName;\\r\\n \\r\\n return sAMAccountName;\\r\\n}\\r\\n\\r\\n// Check if Person data is available\\r\\nif(Person.ExternalId){\\r\\n getSAMAccountName();\\r\\n}\"", "UsedInNotifications": false, "StoreInAccountData": true } @@ -65,7 +65,7 @@ "Delete" ], "MappingMode": "Complex", - "Value": "\"function getUserPrincipalName() {\\r\\n let userPrincipalName = '';\\r\\n \\r\\n if (typeof Person.Accounts.MicrosoftActiveDirectory !== 'undefined' && typeof Person.Accounts.MicrosoftActiveDirectory.userPrincipalName !== 'undefined') {\\r\\n userPrincipalName = Person.Accounts.MicrosoftActiveDirectory.userPrincipalName;\\r\\n }\\r\\n \\r\\n return userPrincipalName;\\r\\n}\\r\\n\\r\\n// Check if Person data is available\\r\\nif(Person.ExternalId){\\r\\n getUserPrincipalName();\\r\\n}\"", + "Value": "\"function getUserPrincipalName() {\\r\\n let userPrincipalName = Person.Accounts.MicrosoftActiveDirectory.userPrincipalName;\\r\\n \\r\\n return userPrincipalName;\\r\\n}\\r\\n\\r\\n// Check if Person data is available\\r\\nif(Person.ExternalId){\\r\\n getUserPrincipalName();\\r\\n}\"", "UsedInNotifications": false, "StoreInAccountData": true } @@ -73,4 +73,4 @@ } ], "UniqueFieldNames": [] -} +} \ No newline at end of file From e565569a6d685b3ad60001f620a929faeb43b71d Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:13:22 +0100 Subject: [PATCH 23/24] Add checkOnExternalSystemsAd.ps1 configuration documentation --- README.md | 127 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 95 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index db79680..79b224e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - [Development resources](#development-resources) - [Available lifecycle actions](#available-lifecycle-actions) - [Additional scripts](#additional-scripts) + - [Configuring checkOnExternalSystemsAd.ps1](#configuring-checkonexternalsystemsadps1) - [Database table structure](#database-table-structure) - [Getting help](#getting-help) - [HelloID docs](#helloid-docs) @@ -53,14 +54,14 @@ This connector is designed to solve common identity management challenges: The following features are available: -| Feature | Supported | Notes | -| ---------------------------------- | --------- | ------------------------------------------------------------------------------- | -| Account Lifecycle | ✅ | Create, Update, Delete (soft-delete with retention period) | -| Permissions | ❌ | Not applicable for blacklist connector | -| Resources | ❌ | Not applicable for blacklist connector | -| Entitlement Import: Accounts | ❌ | Not applicable for blacklist connector | -| Entitlement Import: Permissions | ❌ | Not applicable for blacklist connector | -| Governance Reconciliation Resolutions | ❌ | Not applicable for blacklist connector | +| Feature | Supported | Notes | +| ------------------------------------- | --------- | ---------------------------------------------------------- | +| Account Lifecycle | ✅ | Create, Update, Delete (soft-delete with retention period) | +| Permissions | ❌ | Not applicable for blacklist connector | +| Resources | ❌ | Not applicable for blacklist connector | +| Entitlement Import: Accounts | ❌ | Not applicable for blacklist connector | +| Entitlement Import: Permissions | ❌ | Not applicable for blacklist connector | +| Governance Reconciliation Resolutions | ❌ | Not applicable for blacklist connector | ## Getting started @@ -84,12 +85,12 @@ https://raw.githubusercontent.com/Tools4everBV/HelloID-Conn-Prov-Target-Blacklis The following settings are required to connect to the SQL database. -| Setting | Description | Mandatory | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| Connection string | String value of the connection string used to connect to the SQL database | Yes | -| Table | String value of the table name in which the blacklist values reside | Yes | -| Username | String value of the username of the SQL user to use in the connection string | No | -| Password | String value of the password of the SQL user to use in the connection string | No | +| Setting | Description | Mandatory | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| Connection string | String value of the connection string used to connect to the SQL database | Yes | +| Table | String value of the table name in which the blacklist values reside | Yes | +| Username | String value of the username of the SQL user to use in the connection string | No | +| Password | String value of the password of the SQL user to use in the connection string | No | | RetentionPeriod (days) | **Critical setting**: Number of days a deleted value remains blocked before it can be reused. Common values: `365` (1 year), `730` (2 years), or `999999` (permanent blocking). This protects against immediate reuse while allowing eventual recycling of namespace values. | Yes | ### Correlation configuration @@ -124,6 +125,7 @@ The account reference is populated with the `employeeId` property during the Cre - **Soft-delete with retention**: When a person is deleted, the `whenDeleted` timestamp is set. The value remains blocked for the configured retention period. - **Automatic restore**: If a person is re-enabled and their previous attribute value is still blocked, the Create action automatically restores it by clearing the `whenDeleted` timestamp. - **Retention period configuration**: Use `RetentionPeriod (days)` to specify how long values remain blocked after deletion. Setting this to `999999` effectively makes the retention permanent. +- **Self-usage control**: The `checkOnExternalSystemsAd.ps1` script includes an `$allowSelfUsage` configuration. When set to `$false`, even a person's own existing values are treated as non-unique, forcing complete value regeneration. This is useful for migration scenarios or when implementing new naming conventions. - **Multiple records handling**: The Update action will issue a warning if multiple records with the same `attributeName` and `attributeValue` are found. - **Cross-check validation**: The `checkOnExternalSystemsAd.ps1` script supports `crossCheckOn` configuration to validate uniqueness across different attribute types (e.g., checking if an email address already exists as a proxy address). - **keepInSyncWith functionality**: When configured, non-unique status cascades across related fields automatically. @@ -136,34 +138,95 @@ The account reference is populated with the `employeeId` property during the Cre The following lifecycle actions are available: -| Action | Description | -| ------------- | --------------------------------------------------------------------------------------------------------- | -| Create | Creates or restores a blacklist record in the table (if found, clears the `whenDeleted` timestamp). | -| Update | Updates the `whenUpdated` timestamp but not the `attributeValue`. | -| Delete | Soft-deletes a blacklist record by setting the `whenDeleted` and `whenUpdated` timestamp. | +| Action | Description | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Create | **Creates or restores blacklist records** for each configured attribute. If a value already exists in the blacklist: (1) owned by the same person - clears `whenDeleted` to reactivate, (2) owned by another person but retention period expired - updates `employeeId` and clears `whenDeleted` to reassign, (3) owned by another person within retention period - throws error. If value doesn't exist, creates a new record with `whenCreated` timestamp. | +| Update | **Maintains blacklist records** for each configured attribute. Similar logic to Create: can create new records if missing, reactivate previously deleted values (clear `whenDeleted`), or reassign expired values to current person. Updates `whenUpdated` timestamp. Does **not** modify the `attributeValue` itself - only ownership and timestamps. | +| Delete | **Soft-deletes blacklist records** by setting `whenDeleted` and `whenUpdated` timestamps. Records remain in the database but are marked as deleted. After the configured retention period expires, these values become available for reuse by other persons. Does **not** physically remove rows from the database. | ### Additional scripts Beyond the standard lifecycle scripts, this connector includes specialized scripts: -| Script | Purpose | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `checkOnExternalSystemsAd.ps1` | **Uniqueness validation script** - Configured in the HelloID built-in Active Directory connector to check if proposed values exist in the blacklist before account creation. Includes advanced features like cross-checking (e.g., checking if an email exists as a proxy address) and field synchronization (`keepInSyncWith`). Prevents provisioning errors by validating uniqueness before attempting to create AD accounts. | -| `createTableBlacklist.sql` | **Database setup script** - Creates the required SQL table structure with proper column types (NVARCHAR, DATETIME2) and constraints. Must be executed in SQL Server Management Studio or similar tool before using the connector. Sets up the foundation for all blacklist operations. | -| `GenerateUniqueData/example.create.ps1` | **Legacy example script** - Demonstrates how to generate unique values by querying the SQL blacklist database in older PowerShell v1 connectors. While this is legacy code, it can be adapted for scenarios requiring custom unique value generation (e.g., employee numbers, random identifiers). Not required for standard V2 connector operation. | +| Script | Purpose | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `checkOnExternalSystemsAd.ps1` | **Uniqueness validation script** - Configured in the HelloID built-in Active Directory connector to check if proposed values exist in the blacklist before account creation. Validates against retention period: values are non-unique if owned by another person and within retention period, but can be reused if retention period expired. Includes advanced features: (1) **Self-usage control** - configurable `$allowSelfUsage` to determine if persons can reuse their own values, (2) **Cross-checking** - validate if a value exists under different attribute names (e.g., email as both 'mail' and 'userPrincipalName'), (3) **Field synchronization** - `keepInSyncWith` automatically marks related fields as non-unique. Returns `NonUniqueFields` array to HelloID, preventing provisioning errors before AD account creation attempts. | +| `createTableBlacklist.sql` | **Database setup script** - Creates the required SQL table structure with proper column types (NVARCHAR, DATETIME2) and constraints. Must be executed in SQL Server Management Studio or similar tool before using the connector. Sets up the foundation for all blacklist operations. | +| `GenerateUniqueData/example.create.ps1` | **Legacy example script** - Demonstrates how to generate unique values by querying the SQL blacklist database in older PowerShell v1 connectors. While this is legacy code, it can be adapted for scenarios requiring custom unique value generation (e.g., employee numbers, random identifiers). Not required for standard V2 connector operation. | + +#### Configuring checkOnExternalSystemsAd.ps1 + +The uniqueness check script includes several configuration options that must be set before use: + +> [!IMPORTANT] +> **Retention Period Synchronization**: The `checkOnExternalSystemsAd.ps1` script requires access to the same database and retention period configuration as the main connector. Configure the script within the target connector (e.g., Active Directory) by passing the same connection settings and `retentionPeriod` value. The retention period must be consistent across both the blacklist connector configuration and the uniqueness check script to ensure accurate validation. + +> [!WARNING] +> **Initial Configuration Required**: Before deploying to production, you must customize the following configurations in `checkOnExternalSystemsAd.ps1`: +> 1. `$correlationAttribute` - Must match your account structure (typically `employeeId`) +> 2. `$allowSelfUsage` - Set according to your business requirements +> 3. `$fieldsToCheck` - Define which attributes to validate and their relationships +> +> The example configuration is tailored for Active Directory. Adjust field names and cross-check logic for other target systems. + +**Correlation Attribute Configuration** + +```powershell +$correlationAttribute = [PSCustomObject]@{ + accountFieldName = "employeeId" # Property name in the account object from HelloID + systemFieldName = "employeeId" # Corresponding column name in the blacklist database +} +``` + +This mapping identifies which attribute links persons between HelloID and the blacklist database. It's essential for: +- Determining value ownership (does this value belong to the current person or someone else?) +- Enabling self-usage checks +- Supporting automatic value restoration for returning employees + +**Allow Self-Usage Configuration** + +```powershell +$allowSelfUsage = $true # Default: true (recommended) +``` + +Controls whether a person can reuse values they already own: +- **`$true` (recommended)**: Person's existing values are treated as unique. They can keep their email, username, etc. without triggering non-unique warnings. +- **`$false` (strict mode)**: Even the person's own values are treated as non-unique, forcing regeneration of all values. Use this for complete value refresh scenarios or migrations where all values must be regenerated. + +**Fields to Check Configuration** + +Configure which attributes to validate and how they relate to each other: + +```powershell +$fieldsToCheck = [PSCustomObject]@{ + "userPrincipalName" = [PSCustomObject]@{ + systemFieldName = 'userPrincipalName' # Database column to query + accountValue = $a.userPrincipalName # Value from account object + keepInSyncWith = @("mail", "proxyAddresses") # Related fields that share uniqueness status + crossCheckOn = @("mail") # Also check if value exists as different attribute type + } + # ... additional fields +} +``` + +Configuration properties: +- **systemFieldName**: The `attributeName` value to search for in the blacklist database +- **accountValue**: The actual value from the account object to validate +- **keepInSyncWith**: If this field is non-unique, automatically mark these related fields as non-unique too +- **crossCheckOn**: Also search for this value under different attribute names (e.g., check if email exists as both 'mail' and 'userPrincipalName') ### Database table structure The table includes the following columns: -| Column Name | Data Type | Description | -| --------------- | ------------- | -------------------------------------------------------------------------------- | -| employeeId | NVARCHAR(100) | Unique identifier for an employee (HelloID person) | -| attributeName | NVARCHAR(100) | Name of the attribute (e.g., Mail, SamAccountName, UserPrincipalName) | -| attributeValue | NVARCHAR(250) | Value of the attribute (e.g., john.doe@company.com) | -| whenCreated | DATETIME2(7) | Timestamp of when the record was originally created | -| whenUpdated | DATETIME2(7) | Timestamp of the last update (can be used to track last activity) | -| whenDeleted | DATETIME2(7) | Timestamp when the record was soft-deleted; `NULL` for active records | +| Column Name | Data Type | Description | +| -------------- | ------------- | --------------------------------------------------------------------- | +| employeeId | NVARCHAR(100) | Unique identifier for an employee (HelloID person) | +| attributeName | NVARCHAR(100) | Name of the attribute (e.g., Mail, SamAccountName, UserPrincipalName) | +| attributeValue | NVARCHAR(250) | Value of the attribute (e.g., john.doe@company.com) | +| whenCreated | DATETIME2(7) | Timestamp of when the record was originally created | +| whenUpdated | DATETIME2(7) | Timestamp of the last update (can be used to track last activity) | +| whenDeleted | DATETIME2(7) | Timestamp when the record was soft-deleted; `NULL` for active records | Use the `createTableBlacklist.sql` script to create the required table structure in your database. From 7c7eca9740035f20c31ff06e7c5119cc07d056c5 Mon Sep 17 00:00:00 2001 From: rschouten97 <69046642+rschouten97@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:13:43 +0100 Subject: [PATCH 24/24] Correct CHANGELOG categorization based on main branch --- CHANGELOG.md | 63 +++++++++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ca913..b9ad724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,40 +2,47 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). -## [2.0.0] - 2025-12-24 +## [2.0.0] - 2026-01-07 -This is a major release of HelloID-Conn-Prov-Target-Blacklist-SQL with significant enhancements to match the CSV blacklist connector functionality and Tools4ever V2 connector standards. +This is a major release of HelloID-Conn-Prov-Target-Blacklist-SQL with significant enhancements to match the CSV blacklist connector functionality and Tools4ever V2 connector standards, plus major improvements to code maintainability, configurability, and operational transparency. ### Added -- **Retention period support**: Configurable retention period for deleted values with automatic expiration logic -- **Cross-check validation**: Support for `crossCheckOn` configuration to validate uniqueness across different attribute types (e.g., checking if an email exists as a proxy address) -- **keepInSyncWith functionality**: Automatic cascading of non-unique status across related fields -- **Skip optimization**: Redundant database queries are automatically skipped once a field is marked non-unique -- **Multiple records handling**: Improved logic to filter by employeeId when multiple rows are found -- **Enhanced error handling**: New action types `OtherEmployeeId` and `MultipleFound` with detailed error messages -- **Timestamp tracking**: Added `whenCreated`, `whenUpdated`, and `whenDeleted` columns with proper datetime2(7) precision -- **Comprehensive documentation**: Restructured README with use cases, supported features table, and V2 template compliance -- **Credential support**: Full SQL authentication support with secure credential initialization +- Retention period support with configurable duration for deleted values and automatic expiration logic +- `retentionPeriod` configuration parameter to specify how many days deleted values remain blocked before reuse +- Cross-check validation via `crossCheckOn` configuration to validate uniqueness across different attribute types (e.g., checking if an email exists as a proxy address) +- `keepInSyncWith` functionality to replace legacy `syncIterations` approach, providing automatic cascading of non-unique status across related fields +- `$allowSelfUsage` configuration in `checkOnExternalSystemsAd.ps1` to control whether persons can reuse their own values (replaces `$excludeSelf`) +- `$fieldsToCheck` object-based configuration in `checkOnExternalSystemsAd.ps1` to replace simple `$attributeNames` array +- Skip optimization to automatically skip redundant database queries once a field is marked non-unique +- Action types `OtherEmployeeId` and `MultipleFound` for enhanced error handling with detailed error messages +- Database columns `whenCreated` and `whenUpdated` with datetime2(7) precision for timestamp tracking +- PowerShell-based timestamp generation using `Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff"` for consistent datetime2(7) precision +- Detailed audit logging in Update and Delete actions showing exactly which fields are modified and their new values +- `#region Configuration` block in `checkOnExternalSystemsAd.ps1` for better code organization +- README section "Configuring checkOnExternalSystemsAd.ps1" with detailed configuration examples +- README warnings for retention period synchronization and initial configuration requirements +- README use cases section explaining practical applications of the blacklist connector +- README supported features table documenting available capabilities ### Changed -- **Create script**: Restructured to match CSV connector format with improved action calculation logic -- **Update script**: Aligned with create script logic including retention period validation -- **Delete script**: Rewritten to process per-attribute instead of bulk updates, matching CSV structure -- **checkOnExternalSystemsAd.ps1**: Complete rewrite with advanced field checking configuration and retention period awareness -- **fieldMapping.json**: Updated to match CSV structure exactly (employeeId only for Create, attributes for Create/Update/Delete) -- **Logging**: Changed from Write-Information intentions to result-based logging; adjusted log levels (unique=Information, non-unique=Warning) -- **Audit logs**: Moved inside non-dryRun blocks to prevent audit entries during preview mode -- **SQL queries**: Simplified UPDATE queries to only modify `whenDeleted` and `whenUpdated` fields -- **Account reference**: Moved to absolute top of create script for consistency - -### Fixed - -- **SQL syntax errors**: Fixed bracket joining in SELECT queries that caused "missing or empty column name" errors -- **UPDATE query logic**: Removed employeeId from SET clause and added to WHERE clause for proper record targeting -- **Credential initialization**: Fixed missing credential code in checkOnExternalSystemsAd.ps1's Invoke-SQLQuery function -- **Configuration**: Removed invalid type field from retentionPeriod configuration +- Create script restructured to match CSV connector format with improved action calculation logic +- Update script aligned with Create script logic including retention period validation +- Delete script rewritten to process per-attribute instead of bulk updates +- `whenDeleted` column type changed from `date` to `datetime2(7)` for precision and consistency +- checkOnExternalSystemsAd.ps1 field checking logic enhanced with retention period awareness and cross-attribute validation +- fieldMapping.json updated to match CSV structure (employeeId only for Create, attributes for Create/Update/Delete) with Complex mapping mode using conditional logic +- Credential initialization in checkOnExternalSystemsAd.ps1's Invoke-SQLQuery function now properly creates SqlCredential object +- Configuration comments expanded with detailed explanations of field checking logic, cross-checking, and field synchronization +- README lifecycle action descriptions enhanced with detailed scenario coverage including retention period behavior +- README additional scripts descriptions improved with retention period logic details +- Logging changed from Write-Information intentions to result-based logging with adjusted log levels (unique=Information, non-unique=Warning) +- Audit logs moved inside non-dryRun blocks to prevent audit entries during preview mode +- SQL UPDATE queries simplified to only modify `whenDeleted` and `whenUpdated` fields +- Account reference moved to absolute top of create script for consistency +- Update and Delete actions refactored to build SET clauses dynamically from object properties +- Logging in checkOnExternalSystemsAd.ps1 improved to distinguish between self-usage scenarios and retention period validations ### Deprecated @@ -43,7 +50,7 @@ This is a major release of HelloID-Conn-Prov-Target-Blacklist-SQL with significa ### Removed -- `whenDeleted` field from fieldMapping.json (managed internally by scripts) +- `whenDeleted` field from fieldMapping.json (now managed internally by scripts) - Unnecessary Write-Information statements for action intentions ## [1.1.0] - 2024-12-12