diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 357491c..73bd5cb 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -20,6 +20,7 @@ - [Cmdlet Reference](reference/cmdlets.md) - [Events and Observability](reference/events-and-observability.md) - [Providers and Contracts](reference/providers-and-contracts.md) +- [Active Directory Provider](reference/provider-ad.md) - [Configuration](reference/configuration.md) - [Steps and Metadata](reference/steps-and-metadata.md) - [Step Catalog](reference/steps.md) diff --git a/docs/reference/provider-ad.md b/docs/reference/provider-ad.md new file mode 100644 index 0000000..1190ba8 --- /dev/null +++ b/docs/reference/provider-ad.md @@ -0,0 +1,310 @@ +# IdLE.Provider.AD - Active Directory Provider + +## Overview + +The Active Directory provider (`IdLE.Provider.AD`) is a built-in provider for on-premises Active Directory environments. It enables IdLE to perform identity lifecycle operations directly against Windows Active Directory domains. + +**Platform:** Windows-only (requires RSAT/ActiveDirectory PowerShell module) + +**Module:** IdLE.Provider.AD + +**Factory Function:** `New-IdleADIdentityProvider` + +--- + +## Capabilities + +The AD provider implements the following IdLE capabilities: + +### Identity Operations + +- **IdLE.Identity.Read** - Query identity information +- **IdLE.Identity.List** - List identities (provider API only, no built-in step) +- **IdLE.Identity.Create** - Create new user accounts +- **IdLE.Identity.Delete** - Delete user accounts (opt-in via `-AllowDelete`) +- **IdLE.Identity.Disable** - Disable user accounts +- **IdLE.Identity.Enable** - Enable user accounts +- **IdLE.Identity.Move** - Move users between OUs +- **IdLE.Identity.Attribute.Ensure** - Set/update user attributes + +### Entitlement Operations + +- **IdLE.Entitlement.List** - List group memberships +- **IdLE.Entitlement.Grant** - Add users to groups +- **IdLE.Entitlement.Revoke** - Remove users from groups + +**Note:** AD only supports `Kind='Group'` for entitlements. This is a platform limitation - Active Directory only provides security groups and distribution groups, not arbitrary entitlement types (roles, licenses, etc.). + +--- + +## Prerequisites + +### Windows and RSAT + +The provider requires Windows with the Active Directory PowerShell module (RSAT). + +**Install RSAT on Windows Server:** +```powershell +Install-WindowsFeature -Name RSAT-AD-PowerShell +``` + +**Install RSAT on Windows 10/11:** +```powershell +Get-WindowsCapability -Online -Name "Rsat.ActiveDirectory*" | Add-WindowsCapability -Online +``` + +### Active Directory Permissions + +The account running IdLE (or provided via `-Credential`) must have appropriate AD permissions: + +| Operation | Required Permission | +| --------- | ------------------- | +| Read identity | Read access to user objects | +| Create identity | Create user objects in target OU | +| Delete identity | Delete user objects | +| Disable/Enable | Modify user account flags | +| Set attributes | Write access to specific attributes | +| Move identity | Move objects between OUs | +| Grant/Revoke group membership | Modify group membership | + +Follow the principle of least privilege - grant only the permissions required for your workflows. + +--- + +## Installation and Import + +The AD provider is automatically imported when you import the main IdLE module: + +```powershell +Import-Module IdLE +``` + +This makes `New-IdleADIdentityProvider` available in your session. + +--- + +## Usage + +### Basic Usage (Integrated Auth) + +```powershell +# Create provider using integrated authentication (run-as) +$provider = New-IdleADIdentityProvider + +# Use in workflows +$plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + Identity = $provider +} +``` + +### With Explicit Credentials + +```powershell +$credential = Get-Credential +$provider = New-IdleADIdentityProvider -Credential $credential +``` + +### With Delete Capability (Opt-in) + +By default, the Delete capability is **not** advertised for safety. Enable it explicitly: + +```powershell +$provider = New-IdleADIdentityProvider -AllowDelete +``` + +### Multi-Provider Scenarios + +```powershell +$sourceAD = New-IdleADIdentityProvider -Credential $sourceCred +$targetAD = New-IdleADIdentityProvider -Credential $targetCred -AllowDelete + +$plan = New-IdlePlan -WorkflowPath './migration.psd1' -Request $request -Providers @{ + SourceAD = $sourceAD + TargetAD = $targetAD +} +``` + +--- + +## Identity Resolution + +The provider supports multiple identifier formats and resolves them deterministically: + +1. **GUID** (ObjectGuid): Pattern matches `[System.Guid]::TryParse()` - most deterministic +2. **UPN** (UserPrincipalName): Contains `@` symbol +3. **sAMAccountName**: Fallback for simple usernames + +**Resolution order:** +```powershell +# GUID format → resolve by ObjectGuid +'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + +# Contains @ → resolve by UPN +'john.doe@contoso.local' + +# Otherwise → resolve by sAMAccountName +'jdoe' +``` + +**Canonical output:** The provider returns the input IdentityKey as-is in operation results to maintain workflow consistency. + +**Error handling:** On ambiguous or missing identities, the provider throws deterministic errors (no best-effort guessing). + +--- + +## Idempotency Guarantees + +All operations are idempotent and safe for retries: + +| Operation | Idempotent Behavior | +| --------- | ------------------- | +| Create | If identity exists, returns `Changed=$false` (no error) | +| Delete | If identity already gone, returns `Changed=$false` (no error) | +| Move | If already in target OU, returns `Changed=$false` | +| Enable/Disable | If already in desired state, returns `Changed=$false` | +| Grant membership | If already a member, returns `Changed=$false` | +| Revoke membership | If not a member, returns `Changed=$false` | + +This design ensures workflows can be re-run safely without causing duplicate operations or errors. + +--- + +## Entitlement Model + +Active Directory entitlements use: + +- **Kind:** Always `'Group'` (AD limitation - only supports security and distribution groups) +- **Id (canonical key):** DistinguishedName (DN) + +**Input flexibility:** The provider MAY accept SID or sAMAccountName as input but MUST normalize to DN internally. + +**Example:** +```powershell +@{ + Kind = 'Group' + Id = 'CN=IT-Team,OU=Groups,DC=contoso,DC=local' +} +``` + +--- + +## Built-in Steps + +The following built-in steps in `IdLE.Steps.Common` work with the AD provider: + +- **IdLE.Step.CreateIdentity** - Create new user accounts +- **IdLE.Step.DisableIdentity** - Disable user accounts +- **IdLE.Step.EnableIdentity** - Enable user accounts +- **IdLE.Step.MoveIdentity** - Move users between OUs +- **IdLE.Step.DeleteIdentity** - Delete user accounts (requires `IdLE.Identity.Delete` capability) +- **IdLE.Step.EnsureAttribute** - Set/update user attributes +- **IdLE.Step.EnsureEntitlement** - Manage group memberships + +All steps declare `RequiresCapabilities` for plan-time validation. + +--- + +## Example Workflows + +Complete example workflows are available in the repository: + +- **examples/workflows/ad-joiner-complete.psd1** - Full joiner workflow (Create + Attributes + Groups + OU move) +- **examples/workflows/ad-mover-department-change.psd1** - Mover workflow (Update attributes + Group delta + OU move) +- **examples/workflows/ad-leaver-offboarding.psd1** - Leaver workflow (Disable + OU move + conditional Delete) + +--- + +## Provider Aliases + +The provider uses **provider aliases** - the hashtable key in the `Providers` parameter is an alias chosen by the host: + +```powershell +# Single provider scenario +$plan = New-IdlePlan -Providers @{ Identity = $provider } + +# Multi-provider scenario +$plan = New-IdlePlan -Providers @{ + SourceAD = $sourceProvider + TargetAD = $targetProvider +} +``` + +Workflow steps reference the alias via `With.Provider`: + +```powershell +@{ + Type = 'IdLE.Step.CreateIdentity' + With = @{ + Provider = 'SourceAD' # Matches the alias in Providers hashtable + IdentityKey = 'user@contoso.local' + # ... + } +} +``` + +Built-in steps default to `'Identity'` when `With.Provider` is omitted. + +--- + +## Troubleshooting + +### ActiveDirectory Module Not Found + +**Error:** `The specified module 'ActiveDirectory' was not loaded...` + +**Solution:** Install RSAT as described in Prerequisites. + +### Insufficient Permissions + +**Error:** `Insufficient access rights to perform the operation` + +**Solution:** Verify the account has required AD permissions. Use a dedicated service account with least-privilege access. + +### Identity Not Found + +**Error:** `Identity with not found` + +**Solution:** +- Verify the identifier format (GUID/UPN/sAMAccountName) +- Check the user exists in AD +- Ensure the account has read access to the user object + +### Delete Capability Missing + +**Error:** Plan validation fails with `Required capability 'IdLE.Identity.Delete' not available` + +**Solution:** Create the provider with `-AllowDelete` parameter: +```powershell +$provider = New-IdleADIdentityProvider -AllowDelete +``` + +--- + +## Architecture Notes + +### Testability + +The AD provider uses an internal adapter layer (`New-IdleADAdapter`) that isolates AD cmdlet dependencies. This design: + +- Enables unit testing without real AD (unit tests inject fake adapters) +- Keeps provider logic testable and deterministic +- Separates provider contract from AD implementation details + +### Security + +- **No interactive prompts:** The provider never prompts for credentials (violates headless principle) +- **Opt-in Delete:** Delete capability requires explicit `-AllowDelete` for safety +- **Credential handling:** Credentials are passed to AD cmdlets securely via `-Credential` parameter + +### Capability-Driven Design + +The provider implements `GetCapabilities()` and announces all supported capabilities. The engine validates capabilities at plan-time before execution, enabling fail-fast behavior. + +--- + +## Related Documentation + +- [Providers and Contracts](providers-and-contracts.md) - Provider architecture and contracts +- [Steps and Metadata](steps-and-metadata.md) - Built-in steps and capability requirements +- [Provider Capability Rules](../advanced/provider-capabilities.md) - Capability naming and validation +- [Security Model](../advanced/security.md) - Trust boundaries and security considerations diff --git a/docs/reference/steps.md b/docs/reference/steps.md index cb287db..ae8ac50 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -7,6 +7,94 @@ This page documents built-in IdLE steps discovered from `Invoke-IdleStep*` funct --- +## CreateIdentity + +- **Step Name**: `CreateIdentity` +- **Implementation**: `Invoke-IdleStepCreateIdentity` +- **Idempotent**: `Yes` +- **Contracts**: `Unknown` +- **Events**: Unknown + +**Synopsis** + +Creates a new identity in the target system. + +**Description** + +This is a provider-agnostic step. The host must supply a provider instance via +Context.Providers[] that implements CreateIdentity(identityKey, attributes) +and returns an object with properties 'IdentityKey' and 'Changed'. + +The step is idempotent by design: if the identity already exists, the provider +should return Changed = $false without creating a duplicate. + +**Inputs (With.\*)** + +| Key | Required | +| --- | --- | +| IdentityKey | Yes | +| Attributes | Yes | + +--- + +## DeleteIdentity + +- **Step Name**: `DeleteIdentity` +- **Implementation**: `Invoke-IdleStepDeleteIdentity` +- **Idempotent**: `Yes` +- **Contracts**: `Unknown` +- **Events**: Unknown + +**Synopsis** + +Deletes an identity from the target system. + +**Description** + +This is a provider-agnostic step. The host must supply a provider instance via +Context.Providers[] that implements DeleteIdentity(identityKey) +and returns an object with properties 'IdentityKey' and 'Changed'. + +The step is idempotent by design: if the identity is already deleted, the provider +should return Changed = $false. + +IMPORTANT: This step requires the provider to advertise the IdLE.Identity.Delete +capability, which is typically opt-in for safety. The provider must be configured +to allow deletion (e.g., AllowDelete = $true for AD provider). + +**Inputs (With.\*)** + +_Unknown (not detected automatically). Document required With.* keys in the step help and/or use a supported pattern._ + +--- + +## DisableIdentity + +- **Step Name**: `DisableIdentity` +- **Implementation**: `Invoke-IdleStepDisableIdentity` +- **Idempotent**: `Yes` +- **Contracts**: `Unknown` +- **Events**: Unknown + +**Synopsis** + +Disables an identity in the target system. + +**Description** + +This is a provider-agnostic step. The host must supply a provider instance via +Context.Providers[] that implements DisableIdentity(identityKey) +and returns an object with properties 'IdentityKey' and 'Changed'. + +The step is idempotent by design: if the identity is already disabled, the provider +should return Changed = $false. + +**Inputs (With.\*)** + +_Unknown (not detected automatically). Document required With.* keys in the step help and/or use a supported pattern._ + +--- + ## EmitEvent - **Step Name**: `EmitEvent` @@ -31,6 +119,33 @@ _Unknown (not detected automatically). Document required With.* keys in the step --- +## EnableIdentity + +- **Step Name**: `EnableIdentity` +- **Implementation**: `Invoke-IdleStepEnableIdentity` +- **Idempotent**: `Yes` +- **Contracts**: `Unknown` +- **Events**: Unknown + +**Synopsis** + +Enables an identity in the target system. + +**Description** + +This is a provider-agnostic step. The host must supply a provider instance via +Context.Providers[] that implements EnableIdentity(identityKey) +and returns an object with properties 'IdentityKey' and 'Changed'. + +The step is idempotent by design: if the identity is already enabled, the provider +should return Changed = $false. + +**Inputs (With.\*)** + +_Unknown (not detected automatically). Document required With.* keys in the step help and/or use a supported pattern._ + +--- + ## EnsureAttribute - **Step Name**: `EnsureAttribute` @@ -96,3 +211,33 @@ to change. | State | Yes | --- + +## MoveIdentity + +- **Step Name**: `MoveIdentity` +- **Implementation**: `Invoke-IdleStepMoveIdentity` +- **Idempotent**: `Yes` +- **Contracts**: `Unknown` +- **Events**: Unknown + +**Synopsis** + +Moves an identity to a different container/OU in the target system. + +**Description** + +This is a provider-agnostic step. The host must supply a provider instance via +Context.Providers[] that implements MoveIdentity(identityKey, targetContainer) +and returns an object with properties 'IdentityKey' and 'Changed'. + +The step is idempotent by design: if the identity is already in the target container, +the provider should return Changed = $false. + +**Inputs (With.\*)** + +| Key | Required | +| --- | --- | +| IdentityKey | Yes | +| TargetContainer | Yes | + +--- diff --git a/docs/usage/providers.md b/docs/usage/providers.md index 0e8e133..4eb4ac2 100644 --- a/docs/usage/providers.md +++ b/docs/usage/providers.md @@ -15,6 +15,68 @@ Providers typically: Steps should not handle authentication. +## Provider Aliases + +When you supply providers to IdLE, you use a **hashtable** that maps **alias names** to **provider instances**: + +```powershell +$providers = @{ + Identity = $adProvider +} +``` + +### Alias Naming + +The alias name (hashtable key) is **completely flexible** and chosen by you (the host): + +- It can be any valid PowerShell hashtable key +- Common patterns: + - **Role-based**: `Identity`, `Entitlement`, `Messaging` (when you have one provider per role) + - **Instance-based**: `SourceAD`, `TargetEntra`, `ProdForest`, `DevSystem` (when you have multiple providers) +- The built-in steps default to `'Identity'` if no `Provider` is specified in the step's `With` block + +### How Workflows Reference Providers + +Workflow steps can specify which provider to use via the `Provider` key in the `With` block: + +```powershell +@{ + Name = 'Create user in source' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + IdentityKey = 'newuser' + Attributes = @{ ... } + Provider = 'SourceAD' # References the alias from the provider hashtable + } +} +``` + +If `Provider` is not specified, it defaults to `'Identity'`: + +```powershell +# These are equivalent when Provider is not specified: +With = @{ IdentityKey = 'user1'; Name = 'Department'; Value = 'IT' } +With = @{ IdentityKey = 'user1'; Name = 'Department'; Value = 'IT'; Provider = 'Identity' } +``` + +### Multiple Provider Example + +```powershell +# Create provider instances +$sourceAD = New-IdleADIdentityProvider -Credential $sourceCred +$targetEntra = New-IdleEntraIdentityProvider -Credential $targetCred + +# Map to custom aliases +$providers = @{ + SourceAD = $sourceAD + TargetEntra = $targetEntra +} + +# Workflow steps reference the aliases +# Step 1: With = @{ Provider = 'SourceAD'; ... } +# Step 2: With = @{ Provider = 'TargetEntra'; ... } +``` + ## Acquire sessions via host Providers can acquire sessions through a host-provided execution context callback: diff --git a/examples/workflows/ad-joiner-complete.psd1 b/examples/workflows/ad-joiner-complete.psd1 new file mode 100644 index 0000000..91bdd13 --- /dev/null +++ b/examples/workflows/ad-joiner-complete.psd1 @@ -0,0 +1,89 @@ +@{ + Name = 'Joiner - AD Complete Workflow' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Create AD user account' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + IdentityKey = 'newuser' + Attributes = @{ + SamAccountName = 'newuser' + UserPrincipalName = 'newuser@contoso.local' + GivenName = 'New' + Surname = 'User' + DisplayName = 'New User' + Description = 'New employee account' + Path = 'OU=Joiners,OU=Users,DC=contoso,DC=local' + } + # Provider alias - references the key in the provider hashtable. + # The host chooses this name when creating the provider hashtable. + # If omitted, defaults to 'Identity'. + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Identity.Create') + }, + @{ + Name = 'Set Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'newuser@contoso.local' + Name = 'Department' + Value = 'IT' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') + }, + @{ + Name = 'Set Title' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'newuser@contoso.local' + Name = 'Title' + Value = 'Software Engineer' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') + }, + @{ + Name = 'Grant base access group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = 'newuser@contoso.local' + Entitlement = @{ + Kind = 'Group' + Id = 'CN=All-Employees,OU=Groups,DC=contoso,DC=local' + DisplayName = 'All Employees' + } + State = 'Present' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + }, + @{ + Name = 'Grant IT department group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = 'newuser@contoso.local' + Entitlement = @{ + Kind = 'Group' + Id = 'CN=IT-Department,OU=Groups,DC=contoso,DC=local' + DisplayName = 'IT Department' + } + State = 'Present' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + }, + @{ + Name = 'Move to active users OU' + Type = 'IdLE.Step.MoveIdentity' + With = @{ + IdentityKey = 'newuser@contoso.local' + TargetContainer = 'OU=Active,OU=Users,DC=contoso,DC=local' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Identity.Move') + } + ) +} diff --git a/examples/workflows/ad-leaver-offboarding.psd1 b/examples/workflows/ad-leaver-offboarding.psd1 new file mode 100644 index 0000000..7c0969b --- /dev/null +++ b/examples/workflows/ad-leaver-offboarding.psd1 @@ -0,0 +1,52 @@ +@{ + Name = 'Leaver - AD Offboarding Workflow' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Disable user account' + Type = 'IdLE.Step.DisableIdentity' + With = @{ + IdentityKey = 'leavinguser@contoso.local' + # Provider alias references the provider hashtable key set by the host. + # The alias name is flexible and chosen when injecting providers. + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Identity.Disable') + }, + @{ + Name = 'Update Description with termination date' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'leavinguser@contoso.local' + Name = 'Description' + Value = 'Terminated 2026-01-18' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') + }, + @{ + Name = 'Move to Leavers OU' + Type = 'IdLE.Step.MoveIdentity' + With = @{ + IdentityKey = 'leavinguser@contoso.local' + TargetContainer = 'OU=Leavers,OU=Disabled,DC=contoso,DC=local' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Identity.Move') + }, + @{ + Name = 'Delete user account (opt-in required)' + Type = 'IdLE.Step.DeleteIdentity' + With = @{ + IdentityKey = 'leavinguser@contoso.local' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Identity.Delete') + Condition = @{ + Exists = @{ + Path = 'Input.AllowDelete' + } + } + } + ) +} diff --git a/examples/workflows/ad-mover-department-change.psd1 b/examples/workflows/ad-mover-department-change.psd1 new file mode 100644 index 0000000..c684309 --- /dev/null +++ b/examples/workflows/ad-mover-department-change.psd1 @@ -0,0 +1,70 @@ +@{ + Name = 'Mover - AD Department Change Workflow' + LifecycleEvent = 'Mover' + Steps = @( + @{ + Name = 'Update Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'existinguser@contoso.local' + Name = 'Department' + Value = 'Sales' + # Provider alias - can be customized when host creates the provider hashtable. + # Examples: 'Identity', 'SourceAD', 'TargetAD', 'SystemX', etc. + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') + }, + @{ + Name = 'Update Title' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'existinguser@contoso.local' + Name = 'Title' + Value = 'Sales Manager' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Identity.Attribute.Ensure') + }, + @{ + Name = 'Revoke old IT department group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = 'existinguser@contoso.local' + Entitlement = @{ + Kind = 'Group' + Id = 'CN=IT-Department,OU=Groups,DC=contoso,DC=local' + DisplayName = 'IT Department' + } + State = 'Absent' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Revoke') + }, + @{ + Name = 'Grant Sales department group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = 'existinguser@contoso.local' + Entitlement = @{ + Kind = 'Group' + Id = 'CN=Sales-Department,OU=Groups,DC=contoso,DC=local' + DisplayName = 'Sales Department' + } + State = 'Present' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + }, + @{ + Name = 'Move to Sales OU' + Type = 'IdLE.Step.MoveIdentity' + With = @{ + IdentityKey = 'existinguser@contoso.local' + TargetContainer = 'OU=Sales,OU=Users,DC=contoso,DC=local' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Identity.Move') + } + ) +} diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index 1317bb6..d1b3fa9 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -128,5 +128,40 @@ function Get-IdleStepRegistry { } } + if (-not $registry.ContainsKey('IdLE.Step.CreateIdentity')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepCreateIdentity' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.CreateIdentity'] = $handler + } + } + + if (-not $registry.ContainsKey('IdLE.Step.DisableIdentity')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepDisableIdentity' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.DisableIdentity'] = $handler + } + } + + if (-not $registry.ContainsKey('IdLE.Step.EnableIdentity')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepEnableIdentity' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.EnableIdentity'] = $handler + } + } + + if (-not $registry.ContainsKey('IdLE.Step.MoveIdentity')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepMoveIdentity' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.MoveIdentity'] = $handler + } + } + + if (-not $registry.ContainsKey('IdLE.Step.DeleteIdentity')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepDeleteIdentity' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.DeleteIdentity'] = $handler + } + } + return $registry } diff --git a/src/IdLE.Provider.AD/IdLE.Provider.AD.psd1 b/src/IdLE.Provider.AD/IdLE.Provider.AD.psd1 new file mode 100644 index 0000000..7607950 --- /dev/null +++ b/src/IdLE.Provider.AD/IdLE.Provider.AD.psd1 @@ -0,0 +1,22 @@ +@{ + RootModule = 'IdLE.Provider.AD.psm1' + ModuleVersion = '0.8.0' + GUID = '8a7f3c2e-9b4d-4e1a-a8c6-5f9d2b1e3a4c' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'Active Directory (on-prem) provider implementation for IdLE (Windows-only, requires RSAT/ActiveDirectory module).' + PowerShellVersion = '7.0' + + FunctionsToExport = @( + 'New-IdleADIdentityProvider' + ) + + PrivateData = @{ + PSData = @{ + Tags = @('IdentityLifecycleEngine', 'IdLE', 'Provider', 'ActiveDirectory', 'AD') + LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' + ProjectUri = 'https://github.com/blindzero/IdentityLifecycleEngine' + ContactEmail = '13959569+blindzero@users.noreply.github.com' + } + } +} diff --git a/src/IdLE.Provider.AD/IdLE.Provider.AD.psm1 b/src/IdLE.Provider.AD/IdLE.Provider.AD.psm1 new file mode 100644 index 0000000..8bf9dfd --- /dev/null +++ b/src/IdLE.Provider.AD/IdLE.Provider.AD.psm1 @@ -0,0 +1,39 @@ +#requires -Version 7.0 + +Set-StrictMode -Version Latest + +# Validate ActiveDirectory module availability at module load time (best effort, non-blocking) +# The adapter will perform hard validation when instantiated +# Module import will succeed even if ActiveDirectory is not available to allow unit tests and +# cross-platform development. Provider instantiation will fail with clear error if AD module is missing. +if ($PSVersionTable.Platform -eq 'Win32NT' -or $PSVersionTable.Platform -eq 'Windows') { + if (-not (Get-Module -Name ActiveDirectory -ListAvailable)) { + Write-Verbose "IdLE.Provider.AD: ActiveDirectory module not found. The provider will require RSAT/ActiveDirectory at runtime." + } +} + +$PrivatePath = Join-Path -Path $PSScriptRoot -ChildPath 'Private' +if (Test-Path -Path $PrivatePath) { + + # Materialize first to avoid enumeration issues during import. + $privateScripts = @(Get-ChildItem -Path $PrivatePath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $privateScripts) { + . $script.FullName + } +} + +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +if (Test-Path -Path $PublicPath) { + + # Materialize first to avoid enumeration issues during import. + $publicScripts = @(Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $publicScripts) { + . $script.FullName + } +} + +Export-ModuleMember -Function @( + 'New-IdleADIdentityProvider' +) diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 new file mode 100644 index 0000000..7f5dc55 --- /dev/null +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -0,0 +1,427 @@ +function New-IdleADAdapter { + <# + .SYNOPSIS + Creates an internal adapter that wraps Active Directory cmdlets. + + .DESCRIPTION + This adapter provides a testable boundary between the provider and AD cmdlets. + Unit tests can inject a fake adapter without requiring a real AD environment. + + .PARAMETER Credential + Optional PSCredential for AD operations. If not provided, uses integrated auth. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [PSCredential] $Credential + ) + + # Helper function to escape LDAP filter special characters (LDAP injection prevention) + # Uses 'Protect' prefix as 'Escape' is not an approved PowerShell verb + function Protect-LdapFilterValue { + param( + [Parameter(Mandatory)] + [string] $Value + ) + + $escaped = $Value -replace '\\', '\5c' + $escaped = $escaped -replace '\*', '\2a' + $escaped = $escaped -replace '\(', '\28' + $escaped = $escaped -replace '\)', '\29' + $escaped = $escaped -replace "`0", '\00' + return $escaped + } + + $adapter = [pscustomobject]@{ + PSTypeName = 'IdLE.ADAdapter' + Credential = $Credential + } + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Upn + ) + + $escapedUpn = Protect-LdapFilterValue -Value $Upn + $params = @{ + Filter = "UserPrincipalName -eq '$escapedUpn'" + Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName') + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + try { + $user = Get-ADUser @params + return $user + } + catch { + return $null + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserBySam -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SamAccountName + ) + + $escapedSam = Protect-LdapFilterValue -Value $SamAccountName + + $params = @{ + Filter = "sAMAccountName -eq '$escapedSam'" + Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName') + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + try { + $user = Get-ADUser @params + return $user + } + catch { + return $null + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByGuid -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Guid + ) + + $params = @{ + Identity = $Guid + Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName') + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + try { + $user = Get-ADUser @params + return $user + } + catch { + return $null + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name NewUser -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Attributes, + + [Parameter()] + [bool] $Enabled = $true + ) + + $params = @{ + Name = $Name + Enabled = $Enabled + ErrorAction = 'Stop' + } + + if ($Attributes.ContainsKey('SamAccountName')) { + $params['SamAccountName'] = $Attributes['SamAccountName'] + } + if ($Attributes.ContainsKey('UserPrincipalName')) { + $params['UserPrincipalName'] = $Attributes['UserPrincipalName'] + } + if ($Attributes.ContainsKey('Path')) { + $params['Path'] = $Attributes['Path'] + } + if ($Attributes.ContainsKey('GivenName')) { + $params['GivenName'] = $Attributes['GivenName'] + } + if ($Attributes.ContainsKey('Surname')) { + $params['Surname'] = $Attributes['Surname'] + } + if ($Attributes.ContainsKey('DisplayName')) { + $params['DisplayName'] = $Attributes['DisplayName'] + } + if ($Attributes.ContainsKey('Description')) { + $params['Description'] = $Attributes['Description'] + } + if ($Attributes.ContainsKey('Department')) { + $params['Department'] = $Attributes['Department'] + } + if ($Attributes.ContainsKey('Title')) { + $params['Title'] = $Attributes['Title'] + } + if ($Attributes.ContainsKey('EmailAddress')) { + $params['EmailAddress'] = $Attributes['EmailAddress'] + } + + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + $user = New-ADUser @params -PassThru + return $user + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name SetUser -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Identity, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AttributeName, + + [Parameter()] + [AllowNull()] + [object] $Value + ) + + $params = @{ + Identity = $Identity + ErrorAction = 'Stop' + } + + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + switch ($AttributeName) { + 'GivenName' { $params['GivenName'] = $Value } + 'Surname' { $params['Surname'] = $Value } + 'DisplayName' { $params['DisplayName'] = $Value } + 'Description' { $params['Description'] = $Value } + 'Department' { $params['Department'] = $Value } + 'Title' { $params['Title'] = $Value } + 'EmailAddress' { $params['EmailAddress'] = $Value } + 'UserPrincipalName' { $params['UserPrincipalName'] = $Value } + default { + $params['Replace'] = @{ $AttributeName = $Value } + } + } + + Set-ADUser @params + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name DisableUser -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Identity + ) + + $params = @{ + Identity = $Identity + Enabled = $false + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + Set-ADUser @params + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name EnableUser -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Identity + ) + + $params = @{ + Identity = $Identity + Enabled = $true + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + Set-ADUser @params + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name MoveObject -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Identity, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $TargetPath + ) + + $params = @{ + Identity = $Identity + TargetPath = $TargetPath + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + Move-ADObject @params + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name DeleteUser -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Identity + ) + + $params = @{ + Identity = $Identity + Confirm = $false + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + Remove-ADUser @params + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Identity + ) + + $params = @{ + Identity = $Identity + Properties = @('DistinguishedName', 'Name', 'sAMAccountName', 'ObjectGuid') + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + try { + $group = Get-ADGroup @params + return $group + } + catch { + return $null + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name AddGroupMember -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $GroupIdentity, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MemberIdentity + ) + + $params = @{ + Identity = $GroupIdentity + Members = $MemberIdentity + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + Add-ADGroupMember @params + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $GroupIdentity, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MemberIdentity + ) + + $params = @{ + Identity = $GroupIdentity + Members = $MemberIdentity + Confirm = $false + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + Remove-ADGroupMember @params + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserGroups -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Identity + ) + + $params = @{ + Identity = $Identity + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + try { + $groups = Get-ADPrincipalGroupMembership @params + return $groups + } + catch { + return @() + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { + param( + [Parameter()] + [hashtable] $Filter + ) + + $filterString = '*' + if ($null -ne $Filter -and $Filter.ContainsKey('Search') -and -not [string]::IsNullOrWhiteSpace($Filter['Search'])) { + $searchValue = [string] $Filter['Search'] + $escapedSearch = Protect-LdapFilterValue -Value $searchValue + $filterString = "sAMAccountName -like '$escapedSearch*' -or UserPrincipalName -like '$escapedSearch*'" + } + + $params = @{ + Filter = $filterString + Properties = @('ObjectGuid', 'sAMAccountName', 'UserPrincipalName') + ErrorAction = 'Stop' + } + if ($null -ne $this.Credential) { + $params['Credential'] = $this.Credential + } + + try { + $users = Get-ADUser @params + return $users + } + catch { + return @() + } + } -Force + + return $adapter +} diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 new file mode 100644 index 0000000..50d7b13 --- /dev/null +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -0,0 +1,545 @@ +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 + + .PARAMETER Credential + Optional PSCredential for AD operations. If not provided, uses integrated auth (run-as). + + .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 AD adapter without requiring a real Active Directory environment. + + .EXAMPLE + $provider = New-IdleADIdentityProvider + $provider.GetIdentity('user@domain.com') + + .EXAMPLE + $cred = Get-Credential + $provider = New-IdleADIdentityProvider -Credential $cred -AllowDelete $true + $provider.DeleteIdentity('user@domain.com') + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [PSCredential] $Credential, + + [Parameter()] + [switch] $AllowDelete, + + [Parameter()] + [AllowNull()] + [object] $Adapter + ) + + if ($null -eq $Adapter) { + $Adapter = New-IdleADAdapter -Credential $Credential + } + + $convertToEntitlement = { + [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 "Entitlement.Kind must not be empty." + } + if ([string]::IsNullOrWhiteSpace([string]$id)) { + throw "Entitlement.Id must not be empty." + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = [string]$kind + Id = [string]$id + DisplayName = if ($null -eq $displayName -or [string]::IsNullOrWhiteSpace([string]$displayName)) { + $null + } + else { + [string]$displayName + } + } + } + + $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 + ) + + # Try GUID format first (most deterministic) + $guid = [System.Guid]::Empty + if ([System.Guid]::TryParse($IdentityKey, [ref]$guid)) { + try { + $user = $this.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 = $this.Adapter.GetUserByUpn($IdentityKey) + if ($null -ne $user) { + return $user + } + throw "Identity with UPN '$IdentityKey' not found." + } + + # Fallback to sAMAccountName + $user = $this.Adapter.GetUserBySam($IdentityKey) + if ($null -ne $user) { + return $user + } + throw "Identity with sAMAccountName '$IdentityKey' not found." + } + + $normalizeGroupId = { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $GroupId + ) + + $group = $this.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 + } + + $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 + ) + + $user = $this.ResolveIdentity($IdentityKey) + + $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 + ) + + $users = $this.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 + ) + + try { + $existing = $this.ResolveIdentity($IdentityKey) + 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'] + } + + $null = $this.Adapter.NewUser($IdentityKey, $Attributes, $enabled) + + 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 + ) + + if (-not $this.AllowDelete) { + throw "Delete capability is not enabled. Set AllowDelete = `$true when creating the provider." + } + + try { + $user = $this.ResolveIdentity($IdentityKey) + $this.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 + ) + + $user = $this.ResolveIdentity($IdentityKey) + + $currentValue = $null + if ($user.PSObject.Properties.Name -contains $Name) { + $currentValue = $user.$Name + } + + $changed = $false + if ($currentValue -ne $Value) { + $this.Adapter.SetUser($user.DistinguishedName, $Name, $Value) + $changed = $true + } + + 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 + ) + + $user = $this.ResolveIdentity($IdentityKey) + + $currentOu = $user.DistinguishedName -replace '^CN=[^,]+,', '' + + $changed = $false + if ($currentOu -ne $TargetContainer) { + $this.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 + ) + + $user = $this.ResolveIdentity($IdentityKey) + + $changed = $false + if ($user.Enabled -ne $false) { + $this.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 + ) + + $user = $this.ResolveIdentity($IdentityKey) + + $changed = $false + if ($user.Enabled -ne $true) { + $this.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 + ) + + $user = $this.ResolveIdentity($IdentityKey) + $groups = $this.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 + ) + + $normalized = $this.ConvertToEntitlement($Entitlement) + $user = $this.ResolveIdentity($IdentityKey) + $groupDn = $this.NormalizeGroupId($normalized.Id) + + $currentGroups = $this.ListEntitlements($IdentityKey) + $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } + + $changed = $false + if (@($existing).Count -eq 0) { + $this.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 + ) + + $normalized = $this.ConvertToEntitlement($Entitlement) + $user = $this.ResolveIdentity($IdentityKey) + $groupDn = $this.NormalizeGroupId($normalized.Id) + + $currentGroups = $this.ListEntitlements($IdentityKey) + $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } + + $changed = $false + if (@($existing).Count -gt 0) { + $this.Adapter.RemoveGroupMember($groupDn, $user.DistinguishedName) + $changed = $true + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'RevokeEntitlement' + IdentityKey = $IdentityKey + Changed = $changed + Entitlement = $normalized + } + } -Force + + return $provider +} diff --git a/src/IdLE.Provider.AD/README.md b/src/IdLE.Provider.AD/README.md new file mode 100644 index 0000000..16f326b --- /dev/null +++ b/src/IdLE.Provider.AD/README.md @@ -0,0 +1,31 @@ +# IdLE.Provider.AD + +Active Directory (on-premises) provider for IdLE. + +## Quick Start + +```powershell +# Automatically imported when you import IdLE +Import-Module IdLE + +# Create provider +$provider = New-IdleADIdentityProvider + +# Use in workflows +$providers = @{ Identity = $provider } +$plan = New-IdlePlan -WorkflowPath '.\joiner.psd1' -Request $request -Providers $providers +``` + +## Prerequisites + +- **Windows only** (requires RSAT/ActiveDirectory module) +- PowerShell 7.0+ + +## Documentation + +See **[Complete Provider Documentation](../../docs/reference/provider-ad.md)** for: +- Full usage guide and examples +- Capabilities and built-in steps +- Identity resolution and idempotency +- Prerequisites and permissions +- Troubleshooting diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 index 52a44b9..0001cbe 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IdLE.Steps.Common.psm1' - ModuleVersion = '0.7.4' + ModuleVersion = '0.8.0' GUID = '9bdf5e97-0344-4191-82ed-c534bd7cb9b5' Author = 'Matthias Fleschuetz' Copyright = '(c) Matthias Fleschuetz. All rights reserved.' @@ -10,7 +10,12 @@ FunctionsToExport = @( 'Invoke-IdleStepEmitEvent', 'Invoke-IdleStepEnsureAttribute', - 'Invoke-IdleStepEnsureEntitlement' + 'Invoke-IdleStepEnsureEntitlement', + 'Invoke-IdleStepCreateIdentity', + 'Invoke-IdleStepDisableIdentity', + 'Invoke-IdleStepEnableIdentity', + 'Invoke-IdleStepMoveIdentity', + 'Invoke-IdleStepDeleteIdentity' ) PrivateData = @{ diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 index 5e1abbf..90a5012 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -15,5 +15,10 @@ if (Test-Path -Path $PublicPath) { Export-ModuleMember -Function @( 'Invoke-IdleStepEmitEvent', 'Invoke-IdleStepEnsureAttribute', - 'Invoke-IdleStepEnsureEntitlement' + 'Invoke-IdleStepEnsureEntitlement', + 'Invoke-IdleStepCreateIdentity', + 'Invoke-IdleStepDisableIdentity', + 'Invoke-IdleStepEnableIdentity', + 'Invoke-IdleStepMoveIdentity', + 'Invoke-IdleStepDeleteIdentity' ) diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepCreateIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepCreateIdentity.ps1 new file mode 100644 index 0000000..8710d74 --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepCreateIdentity.ps1 @@ -0,0 +1,80 @@ +function Invoke-IdleStepCreateIdentity { + <# + .SYNOPSIS + Creates a new identity in the target system. + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[] that implements CreateIdentity(identityKey, attributes) + and returns an object with properties 'IdentityKey' and 'Changed'. + + The step is idempotent by design: if the identity already exists, the provider + should return Changed = $false without creating a duplicate. + + .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 + - Attributes (required): hashtable of attributes to set on the new identity + - Provider (optional): provider alias, defaults to 'Identity' + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [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 "CreateIdentity requires 'With' to be a hashtable." + } + + foreach ($key in @('IdentityKey', 'Attributes')) { + if (-not $with.ContainsKey($key)) { + throw "CreateIdentity requires With.$key." + } + } + + if (-not ($with.Attributes -is [hashtable])) { + throw "CreateIdentity requires With.Attributes to be a hashtable." + } + + $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] + $result = $provider.CreateIdentity([string]$with.IdentityKey, $with.Attributes) + + $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/src/IdLE.Steps.Common/Public/Invoke-IdleStepDeleteIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDeleteIdentity.ps1 new file mode 100644 index 0000000..3fce4a7 --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDeleteIdentity.ps1 @@ -0,0 +1,77 @@ +function Invoke-IdleStepDeleteIdentity { + <# + .SYNOPSIS + Deletes an identity from the target system. + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[] that implements DeleteIdentity(identityKey) + and returns an object with properties 'IdentityKey' and 'Changed'. + + The step is idempotent by design: if the identity is already deleted, the provider + should return Changed = $false. + + IMPORTANT: This step requires the provider to advertise the IdLE.Identity.Delete + capability, which is typically opt-in for safety. The provider must be configured + to allow deletion (e.g., AllowDelete = $true for AD provider). + + .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' + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [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 "DeleteIdentity requires 'With' to be a hashtable." + } + + if (-not $with.ContainsKey('IdentityKey')) { + throw "DeleteIdentity 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." + } + + $provider = $Context.Providers[$providerAlias] + $result = $provider.DeleteIdentity([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/src/IdLE.Steps.Common/Public/Invoke-IdleStepDisableIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDisableIdentity.ps1 new file mode 100644 index 0000000..5fcdaa9 --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDisableIdentity.ps1 @@ -0,0 +1,73 @@ +function Invoke-IdleStepDisableIdentity { + <# + .SYNOPSIS + Disables an identity in the target system. + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[] that implements DisableIdentity(identityKey) + and returns an object with properties 'IdentityKey' and 'Changed'. + + The step is idempotent by design: if the identity is already disabled, the provider + should return Changed = $false. + + .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' + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [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 "DisableIdentity requires 'With' to be a hashtable." + } + + if (-not $with.ContainsKey('IdentityKey')) { + throw "DisableIdentity 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." + } + + $provider = $Context.Providers[$providerAlias] + $result = $provider.DisableIdentity([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/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnableIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnableIdentity.ps1 new file mode 100644 index 0000000..8a86af9 --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnableIdentity.ps1 @@ -0,0 +1,73 @@ +function Invoke-IdleStepEnableIdentity { + <# + .SYNOPSIS + Enables an identity in the target system. + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[] that implements EnableIdentity(identityKey) + and returns an object with properties 'IdentityKey' and 'Changed'. + + The step is idempotent by design: if the identity is already enabled, the provider + should return Changed = $false. + + .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' + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [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 "EnableIdentity requires 'With' to be a hashtable." + } + + if (-not $with.ContainsKey('IdentityKey')) { + throw "EnableIdentity 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." + } + + $provider = $Context.Providers[$providerAlias] + $result = $provider.EnableIdentity([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/src/IdLE.Steps.Common/Public/Invoke-IdleStepMoveIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepMoveIdentity.ps1 new file mode 100644 index 0000000..7b841fe --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepMoveIdentity.ps1 @@ -0,0 +1,76 @@ +function Invoke-IdleStepMoveIdentity { + <# + .SYNOPSIS + Moves an identity to a different container/OU in the target system. + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[] that implements MoveIdentity(identityKey, targetContainer) + and returns an object with properties 'IdentityKey' and 'Changed'. + + The step is idempotent by design: if the identity is already in the target container, + the provider should return Changed = $false. + + .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 + - TargetContainer (required): the target container/OU DN + - Provider (optional): provider alias, defaults to 'Identity' + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [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 "MoveIdentity requires 'With' to be a hashtable." + } + + foreach ($key in @('IdentityKey', 'TargetContainer')) { + if (-not $with.ContainsKey($key)) { + throw "MoveIdentity requires With.$key." + } + } + + $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] + $result = $provider.MoveIdentity([string]$with.IdentityKey, [string]$with.TargetContainer) + + $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/src/IdLE/IdLE.psd1 b/src/IdLE/IdLE.psd1 index 5248362..a90af1d 100644 --- a/src/IdLE/IdLE.psd1 +++ b/src/IdLE/IdLE.psd1 @@ -9,7 +9,8 @@ NestedModules = @( '..\IdLE.Core\IdLE.Core.psd1', - '..\IdLE.Steps.Common\IdLE.Steps.Common.psd1' + '..\IdLE.Steps.Common\IdLE.Steps.Common.psd1', + '..\IdLE.Provider.AD\IdLE.Provider.AD.psd1' ) FunctionsToExport = @( diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 new file mode 100644 index 0000000..5144ce9 --- /dev/null +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -0,0 +1,616 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path -Path $PSScriptRoot -ChildPath '..\_testHelpers.ps1') + Import-IdleTestModule + + $testsRoot = Split-Path -Path $PSScriptRoot -Parent + $repoRoot = Split-Path -Path $testsRoot -Parent + + $identityContractPath = Join-Path -Path $repoRoot -ChildPath 'tests\ProviderContracts\IdentityProvider.Contract.ps1' + if (-not (Test-Path -LiteralPath $identityContractPath -PathType Leaf)) { + throw "Identity provider contract not found at: $identityContractPath" + } + . $identityContractPath + + $capabilitiesContractPath = Join-Path -Path $repoRoot -ChildPath 'tests\ProviderContracts\ProviderCapabilities.Contract.ps1' + if (-not (Test-Path -LiteralPath $capabilitiesContractPath -PathType Leaf)) { + throw "Provider capabilities contract not found at: $capabilitiesContractPath" + } + . $capabilitiesContractPath + + $entitlementContractPath = Join-Path -Path $repoRoot -ChildPath 'tests\ProviderContracts\EntitlementProvider.Contract.ps1' + if (-not (Test-Path -LiteralPath $entitlementContractPath -PathType Leaf)) { + throw "Entitlement provider contract not found at: $entitlementContractPath" + } + . $entitlementContractPath +} + +Describe 'AD identity provider' { + BeforeAll { + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $adProviderPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.AD\IdLE.Provider.AD.psd1' + + if (Test-Path -LiteralPath $adProviderPath -PathType Leaf) { + Import-Module $adProviderPath -Force + } + + function New-FakeADAdapter { + $store = @{} + + $adapter = [pscustomobject]@{ + PSTypeName = 'FakeADAdapter' + Store = $store + } + + # Auto-creation behavior: The fake adapter auto-creates identities on lookup + # to support provider contract tests (which expect this behavior from test providers). + # This differs from the real AD adapter which will throw when an identity is not found. + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { + param([string]$Upn) + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].UserPrincipalName -eq $Upn) { + return $this.Store[$key] + } + } + return $null + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserBySam -Value { + param([string]$SamAccountName) + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].sAMAccountName -eq $SamAccountName) { + return $this.Store[$key] + } + } + + # Auto-create for test compatibility (like Mock provider) + $guid = [guid]::NewGuid().ToString() + $user = [pscustomobject]@{ + ObjectGuid = [guid]$guid + sAMAccountName = $SamAccountName + UserPrincipalName = "$SamAccountName@domain.local" + DistinguishedName = "CN=$SamAccountName,OU=Users,DC=domain,DC=local" + Enabled = $true + GivenName = $null + Surname = $null + DisplayName = $null + Description = $null + Department = $null + Title = $null + EmailAddress = $null + Groups = @() + } + $this.Store[$guid] = $user + return $user + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByGuid -Value { + param([string]$Guid) + if ($this.Store.ContainsKey($Guid)) { + return $this.Store[$Guid] + } + return $null + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name NewUser -Value { + param([string]$Name, [hashtable]$Attributes, [bool]$Enabled) + + $guid = [guid]::NewGuid().ToString() + $sam = if ($Attributes.ContainsKey('SamAccountName')) { $Attributes['SamAccountName'] } else { $Name } + $upn = if ($Attributes.ContainsKey('UserPrincipalName')) { $Attributes['UserPrincipalName'] } else { "$sam@domain.local" } + $path = if ($Attributes.ContainsKey('Path')) { $Attributes['Path'] } else { 'OU=Users,DC=domain,DC=local' } + + $user = [pscustomobject]@{ + ObjectGuid = [guid]$guid + sAMAccountName = $sam + UserPrincipalName = $upn + DistinguishedName = "CN=$Name,$path" + Enabled = $Enabled + GivenName = $Attributes['GivenName'] + Surname = $Attributes['Surname'] + DisplayName = $Attributes['DisplayName'] + Description = $Attributes['Description'] + Department = $Attributes['Department'] + Title = $Attributes['Title'] + EmailAddress = $Attributes['EmailAddress'] + Groups = @() + } + + $this.Store[$guid] = $user + return $user + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name SetUser -Value { + param([string]$Identity, [string]$AttributeName, $Value) + + $user = $null + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].DistinguishedName -eq $Identity) { + $user = $this.Store[$key] + break + } + } + + if ($null -eq $user) { + throw "User not found: $Identity" + } + + # Handle known properties + $knownProps = @('GivenName', 'Surname', 'DisplayName', 'Description', 'Department', 'Title', 'EmailAddress', 'UserPrincipalName') + if ($AttributeName -in $knownProps -and $null -ne $user.PSObject.Properties[$AttributeName]) { + $user.$AttributeName = $Value + } else { + # Add as a dynamic property if it doesn't exist + if ($null -eq $user.PSObject.Properties[$AttributeName]) { + $user | Add-Member -MemberType NoteProperty -Name $AttributeName -Value $Value -Force + } else { + $user.$AttributeName = $Value + } + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name DisableUser -Value { + param([string]$Identity) + + $user = $null + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].DistinguishedName -eq $Identity) { + $user = $this.Store[$key] + break + } + } + + if ($null -eq $user) { + throw "User not found: $Identity" + } + + $user.Enabled = $false + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name EnableUser -Value { + param([string]$Identity) + + $user = $null + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].DistinguishedName -eq $Identity) { + $user = $this.Store[$key] + break + } + } + + if ($null -eq $user) { + throw "User not found: $Identity" + } + + $user.Enabled = $true + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name MoveObject -Value { + param([string]$Identity, [string]$TargetPath) + + $user = $null + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].DistinguishedName -eq $Identity) { + $user = $this.Store[$key] + break + } + } + + if ($null -eq $user) { + throw "User not found: $Identity" + } + + $cn = $user.DistinguishedName -replace ',.*$', '' + $user.DistinguishedName = "$cn,$TargetPath" + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name DeleteUser -Value { + param([string]$Identity) + + $keyToRemove = $null + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].DistinguishedName -eq $Identity) { + $keyToRemove = $key + break + } + } + + if ($null -ne $keyToRemove) { + $this.Store.Remove($keyToRemove) + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { + param([string]$Identity) + + return [pscustomobject]@{ + DistinguishedName = $Identity + Name = ($Identity -split ',')[0] -replace '^CN=', '' + sAMAccountName = ($Identity -split ',')[0] -replace '^CN=', '' + ObjectGuid = [guid]::NewGuid() + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name AddGroupMember -Value { + param([string]$GroupIdentity, [string]$MemberIdentity) + + $user = $null + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].DistinguishedName -eq $MemberIdentity) { + $user = $this.Store[$key] + break + } + } + + if ($null -eq $user) { + throw "User not found: $MemberIdentity" + } + + if ($null -eq $user.Groups) { + $user.Groups = @() + } + + # Store as object with metadata for entitlement tracking + $existingGroup = $user.Groups | Where-Object { $_.Id -eq $GroupIdentity } + if ($null -eq $existingGroup) { + $user.Groups = @($user.Groups) + @([pscustomobject]@{ Id = $GroupIdentity; Kind = 'Group' }) + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { + param([string]$GroupIdentity, [string]$MemberIdentity) + + $user = $null + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].DistinguishedName -eq $MemberIdentity) { + $user = $this.Store[$key] + break + } + } + + if ($null -eq $user) { + throw "User not found: $MemberIdentity" + } + + if ($null -ne $user.Groups) { + $user.Groups = @($user.Groups | Where-Object { $_.Id -ne $GroupIdentity }) + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserGroups -Value { + param([string]$Identity) + + $user = $null + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].DistinguishedName -eq $Identity) { + $user = $this.Store[$key] + break + } + } + + if ($null -eq $user) { + throw "User not found: $Identity" + } + + $groups = @() + if ($null -ne $user.Groups) { + foreach ($groupEntry in $user.Groups) { + $groupDn = if ($groupEntry -is [string]) { $groupEntry } else { $groupEntry.Id } + $groups += [pscustomobject]@{ + DistinguishedName = $groupDn + Name = ($groupDn -split ',')[0] -replace '^CN=', '' + } + } + } + return $groups + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { + param([hashtable]$Filter) + + $results = @() + foreach ($key in $this.Store.Keys) { + $user = $this.Store[$key] + + if ($null -ne $Filter -and $Filter.ContainsKey('Search')) { + $search = $Filter['Search'] + if ($user.sAMAccountName -like "$search*" -or $user.UserPrincipalName -like "$search*") { + $results += $user + } + } + else { + $results += $user + } + } + return $results + } -Force + + return $adapter + } + + $script:FakeAdapter = New-FakeADAdapter + } + + Context 'Provider contract tests' { + Invoke-IdleIdentityProviderContractTests -NewProvider { + New-IdleADIdentityProvider -Adapter $script:FakeAdapter + } + + Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { + New-IdleADIdentityProvider -Adapter $script:FakeAdapter + } + + # Note: Generic entitlement contract tests are skipped for AD provider because: + # - AD only supports Kind='Group' (not arbitrary entitlement kinds like 'Contract') + # - Generic contract tests use Kind='Contract' which doesn't match AD's behavior + # - AD-specific entitlement tests with Kind='Group' are in the 'Idempotency' context below + } + + Context 'AD-specific entitlement operations' { + BeforeAll { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + $script:TestProvider = $provider + $script:TestAdapter = $adapter + } + + It 'Exposes required entitlement methods' { + $script:TestProvider.PSObject.Methods.Name | Should -Contain 'ListEntitlements' + $script:TestProvider.PSObject.Methods.Name | Should -Contain 'GrantEntitlement' + $script:TestProvider.PSObject.Methods.Name | Should -Contain 'RevokeEntitlement' + } + + It 'GrantEntitlement returns stable result shape with Kind=Group' { + $testUser = $script:TestAdapter.NewUser('EntTest1', @{ SamAccountName = 'enttest1' }, $true) + $id = $testUser.ObjectGuid.ToString() + + $entitlement = @{ + Kind = 'Group' + Id = 'CN=TestGroup,OU=Groups,DC=domain,DC=local' + DisplayName = 'Test Group' + } + + $result = $script:TestProvider.GrantEntitlement($id, $entitlement) + + $result | Should -Not -BeNullOrEmpty + $result.PSObject.Properties.Name | Should -Contain 'Changed' + $result.PSObject.Properties.Name | Should -Contain 'IdentityKey' + $result.PSObject.Properties.Name | Should -Contain 'Entitlement' + $result.Changed | Should -BeOfType [bool] + $result.Entitlement.Kind | Should -Be 'Group' + } + + It 'GrantEntitlement is idempotent with Kind=Group' { + $testUser = $script:TestAdapter.NewUser('EntTest2', @{ SamAccountName = 'enttest2' }, $true) + $id = $testUser.ObjectGuid.ToString() + + $entitlement = @{ + Kind = 'Group' + Id = 'CN=IdempotentGroup,OU=Groups,DC=domain,DC=local' + } + + $r1 = $script:TestProvider.GrantEntitlement($id, $entitlement) + $r2 = $script:TestProvider.GrantEntitlement($id, $entitlement) + + $r1.Changed | Should -BeTrue + $r2.Changed | Should -BeFalse + } + + It 'RevokeEntitlement is idempotent with Kind=Group' { + $testUser = $script:TestAdapter.NewUser('EntTest3', @{ SamAccountName = 'enttest3' }, $true) + $id = $testUser.ObjectGuid.ToString() + + $entitlement = @{ + Kind = 'Group' + Id = 'CN=RevokeGroup,OU=Groups,DC=domain,DC=local' + } + + $script:TestProvider.GrantEntitlement($id, $entitlement) | Out-Null + + $r1 = $script:TestProvider.RevokeEntitlement($id, $entitlement) + $r2 = $script:TestProvider.RevokeEntitlement($id, $entitlement) + + $r1.Changed | Should -BeTrue + $r2.Changed | Should -BeFalse + } + + It 'ListEntitlements reflects grant and revoke operations with Kind=Group' { + $testUser = $script:TestAdapter.NewUser('EntTest4', @{ SamAccountName = 'enttest4' }, $true) + $id = $testUser.ObjectGuid.ToString() + + $entitlement = @{ + Kind = 'Group' + Id = 'CN=ListTestGroup,OU=Groups,DC=domain,DC=local' + } + + $before = @($script:TestProvider.ListEntitlements($id)) + + $script:TestProvider.GrantEntitlement($id, $entitlement) | Out-Null + $afterGrant = @($script:TestProvider.ListEntitlements($id)) + + $script:TestProvider.RevokeEntitlement($id, $entitlement) | Out-Null + $afterRevoke = @($script:TestProvider.ListEntitlements($id)) + + @($afterGrant | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $entitlement.Id }).Count | Should -Be 1 + @($afterRevoke | Where-Object { $_.Kind -eq 'Group' -and $_.Id -eq $entitlement.Id }).Count | Should -Be 0 + } + } + + Context 'Identity resolution' { + BeforeAll { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + + $testUser = $adapter.NewUser('TestUser', @{ + SamAccountName = 'testuser' + UserPrincipalName = 'testuser@domain.local' + GivenName = 'Test' + Surname = 'User' + }, $true) + + $script:TestProvider = $provider + $script:TestGuid = $testUser.ObjectGuid.ToString() + $script:TestUpn = $testUser.UserPrincipalName + $script:TestSam = $testUser.sAMAccountName + } + + It 'Resolves identity by GUID' { + $identity = $script:TestProvider.GetIdentity($script:TestGuid) + $identity.IdentityKey | Should -Be $script:TestGuid + $identity.Attributes['sAMAccountName'] | Should -Be $script:TestSam + } + + It 'Resolves identity by UPN' { + $identity = $script:TestProvider.GetIdentity($script:TestUpn) + $identity.IdentityKey | Should -Be $script:TestUpn + $identity.Attributes['UserPrincipalName'] | Should -Be $script:TestUpn + } + + It 'Resolves identity by sAMAccountName' { + $identity = $script:TestProvider.GetIdentity($script:TestSam) + $identity.IdentityKey | Should -Be $script:TestSam + $identity.Attributes['sAMAccountName'] | Should -Be $script:TestSam + } + + It 'Returns identity for nonexistent user (auto-creates in test adapter)' { + $identity = $script:TestProvider.GetIdentity('nonexistent-auto') + $identity | Should -Not -BeNullOrEmpty + $identity.IdentityKey | Should -Be 'nonexistent-auto' + } + } + + Context 'Idempotency' { + BeforeEach { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter -AllowDelete + $script:TestProvider = $provider + $script:TestAdapter = $adapter + } + + It 'CreateIdentity is idempotent - returns Changed=$false if identity exists' { + $attrs = @{ + SamAccountName = 'idempotent1' + UserPrincipalName = 'idempotent1@domain.local' + GivenName = 'Test' + Surname = 'User' + } + + # Pre-create the user using the adapter + $script:TestAdapter.NewUser('idempotent1', $attrs, $true) | Out-Null + + # Now create should be idempotent + $result1 = $script:TestProvider.CreateIdentity('idempotent1', $attrs) + $result1.Changed | Should -BeFalse + + $result2 = $script:TestProvider.CreateIdentity('idempotent1', $attrs) + $result2.Changed | Should -BeFalse + } + + It 'DisableIdentity is idempotent' { + $testUser = $script:TestAdapter.NewUser('DisableTest', @{ SamAccountName = 'distest' }, $true) + $guid = $testUser.ObjectGuid.ToString() + + $result1 = $script:TestProvider.DisableIdentity($guid) + $result1.Changed | Should -BeTrue + + $result2 = $script:TestProvider.DisableIdentity($guid) + $result2.Changed | Should -BeFalse + } + + It 'EnableIdentity is idempotent' { + $testUser = $script:TestAdapter.NewUser('EnableTest', @{ SamAccountName = 'entest' }, $false) + $guid = $testUser.ObjectGuid.ToString() + + $result1 = $script:TestProvider.EnableIdentity($guid) + $result1.Changed | Should -BeTrue + + $result2 = $script:TestProvider.EnableIdentity($guid) + $result2.Changed | Should -BeFalse + } + + It 'MoveIdentity is idempotent' { + $testUser = $script:TestAdapter.NewUser('MoveTest', @{ + SamAccountName = 'movetest' + Path = 'OU=Source,DC=domain,DC=local' + }, $true) + $guid = $testUser.ObjectGuid.ToString() + + $targetOu = 'OU=Target,DC=domain,DC=local' + + $result1 = $script:TestProvider.MoveIdentity($guid, $targetOu) + $result1.Changed | Should -BeTrue + + $result2 = $script:TestProvider.MoveIdentity($guid, $targetOu) + $result2.Changed | Should -BeFalse + } + + It 'DeleteIdentity is idempotent - returns Changed=$false if already deleted' { + $testUser = $script:TestAdapter.NewUser('DeleteTest', @{ SamAccountName = 'deltest' }, $true) + $guid = $testUser.ObjectGuid.ToString() + + $result1 = $script:TestProvider.DeleteIdentity($guid) + $result1.Changed | Should -BeTrue + + $result2 = $script:TestProvider.DeleteIdentity($guid) + $result2.Changed | Should -BeFalse + } + + It 'GrantEntitlement is idempotent' { + $testUser = $script:TestAdapter.NewUser('GrantTest', @{ SamAccountName = 'granttest' }, $true) + $guid = $testUser.ObjectGuid.ToString() + + $entitlement = @{ Kind = 'Group'; Id = 'CN=TestGroup,OU=Groups,DC=domain,DC=local' } + + $result1 = $script:TestProvider.GrantEntitlement($guid, $entitlement) + $result1.Changed | Should -BeTrue + + $result2 = $script:TestProvider.GrantEntitlement($guid, $entitlement) + $result2.Changed | Should -BeFalse + } + + It 'RevokeEntitlement is idempotent' { + $testUser = $script:TestAdapter.NewUser('RevokeTest', @{ SamAccountName = 'revoketest' }, $true) + $guid = $testUser.ObjectGuid.ToString() + + $entitlement = @{ Kind = 'Group'; Id = 'CN=TestGroup,OU=Groups,DC=domain,DC=local' } + + $script:TestProvider.GrantEntitlement($guid, $entitlement) | Out-Null + + $result1 = $script:TestProvider.RevokeEntitlement($guid, $entitlement) + $result1.Changed | Should -BeTrue + + $result2 = $script:TestProvider.RevokeEntitlement($guid, $entitlement) + $result2.Changed | Should -BeFalse + } + } + + Context 'AllowDelete gating' { + It 'Advertises Delete capability when AllowDelete=$true' { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter -AllowDelete + + $caps = $provider.GetCapabilities() + $caps | Should -Contain 'IdLE.Identity.Delete' + } + + It 'Does not advertise Delete capability when AllowDelete=$false' { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + + $caps = $provider.GetCapabilities() + $caps | Should -Not -Contain 'IdLE.Identity.Delete' + } + + It 'Throws when DeleteIdentity is called without AllowDelete' { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + + $testUser = $adapter.NewUser('DeleteGateTest', @{ SamAccountName = 'delgate' }, $true) + $guid = $testUser.ObjectGuid.ToString() + + { $provider.DeleteIdentity($guid) } | Should -Throw -ExpectedMessage '*AllowDelete*' + } + } +}