Modules/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1
|
function New-IdleEntraIDIdentityProvider { <# .SYNOPSIS Creates a Microsoft Entra ID identity provider for IdLE. .DESCRIPTION This provider integrates with Microsoft Entra ID (formerly Azure Active Directory) via the Microsoft Graph API (v1.0). It supports both delegated and app-only authentication via the host-provided AuthSessionBroker pattern. The provider supports common identity operations (Create, Read, Disable, Enable, Delete) and group entitlement management (List, Grant, Revoke). Identity addressing supports: - objectId (GUID string) - most deterministic - UserPrincipalName (UPN) - contains @ - mail - email address The canonical identity key for all outputs is the user objectId (GUID string). Authentication: Provider methods accept an optional AuthSession parameter for runtime credential selection via the AuthSessionBroker. The provider supports multiple auth session formats: - String access token (Bearer token) - Object with AccessToken property - Object with GetAccessToken() method - PSCredential (must contain AccessToken in password field for Graph API) By default, steps should use: - With.AuthSessionName = 'MicrosoftGraph' - With.AuthSessionOptions = @{ Role = 'Admin' } (or other routing keys) .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 Adapter Internal parameter for dependency injection during testing. Allows unit tests to inject a fake Graph adapter without requiring a real Entra ID environment. .EXAMPLE # Basic usage with delegated auth # Host obtains token via secure method (not shown here - see provider documentation) $accessToken = Get-SecureGraphToken $broker = New-IdleAuthSessionBroker -SessionMap @{ @{} = $accessToken } -DefaultCredential $accessToken $provider = New-IdleEntraIDIdentityProvider $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ Identity = $provider AuthSessionBroker = $broker } .EXAMPLE # Multi-role scenario $tier0Token = Get-GraphTokenForTier0 # host-managed auth $adminToken = Get-GraphTokenForAdmin $broker = New-IdleAuthSessionBroker -SessionMap @{ @{ Role = 'Tier0' } = $tier0Token @{ Role = 'Admin' } = $adminToken } -DefaultCredential $adminToken $provider = New-IdleEntraIDIdentityProvider $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ Identity = $provider AuthSessionBroker = $broker } # Workflow steps specify: With.AuthSessionOptions = @{ Role = 'Tier0' } .EXAMPLE # Enable delete capability (opt-in) $provider = New-IdleEntraIDIdentityProvider -AllowDelete .OUTPUTS PSCustomObject with IdLE provider contract methods .NOTES Requires Microsoft Graph API permissions (delegated or app-only): - User.Read.All, User.ReadWrite.All - Group.Read.All, GroupMember.ReadWrite.All - For delete: User.ReadWrite.All See docs/reference/providers/provider-entraID.md for detailed permission requirements. #> [CmdletBinding()] param( [Parameter()] [switch] $AllowDelete, [Parameter()] [AllowNull()] [object] $Adapter ) # Check prerequisites and emit warnings if required components are missing $prereqs = Test-IdleEntraIDPrerequisites if (-not $prereqs.IsHealthy) { foreach ($missing in $prereqs.MissingRequired) { Write-Warning "EntraID provider prerequisite check: Required component '$missing' is not available." } foreach ($note in $prereqs.Notes) { Write-Warning "EntraID provider prerequisite check: $note" } } if ($null -eq $Adapter) { $Adapter = New-IdleEntraIDAdapter } $convertToEntitlement = { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [object] $Value ) return ConvertTo-IdleEntraIDEntitlement -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) } $extractAccessToken = { param( [Parameter()] [AllowNull()] [object] $AuthSession ) # Validate prerequisites only when using the real (default) adapter # Skip validation if a fake adapter is injected for tests # Check TypeNames collection (PSTypeName in hashtable adds to TypeNames, not as a property) $isRealAdapter = ($this.Adapter.PSObject.TypeNames -contains 'IdLE.EntraIDAdapter') if ($isRealAdapter) { $prereqCheck = Test-IdleEntraIDPrerequisites if (-not $prereqCheck.IsHealthy) { $missingList = $prereqCheck.MissingRequired -join ', ' $errorMsg = "EntraID provider operation cannot proceed. Required prerequisite(s) missing: $missingList" if ($prereqCheck.Notes.Count -gt 0) { $errorMsg += "`n" + ($prereqCheck.Notes -join "`n") } throw $errorMsg } } if ($null -eq $AuthSession) { # For tests/development, allow null but it will fail when hitting real Graph API # Real usage will fail with proper error from Graph API return 'test-token-not-for-production' } # String token if ($AuthSession -is [string]) { return $AuthSession } # Object with AccessToken property $hasAccessTokenProperty = $null -ne ($AuthSession.PSObject.Properties | Where-Object { $_.Name -eq 'AccessToken' }) if ($hasAccessTokenProperty) { return $AuthSession.AccessToken } # Object with GetAccessToken() method $hasGetAccessTokenMethod = $null -ne ($AuthSession.PSObject.Methods | Where-Object { $_.Name -eq 'GetAccessToken' }) if ($hasGetAccessTokenMethod) { return $AuthSession.GetAccessToken() } # PSCredential with token in password field if ($AuthSession -is [PSCredential]) { return $AuthSession.GetNetworkCredential().Password } throw "AuthSession format not recognized. Expected: string token, object with AccessToken property, object with GetAccessToken() method, or PSCredential." } $resolveIdentity = { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter()] [AllowNull()] [object] $AuthSession ) $accessToken = $this.ExtractAccessToken($AuthSession) # Try GUID format first (most deterministic) # Support both standard GUID format and N-format (32 hex digits) $guid = [System.Guid]::Empty $isGuid = [System.Guid]::TryParse($IdentityKey, [ref]$guid) # Also check for N-format GUID (32 hex digits, no hyphens) # This handles standalone GUIDs and GUIDs with prefixes (e.g., contract test keys like "contract-<guid>") if (-not $isGuid -and $IdentityKey -match '([0-9a-fA-F]{32})') { $hexPart = $Matches[1] $isGuid = [System.Guid]::TryParseExact($hexPart, 'N', [ref]$guid) } if ($isGuid) { $user = $this.Adapter.GetUserById($guid.ToString(), $accessToken) if ($null -ne $user) { return $user } throw "Identity with objectId '$IdentityKey' not found." } # Try UPN format (contains @) if ($IdentityKey -match '@') { $user = $this.Adapter.GetUserByUpn($IdentityKey, $accessToken) if ($null -ne $user) { return $user } # Fallback: try as mail $user = $this.Adapter.GetUserByMail($IdentityKey, $accessToken) if ($null -ne $user) { return $user } throw "Identity with UPN/mail '$IdentityKey' not found." } throw "Identity key '$IdentityKey' is not in a recognized format (objectId GUID, UPN, or mail)." } $normalizeGroupId = { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $GroupId, [Parameter()] [AllowNull()] [object] $AuthSession ) $accessToken = $this.ExtractAccessToken($AuthSession) # Try as objectId first $guid = [System.Guid]::Empty if ([System.Guid]::TryParse($GroupId, [ref]$guid)) { $group = $this.Adapter.GetGroupById($GroupId, $accessToken) if ($null -ne $group) { return $group.id } throw "Group with objectId '$GroupId' not found." } # Try as displayName $group = $this.Adapter.GetGroupByDisplayName($GroupId, $accessToken) if ($null -ne $group) { return $group.id } throw "Group '$GroupId' not found." } $provider = [pscustomobject]@{ PSTypeName = 'IdLE.Provider.EntraIDIdentityProvider' Name = 'EntraIDIdentityProvider' Adapter = $Adapter AllowDelete = [bool]$AllowDelete } $provider | Add-Member -MemberType ScriptMethod -Name ExtractAccessToken -Value $extractAccessToken -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.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 ) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $attributes = @{} # Handle both hashtables and PSCustomObjects $getUserProperty = { param($obj, $propName) if ($obj -is [System.Collections.IDictionary]) { if ($obj.ContainsKey($propName)) { return $obj[$propName] } } elseif ($obj.PSObject.Properties.Name -contains $propName) { return $obj.$propName } return $null } $givenName = & $getUserProperty $user 'givenName' if ($null -ne $givenName) { $attributes['GivenName'] = $givenName } $surname = & $getUserProperty $user 'surname' if ($null -ne $surname) { $attributes['Surname'] = $surname } $displayName = & $getUserProperty $user 'displayName' if ($null -ne $displayName) { $attributes['DisplayName'] = $displayName } $upn = & $getUserProperty $user 'userPrincipalName' if ($null -ne $upn) { $attributes['UserPrincipalName'] = $upn } $mail = & $getUserProperty $user 'mail' if ($null -ne $mail) { $attributes['Mail'] = $mail } $dept = & $getUserProperty $user 'department' if ($null -ne $dept) { $attributes['Department'] = $dept } $jobTitle = & $getUserProperty $user 'jobTitle' if ($null -ne $jobTitle) { $attributes['JobTitle'] = $jobTitle } $officeLocation = & $getUserProperty $user 'officeLocation' if ($null -ne $officeLocation) { $attributes['OfficeLocation'] = $officeLocation } $companyName = & $getUserProperty $user 'companyName' if ($null -ne $companyName) { $attributes['CompanyName'] = $companyName } # Get accountEnabled $accountEnabled = & $getUserProperty $user 'accountEnabled' return [pscustomobject]@{ PSTypeName = 'IdLE.Identity' IdentityKey = $IdentityKey Enabled = [bool]$accountEnabled Attributes = $attributes } } -Force $provider | Add-Member -MemberType ScriptMethod -Name ListIdentities -Value { param( [Parameter()] [hashtable] $Filter, [Parameter()] [AllowNull()] [object] $AuthSession ) $accessToken = $this.ExtractAccessToken($AuthSession) $users = $this.Adapter.ListUsers($Filter, $accessToken) $identityKeys = @() foreach ($user in $users) { $identityKeys += $user.id } 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 ) $accessToken = $this.ExtractAccessToken($AuthSession) # Check if user already exists (idempotency) 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 Write-Verbose "Identity '$IdentityKey' does not exist, proceeding with creation" } # Build Graph API payload $payload = @{ accountEnabled = $true } # Required fields for user creation if ($Attributes.ContainsKey('UserPrincipalName')) { $payload['userPrincipalName'] = $Attributes['UserPrincipalName'] } else { $payload['userPrincipalName'] = $IdentityKey } if ($Attributes.ContainsKey('DisplayName')) { $payload['displayName'] = $Attributes['DisplayName'] } else { $payload['displayName'] = $IdentityKey } # MailNickname is required if ($Attributes.ContainsKey('MailNickname')) { $payload['mailNickname'] = $Attributes['MailNickname'] } else { # Generate from UPN $payload['mailNickname'] = $payload['userPrincipalName'].Split('@')[0] } # Password policy for new users if ($Attributes.ContainsKey('PasswordProfile')) { $payload['passwordProfile'] = $Attributes['PasswordProfile'] } else { # Default: force change on first sign-in $payload['passwordProfile'] = @{ forceChangePasswordNextSignIn = $true password = [System.Guid]::NewGuid().ToString() } } # Optional attributes if ($Attributes.ContainsKey('GivenName')) { $payload['givenName'] = $Attributes['GivenName'] } if ($Attributes.ContainsKey('Surname')) { $payload['surname'] = $Attributes['Surname'] } if ($Attributes.ContainsKey('Mail')) { $payload['mail'] = $Attributes['Mail'] } if ($Attributes.ContainsKey('Department')) { $payload['department'] = $Attributes['Department'] } if ($Attributes.ContainsKey('JobTitle')) { $payload['jobTitle'] = $Attributes['JobTitle'] } if ($Attributes.ContainsKey('OfficeLocation')) { $payload['officeLocation'] = $Attributes['OfficeLocation'] } if ($Attributes.ContainsKey('CompanyName')) { $payload['companyName'] = $Attributes['CompanyName'] } if ($Attributes.ContainsKey('Enabled')) { $payload['accountEnabled'] = [bool]$Attributes['Enabled'] } $this.Adapter.CreateUser($payload, $accessToken) | Out-Null return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'CreateIdentity' IdentityKey = $IdentityKey Changed = $true } } -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." } $accessToken = $this.ExtractAccessToken($AuthSession) try { $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $this.Adapter.DeleteUser($user.id, $accessToken) return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'DeleteIdentity' IdentityKey = $IdentityKey Changed = $true } } catch { # Idempotency: if not found, treat as success if ($_.Exception.Message -match '404|not found|does not exist') { 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 ) $accessToken = $this.ExtractAccessToken($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) # Map IdLE attribute names to Graph API property names $graphPropertyName = switch ($Name) { 'GivenName' { 'givenName' } 'Surname' { 'surname' } 'DisplayName' { 'displayName' } 'UserPrincipalName' { 'userPrincipalName' } 'Mail' { 'mail' } 'Department' { 'department' } 'JobTitle' { 'jobTitle' } 'OfficeLocation' { 'officeLocation' } 'CompanyName' { 'companyName' } default { $Name.Substring(0, 1).ToLower() + $Name.Substring(1) } } $currentValue = $null if ($user -is [System.Collections.IDictionary]) { if ($user.ContainsKey($graphPropertyName)) { $currentValue = $user[$graphPropertyName] } } elseif ($user.PSObject.Properties.Name -contains $graphPropertyName) { $currentValue = $user.$graphPropertyName } $changed = $false # Use loose comparison for idempotency (handles type coercion) if (-not ($currentValue -eq $Value)) { $payload = @{ $graphPropertyName = $Value } $this.Adapter.PatchUser($user.id, $payload, $accessToken) $changed = $true } return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'EnsureAttribute' IdentityKey = $IdentityKey Changed = $changed Name = $Name Value = $Value } } -Force $provider | Add-Member -MemberType ScriptMethod -Name DisableIdentity -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter()] [AllowNull()] [object] $AuthSession ) $accessToken = $this.ExtractAccessToken($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) # Get accountEnabled from user object (handle both hashtable and PSCustomObject) $accountEnabled = if ($user -is [System.Collections.IDictionary]) { if ($user.ContainsKey('accountEnabled')) { $user['accountEnabled'] } else { $null } } else { if ($user.PSObject.Properties.Name -contains 'accountEnabled') { $user.accountEnabled } else { $null } } # Get id from user object $userId = if ($user -is [System.Collections.IDictionary]) { $user['id'] } else { $user.id } $changed = $false if ($accountEnabled -ne $false) { $payload = @{ accountEnabled = $false } $this.Adapter.PatchUser($userId, $payload, $accessToken) $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 ) $accessToken = $this.ExtractAccessToken($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) # Get accountEnabled from user object (handle both hashtable and PSCustomObject) $accountEnabled = if ($user -is [System.Collections.IDictionary]) { if ($user.ContainsKey('accountEnabled')) { $user['accountEnabled'] } else { $null } } else { if ($user.PSObject.Properties.Name -contains 'accountEnabled') { $user.accountEnabled } else { $null } } # Get id from user object $userId = if ($user -is [System.Collections.IDictionary]) { $user['id'] } else { $user.id } $changed = $false if ($accountEnabled -ne $true) { $payload = @{ accountEnabled = $true } $this.Adapter.PatchUser($userId, $payload, $accessToken) $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 ) $accessToken = $this.ExtractAccessToken($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $groups = $this.Adapter.ListUserGroups($user.id, $accessToken) $result = @() foreach ($group in $groups) { # Handle both hashtables and PSCustomObjects $groupId = if ($group -is [System.Collections.IDictionary]) { $group['id'] } else { $group.id } $displayName = if ($group -is [System.Collections.IDictionary]) { if ($group.ContainsKey('displayName')) { $group['displayName'] } else { $null } } else { if ($group.PSObject.Properties.Name -contains 'displayName') { $group.displayName } else { $null } } $mail = if ($group -is [System.Collections.IDictionary]) { if ($group.ContainsKey('mail')) { $group['mail'] } else { $null } } else { if ($group.PSObject.Properties.Name -contains 'mail') { $group.mail } else { $null } } $result += [pscustomobject]@{ PSTypeName = 'IdLE.Entitlement' Kind = 'Group' Id = $groupId DisplayName = $displayName Mail = $mail } } 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 ) $accessToken = $this.ExtractAccessToken($AuthSession) $normalized = $this.ConvertToEntitlement($Entitlement) # GrantEntitlement only supports group entitlements if ($null -ne $normalized.Kind -and $normalized.Kind -ne 'Group') { throw [System.ArgumentException]::new( "GrantEntitlement only supports entitlements with Kind 'Group'. Received Kind '$($normalized.Kind)'." ) } # Default missing Kind to 'Group' for backward compatibility if (-not $normalized.Kind) { $normalized.Kind = 'Group' } $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $groupObjectId = $this.NormalizeGroupId($normalized.Id, $AuthSession) # Update normalized entitlement with canonical group ID $normalized.Id = $groupObjectId # Check if already a member (idempotency) $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } $changed = $false if (@($existing).Count -eq 0) { $this.Adapter.AddGroupMember($groupObjectId, $user.id, $accessToken) $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 ) $accessToken = $this.ExtractAccessToken($AuthSession) $normalized = $this.ConvertToEntitlement($Entitlement) # RevokeEntitlement only supports group entitlements if ($null -ne $normalized.Kind -and $normalized.Kind -ne 'Group') { throw [System.ArgumentException]::new( "RevokeEntitlement only supports entitlements with Kind 'Group'. Received Kind '$($normalized.Kind)'." ) } # Default missing Kind to 'Group' for backward compatibility if (-not $normalized.Kind) { $normalized.Kind = 'Group' } $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $groupObjectId = $this.NormalizeGroupId($normalized.Id, $AuthSession) # Update normalized entitlement with canonical group ID $normalized.Id = $groupObjectId # Check if currently a member (idempotency) $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } $changed = $false if (@($existing).Count -gt 0) { $this.Adapter.RemoveGroupMember($groupObjectId, $user.id, $accessToken) $changed = $true } return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'RevokeEntitlement' IdentityKey = $IdentityKey Changed = $changed Entitlement = $normalized } } -Force return $provider } |