Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,13 @@ The engine (`IdLE.Core`) must **not** depend on:
- Hosts MUST provide an `AuthSessionBroker`, and steps/providers MUST acquire auth sessions via `Context.AcquireAuthSession(...)` rather than receiving raw credentials directly.
- Do not pass secrets or credential objects via provider options or workflow configuration; provider options must remain data-only (no ScriptBlocks, no executable objects).

#### 4.2.1 Capability naming convention
#### 4.2.1 Identifier namespaces (step types, capabilities)

- New work MUST use the IdLE. capability namespace (e.g., IdLE.Identity.Read, IdLE.Identity.Attribute.Ensure, IdLE.Entitlement.Grant).
- Do not introduce new un-namespaced capabilities (e.g., Identity.Read) in new modules.
- If legacy capability names exist, treat them as deprecated aliases and document migration behavior explicitly in the relevant issue/PR.
- `IdLE.*` is reserved for first-party modules shipped in the `IdLE` bundle (e.g., `IdLE.Step.DisableIdentity`, `IdLE.Identity.Read`).
- Extensions (third-party or host modules, including tests) use `<Owner>.IdLE.*` (e.g., `Contoso.IdLE.Step.Hr.SyncManager`, `Contoso.IdLE.Hr.Manager.Read`); tests use `Test.IdLE.*`.
- A new first-party capability MUST be added to `src/IdLE.Core/Data/ReservedNamespaces.psd1` and `docs/reference/capabilities.md` (kept in sync by tests).
- If legacy capability names exist, treat them as deprecated aliases (`Deprecated` in `ReservedNamespaces.psd1`) and document migration behavior explicitly in the relevant issue/PR.
- Rules and error codes: `docs/extend/steps.md` (Step type names) and `docs/reference/capabilities.md` (Naming rules).

### 4.3 Eventing

Expand Down
9 changes: 9 additions & 0 deletions docs/about/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ The public `Assert-IdleNoScriptBlock` function is the single, authoritative vali
These inputs are **provided by the host** and are **privileged** because they determine what code is executed:

- **Step registry** (maps `Step.Type` to a handler function name)
- **Step packs** (modules marked with `PrivateData.IdLE.Kind = 'StepPack'` that declare step types and handlers)
- **Provider modules** / provider objects (system-specific adapters)
- **External event sinks** (streaming events)
- **AuthSessionBroker** (host-provided authentication orchestration)
Expand All @@ -52,6 +53,14 @@ These inputs are **provided by the host** and are **privileged** because they de
**Rule:** Only trusted code should populate these extension points.
:::

Trust levels within these extension points:

- **First-party:** modules shipped in the loaded `IdLE` bundle and loaded from its folders. Only these may
define `IdLE.*` step types. Module names alone never make a module first-party.
- **Host-trusted:** every other loaded step pack or provider, and the host's own `Providers.StepRegistry` /
`Providers.StepMetadata`. They use their own `<Owner>.IdLE.*` namespace and can only add step types; they
cannot replace step types defined by another step pack.

These extension points may contain ScriptMethods (e.g., the `AcquireAuthSession` method on AuthSessionBroker objects) but should not contain ScriptBlock *properties* that could be confused with data.

#### AuthSessionBroker Trust Model
Expand Down
15 changes: 12 additions & 3 deletions docs/extend/extensibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ A new step typically involves:

See [Steps and Metadata](steps.md#step-metadata-contract) for the required metadata shape.

Extension step types and capabilities use your own owner prefix: `<Owner>.IdLE.Step.<Area>.<Name>` and
`<Owner>.IdLE.<Domain>.<Verb>` (e.g. `Contoso.IdLE.Step.Hr.SyncManager`, `Contoso.IdLE.Hr.Manager.Read`).
`IdLE.*` is reserved for the IdLE project; see [Step type names](steps.md#step-type-names).

Steps can emit structured events using the execution context contract:

- `Context.EventSink.WriteEvent(Type, Message, StepName, Data)`
Expand Down Expand Up @@ -73,7 +77,9 @@ during plan build to validate whether all required functionality is
available.

The full contract, naming rules, and validation behavior are described in
[Provider Capabilities](../reference/capabilities.md).
[Provider Capabilities](../reference/capabilities.md). Providers may advertise IdLE-defined capabilities
(`IdLE.*`, as listed in the catalog) and their own extension capabilities (`<Owner>.IdLE.*`); anything else
fails planning.

Providers should include the corresponding provider capability contract tests
to ensure compliance.
Expand Down Expand Up @@ -105,10 +111,13 @@ Those belong in a host application.

## Register step handlers

Steps are executed via a host-provided step registry.
Steps are executed via the step registry.

- Workflows reference steps by `Type` (identifier).
- The host maps this identifier to a **function name** (string) in the step registry.
- Step packs register their handlers with the `Handler` key in their metadata catalog
(see [Step packs](steps.md#step-packs)).
- For host-defined step types only, the host maps the identifier to a **function name** (string) in
`Providers.StepRegistry`. Step types defined by a step pack cannot be replaced.

ScriptBlock handlers are intentionally not supported as a secure default.

Expand Down
25 changes: 22 additions & 3 deletions docs/extend/steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,31 @@ so hosts do not need `Providers.StepRegistry` for step pack steps:
- The module does not need to be imported globally; nested modules work.
- `StepCatalog` must point to a file inside the module folder.
- A step type may be defined by only one step pack.
- `Providers.StepRegistry` still takes precedence over catalog handlers (e.g. to replace a step in tests).
- Discovery uses the manifest marker only. Module names are never used for discovery or ownership.
- `Providers.StepMetadata` must not declare `Handler`; host-defined steps register their handler via
`Providers.StepRegistry`.
- Host extension points only supplement step packs: `Providers.StepMetadata` and `Providers.StepRegistry`
cannot redefine or replace a step type defined by a step pack (`DuplicateStepTypeMetadata`,
`DuplicateStepHandler`).

Modules named `IdLE.Steps.*` that export `Get-IdleStepMetadataCatalog` but carry no marker are still
discovered. This fallback is deprecated.
### Step type names

Step type identifiers follow the namespace rules (owner first, then product):

| Defined by | Format | Example |
| --- | --- | --- |
| IdLE (first-party step packs in the `IdLE` bundle) | `IdLE.Step.<Area>.<Name>` | `IdLE.Step.Mailbox.GetInfo` |
| Extensions (third-party step packs, host steps) | `<Owner>.IdLE.Step.<Area>.<Name>` | `Contoso.IdLE.Step.Hr.SyncManager` |

- `IdLE.*` is reserved. Only modules shipped in the loaded `IdLE` bundle (and loaded from its folders) may
define `IdLE.*` step types; anywhere else they are rejected with `ReservedNamespace`.
- First-party step types that do not match `IdLE.Step.<Name>` or `IdLE.Step.<Area>.<Name>` are rejected with `InvalidFirstPartyStepType`.
- Extension step types that do not match `<Owner>.IdLE.Step.<...>` are rejected with `InvalidExtensionNamespace`.
- The rules apply to step pack catalogs, `Providers.StepMetadata` and `Providers.StepRegistry`.
- Name extension modules `<Owner>.IdLE.<Area>` (e.g. `Contoso.IdLE.Hr`); `IdLE.*` module names are reserved for the
IdLE project.

Capability identifiers follow the same scheme; see [Capabilities](../reference/capabilities.md#naming-rules).

### Provenance

Expand Down
24 changes: 20 additions & 4 deletions docs/reference/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,18 @@ For the conceptual model (why capabilities exist, how discovery/merging works, a

A capability is a **stable string identifier** describing a feature a provider can perform.

Rules:
Namespaces (enforced during planning):

- `IdLE.*` capabilities are defined by the IdLE project. Only the capabilities listed in the [catalog](#catalog)
are valid; any other `IdLE.*` capability is rejected with `UnknownCapability`. Providers (also third-party
providers) may advertise them, and steps may require them.
- Extensions define their own capabilities as `<Owner>.IdLE.<Domain>.<Verb>`, e.g. `Contoso.IdLE.Hr.Manager.Read`.
Anything else (e.g. `Contoso.Hr.Read`) is rejected with `InvalidExtensionNamespace`.
- The rules apply to capabilities advertised by providers (`GetCapabilities()`) and to capabilities required
by step metadata (`RequiredCapabilities`).

Format rules:

- Must start with the `IdLE.` prefix.
- Use **PascalCase** for each segment (e.g., `Identity`, `OutOfOffice`).
- Use **verbs** for the last segment whenever possible (`Read`, `List`, `Create`, `Disable`, `Ensure`).
- No provider names inside a capability ID. Capabilities describe *what* is possible, not *where* it is implemented.
Expand All @@ -40,7 +49,8 @@ Examples:

## Catalog

The catalog below lists capabilities currently referenced by the documentation and expected by steps/providers.
The catalog below is the complete list of IdLE-defined capabilities. It matches the list in
`src/IdLE.Core/Data/ReservedNamespaces.psd1` (enforced by tests).

### Identity

Expand Down Expand Up @@ -68,6 +78,9 @@ Delete an identity/account (hard deprovision). Typically irreversible; steps sho
#### `IdLE.Identity.Attribute.Ensure`
Ensure one or more attributes on an identity match desired state (set/clear/replace). Provider decides how to map logical attribute names to system fields.

#### `IdLE.Identity.RevokeSessions`
Revoke active sign-in sessions and refresh tokens of an identity (e.g., force sign-out during offboarding).

### Entitlements

#### `IdLE.Entitlement.List`
Expand All @@ -93,8 +106,11 @@ Ensure mailbox type/config matches desired state (e.g., shared vs user mailbox,
#### `IdLE.Mailbox.OutOfOffice.Ensure`
Ensure out-of-office / automatic replies configuration matches desired state.

#### `IdLE.Mailbox.Permissions.Ensure`
Ensure mailbox delegate permissions (e.g., FullAccess, SendAs, SendOnBehalf) match desired state.

> Compatibility note: older docs or experiments may reference `IdLE.Mailbox.Read`. Prefer `IdLE.Mailbox.Info.Read` for metadata-only access, to avoid ambiguity with reading message contents.
> Compatibility note: `IdLE.Mailbox.Read` is a deprecated alias of `IdLE.Mailbox.Info.Read`. It is still accepted
> (with a warning) and mapped to `IdLE.Mailbox.Info.Read`.

### Directory synchronization

Expand Down
55 changes: 55 additions & 0 deletions src/IdLE.Core/Data/ReservedNamespaces.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# ReservedNamespaces.psd1 - IdLE.Core
#
# Data-only single source of truth for identifier namespaces (no ScriptBlocks).
#
# Rules (owner first, then product):
# - 'IdLE.' is reserved for the IdLE project (first-party modules shipped in the IdLE bundle).
# - Extensions (every other module, host or organization) use '<Owner>.IdLE.<...>', e.g. 'Contoso.IdLE.Step.Hr.SyncManager'.
#
# Each identifier kind declares:
# ExtensionPattern - regex an extension identifier must match
# Example - extension example used in error messages
@{
ReservedPrefix = 'IdLE.'

StepType = @{
FirstPartyPattern = '^IdLE\.Step(\.[A-Za-z][A-Za-z0-9]*)+$'
ExtensionPattern = '^(?!IdLE\.)[A-Za-z][A-Za-z0-9]*\.IdLE\.Step(\.[A-Za-z][A-Za-z0-9]*)+$'
Example = 'Contoso.IdLE.Step.Hr.SyncManager'
}

Capability = @{
ExtensionPattern = '^(?!IdLE\.)[A-Za-z][A-Za-z0-9]*\.IdLE(\.[A-Za-z][A-Za-z0-9]*){2,}$'
Example = 'Contoso.IdLE.Hr.Manager.Read'

# Capabilities defined by IdLE. 'IdLE.*' capabilities not listed here are rejected,
# whether advertised by a provider or required by step metadata.
# Keep in sync with docs/reference/capabilities.md (enforced by tests).
Defined = @(
'IdLE.DirectorySync.Status'
'IdLE.DirectorySync.Trigger'
'IdLE.Entitlement.Grant'
'IdLE.Entitlement.List'
'IdLE.Entitlement.Prune'
'IdLE.Entitlement.Revoke'
'IdLE.Identity.Attribute.Ensure'
'IdLE.Identity.Create'
'IdLE.Identity.Delete'
'IdLE.Identity.Disable'
'IdLE.Identity.Enable'
'IdLE.Identity.List'
'IdLE.Identity.Move'
'IdLE.Identity.Read'
'IdLE.Identity.RevokeSessions'
'IdLE.Mailbox.Info.Read'
'IdLE.Mailbox.OutOfOffice.Ensure'
'IdLE.Mailbox.Permissions.Ensure'
'IdLE.Mailbox.Type.Ensure'
)

# Deprecated capability -> replacement. Accepted with a warning and mapped to the replacement.
Deprecated = @{
'IdLE.Mailbox.Read' = 'IdLE.Mailbox.Info.Read'
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ function Assert-IdlePlanCapabilitiesSatisfied {
Fail-fast validation executed during planning.
If one or more capabilities are missing, an ArgumentException is thrown with a
deterministic error message listing missing capabilities and affected steps.

Advertised provider capabilities are validated on every planning run (namespace rules), even if no
step requires a capability.
#>
[CmdletBinding()]
param(
Expand All @@ -21,6 +24,9 @@ function Assert-IdlePlanCapabilitiesSatisfied {
[object] $Providers
)

# Validates advertised capabilities (IdLE-defined or '<Owner>.IdLE.*'); throws on invalid identifiers.
$available = @(Get-IdleAvailableCapabilities -Providers $Providers)

if ($null -eq $Steps -or @($Steps).Count -eq 0) {
return
}
Expand Down Expand Up @@ -52,8 +58,6 @@ function Assert-IdlePlanCapabilitiesSatisfied {
return
}

$available = @(Get-IdleAvailableCapabilities -Providers $Providers)

$missing = @()
foreach ($c in $required) {
if ($available -notcontains $c) {
Expand Down
10 changes: 9 additions & 1 deletion src/IdLE.Core/Private/ConvertTo-IdleCapabilityList.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,19 @@ function ConvertTo-IdleCapabilityList {

if ($Validate -and -not (Test-IdleCapabilityIdentifier -Capability $s)) {
throw [System.ArgumentException]::new(
"$ErrorPrefix '$s' is invalid. Expected dot-separated segments like 'IdLE.Identity.Read' or 'IdLE.Entitlement.Write'.",
"$ErrorPrefix '$s' is invalid. Expected dot-separated segments like 'IdLE.Identity.Read'.",
'Capabilities'
)
}

if ($Validate) {
# IdLE-defined capabilities or extension capabilities ('<Owner>.IdLE.*') only.
$namespaceError = Get-IdleIdentifierNamespaceError -Kind 'Capability' -Identifier $s
if ($null -ne $namespaceError) {
throw [System.ArgumentException]::new("$namespaceError ($ErrorPrefix)", 'Capabilities')
}
}

if ($Normalize) {
$s = ConvertTo-IdleNormalizedCapability -Capability $s
}
Expand Down
7 changes: 2 additions & 5 deletions src/IdLE.Core/Private/ConvertTo-IdleNormalizedCapability.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,8 @@ function ConvertTo-IdleNormalizedCapability {
[string] $Capability
)

# Deprecated capability ID mappings (pre-1.0)
# Format: @{ 'OldID' = 'NewID' }
$deprecatedMappings = @{
'IdLE.Mailbox.Read' = 'IdLE.Mailbox.Info.Read'
}
# Deprecated capability ID mappings (@{ 'OldID' = 'NewID' }) live in Data/ReservedNamespaces.psd1.
$deprecatedMappings = (Get-IdleReservedNamespaces).Capability.Deprecated

$normalized = $Capability.Trim()

Expand Down
3 changes: 2 additions & 1 deletion src/IdLE.Core/Private/Get-IdleAvailableCapabilities.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ function Get-IdleAvailableCapabilities {
if ($null -eq $caps) {
return @()
}
return @(ConvertTo-IdleCapabilityList -Capabilities $caps -Normalize -Unique)
# Validate at planning time: providers may only advertise IdLE-defined or '<Owner>.IdLE.*' capabilities.
return @(ConvertTo-IdleCapabilityList -Capabilities $caps -Validate -Normalize -Unique -ErrorPrefix 'Provider capability')
}

return @()
Expand Down
72 changes: 72 additions & 0 deletions src/IdLE.Core/Private/Get-IdleIdentifierNamespaceError.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
function Get-IdleIdentifierNamespaceError {
<#
.SYNOPSIS
Checks an identifier against the namespace rules and returns an error message, or $null if valid.

.DESCRIPTION
Rules (see Data/ReservedNamespaces.psd1):
- StepType: 'IdLE.*' only from first-party modules; extensions use '<Owner>.IdLE.Step.<...>'.
- Capability: 'IdLE.*' only if defined by IdLE (or a deprecated alias); extensions use '<Owner>.IdLE.<Domain>.<Verb>'.

Messages start with a stable error code (ReservedNamespace, InvalidExtensionNamespace, UnknownCapability).

.PARAMETER Kind
Identifier kind: StepType or Capability.

.PARAMETER Identifier
The identifier to check.

.PARAMETER FirstParty
The identifier is defined by a first-party module (StepType only).

.OUTPUTS
System.String (error message) or $null.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('StepType', 'Capability')]
[string] $Kind,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $Identifier,

[Parameter()]
[switch] $FirstParty
)

$rules = Get-IdleReservedNamespaces
$kindRules = $rules[$Kind]
$label = if ($Kind -eq 'StepType') { 'Step type' } else { 'Capability' }

if ($Identifier.StartsWith($rules.ReservedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
if ($Kind -eq 'Capability') {
if ($kindRules.Defined -contains $Identifier -or $kindRules.Deprecated.ContainsKey($Identifier)) {
return $null
}

$hint = "Extensions define their own capabilities as '<Owner>.IdLE.<Domain>.<Verb>', e.g. '$($kindRules.Example)'."
return "UnknownCapability: Capability '$Identifier' is not defined by IdLE (see docs/reference/capabilities.md). $hint"
}

if ($FirstParty) {
# Catch catalog typos such as 'IdLE.Foo.Bar' before they become valid workflow step types.
if ($Identifier -notmatch $kindRules.FirstPartyPattern) {
return "InvalidFirstPartyStepType: $label '$Identifier' must follow 'IdLE.Step.<Name>' (e.g. 'IdLE.Step.EmitEvent' or 'IdLE.Step.Mailbox.GetInfo')."
}

return $null
Comment thread
blindzero marked this conversation as resolved.
Resolved
}

$suffix = $Identifier.Substring($rules.ReservedPrefix.Length)
$hint = "Use '<Owner>.IdLE.$suffix', e.g. 'Contoso.IdLE.$suffix'."
return "ReservedNamespace: $label '$Identifier' uses the reserved prefix '$($rules.ReservedPrefix)'. $hint"
}

if ($Identifier -notmatch $kindRules.ExtensionPattern) {
return "InvalidExtensionNamespace: $label '$Identifier' must follow '<Owner>.IdLE.<...>', e.g. '$($kindRules.Example)'."
}

return $null
}
23 changes: 23 additions & 0 deletions src/IdLE.Core/Private/Get-IdleReservedNamespaces.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
function Get-IdleReservedNamespaces {
<#
.SYNOPSIS
Returns the identifier namespace rules (Data/ReservedNamespaces.psd1).

.DESCRIPTION
The rules are data-only and loaded once per module session.

.OUTPUTS
Hashtable with ReservedPrefix and one entry per identifier kind (StepType, Capability).
#>
[CmdletBinding()]
param()

$rules = Get-Variable -Name 'IdleReservedNamespaces' -Scope Script -ValueOnly -ErrorAction SilentlyContinue
if ($null -eq $rules) {
$path = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'Data/ReservedNamespaces.psd1'
$rules = Import-PowerShellDataFile -LiteralPath $path
Set-Variable -Name 'IdleReservedNamespaces' -Scope Script -Value $rules
}

return $rules
}
Loading
Loading