diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 73bd5cb..ac7be07 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -20,7 +20,8 @@ - [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) +- [Active Directory Provider](reference/providers/provider-ad.md) +- [Entra ID Provider](reference/providers/provider-entraID.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/providers/provider-ad.md similarity index 100% rename from docs/reference/provider-ad.md rename to docs/reference/providers/provider-ad.md diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md new file mode 100644 index 0000000..3095fca --- /dev/null +++ b/docs/reference/providers/provider-entraID.md @@ -0,0 +1,346 @@ +# IdLE.Provider.EntraID Reference + +Microsoft Entra ID (formerly Azure Active Directory) identity provider for IdLE. + +## Overview + +The `IdLE.Provider.EntraID` module provides a production-ready provider for managing identities and group entitlements in Microsoft Entra ID via the Microsoft Graph API (v1.0). + +## Installation + +The provider is included in the IdLE repository under `src/IdLE.Provider.EntraID/`. + +```powershell +Import-Module ./src/IdLE.Provider.EntraID/IdLE.Provider.EntraID.psd1 +``` + +## Authentication + +### Host-Owned Authentication (Required Pattern) + +The EntraID provider follows IdLE's **host-owned authentication** pattern. The provider does NOT perform authentication internally. Instead, authentication is managed by the host application via the `AuthSessionBroker`. + +### What the Host Must Provide + +The host must: + +1. Obtain a valid Microsoft Graph access token (delegated or app-only) +2. Create an `AuthSessionBroker` that returns the token when requested +3. Pass the broker to IdLE via `Providers.AuthSessionBroker` + +### Supported Auth Session Formats + +The provider accepts authentication sessions in these formats: + +- **String**: Direct access token (`"eyJ0eXAiOiJKV1Qi..."`) +- **Object with AccessToken property**: `@{ AccessToken = "token" }` +- **Object with GetAccessToken() method**: Custom object with method returning token string +- **PSCredential**: Token in password field (legacy compatibility) + +### Example: Delegated Authentication + +```powershell +# Host obtains token (example using Azure PowerShell) +Connect-AzAccount +$token = (Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com").Token + +# Create broker +$broker = New-IdleAuthSessionBroker -SessionMap @{ + @{} = $token +} -DefaultCredential $token + +# Create provider +$provider = New-IdleEntraIDIdentityProvider + +# Use in plan +$plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + Identity = $provider + AuthSessionBroker = $broker +} +``` + +### Example: App-Only Authentication (Service Principal) + +```powershell +# Host obtains app-only token (example using MSAL or Azure PowerShell) +$clientId = "your-app-id" +$clientSecret = "your-secret" +$tenantId = "your-tenant-id" + +# Obtain token (pseudo-code - use your preferred auth library) +$token = Get-GraphAppOnlyToken -ClientId $clientId -ClientSecret $clientSecret -TenantId $tenantId + +# Create broker +$broker = New-IdleAuthSessionBroker -SessionMap @{ + @{} = $token +} -DefaultCredential $token + +# Rest is identical to delegated flow +``` + +### Example: Multi-Role Scenario + +```powershell +$tier0Token = Get-GraphToken -Role 'Tier0' +$adminToken = Get-GraphToken -Role 'Admin' + +$broker = New-IdleAuthSessionBroker -SessionMap @{ + @{ Role = 'Tier0' } = $tier0Token + @{ Role = 'Admin' } = $adminToken +} -DefaultCredential $adminToken + +# Workflow steps specify: With.AuthSessionOptions = @{ Role = 'Tier0' } +``` + +## Required Microsoft Graph Permissions + +### Delegated Permissions (User Context) + +Minimum required: + +- `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) + +### 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. + +## Capabilities + +The provider advertises these capabilities via `GetCapabilities()`: + +- `IdLE.Identity.Read` - Read identity information +- `IdLE.Identity.List` - List identities (filter support varies) +- `IdLE.Identity.Create` - Create new identities +- `IdLE.Identity.Attribute.Ensure` - Set/update identity attributes +- `IdLE.Identity.Disable` - Disable user accounts +- `IdLE.Identity.Enable` - Enable user accounts +- `IdLE.Entitlement.List` - List group memberships +- `IdLE.Entitlement.Grant` - Add group membership +- `IdLE.Entitlement.Revoke` - Remove group membership +- `IdLE.Identity.Delete` - **Opt-in only** (see Safety section) + +## Identity Addressing + +### Lookup Modes + +The provider supports multiple ways to reference an identity: + +| Format | Example | Notes | +|--------|---------|-------| +| objectId (GUID) | `"a1b2c3d4-e5f6-7890-abcd-ef1234567890"` | Most deterministic | +| UserPrincipalName (UPN) | `"user@contoso.com"` | Contains `@` | +| mail | `"user.name@contoso.com"` | Fallback if UPN lookup fails | + +### Canonical Identity Key + +**All provider methods return the user objectId (GUID) as the canonical IdentityKey.** + +This ensures deterministic identity references across workflows and is the recommended format for workflow definitions. + +### Resolution Rules + +1. If the input is a valid GUID format, look up by objectId +2. If the input contains `@`, try UPN lookup, then mail lookup +3. Otherwise, throw an error + +## Entitlement Model (Groups) + +### Entitlement Object Format + +```powershell +@{ + Kind = 'Group' + Id = '' # Canonical + DisplayName = 'Group Display Name' # Optional + Mail = 'group@contoso.com' # Optional +} +``` + +### Group Resolution + +The provider accepts group references in two formats: + +1. **objectId (GUID)** - Direct lookup (most reliable) +2. **displayName** - Lookup by name (must be unique) + +#### Ambiguity Handling + +If multiple groups share the same displayName, the provider throws an error. Use objectId for deterministic lookup. + +### Idempotency + +All group operations are idempotent: + +- **Grant**: Returns `Changed = $false` if already a member +- **Revoke**: Returns `Changed = $false` if not a member + +## Safety: Delete Capability + +### Default Behavior (Delete Disabled) + +By default, the `IdLE.Identity.Delete` capability is **NOT advertised** and delete operations will fail. + +```powershell +$provider = New-IdleEntraIDIdentityProvider +# Delete is NOT available +``` + +### Opt-In for Delete + +To enable delete capability, use the `-AllowDelete` switch: + +```powershell +$provider = New-IdleEntraIDIdentityProvider -AllowDelete +# Delete is now available +``` + +### Workflow Requirements + +Workflows that require delete must explicitly declare the capability requirement in their metadata (not yet implemented in IdLE core, but provider is ready). + +## Transient Error Handling + +The provider classifies errors as transient or permanent for retry policy support. + +### Transient Errors (Retryable) + +These errors set `Exception.Data['Idle.IsTransient'] = $true`: + +- HTTP 429 (Rate limiting) +- HTTP 5xx (Server errors) +- HTTP 408 (Request timeout) +- Network timeouts + +### Retry Metadata + +Transient errors include metadata in the exception message: + +- HTTP status code +- Microsoft Graph request ID (if available) +- `Retry-After` header (if present) + +**Note**: The provider does NOT perform retries automatically. Retry policy is a host concern. + +## Supported Attributes + +### Identity Attributes + +These attributes can be set via `CreateIdentity` and `EnsureAttribute`: + +| Attribute | Graph Property | Notes | +|-----------|---------------|-------| +| `GivenName` | `givenName` | First name | +| `Surname` | `surname` | Last name | +| `DisplayName` | `displayName` | Display name (required for create) | +| `UserPrincipalName` | `userPrincipalName` | UPN (required for create) | +| `Mail` | `mail` | Email address | +| `Department` | `department` | Department | +| `JobTitle` | `jobTitle` | Job title | +| `OfficeLocation` | `officeLocation` | Office location | +| `CompanyName` | `companyName` | Company name | +| `MailNickname` | `mailNickname` | Mail alias (auto-generated if not provided) | +| `PasswordProfile` | `passwordProfile` | Password policy for new users | +| `Enabled` | `accountEnabled` | Account enabled state | + +### Password Policy (Create Only) + +When creating users, provide a `PasswordProfile`: + +```powershell +$attributes = @{ + UserPrincipalName = 'newuser@contoso.com' + DisplayName = 'New User' + PasswordProfile = @{ + forceChangePasswordNextSignIn = $true + password = 'Temp@Pass123!' + } +} +``` + +If not provided, a random password is generated with `forceChangePasswordNextSignIn = $true`. + +## Paging + +The provider automatically handles Microsoft Graph paging for `ListUsers` and `ListUserGroups` operations using the `@odata.nextLink` continuation token. + +No additional configuration required. + +## Built-in Steps Compatibility + +The provider works with these built-in IdLE steps: + +- `IdLE.Step.CreateIdentity` +- `IdLE.Step.EnsureAttribute` +- `IdLE.Step.DisableIdentity` +- `IdLE.Step.EnableIdentity` +- `IdLE.Step.DeleteIdentity` (when `AllowDelete = $true`) +- `IdLE.Step.EnsureEntitlement` + +## Workflow Configuration + +### Recommended AuthSession Routing + +- `With.AuthSessionName = 'MicrosoftGraph'` +- `With.AuthSessionOptions = @{ Role = 'Admin' }` (or other routing keys) + +### Example Step Definition + +```powershell +@{ + Id = 'CreateUser' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + Attributes = @{ + UserPrincipalName = 'newuser@contoso.com' + DisplayName = 'New User' + GivenName = 'New' + Surname = 'User' + } + } +} +``` + +## Limitations + +- **Supported API version**: v1.0 (beta endpoints not used) +- **Group types**: Only Entra ID groups (not M365 groups or distribution lists) +- **Licensing**: The provider does NOT manage license assignments +- **MFA/Conditional Access**: Not managed by provider +- **Custom attributes/extensions**: Not supported in MVP + +## Troubleshooting + +### "AuthSession is required" + +Ensure you're passing an `AuthSessionBroker` to `New-IdlePlan` and that steps are using `With.AuthSessionName`. + +### "Multiple groups found with displayName" + +Use the group objectId instead of displayName for deterministic lookup. + +### "429 Too Many Requests" + +Microsoft Graph enforces rate limits. The provider marks these as transient errors. Implement retry logic in your host or reduce request frequency. + +### "Insufficient permissions" + +Verify the access token has the required Graph API permissions (see Required Permissions section). + +## See Also + +- [IdLE Architecture](../advanced/architecture.md) +- [AuthSessionBroker Pattern](../advanced/security.md) +- [Example Workflows](../../examples/workflows/) +- [Invoke-IdleDemo.ps1](../../examples/Invoke-IdleDemo.ps1) diff --git a/examples/workflows/entraid-joiner-complete.psd1 b/examples/workflows/entraid-joiner-complete.psd1 new file mode 100644 index 0000000..757b4e1 --- /dev/null +++ b/examples/workflows/entraid-joiner-complete.psd1 @@ -0,0 +1,85 @@ +@{ + Name = 'EntraID Joiner - Complete Onboarding' + LifecycleEvent = 'Joiner' + Description = 'Creates a new Entra ID user account with attributes and group memberships.' + Steps = @( + @{ + Name = 'CreateEntraIDUser' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + Attributes = @{ + UserPrincipalName = '{{Request.Input.UserPrincipalName}}' + DisplayName = '{{Request.Input.DisplayName}}' + GivenName = '{{Request.Input.GivenName}}' + Surname = '{{Request.Input.Surname}}' + Mail = '{{Request.Input.Mail}}' + Department = '{{Request.Input.Department}}' + JobTitle = '{{Request.Input.JobTitle}}' + OfficeLocation = '{{Request.Input.OfficeLocation}}' + CompanyName = 'Contoso Ltd' + PasswordProfile = @{ + forceChangePasswordNextSignIn = $true + password = '{{Request.Input.TemporaryPassword}}' + } + } + } + } + @{ + Name = 'AddToBaseGroups' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserPrincipalName}}' + Desired = @( + @{ + Kind = 'Group' + Id = 'all-employees-group-id' + DisplayName = 'All Employees' + } + @{ + Kind = 'Group' + Id = '{{Request.Input.DepartmentGroupId}}' + DisplayName = '{{Request.Input.DepartmentName}}' + } + ) + } + } + @{ + Name = 'SetManagerAttribute' + Type = 'IdLE.Step.EnsureAttribute' + Condition = @{ + All = @( + @{ + Exists = 'Request.Input.ManagerId' + } + ) + } + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserPrincipalName}}' + Name = 'Manager' + Value = '{{Request.Input.ManagerId}}' + } + } + @{ + Name = 'EnableAccount' + Type = 'IdLE.Step.EnableIdentity' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserPrincipalName}}' + } + } + @{ + Name = 'EmitCompletionEvent' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'EntraID user {{Request.Input.UserPrincipalName}} created and configured successfully.' + } + } + ) +} diff --git a/examples/workflows/entraid-leaver-offboarding.psd1 b/examples/workflows/entraid-leaver-offboarding.psd1 new file mode 100644 index 0000000..dcfc9bf --- /dev/null +++ b/examples/workflows/entraid-leaver-offboarding.psd1 @@ -0,0 +1,74 @@ +@{ + Name = 'EntraID Leaver - Offboarding with Optional Delete' + LifecycleEvent = 'Leaver' + Description = 'Disables user account and optionally deletes (requires AllowDelete provider flag).' + Steps = @( + @{ + Name = 'RevokeAllGroupMemberships' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + Desired = @() + } + } + @{ + Name = 'ClearManager' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + Name = 'Manager' + Value = $null + } + } + @{ + Name = 'UpdateDisplayNameWithLeaver' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + Name = 'DisplayName' + Value = '{{Request.Input.DisplayName}} (LEAVER)' + } + } + @{ + Name = 'DisableAccount' + Type = 'IdLE.Step.DisableIdentity' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + } + } + @{ + Name = 'DeleteAccountAfterRetention' + Type = 'IdLE.Step.DeleteIdentity' + Condition = @{ + All = @( + @{ + Equals = @{ + Path = 'Request.Input.DeleteAfterDisable' + Value = $true + } + } + ) + } + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Tier0' } + IdentityKey = '{{Request.Input.UserObjectId}}' + } + } + @{ + Name = 'EmitCompletionEvent' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'EntraID user {{Request.Input.UserObjectId}} offboarding completed.' + } + } + ) +} diff --git a/examples/workflows/entraid-mover-department-change.psd1 b/examples/workflows/entraid-mover-department-change.psd1 new file mode 100644 index 0000000..004a340 --- /dev/null +++ b/examples/workflows/entraid-mover-department-change.psd1 @@ -0,0 +1,102 @@ +@{ + Name = 'EntraID Mover - Department Change' + LifecycleEvent = 'Mover' + Description = 'Updates user attributes and group memberships when user moves to new department.' + Steps = @( + @{ + Name = 'UpdateDepartmentAttributes' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + Name = 'Department' + Value = '{{Request.Input.NewDepartment}}' + } + } + @{ + Name = 'UpdateJobTitle' + Type = 'IdLE.Step.EnsureAttribute' + Condition = @{ + All = @( + @{ + Exists = 'Request.Input.NewJobTitle' + } + ) + } + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + Name = 'JobTitle' + Value = '{{Request.Input.NewJobTitle}}' + } + } + @{ + Name = 'UpdateOfficeLocation' + Type = 'IdLE.Step.EnsureAttribute' + Condition = @{ + All = @( + @{ + Exists = 'Request.Input.NewOfficeLocation' + } + ) + } + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + Name = 'OfficeLocation' + Value = '{{Request.Input.NewOfficeLocation}}' + } + } + @{ + Name = 'UpdateGroupMemberships' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + Desired = @( + @{ + Kind = 'Group' + Id = '{{Request.Input.NewDepartmentGroupId}}' + DisplayName = '{{Request.Input.NewDepartment}}' + } + ) + Remove = @( + @{ + Kind = 'Group' + Id = '{{Request.Input.OldDepartmentGroupId}}' + DisplayName = '{{Request.Input.OldDepartment}}' + } + ) + } + } + @{ + Name = 'UpdateManager' + Type = 'IdLE.Step.EnsureAttribute' + Condition = @{ + All = @( + @{ + Exists = 'Request.Input.NewManagerId' + } + ) + } + With = @{ + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + Name = 'Manager' + Value = '{{Request.Input.NewManagerId}}' + } + } + @{ + Name = 'EmitCompletionEvent' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'EntraID user {{Request.Input.UserObjectId}} moved to new department successfully.' + } + } + ) +} diff --git a/src/IdLE.Provider.AD/README.md b/src/IdLE.Provider.AD/README.md index 16f326b..a18f0f7 100644 --- a/src/IdLE.Provider.AD/README.md +++ b/src/IdLE.Provider.AD/README.md @@ -23,7 +23,7 @@ $plan = New-IdlePlan -WorkflowPath '.\joiner.psd1' -Request $request -Providers ## Documentation -See **[Complete Provider Documentation](../../docs/reference/provider-ad.md)** for: +See **[Complete Provider Documentation](../../docs/reference/providers/provider-ad.md)** for: - Full usage guide and examples - Capabilities and built-in steps - Identity resolution and idempotency diff --git a/src/IdLE.Provider.EntraID/IdLE.Provider.EntraID.psd1 b/src/IdLE.Provider.EntraID/IdLE.Provider.EntraID.psd1 new file mode 100644 index 0000000..a1fa849 --- /dev/null +++ b/src/IdLE.Provider.EntraID/IdLE.Provider.EntraID.psd1 @@ -0,0 +1,22 @@ +@{ + RootModule = 'IdLE.Provider.EntraID.psm1' + ModuleVersion = '0.8.0' + GUID = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'Microsoft Entra ID (Azure AD) provider implementation for IdLE using Microsoft Graph API.' + PowerShellVersion = '7.0' + + FunctionsToExport = @( + 'New-IdleEntraIDIdentityProvider' + ) + + PrivateData = @{ + PSData = @{ + Tags = @('IdentityLifecycleEngine', 'IdLE', 'Provider', 'EntraID', 'AzureAD', 'MicrosoftGraph') + 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.EntraID/IdLE.Provider.EntraID.psm1 b/src/IdLE.Provider.EntraID/IdLE.Provider.EntraID.psm1 new file mode 100644 index 0000000..d5b99d8 --- /dev/null +++ b/src/IdLE.Provider.EntraID/IdLE.Provider.EntraID.psm1 @@ -0,0 +1,33 @@ +#requires -Version 7.0 + +Set-StrictMode -Version Latest + +# Note: Microsoft Graph API access is provided by the host via AuthSessionBroker. +# No external module dependencies are required at module load time. +# The provider uses direct REST API calls to Microsoft Graph v1.0 endpoints. + +$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-IdleEntraIDIdentityProvider' +) diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 new file mode 100644 index 0000000..0dbb4fe --- /dev/null +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -0,0 +1,433 @@ +function New-IdleEntraIDAdapter { + <# + .SYNOPSIS + Creates an internal adapter that wraps Microsoft Graph API operations. + + .DESCRIPTION + This adapter provides a testable boundary between the provider and Graph API REST calls. + Unit tests can inject a fake adapter without requiring a real Entra ID environment. + + The adapter uses direct REST calls to Microsoft Graph v1.0 endpoints for maximum portability. + + .PARAMETER BaseUri + Base URI for Microsoft Graph API. Defaults to https://graph.microsoft.com/v1.0 + #> + [CmdletBinding()] + param( + [Parameter()] + [string] $BaseUri = 'https://graph.microsoft.com/v1.0' + ) + + $adapter = [pscustomobject]@{ + PSTypeName = 'IdLE.EntraIDAdapter' + BaseUri = $BaseUri.TrimEnd('/') + } + + # Helper to invoke Graph API with error handling + $invokeGraphRequest = { + param( + [Parameter(Mandatory)] + [string] $Method, + + [Parameter(Mandatory)] + [string] $Uri, + + [Parameter(Mandatory)] + [string] $AccessToken, + + [Parameter()] + [object] $Body, + + [Parameter()] + [string] $ContentType = 'application/json' + ) + + $headers = @{ + 'Authorization' = "Bearer $AccessToken" + 'Content-Type' = $ContentType + } + + $params = @{ + Method = $Method + Uri = $Uri + Headers = $headers + ErrorAction = 'Stop' + } + + if ($null -ne $Body) { + if ($Body -is [string]) { + $params['Body'] = $Body + } + else { + $params['Body'] = $Body | ConvertTo-Json -Depth 10 -Compress + } + } + + try { + $response = Invoke-RestMethod @params + return $response + } + catch { + $statusCode = $null + $requestId = $null + $retryAfter = $null + + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + if ($_.Exception.Response.Headers) { + $requestId = $_.Exception.Response.Headers['request-id'] + $retryAfter = $_.Exception.Response.Headers['Retry-After'] + } + } + + # Classify transient errors + $isTransient = $false + if ($statusCode -ge 500 -or $statusCode -eq 429 -or $statusCode -eq 408) { + $isTransient = $true + } + + # Check for network/timeout errors + if ($_.Exception.InnerException -is [System.Net.WebException] -or + $_.Exception.Message -match 'timeout|timed out') { + $isTransient = $true + } + + # Build error message without exposing sensitive data + $errorMessage = "Graph API request failed" + if ($statusCode) { + $errorMessage += " | Status: $statusCode" + } + if ($requestId) { + $errorMessage += " | RequestId: $requestId" + } + if ($retryAfter) { + $errorMessage += " | RetryAfter: $retryAfter" + } + + # Do not include the full exception message as it might contain tokens or sensitive data + # Only include safe error details + + $ex = [System.Exception]::new($errorMessage, $_.Exception) + if ($isTransient) { + $ex.Data['Idle.IsTransient'] = $true + } + + throw $ex + } + } + + $adapter | Add-Member -MemberType ScriptMethod -Name InvokeGraphRequest -Value $invokeGraphRequest -Force + + # Helper to handle paging + $getAllPages = { + param( + [Parameter(Mandatory)] + [string] $Uri, + + [Parameter(Mandatory)] + [string] $AccessToken + ) + + $allItems = @() + $nextLink = $Uri + + while ($null -ne $nextLink) { + $response = $this.InvokeGraphRequest('GET', $nextLink, $AccessToken, $null) + + if ($response.value) { + $allItems += $response.value + } + + $nextLink = $response.'@odata.nextLink' + } + + return $allItems + } + + $adapter | Add-Member -MemberType ScriptMethod -Name GetAllPages -Value $getAllPages -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ObjectId, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $uri = "$($this.BaseUri)/users/$ObjectId" + $uri += '?$select=id,userPrincipalName,mail,displayName,givenName,surname,accountEnabled,department,jobTitle,officeLocation,companyName' + + try { + $user = $this.InvokeGraphRequest('GET', $uri, $AccessToken, $null) + return $user + } + catch { + if ($_.Exception.Message -match '404|not found|does not exist') { + return $null + } + throw + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Upn, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + # URL encode the UPN for the filter + $encodedUpn = [System.Net.WebUtility]::UrlEncode($Upn) + $uri = "$($this.BaseUri)/users?`$filter=userPrincipalName eq '$encodedUpn'" + $uri += '&$select=id,userPrincipalName,mail,displayName,givenName,surname,accountEnabled,department,jobTitle,officeLocation,companyName' + + $users = $this.InvokeGraphRequest('GET', $uri, $AccessToken, $null) + + if ($users.value -and $users.value.Count -gt 0) { + return $users.value[0] + } + + return $null + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByMail -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Mail, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + # URL encode the mail for the filter + $encodedMail = [System.Net.WebUtility]::UrlEncode($Mail) + $uri = "$($this.BaseUri)/users?`$filter=mail eq '$encodedMail'" + $uri += '&$select=id,userPrincipalName,mail,displayName,givenName,surname,accountEnabled,department,jobTitle,officeLocation,companyName' + + $users = $this.InvokeGraphRequest('GET', $uri, $AccessToken, $null) + + if ($users.value -and $users.value.Count -gt 0) { + return $users.value[0] + } + + return $null + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name CreateUser -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Payload, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $uri = "$($this.BaseUri)/users" + $user = $this.InvokeGraphRequest('POST', $uri, $AccessToken, $Payload) + return $user + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name PatchUser -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ObjectId, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Payload, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $uri = "$($this.BaseUri)/users/$ObjectId" + $null = $this.InvokeGraphRequest('PATCH', $uri, $AccessToken, $Payload) + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name DeleteUser -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ObjectId, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $uri = "$($this.BaseUri)/users/$ObjectId" + $null = $this.InvokeGraphRequest('DELETE', $uri, $AccessToken, $null) + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { + param( + [Parameter()] + [hashtable] $Filter, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $uri = "$($this.BaseUri)/users" + $uri += '?$select=id,userPrincipalName,mail' + + if ($null -ne $Filter -and $Filter.ContainsKey('Search') -and -not [string]::IsNullOrWhiteSpace($Filter['Search'])) { + $searchValue = [string]$Filter['Search'] + $encodedSearch = [System.Net.WebUtility]::UrlEncode($searchValue) + $uri += "&`$filter=startswith(userPrincipalName,'$encodedSearch') or startswith(displayName,'$encodedSearch')" + } + + $users = $this.GetAllPages($uri, $AccessToken) + return $users + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $GroupId, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + # Try as GUID first + $uri = "$($this.BaseUri)/groups/$GroupId" + $uri += '?$select=id,displayName,mail,mailNickname' + + try { + $group = $this.InvokeGraphRequest('GET', $uri, $AccessToken, $null) + return $group + } + catch { + if ($_.Exception.Message -match '404|not found|does not exist') { + return $null + } + throw + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetGroupByDisplayName -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $DisplayName, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $encodedName = [System.Net.WebUtility]::UrlEncode($DisplayName) + $uri = "$($this.BaseUri)/groups?`$filter=displayName eq '$encodedName'" + $uri += '&$select=id,displayName,mail,mailNickname' + + $groups = $this.InvokeGraphRequest('GET', $uri, $AccessToken, $null) + + if (-not $groups.value -or $groups.value.Count -eq 0) { + return $null + } + + if ($groups.value.Count -gt 1) { + throw "Multiple groups found with displayName '$DisplayName'. Use objectId for deterministic lookup." + } + + return $groups.value[0] + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name ListUserGroups -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ObjectId, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $uri = "$($this.BaseUri)/users/$ObjectId/memberOf" + $uri += '?$select=id,displayName,mail' + + $groups = $this.GetAllPages($uri, $AccessToken) + return $groups + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name AddGroupMember -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $GroupObjectId, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $UserObjectId, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $uri = "$($this.BaseUri)/groups/$GroupObjectId/members/`$ref" + $body = @{ + '@odata.id' = "$($this.BaseUri)/directoryObjects/$UserObjectId" + } + + try { + $null = $this.InvokeGraphRequest('POST', $uri, $AccessToken, $body) + } + catch { + # Idempotency: if already a member, treat as success + if ($_.Exception.Message -match 'already exists|already a member') { + return + } + throw + } + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $GroupObjectId, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $UserObjectId, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AccessToken + ) + + $uri = "$($this.BaseUri)/groups/$GroupObjectId/members/$UserObjectId/`$ref" + + try { + $null = $this.InvokeGraphRequest('DELETE', $uri, $AccessToken, $null) + } + catch { + # Idempotency: if not a member, treat as success + if ($_.Exception.Message -match '404|not found|does not exist') { + return + } + throw + } + } -Force + + return $adapter +} diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 new file mode 100644 index 0000000..a6ef233 --- /dev/null +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -0,0 +1,873 @@ +function New-IdleEntraIDIdentityProvider { + <# + .SYNOPSIS + Creates a Microsoft Entra ID identity provider for IdLE. + + .DESCRIPTION + This provider integrates with Microsoft Entra ID (formerly Azure Active Directory) + via the Microsoft Graph API (v1.0). It supports both delegated and app-only authentication + via the host-provided AuthSessionBroker pattern. + + The provider supports common identity operations (Create, Read, Disable, Enable, Delete) + and group entitlement management (List, Grant, Revoke). + + Identity addressing supports: + - objectId (GUID string) - most deterministic + - UserPrincipalName (UPN) - contains @ + - mail - email address + + The canonical identity key for all outputs is the user objectId (GUID string). + + Authentication: + Provider methods accept an optional AuthSession parameter for runtime credential + selection via the AuthSessionBroker. The provider supports multiple auth session formats: + - String access token (Bearer token) + - Object with AccessToken property + - Object with GetAccessToken() method + - PSCredential (must contain AccessToken in password field for Graph API) + + By default, steps should use: + - With.AuthSessionName = 'MicrosoftGraph' + - With.AuthSessionOptions = @{ Role = 'Admin' } (or other routing keys) + + .PARAMETER AllowDelete + Opt-in flag to enable the IdLE.Identity.Delete capability. + When $true, the provider advertises the Delete capability and allows identity deletion. + Default is $false for safety. + + .PARAMETER Adapter + Internal parameter for dependency injection during testing. Allows unit tests to inject + a fake Graph adapter without requiring a real Entra ID environment. + + .EXAMPLE + # Basic usage with delegated auth + # Host obtains token via secure method (not shown here - see provider documentation) + $accessToken = Get-SecureGraphToken + $broker = New-IdleAuthSessionBroker -SessionMap @{ + @{} = $accessToken + } -DefaultCredential $accessToken + + $provider = New-IdleEntraIDIdentityProvider + $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + Identity = $provider + AuthSessionBroker = $broker + } + + .EXAMPLE + # Multi-role scenario + $tier0Token = Get-GraphTokenForTier0 # host-managed auth + $adminToken = Get-GraphTokenForAdmin + + $broker = New-IdleAuthSessionBroker -SessionMap @{ + @{ Role = 'Tier0' } = $tier0Token + @{ Role = 'Admin' } = $adminToken + } -DefaultCredential $adminToken + + $provider = New-IdleEntraIDIdentityProvider + $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + Identity = $provider + AuthSessionBroker = $broker + } + + # Workflow steps specify: With.AuthSessionOptions = @{ Role = 'Tier0' } + + .EXAMPLE + # Enable delete capability (opt-in) + $provider = New-IdleEntraIDIdentityProvider -AllowDelete + + .OUTPUTS + PSCustomObject with IdLE provider contract methods + + .NOTES + Requires Microsoft Graph API permissions (delegated or app-only): + - User.Read.All, User.ReadWrite.All + - Group.Read.All, GroupMember.ReadWrite.All + - For delete: User.ReadWrite.All + + See docs/reference/providers/provider-entraID.md for detailed permission requirements. + #> + [CmdletBinding()] + param( + [Parameter()] + [switch] $AllowDelete, + + [Parameter()] + [AllowNull()] + [object] $Adapter + ) + + if ($null -eq $Adapter) { + $Adapter = New-IdleEntraIDAdapter + } + + $convertToEntitlement = { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Value + ) + + $kind = $null + $id = $null + $displayName = $null + $mail = $null + + if ($Value -is [System.Collections.IDictionary]) { + $kind = $Value['Kind'] + $id = $Value['Id'] + if ($Value.Contains('DisplayName')) { $displayName = $Value['DisplayName'] } + if ($Value.Contains('Mail')) { $mail = $Value['Mail'] } + } + 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 ($props.Name -contains 'Mail') { $mail = $Value.Mail } + } + + 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 + } + Mail = if ($null -eq $mail -or [string]::IsNullOrWhiteSpace([string]$mail)) { + $null + } + else { + [string]$mail + } + } + } + + $testEntitlementEquals = { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $A, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $B + ) + + $aEnt = $this.ConvertToEntitlement($A) + $bEnt = $this.ConvertToEntitlement($B) + + if ($aEnt.Kind -ne $bEnt.Kind) { + return $false + } + + return [string]::Equals($aEnt.Id, $bEnt.Id, [System.StringComparison]::OrdinalIgnoreCase) + } + + $extractAccessToken = { + param( + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + if ($null -eq $AuthSession) { + # For tests/development, allow null but it will fail when hitting real Graph API + # Real usage will fail with proper error from Graph API + return 'test-token-not-for-production' + } + + # String token + if ($AuthSession -is [string]) { + return $AuthSession + } + + # Object with GetAccessToken() method + if ($AuthSession.PSObject.Methods.Name -contains 'GetAccessToken') { + return $AuthSession.GetAccessToken() + } + + # Object with AccessToken property + if ($AuthSession.PSObject.Properties.Name -contains 'AccessToken') { + return $AuthSession.AccessToken + } + + # PSCredential with token in password field + if ($AuthSession -is [PSCredential]) { + return $AuthSession.GetNetworkCredential().Password + } + + throw "AuthSession format not recognized. Expected: string token, object with AccessToken property, object with GetAccessToken() method, or PSCredential." + } + + $resolveIdentity = { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + + # Try GUID format first (most deterministic) + # Support both standard GUID format and N-format (32 hex digits) + $guid = [System.Guid]::Empty + $isGuid = [System.Guid]::TryParse($IdentityKey, [ref]$guid) + + # Also check for N-format GUID (32 hex digits, no hyphens) + # This handles standalone GUIDs and GUIDs with prefixes (e.g., contract test keys like "contract-") + if (-not $isGuid -and $IdentityKey -match '([0-9a-fA-F]{32})') { + $hexPart = $Matches[1] + $isGuid = [System.Guid]::TryParseExact($hexPart, 'N', [ref]$guid) + } + + if ($isGuid) { + $user = $this.Adapter.GetUserById($guid.ToString(), $accessToken) + if ($null -ne $user) { + return $user + } + throw "Identity with objectId '$IdentityKey' not found." + } + + # Try UPN format (contains @) + if ($IdentityKey -match '@') { + $user = $this.Adapter.GetUserByUpn($IdentityKey, $accessToken) + if ($null -ne $user) { + return $user + } + + # Fallback: try as mail + $user = $this.Adapter.GetUserByMail($IdentityKey, $accessToken) + if ($null -ne $user) { + return $user + } + + throw "Identity with UPN/mail '$IdentityKey' not found." + } + + throw "Identity key '$IdentityKey' is not in a recognized format (objectId GUID, UPN, or mail)." + } + + $normalizeGroupId = { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $GroupId, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + + # Try as objectId first + $guid = [System.Guid]::Empty + if ([System.Guid]::TryParse($GroupId, [ref]$guid)) { + $group = $this.Adapter.GetGroupById($GroupId, $accessToken) + if ($null -ne $group) { + return $group.id + } + throw "Group with objectId '$GroupId' not found." + } + + # Try as displayName + $group = $this.Adapter.GetGroupByDisplayName($GroupId, $accessToken) + if ($null -ne $group) { + return $group.id + } + + throw "Group '$GroupId' not found." + } + + $provider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.EntraIDIdentityProvider' + Name = 'EntraIDIdentityProvider' + Adapter = $Adapter + AllowDelete = [bool]$AllowDelete + } + + $provider | Add-Member -MemberType ScriptMethod -Name ExtractAccessToken -Value $extractAccessToken -Force + $provider | Add-Member -MemberType ScriptMethod -Name ConvertToEntitlement -Value $convertToEntitlement -Force + $provider | Add-Member -MemberType ScriptMethod -Name TestEntitlementEquals -Value $testEntitlementEquals -Force + $provider | Add-Member -MemberType ScriptMethod -Name ResolveIdentity -Value $resolveIdentity -Force + $provider | Add-Member -MemberType ScriptMethod -Name NormalizeGroupId -Value $normalizeGroupId -Force + + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + $caps = @( + 'IdLE.Identity.Read' + 'IdLE.Identity.List' + 'IdLE.Identity.Create' + 'IdLE.Identity.Attribute.Ensure' + 'IdLE.Identity.Disable' + 'IdLE.Identity.Enable' + 'IdLE.Entitlement.List' + 'IdLE.Entitlement.Grant' + 'IdLE.Entitlement.Revoke' + ) + + if ($this.AllowDelete) { + $caps += 'IdLE.Identity.Delete' + } + + return $caps + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + + $attributes = @{} + + # Handle both hashtables and PSCustomObjects + $getUserProperty = { + param($obj, $propName) + if ($obj -is [System.Collections.IDictionary]) { + if ($obj.ContainsKey($propName)) { + return $obj[$propName] + } + } + elseif ($obj.PSObject.Properties.Name -contains $propName) { + return $obj.$propName + } + return $null + } + + $givenName = & $getUserProperty $user 'givenName' + if ($null -ne $givenName) { $attributes['GivenName'] = $givenName } + + $surname = & $getUserProperty $user 'surname' + if ($null -ne $surname) { $attributes['Surname'] = $surname } + + $displayName = & $getUserProperty $user 'displayName' + if ($null -ne $displayName) { $attributes['DisplayName'] = $displayName } + + $upn = & $getUserProperty $user 'userPrincipalName' + if ($null -ne $upn) { $attributes['UserPrincipalName'] = $upn } + + $mail = & $getUserProperty $user 'mail' + if ($null -ne $mail) { $attributes['Mail'] = $mail } + + $dept = & $getUserProperty $user 'department' + if ($null -ne $dept) { $attributes['Department'] = $dept } + + $jobTitle = & $getUserProperty $user 'jobTitle' + if ($null -ne $jobTitle) { $attributes['JobTitle'] = $jobTitle } + + $officeLocation = & $getUserProperty $user 'officeLocation' + if ($null -ne $officeLocation) { $attributes['OfficeLocation'] = $officeLocation } + + $companyName = & $getUserProperty $user 'companyName' + if ($null -ne $companyName) { $attributes['CompanyName'] = $companyName } + + # Get accountEnabled + $accountEnabled = & $getUserProperty $user 'accountEnabled' + + return [pscustomobject]@{ + PSTypeName = 'IdLE.Identity' + IdentityKey = $IdentityKey + Enabled = [bool]$accountEnabled + Attributes = $attributes + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name ListIdentities -Value { + param( + [Parameter()] + [hashtable] $Filter, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + $users = $this.Adapter.ListUsers($Filter, $accessToken) + + $identityKeys = @() + foreach ($user in $users) { + $identityKeys += $user.id + } + return $identityKeys + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name CreateIdentity -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Attributes, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + + # Check if user already exists (idempotency) + try { + $existing = $this.ResolveIdentity($IdentityKey, $AuthSession) + if ($null -ne $existing) { + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'CreateIdentity' + IdentityKey = $IdentityKey + Changed = $false + } + } + } + catch { + # Identity does not exist, proceed with creation + Write-Verbose "Identity '$IdentityKey' does not exist, proceeding with creation" + } + + # Build Graph API payload + $payload = @{ + accountEnabled = $true + } + + # Required fields for user creation + if ($Attributes.ContainsKey('UserPrincipalName')) { + $payload['userPrincipalName'] = $Attributes['UserPrincipalName'] + } + else { + $payload['userPrincipalName'] = $IdentityKey + } + + if ($Attributes.ContainsKey('DisplayName')) { + $payload['displayName'] = $Attributes['DisplayName'] + } + else { + $payload['displayName'] = $IdentityKey + } + + # MailNickname is required + if ($Attributes.ContainsKey('MailNickname')) { + $payload['mailNickname'] = $Attributes['MailNickname'] + } + else { + # Generate from UPN + $payload['mailNickname'] = $payload['userPrincipalName'].Split('@')[0] + } + + # Password policy for new users + if ($Attributes.ContainsKey('PasswordProfile')) { + $payload['passwordProfile'] = $Attributes['PasswordProfile'] + } + else { + # Default: force change on first sign-in + $payload['passwordProfile'] = @{ + forceChangePasswordNextSignIn = $true + password = [System.Guid]::NewGuid().ToString() + } + } + + # Optional attributes + if ($Attributes.ContainsKey('GivenName')) { $payload['givenName'] = $Attributes['GivenName'] } + if ($Attributes.ContainsKey('Surname')) { $payload['surname'] = $Attributes['Surname'] } + if ($Attributes.ContainsKey('Mail')) { $payload['mail'] = $Attributes['Mail'] } + if ($Attributes.ContainsKey('Department')) { $payload['department'] = $Attributes['Department'] } + if ($Attributes.ContainsKey('JobTitle')) { $payload['jobTitle'] = $Attributes['JobTitle'] } + if ($Attributes.ContainsKey('OfficeLocation')) { $payload['officeLocation'] = $Attributes['OfficeLocation'] } + if ($Attributes.ContainsKey('CompanyName')) { $payload['companyName'] = $Attributes['CompanyName'] } + + if ($Attributes.ContainsKey('Enabled')) { + $payload['accountEnabled'] = [bool]$Attributes['Enabled'] + } + + $user = $this.Adapter.CreateUser($payload, $accessToken) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'CreateIdentity' + IdentityKey = $IdentityKey + Changed = $true + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name DeleteIdentity -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + if (-not $this.AllowDelete) { + throw "Delete capability is not enabled. Set AllowDelete = `$true when creating the provider." + } + + $accessToken = $this.ExtractAccessToken($AuthSession) + + try { + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + $this.Adapter.DeleteUser($user.id, $accessToken) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'DeleteIdentity' + IdentityKey = $IdentityKey + Changed = $true + } + } + catch { + # Idempotency: if not found, treat as success + if ($_.Exception.Message -match '404|not found|does not exist') { + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'DeleteIdentity' + IdentityKey = $IdentityKey + Changed = $false + } + } + throw + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + [Parameter()] + [AllowNull()] + [object] $Value, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + + # Map IdLE attribute names to Graph API property names + $graphPropertyName = switch ($Name) { + 'GivenName' { 'givenName' } + 'Surname' { 'surname' } + 'DisplayName' { 'displayName' } + 'UserPrincipalName' { 'userPrincipalName' } + 'Mail' { 'mail' } + 'Department' { 'department' } + 'JobTitle' { 'jobTitle' } + 'OfficeLocation' { 'officeLocation' } + 'CompanyName' { 'companyName' } + default { $Name.Substring(0, 1).ToLower() + $Name.Substring(1) } + } + + $currentValue = $null + if ($user -is [System.Collections.IDictionary]) { + if ($user.ContainsKey($graphPropertyName)) { + $currentValue = $user[$graphPropertyName] + } + } + elseif ($user.PSObject.Properties.Name -contains $graphPropertyName) { + $currentValue = $user.$graphPropertyName + } + + $changed = $false + # Use loose comparison for idempotency (handles type coercion) + if (-not ($currentValue -eq $Value)) { + $payload = @{ + $graphPropertyName = $Value + } + $this.Adapter.PatchUser($user.id, $payload, $accessToken) + $changed = $true + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureAttribute' + IdentityKey = $IdentityKey + Changed = $changed + Name = $Name + Value = $Value + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name DisableIdentity -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + + # Get accountEnabled from user object (handle both hashtable and PSCustomObject) + $accountEnabled = if ($user -is [System.Collections.IDictionary]) { + if ($user.ContainsKey('accountEnabled')) { $user['accountEnabled'] } else { $null } + } + else { + if ($user.PSObject.Properties.Name -contains 'accountEnabled') { $user.accountEnabled } else { $null } + } + + # Get id from user object + $userId = if ($user -is [System.Collections.IDictionary]) { + $user['id'] + } + else { + $user.id + } + + $changed = $false + if ($accountEnabled -ne $false) { + $payload = @{ accountEnabled = $false } + $this.Adapter.PatchUser($userId, $payload, $accessToken) + $changed = $true + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'DisableIdentity' + IdentityKey = $IdentityKey + Changed = $changed + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name EnableIdentity -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + + # Get accountEnabled from user object (handle both hashtable and PSCustomObject) + $accountEnabled = if ($user -is [System.Collections.IDictionary]) { + if ($user.ContainsKey('accountEnabled')) { $user['accountEnabled'] } else { $null } + } + else { + if ($user.PSObject.Properties.Name -contains 'accountEnabled') { $user.accountEnabled } else { $null } + } + + # Get id from user object + $userId = if ($user -is [System.Collections.IDictionary]) { + $user['id'] + } + else { + $user.id + } + + $changed = $false + if ($accountEnabled -ne $true) { + $payload = @{ accountEnabled = $true } + $this.Adapter.PatchUser($userId, $payload, $accessToken) + $changed = $true + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnableIdentity' + IdentityKey = $IdentityKey + Changed = $changed + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + + $groups = $this.Adapter.ListUserGroups($user.id, $accessToken) + + $result = @() + foreach ($group in $groups) { + # Handle both hashtables and PSCustomObjects + $groupId = if ($group -is [System.Collections.IDictionary]) { + $group['id'] + } else { + $group.id + } + + $displayName = if ($group -is [System.Collections.IDictionary]) { + if ($group.ContainsKey('displayName')) { $group['displayName'] } else { $null } + } else { + if ($group.PSObject.Properties.Name -contains 'displayName') { $group.displayName } else { $null } + } + + $mail = if ($group -is [System.Collections.IDictionary]) { + if ($group.ContainsKey('mail')) { $group['mail'] } else { $null } + } else { + if ($group.PSObject.Properties.Name -contains 'mail') { $group.mail } else { $null } + } + + $result += [pscustomobject]@{ + PSTypeName = 'IdLE.Entitlement' + Kind = 'Group' + Id = $groupId + DisplayName = $displayName + Mail = $mail + } + } + + return $result + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name GrantEntitlement -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Entitlement, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + $normalized = $this.ConvertToEntitlement($Entitlement) + + # GrantEntitlement only supports group entitlements + if ($null -ne $normalized.Kind -and $normalized.Kind -ne 'Group') { + throw [System.ArgumentException]::new( + "GrantEntitlement only supports entitlements with Kind 'Group'. Received Kind '$($normalized.Kind)'." + ) + } + + # Default missing Kind to 'Group' for backward compatibility + if (-not $normalized.Kind) { + $normalized.Kind = 'Group' + } + + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + $groupObjectId = $this.NormalizeGroupId($normalized.Id, $AuthSession) + + # Update normalized entitlement with canonical group ID + $normalized.Id = $groupObjectId + + # Check if already a member (idempotency) + $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) + $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } + + $changed = $false + if (@($existing).Count -eq 0) { + $this.Adapter.AddGroupMember($groupObjectId, $user.id, $accessToken) + $changed = $true + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'GrantEntitlement' + IdentityKey = $IdentityKey + Changed = $changed + Entitlement = $normalized + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name RevokeEntitlement -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Entitlement, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + $normalized = $this.ConvertToEntitlement($Entitlement) + + # RevokeEntitlement only supports group entitlements + if ($null -ne $normalized.Kind -and $normalized.Kind -ne 'Group') { + throw [System.ArgumentException]::new( + "RevokeEntitlement only supports entitlements with Kind 'Group'. Received Kind '$($normalized.Kind)'." + ) + } + + # Default missing Kind to 'Group' for backward compatibility + if (-not $normalized.Kind) { + $normalized.Kind = 'Group' + } + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + $groupObjectId = $this.NormalizeGroupId($normalized.Id, $AuthSession) + + # Update normalized entitlement with canonical group ID + $normalized.Id = $groupObjectId + + # Check if currently a member (idempotency) + $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) + $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } + + $changed = $false + if (@($existing).Count -gt 0) { + $this.Adapter.RemoveGroupMember($groupObjectId, $user.id, $accessToken) + $changed = $true + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'RevokeEntitlement' + IdentityKey = $IdentityKey + Changed = $changed + Entitlement = $normalized + } + } -Force + + return $provider +} diff --git a/src/IdLE.Provider.EntraID/README.md b/src/IdLE.Provider.EntraID/README.md new file mode 100644 index 0000000..58f5173 --- /dev/null +++ b/src/IdLE.Provider.EntraID/README.md @@ -0,0 +1,43 @@ +# IdLE.Provider.EntraID + +Microsoft Entra ID (Azure AD) provider for IdLE. + +## Quick Start + +```powershell +# Automatically imported when you import IdLE +Import-Module IdLE + +# Host obtains Graph access token (delegated or app-only) +$token = Get-GraphToken + +# Create broker for auth routing +$broker = New-IdleAuthSessionBroker -SessionMap @{ + @{} = $token +} -DefaultCredential $token + +# Create provider +$provider = New-IdleEntraIDIdentityProvider + +# Use in workflows +$providers = @{ + Identity = $provider + AuthSessionBroker = $broker +} +$plan = New-IdlePlan -WorkflowPath '.\joiner.psd1' -Request $request -Providers $providers +``` + +## Prerequisites + +- PowerShell 7.0+ +- Microsoft Graph API access token (host-managed) + +## Documentation + +See **[Complete Provider Documentation](../../docs/reference/providers/provider-entraID.md)** for: +- Full usage guide and examples +- Capabilities and built-in steps +- Authentication patterns (delegated + app-only) +- Required Graph API permissions +- Identity resolution and idempotency +- Troubleshooting diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 new file mode 100644 index 0000000..8296df9 --- /dev/null +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -0,0 +1,798 @@ +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 + + # Import EntraID provider + $entraIDModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.EntraID\IdLE.Provider.EntraID.psm1' + if (-not (Test-Path -LiteralPath $entraIDModulePath -PathType Leaf)) { + throw "EntraID provider module not found at: $entraIDModulePath" + } + Import-Module $entraIDModulePath -Force +} + +Describe 'EntraID identity provider - Contract tests' { + BeforeAll { + # Create a fake adapter for contract tests + $fakeAdapter = [pscustomobject]@{ + PSTypeName = 'IdLE.EntraIDAdapter.Fake' + Store = @{} + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { + param($ObjectId, $AccessToken) + $key = "id:$ObjectId" + if (-not $this.Store.ContainsKey($key)) { + $this.Store[$key] = @{ + id = $ObjectId + userPrincipalName = "$ObjectId@test.local" + mail = "$ObjectId@test.local" + displayName = "User $ObjectId" + accountEnabled = $true + givenName = "Test" + surname = "User" + } + } + return $this.Store[$key] + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { + param($Upn, $AccessToken) + # Try direct lookup first + if ($this.Store.ContainsKey("upn:$Upn")) { + return $this.Store["upn:$Upn"] + } + # Fallback to search (for backwards compatibility) + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].userPrincipalName -eq $Upn) { + return $this.Store[$key] + } + } + return $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserByMail -Value { + param($Mail, $AccessToken) + # Try direct lookup first + if ($this.Store.ContainsKey("mail:$Mail")) { + return $this.Store["mail:$Mail"] + } + # Fallback to search (for backwards compatibility) + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].mail -eq $Mail) { + return $this.Store[$key] + } + } + return $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name CreateUser -Value { + param($Payload, $AccessToken) + $id = [guid]::NewGuid().ToString() + $user = @{ + id = $id + userPrincipalName = $Payload.userPrincipalName + mail = $Payload.mail + displayName = $Payload.displayName + accountEnabled = $Payload.accountEnabled + givenName = $Payload.givenName + surname = $Payload.surname + } + $this.Store["id:$id"] = $user + # Also store by UPN for easier lookup + if ($Payload.userPrincipalName) { + $this.Store["upn:$($Payload.userPrincipalName)"] = $user + } + return $user + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name PatchUser -Value { + param($ObjectId, $Payload, $AccessToken) + $key = "id:$ObjectId" + if ($this.Store.ContainsKey($key)) { + foreach ($prop in $Payload.Keys) { + $this.Store[$key][$prop] = $Payload[$prop] + } + } + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name DeleteUser -Value { + param($ObjectId, $AccessToken) + $key = "id:$ObjectId" + if ($this.Store.ContainsKey($key)) { + $this.Store.Remove($key) + } + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { + param($Filter, $AccessToken) + $users = @() + foreach ($key in $this.Store.Keys) { + $users += $this.Store[$key] + } + return $users + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { + param($GroupId, $AccessToken) + return @{ + id = $GroupId + displayName = "Group $GroupId" + mail = "group-$GroupId@test.local" + } + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetGroupByDisplayName -Value { + param($DisplayName, $AccessToken) + # Generate a deterministic GUID based on the display name + # This simulates real Graph API behavior where groups have GUID ids + $hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash([System.Text.Encoding]::UTF8.GetBytes($DisplayName)) + $guidBytes = [byte[]]$hash[0..15] + $guid = [System.Guid]::new($guidBytes) + + return @{ + id = $guid.ToString() + displayName = $DisplayName + mail = "group-$DisplayName@test.local" + } + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name ListUserGroups -Value { + param($ObjectId, $AccessToken) + $key = "groups:$ObjectId" + if (-not $this.Store.ContainsKey($key)) { + $this.Store[$key] = @() + } + return $this.Store[$key] + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name AddGroupMember -Value { + param($GroupObjectId, $UserObjectId, $AccessToken) + $key = "groups:$UserObjectId" + if (-not $this.Store.ContainsKey($key)) { + $this.Store[$key] = @() + } + + # Check if already a member (idempotency) + $alreadyMember = $false + foreach ($existingGroup in $this.Store[$key]) { + if ($existingGroup.id -eq $GroupObjectId) { + $alreadyMember = $true + break + } + } + + if (-not $alreadyMember) { + $group = @{ + id = $GroupObjectId + displayName = "Group $GroupObjectId" + mail = "group-$GroupObjectId@test.local" + } + $this.Store[$key] += $group + } + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { + param($GroupObjectId, $UserObjectId, $AccessToken) + $key = "groups:$UserObjectId" + if ($this.Store.ContainsKey($key)) { + $this.Store[$key] = $this.Store[$key] | Where-Object { $_.id -ne $GroupObjectId } + } + } + + $script:FakeAdapter = $fakeAdapter + } + + Invoke-IdleIdentityProviderContractTests -NewProvider { + New-IdleEntraIDIdentityProvider -Adapter $script:FakeAdapter + } + + Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { + New-IdleEntraIDIdentityProvider -Adapter $script:FakeAdapter + } + + # Note: Generic entitlement contract tests are skipped for EntraID provider because: + # - EntraID only supports Kind='Group' (not arbitrary entitlement kinds like 'Contract') + # - Generic contract tests use Kind='Contract' which doesn't match EntraID's behavior + # - EntraID-specific entitlement tests with Kind='Group' are in the 'EntraID identity provider - Entitlements' context below +} + +Describe 'EntraID identity provider - Capabilities' { + It 'Advertises expected capabilities by default' { + $provider = New-IdleEntraIDIdentityProvider -Adapter ([pscustomobject]@{}) + $caps = $provider.GetCapabilities() + + $caps | Should -Contain 'IdLE.Identity.Read' + $caps | Should -Contain 'IdLE.Identity.List' + $caps | Should -Contain 'IdLE.Identity.Create' + $caps | Should -Contain 'IdLE.Identity.Attribute.Ensure' + $caps | Should -Contain 'IdLE.Identity.Disable' + $caps | Should -Contain 'IdLE.Identity.Enable' + $caps | Should -Contain 'IdLE.Entitlement.List' + $caps | Should -Contain 'IdLE.Entitlement.Grant' + $caps | Should -Contain 'IdLE.Entitlement.Revoke' + $caps | Should -Not -Contain 'IdLE.Identity.Delete' + } + + It 'Advertises Delete capability when AllowDelete is true' { + $provider = New-IdleEntraIDIdentityProvider -AllowDelete -Adapter ([pscustomobject]@{}) + $caps = $provider.GetCapabilities() + + $caps | Should -Contain 'IdLE.Identity.Delete' + } + + It 'Does not advertise Delete capability when AllowDelete is false' { + $provider = New-IdleEntraIDIdentityProvider -AllowDelete:$false -Adapter ([pscustomobject]@{}) + $caps = $provider.GetCapabilities() + + $caps | Should -Not -Contain 'IdLE.Identity.Delete' + } +} + +Describe 'EntraID identity provider - AllowDelete gate' { + It 'Throws when Delete is called without AllowDelete' { + $fakeAdapter = [pscustomobject]@{ PSTypeName = 'Fake' } + $provider = New-IdleEntraIDIdentityProvider -Adapter $fakeAdapter + + { $provider.DeleteIdentity('test-id', 'fake-token') } | Should -Throw '*Delete capability is not enabled*' + } + + It 'Allows Delete when AllowDelete is true' { + $fakeAdapter = [pscustomobject]@{ + PSTypeName = 'Fake' + } + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { + param($ObjectId, $AccessToken) + return $null + } + + $provider = New-IdleEntraIDIdentityProvider -AllowDelete -Adapter $fakeAdapter + + # Use GUID format, should not throw capability error + $userId = [guid]::NewGuid().ToString() + $result = $provider.DeleteIdentity($userId, 'fake-token') + $result.Changed | Should -BeFalse + } +} + +Describe 'EntraID identity provider - Idempotency' { + BeforeEach { + $store = @{} + $fakeAdapter = [pscustomobject]@{ + PSTypeName = 'IdLE.EntraIDAdapter.Fake' + Store = $store + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { + param($ObjectId, $AccessToken) + if ($this.Store.ContainsKey($ObjectId)) { + return $this.Store[$ObjectId] + } + return $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { + param($Upn, $AccessToken) + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].userPrincipalName -eq $Upn) { + return $this.Store[$key] + } + } + return $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserByMail -Value { + param($Mail, $AccessToken) + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].mail -eq $Mail) { + return $this.Store[$key] + } + } + return $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name CreateUser -Value { + param($Payload, $AccessToken) + $id = [guid]::NewGuid().ToString() + $user = @{ + id = $id + userPrincipalName = $Payload.userPrincipalName + displayName = $Payload.displayName + accountEnabled = $Payload.accountEnabled + } + $this.Store[$id] = $user + return $user + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name PatchUser -Value { + param($ObjectId, $Payload, $AccessToken) + if ($this.Store.ContainsKey($ObjectId)) { + foreach ($prop in $Payload.Keys) { + $this.Store[$ObjectId][$prop] = $Payload[$prop] + } + } + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name DeleteUser -Value { + param($ObjectId, $AccessToken) + if ($this.Store.ContainsKey($ObjectId)) { + $this.Store.Remove($ObjectId) + } + } + + $script:TestAdapter = $fakeAdapter + } + + It 'CreateIdentity is idempotent - returns Changed=false when user exists' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $attrs = @{ + UserPrincipalName = 'test@test.local' + DisplayName = 'Test User' + } + + $result1 = $provider.CreateIdentity('test@test.local', $attrs, 'fake-token') + $result1.Changed | Should -BeTrue + + $userId = $result1.IdentityKey + + # Second create should be idempotent + $result2 = $provider.CreateIdentity($userId, $attrs, 'fake-token') + $result2.Changed | Should -BeFalse + } + + It 'DeleteIdentity is idempotent - returns Changed=false when user does not exist' { + $provider = New-IdleEntraIDIdentityProvider -AllowDelete -Adapter $script:TestAdapter + + $userId = [guid]::NewGuid().ToString() + $result = $provider.DeleteIdentity($userId, 'fake-token') + $result.Changed | Should -BeFalse + } + + It 'DisableIdentity is idempotent - returns Changed=false when already disabled' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $userId = [guid]::NewGuid().ToString() + $script:TestAdapter.Store[$userId] = @{ + id = $userId + accountEnabled = $true + } + + $result1 = $provider.DisableIdentity($userId, 'fake-token') + $result1.Changed | Should -BeTrue + + $result2 = $provider.DisableIdentity($userId, 'fake-token') + $result2.Changed | Should -BeFalse + } + + It 'EnableIdentity is idempotent - returns Changed=false when already enabled' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $userId = [guid]::NewGuid().ToString() + $script:TestAdapter.Store[$userId] = @{ + id = $userId + accountEnabled = $false + } + + $result1 = $provider.EnableIdentity($userId, 'fake-token') + $result1.Changed | Should -BeTrue + + $result2 = $provider.EnableIdentity($userId, 'fake-token') + $result2.Changed | Should -BeFalse + } + + It 'EnsureAttribute is idempotent - returns Changed=false when value matches' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $userId = [guid]::NewGuid().ToString() + $script:TestAdapter.Store[$userId] = @{ + id = $userId + displayName = 'Old Name' + } + + $result1 = $provider.EnsureAttribute($userId, 'DisplayName', 'New Name', 'fake-token') + $result1.Changed | Should -BeTrue + + $result2 = $provider.EnsureAttribute($userId, 'DisplayName', 'New Name', 'fake-token') + $result2.Changed | Should -BeFalse + } +} + +Describe 'EntraID identity provider - AuthSession handling' { + BeforeEach { + $fakeAdapter = [pscustomobject]@{ + PSTypeName = 'IdLE.EntraIDAdapter.Fake' + LastTokenUsed = $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { + param($ObjectId, $AccessToken) + $this.LastTokenUsed = $AccessToken + return @{ + id = $ObjectId + accountEnabled = $true + displayName = "User $ObjectId" + } + } + + $script:TestAdapter = $fakeAdapter + } + + It 'Accepts string access token' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $userId = [guid]::NewGuid().ToString() + $result = $provider.GetIdentity($userId, 'string-token') + $script:TestAdapter.LastTokenUsed | Should -Be 'string-token' + } + + It 'Accepts object with AccessToken property' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $authSession = [pscustomobject]@{ + AccessToken = 'property-token' + } + + $userId = [guid]::NewGuid().ToString() + $result = $provider.GetIdentity($userId, $authSession) + $script:TestAdapter.LastTokenUsed | Should -Be 'property-token' + } + + It 'Accepts object with GetAccessToken() method' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $authSession = [pscustomobject]@{} + $authSession | Add-Member -MemberType ScriptMethod -Name GetAccessToken -Value { + return 'method-token' + } + + $userId = [guid]::NewGuid().ToString() + $result = $provider.GetIdentity($userId, $authSession) + $script:TestAdapter.LastTokenUsed | Should -Be 'method-token' + } + + It 'Allows null AuthSession (for testing)' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $userId = [guid]::NewGuid().ToString() + # Should not throw - will use test token + $provider.GetIdentity($userId, $null) | Should -Not -BeNullOrEmpty + } + + It 'Throws when AuthSession format is unrecognized' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $badSession = [pscustomobject]@{ + SomeProperty = 'value' + } + + { $provider.GetIdentity('test-id', $badSession) } | Should -Throw '*AuthSession format not recognized*' + } +} + +Describe 'EntraID identity provider - Identity resolution' { + BeforeEach { + $store = @{} + $fakeAdapter = [pscustomobject]@{ + PSTypeName = 'IdLE.EntraIDAdapter.Fake' + Store = $store + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { + param($ObjectId, $AccessToken) + if ($this.Store.ContainsKey("id:$ObjectId")) { + return $this.Store["id:$ObjectId"] + } + return $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { + param($Upn, $AccessToken) + if ($this.Store.ContainsKey("upn:$Upn")) { + return $this.Store["upn:$Upn"] + } + return $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserByMail -Value { + param($Mail, $AccessToken) + if ($this.Store.ContainsKey("mail:$Mail")) { + return $this.Store["mail:$Mail"] + } + return $null + } + + $script:TestAdapter = $fakeAdapter + } + + It 'Resolves identity by objectId (GUID)' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $guid = [guid]::NewGuid().ToString() + $script:TestAdapter.Store["id:$guid"] = @{ + id = $guid + accountEnabled = $true + displayName = "User $guid" + } + + $result = $provider.GetIdentity($guid, 'fake-token') + $result.IdentityKey | Should -Be $guid + } + + It 'Resolves identity by UPN' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $upn = 'test@test.local' + $userId = [guid]::NewGuid().ToString() + $script:TestAdapter.Store["upn:$upn"] = @{ + id = $userId + userPrincipalName = $upn + accountEnabled = $true + displayName = "Test User" + } + + $result = $provider.GetIdentity($upn, 'fake-token') + $result.IdentityKey | Should -Be $upn # Returns original key format + } + + It 'Falls back to mail when UPN lookup fails' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $mail = 'test@test.local' + $userId = [guid]::NewGuid().ToString() + $script:TestAdapter.Store["mail:$mail"] = @{ + id = $userId + mail = $mail + accountEnabled = $true + displayName = "Test User" + } + + $result = $provider.GetIdentity($mail, 'fake-token') + $result.IdentityKey | Should -Be $mail # Returns original key format + } + + It 'Throws when identity is not found' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + { $provider.GetIdentity('nonexistent@test.local', 'fake-token') } | Should -Throw '*not found*' + } +} + +Describe 'EntraID identity provider - Group resolution' { + BeforeEach { + $fakeAdapter = [pscustomobject]@{ + PSTypeName = 'IdLE.EntraIDAdapter.Fake' + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { + param($GroupId, $AccessToken) + $guid = [System.Guid]::Empty + if ([System.Guid]::TryParse($GroupId, [ref]$guid)) { + return @{ + id = $GroupId + displayName = "Group $GroupId" + } + } + return $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetGroupByDisplayName -Value { + param($DisplayName, $AccessToken) + if ($DisplayName -eq 'AmbiguousGroup') { + throw "Multiple groups found with displayName '$DisplayName'. Use objectId for deterministic lookup." + } + return @{ + id = "resolved-$DisplayName" + displayName = $DisplayName + } + } + + $script:TestAdapter = $fakeAdapter + } + + It 'Resolves group by objectId' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $groupGuid = [guid]::NewGuid().ToString() + $resolvedId = $provider.NormalizeGroupId($groupGuid, 'fake-token') + + $resolvedId | Should -Be $groupGuid + } + + It 'Resolves group by displayName' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + $resolvedId = $provider.NormalizeGroupId('UniqueGroup', 'fake-token') + $resolvedId | Should -Be 'resolved-UniqueGroup' + } + + It 'Throws when multiple groups match displayName' { + $provider = New-IdleEntraIDIdentityProvider -Adapter $script:TestAdapter + + { $provider.NormalizeGroupId('AmbiguousGroup', 'fake-token') } | Should -Throw '*Multiple groups found*' + } +} + +Describe 'EntraID identity provider - Entitlement operations' { + BeforeAll { + function New-FakeEntraIDAdapterForEntitlements { + $store = @{} + + $adapter = [pscustomobject]@{ + PSTypeName = 'IdLE.EntraIDAdapter.Fake' + Store = $store + } + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { + param($ObjectId, $AccessToken) + $key = "id:$ObjectId" + if (-not $this.Store.ContainsKey($key)) { + $this.Store[$key] = @{ + id = $ObjectId + userPrincipalName = "$ObjectId@test.local" + displayName = "User $ObjectId" + accountEnabled = $true + } + } + return $this.Store[$key] + } + + $adapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { + param($GroupId, $AccessToken) + return @{ + id = $GroupId + displayName = "Group $GroupId" + mail = "group-$GroupId@test.local" + } + } + + $adapter | Add-Member -MemberType ScriptMethod -Name ListUserGroups -Value { + param($ObjectId, $AccessToken) + $key = "groups:$ObjectId" + if (-not $this.Store.ContainsKey($key)) { + $this.Store[$key] = @() + } + return $this.Store[$key] + } + + $adapter | Add-Member -MemberType ScriptMethod -Name AddGroupMember -Value { + param($GroupObjectId, $UserObjectId, $AccessToken) + $key = "groups:$UserObjectId" + if (-not $this.Store.ContainsKey($key)) { + $this.Store[$key] = @() + } + + $alreadyMember = $false + foreach ($existingGroup in $this.Store[$key]) { + if ($existingGroup.id -eq $GroupObjectId) { + $alreadyMember = $true + break + } + } + + if (-not $alreadyMember) { + $group = @{ + id = $GroupObjectId + displayName = "Group $GroupObjectId" + mail = "group-$GroupObjectId@test.local" + } + $this.Store[$key] += $group + } + } + + $adapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { + param($GroupObjectId, $UserObjectId, $AccessToken) + $key = "groups:$UserObjectId" + if ($this.Store.ContainsKey($key)) { + $this.Store[$key] = @($this.Store[$key] | Where-Object { $_.id -ne $GroupObjectId }) + } + } + + return $adapter + } + + $script:EntAdapter = New-FakeEntraIDAdapterForEntitlements + $script:EntProvider = New-IdleEntraIDIdentityProvider -Adapter $script:EntAdapter + } + + It 'Exposes required entitlement methods' { + $script:EntProvider.PSObject.Methods.Name | Should -Contain 'ListEntitlements' + $script:EntProvider.PSObject.Methods.Name | Should -Contain 'GrantEntitlement' + $script:EntProvider.PSObject.Methods.Name | Should -Contain 'RevokeEntitlement' + } + + It 'GrantEntitlement returns stable result shape with Kind=Group' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + + $entitlement = [pscustomobject]@{ + Kind = 'Group' + Id = [guid]::NewGuid().ToString() + } + + $result = $script:EntProvider.GrantEntitlement($userId, $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.Entitlement.Kind | Should -Be 'Group' + } + + It 'GrantEntitlement is idempotent with Kind=Group' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + + $entitlement = [pscustomobject]@{ + Kind = 'Group' + Id = [guid]::NewGuid().ToString() + } + + $result1 = $script:EntProvider.GrantEntitlement($userId, $entitlement) + $result1.Changed | Should -Be $true + + $result2 = $script:EntProvider.GrantEntitlement($userId, $entitlement) + $result2.Changed | Should -Be $false + } + + It 'RevokeEntitlement is idempotent (after a grant) with Kind=Group' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + + $entitlement = [pscustomobject]@{ + Kind = 'Group' + Id = [guid]::NewGuid().ToString() + } + + [void]$script:EntProvider.GrantEntitlement($userId, $entitlement) + + $result1 = $script:EntProvider.RevokeEntitlement($userId, $entitlement) + $result1.Changed | Should -Be $true + + $result2 = $script:EntProvider.RevokeEntitlement($userId, $entitlement) + $result2.Changed | Should -Be $false + } + + It 'ListEntitlements reflects grant and revoke operations with Kind=Group' { + $userId = [guid]::NewGuid().ToString() + [void]$script:EntProvider.GetIdentity($userId) + + $entitlement = [pscustomobject]@{ + Kind = 'Group' + Id = [guid]::NewGuid().ToString() + } + + $before = @($script:EntProvider.ListEntitlements($userId)) + + [void]$script:EntProvider.GrantEntitlement($userId, $entitlement) + $afterGrant = @($script:EntProvider.ListEntitlements($userId)) + + [void]$script:EntProvider.RevokeEntitlement($userId, $entitlement) + $afterRevoke = @($script:EntProvider.ListEntitlements($userId)) + + @($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 + } +}