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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b9ad724 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,90 @@ +# 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] - 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, plus major improvements to code maintainability, configurability, and operational transparency. + +### Added + +- 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 +- `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 + +- Legacy syncIterations and syncIterationsAttributeNames approach replaced by keepInSyncWith configuration + +### Removed + +- `whenDeleted` field from fieldMapping.json (now 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) diff --git a/README.md b/README.md index 27d8753..79b224e 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,243 @@ # HelloID-Conn-Prov-Target-Blacklist-SQL -Repository for HelloID Provisioning Target Connector to SQL Blacklist - -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.

- +

-## 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) + - [Configuring checkOnExternalSystemsAd.ps1](#configuring-checkonexternalsystemsadps1) + - [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-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. +- 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. -## Repository contents +### Connection settings -The HelloID connector consists of the template scripts shown in the following table. +The following settings are required to connect to the SQL database. -| 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. | +| 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 | -## Connection settings +### Correlation configuration -The following settings are required to connect to SQL DB. +The correlation configuration is not used or required in this connector. -| 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 | +### Field mapping -## Correlation configuration +The field mapping can be imported by using the `fieldMapping.json` file. -The correlation configuration is not used or required in this connector +- `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` -## Settings in AD uniqueness script -The following settings can and should be set in the AD uniqueness script +### Account Reference -| 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 | +The account reference is populated with the `employeeId` property during the Create action. + +**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. +- **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. +- **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 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. 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 | + +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/. diff --git a/checkOnExternalSystemsAd.ps1 b/checkOnExternalSystemsAd.ps1 index 0731faf..8011eec 100644 --- a/checkOnExternalSystemsAd.ps1 +++ b/checkOnExternalSystemsAd.ps1 @@ -2,36 +2,89 @@ # 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") +#region Change mapping here -# Exclude self from query -$excludeSelf = $true +# 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]@{ + systemFieldName = 'userPrincipalName' + accountValue = $a.userPrincipalName + keepInSyncWith = @("mail", "proxyAddresses") + crossCheckOn = @("mail") + } + "mail" = [PSCustomObject]@{ + systemFieldName = 'mail' + accountValue = $a.mail + keepInSyncWith = @("userPrincipalName", "proxyAddresses") + crossCheckOn = @("userPrincipalName") + } + "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") + crossCheckOn = @("userPrincipalName") + } + "sAMAccountName" = [PSCustomObject]@{ + systemFieldName = 'sAMAccountName' + accountValue = $a.sAMAccountName + keepInSyncWith = @("commonName") + crossCheckOn = $null + } + "commonName" = [PSCustomObject]@{ + systemFieldName = 'cn' + accountValue = $a.commonName + keepInSyncWith = @("sAMAccountName") + crossCheckOn = $null + } +} #endregion Change mapping here #region functions @@ -55,6 +108,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 +127,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,81 +152,137 @@ 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" + + $querySelectSplatParams = @{ + ConnectionString = $eRef.configuration.connectionString + Username = $eRef.configuration.username + Password = $eRef.configuration.password + SqlQuery = $querySelect + ErrorAction = "Stop" + } - $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)" + $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" - 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]" + # Check property uniqueness with retention period logic + if (@($querySelectResult).count -gt 0) { + foreach ($dbRow in $querySelectResult) { + # 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 + 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.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.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) + + # 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.systemFieldName)]: [$($dbRow.($correlationAttribute.systemFieldName))], 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." } } } } 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 + Write-Warning $warningMessage - 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)" + # 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 @@ -169,17 +290,13 @@ finally { $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 diff --git a/configuration.json b/configuration.json index 7620d13..0a4afcc 100644 --- a/configuration.json +++ b/configuration.json @@ -43,5 +43,16 @@ "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)", + "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 diff --git a/create.ps1 b/create.ps1 index 542d334..83cd536 100644 --- a/create.ps1 +++ b/create.ps1 @@ -3,6 +3,15 @@ # 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 + +#region functions function Invoke-SQLQuery { param( [parameter(Mandatory = $true)] @@ -42,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() @@ -67,150 +76,229 @@ 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 { - $table = $actionContext.configuration.table + 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).PSObject.Properties.Name + $attributeValue = $actionContext.Data.$attributeName -Replace "'", "''" - 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-Verbose "Queried data FROM [$table] WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$attributeValue'. 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 ($correlatedAccount.employeeId -ne $account.employeeId -or (-not([string]::IsNullOrEmpty($correlatedAccount.whenDeleted)))) { - $action = "UpdateAccount" + # If multiple rows are found, filter additionally for employeeId + if ($selectRowCount -gt 1) { + $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" + } + + if ($selectRowCount -eq 1) { + $correlatedAccount = $querySelectResult + + # Check if value belongs to someone else + if ($correlatedAccount.employeeId -ne $actionContext.Data.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.Data.employeeId)]" + + # 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 = $("[" + (($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 = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $queryInsert + ErrorAction = "Stop" + } + $outputContext.Data | Add-Member -NotePropertyName $attributeName -NotePropertyValue $attributeValue -Force + + if (-not($actioncontext.dryRun -eq $true)) { $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 + Invoke-SQLQuery @queryInsertSplatParams -Data ([ref]$queryInsertResult) + $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" - $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 and update employeeId (either for current employee or reusing expired row) + $actionMessage = "updating [employeeId] to [$($updateObject.employeeId)] and [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" + + # Create new object for update + $updateObject = [PSCustomObject]@{ + employeeId = $actionContext.Data.employeeId + whenDeleted = $null + whenUpdated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") + } + + # 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 = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $queryUpdate + ErrorAction = "Stop" + } + + $outputContext.Data | Add-Member -NotePropertyName $attributeName -NotePropertyValue $attributeValue -Force + + + if (-not($actioncontext.dryRun -eq $true)) { $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 + Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) + $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "Successfully updated row in table [$table] for attribute [$attributeName] and value [$attributeValue]" + # Action = "" # Optional + Message = "Updated [employeeId] to [$($updateObject.employeeId)] and [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]." 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 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.Data.employeeId)]" + + $outputContext.Data | Add-Member -NotePropertyName $attributeName -NotePropertyValue $correlatedAccount.attributeValue -Force + + $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 }) } 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 diff --git a/delete.ps1 b/delete.ps1 index 4570887..7d832e4 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,121 @@ 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)]" + + # 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 = @{ + 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 [$($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 [$($updateObject.whenDeleted)] 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 }) } diff --git a/fieldMapping.json b/fieldMapping.json index 2ec32b2..e32fb04 100644 --- a/fieldMapping.json +++ b/fieldMapping.json @@ -2,79 +2,72 @@ "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 = 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": 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 = 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 } ] }, { - "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 = 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": false - }, - { - "MapForActions": [ - "Create", - "Update" - ], - "MappingMode": "Fixed", - "Value": "\"NULL\"", - "UsedInNotifications": false, - "StoreInAccountData": false + "StoreInAccountData": true } ] } diff --git a/update.ps1 b/update.ps1 index 3442fdd..9c74a06 100644 --- a/update.ps1 +++ b/update.ps1 @@ -3,6 +3,12 @@ # 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 + +#region functions function Invoke-SQLQuery { param( [parameter(Mandatory = $true)] @@ -42,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() @@ -67,153 +73,241 @@ 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 { - $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)]" + + $attributeValue = $actionContext.Data.$attributeName -Replace "'", "''" + + $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 "Successfully queried data from table [$table] for attribute [$attributeName]. Query: $($querySelect). Returned rows: $selectRowCount" + $selectRowCount = ($querySelectResult | measure-object).count + Write-Verbose "Queried data FROM [$table] WHERE [attributeName] = '$attributeName' AND [attributeValue] = '$attributeValue'. Result count: $selectRowCount" - if ($selectRowCount -eq 1) { - $correlatedAccount = $querySelectResult - if ($correlatedAccount.employeeId -ne $account.employeeId -or (-not([string]::IsNullOrEmpty($correlatedAccount.whenDeleted)))) { - $action = "UpdateAccount" + # Calculate action + $actionMessage = "calculating action" + + # If multiple rows are found, filter additionally for employeeId + if ($selectRowCount -gt 1) { + $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" + } + + if ($selectRowCount -eq 1) { + $correlatedAccount = $querySelectResult + + # Check if value belongs to someone else + if ($correlatedAccount.employeeId -ne $actionContext.References.Account) { + # 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)]" + # 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 = $("[" + (($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 = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $queryInsert + ErrorAction = "Stop" + } + + # 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() - 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 + Invoke-SQLQuery @queryInsertSplatParams -Data ([ref]$queryInsertResult) + $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 and update employeeId (either for current employee or reusing expired row) + $actionMessage = "updating [employeeId] to [$($updateObject.employeeId)] and [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]" + + # Create new object for update + $updateObject = [PSCustomObject]@{ + employeeId = $actionContext.References.Account + whenDeleted = $null + whenUpdated = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff") + } + # 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 = @{ + ConnectionString = $actionContext.configuration.connectionString + Username = $actionContext.configuration.username + Password = $actionContext.configuration.password + SqlQuery = $queryUpdate + ErrorAction = "Stop" + } + + # 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)) { $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 + Invoke-SQLQuery @queryUpdateSplatParams -Data ([ref]$queryUpdateResult) + $outputContext.auditlogs.Add([PSCustomObject]@{ - Message = "Successfully updated row in table [$table] for attribute [$attributeName] and value [$attributeValue]" + # Action = "" # Optional + Message = "Updated [employeeId] to [$($updateObject.employeeId)] and [whenDeleted] to [$($updateObject.whenDeleted)] for row in table [$table] where [$($attributeName)] = [$($actionContext.Data.$attributeName)]." 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 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 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 + 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 }) }