Public/New-IdleADIdentityProvider.ps1

function New-IdleADIdentityProvider {
    <#
    .SYNOPSIS
    Creates an Active Directory identity provider for IdLE.

    .DESCRIPTION
    This provider integrates with on-premises Active Directory environments.
    It requires the ActiveDirectory PowerShell module (RSAT) and runs on Windows only.

    The provider supports common identity operations (Create, Read, Disable, Enable, Move, Delete)
    and group entitlement management (List, Grant, Revoke).

    Identity addressing supports:
    - GUID (ObjectGuid) - pattern: ^[0-9a-fA-F-]{36}$ or N-format
    - UPN (UserPrincipalName) - contains @
    - sAMAccountName - default fallback

    Authentication:
    Provider methods accept an optional AuthSession parameter for runtime credential
    selection via the AuthSessionBroker. This enables multi-role scenarios (e.g.,
    Tier0 vs. Admin) without embedding credentials in the provider or workflow.

    By default, the provider uses integrated authentication (run-as credentials).
    For runtime credential selection, configure an AuthSessionBroker and use
    With.AuthSessionName and With.AuthSessionOptions in step definitions.

    .PARAMETER AllowDelete
    Opt-in flag to enable the IdLE.Identity.Delete capability.
    When $true, the provider advertises the Delete capability and allows identity deletion.
    Default is $false for safety.

    .PARAMETER PasswordGenerationFallbackMinLength
    Fallback minimum password length when domain policy cannot be read. Default is 24.

    .PARAMETER PasswordGenerationRequireUpper
    Fallback requirement for uppercase characters in generated passwords. Default is $true.

    .PARAMETER PasswordGenerationRequireLower
    Fallback requirement for lowercase characters in generated passwords. Default is $true.

    .PARAMETER PasswordGenerationRequireDigit
    Fallback requirement for digit characters in generated passwords. Default is $true.

    .PARAMETER PasswordGenerationRequireSpecial
    Fallback requirement for special characters in generated passwords. Default is $true.

    .PARAMETER PasswordGenerationSpecialCharSet
    Set of special characters to use in generated passwords. Default is '!@#$%&*+-_=?'.

    .PARAMETER Adapter
    Internal parameter for dependency injection during testing. Allows unit tests to inject
    a fake AD adapter without requiring a real Active Directory environment.

    .EXAMPLE
    # Use integrated authentication (run-as)
    $provider = New-IdleADIdentityProvider
    $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{
        Identity = $provider
    }

    .EXAMPLE
    # Custom password generation fallback configuration
    $provider = New-IdleADIdentityProvider -PasswordGenerationFallbackMinLength 32 -PasswordGenerationSpecialCharSet '!@#$%^&*()'
    $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{
        Identity = $provider
    }

    .EXAMPLE
    # Multi-role scenario with New-IdleAuthSessionBroker (recommended)
    $tier0Credential = Get-Credential -Message "Enter Tier0 admin credentials"
    $adminCredential = Get-Credential -Message "Enter regular admin credentials"

    $broker = New-IdleAuthSessionBroker -SessionMap @{
        @{ Role = 'Tier0' } = $tier0Credential
        @{ Role = 'Admin' } = $adminCredential
    } -DefaultCredential $adminCredential

    $provider = New-IdleADIdentityProvider
    $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{
        Identity = $provider
        AuthSessionBroker = $broker
    }

    # Workflow steps can specify different auth contexts:
    # With.AuthSessionName = 'ActiveDirectory'
    # With.AuthSessionOptions = @{ Role = 'Tier0' }

    .EXAMPLE
    # Custom broker for advanced scenarios (vault integration, MFA)
    $broker = [pscustomobject]@{}
    $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value {
        param($Name, $Options)
        if ($Options.Role -eq 'Tier0') {
            return Get-SecretFromVault -Name 'AD-Tier0'
        }
        return Get-SecretFromVault -Name 'AD-Admin'
    }

    $provider = New-IdleADIdentityProvider
    $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{
        Identity = $provider
        AuthSessionBroker = $broker
    }
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [switch] $AllowDelete,

        [Parameter()]
        [int] $PasswordGenerationFallbackMinLength = 24,

        [Parameter()]
        [bool] $PasswordGenerationRequireUpper = $true,

        [Parameter()]
        [bool] $PasswordGenerationRequireLower = $true,

        [Parameter()]
        [bool] $PasswordGenerationRequireDigit = $true,

        [Parameter()]
        [bool] $PasswordGenerationRequireSpecial = $true,

        [Parameter()]
        [string] $PasswordGenerationSpecialCharSet = '!@#$%&*+-_=?',

        [Parameter()]
        [AllowNull()]
        [object] $Adapter
    )

    # Check prerequisites and emit warnings if required components are missing
    $prereqs = Test-IdleADPrerequisites
    if (-not $prereqs.IsHealthy) {
        foreach ($missing in $prereqs.MissingRequired) {
            Write-Warning "AD provider prerequisite check: Required component '$missing' is not available."
        }
        foreach ($note in $prereqs.Notes) {
            Write-Warning "AD provider prerequisite check: $note"
        }
    }

    if ($null -eq $Adapter) {
        $Adapter = New-IdleADAdapter -PasswordGenerationFallbackMinLength $PasswordGenerationFallbackMinLength `
            -PasswordGenerationRequireUpper $PasswordGenerationRequireUpper `
            -PasswordGenerationRequireLower $PasswordGenerationRequireLower `
            -PasswordGenerationRequireDigit $PasswordGenerationRequireDigit `
            -PasswordGenerationRequireSpecial $PasswordGenerationRequireSpecial `
            -PasswordGenerationSpecialCharSet $PasswordGenerationSpecialCharSet
    }

    $convertToEntitlement = {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object] $Value
        )

        return ConvertTo-IdleADEntitlement -Value $Value
    }

    $testEntitlementEquals = {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object] $A,

            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object] $B
        )

        $aEnt = $this.ConvertToEntitlement($A)
        $bEnt = $this.ConvertToEntitlement($B)

        if ($aEnt.Kind -ne $bEnt.Kind) {
            return $false
        }

        return [string]::Equals($aEnt.Id, $bEnt.Id, [System.StringComparison]::OrdinalIgnoreCase)
    }

    $resolveIdentity = {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $IdentityKey,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        # Try GUID format first (most deterministic)
        $guid = [System.Guid]::Empty
        if ([System.Guid]::TryParse($IdentityKey, [ref]$guid)) {
            try {
                $user = $adapter.GetUserByGuid($guid.ToString())
            }
            catch [System.Management.Automation.MethodException] {
                Write-Verbose "GetUserByGuid failed for GUID '$IdentityKey': $_"
                $user = $null
            }

            if ($null -ne $user) {
                return $user
            }
            throw "Identity with GUID '$IdentityKey' not found."
        }

        # Try UPN format (contains @)
        if ($IdentityKey -match '@') {
            $user = $adapter.GetUserByUpn($IdentityKey)
            if ($null -ne $user) {
                return $user
            }
            throw "Identity with UPN '$IdentityKey' not found."
        }

        # Fallback to sAMAccountName
        $user = $adapter.GetUserBySam($IdentityKey)
        if ($null -ne $user) {
            return $user
        }
        throw "Identity with sAMAccountName '$IdentityKey' not found."
    }

    $normalizeGroupId = {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $GroupId,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        $group = $adapter.GetGroupById($GroupId)
        if ($null -eq $group) {
            throw "Group '$GroupId' not found."
        }

        return $group.DistinguishedName
    }

    $provider = [pscustomobject]@{
        PSTypeName  = 'IdLE.Provider.ADIdentityProvider'
        Name        = 'ADIdentityProvider'
        Adapter     = $Adapter
        AllowDelete = [bool]$AllowDelete
        PasswordGenerationFallbackMinLength = $PasswordGenerationFallbackMinLength
        PasswordGenerationRequireUpper = $PasswordGenerationRequireUpper
        PasswordGenerationRequireLower = $PasswordGenerationRequireLower
        PasswordGenerationRequireDigit = $PasswordGenerationRequireDigit
        PasswordGenerationRequireSpecial = $PasswordGenerationRequireSpecial
        PasswordGenerationSpecialCharSet = $PasswordGenerationSpecialCharSet
    }

    # Helper method to extract credential from AuthSession and create effective adapter
    $getEffectiveAdapter = {
        param(
            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        # If no AuthSession, return the default adapter
        # Only validate prerequisites for the default adapter if it's the real one (not injected for tests)
        # Check TypeNames collection (PSTypeName in hashtable adds to TypeNames, not as a property)
        if ($null -eq $AuthSession) {
            $isRealAdapter = ($this.Adapter.PSObject.TypeNames -contains 'IdLE.ADAdapter')
            
            if ($isRealAdapter) {
                $prereqCheck = Test-IdleADPrerequisites
                if (-not $prereqCheck.IsHealthy) {
                    $missingList = $prereqCheck.MissingRequired -join ', '
                    $errorMsg = "AD provider operation cannot proceed. Required prerequisite(s) missing: $missingList"
                    if ($prereqCheck.Notes.Count -gt 0) {
                        $errorMsg += "`n" + ($prereqCheck.Notes -join "`n")
                    }
                    throw $errorMsg
                }
            }
            return $this.Adapter
        }

        $credential = $null
        if ($AuthSession -is [PSCredential]) {
            $credential = $AuthSession
        }
        elseif ($AuthSession.PSObject.Properties.Name -contains 'Credential') {
            $credential = $AuthSession.Credential
        }

        if ($null -ne $credential) {
            # Creating new adapter with credential - validate prerequisites
            $prereqCheck = Test-IdleADPrerequisites
            if (-not $prereqCheck.IsHealthy) {
                $missingList = $prereqCheck.MissingRequired -join ', '
                $errorMsg = "AD provider operation cannot proceed. Required prerequisite(s) missing: $missingList"
                if ($prereqCheck.Notes.Count -gt 0) {
                    $errorMsg += "`n" + ($prereqCheck.Notes -join "`n")
                }
                throw $errorMsg
            }
            return New-IdleADAdapter -Credential $credential `
                -PasswordGenerationFallbackMinLength $this.PasswordGenerationFallbackMinLength `
                -PasswordGenerationRequireUpper $this.PasswordGenerationRequireUpper `
                -PasswordGenerationRequireLower $this.PasswordGenerationRequireLower `
                -PasswordGenerationRequireDigit $this.PasswordGenerationRequireDigit `
                -PasswordGenerationRequireSpecial $this.PasswordGenerationRequireSpecial `
                -PasswordGenerationSpecialCharSet $this.PasswordGenerationSpecialCharSet
        }

        return $this.Adapter
    }

    $provider | Add-Member -MemberType ScriptMethod -Name GetEffectiveAdapter -Value $getEffectiveAdapter -Force
    $provider | Add-Member -MemberType ScriptMethod -Name ConvertToEntitlement -Value $convertToEntitlement -Force
    $provider | Add-Member -MemberType ScriptMethod -Name TestEntitlementEquals -Value $testEntitlementEquals -Force
    $provider | Add-Member -MemberType ScriptMethod -Name ResolveIdentity -Value $resolveIdentity -Force
    $provider | Add-Member -MemberType ScriptMethod -Name NormalizeGroupId -Value $normalizeGroupId -Force

    $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value {
        $caps = @(
            'IdLE.Identity.Read'
            'IdLE.Identity.List'
            'IdLE.Identity.Create'
            'IdLE.Identity.Attribute.Ensure'
            'IdLE.Identity.Move'
            'IdLE.Identity.Disable'
            'IdLE.Identity.Enable'
            'IdLE.Entitlement.List'
            'IdLE.Entitlement.Grant'
            'IdLE.Entitlement.Revoke'
        )

        if ($this.AllowDelete) {
            $caps += 'IdLE.Identity.Delete'
        }

        return $caps
    } -Force

    $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $IdentityKey,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        # Validate adapter is available
        $this.GetEffectiveAdapter($AuthSession) | Out-Null

        $user = $this.ResolveIdentity($IdentityKey, $AuthSession)

        $attributes = @{}
        if ($null -ne $user.GivenName) { $attributes['GivenName'] = $user.GivenName }
        if ($null -ne $user.Surname) { $attributes['Surname'] = $user.Surname }
        if ($null -ne $user.DisplayName) { $attributes['DisplayName'] = $user.DisplayName }
        if ($null -ne $user.Description) { $attributes['Description'] = $user.Description }
        if ($null -ne $user.Department) { $attributes['Department'] = $user.Department }
        if ($null -ne $user.Title) { $attributes['Title'] = $user.Title }
        if ($null -ne $user.EmailAddress) { $attributes['EmailAddress'] = $user.EmailAddress }
        if ($null -ne $user.UserPrincipalName) { $attributes['UserPrincipalName'] = $user.UserPrincipalName }
        if ($null -ne $user.sAMAccountName) { $attributes['sAMAccountName'] = $user.sAMAccountName }
        if ($null -ne $user.DistinguishedName) { $attributes['DistinguishedName'] = $user.DistinguishedName }

        return [pscustomobject]@{
            PSTypeName  = 'IdLE.Identity'
            IdentityKey = $IdentityKey
            Enabled     = [bool]$user.Enabled
            Attributes  = $attributes
        }
    } -Force

    $provider | Add-Member -MemberType ScriptMethod -Name ListIdentities -Value {
        param(
            [Parameter()]
            [hashtable] $Filter,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        $users = $adapter.ListUsers($Filter)
        $identityKeys = @()
        foreach ($user in $users) {
            $identityKeys += $user.ObjectGuid.ToString()
        }
        return $identityKeys
    } -Force

    $provider | Add-Member -MemberType ScriptMethod -Name CreateIdentity -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $IdentityKey,

            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [hashtable] $Attributes,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        # Validate attributes against contract (strict mode - will throw on unsupported attributes)
        $validationResult = Test-IdleADAttributeContract -Attributes $Attributes -Operation 'CreateIdentity'

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        try {
            $existing = $this.ResolveIdentity($IdentityKey, $AuthSession)
            if ($null -ne $existing) {
                return [pscustomobject]@{
                    PSTypeName  = 'IdLE.ProviderResult'
                    Operation   = 'CreateIdentity'
                    IdentityKey = $IdentityKey
                    Changed     = $false
                }
            }
        }
        catch {
            # Identity does not exist, proceed with creation (expected for idempotent create)
            Write-Verbose "Identity '$IdentityKey' does not exist, proceeding with creation"
        }

        $enabled = $true
        if ($Attributes.ContainsKey('Enabled')) {
            $enabled = [bool]$Attributes['Enabled']
        }

        # Extract AllowPlainTextPasswordOutput before passing to adapter
        $allowPlainTextOutput = $false
        if ($Attributes.ContainsKey('AllowPlainTextPasswordOutput')) {
            $allowPlainTextOutput = [bool]$Attributes['AllowPlainTextPasswordOutput']
        }

        $user = $adapter.NewUser($IdentityKey, $Attributes, $enabled)

        # Emit observability event
        if ($this.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $this.EventSink) {
            $eventData = @{
                IdentityKey = $IdentityKey
                Requested   = $validationResult.Requested
            }
            $this.EventSink.WriteEvent('Provider.AD.CreateIdentity.AttributesRequested', 'Attributes requested during identity creation', 'CreateIdentity', $eventData)
        }

        # Build result with optional password generation info
        $result = [pscustomobject]@{
            PSTypeName  = 'IdLE.ProviderResult'
            Operation   = 'CreateIdentity'
            IdentityKey = $IdentityKey
            Changed     = $true
        }

        # Handle password generation output (if password was generated)
        if ($null -ne $user.PSObject.Properties['_GeneratedPasswordInfo']) {
            $passwordInfo = $user._GeneratedPasswordInfo
            
            # Always include ProtectedString for reveal path (DPAPI-scoped)
            $result | Add-Member -MemberType NoteProperty -Name 'GeneratedAccountPasswordProtected' -Value $passwordInfo.ProtectedString
            
            if ($allowPlainTextOutput) {
                # Include plaintext password only when explicitly requested
                $result | Add-Member -MemberType NoteProperty -Name 'GeneratedAccountPasswordPlainText' -Value $passwordInfo.PlainText
                Write-Verbose "AD Provider: Plaintext password output enabled (AllowPlainTextPasswordOutput=true)"
            }
            
            # Add metadata about password generation
            $result | Add-Member -MemberType NoteProperty -Name 'PasswordGenerated' -Value $true
            $result | Add-Member -MemberType NoteProperty -Name 'PasswordGenerationPolicyUsed' -Value $passwordInfo.UsedPolicy
            
            Write-Verbose "AD Provider: Password was generated using $($passwordInfo.UsedPolicy) policy"
        }

        return $result
    } -Force

    $provider | Add-Member -MemberType ScriptMethod -Name DeleteIdentity -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $IdentityKey,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        if (-not $this.AllowDelete) {
            throw "Delete capability is not enabled. Set AllowDelete = `$true when creating the provider."
        }

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        try {
            $user = $this.ResolveIdentity($IdentityKey, $AuthSession)
            $adapter.DeleteUser($user.DistinguishedName)
            return [pscustomobject]@{
                PSTypeName  = 'IdLE.ProviderResult'
                Operation   = 'DeleteIdentity'
                IdentityKey = $IdentityKey
                Changed     = $true
            }
        }
        catch {
            # Check if identity doesn't exist (idempotent delete)
            # Use exception type if available, otherwise fall back to message check
            $isNotFound = $false
            if ($_.Exception.GetType().FullName -eq 'Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException') {
                $isNotFound = $true
            }
            elseif ($_.Exception.Message -match 'not found|cannot be found|does not exist') {
                $isNotFound = $true
            }

            if ($isNotFound) {
                return [pscustomobject]@{
                    PSTypeName  = 'IdLE.ProviderResult'
                    Operation   = 'DeleteIdentity'
                    IdentityKey = $IdentityKey
                    Changed     = $false
                }
            }
            throw
        }
    } -Force

    $provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $IdentityKey,

            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $Name,

            [Parameter()]
            [AllowNull()]
            [object] $Value,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        # Validate attribute against contract (strict mode - will throw on unsupported attributes)
        $validationResult = Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName $Name

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        $user = $this.ResolveIdentity($IdentityKey, $AuthSession)

        $currentValue = $null
        if ($user.PSObject.Properties.Name -contains $Name) {
            $currentValue = $user.$Name
        }

        $changed = $false
        if ($currentValue -ne $Value) {
            # Special handling for Manager attribute - resolve to DN
            $valueToSet = $Value
            if ($Name -eq 'Manager' -and $null -ne $Value) {
                $valueToSet = $adapter.ResolveManagerDN($Value)
            }
            
            $adapter.SetUser($user.DistinguishedName, $Name, $valueToSet)
            $changed = $true

            # Emit observability event
            if ($this.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $this.EventSink) {
                $eventData = @{
                    IdentityKey  = $IdentityKey
                    AttributeName = $Name
                    OldValue     = $currentValue
                    NewValue     = $Value
                }
                $this.EventSink.WriteEvent('Provider.AD.EnsureAttribute.AttributeChanged', "Attribute '$Name' changed", 'EnsureAttribute', $eventData)
            }
        }

        return [pscustomobject]@{
            PSTypeName  = 'IdLE.ProviderResult'
            Operation   = 'EnsureAttribute'
            IdentityKey = $IdentityKey
            Changed     = $changed
            Name        = $Name
            Value       = $Value
        }
    } -Force

    $provider | Add-Member -MemberType ScriptMethod -Name MoveIdentity -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $IdentityKey,

            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $TargetContainer,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        $user = $this.ResolveIdentity($IdentityKey, $AuthSession)

        $currentOu = $user.DistinguishedName -replace '^CN=[^,]+,', ''

        $changed = $false
        if ($currentOu -ne $TargetContainer) {
            $adapter.MoveObject($user.DistinguishedName, $TargetContainer)
            $changed = $true
        }

        return [pscustomobject]@{
            PSTypeName       = 'IdLE.ProviderResult'
            Operation        = 'MoveIdentity'
            IdentityKey      = $IdentityKey
            Changed          = $changed
            TargetContainer  = $TargetContainer
        }
    } -Force

    $provider | Add-Member -MemberType ScriptMethod -Name DisableIdentity -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $IdentityKey,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        $user = $this.ResolveIdentity($IdentityKey, $AuthSession)

        $changed = $false
        if ($user.Enabled -ne $false) {
            $adapter.DisableUser($user.DistinguishedName)
            $changed = $true
        }

        return [pscustomobject]@{
            PSTypeName  = 'IdLE.ProviderResult'
            Operation   = 'DisableIdentity'
            IdentityKey = $IdentityKey
            Changed     = $changed
        }
    } -Force

    $provider | Add-Member -MemberType ScriptMethod -Name EnableIdentity -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $IdentityKey,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        $user = $this.ResolveIdentity($IdentityKey, $AuthSession)

        $changed = $false
        if ($user.Enabled -ne $true) {
            $adapter.EnableUser($user.DistinguishedName)
            $changed = $true
        }

        return [pscustomobject]@{
            PSTypeName  = 'IdLE.ProviderResult'
            Operation   = 'EnableIdentity'
            IdentityKey = $IdentityKey
            Changed     = $changed
        }
    } -Force

    $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $IdentityKey,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        $user = $this.ResolveIdentity($IdentityKey, $AuthSession)

        $groups = $adapter.GetUserGroups($user.DistinguishedName)

        $result = @()
        foreach ($group in $groups) {
            $result += [pscustomobject]@{
                PSTypeName  = 'IdLE.Entitlement'
                Kind        = 'Group'
                Id          = $group.DistinguishedName
                DisplayName = $group.Name
            }
        }

        return $result
    } -Force

    $provider | Add-Member -MemberType ScriptMethod -Name GrantEntitlement -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $IdentityKey,

            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object] $Entitlement,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        $normalized = $this.ConvertToEntitlement($Entitlement)

        $user = $this.ResolveIdentity($IdentityKey, $AuthSession)
        $groupDn = $this.NormalizeGroupId($normalized.Id, $AuthSession)

        $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession)
        $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) }

        $changed = $false
        if (@($existing).Count -eq 0) {
            $adapter.AddGroupMember($groupDn, $user.DistinguishedName)
            $changed = $true
        }

        return [pscustomobject]@{
            PSTypeName  = 'IdLE.ProviderResult'
            Operation   = 'GrantEntitlement'
            IdentityKey = $IdentityKey
            Changed     = $changed
            Entitlement = $normalized
        }
    } -Force

    $provider | Add-Member -MemberType ScriptMethod -Name RevokeEntitlement -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $IdentityKey,

            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object] $Entitlement,

            [Parameter()]
            [AllowNull()]
            [object] $AuthSession
        )

        $adapter = $this.GetEffectiveAdapter($AuthSession)

        $normalized = $this.ConvertToEntitlement($Entitlement)

        $user = $this.ResolveIdentity($IdentityKey, $AuthSession)
        $groupDn = $this.NormalizeGroupId($normalized.Id, $AuthSession)

        $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession)
        $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) }

        $changed = $false
        if (@($existing).Count -gt 0) {
            $adapter.RemoveGroupMember($groupDn, $user.DistinguishedName)
            $changed = $true
        }

        return [pscustomobject]@{
            PSTypeName  = 'IdLE.ProviderResult'
            Operation   = 'RevokeEntitlement'
            IdentityKey = $IdentityKey
            Changed     = $changed
            Entitlement = $normalized
        }
    } -Force

    return $provider
}