Modules/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1

function Invoke-IdleStepEnsureEntitlement {
    <#
    .SYNOPSIS
    Ensures that an entitlement assignment is present or absent for an identity.

    .DESCRIPTION
    This provider-agnostic step uses entitlement provider contracts to converge
    an assignment to the desired state. The host must supply a provider instance
    via `Context.Providers[<ProviderAlias>]` that implements:
    - ListEntitlements(identityKey)
    - GrantEntitlement(identityKey, entitlement)
    - RevokeEntitlement(identityKey, entitlement)

    The step is idempotent and only calls Grant/Revoke when the assignment needs
    to change.

    .PARAMETER Context
    Execution context created by IdLE.Core.

    .PARAMETER Step
    Normalized step object from the plan. Must contain a 'With' hashtable.

    .EXAMPLE
    Invoke-IdleStepEnsureEntitlement -Context $context -Step [pscustomobject]@{
        Name = 'Ensure group access'
        Type = 'IdLE.Step.EnsureEntitlement'
        With = @{
            IdentityKey = 'user1'
            Entitlement = @{ Kind = 'Group'; Id = 'example-group'; DisplayName = 'Example Group' }
            State = 'Present'
            Provider = 'Identity'
        }
    }

    .OUTPUTS
    PSCustomObject (PSTypeName: IdLE.StepResult)
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object] $Context,

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

    function ConvertTo-IdleStepEntitlement {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object] $Value
        )

        $kind = $null
        $id = $null
        $displayName = $null

        if ($Value -is [System.Collections.IDictionary]) {
            $kind = $Value['Kind']
            $id = $Value['Id']
            if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] }
        }
        else {
            $props = $Value.PSObject.Properties
            if ($props.Name -contains 'Kind') { $kind = $Value.Kind }
            if ($props.Name -contains 'Id') { $id = $Value.Id }
            if ($props.Name -contains 'DisplayName') { $displayName = $Value.DisplayName }
        }

        if ([string]::IsNullOrWhiteSpace([string]$kind)) {
            throw "EnsureEntitlement requires Entitlement.Kind."
        }
        if ([string]::IsNullOrWhiteSpace([string]$id)) {
            throw "EnsureEntitlement requires Entitlement.Id."
        }

        $normalized = [ordered]@{
            Kind = [string]$kind
            Id   = [string]$id
        }

        if ($null -ne $displayName -and -not [string]::IsNullOrWhiteSpace([string]$displayName)) {
            $normalized['DisplayName'] = [string]$displayName
        }

        return [pscustomobject]$normalized
    }

    function Test-IdleStepEntitlementEquals {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object] $A,

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

        $ea = ConvertTo-IdleStepEntitlement -Value $A
        $eb = ConvertTo-IdleStepEntitlement -Value $B

        if ($ea.Kind -ne $eb.Kind) {
            return $false
        }

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

    $with = $Step.With
    if ($null -eq $with -or -not ($with -is [hashtable])) {
        throw "EnsureEntitlement requires 'With' to be a hashtable."
    }

    foreach ($key in @('IdentityKey', 'Entitlement', 'State')) {
        if (-not $with.ContainsKey($key)) {
            throw "EnsureEntitlement requires With.$key."
        }
    }

    $stateRaw = [string]$with.State
    if ([string]::IsNullOrWhiteSpace($stateRaw)) {
        throw "EnsureEntitlement requires With.State to be 'Present' or 'Absent'."
    }

    $state = $stateRaw.Trim().ToLowerInvariant()
    if ($state -notin @('present', 'absent')) {
        throw "EnsureEntitlement With.State must be 'Present' or 'Absent'."
    }

    $entitlement = ConvertTo-IdleStepEntitlement -Value $with.Entitlement
    $identityKey = [string]$with.IdentityKey

    $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'Identity' }

    if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) {
        throw "Context does not contain a Providers hashtable."
    }
    if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) {
        throw "Context.Providers must be a hashtable."
    }
    if (-not $Context.Providers.ContainsKey($providerAlias)) {
        throw "Provider '$providerAlias' was not supplied by the host."
    }

    $provider = $Context.Providers[$providerAlias]

    $requiredMethods = @('ListEntitlements')
    if ($state -eq 'present') {
        $requiredMethods += 'GrantEntitlement'
    }
    else {
        $requiredMethods += 'RevokeEntitlement'
    }

    foreach ($m in $requiredMethods) {
        if (-not ($provider.PSObject.Methods.Name -contains $m)) {
            throw "Provider '$providerAlias' must implement method '$m' for EnsureEntitlement."
        }
    }

    $current = @($provider.ListEntitlements($identityKey))
    $matches = @($current | Where-Object { Test-IdleStepEntitlementEquals -A $_ -B $entitlement })

    $changed = $false

    if ($state -eq 'present') {
        if (@($matches).Count -eq 0) {
            $result = $provider.GrantEntitlement($identityKey, $entitlement)
            if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) {
                $changed = [bool]$result.Changed
            }
            else {
                $changed = $true
            }
        }
    }
    else {
        if (@($matches).Count -gt 0) {
            $result = $provider.RevokeEntitlement($identityKey, $entitlement)
            if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) {
                $changed = [bool]$result.Changed
            }
            else {
                $changed = $true
            }
        }
    }

    return [pscustomobject]@{
        PSTypeName = 'IdLE.StepResult'
        Name       = [string]$Step.Name
        Type       = [string]$Step.Type
        Status     = 'Completed'
        Changed    = $changed
        Error      = $null
    }
}