diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..ca0086ca --- /dev/null +++ b/.editorconfig @@ -0,0 +1,42 @@ +# IdLE .editorconfig +# Enforces formatting rules from STYLEGUIDE.md (PowerShell 7+, 4 spaces, UTF-8, LF) +# See: STYLEGUIDE.md / CONTRIBUTING.md + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +tab_width = 4 +trim_trailing_whitespace = true + +# PowerShell source and data files +[*.{ps1,psm1,psd1}] +indent_style = space +indent_size = 4 +tab_width = 4 +trim_trailing_whitespace = true + +# Markdown: keep trailing whitespace (can be meaningful for line breaks) +[*.md] +trim_trailing_whitespace = false +indent_style = space +indent_size = 4 + +# JSON/YAML: keep common 2-space indentation (editor convenience; not in conflict with core rules) +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 +tab_width = 2 +trim_trailing_whitespace = true + +# Shell scripts (if any) +[*.{sh,bash}] +end_of_line = lf +indent_style = space +indent_size = 2 +tab_width = 2 +trim_trailing_whitespace = true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..5013c928 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Report a bug in IdentityLifecycleEngine (IdLE) +title: "[Bug] " +labels: bug +assignees: "" +--- + +## Description + +A clear and concise description of the bug. + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + +What you expected to happen. + +## Actual Behavior + +What actually happened. + +## Environment + +- PowerShell version: +- OS: +- IdLE version / commit: +- Execution context (CLI / Service / CI): + +## Additional Context + +Add any other context, logs, or screenshots here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..6ff504a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,28 @@ +--- +name: Feature request +about: Suggest an enhancement or new feature for IdLE +title: "[Feature] " +labels: enhancement +assignees: "" +--- + +## Problem Statement + +Describe the problem this feature would solve. + +## Proposed Solution + +Describe what you would like to see implemented. + +## Alternatives Considered + +Describe any alternative solutions you have considered. + +## Impact + +- Does this affect existing workflows? +- Any backward compatibility concerns? + +## Additional Context + +Add any other context or examples here. diff --git a/.github/ISSUE_TEMPLATE/pull_request_template.md b/.github/ISSUE_TEMPLATE/pull_request_template.md new file mode 100644 index 00000000..c598ea6a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pull_request_template.md @@ -0,0 +1,43 @@ +## Summary + +Provide a short summary of the changes. + +## Motivation + +Why is this change needed? What problem does it solve? + +## Changes + +- +- +- + +## Type of Change + +Please select the relevant option: + +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation update +- [ ] Refactoring / internal improvement + +## Testing + +Describe how this change was tested. + +- [ ] Unit tests +- [ ] Contract tests +- [ ] Manual testing + +## Checklist + +- [ ] Code follows STYLEGUIDE.md +- [ ] Tests added or updated +- [ ] Documentation updated +- [ ] No UI/auth logic added to IdLE.Core +- [ ] No breaking changes without discussion + +## Related Issues + +Link related issues here (if any). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..6387aef2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,154 @@ +# Contributing to IdentityLifecycleEngine (IdLE) + +Thank you for your interest in contributing to **IdentityLifecycleEngine (IdLE)**! 🎉 +We welcome contributions that improve quality, stability, and maintainability. + +This document follows common **GitHub open-source conventions** and explains **how to contribute**. +For detailed coding rules, see **STYLEGUIDE.md**. + +--- + +## Code of Conduct + +This project expects respectful and constructive collaboration. +(If a CODE_OF_CONDUCT.md is added, it applies to all contributors.) + +--- + +## How Can I Contribute? + +You can contribute by: + +- Reporting bugs +- Suggesting enhancements +- Improving documentation +- Submitting pull requests + +--- + +## Reporting Bugs + +Please open a GitHub Issue and include: + +- a clear and descriptive title +- steps to reproduce +- expected vs. actual behavior +- environment details (PowerShell version, OS) + +--- + +## Suggesting Enhancements + +Enhancement proposals should: + +- explain the problem being solved +- explain why it fits IdLE’s architecture +- consider backward compatibility + +--- + +## Development Setup + +### Prerequisites + +- PowerShell Core 7+ +- Git +- Visual Studio Code (recommended) + +### Recommended IDE & extensions (optional) + +- Visual Studio Code +- Extensions: + - PowerShell + - EditorConfig + - Markdown All in One + +### Clone the Repository + +```bash +git clone https://github.com/blindzero/IdentityLifecycleEngine.git +``` + +--- + +## Development Workflow + +### Branching Model + +- `main` → stable +- feature branches: + - `feature/` + - `fix/` + +--- + +### Commit Messages + +- Use clear, concise English +- One logical change per commit + +Recommended format: + +```shell +: +``` + +Example: + +```shell +core: add strict workflow validation +``` + +--- + +### Pull Requests + +1. Fork the repository (if external contributor) +2. Create a feature branch +3. Make your changes +4. Add or update tests +5. Update documentation if needed +6. Open a Pull Request against `main` + +Pull Requests must: + +- have a clear description of changes +- reference related issues (if applicable) +- pass all tests +- follow STYLEGUIDE.md + +--- + +## Definition of Done + +A contribution is complete when: + +- all tests pass (`Invoke-Pester -Path ./tests`) +- no architecture rules are violated (see `docs/01-architecture.md`) +- public APIs are documented (comment-based help for exported functions) +- documentation is updated where required: + - README.md (only high-level overview + pointers) + - docs/ (usage/concepts/examples) + - provider/step module READMEs if behavior/auth changes + +--- + +## Documentation + +Keep docs short and linkable: + +- README.md: landing page (what/why + 30s quickstart + links) +- docs/: architecture, usage, examples (small focused pages) +- examples/: runnable scripts and workflow samples + +Key links: + +- Docs map: `docs/00-index.md` +- Architecture: `docs/01-architecture.md` +- Examples: `docs/02-examples.md` +- Coding & in-code documentation rules: `STYLEGUIDE.md` + +--- + +Thank you for contributing 🚀 +— *IdLE Maintainers* diff --git a/README.md b/README.md index 3a91ba5c..fad106ed 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ IdLE aims to be: ## Features -- **Joiner / Mover / Leaver** orchestration (and custom scenarios) +- **Joiner / Mover / Leaver** orchestration (and custom life cycle events) - **Plan → Execute** flow (preview actions before applying them) - **Plugin step model** (`Test` / `Invoke`, optional `Rollback` later) - **Provider/Adapter pattern** (directory, SaaS, REST, file/mock…) @@ -53,7 +53,7 @@ IdLE aims to be: ### Option A — Clone & import locally (current) ```powershell -git clone [https://github.com/blindzero/IdentityLifecycleEngine](https://github.com/blindzero/IdentityLifecycleEngine) +git clone https://github.com/blindzero/IdentityLifecycleEngine cd IdentityLifecycleEngine Import-Module ./src/IdLE/IdLE.psd1 -Force @@ -71,101 +71,45 @@ Install-Module IdLE ## Quickstart -Typical flow: **Create request → Validate workflow → Build plan → Execute plan** +Run the end-to-end demo (Plan → Execute): ```powershell -# 1) Create a request (scenario + identity keys + desired state) -$request = New-IdleLifecycleRequest -Scenario Joiner -Actor 'alice@contoso.com' -CorrelationId (New-Guid) -IdentityKeys @{ - EmployeeId = '12345' - UPN = 'new.user@contoso.com' -} -DesiredState @{ - Attributes = @{ - Department = 'IT' - Title = 'Engineer' - } - Entitlements = @( - @{ Type = 'Group'; Value = 'APP-CRM-Users' } - @{ Type = 'License'; Value = 'M365_E3' } - ) -} - -# 2) Validate configuration/workflow compatibility -Test-IdleWorkflow -WorkflowPath ./workflows/joiner.psd1 -Request $request - -# 3) Build a plan (preview actions and warnings) -$plan = New-IdlePlan -WorkflowPath ./workflows/joiner.psd1 -Request $request -Providers $providers - -# Optional: inspect the plan -$plan.Actions | Format-Table - -# 4) Execute the plan -Invoke-IdlePlan -Plan $plan -Providers $providers +pwsh -File .\examples\run-demo.ps1 ``` ---- - -## Workflow Definitions (concept) - -Workflows are configuration-first (e.g., `.psd1`) and describe: +The demo shows: -- step sequence -- conditions (declarative, not arbitrary PowerShell expressions) -- required inputs / produced outputs +- creating a lifecycle request +- building a deterministic plan from a workflow definition (`.psd1`) +- executing the plan using a host-provided step registry -Example (illustrative): +Next steps: -```powershell -@{ - Name = 'Joiner - Standard' - Scenario = 'Joiner' - Steps = @( - @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } - @{ Name = 'EnsureAttributes'; Type = 'IdLE.Step.EnsureAttributes' } - @{ Name = 'EnsureEntitlements'; Type = 'IdLE.Step.EnsureEntitlements' } - @{ Name = 'Finalize'; Type = 'IdLE.Step.EmitSummary' } - ) -} -``` +- Usage & examples: `docs/02-examples.md` +- Architecture: `docs/01-architecture.md` +- Workflow samples: `examples/workflows/` +- Pester tests: `tests/` --- -## Providers & Steps +## Documentation -IdLE deliberately does not hardcode system access. Instead, it calls provider interfaces/ports. - -- **Steps**: reusable building blocks (e.g., ensure attribute, ensure entitlement, disable identity) -- **Providers**: concrete implementations (e.g., Entra ID, AD DS, REST API, file/mock) - -This keeps workflows stable even when the underlying systems change. - ---- +Start here: -## Event Stream / Auditing +- `docs/00-index.md` – documentation map +- `docs/01-architecture.md` – architecture and principles +- `docs/02-examples.md` – runnable examples + workflow snippets -Every run emits structured events (progress, audit, warnings, errors), typically including: +Project docs: -- `CorrelationId` -- `Actor` -- step name / outcome -- change summaries (plan diffs, applied actions) - -This enables integration into logging systems, SIEM, ticketing, or custom dashboards. - ---- - -## Testing - -Run the full test suite: - -```powershell -Invoke-Pester -Path ./tests -``` +- Contributing: `CONTRIBUTING.md` +- Style guide: `STYLEGUIDE.md` --- ## Contributing -PRs welcome. A few guiding principles: +PRs welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md) - Keep the core **host-agnostic** - Prefer **configuration** over hardcoding logic diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md new file mode 100644 index 00000000..4ccd8f3a --- /dev/null +++ b/STYLEGUIDE.md @@ -0,0 +1,124 @@ +# IdLE Style Guide + +This document defines **coding, documentation, and testing standards** +for **IdentityLifecycleEngine (IdLE)**. +It follows widely accepted **GitHub and PowerShell community conventions**. + +--- + +## General Principles + +- Prefer clarity over cleverness +- Fail early and explicitly +- Keep behavior deterministic +- Avoid hidden side effects + +--- + +## PowerShell Standards + +### PowerShell Version + +- PowerShell Core **7+ only** + +--- + +### Naming Conventions + +- Verb-Noun cmdlet naming +- Singular nouns +- Avoid abbreviations + +--- + +### Formatting + +- 4 spaces indentation +- UTF-8, LF +- One statement per line + +--- + +## Public APIs + +### Comment-Based Help (Required) + +All exported functions must include comment-based help with: + +- `.SYNOPSIS` +- `.DESCRIPTION` +- `.PARAMETER` +- `.EXAMPLE` +- `.OUTPUTS` + +Public APIs are part of the contract and must remain stable. + +--- + +## Inline Comments + +- Explain **why**, not **what** +- Avoid restating obvious code + +--- + +## Configuration Rules + +- PSD1 only +- No script blocks +- No PowerShell expressions +- Configuration must be data-only + +--- + +## Steps + +Steps must: + +- be idempotent +- produce data-only actions +- not perform authentication +- write only declared `State.*` outputs + +--- + +## Providers + +Providers: + +- handle authentication +- use `ExecutionContext.AcquireSession()` +- must be mockable +- must not assume global state + +--- + +## Testing + +- Pester only +- No live system calls in unit tests +- Providers require contract tests + +--- + +## Documentation Responsibilities + +Documentation *process* lives in **CONTRIBUTING.md**. +This style guide focuses on **in-code documentation rules** (comment-based help, inline comments). + +--- + +## Do's and Don'ts + +### Do + +- validate early +- write tests +- document decisions + +### Don't + +- add UI to the engine +- add auth to steps +- hide logic in config +- introduce global state diff --git a/docs/00-index.md b/docs/00-index.md new file mode 100644 index 00000000..611b1d36 --- /dev/null +++ b/docs/00-index.md @@ -0,0 +1,18 @@ +# Documentation + +This folder contains short, focused documentation pages for IdLE. + +## Start here + +- **README:** [`../README.md`](../README.md) +- **Architecture:** [`01-architecture.md`](`01-architecture.md`) +- **Examples & workflow snippets:** [`02-examples.md`](02-examples.md) + +## Repository docs + +- **Contributing:** [`../CONTRIBUTING.md`](../CONTRIBUTING.md) +- **Style guide:** [`../STYLEGUIDE.md`](../STYLEGUIDE.md) + +## Guiding principle + +Keep docs **short and linkable**. The README is a landing page; details live in `docs/` and runnable scripts live in `examples/`. diff --git a/docs/01-architecture.md b/docs/01-architecture.md new file mode 100644 index 00000000..b4ce0dd8 --- /dev/null +++ b/docs/01-architecture.md @@ -0,0 +1,340 @@ +# IdentityLifecycleEngine (IdLE) - Architecture + +## Decisions, Rules, and Rationale + +This document captures the **current architecture** of **IdentityLifecycleEngine (IdLE)** and the +**decisions we have made so far**, including the rationale behind them. + +--- + +## 1. Goals and Non-Goals + +### Goals + +- Provide a **generic**, **configurable** orchestration engine for Identity Lifecycle / JML scenarios. +- Be **portable**, **testable**, and **host-agnostic** (CLI, service, pipeline). +- Prefer **configuration over code** for workflows. +- Support **Plan → Execute** with deterministic execution and strong auditing via events. + +### Non-Goals + +- No UI framework, web server, or stateful host dependencies inside IdLE.Core. +- No DSL and no dynamic code execution from configuration. +- No automatic rollback orchestration. +- No deep-merge semantics for state outputs. +- No fully generic action executor in the engine (Actions are planned; Steps execute them). + +--- + +## 2. Headless Core + +The IdLE engine core is **headless**: + +- No interactive prompts +- No direct authentication flows +- No UI responsibilities +- No global session/caching requirements + +-> Ensures IdLE.Core runs identically in CLI, server, and test environments. +-> Keeps the engine deterministic and testable. +-> Prevents UI/auth dependencies from leaking into orchestration logic. + +--- + +## 3. Data-Driven Workflows + +### 3.1 Workflow and Metadata Formats + +- Workflows are defined in **PSD1**. +- Step metadata is defined in **PSD1**. +- YAML is explicitly postponed (dependency-free). +- JSON is avoided for authoring because it cannot contain comments and hard to read. + +-> PSD1 is built-in to PowerShell and supports comments. +-> Data-only files enable strict validation and safe review. + +--- + +## 4. Strict Validation + +IdLE uses **Strict** validation for configuration and planning: + +- Unknown keys are errors. +- Missing required inputs are errors. +- Mutually exclusive inputs are errors. +- Declarative conditions and paths are validated before execution. + +-> Avoids silent misconfiguration. +-> Shifts failures to **Plan/Validate time** instead of runtime. + +--- + +## 5. Conditions (Declarative Only) + +- Conditions are **declarative** objects, not PowerShell expressions. +- Allowed roots for condition paths: `Request`, `State`, `Policy` (optional). + +-> Declarative conditions are safe and statically validatable. +-> No expression parsing/execution reduces risk and runtime failures. + +--- + +## 6. References: Field-Based “From” Convention + +We use the field-based reference convention: + +- Literal value: `Value = ` +- Reference: `ValueFrom = 'Request.X'` or `ValueFrom = 'State.Y'` +- Optional fallback: `ValueDefault = ` + +**No implicit interpretation** of plain strings as references. + +- Readable in PSD1. +- Strictly validatable without executing code. +- Clean separation of literal vs. referenced values. +- Validate **paths** (`*From`) strictly. +- Do **not** enforce strong typing for all literals (Steps may validate/cast) - yet. + +--- + +## 7. Request vs. Plan vs. Execute + +### 7.1 LifecycleRequest + +**LifecycleRequest** is the domain input representing business intent: + +- LifecycleEvent (Joiner/Mover/Leaver/…) +- IdentityKeys (UPN, EmployeeId, ObjectId, …) +- DesiredState (attributes, entitlements, etc.) +- Changes (for mover lifecycle events) +- CorrelationId (required; generated if missing) + +### 7.2 LifecyclePlan + +**LifecyclePlan** is derived from Request + Workflow + Step Catalog: + +- Evaluated steps (run/skip via conditions) +- Planned data-only Actions +- Warnings/required inputs +- State outputs produced during planning (if applicable) +- Workflow identity (id/version) for audit + +### 7.3 Execute Phase + +**Execute** runs **only the plan**: + +- No re-evaluation of conditions +- No re-testing / re-planning +- Deterministic “do what the plan says” + +-> Enables preview and approval patterns. +-> Improves auditability and repeatability. +-> Avoids “plan drift” between preview and execution. + +--- + +## 8. Public Cmdlet API + +IdLE exposes four core cmdlets: + +| Cmdlet | Purpose | +| --- | --- | +| `Test-IdleWorkflow` | Validate workflow and step metadata (config correctness) | +| `New-IdleLifecycleRequest` | Create/normalize a LifecycleRequest | +| `New-IdlePlan` | Build a plan (preview) | +| `Invoke-IdlePlan` | Execute a plan deterministically | + +-> `Test-IdleWorkflow` is **not** an operational execution tool. +-> Operational flow: Request → Plan → Execute +-> Clear separation of responsibilities. +-> CI/CD-friendly workflow validation. +-> Easier testing and maintenance than monolithic cmdlets. + +--- + +## 9. Steps, Metadata, and Handler Resolution + +### 9.1 Step Model + +Steps are reusable plugins that: + +- plan data-only actions in `Test-*` +- execute actions in `Invoke-*` +- (Later) implement `Rollback-*` + +### 9.2 Step Metadata + +Each step has a metadata PSD1 file describing: + +- Allowed keys in `With` +- Required keys +- Mutually exclusive keys +- Declared outputs (State ownership) +- Optional explicit handlers + +### 9.3 Handler Resolution (Hybrid) + +Resolution order: + +1. Use explicit `Handlers` from metadata (if present) +2. Else use naming convention: + - `Test-JmlStep` (or IdLE naming; project decides final prefix) + - `Invoke-JmlStep` + - optional `Rollback-JmlStep` + +> Note: We intentionally support both to enable future refactoring without breaking workflows. + +-> Conventions enable quick start. +-> Explicit handlers enable refactoring/versioning later without changing StepId in workflows. + +--- + +## 10. Actions + +- Steps produce **data-only actions** during planning. +- Steps execute their own actions during `Invoke-*` (engine does not interpret action semantics in V1). +- Actions use a **namespaced `Op`** value: + - `Identity.*`, `Entitlement.*`, `External.*`, `Custom.*` + +--- + +## 11. State and Outputs + +### 11.1 State Roots + +- `State.*` is engine-managed runtime/planning state. + +### 11.2 Output Rules (Strict) + +- Steps may only write to `State.*`. +- Steps may only write to **declared output paths** from metadata. +- Steps may not overwrite output paths owned by other steps. + +### 11.3 Merge Semantics + +- **Replace-at-path** only (no deep merge). +- Overwrites are allowed only for the same step (e.g., retries), not across steps. + +-> Keeps dataflow explicit and validatable. +-> Prevents state collisions. +-> Avoids complex merge semantics in V1. + +--- + +## 12. Execution Semantics + +- Sequential step execution. +- **Fail-fast**: stop plan execution on first step failure. +- No automatic rollback orchestration in V1. +- Execution produces structured events (audit/progress) via sinks. + +-> Predictable and auditable behavior. +-> Rollback semantics are domain-specific and often not reversible. +-> “Compensation” is treated as a separate workflow/process. + +--- + +## 13. Authentication and Identity + +- V1 does not require an `Actor` field in the request. + +- Target systems often require and audit **personal admin logins**. +- A request-level actor claim is not verifiable by the engine and can be misleading. + +### 13.2 Auth belongs to Providers + +- Steps do not perform authentication. +- Providers handle authentication and obtain sessions through an ExecutionContext provided by the host. + +-> Prevents UI/auth code from leaking into the engine. +-> Supports heterogeneous target systems with different auth modes. + +### 13.3 AuthProfile + ExecutionContext callback + +- Steps may reference an `AuthProfile` label (no secrets). +- Providers request sessions via: + - `ExecutionContext.AcquireSession(providerAlias, authProfile, requirements)` + +The **host** (CLI/service) implements: + +- interactive login flows +- MFA handling +- credential/session caching (optional) +- enforcement of “interactive allowed/not allowed” + +-> Different steps/providers may require different accounts and auth methods (incl. MFA). +-> Keeps engine headless while enabling personalized admin logins. + +--- + +## 14. Provider/Adapter Pattern + +IdLE.Core communicates only with contracts/ports (interfaces), never directly with systems. + +Examples of ports: + +- Identity provider +- Entitlement provider +- External system providers (ticketing/HR) +- Event/Audit sinks + +-> Swappable implementations +-> Strong testability via mocks +-> Separation of orchestration vs. system-specific logic + +--- + +## Appendix A: Architecture Diagram + +```mermaid +flowchart LR + Host[Host / CLI / Service] + Engine[IdLE Core Engine] + Workflow[Workflow (PSD1)] + Steps[Step Plugins] + Providers[Providers] + Targets[Target Systems] + + Host -->|ExecutionContext| Engine + Host -->|Load| Workflow + + Engine --> Workflow + Engine --> Steps + + Steps --> Providers + Providers -->|AcquireSession(...)| Host + + Providers --> Targets +``` + +--- + +## Appendix B: Open Items / V2 Candidates (Not in V1) + +- Deep merge semantics for State outputs (if needed) +- Generic execution of core `Op` namespaces in the engine +- Automatic rollback orchestration (if domain demands it) +- YAML support (optional; introduces dependency) +- Verified actor / requestor claims (if host can provide verified identity) + +## Step dispatch and plugins + +Workflows are **data-only**. Each step specifies a `Type` string (e.g. `IdLE.Step.EmitEvent`). +At runtime, the host provides a **Step Registry** mapping `Step.Type` → handler. + +A handler can be: + +- a **function name** (PowerShell function implementing the step contract), or +- a **scriptblock** (useful for tests/examples and lightweight hosts) + +The engine resolves the handler and invokes it with a minimal execution context (`Context`) and the step definition (`Step`). + +## Declarative step conditions + +Steps can declare a `When` condition using a simple data-only schema: + +- `Path` + `Equals` +- `Path` + `NotEquals` +- `Path` + `Exists` + +If a condition evaluates to false, the engine marks the step as `Skipped` and emits a `StepSkipped` event. diff --git a/docs/02-examples.md b/docs/02-examples.md new file mode 100644 index 00000000..9a25127f --- /dev/null +++ b/docs/02-examples.md @@ -0,0 +1,86 @@ +# Examples + +Runnable examples live in the repository under `examples/`. + +- `examples/run-demo.ps1` – end-to-end demo (Plan → Execute) with a host-provided Step Registry +- `examples/workflows/` – workflow definition samples (`.psd1`) + +## Run the demo + +From the repository root: + +```powershell +pwsh -File .\examples\run-demo.ps1 +``` + +The demo prints the execution status and a small event table. + +## Plan → Execute (minimal) + +IdLE separates **planning** from **execution**: + +1) Create a request +2) Build a deterministic plan from a workflow definition (`.psd1`) +3) Execute the plan using a host-provided step registry (handlers) + +Minimal example (mirrors `examples/run-demo.ps1`): + +```powershell +$workflowPath = Join-Path $PSScriptRoot 'workflows\joiner-with-when.psd1' + +$request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -Actor 'example-user' # Actor optional +$plan = New-IdlePlan -WorkflowPath $workflowPath -Request $request + +# Providers are host-provided and can be mocked in tests. +$providers = @{} + +$result = Invoke-IdlePlan -Plan $plan -Providers $providers +``` + +## Workflow definition (PSD1) – structure + +Workflows are **data-only** and typically stored as `.psd1`. + +Minimal shape: + +```powershell +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Emit:Start' + Step = 'EmitEvent' + Inputs = @{ Message = 'Starting Joiner' } + } + ) +} +``` + +## Declarative `When` conditions + +Steps can be conditionally skipped using a declarative `When` block: + +```powershell +When = @{ + Path = 'Plan.LifecycleEvent' + Equals = 'Joiner' +} +``` + +If the condition is not met: + +- the step result status becomes `Skipped` +- a `StepSkipped` event is emitted + +## Optional built-in steps + +IdLE is engine-first. Step implementations are shipped in optional step modules. + +Example: + +```powershell +Import-Module ./src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 -Force +``` + +(See `examples/run-demo.ps1` for a complete runnable flow.) diff --git a/examples/run-demo.ps1 b/examples/run-demo.ps1 new file mode 100644 index 00000000..3c07e3fa --- /dev/null +++ b/examples/run-demo.ps1 @@ -0,0 +1,137 @@ +#requires -Version 7.0 +Set-StrictMode -Version Latest + +function Test-IdleAnsiSupport { + try { + return ($Host.UI.SupportsVirtualTerminal -or $env:TERM -and $env:TERM -ne 'dumb') + } catch { return $false } +} + +$UseAnsi = Test-IdleAnsiSupport + +function Write-DemoHeader { + param([string]$Title) + + if ($UseAnsi) { + Write-Host "$($PSStyle.Bold)$($PSStyle.Foreground.Cyan)$Title$($PSStyle.Reset)" + } else { + Write-Host $Title + } +} + +function Format-EventRow { + param([object]$Event) + + $icons = @{ + RunStarted = '🚀' + RunCompleted = '🏁' + StepStarted = '▶️' + StepCompleted = '✅' + StepSkipped = '⏭️' + StepFailed = '❌' + Custom = '📝' + Debug = '🔎' + } + + $icon = if ($icons.ContainsKey($Event.Type)) { $icons[$Event.Type] } else { '•' } + + $time = ([DateTime]$Event.TimestampUtc).ToLocalTime().ToString('HH:mm:ss.fff') + $step = if ([string]::IsNullOrWhiteSpace($Event.StepName)) { '-' } else { [string]$Event.StepName } + + [pscustomobject]@{ + Time = $time + Type = "$icon $($Event.Type)" + Step = $step + Message = $Event.Message + } +} + +function Write-ResultSummary { + param([object]$Result) + + $statusIcon = switch ($Result.Status) { + 'Completed' { '✅' } + 'Failed' { '❌' } + default { 'ℹ️' } + } + + if ($UseAnsi) { + $color = if ($Result.Status -eq 'Completed') { $PSStyle.Foreground.Green } else { $PSStyle.Foreground.Red } + Write-Host "$($PSStyle.Bold)$statusIcon Status: $color$($Result.Status)$($PSStyle.Reset)" + } else { + Write-Host "$statusIcon Status: $($Result.Status)" + } + + $counts = $Result.Events | Group-Object Type | Sort-Object Name | ForEach-Object { "$($_.Name)=$($_.Count)" } + Write-Host ("Events: " + ($counts -join ', ')) +} + +Import-Module (Join-Path $PSScriptRoot '..\src\IdLE\IdLE.psd1') -Force +Import-Module (Join-Path $PSScriptRoot '..\src\IdLE.Steps.Common\IdLE.Steps.Common.psd1') -Force + +$workflowPath = Join-Path $PSScriptRoot 'workflows\joiner-with-when.psd1' + +$request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -Actor 'example-user' + +$plan = New-IdlePlan -WorkflowPath $workflowPath -Request $request + +# Host-provided step registry: +# The handler can be a scriptblock (ideal for tests/examples) or a function name. +$emitHandler = { + param($Context, $Step) + + # Support both hashtable/dictionary and PSCustomObject step shapes. + $stepName = $null + $stepType = $null + $with = $null + + if ($Step -is [System.Collections.IDictionary]) { + $stepName = if ($Step.Contains('Name')) { [string]$Step['Name'] } else { $null } + $stepType = if ($Step.Contains('Type')) { [string]$Step['Type'] } else { $null } + $with = if ($Step.Contains('With')) { $Step['With'] } else { $null } + } + else { + $stepName = if ($Step.PSObject.Properties['Name']) { [string]$Step.Name } else { $null } + $stepType = if ($Step.PSObject.Properties['Type']) { [string]$Step.Type } else { $null } + $with = if ($Step.PSObject.Properties['With']) { $Step.With } else { $null } + } + + $msg = $null + if ($with -is [System.Collections.IDictionary] -and $with.Contains('Message')) { + $msg = [string]$with['Message'] + } + elseif ($null -ne $with -and $with.PSObject.Properties['Message']) { + $msg = [string]$with.Message + } + + if ([string]::IsNullOrWhiteSpace($msg)) { + $msg = 'EmitEvent executed.' + } + + & $Context.WriteEvent 'Custom' $msg $stepName @{ StepType = $stepType } + + [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'Completed' + Error = $null + } +} + +$providers = @{ + StepRegistry = @{ + 'IdLE.Step.EmitEvent' = $emitHandler + } +} + +$result = Invoke-IdlePlan -Plan $plan -Providers $providers + +Write-DemoHeader "IdLE Demo – Plan Execution" +Write-ResultSummary -Result $result + +Write-Host "" +Write-DemoHeader "Event Stream" +$result.Events | + ForEach-Object { Format-EventRow $_ } | + Format-Table Time, Type, Step, Message -AutoSize diff --git a/examples/workflows/joiner-minimal.psd1 b/examples/workflows/joiner-minimal.psd1 new file mode 100644 index 00000000..684c38a4 --- /dev/null +++ b/examples/workflows/joiner-minimal.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Joiner - Minimal Demo' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'EmitHello' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Hello from Joiner minimal workflow.' + } + } + ) +} diff --git a/examples/workflows/joiner-with-when.psd1 b/examples/workflows/joiner-with-when.psd1 new file mode 100644 index 00000000..48e88090 --- /dev/null +++ b/examples/workflows/joiner-with-when.psd1 @@ -0,0 +1,28 @@ +@{ + Name = 'Joiner - When Demo' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'EmitOnlyForJoiner' + Type = 'IdLE.Step.EmitEvent' + When = @{ + Path = 'Plan.LifecycleEvent' + Equals = 'Joiner' + } + With = @{ + Message = 'This step runs only when Plan.LifecycleEvent == Joiner.' + } + } + @{ + Name = 'SkipForJoiner' + Type = 'IdLE.Step.EmitEvent' + When = @{ + Path = 'Plan.LifecycleEvent' + Equals = 'Leaver' + } + With = @{ + Message = 'You should never see this in a Joiner run.' + } + } + ) +} diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 new file mode 100644 index 00000000..c4b5a5ef --- /dev/null +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -0,0 +1,27 @@ +@{ + RootModule = 'IdLE.Core.psm1' + ModuleVersion = '0.0.1' + GUID = 'c6232cd4-6fe9-4c37-a87b-eed8ce7e3517' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'IdLE Core engine: domain model, workflow loading/validation, plan builder and execution pipeline.' + PowerShellVersion = '7.0' + + FunctionsToExport = @( + 'New-IdleLifecycleRequestObject', + 'Test-IdleWorkflowDefinitionObject', + 'New-IdlePlanObject', + 'Invoke-IdlePlanObject' + ) + CmdletsToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('Identity Lifecycle Engine', 'IdLE', 'Identity', 'Lifecycle', 'Automation', 'Identity Management', 'JML', 'Onboarding', 'Offboarding', 'Account Management') + 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.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 new file mode 100644 index 00000000..2fa82a27 --- /dev/null +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -0,0 +1,26 @@ +#requires -Version 7.0 + +Set-StrictMode -Version Latest + +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +$PrivatePath = Join-Path -Path $PSScriptRoot -ChildPath 'Private' + +foreach ($path in @($PrivatePath, $PublicPath)) { + if (-not (Test-Path -Path $path)) { + continue + } + + Get-ChildItem -Path $path -Filter '*.ps1' -File | + Sort-Object -Property FullName | + ForEach-Object { + . $_.FullName + } +} + +# Core exports selected factory functions. The meta module (IdLE) exposes the public API. +Export-ModuleMember -Function @( + 'New-IdleLifecycleRequestObject', + 'Test-IdleWorkflowDefinitionObject', + 'New-IdlePlanObject', + 'Invoke-IdlePlanObject' +) -Alias @() diff --git a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 new file mode 100644 index 00000000..984a1a41 --- /dev/null +++ b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 @@ -0,0 +1,48 @@ +# Asserts that the provided InputObject does not contain any ScriptBlock objects. +# Recursively walks hashtables, enumerables, and PSCustomObjects. + +function Assert-IdleNoScriptBlock { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $InputObject, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + if ($null -eq $InputObject) { return } + + if ($InputObject -is [scriptblock]) { + throw [System.ArgumentException]::new("ScriptBlocks are not allowed in request data. Found at: $Path", $Path) + } + + # Hashtable / Dictionary + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($key in $InputObject.Keys) { + Assert-IdleNoScriptBlock -InputObject $InputObject[$key] -Path "$Path.$key" + } + return + } + + # Enumerable (but not string) + if (($InputObject -is [System.Collections.IEnumerable]) -and ($InputObject -isnot [string])) { + $i = 0 + foreach ($item in $InputObject) { + Assert-IdleNoScriptBlock -InputObject $item -Path "$Path[$i]" + $i++ + } + return + } + + # PSCustomObject (walk note properties) + if ($InputObject -is [pscustomobject]) { + foreach ($p in $InputObject.PSObject.Properties) { + if ($p.MemberType -eq 'NoteProperty') { + Assert-IdleNoScriptBlock -InputObject $p.InputObject -Path "$Path.$($p.Name)" + } + } + } +} diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 new file mode 100644 index 00000000..fc7dda0c --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -0,0 +1,52 @@ +function Get-IdleStepRegistry { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + # Registry maps workflow Step.Type -> handler. + # Handler can be: + # - string : PowerShell function name + # - scriptblock : executable handler (ideal for tests / hosts) + $registry = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + if ($null -eq $Providers) { + return $registry + } + + # 1) Providers as hashtable / dictionary (most common in tests) + if ($Providers -is [hashtable] -or $Providers -is [System.Collections.IDictionary]) { + if ($Providers.Contains('StepRegistry') -and $Providers['StepRegistry'] -is [hashtable]) { + # Clone to avoid mutating host-provided hashtable during execution. + $source = $Providers['StepRegistry'] + foreach ($k in $source.Keys) { + $registry[[string]$k] = $source[$k] + } + } + + return $registry + } + + # 2) Providers as object with property StepRegistry (host objects) + # StrictMode-safe: do NOT access $Providers.StepRegistry unless the property exists. + $prop = $Providers.PSObject.Properties['StepRegistry'] + if ($null -ne $prop -and $prop.Value -is [hashtable]) { + $source = $prop.Value + foreach ($k in $source.Keys) { + $registry[[string]$k] = $source[$k] + } + } + + # Add built-in defaults only if the step implementation is actually available. + # This keeps IdLE's public surface minimal: steps are optional modules. + if (-not $registry.ContainsKey('IdLE.Step.EmitEvent')) { + $cmd = Get-Command -Name 'Invoke-IdleStepEmitEvent' -ErrorAction SilentlyContinue + if ($null -ne $cmd) { + $registry['IdLE.Step.EmitEvent'] = $cmd.Name + } + } + + return $registry +} diff --git a/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 b/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 new file mode 100644 index 00000000..9cba88d6 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 @@ -0,0 +1,25 @@ +function Get-IdleValueByPath { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Object, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + # Supports dotted property paths, e.g. "DesiredState.Department" + $current = $Object + foreach ($segment in ($Path -split '\.')) { + if ($null -eq $current) { return $null } + + $prop = $current.PSObject.Properties[$segment] + if ($null -eq $prop) { return $null } + + $current = $prop.Value + } + + return $current +} diff --git a/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 b/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 new file mode 100644 index 00000000..40870fa3 --- /dev/null +++ b/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 @@ -0,0 +1,58 @@ +# Domain model: LifecycleRequest +# Actor is intentionally optional in V1 (see architecture). +# Changes is optional and stays $null if not provided (intent-only requests typically only provide DesiredState). + +class IdleLifecycleRequest { + [string] $LifecycleEvent + [hashtable] $IdentityKeys + [hashtable] $DesiredState + [hashtable] $Changes + [string] $CorrelationId + [string] $Actor + + IdleLifecycleRequest( + [string] $lifecycleEvent, + [hashtable] $identityKeys, + [hashtable] $desiredState, + [hashtable] $changes, + [string] $correlationId, + [string] $actor + ) { + $this.LifecycleEvent = $lifecycleEvent + $this.IdentityKeys = $identityKeys + $this.DesiredState = $desiredState + $this.Changes = $changes + $this.CorrelationId = $correlationId + $this.Actor = $actor + + $this.Normalize() + } + + [void] Normalize() { + if ([string]::IsNullOrWhiteSpace($this.LifecycleEvent)) { + throw [System.ArgumentException]::new('LifecycleEvent must not be empty.', 'LifecycleEvent') + } + + if ($null -eq $this.IdentityKeys) { + $this.IdentityKeys = @{} + } + + if ($null -eq $this.DesiredState) { + $this.DesiredState = @{} + } + + # Changes stays $null if not provided. If provided, it must be a hashtable. + if ($null -ne $this.Changes -and $this.Changes -isnot [hashtable]) { + throw [System.ArgumentException]::new('Changes must be a hashtable when provided.', 'Changes') + } + + if ([string]::IsNullOrWhiteSpace($this.CorrelationId)) { + $this.CorrelationId = [guid]::NewGuid().Guid + } + + # Actor is optional; normalize whitespace to $null for consistency. + if ([string]::IsNullOrWhiteSpace($this.Actor)) { + $this.Actor = $null + } + } +} diff --git a/src/IdLE.Core/Private/Import-IdleWorkflowDefinition.ps1 b/src/IdLE.Core/Private/Import-IdleWorkflowDefinition.ps1 new file mode 100644 index 00000000..56e4daea --- /dev/null +++ b/src/IdLE.Core/Private/Import-IdleWorkflowDefinition.ps1 @@ -0,0 +1,23 @@ +function Import-IdleWorkflowDefinition { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $WorkflowPath + ) + + # Resolve to an absolute path early to keep error messages deterministic. + $resolvedPath = (Resolve-Path -Path $WorkflowPath -ErrorAction Stop).Path + + # Import PSD1 via built-in data-file loader (safer than dot-sourcing). + $data = Import-PowerShellDataFile -Path $resolvedPath + + if ($null -eq $data -or $data -isnot [hashtable]) { + throw [System.ArgumentException]::new( + "Workflow definition must be a hashtable at the root. Path: $resolvedPath", + 'WorkflowPath' + ) + } + + return $data +} diff --git a/src/IdLE.Core/Private/New-IdleEvent.ps1 b/src/IdLE.Core/Private/New-IdleEvent.ps1 new file mode 100644 index 00000000..d82ac6c5 --- /dev/null +++ b/src/IdLE.Core/Private/New-IdleEvent.ps1 @@ -0,0 +1,40 @@ +function New-IdleEvent { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Type, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Message, + + [Parameter()] + [AllowNull()] + [string] $CorrelationId, + + [Parameter()] + [AllowNull()] + [string] $Actor, + + [Parameter()] + [AllowNull()] + [string] $StepName, + + [Parameter()] + [AllowNull()] + [hashtable] $Data + ) + + # Create a structured event object that can be streamed to an audit sink later. + return [pscustomobject]@{ + PSTypeName = 'IdLE.Event' + TimestampUtc = [DateTime]::UtcNow + Type = $Type + Message = $Message + CorrelationId = $CorrelationId + Actor = $Actor + StepName = $StepName + Data = $Data + } +} diff --git a/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 new file mode 100644 index 00000000..8d1f8b45 --- /dev/null +++ b/src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 @@ -0,0 +1,47 @@ +function Resolve-IdleStepHandler { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $StepType, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Registry + ) + + # Registry maps StepType -> handler + # Handler can be: + # - [string] : PowerShell function name + # - [scriptblock] : executable handler (useful for tests / hosts) + if (-not $Registry.ContainsKey($StepType)) { + return $null + } + + $handler = $Registry[$StepType] + if ($null -eq $handler) { + return $null + } + + if ($handler -is [scriptblock]) { + return $handler + } + + if ($handler -is [string]) { + $fn = [string]$handler + if ([string]::IsNullOrWhiteSpace($fn)) { + return $null + } + + # Ensure the function exists in the current session. + $cmd = Get-Command -Name $fn -ErrorAction SilentlyContinue + if ($null -eq $cmd) { + return $null + } + + return $cmd.Name + } + + # Any other type is invalid configuration. + throw [System.ArgumentException]::new("Invalid step handler type for '$StepType'. Allowed: string (function name) or scriptblock.", 'Registry') +} diff --git a/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 b/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 new file mode 100644 index 00000000..b902cbea --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 @@ -0,0 +1,56 @@ +function Test-IdleStepDefinition { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter(Mandatory)] + [ValidateRange(0, 1000000)] + [int] $Index + ) + + $errors = [System.Collections.Generic.List[string]]::new() + + # Step must be a dictionary-like object (workflow steps are data) + if (-not ($Step -is [System.Collections.IDictionary])) { + $errors.Add("Step[$Index]: Step must be a hashtable/dictionary.") + return $errors + } + + $name = if ($Step.Contains('Name')) { [string]$Step['Name'] } else { $null } + $type = if ($Step.Contains('Type')) { [string]$Step['Type'] } else { $null } + + if ([string]::IsNullOrWhiteSpace($name)) { $errors.Add("Step[$Index]: Missing or empty 'Name'.") } + if ([string]::IsNullOrWhiteSpace($type)) { $errors.Add("Step[$Index] ($name): Missing or empty 'Type'.") } + + # Enforce data-only: no ScriptBlock anywhere inside the step definition + # (Reuse your existing helper if available) + try { + Assert-IdleNoScriptBlock -InputObject $Step -Path ("Step[$Index] ($name)") + } + catch { + $errors.Add($_.Exception.Message) + } + + # Validate With (if present) + if ($Step.Contains('With') -and $null -ne $Step['With']) { + if (-not ($Step['With'] -is [System.Collections.IDictionary])) { + $errors.Add("Step[$Index] ($name): 'With' must be a hashtable/dictionary when provided.") + } + } + + # Validate When schema (if present) + if ($Step.Contains('When') -and $null -ne $Step['When']) { + if (-not ($Step['When'] -is [hashtable])) { + $errors.Add("Step[$Index] ($name): 'When' must be a hashtable when provided.") + } + else { + foreach ($e in (Test-IdleWhenConditionSchema -When $Step['When'] -StepName $name)) { + $errors.Add($e) + } + } + } + + return $errors +} diff --git a/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 b/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 new file mode 100644 index 00000000..d92128aa --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 @@ -0,0 +1,44 @@ +function Test-IdleWhenCondition { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $When, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context + ) + + # Minimal declarative condition schema: + # - Path (string) required + # - Exactly one of: Equals, NotEquals, Exists + if (-not $When.ContainsKey('Path') -or [string]::IsNullOrWhiteSpace([string]$When.Path)) { + throw [System.ArgumentException]::new("When condition requires key 'Path'.", 'When') + } + + $ops = @('Equals', 'NotEquals', 'Exists') + $presentOps = @($ops | Where-Object { $When.ContainsKey($_) }) + if ($presentOps.Count -ne 1) { + throw [System.ArgumentException]::new("When condition must specify exactly one operator: Equals, NotEquals, Exists.", 'When') + } + + $value = Get-IdleValueByPath -Object $Context -Path ([string]$When.Path) + + if ($When.ContainsKey('Exists')) { + $expected = [bool]$When.Exists + $actual = ($null -ne $value) + return ($actual -eq $expected) + } + + if ($When.ContainsKey('Equals')) { + return ([string]$value -eq [string]$When.Equals) + } + + if ($When.ContainsKey('NotEquals')) { + return ([string]$value -ne [string]$When.NotEquals) + } + + # Should never reach here due to validation. + return $false +} diff --git a/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 new file mode 100644 index 00000000..50dec924 --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 @@ -0,0 +1,36 @@ +function Test-IdleWhenConditionSchema { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $When, + + [Parameter()] + [AllowNull()] + [string] $StepName + ) + + $errors = [System.Collections.Generic.List[string]]::new() + $prefix = if ([string]::IsNullOrWhiteSpace($StepName)) { 'Step' } else { "Step '$StepName'" } + + if (-not $When.ContainsKey('Path') -or [string]::IsNullOrWhiteSpace([string]$When.Path)) { + $errors.Add("$($prefix): When requires key 'Path' with a non-empty string value.") + return $errors + } + + # Exactly one operator allowed (MVP) + $ops = @('Equals', 'NotEquals', 'Exists') + $presentOps = @($ops | Where-Object { $When.ContainsKey($_) }) + + if ($presentOps.Count -ne 1) { + $errors.Add("$($prefix): When must specify exactly one operator: Equals, NotEquals, Exists.") + return $errors + } + + # Exists must be boolean-like + if ($When.ContainsKey('Exists')) { + try { [void][bool]$When.Exists } catch { $errors.Add("$($prefix): When.Exists must be boolean.") } + } + + return $errors +} diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 new file mode 100644 index 00000000..1a61ae9b --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -0,0 +1,81 @@ +function Test-IdleWorkflowSchema { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable] $Workflow + ) + + # Strict validation: collect all schema violations and return them as a list. + $errors = [System.Collections.Generic.List[string]]::new() + + $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'Description') + foreach ($key in $Workflow.Keys) { + if ($allowedRootKeys -notcontains $key) { + $errors.Add("Unknown root key '$key'. Allowed keys: $($allowedRootKeys -join ', ').") + } + } + + if (-not $Workflow.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$Workflow.Name)) { + $errors.Add("Missing or empty required root key 'Name'.") + } + + if (-not $Workflow.ContainsKey('LifecycleEvent') -or [string]::IsNullOrWhiteSpace([string]$Workflow.LifecycleEvent)) { + $errors.Add("Missing or empty required root key 'LifecycleEvent'.") + } + + if (-not $Workflow.ContainsKey('Steps') -or $null -eq $Workflow.Steps) { + $errors.Add("Missing required root key 'Steps'.") + } + elseif ($Workflow.Steps -isnot [System.Collections.IEnumerable] -or $Workflow.Steps -is [string]) { + $errors.Add("'Steps' must be an array/list of step hashtables.") + } + else { + $stepNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + $i = 0 + foreach ($step in $Workflow.Steps) { + $stepPath = "Steps[$i]" + + if ($null -eq $step -or $step -isnot [hashtable]) { + $errors.Add("$stepPath must be a hashtable.") + $i++ + continue + } + + $allowedStepKeys = @('Name', 'Type', 'When', 'With', 'Description') + foreach ($k in $step.Keys) { + if ($allowedStepKeys -notcontains $k) { + $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") + } + } + + if (-not $step.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$step.Name)) { + $errors.Add("Missing or empty required key '$stepPath.Name'.") + } + else { + if (-not $stepNames.Add([string]$step.Name)) { + $errors.Add("Duplicate step name '$($step.Name)' detected. Step names must be unique.") + } + } + + if (-not $step.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$step.Type)) { + $errors.Add("Missing or empty required key '$stepPath.Type'.") + } + + # Conditions must be declarative data, never a ScriptBlock/expression. + # We only enforce the shape here; semantic validation comes later. + if ($step.ContainsKey('When') -and $null -ne $step.When -and $step.When -isnot [hashtable]) { + $errors.Add("'$stepPath.When' must be a hashtable (declarative condition object).") + } + + # 'With' is step parameter bag (data-only). Detailed validation comes with step metadata later. + if ($step.ContainsKey('With') -and $null -ne $step.With -and $step.With -isnot [hashtable]) { + $errors.Add("'$stepPath.With' must be a hashtable (step parameters).") + } + + $i++ + } + } + + return $errors +} diff --git a/src/IdLE.Core/Private/Write-IdleEvent.ps1 b/src/IdLE.Core/Private/Write-IdleEvent.ps1 new file mode 100644 index 00000000..0ad9bdf7 --- /dev/null +++ b/src/IdLE.Core/Private/Write-IdleEvent.ps1 @@ -0,0 +1,35 @@ +function Write-IdleEvent { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Event, + + [Parameter()] + [AllowNull()] + [object] $EventSink, + + [Parameter()] + [AllowNull()] + [System.Collections.Generic.List[object]] $EventBuffer + ) + + # If an event sink is provided, try to emit events immediately. + # Supported shapes: + # - ScriptBlock: invoked with the event as the only argument + # - Object with method "WriteEvent": called as $EventSink.WriteEvent($Event) + # - If nothing is provided: do nothing (events can still be buffered) + if ($null -ne $EventSink) { + if ($EventSink -is [scriptblock]) { + & $EventSink $Event + } + elseif ($EventSink.PSObject.Methods.Name -contains 'WriteEvent') { + $EventSink.WriteEvent($Event) + } + } + + # Buffer events for return value / tests if requested. + if ($null -ne $EventBuffer) { + [void]$EventBuffer.Add($Event) + } +} diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 new file mode 100644 index 00000000..c4c0438f --- /dev/null +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -0,0 +1,200 @@ +function Invoke-IdlePlanObject { + <# + .SYNOPSIS + Executes an IdLE plan (skeleton). + + .DESCRIPTION + Executes a plan deterministically and emits structured events. + Executes steps via a registry mapping Step.Type to PowerShell functions. + + .PARAMETER Plan + Plan object created by New-IdlePlanObject. + + .PARAMETER Providers + Provider registry/collection (used for StepRegistry in this increment; passed through for future steps). + + .PARAMETER EventSink + Optional sink for event streaming. Can be a ScriptBlock or an object with a WriteEvent() method. + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.ExecutionResult) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Plan, + + [Parameter()] + [AllowNull()] + [object] $Providers, + + [Parameter()] + [AllowNull()] + [object] $EventSink + ) + + # Validate minimal plan shape. Avoid hard typing to keep cross-module compatibility. + $planProps = $Plan.PSObject.Properties.Name + foreach ($required in @('CorrelationId', 'LifecycleEvent', 'Steps')) { + if ($planProps -notcontains $required) { + throw [System.ArgumentException]::new("Plan object must contain property '$required'.", 'Plan') + } + } + + $events = [System.Collections.Generic.List[object]]::new() + + $corr = [string]$Plan.CorrelationId + $actor = if ($planProps -contains 'Actor') { [string]$Plan.Actor } else { $null } + + # Capture command references once to avoid scope/name resolution issues inside closures. + $newIdleEventCmd = Get-Command -Name 'New-IdleEvent' -CommandType Function -ErrorAction Stop + $writeIdleEventCmd = Get-Command -Name 'Write-IdleEvent' -CommandType Function -ErrorAction Stop + + # Resolve step types to PowerShell functions via a registry. + # This decouples workflow "Type" strings from actual implementation functions. + $registry = Get-IdleStepRegistry -Providers $Providers + + # Provide a small execution context for steps. + # Steps must not call engine-private functions directly; they only use the context. + $context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + CorrelationId = $corr + Actor = $actor + Plan = $Plan + Providers = $Providers + + # Expose a single event writer for steps. + # The engine stays in control of event shape, sinks and buffering. + WriteEvent = { + param( + [Parameter(Mandatory)][string] $Type, + [Parameter(Mandatory)][string] $Message, + [Parameter()][AllowNull()][string] $StepName, + [Parameter()][AllowNull()][hashtable] $Data + ) + + # Use captured command references to avoid scope/name resolution issues in step handlers. + $evt = & $newIdleEventCmd -Type $Type -Message $Message -CorrelationId $corr -Actor $actor -StepName $StepName -Data $Data + & $writeIdleEventCmd -Event $evt -EventSink $EventSink -EventBuffer $events + }.GetNewClosure() + } + + # Emit run start event. + & $context.WriteEvent 'RunStarted' 'Plan execution started.' $null @{ + LifecycleEvent = [string]$Plan.LifecycleEvent + WorkflowName = if ($planProps -contains 'WorkflowName') { + [string]$Plan.WorkflowName + } else { + $null + } + StepCount = @($Plan.Steps).Count + } + + $stepResults = @() + $failed = $false + + $i = 0 + foreach ($step in @($Plan.Steps)) { + $stepName = if ($step.PSObject.Properties.Name -contains 'Name') { [string]$step.Name } else { "Step[$i]" } + $stepType = if ($step.PSObject.Properties.Name -contains 'Type' -and $null -ne $step.Type) { + ([string]$step.Type).Trim() + } else { + $null + } + + # Evaluate declarative When condition (data-only). + if ($step.PSObject.Properties.Name -contains 'When' -and $null -ne $step.When) { + $shouldRun = Test-IdleWhenCondition -When $step.When -Context $context + if (-not $shouldRun) { + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'Skipped' + Error = $null + } + + & $context.WriteEvent 'StepSkipped' "Step '$stepName' skipped (condition not met)." $stepName @{ + StepType = $stepType + Index = $i + } + + $i++ + continue + } + } + + & $context.WriteEvent 'StepStarted' "Step '$stepName' started." $stepName @{ + StepType = $stepType + Index = $i + } + + try { + # Resolve implementation handler for this step type. + # Handler can be: + # - [scriptblock] : invoked as & $handler $context $step + # - [string] : function name invoked as & $handler -Context $context -Step $step + $handler = Resolve-IdleStepHandler -StepType $stepType -Registry $registry + if ($null -eq $handler) { + throw [System.InvalidOperationException]::new("Step type '$stepType' is not registered.") + } + + # Invoke the step plugin depending on handler type. + if ($handler -is [scriptblock]) { + $stepResult = & $handler $context $step + } + else { + # handler is a function name (string) + $stepResult = & $handler -Context $context -Step $step + } + + $stepResults += $stepResult + + & $context.WriteEvent 'StepCompleted' "Step '$stepName' completed." $stepName @{ + StepType = $stepType + Index = $i + } + } + catch { + $failed = $true + $err = $_ + + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'Failed' + Error = $err.Exception.Message + } + + & $context.WriteEvent 'StepFailed' "Step '$stepName' failed." $stepName @{ + StepType = $stepType + Index = $i + Error = $err.Exception.Message + } + + # Fail-fast in this increment. + break + } + + $i++ + } + + $runStatus = if ($failed) { 'Failed' } else { 'Completed' } + + & $context.WriteEvent 'RunCompleted' "Plan execution finished (status: $runStatus)." $null @{ + Status = $runStatus + StepCount = @($Plan.Steps).Count + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionResult' + Status = $runStatus + CorrelationId = $corr + Actor = $actor + Steps = $stepResults + Events = $events + Providers = $Providers + } +} diff --git a/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 new file mode 100644 index 00000000..0be407c7 --- /dev/null +++ b/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 @@ -0,0 +1,44 @@ +function New-IdleLifecycleRequestObject { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $LifecycleEvent, + + [Parameter()] + [string] $CorrelationId, + + [Parameter()] + [string] $Actor, + + [Parameter()] + [hashtable] $IdentityKeys = @{}, + + [Parameter()] + [hashtable] $DesiredState = @{}, + + [Parameter()] + [hashtable] $Changes + ) + + # Validate that no ScriptBlocks are present in the input data + Assert-IdleNoScriptBlock -InputObject $IdentityKeys -Path 'IdentityKeys' + Assert-IdleNoScriptBlock -InputObject $DesiredState -Path 'DesiredState' + Assert-IdleNoScriptBlock -InputObject $Changes -Path 'Changes' + + # Clone hashtables to avoid external mutation after object creation + # shallow clone is sufficient as we have already validated no ScriptBlocks are present + $IdentityKeys = if ($null -eq $IdentityKeys) { @{} } else { $IdentityKeys.Clone() } + $DesiredState = if ($null -eq $DesiredState) { @{} } else { $DesiredState.Clone() } + $Changes = if ($null -eq $Changes) { $null } else { $Changes.Clone() } + + # Construct and return the core domain object defined in Private/IdleLifecycleRequest.ps1 + return [IdleLifecycleRequest]::new( + $LifecycleEvent, + $IdentityKeys, + $DesiredState, + $Changes, + $CorrelationId, + $Actor + ) +} diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 new file mode 100644 index 00000000..077fe88a --- /dev/null +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -0,0 +1,79 @@ +function New-IdlePlanObject { + <# + .SYNOPSIS + Builds a deterministic plan from a request and a workflow definition. + + .DESCRIPTION + Loads and validates the workflow definition (PSD1) and creates a normalized plan object. + This is a planning-only artifact. Execution is handled by Invoke-IdlePlan later. + + .PARAMETER WorkflowPath + Path to the workflow definition (PSD1). + + .PARAMETER Request + Lifecycle request object (must contain LifecycleEvent and CorrelationId). + + .PARAMETER Providers + Optional provider registry/collection. Not used in this increment; stored for later. + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.Plan) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $WorkflowPath, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Request, + + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + # Ensure required request properties exist without hard-typing the request class. + $reqProps = $Request.PSObject.Properties.Name + if ($reqProps -notcontains 'LifecycleEvent') { + throw [System.ArgumentException]::new("Request object must contain property 'LifecycleEvent'.", 'Request') + } + if ($reqProps -notcontains 'CorrelationId') { + throw [System.ArgumentException]::new("Request object must contain property 'CorrelationId'.", 'Request') + } + + # Validate workflow and ensure it matches the request's LifecycleEvent. + $workflow = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request + + # Normalize steps into a stable internal representation. + # We deliberately keep step entries as PSCustomObject to avoid cross-module class loading issues. + $normalizedSteps = @() + foreach ($s in @($workflow.Steps)) { + $normalizedSteps += [pscustomobject]@{ + PSTypeName = 'IdLE.PlanStep' + Name = [string]$s.Name + Type = [string]$s.Type + Description = if ($s.ContainsKey('Description')) { [string]$s.Description } else { $null } + When = if ($s.ContainsKey('When')) { $s.When } else { $null } # Declarative; evaluated later. + With = if ($s.ContainsKey('With')) { $s.With } else { $null } # Parameter bag; validated later. + } + } + + # Create the plan object. Actions are empty in this increment. + # Warnings are an extensibility point (e.g. missing optional inputs). + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + WorkflowName = [string]$workflow.Name + LifecycleEvent = [string]$workflow.LifecycleEvent + CorrelationId = [string]$Request.CorrelationId + Actor = if ($reqProps -contains 'Actor') { [string]$Request.Actor } else { $null } + CreatedUtc = [DateTime]::UtcNow + Steps = $normalizedSteps + Actions = @() + Warnings = @() + Providers = $Providers + } + + return $plan +} diff --git a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 new file mode 100644 index 00000000..96085757 --- /dev/null +++ b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 @@ -0,0 +1,86 @@ +function Test-IdleWorkflowDefinitionObject { + <# + .SYNOPSIS + Loads and strictly validates a workflow definition (PSD1). + + .DESCRIPTION + Performs strict schema validation (unknown keys = error), verifies the workflow is data-only + (no ScriptBlocks), and optionally validates compatibility with a LifecycleRequest. + + .PARAMETER WorkflowPath + Path to the workflow definition PSD1. + + .PARAMETER Request + Optional request object. If provided, Workflow.LifecycleEvent must match Request.LifecycleEvent. + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.WorkflowDefinition) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $WorkflowPath, + + [Parameter()] + [AllowNull()] + [object] $Request + ) + + # 1) Load PSD1 data (no execution). + $workflow = Import-IdleWorkflowDefinition -WorkflowPath $WorkflowPath + + # 2) Enforce "data-only": no ScriptBlocks anywhere in the workflow object. + # This matches the project's config safety rules. + Assert-IdleNoScriptBlock -InputObject $workflow -Path 'Workflow' + + # 3) Strict schema validation: unknown keys and missing required keys are errors. + # using a resizable list to collect all violations and have .Add method available later + $errors = [System.Collections.Generic.List[string]]::new() + $schemaErrors = Test-IdleWorkflowSchema -Workflow $workflow + if ($schemaErrors) { + foreach ($e in @($schemaErrors)) { $null = $errors.Add([string]$e) } + } + + # 4) Optional compatibility check with request (LifecycleEvent match). + if ($null -ne $Request) { + if (-not ($Request.PSObject.Properties.Name -contains 'LifecycleEvent')) { + $errors.Add("Request object does not contain required property 'LifecycleEvent'.") + } + else { + $reqEvent = [string]$Request.LifecycleEvent + $wfEvent = [string]$workflow.LifecycleEvent + + if (-not [string]::IsNullOrWhiteSpace($reqEvent) -and + -not $reqEvent.Equals($wfEvent, [System.StringComparison]::OrdinalIgnoreCase)) { + $errors.Add("Workflow LifecycleEvent '$wfEvent' does not match request LifecycleEvent '$reqEvent'.") + } + } + } + + # 4b) Validate step definitions (Name/Type/When/With + data-only). + $idx = 0 + foreach ($s in @($workflow.Steps)) { + $stepErrors = Test-IdleStepDefinition -Step $s -Index $idx + foreach ($e in @($stepErrors)) { + $null = $errors.Add([string]$e) + } + $idx++ + } + + if ($errors.Count -gt 0) { + # Fail early with a single terminating exception, including all violations. + $message = "Workflow validation failed:`n- " + ($errors -join "`n- ") + throw [System.ArgumentException]::new($message, 'WorkflowPath') + } + + # 5) Return normalized object (stable contract for planning). + # PSCustomObject avoids class/type load-order problems across modules. + return [pscustomobject]@{ + PSTypeName = 'IdLE.WorkflowDefinition' + Name = [string]$workflow.Name + LifecycleEvent = [string]$workflow.LifecycleEvent + Description = if ($workflow.ContainsKey('Description')) { [string]$workflow.Description } else { $null } + Steps = @($workflow.Steps) + } +} diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 new file mode 100644 index 00000000..ceb86888 --- /dev/null +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -0,0 +1,22 @@ +@{ + RootModule = 'IdLE.Steps.Common.psm1' + ModuleVersion = '0.1.0' + GUID = '9bdf5e97-0344-4191-82ed-c534bd7cb9b5' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'Common built-in steps for IdLE.' + PowerShellVersion = '7.0' + + FunctionsToExport = @( + 'Invoke-IdleStepEmitEvent' + ) + + PrivateData = @{ + PSData = @{ + Tags = @('Identity Lifecycle Engine', 'IdLE', 'Steps') + 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.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 new file mode 100644 index 00000000..2f8fdaa9 --- /dev/null +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -0,0 +1,13 @@ +#requires -Version 7.0 +Set-StrictMode -Version Latest + +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +if (Test-Path -Path $PublicPath) { + Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | + Sort-Object -Property FullName | + ForEach-Object { . $_.FullName } +} + +Export-ModuleMember -Function @( + 'Invoke-IdleStepEmitEvent' +) diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 new file mode 100644 index 00000000..4bbde3fa --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 @@ -0,0 +1,53 @@ +function Invoke-IdleStepEmitEvent { + <# + .SYNOPSIS + Emits a custom event (demo step). + + .DESCRIPTION + This step does not change any external state. It simply emits a custom event message. + It is used as a reference implementation for the step plugin contract. + + .PARAMETER Context + Execution context (Request, Plan, Providers, EventSink, CorrelationId, Actor). + + .PARAMETER Step + The plan step object (Name, Type, With, When). + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $message = $null + if ($Step.PSObject.Properties.Name -contains 'With' -and $null -ne $Step.With) { + if ($Step.With -is [hashtable] -and $Step.With.ContainsKey('Message')) { + $message = [string]$Step.With.Message + } + } + + if ([string]::IsNullOrWhiteSpace($message)) { + $message = "EmitEvent step executed." + } + + # Emit a custom event through the engine event sink. + if ($Context.PSObject.Properties.Name -contains 'WriteEvent') { + $Context.WriteEvent('Custom', $message, $Step.Name, @{ StepType = $Step.Type }) + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } +} diff --git a/src/IdLE/IdLE.psd1 b/src/IdLE/IdLE.psd1 new file mode 100644 index 00000000..88fa0f8b --- /dev/null +++ b/src/IdLE/IdLE.psd1 @@ -0,0 +1,27 @@ +@{ + RootModule = 'IdLE.psm1' + ModuleVersion = '0.0.1' + GUID = 'e2f1c3a4-7b9d-4f2a-8c3e-1d5b6a7c8e9f' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'IdentityLifecycleEngine (IdLE) meta-module. Imports IdLE.Core and optional packs.' + PowerShellVersion = '7.0' + + FunctionsToExport = @( + 'Test-IdleWorkflow', + 'New-IdleLifecycleRequest', + 'New-IdlePlan', + 'Invoke-IdlePlan' + ) + CmdletsToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('Identity Lifecycle Engine', 'IdLE', 'Identity', 'Lifecycle', 'Automation', 'Identity Management', 'JML', 'Onboarding', 'Offboarding', 'Account Management') + 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/IdLE.psm1 b/src/IdLE/IdLE.psm1 new file mode 100644 index 00000000..c25b0390 --- /dev/null +++ b/src/IdLE/IdLE.psm1 @@ -0,0 +1,23 @@ +#requires -Version 7.0 + +Set-StrictMode -Version Latest + +$CoreManifestPath = Join-Path -Path $PSScriptRoot -ChildPath '..\IdLE.Core\IdLE.Core.psd1' +Import-Module -Name $CoreManifestPath -Force -ErrorAction Stop + +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +if (Test-Path -Path $PublicPath) { + Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | + Sort-Object -Property FullName | + ForEach-Object { + . $_.FullName + } +} + +# Export exactly the public API cmdlets (contract). +Export-ModuleMember -Function @( + 'Test-IdleWorkflow', + 'New-IdleLifecycleRequest', + 'New-IdlePlan', + 'Invoke-IdlePlan' +) diff --git a/src/IdLE/Public/Invoke-IdlePlan.ps1 b/src/IdLE/Public/Invoke-IdlePlan.ps1 new file mode 100644 index 00000000..f2517eee --- /dev/null +++ b/src/IdLE/Public/Invoke-IdlePlan.ps1 @@ -0,0 +1,52 @@ +function Invoke-IdlePlan { + <# + .SYNOPSIS + Executes an IdLE plan. + + .DESCRIPTION + Executes a plan deterministically and emits structured events. + Delegates execution to IdLE.Core. + + .PARAMETER Plan + The plan object created by New-IdlePlan. + + .PARAMETER Providers + Provider registry/collection passed through to execution. + + .PARAMETER EventSink + Optional event sink. Can be a ScriptBlock or an object with a WriteEvent() method. + + .EXAMPLE + Invoke-IdlePlan -Plan $plan -Providers $providers + + .OUTPUTS + System.Object + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Plan, + + [Parameter()] + [AllowNull()] + [object] $Providers, + + [Parameter()] + [AllowNull()] + [object] $EventSink + ) + + if (-not $PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { + # For WhatIf: return a minimal preview object. + return [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionResult' + Status = 'WhatIf' + CorrelationId = if ($Plan.PSObject.Properties.Name -contains 'CorrelationId') { [string]$Plan.CorrelationId } else { $null } + Steps = @($Plan.Steps) + Events = @() + } + } + + return Invoke-IdlePlanObject -Plan $Plan -Providers $Providers -EventSink $EventSink +} diff --git a/src/IdLE/Public/New-IdleLifecycleRequest.ps1 b/src/IdLE/Public/New-IdleLifecycleRequest.ps1 new file mode 100644 index 00000000..53f5323d --- /dev/null +++ b/src/IdLE/Public/New-IdleLifecycleRequest.ps1 @@ -0,0 +1,59 @@ +function New-IdleLifecycleRequest { + <# + .SYNOPSIS + Creates a lifecycle request object. + + .DESCRIPTION + Creates and normalizes an IdLE LifecycleRequest representing business intent + (e.g. Joiner/Mover/Leaver). CorrelationId is generated if missing. Actor is optional. + Changes is optional and stays $null when omitted. + + .PARAMETER LifecycleEvent + The lifecycle event name (e.g. Joiner, Mover, Leaver). + + .PARAMETER CorrelationId + Correlation identifier for audit/event correlation. Generated if missing. + + .PARAMETER Actor + Optional actor claim who initiated the request. Not required by the core engine in V1. + + .PARAMETER IdentityKeys + A hashtable of system-neutral identity keys (e.g. EmployeeId, UPN, ObjectId). + + .PARAMETER DesiredState + A hashtable describing the desired state (attributes, entitlements, etc.). + + .PARAMETER Changes + Optional hashtable describing changes (typically used for Mover lifecycle events). + + .EXAMPLE + New-IdleLifecycleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } + + .OUTPUTS + IdleLifecycleRequest + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $LifecycleEvent, + + [Parameter()] + [string] $CorrelationId, + + [Parameter()] + [string] $Actor, + + [Parameter()] + [hashtable] $IdentityKeys = @{}, + + [Parameter()] + [hashtable] $DesiredState = @{}, + + [Parameter()] + [hashtable] $Changes + ) + + # Use core-exported factory to construct the domain object. Keeps domain model inside IdLE.Core. + New-IdleLifecycleRequestObject @PSBoundParameters +} diff --git a/src/IdLE/Public/New-IdlePlan.ps1 b/src/IdLE/Public/New-IdlePlan.ps1 new file mode 100644 index 00000000..841d6587 --- /dev/null +++ b/src/IdLE/Public/New-IdlePlan.ps1 @@ -0,0 +1,42 @@ +function New-IdlePlan { + <# + .SYNOPSIS + Creates a deterministic plan from a lifecycle request and a workflow definition. + + .DESCRIPTION + Delegates plan building to IdLE.Core and returns a plan artifact. + Providers are passed through for later increments. + + .PARAMETER WorkflowPath + Path to the workflow definition file (PSD1). + + .PARAMETER Request + The lifecycle request object created by New-IdleLifecycleRequest. + + .PARAMETER Providers + Provider registry/collection passed through to planning. (Structure to be defined later.) + + .EXAMPLE + $plan = New-IdlePlan -WorkflowPath ./workflows/joiner.psd1 -Request $request -Providers $providers + + .OUTPUTS + System.Object + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $WorkflowPath, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Request, + + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + # Keep meta module thin: delegate planning to IdLE.Core. + return New-IdlePlanObject -WorkflowPath $WorkflowPath -Request $Request -Providers $Providers +} diff --git a/src/IdLE/Public/Test-IdleWorkflow.ps1 b/src/IdLE/Public/Test-IdleWorkflow.ps1 new file mode 100644 index 00000000..73ab60f4 --- /dev/null +++ b/src/IdLE/Public/Test-IdleWorkflow.ps1 @@ -0,0 +1,43 @@ +function Test-IdleWorkflow { + <# + .SYNOPSIS + Validates an IdLE workflow definition file. + + .DESCRIPTION + Loads and strictly validates a workflow definition (PSD1). + Throws on validation errors. + + .PARAMETER WorkflowPath + Path to the workflow definition file (PSD1). + + .PARAMETER Request + Optional lifecycle request for validating compatibility (LifecycleEvent match). + + .EXAMPLE + Test-IdleWorkflow -WorkflowPath ./workflows/joiner.psd1 -Request $request + + .OUTPUTS + System.Object + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $WorkflowPath, + + [Parameter()] + [AllowNull()] + [object] $Request + ) + + # Delegate validation to IdLE.Core to keep the meta module thin and stable. + $wf = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request + + # Test-* cmdlets typically return a small report object instead of the full definition. + return [pscustomobject]@{ + IsValid = $true + WorkflowName = $wf.Name + LifecycleEvent = $wf.LifecycleEvent + StepCount = @($wf.Steps).Count + } +} diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 new file mode 100644 index 00000000..8ae825ab --- /dev/null +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -0,0 +1,190 @@ +BeforeAll { + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' + Import-Module $modulePath -Force +} + +Describe 'Invoke-IdlePlan' { + It 'returns an execution result with events in deterministic order' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + @{ Name = 'EnsureAttributes'; Type = 'IdLE.Step.EnsureAttributes' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $noop = { + param($Context, $Step) + [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = $noop + 'IdLE.Step.EnsureAttributes' = $noop + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + @($result.Steps).Count | Should -Be 2 + + @($result.Events).Count | Should -BeGreaterThan 0 + $result.Events[0].Type | Should -Be 'RunStarted' + $result.Events[-1].Type | Should -Be 'RunCompleted' + + $result.Steps[0].Status | Should -Be 'Completed' + $result.Steps[1].Status | Should -Be 'Completed' + } + + It 'supports -WhatIf and does not execute' { + $plan = [pscustomobject]@{ + CorrelationId = 'test' + Steps = @( + @{ Name = 'A'; Type = 'X' } + ) + } + + $result = Invoke-IdlePlan -Plan $plan -WhatIf + $result.Status | Should -Be 'WhatIf' + @($result.Events).Count | Should -Be 0 + } + + It 'can stream events to a ScriptBlock sink' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $noop = { + param($Context, $Step) + [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = $noop + } + } + + $sinkEvents = [System.Collections.Generic.List[object]]::new() + $sink = { + param($e) + [void]$sinkEvents.Add($e) + }.GetNewClosure() + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers -EventSink $sink + + $sinkEvents.Count | Should -BeGreaterThan 0 + $sinkEvents[0].PSTypeNames | Should -Contain 'IdLE.Event' + $result.Events[0].Type | Should -Be 'RunStarted' + } + + It 'executes a registered step and returns Completed status' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'emit.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Demo' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Emit'; Type = 'IdLE.Step.EmitEvent'; With = @{ Message = 'Hello' } } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $emit = { + param($Context, $Step) + & $Context.WriteEvent 'Custom' 'Hello from test step' $Step.Name @{ StepType = $Step.Type } + + [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.EmitEvent' = $emit + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + } + + It 'fails planning when a step is missing Type' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Bad' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'NoType' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw + } + + It 'fails planning when When schema is invalid' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad-when.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'BadWhen' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Emit' + Type = 'IdLE.Step.EmitEvent' + When = @{ Path = 'Plan.LifecycleEvent'; Equals = 'Joiner'; Exists = $true } + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw + } +} \ No newline at end of file diff --git a/tests/Invoke-IdlePlan.When.Tests.ps1 b/tests/Invoke-IdlePlan.When.Tests.ps1 new file mode 100644 index 00000000..c1270e2d --- /dev/null +++ b/tests/Invoke-IdlePlan.When.Tests.ps1 @@ -0,0 +1,96 @@ +BeforeAll { + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' + Import-Module $modulePath -Force +} + +Describe 'Invoke-IdlePlan - When conditions' { + + It 'skips a step when condition is not met' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'when.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'When Demo' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Emit' + Type = 'IdLE.Step.EmitEvent' + When = @{ Path = 'Plan.LifecycleEvent'; Equals = 'Leaver' } + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $emit = { + param($Context, $Step) + & $Context.WriteEvent 'Custom' 'Hello' $Step.Name @{ StepType = $Step.Type } + [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.EmitEvent' = $emit + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Skipped' + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 + ($result.Events | Where-Object Type -eq 'StepSkipped').Count | Should -Be 1 + } + + It 'runs a step when condition is met' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'when2.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'When Demo' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Emit' + Type = 'IdLE.Step.EmitEvent' + When = @{ Path = 'Plan.LifecycleEvent'; Equals = 'Joiner' } + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $emit = { + param($Context, $Step) + & $Context.WriteEvent 'Custom' 'Hello' $Step.Name @{ StepType = $Step.Type } + [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.EmitEvent' = $emit + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + } +} diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 new file mode 100644 index 00000000..b0a357fa --- /dev/null +++ b/tests/ModuleSurface.Tests.ps1 @@ -0,0 +1,66 @@ +BeforeAll { + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') + $idlePsd1 = Join-Path $repoRoot 'src\IdLE\IdLE.psd1' + $corePsd1 = Join-Path $repoRoot 'src\IdLE.Core\IdLE.Core.psd1' + $stepsPsd1 = Join-Path $repoRoot 'src\IdLE.Steps.Common\IdLE.Steps.Common.psd1' +} + +Describe 'Module manifests and public surface' { + + It 'IdLE manifest is valid' { + { Test-ModuleManifest -Path $idlePsd1 -ErrorAction Stop } | Should -Not -Throw + } + + It 'IdLE.Core manifest is valid' { + { Test-ModuleManifest -Path $corePsd1 -ErrorAction Stop } | Should -Not -Throw + } + + It 'IdLE exports only the intended public commands' { + Remove-Module IdLE -Force -ErrorAction SilentlyContinue + Import-Module $idlePsd1 -Force -ErrorAction Stop + + $expected = @( + 'Invoke-IdlePlan' + 'New-IdleLifecycleRequest' + 'New-IdlePlan' + 'Test-IdleWorkflow' + ) | Sort-Object + + $actual = (Get-Command -Module IdLE).Name | Sort-Object + + $actual | Should -Be $expected + } + + It 'Importing IdLE does not load IdLE.Steps.Common by default' { + Remove-Module IdLE, IdLE.Steps.Common -Force -ErrorAction SilentlyContinue + Import-Module $idlePsd1 -Force -ErrorAction Stop + + (Get-Module IdLE.Steps.Common) | Should -BeNullOrEmpty + (Get-Command Invoke-IdleStepEmitEvent -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty + } + + It 'Importing IdLE does not expose IdLE.Core object cmdlets globally' { + Remove-Module IdLE, IdLE.Core -Force -ErrorAction SilentlyContinue + Import-Module $idlePsd1 -Force -ErrorAction Stop + + (Get-Command New-IdlePlanObject -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty + (Get-Command Invoke-IdlePlanObject -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty + } + + It 'IdLE module includes IdLE.Core as nested module' { + Remove-Module IdLE -Force -ErrorAction SilentlyContinue + Import-Module $idlePsd1 -Force -ErrorAction Stop + + $idle = Get-Module IdLE + $idle | Should -Not -BeNullOrEmpty + + ($idle.NestedModules | Where-Object Name -eq 'IdLE.Core') | Should -Not -BeNullOrEmpty + } + + It 'Steps module exports the intended step function' { + Remove-Module IdLE.Steps.Common -Force -ErrorAction SilentlyContinue + Import-Module $stepsPsd1 -Force -ErrorAction Stop + + (Get-Command -Module IdLE.Steps.Common).Name | Should -Contain 'Invoke-IdleStepEmitEvent' + } +} diff --git a/tests/New-IdleLifecycleRequest.Tests.ps1 b/tests/New-IdleLifecycleRequest.Tests.ps1 new file mode 100644 index 00000000..ced50f9f --- /dev/null +++ b/tests/New-IdleLifecycleRequest.Tests.ps1 @@ -0,0 +1,115 @@ +BeforeAll { + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' + Import-Module $modulePath -Force +} + +Describe 'New-IdleLifecycleRequest' { + It 'creates a request object with the expected type' { + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req | Should -Not -BeNullOrEmpty + $req.GetType().Name | Should -Be 'IdleLifecycleRequest' + } + + It 'generates CorrelationId when missing' { + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req.CorrelationId | Should -Not -BeNullOrEmpty + { [guid]::Parse($req.CorrelationId) } | Should -Not -Throw + } + + It 'preserves CorrelationId when provided' { + $cid = ([guid]::NewGuid()).Guid + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -CorrelationId $cid + $req.CorrelationId | Should -Be $cid + } + + It 'defaults IdentityKeys and DesiredState to empty hashtables when omitted' { + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req.IdentityKeys | Should -BeOfType 'hashtable' + $req.DesiredState | Should -BeOfType 'hashtable' + $req.IdentityKeys.Count | Should -Be 0 + $req.DesiredState.Count | Should -Be 0 + } + + It 'leaves Changes as null when omitted' { + $req = New-IdleLifecycleRequest -LifecycleEvent 'Mover' + $req.Changes | Should -BeNullOrEmpty + } + + It 'accepts Changes when provided' { + $req = New-IdleLifecycleRequest -LifecycleEvent 'Mover' -Changes @{ + Attributes = @{ + Department = @{ + From = 'Sales' + To = 'IT' + } + } + } + + $req.Changes | Should -BeOfType 'hashtable' + $req.Changes.Attributes.Department.From | Should -Be 'Sales' + $req.Changes.Attributes.Department.To | Should -Be 'IT' + } + + It 'treats Actor as optional (null when omitted)' { + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $req.Actor | Should -BeNullOrEmpty + } + + It 'accepts Actor when provided' { + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -Actor 'alice@contoso.com' + $req.Actor | Should -Be 'alice@contoso.com' + } +} + +Describe 'New-IdleLifecycleRequest - data-only validation' { + + It 'rejects ScriptBlock in DesiredState when provided' { + try { + New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + Attributes = @{ Department = { 'IT' } } + } + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception | Should -BeOfType ([System.ArgumentException]) + $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' + $_.Exception.Message | Should -Match 'DesiredState' + } + } + + It 'rejects ScriptBlock nested in arrays' { + try { + New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + Entitlements = @( + @{ Type = 'Group'; Value = 'APP-CRM-Users' } + @{ Type = 'Custom'; Value = { 'NOPE' } } + ) + } + } + catch { + $_.Exception | Should -BeOfType ([System.ArgumentException]) + $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' + $_.Exception.Message | Should -Match 'DesiredState' + } + } + + It 'rejects ScriptBlock in Changes when provided' { + try { + New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -Changes @{ + Attributes = @{ + Department = @{ + From = 'Sales' + To = { 'IT' } + } + } + } + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception | Should -BeOfType ([System.ArgumentException]) + $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' + $_.Exception.Message | Should -Match 'Changes' + } + } +} + diff --git a/tests/New-IdlePlan.Tests.ps1 b/tests/New-IdlePlan.Tests.ps1 new file mode 100644 index 00000000..c487e872 --- /dev/null +++ b/tests/New-IdlePlan.Tests.ps1 @@ -0,0 +1,63 @@ +BeforeAll { + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' + Import-Module $modulePath -Force +} + +Describe 'New-IdlePlan' { + + It 'creates a plan with normalized steps' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + @{ Name = 'EnsureAttributes'; Type = 'IdLE.Step.EnsureAttributes'; With = @{ Mode = 'Minimal' } } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + + $plan | Should -Not -BeNullOrEmpty + $plan.PSTypeNames | Should -Contain 'IdLE.Plan' + $plan.WorkflowName | Should -Be 'Joiner - Standard' + $plan.LifecycleEvent | Should -Be 'Joiner' + $plan.CorrelationId | Should -Be $req.CorrelationId + + @($plan.Steps).Count | Should -Be 2 + $plan.Steps[0].PSTypeNames | Should -Contain 'IdLE.PlanStep' + $plan.Steps[0].Name | Should -Be 'ResolveIdentity' + $plan.Steps[0].Type | Should -Be 'IdLE.Step.ResolveIdentity' + + @($plan.Actions).Count | Should -Be 0 + @($plan.Warnings).Count | Should -Be 0 + + $plan.Providers.Dummy | Should -BeTrue + } + + It 'throws when request LifecycleEvent does not match workflow LifecycleEvent' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'does not match request LifecycleEvent' + } + } +} diff --git a/tests/Test-IdleWorkflow.Tests.ps1 b/tests/Test-IdleWorkflow.Tests.ps1 new file mode 100644 index 00000000..33f30faf --- /dev/null +++ b/tests/Test-IdleWorkflow.Tests.ps1 @@ -0,0 +1,115 @@ +BeforeAll { + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' + Import-Module $modulePath -Force +} + +Describe 'Test-IdleWorkflow' { + + It 'returns a valid result for a minimal correct workflow' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $result = Test-IdleWorkflow -WorkflowPath $wfPath -Request $req + + $result.IsValid | Should -BeTrue + $result.WorkflowName | Should -Be 'Joiner - Standard' + $result.LifecycleEvent | Should -Be 'Joiner' + $result.StepCount | Should -Be 1 + } + + It 'throws for unknown root keys (strict validation)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad-root.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) + Bogus = 'nope' +} +'@ + + try { + Test-IdleWorkflow -WorkflowPath $wfPath | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'Unknown root key' + } + } + + It 'throws when a step is missing required keys' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad-step.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity' } + ) +} +'@ + + try { + Test-IdleWorkflow -WorkflowPath $wfPath | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'Steps\[0\]\.Type' + } + } + + It 'throws when the workflow contains ScriptBlocks (data-only rule)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad-sb.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity'; With = @{ X = { "NOPE" } } } + ) +} +'@ + + try { + Test-IdleWorkflow -WorkflowPath $wfPath | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' + } + } + + It 'throws when request LifecycleEvent does not match workflow LifecycleEvent' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' + + try { + Test-IdleWorkflow -WorkflowPath $wfPath -Request $req | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'does not match request LifecycleEvent' + } + } +}