diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index 1f89ac30..875b13fc 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -21,6 +21,7 @@ sidebar_label: Entra ID - Create, read, update, disable, enable, and delete (opt-in) user accounts in Microsoft Entra ID - Set and update user attributes (givenName, surname, department, jobTitle, etc.) - List group memberships and manage group entitlements (grant/revoke) + - Revoke active sign-in sessions (refresh tokens) for user accounts - Resolve identities by objectId (GUID), UserPrincipalName (UPN), or mail address - **Out of scope / non-goals:** - Establishing authentication or obtaining Graph access tokens (handled by host-provided broker) @@ -49,6 +50,7 @@ sidebar_label: Entra ID - `IdLE.Identity.Attribute.Ensure` - Set/update identity attributes - `IdLE.Identity.Disable` - Disable user accounts - `IdLE.Identity.Enable` - Enable user accounts + - `IdLE.Identity.RevokeSessions` - Revoke active sign-in sessions - `IdLE.Entitlement.List` - List group memberships - `IdLE.Entitlement.Grant` - Add group membership - `IdLE.Entitlement.Revoke` - Remove group membership @@ -305,25 +307,25 @@ This provider has **no provider-specific option bag**. All configuration is done ## Required Microsoft Graph Permissions -### Delegated Permissions (User Context) +This section lists the Microsoft Graph API permissions required for each step type supported by this provider. The same permissions apply for both delegated (user context) and application (app-only) permissions. -Minimum required: +### Permissions by Step Type -- `User.Read.All` (read user information) -- `User.ReadWrite.All` (create/update/delete users) -- `Group.Read.All` (list group memberships) -- `GroupMember.ReadWrite.All` (add/remove group members) +| Step Type | Required Permissions | Notes | +|-----------|---------------------|-------| +| `IdLE.Step.CreateIdentity` | `User.ReadWrite.All` | Requires write permissions to create users | +| `IdLE.Step.DisableIdentity` | `User.ReadWrite.All` | Modifies `accountEnabled` property | +| `IdLE.Step.EnableIdentity` | `User.ReadWrite.All` | Modifies `accountEnabled` property | +| `IdLE.Step.EnsureAttribute` | `User.ReadWrite.All` | Modifies user properties (displayName, department, etc.) | +| `IdLE.Step.DeleteIdentity` | `User.ReadWrite.All` | Requires `AllowDelete = $true` on provider | +| `IdLE.Step.RevokeIdentitySessions` | `User.RevokeSessions.All` | Security-sensitive; invalidates all active sessions | +| `IdLE.Step.EnsureEntitlement` | `Group.Read.All`
`GroupMember.ReadWrite.All` | Lists and modifies group memberships | -### Application Permissions (App-Only Context) - -Minimum required (same as delegated): - -- `User.Read.All` -- `User.ReadWrite.All` -- `Group.Read.All` -- `GroupMember.ReadWrite.All` - -**Note**: Application permissions require admin consent in the tenant. +**Notes:** +- Application permissions require admin consent in the tenant +- `User.Read.All` is included in `User.ReadWrite.All` for identity resolution +- Grant only the permissions you need based on the workflow steps you will use +- `User.RevokeSessions.All` is security-sensitive; ensure appropriate approval processes are in place before granting --- @@ -426,6 +428,46 @@ All operations are idempotent: | Grant membership | If already a member, returns `Changed=$false` | | Revoke membership | If not a member, returns `Changed=$false` | | Set attribute | If already at desired value, returns `Changed=$false` | +| Revoke sessions | Returns `Changed` based on Graph API response (true if sessions existed, false if none to revoke) | + +### Session Revocation Behavior + +The `RevokeSessions` operation invalidates all active sign-in sessions and refresh tokens for a user account. This is typically used in Leaver workflows after disabling an account to ensure immediate sign-out. + +**Important characteristics:** + +- **Immediate effect**: Sign-in sessions are invalidated, forcing re-authentication on the next request +- **Propagation delay**: Due to token caching and Conditional Access Evaluation (CAE), there may be a short delay (typically a few minutes) before all sessions are terminated +- **Changed flag**: The operation passes through the Graph API response: `Changed=$true` if active sessions were revoked, `Changed=$false` if there were no active sessions to revoke +- **Idempotency**: Safe to call multiple times; if no active sessions exist, returns `Changed=$false` +- **No account state change**: This operation does NOT disable the account; use `DisableIdentity` separately if account disabling is also required + +**Workflow pattern for Leaver scenarios:** + +```powershell +Steps = @( + @{ + Name = 'DisableAccount' + Type = 'IdLE.Step.DisableIdentity' + With = @{ + Provider = 'Identity' + AuthSessionName = 'MicrosoftGraph' + IdentityKey = '{{Request.Input.UserObjectId}}' + } + } + @{ + Name = 'RevokeActiveSessions' + Type = 'IdLE.Step.RevokeIdentitySessions' + With = @{ + Provider = 'Identity' + AuthSessionName = 'MicrosoftGraph' + IdentityKey = '{{Request.Input.UserObjectId}}' + } + } +) +``` + +**Note**: The `DisableIdentity` step does NOT automatically revoke sessions. Session revocation must be explicitly requested via the `RevokeIdentitySessions` step. ### Error mapping and retry behavior @@ -570,6 +612,7 @@ The provider works with these built-in IdLE steps: - `IdLE.Step.EnsureAttribute` - `IdLE.Step.DisableIdentity` - `IdLE.Step.EnableIdentity` +- `IdLE.Step.RevokeIdentitySessions` (revokes active sign-in sessions) - `IdLE.Step.DeleteIdentity` (when `AllowDelete = $true`) - `IdLE.Step.EnsureEntitlement` diff --git a/docs/reference/steps.md b/docs/reference/steps.md index a89fea83..f9e461c1 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -16,4 +16,5 @@ | [IdLE.Step.Mailbox.EnsureType](steps/step-mailbox-ensure-type.md) | ``IdLE.Steps.Mailbox`` | Ensures that a mailbox is of the desired type (User, Shared, Room, Equipment). | | [IdLE.Step.Mailbox.GetInfo](steps/step-mailbox-get-info.md) | ``IdLE.Steps.Mailbox`` | Retrieves mailbox details and returns a structured report. | | [IdLE.Step.MoveIdentity](steps/step-move-identity.md) | ``IdLE.Steps.Common`` | Moves an identity to a different container/OU in the target system. | +| [IdLE.Step.RevokeIdentitySessions](steps/step-revoke-identity-sessions.md) | ``IdLE.Steps.Common`` | Revokes all active sign-in sessions for an identity in the target system. | | [IdLE.Step.TriggerDirectorySync](steps/step-trigger-directory-sync.md) | ``IdLE.Steps.DirectorySync`` | Triggers a directory sync cycle and optionally waits for completion. | diff --git a/docs/reference/steps/step-revoke-identity-sessions.md b/docs/reference/steps/step-revoke-identity-sessions.md new file mode 100644 index 00000000..a0a6f861 --- /dev/null +++ b/docs/reference/steps/step-revoke-identity-sessions.md @@ -0,0 +1,64 @@ +# IdLE.Step.RevokeIdentitySessions + +> Generated file. Do not edit by hand. +> Source: tools/Generate-IdleStepReference.ps1 + +## Summary + +- **Step Type**: `IdLE.Step.RevokeIdentitySessions` +- **Module**: `IdLE.Steps.Common` +- **Implementation**: `Invoke-IdleStepRevokeIdentitySessions` +- **Idempotent**: `Unknown` + +## Synopsis + +Revokes all active sign-in sessions for an identity in the target system. + +## Description + +This is a provider-agnostic step that revokes active sign-in sessions (refresh tokens) +for a given identity. The host must supply a provider instance via +Context.Providers[<ProviderAlias>] that implements RevokeSessions(identityKey) +and returns an object with properties 'IdentityKey' and 'Changed'. + +This step is typically used in Leaver workflows after disabling an identity to ensure +that existing sessions are terminated immediately, rather than waiting for tokens to expire. + +The step does not modify the identity itself (e.g., does not disable the account). +Use IdLE.Step.DisableIdentity separately if account disabling is also required. + +Authentication: + +- If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. + +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @\{ Role = 'Tier0' \}). + +- ScriptBlocks in AuthSessionOptions are rejected (security boundary). + +## Inputs (With.*) + +The following keys are required in the step's ``With`` configuration: + +| Key | Required | Description | +| --- | --- | --- | +| `IdentityKey` | Yes | Unique identifier for the identity | + +## Example + +```powershell +@{ + Name = 'IdLE.Step.RevokeIdentitySessions Example' + Type = 'IdLE.Step.RevokeIdentitySessions' + With = @{ + IdentityKey = 'user.name' + } +} +``` + +## See Also + +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities +- [Providers](../providers.md) - Available provider implementations diff --git a/examples/workflows/templates/entraid-leaver-offboarding.psd1 b/examples/workflows/templates/entraid-leaver-offboarding.psd1 index dcfc9bf0..86a633d2 100644 --- a/examples/workflows/templates/entraid-leaver-offboarding.psd1 +++ b/examples/workflows/templates/entraid-leaver-offboarding.psd1 @@ -1,7 +1,7 @@ @{ Name = 'EntraID Leaver - Offboarding with Optional Delete' LifecycleEvent = 'Leaver' - Description = 'Disables user account and optionally deletes (requires AllowDelete provider flag).' + Description = 'Disables user account, revokes active sessions, and optionally deletes (requires AllowDelete provider flag).' Steps = @( @{ Name = 'RevokeAllGroupMemberships' @@ -44,6 +44,15 @@ IdentityKey = '{{Request.Input.UserObjectId}}' } } + @{ + Name = 'RevokeActiveSessions' + Type = 'IdLE.Step.RevokeIdentitySessions' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + } + } @{ Name = 'DeleteAccountAfterRetention' Type = 'IdLE.Step.DeleteIdentity' diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index 83bcc64d..2a4cc497 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -163,6 +163,13 @@ function Get-IdleStepRegistry { } } + if (-not $registry.ContainsKey('IdLE.Step.RevokeIdentitySessions')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepRevokeIdentitySessions' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.RevokeIdentitySessions'] = $handler + } + } + if (-not $registry.ContainsKey('IdLE.Step.TriggerDirectorySync')) { $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepTriggerDirectorySync' -ModuleName 'IdLE.Steps.DirectorySync' if (-not [string]::IsNullOrWhiteSpace($handler)) { diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index 0dbb4fee..61b615ad 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -429,5 +429,24 @@ function New-IdleEntraIDAdapter { } } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name RevokeSignInSessions -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ObjectId, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $uri = "$($this.BaseUri)/users/$ObjectId/revokeSignInSessions" + + $response = $this.InvokeGraphRequest('POST', $uri, $AccessToken, $null) + # Graph returns { "@odata.context": "...", "value": true/false } + # The value indicates whether sessions were revoked + return $response + } -Force + return $adapter } diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index 2b89d284..d801b5fc 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -304,6 +304,7 @@ function New-IdleEntraIDIdentityProvider { 'IdLE.Identity.Attribute.Ensure' 'IdLE.Identity.Disable' 'IdLE.Identity.Enable' + 'IdLE.Identity.RevokeSessions' 'IdLE.Entitlement.List' 'IdLE.Entitlement.Grant' 'IdLE.Entitlement.Revoke' @@ -699,6 +700,49 @@ function New-IdleEntraIDIdentityProvider { } } -Force + $provider | Add-Member -MemberType ScriptMethod -Name RevokeSessions -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + + # Get id from user object + $userId = if ($user -is [System.Collections.IDictionary]) { + $user['id'] + } + else { + $user.id + } + + # Call the adapter to revoke sign-in sessions + $response = $this.Adapter.RevokeSignInSessions($userId, $accessToken) + + # Graph returns a response with a 'value' property indicating whether sessions were revoked + # value=true means active sessions existed and were revoked + # value=false means there were no active sessions to revoke + $changed = $true + + # Pass through the actual Graph API response for accurate Changed status + if ($null -ne $response -and ($response.PSObject.Properties.Name -contains 'value')) { + $changed = [bool]$response.value + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'RevokeSessions' + IdentityKey = $IdentityKey + Changed = $changed + } + } -Force + $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { param( [Parameter(Mandatory)] diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 index b4e270f3..88389488 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -19,7 +19,8 @@ 'Invoke-IdleStepDisableIdentity', 'Invoke-IdleStepEnableIdentity', 'Invoke-IdleStepMoveIdentity', - 'Invoke-IdleStepDeleteIdentity' + 'Invoke-IdleStepDeleteIdentity', + 'Invoke-IdleStepRevokeIdentitySessions' ) PrivateData = @{ diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 index d1d85993..8e5c5f9b 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -53,5 +53,6 @@ Export-ModuleMember -Function @( 'Invoke-IdleStepDisableIdentity', 'Invoke-IdleStepEnableIdentity', 'Invoke-IdleStepMoveIdentity', - 'Invoke-IdleStepDeleteIdentity' + 'Invoke-IdleStepDeleteIdentity', + 'Invoke-IdleStepRevokeIdentitySessions' ) diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index 42f2818a..cc5a95b7 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -63,5 +63,10 @@ function Get-IdleStepMetadataCatalog { RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') } + # IdLE.Step.RevokeIdentitySessions - requires identity session revocation capability + $catalog['IdLE.Step.RevokeIdentitySessions'] = @{ + RequiredCapabilities = @('IdLE.Identity.RevokeSessions') + } + return $catalog } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepRevokeIdentitySessions.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepRevokeIdentitySessions.ps1 new file mode 100644 index 00000000..0f27ceea --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepRevokeIdentitySessions.ps1 @@ -0,0 +1,115 @@ +function Invoke-IdleStepRevokeIdentitySessions { + <# + .SYNOPSIS + Revokes all active sign-in sessions for an identity in the target system. + + .DESCRIPTION + This is a provider-agnostic step that revokes active sign-in sessions (refresh tokens) + for a given identity. The host must supply a provider instance via + Context.Providers[] that implements RevokeSessions(identityKey) + and returns an object with properties 'IdentityKey' and 'Changed'. + + This step is typically used in Leaver workflows after disabling an identity to ensure + that existing sessions are terminated immediately, rather than waiting for tokens to expire. + + The step does not modify the identity itself (e.g., does not disable the account). + Use IdLE.Step.DisableIdentity separately if account disabling is also required. + + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). + - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable with keys: + - IdentityKey (required): the identity identifier + - Provider (optional): provider alias, defaults to 'Identity' + - AuthSessionName (optional): name for auth session acquisition + - AuthSessionOptions (optional): routing options for auth session broker + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + + .EXAMPLE + # In a workflow definition (PSD1): + @{ + Name = 'Revoke Entra sessions' + Type = 'IdLE.Step.RevokeIdentitySessions' + With = @{ + Provider = 'Entra' + IdentityKey = 'max.power@contoso.com' + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + } + } + + .NOTES + Requires provider capability: IdLE.Identity.RevokeSessions + + For Entra ID provider, this calls Microsoft Graph API: + POST /users/{id}/revokeSignInSessions + + Required Graph permissions: User.RevokeSessions.All + + Note: Session revocation may not be instantaneous; a small propagation delay may occur + depending on the identity provider and token lifetime policies. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "RevokeIdentitySessions requires 'With' to be a hashtable." + } + + if (-not $with.ContainsKey('IdentityKey')) { + throw "RevokeIdentitySessions requires 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." + } + + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'RevokeSessions' ` + -MethodArguments @([string]$with.IdentityKey) + + $changed = $false + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { + $changed = [bool]$result.Changed + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = $changed + Error = $null + } +} diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index 8296df97..f2896391 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -230,6 +230,7 @@ Describe 'EntraID identity provider - Capabilities' { $caps | Should -Contain 'IdLE.Identity.Attribute.Ensure' $caps | Should -Contain 'IdLE.Identity.Disable' $caps | Should -Contain 'IdLE.Identity.Enable' + $caps | Should -Contain 'IdLE.Identity.RevokeSessions' $caps | Should -Contain 'IdLE.Entitlement.List' $caps | Should -Contain 'IdLE.Entitlement.Grant' $caps | Should -Contain 'IdLE.Entitlement.Revoke' @@ -796,3 +797,147 @@ Describe 'EntraID identity provider - Entitlement operations' { @($afterRevoke | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $entitlement.Id }).Count | Should -Be 0 } } + +Describe 'EntraID identity provider - RevokeSessions' { + BeforeAll { + # Create a fake adapter that tracks revocation calls + $fakeAdapter = [pscustomobject]@{ + PSTypeName = 'IdLE.EntraIDAdapter.Fake' + RevocationCallLog = @() + RevocationResponses = @{} + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { + param($ObjectId, $AccessToken) + return @{ + id = $ObjectId + userPrincipalName = "$ObjectId@test.local" + accountEnabled = $true + } + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { + param($Upn, $AccessToken) + return @{ + id = 'test-user-id' + userPrincipalName = $Upn + accountEnabled = $true + } + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserByMail -Value { + param($Mail, $AccessToken) + return @{ + id = 'test-user-id' + mail = $Mail + userPrincipalName = "$Mail" + accountEnabled = $true + } + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name RevokeSignInSessions -Value { + param($ObjectId, $AccessToken) + $this.RevocationCallLog += @{ + ObjectId = $ObjectId + AccessToken = $AccessToken + Timestamp = [datetime]::UtcNow + } + # Return a response that simulates Graph API behavior + if ($this.RevocationResponses.ContainsKey($ObjectId)) { + return $this.RevocationResponses[$ObjectId] + } + return [pscustomobject]@{ + '@odata.context' = 'https://graph.microsoft.com/v1.0/$metadata#Edm.Boolean' + value = $true + } + } + + $script:RevokeAdapter = $fakeAdapter + $script:RevokeProvider = New-IdleEntraIDIdentityProvider -Adapter $script:RevokeAdapter + } + + It 'Advertises IdLE.Identity.RevokeSessions capability' { + $caps = $script:RevokeProvider.GetCapabilities() + $caps | Should -Contain 'IdLE.Identity.RevokeSessions' + } + + It 'Exposes RevokeSessions method' { + $script:RevokeProvider.PSObject.Methods.Name | Should -Contain 'RevokeSessions' + } + + It 'RevokeSessions calls adapter with correct user ID' { + $userId = [guid]::NewGuid().ToString() + $script:RevokeAdapter.RevocationCallLog = @() + + $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') + + $script:RevokeAdapter.RevocationCallLog.Count | Should -Be 1 + $script:RevokeAdapter.RevocationCallLog[0].ObjectId | Should -Be $userId + } + + It 'RevokeSessions returns ProviderResult with correct shape' { + $userId = [guid]::NewGuid().ToString() + + $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') + + $result | Should -Not -BeNullOrEmpty + $result.PSObject.TypeNames[0] | Should -Be 'IdLE.ProviderResult' + $result.Operation | Should -Be 'RevokeSessions' + $result.IdentityKey | Should -Be $userId + $result.PSObject.Properties.Name | Should -Contain 'Changed' + } + + It 'RevokeSessions reports Changed=true when Graph returns value=true' { + $userId = [guid]::NewGuid().ToString() + $script:RevokeAdapter.RevocationResponses[$userId] = [pscustomobject]@{ + value = $true + } + + $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') + + $result.Changed | Should -Be $true + } + + It 'RevokeSessions reports Changed=false when Graph returns value=false' { + $userId = [guid]::NewGuid().ToString() + $script:RevokeAdapter.RevocationResponses[$userId] = [pscustomobject]@{ + value = $false + } + + $result = $script:RevokeProvider.RevokeSessions($userId, 'fake-token') + + $result.Changed | Should -Be $false + } + + It 'RevokeSessions resolves identity by UPN' { + $upn = 'test.user@contoso.com' + $script:RevokeAdapter.RevocationCallLog = @() + + $result = $script:RevokeProvider.RevokeSessions($upn, 'fake-token') + + $script:RevokeAdapter.RevocationCallLog.Count | Should -Be 1 + $script:RevokeAdapter.RevocationCallLog[0].ObjectId | Should -Be 'test-user-id' + } + + It 'RevokeSessions resolves identity by mail' { + $mail = 'test.user@contoso.com' + $script:RevokeAdapter.RevocationCallLog = @() + + $result = $script:RevokeProvider.RevokeSessions($mail, 'fake-token') + + $script:RevokeAdapter.RevocationCallLog.Count | Should -Be 1 + $script:RevokeAdapter.RevocationCallLog[0].ObjectId | Should -Be 'test-user-id' + } + + It 'RevokeSessions accepts AuthSession object' { + $userId = [guid]::NewGuid().ToString() + $authSession = [pscustomobject]@{ + AccessToken = 'session-token' + } + + $result = $script:RevokeProvider.RevokeSessions($userId, $authSession) + + $result | Should -Not -BeNullOrEmpty + $result.Operation | Should -Be 'RevokeSessions' + } +} diff --git a/tests/Steps/Invoke-IdleStepRevokeIdentitySessions.Tests.ps1 b/tests/Steps/Invoke-IdleStepRevokeIdentitySessions.Tests.ps1 new file mode 100644 index 00000000..76dce941 --- /dev/null +++ b/tests/Steps/Invoke-IdleStepRevokeIdentitySessions.Tests.ps1 @@ -0,0 +1,179 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Invoke-IdleStepRevokeIdentitySessions (built-in step)' { + BeforeEach { + # Create a fake provider with RevokeSessions support + $script:FakeProvider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.FakeWithRevoke' + CallLog = @() + } + + $script:FakeProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.RevokeSessions') + } + + $script:FakeProvider | Add-Member -MemberType ScriptMethod -Name RevokeSessions -Value { + param( + [Parameter(Mandatory)] + [string] $IdentityKey, + + [Parameter()] + [object] $AuthSession + ) + + $this.CallLog += @{ + Method = 'RevokeSessions' + IdentityKey = $IdentityKey + AuthSession = $AuthSession + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'RevokeSessions' + IdentityKey = $IdentityKey + Changed = $true + } + } + + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ Identity = $script:FakeProvider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return [pscustomobject]@{ + SessionName = $Name + Options = $Options + Token = 'fake-auth-token' + } + } + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Revoke sessions' + Type = 'IdLE.Step.RevokeIdentitySessions' + With = @{ + IdentityKey = 'user@contoso.com' + Provider = 'Identity' + } + } + } + + It 'calls provider RevokeSessions method with correct identity key' { + $step = $script:StepTemplate + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:FakeProvider.CallLog.Count | Should -Be 1 + $script:FakeProvider.CallLog[0].IdentityKey | Should -Be 'user@contoso.com' + } + + It 'returns StepResult with correct shape' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result | Should -Not -BeNullOrEmpty + $result.PSObject.TypeNames[0] | Should -Be 'IdLE.StepResult' + $result.Name | Should -Be 'Revoke sessions' + $result.Type | Should -Be 'IdLE.Step.RevokeIdentitySessions' + $result.Status | Should -Be 'Completed' + $result.PSObject.Properties.Name | Should -Contain 'Changed' + $result.PSObject.Properties.Name | Should -Contain 'Error' + $result.Error | Should -BeNullOrEmpty + } + + It 'acquires auth session when AuthSessionName is provided' { + $step = $script:StepTemplate + $step.With.AuthSessionName = 'MicrosoftGraph' + $step.With.AuthSessionOptions = @{ Role = 'Admin' } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $script:FakeProvider.CallLog.Count | Should -Be 1 + $script:FakeProvider.CallLog[0].AuthSession | Should -Not -BeNullOrEmpty + $script:FakeProvider.CallLog[0].AuthSession.SessionName | Should -Be 'MicrosoftGraph' + } + + It 'throws when With.IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.IdentityKey*' + } + + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw '*Provider*was not supplied*' + } + + It 'throws when provider does not support RevokeSessions method' { + # Create a provider without RevokeSessions support + $unsupportedProvider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.FakeWithoutRevoke' + } + $unsupportedProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Read') + } + + $script:Context.Providers['Identity'] = $unsupportedProvider + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + + It 'respects Changed flag from provider result' { + # Modify provider to return Changed=false + $script:FakeProvider | Add-Member -MemberType ScriptMethod -Name RevokeSessions -Value { + param($IdentityKey, $AuthSession) + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'RevokeSessions' + IdentityKey = $IdentityKey + Changed = $false + } + } -Force + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Changed | Should -Be $false + } + + It 'uses default provider alias "Identity" when not specified' { + $step = $script:StepTemplate + $step.With.Remove('Provider') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $script:FakeProvider.CallLog.Count | Should -Be 1 + } + + It 'supports custom provider alias' { + $script:Context.Providers['CustomEntra'] = $script:FakeProvider + $step = $script:StepTemplate + $step.With.Provider = 'CustomEntra' + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepRevokeIdentitySessions' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $script:FakeProvider.CallLog.Count | Should -Be 1 + } +}