Skip to content
Merged
32 changes: 32 additions & 0 deletions docs/extend/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,38 @@ their implementation beyond contract usage.

---

## Entitlement providers

An entitlement is identified by `Kind` (e.g. `Group`, `AdministrativeUnit`) and `Id`. The entitlement steps
(`IdLE.Step.EnsureEntitlement`, `IdLE.Step.PruneEntitlements`, `IdLE.Step.PruneEntitlementsEnsureKeep`) and the
`IdLE.Entitlement.List` context resolver call these methods:

| Method | Contract |
| --- | --- |
| `ListEntitlements(IdentityKey[, AuthSession][, Kind])` | Returns entitlement objects with `Kind` and `Id`. The optional `Kind` parameter filters the result; callers pass it when the method declares it. |
| `GrantEntitlement(IdentityKey, Entitlement[, AuthSession])` | Idempotent; returns a result with `Changed`, `IdentityKey`, `Entitlement`. |
| `RevokeEntitlement(IdentityKey, Entitlement[, AuthSession])` | Idempotent; same result shape. |
| `ResolveEntitlement(Entitlement[, AuthSession])` (optional) | Returns the entitlement with its canonical `Id` (e.g. a group DN for a sAMAccountName), so that prune Keep entries match `ListEntitlements` output. The Kind comes from `Entitlement.Kind`. |
| `BulkGrantEntitlements` / `BulkRevokeEntitlements(IdentityKey, Entitlements[, AuthSession])` (optional) | Per-item results with `Changed`, `Error`, `Entitlement`. |

**Kinds:** a provider supports a fixed set of Kinds and **rejects every other Kind** (including misspellings) with an
error before changing anything, in `ListEntitlements(Kind)` as well as in Grant, Revoke, Resolve and the bulk methods.
Kinds compare case-insensitively; providers return the canonical casing.

| Provider | Supported Kinds |
| --- | --- |
| AD | `Group` |
| Entra ID | `Group`, `AdministrativeUnit` |
| Mock | any non-empty Kind |

This keeps workflow mistakes visible: without the check, an `AdministrativeUnit` entitlement on AD would be resolved as
a group with the same name, and a prune with an unsupported Kind would silently find nothing to remove.

Migration from earlier versions: `ResolveEntitlement($Kind, $Entitlement, $AuthSession)` is now
`ResolveEntitlement($Entitlement, $AuthSession)`.

---

## Auth session acquisition (AuthSessionBroker)

Many providers require authenticated connections (tokens, API clients, remote sessions).
Expand Down
10 changes: 6 additions & 4 deletions docs/reference/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,12 @@ Each resolver writes to a **provider/auth-scoped source-of-truth path** under `R
Request.Context.Providers.<ProviderAlias>.<AuthSessionKey>.<CapabilitySubPath>
```

| Capability | CapabilitySubPath | Required `With` keys |
|---|---|---|
| `IdLE.Entitlement.List` | `Identity.Entitlements` | `IdentityKey` (string) |
| `IdLE.Identity.Read` | `Identity.Profile` | `IdentityKey` (string) |
| Capability | CapabilitySubPath | Required `With` keys | Optional `With` keys |
|---|---|---|---|
| `IdLE.Entitlement.List` | `Identity.Entitlements` | `IdentityKey` (string) | `Kind` (string): list only this entitlement Kind |
| `IdLE.Identity.Read` | `Identity.Profile` | `IdentityKey` (string) | |

Only one resolver per capability, provider and auth session is allowed.

Where `<AuthSessionKey>` is `Default` when `With.AuthSessionName` is not specified.

Expand Down
1 change: 1 addition & 0 deletions docs/reference/cmdlets/New-IdleADIdentityProvider.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ It requires the ActiveDirectory PowerShell module (RSAT) and runs on Windows onl

The provider supports common identity operations (Create, Read, Disable, Enable, Move, Delete)
and group entitlement management (List, Grant, Revoke).
The only supported entitlement Kind is 'Group'; any other Kind is rejected.

Identity addressing supports:
- GUID (ObjectGuid) - 36 hex characters with hyphens (D-format) or 32 hex characters (N-format)
Expand Down
1 change: 1 addition & 0 deletions docs/reference/cmdlets/New-IdleEntraIDIdentityProvider.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ via the host-provided AuthSessionBroker pattern.

The provider supports common identity operations (Create, Read, Disable, Enable, Delete)
and entitlement management for groups and Administrative Units (List, Grant, Revoke).
Supported entitlement Kinds are 'Group' and 'AdministrativeUnit'; any other Kind is rejected.

Identity addressing supports:
- objectId (GUID string) - most deterministic
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/providers/provider-ad.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ $provider = New-IdleADIdentityProvider -AllowDelete

- **Identity addressing:** GUID (ObjectGuid), UPN, or sAMAccountName (fallback)
- **Safety defaults:** deletion is disabled unless you pass `-AllowDelete`
- **Entitlements:** groups only (`Kind='Group'`)
- **Entitlements:** groups only (`Kind='Group'`); entitlement operations with any other Kind (e.g. `AdministrativeUnit`) fail instead of being treated as a group

## Attribute handling

Expand Down
6 changes: 4 additions & 2 deletions docs/reference/providers/provider-entraID.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ Writes to scoped path: `Request.Context.Providers.<ProviderAlias>.<AuthSessionKe
Engine-defined View: `Request.Context.Views.Identity.Entitlements`
Type: `object[]` (array of `PSCustomObject`, `PSTypeName = 'IdLE.Entitlement'`)

Each element represents one entitlement (group membership or Administrative Unit membership):
Each element represents one entitlement (group membership or Administrative Unit membership).
Supported Kinds are `Group` and `AdministrativeUnit`; entitlement operations with any other Kind fail. With the
resolver's `With.Kind`, only that Kind is listed.

**Group entitlements (`Kind = 'Group'`):**

Expand Down Expand Up @@ -165,7 +167,7 @@ This provider has **no provider-specific option bag**. Configuration is done thr
At minimum, you typically need:
- **Users:** read/write (create/update/disable/delete if enabled)
- **Groups:** read/write memberships (if you use group entitlement steps)
- **Administrative Units:** read/write memberships — only required when using `Kind = 'AdministrativeUnit'` in entitlement steps. Group-only workflows do not need these permissions because `ListEntitlements` skips AU Graph calls when `Kind = 'Group'` is specified.
- **Administrative Units:** read/write memberships — only required when using `Kind = 'AdministrativeUnit'` in entitlement steps. Group-only workflows do not need these permissions because `ListEntitlements` skips AU Graph calls when `Kind = 'Group'` is specified. The entitlement steps always pass their Kind; an `IdLE.Entitlement.List` context resolver needs `With.Kind = 'Group'`, otherwise it lists both Kinds.

Exact permission names depend on your auth model (delegated vs application) and what operations you enable.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/providers/provider-mock.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Each element is normalized via `ConvertToEntitlement`:
| Property | Type | Notes |
| --- | --- | --- |
| `PSTypeName` | `string` | Always `IdLE.Entitlement`. |
| `Kind` | `string` | Required; non-empty. |
| `Kind` | `string` | Required; non-empty. Any Kind is accepted (test provider). |
| `Id` | `string` | Required; non-empty. |

Notes:
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/steps/step-ensure-entitlement.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ as -Provider. The provider implements:
The step is idempotent and only calls Grant/Revoke when the assignment needs
to change.

With.Entitlement.Kind selects the entitlement type (e.g., 'Group', 'AdministrativeUnit').
The provider rejects a Kind it does not support (e.g., 'AdministrativeUnit' on AD).

Authentication is handled by the engine (With.AuthSessionName, With.AuthSessionOptions).

## Inputs (With.*)
Expand Down
9 changes: 7 additions & 2 deletions docs/reference/steps/step-prune-entitlements-ensure-keep.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ cannot be granted reliably. If you need to protect entitlements via wildcard mat
granting them, run IdLE.Step.PruneEntitlements or another cleanup step before this EnsureKeep
step.

With.Kind is the prune scope: only current entitlements of this Kind are removed, and Keep
entries are granted with this Kind. Keep entries inherit With.Kind; an entry may repeat Kind, but
a different Kind fails the step before anything is changed. The provider rejects a Kind it does
not support (e.g., 'AdministrativeUnit' on AD). To prune several Kinds, use one step per Kind.

With.Keep entries -> kept (NOT removed) AND ensured (GRANTED if currently missing). After this
step completes, every identity referenced by With.Keep is guaranteed to hold that entitlement —
regardless of whether it was already present.
Expand Down Expand Up @@ -94,7 +99,7 @@ Validated at plan time against the step's ``WithSchema`` (see [Step metadata con
Kind = 'Group'
# KEPT + GRANTED if missing: after the step, the identity is guaranteed to be a member.
Keep = @(
@{ Kind = 'Group'; Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' }
@{ Id = 'CN=LEAVER-RETAIN,OU=Groups,DC=contoso,DC=com' }
)
# Pattern-based retention is not supported by EnsureKeep. Use IdLE.Step.PruneEntitlements
# earlier in the workflow if you must preserve wildcard-matched entitlements without grants.
Expand All @@ -118,7 +123,7 @@ Validated at plan time against the step's ``WithSchema`` (see [Step metadata con
Kind = 'License'
# This license is KEPT and GRANTED if missing — always present after this step.
Keep = @(
@{ Kind = 'License'; Id = 'BASELINE-E1' }
@{ Id = 'BASELINE-E1' }
)
AuthSessionName = 'Licensing'
}
Expand Down
10 changes: 8 additions & 2 deletions docs/reference/steps/step-prune-entitlements.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,15 @@ Use IdLE.Step.PruneEntitlementsEnsureKeep instead when you also need to guarante
explicit Keep entries are present after the prune (e.g., a leaver-retention group must be granted
if it is missing).

With.Kind is the prune scope: only current entitlements of this Kind are considered for removal.
The provider rejects a Kind it does not support (e.g., 'AdministrativeUnit' on AD) before anything
is removed. To prune several Kinds, use one step per Kind.

How the keep-set is built:

- With.Keep — explicit entitlement references (kept AND matched case-insensitively by Id)
- With.Keep — explicit entitlement references (kept AND matched case-insensitively by Id).
Entries inherit With.Kind; an entry may repeat Kind, but a different Kind fails
the step before anything is removed.

- With.KeepPattern — wildcard strings (-like semantics); any current entitlement whose Id matches
is kept. Patterns are NEVER granted, only protected from removal.
Expand Down Expand Up @@ -109,7 +115,7 @@ Validated at plan time against the step's ``WithSchema`` (see [Step metadata con
Kind = 'Group'
Keep = @(
# Kept if currently a member — but NOT granted if missing.
@{ Kind = 'Group'; Id = 'CN=All-Users,OU=Groups,DC=contoso,DC=com' }
@{ Id = 'CN=All-Users,OU=Groups,DC=contoso,DC=com' }
)
KeepPattern = @('CN=LEAVER-*,OU=Groups,DC=contoso,DC=com')
AuthSessionName = 'Directory'
Expand Down
14 changes: 14 additions & 0 deletions docs/use/workflows/context-resolver.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
title: Context Resolvers
sidebar_label: Context Resolvers
Expand Down Expand Up @@ -172,6 +172,10 @@
| `Provider` | `string` | No | Provider alias. If omitted, IdLE auto-selects a provider advertising the capability. Ambiguity (multiple providers matching) is a fail-fast error. Also used to determine `<ProviderAlias>` in the scoped path. |
| `AuthSessionName` | `string` | No | Named auth session key. Becomes `<AuthSessionKey>` in the scoped path. If omitted, `Default` is used. Requires an `AuthSessionBroker` entry in `Providers`. Must be a valid path segment (no dots). |
| `AuthSessionOptions` | `hashtable` | No | Options passed to `AuthSessionBroker.AcquireAuthSession`. Must be a hashtable. ScriptBlocks are rejected. |
| `Kind` | `string` | No | `IdLE.Entitlement.List` only: list only entitlements of this Kind (e.g. `Group`). Passed to the provider, which may then skip other directory objects and their permissions (Entra ID: no Administrative Unit calls for `Group`). The result is always filtered to this Kind. The provider rejects a Kind it does not support. |

Each resolver owns its scoped path, so only **one resolver per capability, provider and auth session** is allowed;
a second one fails planning. To get several entitlement Kinds from one provider and session, omit `Kind`.

---

Expand Down Expand Up @@ -218,6 +222,16 @@
# Views.Identity.Entitlements (merged, both providers)
```

### Group entitlements only

List only groups, e.g. to avoid the Administrative Unit Graph permissions on Entra ID:

```powershell
ContextResolvers = @(
@{ Capability = 'IdLE.Entitlement.List'; With = @{ IdentityKey = 'user1'; Provider = 'Entra'; Kind = 'Group' } }
)
```

### Step-relative precondition using Current

Use `Request.Context.Current.*` in a step's `Precondition` to check the scoped context
Expand Down
3 changes: 3 additions & 0 deletions examples/workflows/mock/joiner-with-context-resolvers.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
# Provider alias that supports IdLE.Entitlement.List.
# If omitted, the provider is auto-selected when exactly one match exists.
Provider = 'Identity'
# Optional: list only this entitlement Kind (omit to list all Kinds).
# Only one resolver per capability, provider and auth session is allowed.
Kind = 'Group'
}
}
)
Expand Down
2 changes: 1 addition & 1 deletion examples/workflows/templates/ad-leaver.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@

# Explicitly retain this group and ensure it is present after pruning.
Keep = @(
@{ Kind = 'Group'; Id = '{{Request.Intent.LeaverRetainGroupDn}}' }
@{ Id = '{{Request.Intent.LeaverRetainGroupDn}}' }
)
# Pattern-based retention is not supported by PruneEntitlementsEnsureKeep. Use a
# separate IdLE.Step.PruneEntitlements step earlier in the workflow if you need to
Expand Down
4 changes: 2 additions & 2 deletions examples/workflows/templates/entraid-leaver.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@

# Retain this specific leaver group and ensure it is present.
Keep = @(
@{ Kind = 'Group'; Id = '{{Request.Intent.LeaverRetainGroupId}}' }
@{ Id = '{{Request.Intent.LeaverRetainGroupId}}' }
)
# Pattern-based retention is not supported by PruneEntitlementsEnsureKeep. Use a
# separate IdLE.Step.PruneEntitlements step earlier if you must protect wildcard
Expand Down Expand Up @@ -148,7 +148,7 @@
# This AU is retained AND guaranteed to be present after the step.
# Reference by objectId (GUID) or by displayName (must be tenant-unique).
Keep = @(
@{ Kind = 'AdministrativeUnit'; Id = '{{Request.Intent.RetainAdministrativeUnitId}}' }
@{ Id = '{{Request.Intent.RetainAdministrativeUnitId}}' }
)
}
}
Expand Down
53 changes: 49 additions & 4 deletions src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ function Invoke-IdleContextResolvers {
capability; zero matches or multiple matches both cause a fail-fast error.
- Auth sessions are supported via With.AuthSessionName / With.AuthSessionOptions,
using the AuthSessionBroker in Providers (same pattern as step execution).
- At most one resolver per capability, provider alias and AuthSessionKey (each owns its scoped path).
- IdLE.Entitlement.List accepts an optional With.Kind: it is passed to ListEntitlements when the
provider declares a Kind parameter, and the result is always filtered to that Kind.

This function mutates Request.Context in place so that subsequent condition evaluation
can reference the resolved data via scoped paths or Views.
Expand Down Expand Up @@ -66,6 +69,7 @@ function Invoke-IdleContextResolvers {
}

$readOnlyCapabilities = @(Get-IdleReadOnlyCapabilities)
$resolvedOutputs = @{}

$i = 0
foreach ($resolver in @($Resolvers)) {
Expand Down Expand Up @@ -123,10 +127,22 @@ function Invoke-IdleContextResolvers {
$authSession = $null
$authBroker = Get-IdleAuthSessionBroker -Providers $Providers
$authSessionKey = 'Default'
if ($with.Contains('AuthSessionName') -and -not [string]::IsNullOrWhiteSpace([string]$with.AuthSessionName)) {
$authSessionKey = [string]$with.AuthSessionName
}

# Each resolver owns its scoped output path; a second resolver for the same capability, provider and
# session would silently replace the first result (e.g. a 'Group' listing by an 'AdministrativeUnit' one).
$outputKey = "$capability|$resolvedProviderAlias|$authSessionKey"
if ($resolvedOutputs.ContainsKey($outputKey)) {
$message = "$resolverPath resolves capability '$capability' for provider '$resolvedProviderAlias' and auth session " +
"'$authSessionKey', which $($resolvedOutputs[$outputKey]) already resolves. Use one resolver per capability, provider and auth session."
throw [System.ArgumentException]::new($message, 'Workflow')
}
$resolvedOutputs[$outputKey] = $resolverPath

if ($with -is [System.Collections.IDictionary] -and $with.Contains('AuthSessionName') -and -not [string]::IsNullOrWhiteSpace([string]$with.AuthSessionName)) {
$sessionName = [string]$with.AuthSessionName
$authSessionKey = $sessionName
$sessionOptions = if ($with.Contains('AuthSessionOptions')) { $with.AuthSessionOptions } else { $null }
if ($null -ne $sessionOptions -and $sessionOptions -isnot [hashtable]) {
throw [System.ArgumentException]::new("$resolverPath 'With.AuthSessionOptions' must be a hashtable.", 'Workflow')
Expand Down Expand Up @@ -714,18 +730,47 @@ function Invoke-IdleResolverCapabilityDispatch {

$identityKey = [string]$With.IdentityKey

# Optional Kind narrows the listing (e.g. Entra ID then skips Administrative Unit calls and
# does not need their Graph permissions).
$kind = $null
if ($With.Contains('Kind') -and $null -ne $With.Kind) {
if ($With.Kind -isnot [string] -or [string]::IsNullOrWhiteSpace($With.Kind)) {
throw [System.ArgumentException]::new(
"$ResolverPath with capability 'IdLE.Entitlement.List': With.Kind must be a non-empty string.",
'Workflow'
)
}
$kind = [string]$With.Kind
}

$method = $provider.PSObject.Methods['ListEntitlements']
if ($null -eq $method) {
throw [System.InvalidOperationException]::new(
"${ResolverPath}: Provider '$ProviderAlias' does not implement 'ListEntitlements', which is required for capability 'IdLE.Entitlement.List'."
)
}

# Contract order: ListEntitlements(IdentityKey[, AuthSession][, Kind]).
$arguments = [System.Collections.Generic.List[object]]::new()
$arguments.Add($identityKey)
$supportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $method -ParameterName 'AuthSession'
if ($supportsAuthSession -and $null -ne $AuthSession) {
return @($provider.ListEntitlements($identityKey, $AuthSession))
$passKind = $null -ne $kind -and (Test-IdleProviderMethodParameter -ProviderMethod $method -ParameterName 'Kind')
if ($supportsAuthSession -and ($null -ne $AuthSession -or $passKind)) {
$arguments.Add($AuthSession)
}
if ($passKind) {
$arguments.Add($kind)
}
return @($provider.ListEntitlements($identityKey))

$entitlements = @($method.Invoke($arguments.ToArray()))
if ($null -eq $kind) {
return $entitlements
}

# Filtered here as well so that providers without a Kind parameter behave the same.
return @($entitlements | Where-Object {
[string]::Equals([string](Get-IdlePropertyValue -Object $_ -Name 'Kind'), $kind, [System.StringComparison]::OrdinalIgnoreCase)
})
}

'IdLE.Identity.Read' {
Expand Down
Loading
Loading