From a925db43b2d979043ab31d60ca888587a56a3b47 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:17:19 +0100 Subject: [PATCH 01/32] adding styleguide and contributing --- CONTRIBUTING.md | 100 +++++++++++++++++++++++++++ STYLEGUIDE.md | 176 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 STYLEGUIDE.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..1940bb4c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,100 @@ +# Contributing to IdentityLifecycleEngine (IdLE) + +Thank you for contributing to **IdentityLifecycleEngine (IdLE)** πŸŽ‰ +This document explains **how we work in this repository**. + +For detailed coding and documentation rules, see **STYLEGUIDE.md**. + +--- + +## 1. Project Overview + +IdLE is a **headless, data-driven Identity Lifecycle (JML) engine** built with PowerShell Core. +The project prioritizes: + +- deterministic behavior +- strict validation +- security by design +- long-term maintainability + +--- + +## 2. Repository Structure + +```shell +/src + /IdLE.Core + /IdLE.Steps.* + /IdLE.Providers.* +/tests + /IdLE.Core.Tests + /IdLE.Steps.Tests +/docs + architecture.md +STYLEGUIDE.md +CONTRIBUTING.md +README.md +``` + +--- + +## 3. Development Workflow + +### 3.1 Branching + +- `main` – stable +- `feature/` +- `fix/` + +### 3.2 Commits + +- Small, focused commits +- English commit messages + +Format: + +``` +: +``` + +### 3.3 Pull Requests + +All changes require a Pull Request. + +PRs must include: + +- clear description of what and why +- tests for new/changed behavior +- documentation updates if public behavior changes + +--- + +## 4. Definition of Done + +A contribution is considered done when: + +- tests pass (`Invoke-Pester`) +- strict validation rules are not weakened +- public APIs are documented +- architecture principles are respected +- STYLEGUIDE.md rules are followed + +--- + +## 5. Tooling + +- PowerShell Core 7+ +- Pester for tests +- Visual Studio Code recommended + +--- + +## 6. Documentation + +- Architecture: `docs/architecture.md` +- Coding & documentation rules: **STYLEGUIDE.md** + +--- + +Happy contributing πŸš€ +β€” *IdLE Maintainers* diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md new file mode 100644 index 00000000..3a884dda --- /dev/null +++ b/STYLEGUIDE.md @@ -0,0 +1,176 @@ +# IdentityLifecycleEngine (IdLE) Style Guide + +This document defines the **coding, documentation, and testing standards** +for **IdentityLifecycleEngine (IdLE)**. + +This is the authoritative source for style and best practices. + +--- + +## 1. PowerShell Coding Standards + +### 1.1 PowerShell Version + +- PowerShell Core **7+ only** + +--- + +### 1.2 Language Rules + +- All code, comments, and documentation **must be in English** +- Prefer explicit, readable code + +--- + +### 1.3 Naming + +- Verb-Noun for public cmdlets (`New-`, `Test-`, `Invoke-`) +- Singular nouns +- No abbreviations unless well known + +--- + +### 1.4 Formatting + +- 4 spaces indentation +- One statement per line +- Curly braces on same line + +```powershell +if ($condition) { + Do-Something +} +``` + +--- + +## 2. Public APIs & Functions + +### 2.1 Public vs Private + +- Public functions must be exported explicitly +- Private helpers must not be exported + +--- + +### 2.2 Error Handling + +- Use `throw` for errors +- Do not use `Write-Error` for control flow +- Errors must be actionable + +--- + +## 3. Comment-Based Help (Mandatory) + +All public functions MUST include comment-based help. + +Required sections: + +- `.SYNOPSIS` +- `.DESCRIPTION` +- `.PARAMETER` +- `.EXAMPLE` +- `.OUTPUTS` + +--- + +## 4. Inline Comments + +- Explain **why**, not **what** +- Avoid obvious comments + +--- + +## 5. Configuration Rules + +- PSD1 only for workflows and metadata +- No PowerShell expressions +- No script blocks +- No dynamic evaluation + +--- + +## 6. Steps + +Steps must: + +- be idempotent +- produce data-only actions +- never perform authentication +- write only declared `State.*` outputs + +--- + +## 7. Providers + +Providers: + +- handle all authentication +- use `ExecutionContext.AcquireSession()` +- never assume global state +- must be mockable + +--- + +## 8. State Management + +- Replace-at-path semantics (V1) +- No deep merges +- No overwriting other steps' outputs + +--- + +## 9. Testing Standards + +### 9.1 Framework + +- Pester only + +### 9.2 Test Types + +- Unit tests (Core) +- Contract tests (Providers) +- Workflow validation tests + +--- + +## 10. Documentation Structure + +- `/docs/architecture.md` +- `/docs/domain-model.md` +- `/docs/steps/.md` +- `/docs/providers/.md` + +Documentation must be updated together with code changes. + +--- + +## 11. IDE & Tooling + +### 11.1 Recommended IDE + +- Visual Studio Code + +### 11.2 Extensions + +- PowerShell +- EditorConfig +- Markdown All in One + +--- + +## 12. 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 configuration +- introduce global state From 9c48bf78e7c7d241bb7241c104a1716c0e17b5ee Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:31:58 +0100 Subject: [PATCH 02/32] added architecture docs --- docs/01-architecture.md | 318 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 docs/01-architecture.md diff --git a/docs/01-architecture.md b/docs/01-architecture.md new file mode 100644 index 00000000..a862051c --- /dev/null +++ b/docs/01-architecture.md @@ -0,0 +1,318 @@ +# 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: + +- Scenario (Joiner/Mover/Leaver/…) +- IdentityKeys (UPN, EmployeeId, ObjectId, …) +- DesiredState (attributes, entitlements, etc.) +- Changes (for mover scenarios) +- 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) From b56c676fd9a8d09ac08e8e858179f8ecda7712da Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:36:07 +0100 Subject: [PATCH 03/32] optimized CONTRIBUTING and STYLEGUIDE --- CONTRIBUTING.md | 145 +++++++++++++++++++++++++++++------------------- STYLEGUIDE.md | 127 +++++++++++++++--------------------------- 2 files changed, 135 insertions(+), 137 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1940bb4c..9061b1e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,100 +1,133 @@ # Contributing to IdentityLifecycleEngine (IdLE) -Thank you for contributing to **IdentityLifecycleEngine (IdLE)** πŸŽ‰ -This document explains **how we work in this repository**. +Thank you for your interest in contributing to **IdentityLifecycleEngine (IdLE)**! πŸŽ‰ +We welcome contributions that improve quality, stability, and maintainability. -For detailed coding and documentation rules, see **STYLEGUIDE.md**. +This document follows common **GitHub open-source conventions** and explains **how to contribute**. +For detailed coding rules, see **STYLEGUIDE.md**. --- -## 1. Project Overview +## Code of Conduct -IdLE is a **headless, data-driven Identity Lifecycle (JML) engine** built with PowerShell Core. -The project prioritizes: +This project expects respectful and constructive collaboration. +(If a CODE_OF_CONDUCT.md is added, it applies to all contributors.) -- deterministic behavior -- strict validation -- security by design -- long-term maintainability +--- + +## How Can I Contribute? + +You can contribute by: + +- Reporting bugs +- Suggesting enhancements +- Improving documentation +- Submitting pull requests --- -## 2. Repository Structure +## Reporting Bugs -```shell -/src - /IdLE.Core - /IdLE.Steps.* - /IdLE.Providers.* -/tests - /IdLE.Core.Tests - /IdLE.Steps.Tests -/docs - architecture.md -STYLEGUIDE.md -CONTRIBUTING.md -README.md -``` +Please open a GitHub Issue and include: + +- a clear and descriptive title +- steps to reproduce +- expected vs. actual behavior +- environment details (PowerShell version, OS) --- -## 3. Development Workflow +## Suggesting Enhancements + +Enhancement proposals should: -### 3.1 Branching +- explain the problem being solved +- explain why it fits IdLE’s architecture +- consider backward compatibility -- `main` – stable -- `feature/` -- `fix/` +--- -### 3.2 Commits +## Development Setup -- Small, focused commits -- English commit messages +### Prerequisites -Format: +- PowerShell Core 7+ +- Git +- Visual Studio Code (recommended) -``` -: +### Clone the Repository + +```bash +git clone https://github.com//IdentityLifecycleEngine.git ``` -### 3.3 Pull Requests +--- -All changes require a Pull Request. +## Development Workflow -PRs must include: +### Branching Model -- clear description of what and why -- tests for new/changed behavior -- documentation updates if public behavior changes +- `main` β†’ stable +- feature branches: + - `feature/` + - `fix/` --- -## 4. Definition of Done +### Commit Messages -A contribution is considered done when: +- Use clear, concise English +- One logical change per commit -- tests pass (`Invoke-Pester`) -- strict validation rules are not weakened -- public APIs are documented -- architecture principles are respected -- STYLEGUIDE.md rules are followed +Recommended format: + +```shell +: +``` + +Example: + +```shell +core: add strict workflow validation +``` --- -## 5. Tooling +### Pull Requests -- PowerShell Core 7+ -- Pester for tests -- Visual Studio Code recommended +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 +- no architecture rules are violated +- public APIs are documented +- relevant docs are updated --- -## 6. Documentation +## Documentation -- Architecture: `docs/architecture.md` +- Architecture: `docs/idle-architecture.md` - Coding & documentation rules: **STYLEGUIDE.md** --- -Happy contributing πŸš€ +Thank you for contributing πŸš€ β€” *IdLE Maintainers* diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 3a884dda..6166f138 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -1,71 +1,49 @@ -# IdentityLifecycleEngine (IdLE) Style Guide +# IdLE Style Guide -This document defines the **coding, documentation, and testing standards** +This document defines **coding, documentation, and testing standards** for **IdentityLifecycleEngine (IdLE)**. - -This is the authoritative source for style and best practices. +It follows widely accepted **GitHub and PowerShell community conventions**. --- -## 1. PowerShell Coding Standards - -### 1.1 PowerShell Version +## General Principles -- PowerShell Core **7+ only** +- Prefer clarity over cleverness +- Fail early and explicitly +- Keep behavior deterministic +- Avoid hidden side effects --- -### 1.2 Language Rules +## PowerShell Standards -- All code, comments, and documentation **must be in English** -- Prefer explicit, readable code +### PowerShell Version + +- PowerShell Core **7+ only** --- -### 1.3 Naming +### Naming Conventions -- Verb-Noun for public cmdlets (`New-`, `Test-`, `Invoke-`) +- Verb-Noun cmdlet naming - Singular nouns -- No abbreviations unless well known +- Avoid abbreviations --- -### 1.4 Formatting +### Formatting - 4 spaces indentation +- UTF-8, LF - One statement per line -- Curly braces on same line - -```powershell -if ($condition) { - Do-Something -} -``` --- -## 2. Public APIs & Functions - -### 2.1 Public vs Private +## Public APIs -- Public functions must be exported explicitly -- Private helpers must not be exported - ---- - -### 2.2 Error Handling - -- Use `throw` for errors -- Do not use `Write-Error` for control flow -- Errors must be actionable - ---- +### Comment-Based Help (Required) -## 3. Comment-Based Help (Mandatory) - -All public functions MUST include comment-based help. - -Required sections: +All exported functions must include comment-based help with: - `.SYNOPSIS` - `.DESCRIPTION` @@ -73,86 +51,73 @@ Required sections: - `.EXAMPLE` - `.OUTPUTS` +Public APIs are part of the contract and must remain stable. + --- -## 4. Inline Comments +## Inline Comments - Explain **why**, not **what** -- Avoid obvious comments +- Avoid restating obvious code --- -## 5. Configuration Rules +## Configuration Rules -- PSD1 only for workflows and metadata -- No PowerShell expressions +- PSD1 only - No script blocks -- No dynamic evaluation +- No PowerShell expressions +- Configuration must be data-only --- -## 6. Steps +## Steps Steps must: - be idempotent - produce data-only actions -- never perform authentication +- not perform authentication - write only declared `State.*` outputs --- -## 7. Providers +## Providers Providers: -- handle all authentication +- handle authentication - use `ExecutionContext.AcquireSession()` -- never assume global state - must be mockable +- must not assume global state --- -## 8. State Management - -- Replace-at-path semantics (V1) -- No deep merges -- No overwriting other steps' outputs - ---- - -## 9. Testing Standards - -### 9.1 Framework +## Testing - Pester only - -### 9.2 Test Types - -- Unit tests (Core) -- Contract tests (Providers) -- Workflow validation tests +- No live system calls in unit tests +- Providers require contract tests --- -## 10. Documentation Structure +## Documentation -- `/docs/architecture.md` -- `/docs/domain-model.md` -- `/docs/steps/.md` -- `/docs/providers/.md` +Documentation must be updated when: -Documentation must be updated together with code changes. +- public APIs change +- workflow behavior changes +- provider auth requirements change --- -## 11. IDE & Tooling +## Tooling -### 11.1 Recommended IDE +### Recommended IDE - Visual Studio Code -### 11.2 Extensions +### Extensions - PowerShell - EditorConfig @@ -160,7 +125,7 @@ Documentation must be updated together with code changes. --- -## 12. Do's and Don'ts +## Do's and Don'ts ### Do @@ -172,5 +137,5 @@ Documentation must be updated together with code changes. - add UI to the engine - add auth to steps -- hide logic in configuration +- hide logic in config - introduce global state From f9dd29497163f63ecff33954d956a2700d783ee3 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:40:06 +0100 Subject: [PATCH 04/32] adding github templates --- .github/ISSUE_TEMPLATE/bug_report.md | 36 ++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 28 ++++++++++++ .../ISSUE_TEMPLATE/pull_request_template.md | 43 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/pull_request_template.md 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). From 85c5e9e4ef976f8e5e78f8dce531bebdd94ba9ae Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:49:27 +0100 Subject: [PATCH 05/32] referencing to CONTRIBUTING file in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a91ba5c..80be006b 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ Invoke-Pester -Path ./tests ## 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 From 8cdae96a7c75f79959f69d7b5e14f50728302028 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 14:04:21 +0100 Subject: [PATCH 06/32] changing scenarios to lifecycleevent --- README.md | 10 +++++----- docs/01-architecture.md | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 80be006b..583822d8 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…) @@ -74,8 +74,8 @@ Install-Module IdLE Typical flow: **Create request β†’ Validate workflow β†’ Build plan β†’ Execute plan** ```powershell -# 1) Create a request (scenario + identity keys + desired state) -$request = New-IdleLifecycleRequest -Scenario Joiner -Actor 'alice@contoso.com' -CorrelationId (New-Guid) -IdentityKeys @{ +# 1) Create a request (LifecycleEvent + identity keys + desired state) +$request = New-IdleLifecycleRequest -LifecycleEvent Joiner -Actor 'alice@contoso.com' -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' UPN = 'new.user@contoso.com' } -DesiredState @{ @@ -116,8 +116,8 @@ Example (illustrative): ```powershell @{ - Name = 'Joiner - Standard' - Scenario = 'Joiner' + Name = 'Joiner - Standard' + LifecycleEvent = 'Joiner' Steps = @( @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } @{ Name = 'EnsureAttributes'; Type = 'IdLE.Step.EnsureAttributes' } diff --git a/docs/01-architecture.md b/docs/01-architecture.md index a862051c..b1b0b9c0 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -103,10 +103,10 @@ We use the field-based reference convention: **LifecycleRequest** is the domain input representing business intent: -- Scenario (Joiner/Mover/Leaver/…) +- LifecycleEvent (Joiner/Mover/Leaver/…) - IdentityKeys (UPN, EmployeeId, ObjectId, …) - DesiredState (attributes, entitlements, etc.) -- Changes (for mover scenarios) +- Changes (for mover lifecycle events) - CorrelationId (required; generated if missing) ### 7.2 LifecyclePlan From 017ad4d08fb8fb98c0d0b39c7dc3709178104fe5 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 14:06:55 +0100 Subject: [PATCH 07/32] core: add module skeleton for IdLE and IdLE.Core --- src/IdLE.Core/IdLE.Core.psd1 | 22 ++++++++ src/IdLE.Core/IdLE.Core.psm1 | 21 ++++++++ src/IdLE/IdLE.psd1 | 27 ++++++++++ src/IdLE/IdLE.psm1 | 24 +++++++++ src/IdLE/Public/Invoke-IdlePlan.ps1 | 33 ++++++++++++ src/IdLE/Public/New-IdleLifecycleRequest.ps1 | 56 ++++++++++++++++++++ src/IdLE/Public/New-IdlePlan.ps1 | 35 ++++++++++++ src/IdLE/Public/Test-IdleWorkflow.ps1 | 34 ++++++++++++ 8 files changed, 252 insertions(+) create mode 100644 src/IdLE.Core/IdLE.Core.psd1 create mode 100644 src/IdLE.Core/IdLE.Core.psm1 create mode 100644 src/IdLE/IdLE.psd1 create mode 100644 src/IdLE/IdLE.psm1 create mode 100644 src/IdLE/Public/Invoke-IdlePlan.ps1 create mode 100644 src/IdLE/Public/New-IdleLifecycleRequest.ps1 create mode 100644 src/IdLE/Public/New-IdlePlan.ps1 create mode 100644 src/IdLE/Public/Test-IdleWorkflow.ps1 diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 new file mode 100644 index 00000000..8c30a748 --- /dev/null +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -0,0 +1,22 @@ +@{ + 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 = @() + CmdletsToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('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..21d130b4 --- /dev/null +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -0,0 +1,21 @@ +#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 nothing directly. The meta module (IdLE) exposes the public API. +Export-ModuleMember -Function @() -Alias @() diff --git a/src/IdLE/IdLE.psd1 b/src/IdLE/IdLE.psd1 new file mode 100644 index 00000000..bb55aae9 --- /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', '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..8cc8269a --- /dev/null +++ b/src/IdLE/IdLE.psm1 @@ -0,0 +1,24 @@ +#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..38c7be0c --- /dev/null +++ b/src/IdLE/Public/Invoke-IdlePlan.ps1 @@ -0,0 +1,33 @@ +function Invoke-IdlePlan { + <# + .SYNOPSIS + Executes an IdLE plan. + + .DESCRIPTION + Executes a previously created plan in a deterministic way and emits structured events. + This is a stub in the core skeleton increment and will be implemented in subsequent commits. + + .PARAMETER Plan + The plan object created by New-IdlePlan. + + .PARAMETER WhatIf + Shows what would happen if the plan is executed. + + .EXAMPLE + $plan = New-IdlePlan -Request $req -WorkflowPath ./workflows/joiner.psd1 + Invoke-IdlePlan -Plan $plan + + .OUTPUTS + System.Object + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Plan + ) + + if ($PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { + throw 'Not implemented: Invoke-IdlePlan will be implemented in IdLE.Core in a subsequent increment.' + } +} diff --git a/src/IdLE/Public/New-IdleLifecycleRequest.ps1 b/src/IdLE/Public/New-IdleLifecycleRequest.ps1 new file mode 100644 index 00000000..0a4613ab --- /dev/null +++ b/src/IdLE/Public/New-IdleLifecycleRequest.ps1 @@ -0,0 +1,56 @@ +function New-IdleLifecycleRequest { + <# + .SYNOPSIS + Creates a lifecycle request object. + + .DESCRIPTION + Creates an IdLE lifecycle request representing business intent (e.g. Joiner/Mover/Leaver). + This is a stub in the core skeleton increment and will be implemented in subsequent commits. + + .PARAMETER LifecycleEvent + The lifecycle event name (e.g. Joiner, Mover, Leaver). + + .PARAMETER Actor + The actor who initiated the request (required). + + .PARAMETER CorrelationId + A correlation identifier for audit/event correlation (required). + + .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 -Actor 'alice@contoso.com' -CorrelationId (New-Guid) + + .OUTPUTS + System.Object + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $LifecycleEvent, + + [Parameter(Mandatory)] + [string] $Actor, + + [Parameter(Mandatory)] + [string] $CorrelationId, + + [Parameter()] + [hashtable] $IdentityKeys = @{}, + + [Parameter()] + [hashtable] $DesiredState = @{}, + + [Parameter()] + [hashtable] $Changes + ) + + throw 'Not implemented: New-IdleLifecycleRequest will be implemented in IdLE.Core in a subsequent increment.' +} diff --git a/src/IdLE/Public/New-IdlePlan.ps1 b/src/IdLE/Public/New-IdlePlan.ps1 new file mode 100644 index 00000000..91192783 --- /dev/null +++ b/src/IdLE/Public/New-IdlePlan.ps1 @@ -0,0 +1,35 @@ +function New-IdlePlan { + <# + .SYNOPSIS + Creates a deterministic plan from a lifecycle request and a workflow definition. + + .DESCRIPTION + Loads and validates a workflow definition (PSD1) and builds a deterministic plan for execution. + This is a stub in the core skeleton increment and will be implemented in subsequent commits. + + .PARAMETER Request + The lifecycle request object created by New-IdleLifecycleRequest. + + .PARAMETER WorkflowPath + Path to the workflow definition file (PSD1). + + .EXAMPLE + $req = New-IdleLifecycleRequest -LifecycleEvent Joiner -Actor 'alice@contoso.com' -CorrelationId (New-Guid) + New-IdlePlan -Request $req -WorkflowPath ./workflows/joiner.psd1 + + .OUTPUTS + System.Object + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Request, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $WorkflowPath + ) + + throw 'Not implemented: New-IdlePlan will be implemented in IdLE.Core in a subsequent increment.' +} diff --git a/src/IdLE/Public/Test-IdleWorkflow.ps1 b/src/IdLE/Public/Test-IdleWorkflow.ps1 new file mode 100644 index 00000000..ef019563 --- /dev/null +++ b/src/IdLE/Public/Test-IdleWorkflow.ps1 @@ -0,0 +1,34 @@ +function Test-IdleWorkflow { + <# + .SYNOPSIS + Validates an IdLE workflow definition file. + + .DESCRIPTION + Loads and validates a workflow definition (PSD1). + This is a stub in the core skeleton increment and will be implemented in subsequent commits. + + .PARAMETER Path + Path to the workflow definition file (PSD1). + + .PARAMETER LifecycleEvent + Optional lifecycle evet name to validate compatibility (e.g. Joiner/Mover/Leaver). + + .EXAMPLE + Test-IdleWorkflow -Path ./workflows/joiner.psd1 -Scenario Joiner + + .OUTPUTS + System.Object + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Scenario + ) + + throw 'Not implemented: Test-IdleWorkflow will be implemented in IdLE.Core in a subsequent increment.' +} From 6a25dd3734d984517497487520a1ddf209c6b9b5 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 14:10:14 +0100 Subject: [PATCH 08/32] added editorconfig --- .editorconfig | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .editorconfig 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 From 3f0c13e4a30e005f86c18916ae66181075d6580f Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 15:31:06 +0100 Subject: [PATCH 09/32] implement New-IdleLifecycleRequest + Core Factory to keep domain in Core Module --- src/IdLE.Core/IdLE.Core.psd1 | 4 +- src/IdLE.Core/IdLE.Core.psm1 | 6 +- .../Private/IdleLifecycleRequest.ps1 | 58 +++++++++++++++++ .../Public/New-IdleLifecycleRequestCore.ps1 | 33 ++++++++++ src/IdLE/IdLE.psm1 | 1 - src/IdLE/Public/New-IdleLifecycleRequest.ps1 | 29 +++++---- tests/New-IdleLifecycleRequest.Tests.ps1 | 62 +++++++++++++++++++ 7 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 src/IdLE.Core/Private/IdleLifecycleRequest.ps1 create mode 100644 src/IdLE.Core/Public/New-IdleLifecycleRequestCore.ps1 create mode 100644 tests/New-IdleLifecycleRequest.Tests.ps1 diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 index 8c30a748..e0401366 100644 --- a/src/IdLE.Core/IdLE.Core.psd1 +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -7,7 +7,9 @@ Description = 'IdLE Core engine: domain model, workflow loading/validation, plan builder and execution pipeline.' PowerShellVersion = '7.0' - FunctionsToExport = @() + FunctionsToExport = @( + 'New-IdleLifecycleRequestCore' + ) CmdletsToExport = @() AliasesToExport = @() diff --git a/src/IdLE.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 index 21d130b4..1d984b6c 100644 --- a/src/IdLE.Core/IdLE.Core.psm1 +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -17,5 +17,7 @@ foreach ($path in @($PrivatePath, $PublicPath)) { } } -# Core exports nothing directly. The meta module (IdLE) exposes the public API. -Export-ModuleMember -Function @() -Alias @() +# Core exports selected factory functions. The meta module (IdLE) exposes the public API. +Export-ModuleMember -Function @( + 'New-IdleLifecycleRequestCore' +) -Alias @() 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/Public/New-IdleLifecycleRequestCore.ps1 b/src/IdLE.Core/Public/New-IdleLifecycleRequestCore.ps1 new file mode 100644 index 00000000..8929a368 --- /dev/null +++ b/src/IdLE.Core/Public/New-IdleLifecycleRequestCore.ps1 @@ -0,0 +1,33 @@ +function New-IdleLifecycleRequestCore { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $LifecycleEvent, + + [Parameter()] + [string] $CorrelationId, + + [Parameter()] + [string] $Actor, + + [Parameter()] + [hashtable] $IdentityKeys = @{}, + + [Parameter()] + [hashtable] $DesiredState = @{}, + + [Parameter()] + [hashtable] $Changes + ) + + # 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/IdLE.psm1 b/src/IdLE/IdLE.psm1 index 8cc8269a..362f7b00 100644 --- a/src/IdLE/IdLE.psm1 +++ b/src/IdLE/IdLE.psm1 @@ -4,7 +4,6 @@ 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) { diff --git a/src/IdLE/Public/New-IdleLifecycleRequest.ps1 b/src/IdLE/Public/New-IdleLifecycleRequest.ps1 index 0a4613ab..7887a418 100644 --- a/src/IdLE/Public/New-IdleLifecycleRequest.ps1 +++ b/src/IdLE/Public/New-IdleLifecycleRequest.ps1 @@ -4,17 +4,18 @@ function New-IdleLifecycleRequest { Creates a lifecycle request object. .DESCRIPTION - Creates an IdLE lifecycle request representing business intent (e.g. Joiner/Mover/Leaver). - This is a stub in the core skeleton increment and will be implemented in subsequent commits. + 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 Actor - The actor who initiated the request (required). - .PARAMETER CorrelationId - A correlation identifier for audit/event correlation (required). + 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). @@ -26,22 +27,23 @@ function New-IdleLifecycleRequest { Optional hashtable describing changes (typically used for Mover lifecycle events). .EXAMPLE - New-IdleLifecycleRequest -LifecycleEvent Joiner -Actor 'alice@contoso.com' -CorrelationId (New-Guid) + New-IdleLifecycleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } .OUTPUTS - System.Object + IdleLifecycleRequest #> [CmdletBinding()] param( [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $LifecycleEvent, - [Parameter(Mandatory)] - [string] $Actor, - - [Parameter(Mandatory)] + [Parameter()] [string] $CorrelationId, + [Parameter()] + [string] $Actor, + [Parameter()] [hashtable] $IdentityKeys = @{}, @@ -52,5 +54,6 @@ function New-IdleLifecycleRequest { [hashtable] $Changes ) - throw 'Not implemented: New-IdleLifecycleRequest will be implemented in IdLE.Core in a subsequent increment.' + # Use core-exported factory to construct the domain object. Keeps domain model inside IdLE.Core. + return New-IdleLifecycleRequestCore -LifecycleEvent $LifecycleEvent -CorrelationId $CorrelationId -Actor $Actor -IdentityKeys $IdentityKeys -DesiredState $DesiredState -Changes $Changes } diff --git a/tests/New-IdleLifecycleRequest.Tests.ps1 b/tests/New-IdleLifecycleRequest.Tests.ps1 new file mode 100644 index 00000000..de49f50a --- /dev/null +++ b/tests/New-IdleLifecycleRequest.Tests.ps1 @@ -0,0 +1,62 @@ +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' + } +} From 67cbe42f208ecf9d4f71e1e0fa0ccc49a9326498 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 15:57:04 +0100 Subject: [PATCH 10/32] adding no scriptblock assertion and renaming RequestCore to New...RequestObject --- src/IdLE.Core/IdLE.Core.psd1 | 2 +- src/IdLE.Core/IdLE.Core.psm1 | 2 +- .../Private/Assert-IdleNoScriptBlock.ps1 | 48 +++++++++++++++++++ ...ps1 => New-IdleLifecycleRequestObject.ps1} | 12 ++++- src/IdLE/Public/New-IdleLifecycleRequest.ps1 | 2 +- tests/New-IdleLifecycleRequest.Tests.ps1 | 32 +++++++++++++ 6 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 rename src/IdLE.Core/Public/{New-IdleLifecycleRequestCore.ps1 => New-IdleLifecycleRequestObject.ps1} (51%) diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 index e0401366..dd0a01c3 100644 --- a/src/IdLE.Core/IdLE.Core.psd1 +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -8,7 +8,7 @@ PowerShellVersion = '7.0' FunctionsToExport = @( - 'New-IdleLifecycleRequestCore' + 'New-IdleLifecycleRequestObject' ) CmdletsToExport = @() AliasesToExport = @() diff --git a/src/IdLE.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 index 1d984b6c..0e1a5dc5 100644 --- a/src/IdLE.Core/IdLE.Core.psm1 +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -19,5 +19,5 @@ foreach ($path in @($PrivatePath, $PublicPath)) { # Core exports selected factory functions. The meta module (IdLE) exposes the public API. Export-ModuleMember -Function @( - 'New-IdleLifecycleRequestCore' + 'New-IdleLifecycleRequestObject' ) -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..ac3ebd08 --- /dev/null +++ b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 @@ -0,0 +1,48 @@ +# Asserts that the provided value does not contain any ScriptBlock objects. +# Recursively walks hashtables, enumerables, and PSCustomObjects. + +function Assert-IdleNoScriptBlock { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + if ($null -eq $Value) { return } + + if ($Value -is [scriptblock]) { + throw [System.ArgumentException]::new("ScriptBlocks are not allowed in request data. Found at: $Path", $Path) + } + + # Hashtable / Dictionary + if ($Value -is [System.Collections.IDictionary]) { + foreach ($key in $Value.Keys) { + Assert-IdleNoScriptBlock -Value $Value[$key] -Path "$Path.$key" + } + return + } + + # Enumerable (but not string) + if (($Value -is [System.Collections.IEnumerable]) -and ($Value -isnot [string])) { + $i = 0 + foreach ($item in $Value) { + Assert-IdleNoScriptBlock -Value $item -Path "$Path[$i]" + $i++ + } + return + } + + # PSCustomObject (walk note properties) + if ($Value -is [psobject]) { + foreach ($p in $Value.PSObject.Properties) { + if ($p.MemberType -eq 'NoteProperty') { + Assert-IdleNoScriptBlock -Value $p.Value -Path "$Path.$($p.Name)" + } + } + } +} diff --git a/src/IdLE.Core/Public/New-IdleLifecycleRequestCore.ps1 b/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 similarity index 51% rename from src/IdLE.Core/Public/New-IdleLifecycleRequestCore.ps1 rename to src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 index 8929a368..025cb6c3 100644 --- a/src/IdLE.Core/Public/New-IdleLifecycleRequestCore.ps1 +++ b/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 @@ -1,4 +1,4 @@ -function New-IdleLifecycleRequestCore { +function New-IdleLifecycleRequestObject { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -21,6 +21,16 @@ function New-IdleLifecycleRequestCore { [hashtable] $Changes ) + # Validate that no ScriptBlocks are present in the input data + Assert-IdleNoScriptBlock -Value $IdentityKeys -Path 'IdentityKeys' + Assert-IdleNoScriptBlock -Value $DesiredState -Path 'DesiredState' + Assert-IdleNoScriptBlock -Value $Changes -Path 'Changes' + + # Clone hashtables to avoid external mutation after object creation + $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, diff --git a/src/IdLE/Public/New-IdleLifecycleRequest.ps1 b/src/IdLE/Public/New-IdleLifecycleRequest.ps1 index 7887a418..53f5323d 100644 --- a/src/IdLE/Public/New-IdleLifecycleRequest.ps1 +++ b/src/IdLE/Public/New-IdleLifecycleRequest.ps1 @@ -55,5 +55,5 @@ function New-IdleLifecycleRequest { ) # Use core-exported factory to construct the domain object. Keeps domain model inside IdLE.Core. - return New-IdleLifecycleRequestCore -LifecycleEvent $LifecycleEvent -CorrelationId $CorrelationId -Actor $Actor -IdentityKeys $IdentityKeys -DesiredState $DesiredState -Changes $Changes + New-IdleLifecycleRequestObject @PSBoundParameters } diff --git a/tests/New-IdleLifecycleRequest.Tests.ps1 b/tests/New-IdleLifecycleRequest.Tests.ps1 index de49f50a..3ef0ef15 100644 --- a/tests/New-IdleLifecycleRequest.Tests.ps1 +++ b/tests/New-IdleLifecycleRequest.Tests.ps1 @@ -60,3 +60,35 @@ Describe 'New-IdleLifecycleRequest' { $req.Actor | Should -Be 'alice@contoso.com' } } + +Describe 'New-IdleLifecycleRequest - data-only validation' { + + It 'rejects ScriptBlock in DesiredState' { + { New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + Attributes = @{ + Department = { 'IT' } + } + } } | Should -Throw + } + + It 'rejects ScriptBlock nested in arrays' { + { New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + Entitlements = @( + @{ Type = 'Group'; Value = 'APP-CRM-Users' } + @{ Type = 'Custom'; Value = { 'NOPE' } } + ) + } } | Should -Throw + } + + It 'rejects ScriptBlock in Changes when provided' { + { New-IdleLifecycleRequest -LifecycleEvent 'Mover' -Changes @{ + Attributes = @{ + Department = @{ + From = 'Sales' + To = { 'IT' } + } + } + } } | Should -Throw + } +} + From e5728f121654e37309701b902c77b5af558c2a2f Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:05:17 +0100 Subject: [PATCH 11/32] fix scenario / lifecycleevent renaming left overs --- src/IdLE/Public/Test-IdleWorkflow.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/IdLE/Public/Test-IdleWorkflow.ps1 b/src/IdLE/Public/Test-IdleWorkflow.ps1 index ef019563..fa4bd8e6 100644 --- a/src/IdLE/Public/Test-IdleWorkflow.ps1 +++ b/src/IdLE/Public/Test-IdleWorkflow.ps1 @@ -11,10 +11,10 @@ function Test-IdleWorkflow { Path to the workflow definition file (PSD1). .PARAMETER LifecycleEvent - Optional lifecycle evet name to validate compatibility (e.g. Joiner/Mover/Leaver). + Optional lifecycle event name to validate compatibility (e.g. Joiner/Mover/Leaver). .EXAMPLE - Test-IdleWorkflow -Path ./workflows/joiner.psd1 -Scenario Joiner + Test-IdleWorkflow -Path ./workflows/joiner.psd1 -LifecycleEvent Joiner .OUTPUTS System.Object @@ -27,7 +27,7 @@ function Test-IdleWorkflow { [Parameter()] [ValidateNotNullOrEmpty()] - [string] $Scenario + [string] $LifecycleEvent ) throw 'Not implemented: Test-IdleWorkflow will be implemented in IdLE.Core in a subsequent increment.' From 8c74a0afa5a3676b2e4a5d0073ee1b94198f778e Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:06:47 +0100 Subject: [PATCH 12/32] typecheck tweak in assertion on custom objects --- src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 index ac3ebd08..d212c26e 100644 --- a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 @@ -38,7 +38,7 @@ function Assert-IdleNoScriptBlock { } # PSCustomObject (walk note properties) - if ($Value -is [psobject]) { + if ($Value -is [pscustomobject]) { foreach ($p in $Value.PSObject.Properties) { if ($p.MemberType -eq 'NoteProperty') { Assert-IdleNoScriptBlock -Value $p.Value -Path "$Path.$($p.Name)" From 7a21f8ecb644854bceeea4dda804d0e04c82fda0 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:11:15 +0100 Subject: [PATCH 13/32] added inline comment on shallow clone --- src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 index 025cb6c3..5e8d00a1 100644 --- a/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 +++ b/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 @@ -27,6 +27,7 @@ function New-IdleLifecycleRequestObject { Assert-IdleNoScriptBlock -Value $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() } From 21f0a3cd7b482f6a7397dd155e9355de1e2d8dbf Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:11:27 +0100 Subject: [PATCH 14/32] adding providers parameter --- src/IdLE/Public/Invoke-IdlePlan.ps1 | 15 +++++++++------ src/IdLE/Public/New-IdlePlan.ps1 | 13 ++++++++++--- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/IdLE/Public/Invoke-IdlePlan.ps1 b/src/IdLE/Public/Invoke-IdlePlan.ps1 index 38c7be0c..2b389e7b 100644 --- a/src/IdLE/Public/Invoke-IdlePlan.ps1 +++ b/src/IdLE/Public/Invoke-IdlePlan.ps1 @@ -5,17 +5,16 @@ function Invoke-IdlePlan { .DESCRIPTION Executes a previously created plan in a deterministic way and emits structured events. - This is a stub in the core skeleton increment and will be implemented in subsequent commits. + Providers are passed through to execution (structure will be defined later). .PARAMETER Plan The plan object created by New-IdlePlan. - .PARAMETER WhatIf - Shows what would happen if the plan is executed. + .PARAMETER Providers + Provider registry/collection passed through to execution. (Structure to be defined later.) .EXAMPLE - $plan = New-IdlePlan -Request $req -WorkflowPath ./workflows/joiner.psd1 - Invoke-IdlePlan -Plan $plan + Invoke-IdlePlan -Plan $plan -Providers $providers .OUTPUTS System.Object @@ -24,7 +23,11 @@ function Invoke-IdlePlan { param( [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Plan + [object] $Plan, + + [Parameter()] + [AllowNull()] + [object] $Providers ) if ($PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { diff --git a/src/IdLE/Public/New-IdlePlan.ps1 b/src/IdLE/Public/New-IdlePlan.ps1 index 91192783..bc20d8b6 100644 --- a/src/IdLE/Public/New-IdlePlan.ps1 +++ b/src/IdLE/Public/New-IdlePlan.ps1 @@ -13,9 +13,12 @@ function New-IdlePlan { .PARAMETER WorkflowPath Path to the workflow definition file (PSD1). + .PARAMETER Providers + Provider registry/collection passed through to planning. (Structure to be defined later.) + .EXAMPLE - $req = New-IdleLifecycleRequest -LifecycleEvent Joiner -Actor 'alice@contoso.com' -CorrelationId (New-Guid) - New-IdlePlan -Request $req -WorkflowPath ./workflows/joiner.psd1 + $request = New-IdleLifecycleRequest -LifecycleEvent Joiner -Actor 'alice@contoso.com' -CorrelationId (New-Guid) + $plan = New-IdlePlan -WorkflowPath ./workflows/joiner.psd1 -Request $request -Providers $providers .OUTPUTS System.Object @@ -28,7 +31,11 @@ function New-IdlePlan { [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $WorkflowPath + [string] $WorkflowPath, + + [Parameter()] + [AllowNull()] + [object] $Providers ) throw 'Not implemented: New-IdlePlan will be implemented in IdLE.Core in a subsequent increment.' From c6407cb4407e2334ef415b455995383f053ee2a8 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:17:24 +0100 Subject: [PATCH 15/32] added testing on detailed error throw outputs --- tests/New-IdleLifecycleRequest.Tests.ps1 | 55 ++++++++++++++++-------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/tests/New-IdleLifecycleRequest.Tests.ps1 b/tests/New-IdleLifecycleRequest.Tests.ps1 index 3ef0ef15..ced50f9f 100644 --- a/tests/New-IdleLifecycleRequest.Tests.ps1 +++ b/tests/New-IdleLifecycleRequest.Tests.ps1 @@ -63,32 +63,53 @@ Describe 'New-IdleLifecycleRequest' { Describe 'New-IdleLifecycleRequest - data-only validation' { - It 'rejects ScriptBlock in DesiredState' { - { New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ - Attributes = @{ - Department = { 'IT' } + It 'rejects ScriptBlock in DesiredState when provided' { + try { + New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + Attributes = @{ Department = { 'IT' } } } - } } | Should -Throw + 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' { - { New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -DesiredState @{ - Entitlements = @( - @{ Type = 'Group'; Value = 'APP-CRM-Users' } - @{ Type = 'Custom'; Value = { 'NOPE' } } - ) - } } | Should -Throw + 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' { - { New-IdleLifecycleRequest -LifecycleEvent 'Mover' -Changes @{ - Attributes = @{ - Department = @{ - From = 'Sales' - To = { 'IT' } + try { + New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -Changes @{ + Attributes = @{ + Department = @{ + From = 'Sales' + To = { 'IT' } + } } } - } } | Should -Throw + 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' + } } } From 1c35307afcfc54a0a6fbc15c4274d3083b417aed Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:44:45 +0100 Subject: [PATCH 16/32] core: add test-idleworkflow + test schema + test object --- src/IdLE.Core/IdLE.Core.psd1 | 3 +- src/IdLE.Core/IdLE.Core.psm1 | 3 +- .../Private/Import-IdleWorkflowDefinition.ps1 | 23 ++++ .../Private/Test-IdleWorkflowSchema.ps1 | 81 ++++++++++++ .../Test-IdleWorkflowDefinitionObject.ps1 | 76 ++++++++++++ src/IdLE/Public/Test-IdleWorkflow.ps1 | 29 +++-- tests/Test-IdleWorkflow.Tests.ps1 | 115 ++++++++++++++++++ 7 files changed, 318 insertions(+), 12 deletions(-) create mode 100644 src/IdLE.Core/Private/Import-IdleWorkflowDefinition.ps1 create mode 100644 src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 create mode 100644 src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 create mode 100644 tests/Test-IdleWorkflow.Tests.ps1 diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 index dd0a01c3..6c41b841 100644 --- a/src/IdLE.Core/IdLE.Core.psd1 +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -8,7 +8,8 @@ PowerShellVersion = '7.0' FunctionsToExport = @( - 'New-IdleLifecycleRequestObject' + 'New-IdleLifecycleRequestObject', + 'Test-IdleWorkflowDefinitionObject' ) CmdletsToExport = @() AliasesToExport = @() diff --git a/src/IdLE.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 index 0e1a5dc5..e574de10 100644 --- a/src/IdLE.Core/IdLE.Core.psm1 +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -19,5 +19,6 @@ foreach ($path in @($PrivatePath, $PublicPath)) { # Core exports selected factory functions. The meta module (IdLE) exposes the public API. Export-ModuleMember -Function @( - 'New-IdleLifecycleRequestObject' + 'New-IdleLifecycleRequestObject', + 'Test-IdleWorkflowDefinitionObject' ) -Alias @() 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/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/Public/Test-IdleWorkflowDefinitionObject.ps1 b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 new file mode 100644 index 00000000..b0edbf33 --- /dev/null +++ b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 @@ -0,0 +1,76 @@ +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 -Value $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'.") + } + } + } + + 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/Public/Test-IdleWorkflow.ps1 b/src/IdLE/Public/Test-IdleWorkflow.ps1 index fa4bd8e6..73ab60f4 100644 --- a/src/IdLE/Public/Test-IdleWorkflow.ps1 +++ b/src/IdLE/Public/Test-IdleWorkflow.ps1 @@ -4,17 +4,17 @@ function Test-IdleWorkflow { Validates an IdLE workflow definition file. .DESCRIPTION - Loads and validates a workflow definition (PSD1). - This is a stub in the core skeleton increment and will be implemented in subsequent commits. + Loads and strictly validates a workflow definition (PSD1). + Throws on validation errors. - .PARAMETER Path + .PARAMETER WorkflowPath Path to the workflow definition file (PSD1). - .PARAMETER LifecycleEvent - Optional lifecycle event name to validate compatibility (e.g. Joiner/Mover/Leaver). + .PARAMETER Request + Optional lifecycle request for validating compatibility (LifecycleEvent match). .EXAMPLE - Test-IdleWorkflow -Path ./workflows/joiner.psd1 -LifecycleEvent Joiner + Test-IdleWorkflow -WorkflowPath ./workflows/joiner.psd1 -Request $request .OUTPUTS System.Object @@ -23,12 +23,21 @@ function Test-IdleWorkflow { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $Path, + [string] $WorkflowPath, [Parameter()] - [ValidateNotNullOrEmpty()] - [string] $LifecycleEvent + [AllowNull()] + [object] $Request ) - throw 'Not implemented: Test-IdleWorkflow will be implemented in IdLE.Core in a subsequent increment.' + # 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/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' + } + } +} From ad6eb672b21228c8f93097322ee7211cc9521343 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:49:40 +0100 Subject: [PATCH 17/32] core: implement New-IdlePlan and add tests --- src/IdLE.Core/IdLE.Core.psd1 | 3 +- src/IdLE.Core/IdLE.Core.psm1 | 3 +- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 79 +++++++++++++++++++++ src/IdLE/Public/New-IdlePlan.ps1 | 22 +++--- tests/New-IdlePlan.Tests.ps1 | 63 ++++++++++++++++ 5 files changed, 157 insertions(+), 13 deletions(-) create mode 100644 src/IdLE.Core/Public/New-IdlePlanObject.ps1 create mode 100644 tests/New-IdlePlan.Tests.ps1 diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 index 6c41b841..86805bee 100644 --- a/src/IdLE.Core/IdLE.Core.psd1 +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -9,7 +9,8 @@ FunctionsToExport = @( 'New-IdleLifecycleRequestObject', - 'Test-IdleWorkflowDefinitionObject' + 'Test-IdleWorkflowDefinitionObject', + 'New-IdlePlanObject' ) CmdletsToExport = @() AliasesToExport = @() diff --git a/src/IdLE.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 index e574de10..c0d33677 100644 --- a/src/IdLE.Core/IdLE.Core.psm1 +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -20,5 +20,6 @@ foreach ($path in @($PrivatePath, $PublicPath)) { # Core exports selected factory functions. The meta module (IdLE) exposes the public API. Export-ModuleMember -Function @( 'New-IdleLifecycleRequestObject', - 'Test-IdleWorkflowDefinitionObject' + 'Test-IdleWorkflowDefinitionObject', + 'New-IdlePlanObject' ) -Alias @() 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/Public/New-IdlePlan.ps1 b/src/IdLE/Public/New-IdlePlan.ps1 index bc20d8b6..841d6587 100644 --- a/src/IdLE/Public/New-IdlePlan.ps1 +++ b/src/IdLE/Public/New-IdlePlan.ps1 @@ -4,20 +4,19 @@ function New-IdlePlan { Creates a deterministic plan from a lifecycle request and a workflow definition. .DESCRIPTION - Loads and validates a workflow definition (PSD1) and builds a deterministic plan for execution. - This is a stub in the core skeleton increment and will be implemented in subsequent commits. - - .PARAMETER Request - The lifecycle request object created by New-IdleLifecycleRequest. + 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 - $request = New-IdleLifecycleRequest -LifecycleEvent Joiner -Actor 'alice@contoso.com' -CorrelationId (New-Guid) $plan = New-IdlePlan -WorkflowPath ./workflows/joiner.psd1 -Request $request -Providers $providers .OUTPUTS @@ -25,18 +24,19 @@ function New-IdlePlan { #> [CmdletBinding()] param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Request, - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $WorkflowPath, + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Request, + [Parameter()] [AllowNull()] [object] $Providers ) - throw 'Not implemented: New-IdlePlan will be implemented in IdLE.Core in a subsequent increment.' + # Keep meta module thin: delegate planning to IdLE.Core. + return New-IdlePlanObject -WorkflowPath $WorkflowPath -Request $Request -Providers $Providers } 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' + } + } +} From 7f371e708dd7df78d35efcbf55e35a91f98a9f5f Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 18:00:57 +0100 Subject: [PATCH 18/32] core: implement Invoke-IdlePlan skeleton with events --- src/IdLE.Core/IdLE.Core.psd1 | 3 +- src/IdLE.Core/IdLE.Core.psm1 | 3 +- src/IdLE.Core/Private/New-IdleEvent.ps1 | 40 ++++++ src/IdLE.Core/Private/Write-IdleEvent.ps1 | 35 +++++ .../Public/Invoke-IdlePlanObject.ps1 | 130 ++++++++++++++++++ src/IdLE/Public/Invoke-IdlePlan.ps1 | 28 +++- tests/Invoke-IdlePlan.Tests.ps1 | 80 +++++++++++ 7 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 src/IdLE.Core/Private/New-IdleEvent.ps1 create mode 100644 src/IdLE.Core/Private/Write-IdleEvent.ps1 create mode 100644 src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 create mode 100644 tests/Invoke-IdlePlan.Tests.ps1 diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 index 86805bee..2dd894f9 100644 --- a/src/IdLE.Core/IdLE.Core.psd1 +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -10,7 +10,8 @@ FunctionsToExport = @( 'New-IdleLifecycleRequestObject', 'Test-IdleWorkflowDefinitionObject', - 'New-IdlePlanObject' + 'New-IdlePlanObject', + 'Invoke-IdlePlanObject' ) CmdletsToExport = @() AliasesToExport = @() diff --git a/src/IdLE.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 index c0d33677..2fa82a27 100644 --- a/src/IdLE.Core/IdLE.Core.psm1 +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -21,5 +21,6 @@ foreach ($path in @($PrivatePath, $PublicPath)) { Export-ModuleMember -Function @( 'New-IdleLifecycleRequestObject', 'Test-IdleWorkflowDefinitionObject', - 'New-IdlePlanObject' + 'New-IdlePlanObject', + 'Invoke-IdlePlanObject' ) -Alias @() 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/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..520d058d --- /dev/null +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -0,0 +1,130 @@ +function Invoke-IdlePlanObject { + <# + .SYNOPSIS + Executes an IdLE plan (skeleton). + + .DESCRIPTION + Executes a plan deterministically and emits structured events. + This increment does NOT execute real step plugins yet. It only simulates step execution. + + .PARAMETER Plan + Plan object created by New-IdlePlanObject. + + .PARAMETER Providers + Provider registry/collection (not used 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 } + + # Emit run start event. + Write-IdleEvent -Event (New-IdleEvent -Type 'RunStarted' -Message 'Plan execution started.' -CorrelationId $corr -Actor $actor -Data @{ + LifecycleEvent = [string]$Plan.LifecycleEvent + WorkflowName = if ($planProps -contains 'WorkflowName') { [string]$Plan.WorkflowName } else { $null } + StepCount = @($Plan.Steps).Count + }) -EventSink $EventSink -EventBuffer $events + + $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') { [string]$step.Type } else { $null } + + Write-IdleEvent -Event (New-IdleEvent -Type 'StepStarted' -Message "Step '$stepName' started." -CorrelationId $corr -Actor $actor -StepName $stepName -Data @{ + StepType = $stepType + Index = $i + }) -EventSink $EventSink -EventBuffer $events + + try { + # Skeleton behavior: no real execution yet. + # We treat all steps as "Skipped/NotImplemented" to keep deterministic behavior. + $status = 'NotImplemented' + + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = $status + Error = $null + } + + Write-IdleEvent -Event (New-IdleEvent -Type 'StepCompleted' -Message "Step '$stepName' completed (status: $status)." -CorrelationId $corr -Actor $actor -StepName $stepName -Data @{ + StepType = $stepType + Status = $status + Index = $i + }) -EventSink $EventSink -EventBuffer $events + } + catch { + $failed = $true + $err = $_ + + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'Failed' + Error = $err.Exception.Message + } + + Write-IdleEvent -Event (New-IdleEvent -Type 'StepFailed' -Message "Step '$stepName' failed." -CorrelationId $corr -Actor $actor -StepName $stepName -Data @{ + StepType = $stepType + Index = $i + Error = $err.Exception.Message + }) -EventSink $EventSink -EventBuffer $events + + # Fail-fast in this increment. + break + } + + $i++ + } + + $runStatus = if ($failed) { 'Failed' } else { 'Completed' } + + Write-IdleEvent -Event (New-IdleEvent -Type 'RunCompleted' -Message "Plan execution finished (status: $runStatus)." -CorrelationId $corr -Actor $actor -Data @{ + Status = $runStatus + StepCount = @($Plan.Steps).Count + }) -EventSink $EventSink -EventBuffer $events + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionResult' + Status = $runStatus + CorrelationId = $corr + Actor = $actor + Steps = $stepResults + Events = $events + Providers = $Providers + } +} diff --git a/src/IdLE/Public/Invoke-IdlePlan.ps1 b/src/IdLE/Public/Invoke-IdlePlan.ps1 index 2b389e7b..f2517eee 100644 --- a/src/IdLE/Public/Invoke-IdlePlan.ps1 +++ b/src/IdLE/Public/Invoke-IdlePlan.ps1 @@ -4,14 +4,17 @@ function Invoke-IdlePlan { Executes an IdLE plan. .DESCRIPTION - Executes a previously created plan in a deterministic way and emits structured events. - Providers are passed through to execution (structure will be defined later). + 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. (Structure to be defined later.) + 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 @@ -27,10 +30,23 @@ function Invoke-IdlePlan { [Parameter()] [AllowNull()] - [object] $Providers + [object] $Providers, + + [Parameter()] + [AllowNull()] + [object] $EventSink ) - if ($PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { - throw 'Not implemented: Invoke-IdlePlan will be implemented in IdLE.Core in a subsequent increment.' + 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/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 new file mode 100644 index 00000000..fae079ce --- /dev/null +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -0,0 +1,80 @@ +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 + + $result = Invoke-IdlePlan -Plan $plan + + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + @($result.Steps).Count | Should -Be 2 + + # Basic event checks + @($result.Events).Count | Should -BeGreaterThan 0 + $result.Events[0].Type | Should -Be 'RunStarted' + $result.Events[-1].Type | Should -Be 'RunCompleted' + + # Step status placeholder + $result.Steps[0].Status | Should -Be 'NotImplemented' + $result.Steps[1].Status | Should -Be 'NotImplemented' + } + + 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 + + $sinkEvents = [System.Collections.Generic.List[object]]::new() + $sink = { + param($e) + [void]$sinkEvents.Add($e) + }.GetNewClosure() + + $result = Invoke-IdlePlan -Plan $plan -EventSink $sink + + @($sinkEvents).Count | Should -BeGreaterThan 0 + $sinkEvents[0].PSTypeNames | Should -Contain 'IdLE.Event' + $result.Events[0].Type | Should -Be 'RunStarted' + } +} From c99eea6a01b9d0e2a94a39f95b76c425d280a82a Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:06:43 +0100 Subject: [PATCH 19/32] core: add step registry and execute steps via handlers (scriptblock/function) --- src/IdLE.Core/IdLE.Core.psd1 | 2 +- .../Private/Get-IdleStepRegistry.ps1 | 43 ++++++ .../Private/Resolve-IdleStepHandler.ps1 | 47 +++++++ .../Public/Invoke-IdlePlanObject.ps1 | 104 ++++++++++---- src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 | 22 +++ src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 | 13 ++ .../Public/Invoke-IdleStepEmitEvent.ps1 | 53 +++++++ src/IdLE/IdLE.psd1 | 2 +- src/IdLE/IdLE.psm1 | 5 +- tests/Invoke-IdlePlan.Tests.ps1 | 133 ++++++++++++++---- 10 files changed, 363 insertions(+), 61 deletions(-) create mode 100644 src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 create mode 100644 src/IdLE.Core/Private/Resolve-IdleStepHandler.ps1 create mode 100644 src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 create mode 100644 src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 create mode 100644 src/IdLE.Steps.Common/Public/Invoke-IdleStepEmitEvent.ps1 diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 index 2dd894f9..c4b5a5ef 100644 --- a/src/IdLE.Core/IdLE.Core.psd1 +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -18,7 +18,7 @@ PrivateData = @{ PSData = @{ - Tags = @('Identity', 'Lifecycle', 'Automation', 'Identity Management', 'JML', 'Onboarding', 'Offboarding', 'Account Management') + 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/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 new file mode 100644 index 00000000..d721c819 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -0,0 +1,43 @@ +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] + } + } + + return $registry +} 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/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 520d058d..df9c11b0 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -5,13 +5,13 @@ function Invoke-IdlePlanObject { .DESCRIPTION Executes a plan deterministically and emits structured events. - This increment does NOT execute real step plugins yet. It only simulates step execution. + Executes steps via a registry mapping Step.Type to PowerShell functions. .PARAMETER Plan Plan object created by New-IdlePlanObject. .PARAMETER Providers - Provider registry/collection (not used in this increment; passed through for future steps). + 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. @@ -47,12 +47,49 @@ function Invoke-IdlePlanObject { $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. - Write-IdleEvent -Event (New-IdleEvent -Type 'RunStarted' -Message 'Plan execution started.' -CorrelationId $corr -Actor $actor -Data @{ + & $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 - }) -EventSink $EventSink -EventBuffer $events + WorkflowName = if ($planProps -contains 'WorkflowName') { + [string]$Plan.WorkflowName + } else { + $null + } + StepCount = @($Plan.Steps).Count + } $stepResults = @() $failed = $false @@ -60,31 +97,42 @@ function Invoke-IdlePlanObject { $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') { [string]$step.Type } else { $null } + $stepType = if ($step.PSObject.Properties.Name -contains 'Type' -and $null -ne $step.Type) { + ([string]$step.Type).Trim() + } else { + $null + } - Write-IdleEvent -Event (New-IdleEvent -Type 'StepStarted' -Message "Step '$stepName' started." -CorrelationId $corr -Actor $actor -StepName $stepName -Data @{ + & $context.WriteEvent 'StepStarted' "Step '$stepName' started." $stepName @{ StepType = $stepType Index = $i - }) -EventSink $EventSink -EventBuffer $events + } try { - # Skeleton behavior: no real execution yet. - # We treat all steps as "Skipped/NotImplemented" to keep deterministic behavior. - $status = 'NotImplemented' + # 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.") + } - $stepResults += [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = $stepType - Status = $status - Error = $null + # 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 } - Write-IdleEvent -Event (New-IdleEvent -Type 'StepCompleted' -Message "Step '$stepName' completed (status: $status)." -CorrelationId $corr -Actor $actor -StepName $stepName -Data @{ + $stepResults += $stepResult + + & $context.WriteEvent 'StepCompleted' "Step '$stepName' completed." $stepName @{ StepType = $stepType - Status = $status Index = $i - }) -EventSink $EventSink -EventBuffer $events + } } catch { $failed = $true @@ -98,11 +146,11 @@ function Invoke-IdlePlanObject { Error = $err.Exception.Message } - Write-IdleEvent -Event (New-IdleEvent -Type 'StepFailed' -Message "Step '$stepName' failed." -CorrelationId $corr -Actor $actor -StepName $stepName -Data @{ - StepType = $stepType - Index = $i - Error = $err.Exception.Message - }) -EventSink $EventSink -EventBuffer $events + & $context.WriteEvent 'StepFailed' "Step '$stepName' failed." $stepName @{ + StepType = $stepType + Index = $i + Error = $err.Exception.Message + } # Fail-fast in this increment. break @@ -113,10 +161,10 @@ function Invoke-IdlePlanObject { $runStatus = if ($failed) { 'Failed' } else { 'Completed' } - Write-IdleEvent -Event (New-IdleEvent -Type 'RunCompleted' -Message "Plan execution finished (status: $runStatus)." -CorrelationId $corr -Actor $actor -Data @{ + & $context.WriteEvent 'RunCompleted' "Plan execution finished (status: $runStatus)." $null @{ Status = $runStatus StepCount = @($Plan.Steps).Count - }) -EventSink $EventSink -EventBuffer $events + } return [pscustomobject]@{ PSTypeName = 'IdLE.ExecutionResult' 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 index bb55aae9..88fa0f8b 100644 --- a/src/IdLE/IdLE.psd1 +++ b/src/IdLE/IdLE.psd1 @@ -18,7 +18,7 @@ PrivateData = @{ PSData = @{ - Tags = @('Identity', 'Lifecycle', 'Automation', 'Identity Management', 'JML', 'Onboarding', 'Offboarding', 'Account Management') + 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 index 362f7b00..44a7a901 100644 --- a/src/IdLE/IdLE.psm1 +++ b/src/IdLE/IdLE.psm1 @@ -4,8 +4,8 @@ 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' +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' if (Test-Path -Path $PublicPath) { Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | Sort-Object -Property FullName | @@ -14,6 +14,9 @@ if (Test-Path -Path $PublicPath) { } } +$StepsManifestPath = Join-Path -Path $PSScriptRoot -ChildPath '..\IdLE.Steps.Common\IdLE.Steps.Common.psd1' +Import-Module -Name $StepsManifestPath -Force -ErrorAction Stop + # Export exactly the public API cmdlets (contract). Export-ModuleMember -Function @( 'Test-IdleWorkflow', diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index fae079ce..eb6c2e86 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -4,10 +4,9 @@ BeforeAll { } 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 @' + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ Name = 'Joiner - Standard' LifecycleEvent = 'Joiner' @@ -18,23 +17,39 @@ Describe 'Invoke-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $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 + $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.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + @($result.Steps).Count | Should -Be 2 - # Basic event checks - @($result.Events).Count | Should -BeGreaterThan 0 - $result.Events[0].Type | Should -Be 'RunStarted' - $result.Events[-1].Type | Should -Be 'RunCompleted' + @($result.Events).Count | Should -BeGreaterThan 0 + $result.Events[0].Type | Should -Be 'RunStarted' + $result.Events[-1].Type | Should -Be 'RunCompleted' - # Step status placeholder - $result.Steps[0].Status | Should -Be 'NotImplemented' - $result.Steps[1].Status | Should -Be 'NotImplemented' + $result.Steps[0].Status | Should -Be 'Completed' + $result.Steps[1].Status | Should -Be 'Completed' } It 'supports -WhatIf and does not execute' { @@ -51,8 +66,8 @@ Describe 'Invoke-IdlePlan' { } It 'can stream events to a ScriptBlock sink' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ Name = 'Joiner - Standard' LifecycleEvent = 'Joiner' @@ -62,19 +77,77 @@ Describe 'Invoke-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + $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 -EventSink $sink + $sinkEvents = [System.Collections.Generic.List[object]]::new() + $sink = { + param($e) + [void]$sinkEvents.Add($e) + }.GetNewClosure() - @($sinkEvents).Count | Should -BeGreaterThan 0 - $sinkEvents[0].PSTypeNames | Should -Contain 'IdLE.Event' - $result.Events[0].Type | Should -Be 'RunStarted' + $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 + } +} \ No newline at end of file From e6db2a4ee2f763676cd7fae15a877f5aef627466 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:10:06 +0100 Subject: [PATCH 20/32] core: evaluate declarative When conditions and support skipped steps --- src/IdLE.Core/Private/Get-IdleValueByPath.ps1 | 25 +++++ .../Private/Test-IdleWhenCondition.ps1 | 44 +++++++++ .../Public/Invoke-IdlePlanObject.ps1 | 22 +++++ tests/Invoke-IdlePlan.When.Tests.ps1 | 96 +++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 src/IdLE.Core/Private/Get-IdleValueByPath.ps1 create mode 100644 src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 create mode 100644 tests/Invoke-IdlePlan.When.Tests.ps1 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/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/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index df9c11b0..f8b1b557 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -108,6 +108,28 @@ function Invoke-IdlePlanObject { Index = $i } + # 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 + } + } + try { # Resolve implementation handler for this step type. # Handler can be: 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 + } +} From 82362105e3c0a6e0d61bae74159fdc5a72902d24 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:13:35 +0100 Subject: [PATCH 21/32] core: move StepStarted after StepSkipped to avoid clutter --- src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index f8b1b557..c4c0438f 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -103,11 +103,6 @@ function Invoke-IdlePlanObject { $null } - & $context.WriteEvent 'StepStarted' "Step '$stepName' started." $stepName @{ - StepType = $stepType - Index = $i - } - # 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 @@ -130,6 +125,11 @@ function Invoke-IdlePlanObject { } } + & $context.WriteEvent 'StepStarted' "Step '$stepName' started." $stepName @{ + StepType = $stepType + Index = $i + } + try { # Resolve implementation handler for this step type. # Handler can be: From 123381226fe786a369609b9a0d3d4fd177fd5040 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:49:12 +0100 Subject: [PATCH 22/32] core: validate workflow steps (Name/Type/When/With) and enforce data-only definitions --- .../Private/Assert-IdleNoScriptBlock.ps1 | 26 ++++----- .../Private/Test-IdleStepDefinition.ps1 | 56 +++++++++++++++++++ .../Private/Test-IdleWhenConditionSchema.ps1 | 36 ++++++++++++ .../Public/New-IdleLifecycleRequestObject.ps1 | 6 +- .../Test-IdleWorkflowDefinitionObject.ps1 | 12 +++- tests/Invoke-IdlePlan.Tests.ps1 | 37 ++++++++++++ 6 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 create mode 100644 src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 diff --git a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 index d212c26e..984a1a41 100644 --- a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 @@ -1,4 +1,4 @@ -# Asserts that the provided value does not contain any ScriptBlock objects. +# Asserts that the provided InputObject does not contain any ScriptBlock objects. # Recursively walks hashtables, enumerables, and PSCustomObjects. function Assert-IdleNoScriptBlock { @@ -6,42 +6,42 @@ function Assert-IdleNoScriptBlock { param( [Parameter(Mandatory)] [AllowNull()] - [object] $Value, + [object] $InputObject, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path ) - if ($null -eq $Value) { return } + if ($null -eq $InputObject) { return } - if ($Value -is [scriptblock]) { + if ($InputObject -is [scriptblock]) { throw [System.ArgumentException]::new("ScriptBlocks are not allowed in request data. Found at: $Path", $Path) } # Hashtable / Dictionary - if ($Value -is [System.Collections.IDictionary]) { - foreach ($key in $Value.Keys) { - Assert-IdleNoScriptBlock -Value $Value[$key] -Path "$Path.$key" + 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 (($Value -is [System.Collections.IEnumerable]) -and ($Value -isnot [string])) { + if (($InputObject -is [System.Collections.IEnumerable]) -and ($InputObject -isnot [string])) { $i = 0 - foreach ($item in $Value) { - Assert-IdleNoScriptBlock -Value $item -Path "$Path[$i]" + foreach ($item in $InputObject) { + Assert-IdleNoScriptBlock -InputObject $item -Path "$Path[$i]" $i++ } return } # PSCustomObject (walk note properties) - if ($Value -is [pscustomobject]) { - foreach ($p in $Value.PSObject.Properties) { + if ($InputObject -is [pscustomobject]) { + foreach ($p in $InputObject.PSObject.Properties) { if ($p.MemberType -eq 'NoteProperty') { - Assert-IdleNoScriptBlock -Value $p.Value -Path "$Path.$($p.Name)" + Assert-IdleNoScriptBlock -InputObject $p.InputObject -Path "$Path.$($p.Name)" } } } 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-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/Public/New-IdleLifecycleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 index 5e8d00a1..0be407c7 100644 --- a/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 +++ b/src/IdLE.Core/Public/New-IdleLifecycleRequestObject.ps1 @@ -22,9 +22,9 @@ function New-IdleLifecycleRequestObject { ) # Validate that no ScriptBlocks are present in the input data - Assert-IdleNoScriptBlock -Value $IdentityKeys -Path 'IdentityKeys' - Assert-IdleNoScriptBlock -Value $DesiredState -Path 'DesiredState' - Assert-IdleNoScriptBlock -Value $Changes -Path 'Changes' + 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 diff --git a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 index b0edbf33..96085757 100644 --- a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 +++ b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 @@ -32,7 +32,7 @@ function Test-IdleWorkflowDefinitionObject { # 2) Enforce "data-only": no ScriptBlocks anywhere in the workflow object. # This matches the project's config safety rules. - Assert-IdleNoScriptBlock -Value $workflow -Path 'Workflow' + 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 @@ -58,6 +58,16 @@ function Test-IdleWorkflowDefinitionObject { } } + # 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- ") diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index eb6c2e86..8ae825ab 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -150,4 +150,41 @@ Describe 'Invoke-IdlePlan' { $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 From 69c93a2b859abceeed8ced8d762f33291fb20211 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:03:24 +0100 Subject: [PATCH 23/32] added examples --- docs/02-examples.md | 16 ++++++ examples/run-demo.ps1 | 65 ++++++++++++++++++++++++ examples/workflows/joiner-minimal.psd1 | 13 +++++ examples/workflows/joiner-with-when.psd1 | 28 ++++++++++ 4 files changed, 122 insertions(+) create mode 100644 docs/02-examples.md create mode 100644 examples/run-demo.ps1 create mode 100644 examples/workflows/joiner-minimal.psd1 create mode 100644 examples/workflows/joiner-with-when.psd1 diff --git a/docs/02-examples.md b/docs/02-examples.md new file mode 100644 index 00000000..a221a4a8 --- /dev/null +++ b/docs/02-examples.md @@ -0,0 +1,16 @@ +# 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. diff --git a/examples/run-demo.ps1 b/examples/run-demo.ps1 new file mode 100644 index 00000000..9f522772 --- /dev/null +++ b/examples/run-demo.ps1 @@ -0,0 +1,65 @@ +#requires -Version 7.0 +Set-StrictMode -Version Latest + +Import-Module (Join-Path $PSScriptRoot '..\src\IdLE\IdLE.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 + +$result.Status +$result.Events | Format-Table TimestampUtc, Type, StepName, 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.' + } + } + ) +} From 0731753a7a61ebb5459cde37995458344a83edd5 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:03:30 +0100 Subject: [PATCH 24/32] updated docs --- README.md | 78 ++++++++++++++++++++++++++++------------- docs/01-architecture.md | 22 ++++++++++++ 2 files changed, 76 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 583822d8..c43d0b35 100644 --- a/README.md +++ b/README.md @@ -69,40 +69,70 @@ Install-Module IdLE --- -## Quickstart +## Quickstart (Plan β†’ Execute) -Typical flow: **Create request β†’ Validate workflow β†’ Build plan β†’ Execute plan** +IdLE separates **planning** from **execution**: + +1. Create a `LifecycleRequest` +2. Build a deterministic `Plan` from a workflow definition (PSD1) +3. Execute the plan with a host-provided step registry (handlers) + +> Note: Workflows are **data-only**. Step implementations are provided by the host via the Step Registry. + +### Example ```powershell -# 1) Create a request (LifecycleEvent + identity keys + desired state) -$request = New-IdleLifecycleRequest -LifecycleEvent 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' } - ) +Import-Module .\src\IdLE\IdLE.psd1 -Force + +# 1) Request +$request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -Actor 'demo-user' + +# 2) Plan +$plan = New-IdlePlan -WorkflowPath .\examples\workflows\joiner-minimal.psd1 -Request $request + +# 3) Step registry (host configuration) +$emitHandler = { + param($Context, $Step) + + & $Context.WriteEvent 'Custom' 'Hello from handler.' $Step.Name @{ StepType = $Step.Type } + + [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } } -# 2) Validate configuration/workflow compatibility -Test-IdleWorkflow -WorkflowPath ./workflows/joiner.psd1 -Request $request +$providers = @{ + StepRegistry = @{ + 'IdLE.Step.EmitEvent' = $emitHandler + } +} + +# Execute +$result = Invoke-IdlePlan -Plan $plan -Providers $providers +$result.Status +$result.Events | Format-Table TimestampUtc, Type, StepName, Message -AutoSize +``` -# 3) Build a plan (preview actions and warnings) -$plan = New-IdlePlan -WorkflowPath ./workflows/joiner.psd1 -Request $request -Providers $providers +### Declarative `When` conditions (data-only) -# Optional: inspect the plan -$plan.Actions | Format-Table +Steps can be conditionally skipped using a declarative `When` block: -# 4) Execute the plan -Invoke-IdlePlan -Plan $plan -Providers $providers +```powershell +When = @{ + Path = 'Plan.LifecycleEvent' + Equals = 'Joiner' +} ``` ---- +If the condition is not met, the step result status becomes `Skipped` and a `StepSkipped` event is emitted. + +### More examples + +See the runnable demo in `examples/run-demo.ps1` and additional workflow samples in `examples/workflows/`. ## Workflow Definitions (concept) diff --git a/docs/01-architecture.md b/docs/01-architecture.md index b1b0b9c0..b4ce0dd8 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -316,3 +316,25 @@ flowchart LR - 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. From bb6bb907dd11df988bac8af1bb9137d1845426bf Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:35:09 +0100 Subject: [PATCH 25/32] remove Steps.Common from auto-import; manual option --- README.md | 11 +++++++++++ examples/run-demo.ps1 | 1 + src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 | 9 +++++++++ src/IdLE/IdLE.psm1 | 3 --- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c43d0b35..1a9ff48b 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,17 @@ IdLE separates **planning** from **execution**: > Note: Workflows are **data-only**. Step implementations are provided by the host via the Step Registry. +### Optional built-in steps + +IdLE is an engine-only module. Built-in step implementations are provided via optional step modules. + +To use the common built-in steps: + +```powershell +Import-Module IdLE +Import-Module IdLE.Steps.Common +``` + ### Example ```powershell diff --git a/examples/run-demo.ps1 b/examples/run-demo.ps1 index 9f522772..60e6e3b0 100644 --- a/examples/run-demo.ps1 +++ b/examples/run-demo.ps1 @@ -2,6 +2,7 @@ Set-StrictMode -Version Latest 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' diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index d721c819..fc7dda0c 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -39,5 +39,14 @@ function Get-IdleStepRegistry { } } + # 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/IdLE.psm1 b/src/IdLE/IdLE.psm1 index 44a7a901..c25b0390 100644 --- a/src/IdLE/IdLE.psm1 +++ b/src/IdLE/IdLE.psm1 @@ -14,9 +14,6 @@ if (Test-Path -Path $PublicPath) { } } -$StepsManifestPath = Join-Path -Path $PSScriptRoot -ChildPath '..\IdLE.Steps.Common\IdLE.Steps.Common.psd1' -Import-Module -Name $StepsManifestPath -Force -ErrorAction Stop - # Export exactly the public API cmdlets (contract). Export-ModuleMember -Function @( 'Test-IdleWorkflow', From ee31f26dbc24ac063bdc2f4228c45cdfeda24d06 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:39:05 +0100 Subject: [PATCH 26/32] beautified run-demo output --- examples/run-demo.ps1 | 75 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/examples/run-demo.ps1 b/examples/run-demo.ps1 index 60e6e3b0..3c07e3fa 100644 --- a/examples/run-demo.ps1 +++ b/examples/run-demo.ps1 @@ -1,6 +1,71 @@ #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 @@ -62,5 +127,11 @@ $providers = @{ $result = Invoke-IdlePlan -Plan $plan -Providers $providers -$result.Status -$result.Events | Format-Table TimestampUtc, Type, StepName, Message -AutoSize +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 From 463dd673560dfcc18f0a88e57d35ddf693906c1d Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:40:05 +0100 Subject: [PATCH 27/32] added generic manifest tests / surface tests --- tests/ModuleSurface.Tests.ps1 | 66 +++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/ModuleSurface.Tests.ps1 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' + } +} From f0b471a9f7541ea7d69d90a616164d576bf80415 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:52:08 +0100 Subject: [PATCH 28/32] renaming docs --- docs/{01-architecture.md => architecture.md} | 0 docs/{02-examples.md => examples.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename docs/{01-architecture.md => architecture.md} (100%) rename docs/{02-examples.md => examples.md} (100%) diff --git a/docs/01-architecture.md b/docs/architecture.md similarity index 100% rename from docs/01-architecture.md rename to docs/architecture.md diff --git a/docs/02-examples.md b/docs/examples.md similarity index 100% rename from docs/02-examples.md rename to docs/examples.md From 296ce43555ec0186dc462b0d91083c82a710d232 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:54:33 +0100 Subject: [PATCH 29/32] adding docs INDEX.md file --- docs/00_index.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/00_index.md diff --git a/docs/00_index.md b/docs/00_index.md new file mode 100644 index 00000000..6fc662e4 --- /dev/null +++ b/docs/00_index.md @@ -0,0 +1,7 @@ +# Identity Lifecycle Engine (IdLE) Documentation Index + +* Start here: [README](../README.md) +* [Architecture](architecture.md) +* [Examples (Folder)](examples/) +* [Contributing](../CONTRIBUTING.md) +* [Style Guide](../STYLEGUIDE.md) \ No newline at end of file From fe29eaa33f0697de0189704afef96748875170f1 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:56:19 +0100 Subject: [PATCH 30/32] fix file links / shorten doku to index --- CONTRIBUTING.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9061b1e1..fbb72f41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ Enhancement proposals should: ### Clone the Repository ```bash -git clone https://github.com//IdentityLifecycleEngine.git +git clone https://github.com/blindzero/IdentityLifecycleEngine.git ``` --- @@ -124,8 +124,7 @@ A contribution is complete when: ## Documentation -- Architecture: `docs/idle-architecture.md` -- Coding & documentation rules: **STYLEGUIDE.md** +See Index: [`docs/00_index.md`](docs/00_index.md) --- From ff35a8f370ffc56cea19056d109ff23473be6cf6 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:04:03 +0100 Subject: [PATCH 31/32] docs: fix text link in code block --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1a9ff48b..ad0186f2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -96,7 +96,7 @@ Import-Module IdLE.Steps.Common Import-Module .\src\IdLE\IdLE.psd1 -Force # 1) Request -$request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -Actor 'demo-user' +$request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' # 2) Plan $plan = New-IdlePlan -WorkflowPath .\examples\workflows\joiner-minimal.psd1 -Request $request From d72462622a8914fbb6a398c62b65ece7a83d2089 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:26:56 +0100 Subject: [PATCH 32/32] docs: simplified, indexing, renaming --- CONTRIBUTING.md | 32 ++++- README.md | 137 +++---------------- STYLEGUIDE.md | 23 +--- docs/00-index.md | 18 +++ docs/00_index.md | 7 - docs/{architecture.md => 01-architecture.md} | 0 docs/02-examples.md | 86 ++++++++++++ docs/examples.md | 16 --- 8 files changed, 154 insertions(+), 165 deletions(-) create mode 100644 docs/00-index.md delete mode 100644 docs/00_index.md rename docs/{architecture.md => 01-architecture.md} (100%) create mode 100644 docs/02-examples.md delete mode 100644 docs/examples.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fbb72f41..6387aef2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,6 +55,14 @@ Enhancement proposals should: - Git - Visual Studio Code (recommended) +### Recommended IDE & extensions (optional) + +- Visual Studio Code +- Extensions: + - PowerShell + - EditorConfig + - Markdown All in One + ### Clone the Repository ```bash @@ -115,16 +123,30 @@ Pull Requests must: A contribution is complete when: -- all tests pass -- no architecture rules are violated -- public APIs are documented -- relevant docs are updated +- 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 -See Index: [`docs/00_index.md`](docs/00_index.md) +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` --- diff --git a/README.md b/README.md index ad0186f2..fad106ed 100644 --- a/README.md +++ b/README.md @@ -69,138 +69,41 @@ Install-Module IdLE --- -## Quickstart (Plan β†’ Execute) +## Quickstart -IdLE separates **planning** from **execution**: - -1. Create a `LifecycleRequest` -2. Build a deterministic `Plan` from a workflow definition (PSD1) -3. Execute the plan with a host-provided step registry (handlers) - -> Note: Workflows are **data-only**. Step implementations are provided by the host via the Step Registry. - -### Optional built-in steps - -IdLE is an engine-only module. Built-in step implementations are provided via optional step modules. - -To use the common built-in steps: - -```powershell -Import-Module IdLE -Import-Module IdLE.Steps.Common -``` - -### Example - -```powershell -Import-Module .\src\IdLE\IdLE.psd1 -Force - -# 1) Request -$request = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - -# 2) Plan -$plan = New-IdlePlan -WorkflowPath .\examples\workflows\joiner-minimal.psd1 -Request $request - -# 3) Step registry (host configuration) -$emitHandler = { - param($Context, $Step) - - & $Context.WriteEvent 'Custom' 'Hello from handler.' $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' = $emitHandler - } -} - -# Execute -$result = Invoke-IdlePlan -Plan $plan -Providers $providers -$result.Status -$result.Events | Format-Table TimestampUtc, Type, StepName, Message -AutoSize -``` - -### Declarative `When` conditions (data-only) - -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` and a `StepSkipped` event is emitted. - -### More examples - -See the runnable demo in `examples/run-demo.ps1` and additional workflow samples in `examples/workflows/`. - -## Workflow Definitions (concept) - -Workflows are configuration-first (e.g., `.psd1`) and describe: - -- step sequence -- conditions (declarative, not arbitrary PowerShell expressions) -- required inputs / produced outputs - -Example (illustrative): +Run the end-to-end demo (Plan β†’ Execute): ```powershell -@{ - Name = 'Joiner - Standard' - LifecycleEvent = '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' } - ) -} +pwsh -File .\examples\run-demo.ps1 ``` ---- - -## Providers & Steps +The demo shows: -IdLE deliberately does not hardcode system access. Instead, it calls provider interfaces/ports. +- creating a lifecycle request +- building a deterministic plan from a workflow definition (`.psd1`) +- executing the plan using a host-provided step registry -- **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) +Next steps: -This keeps workflows stable even when the underlying systems change. +- Usage & examples: `docs/02-examples.md` +- Architecture: `docs/01-architecture.md` +- Workflow samples: `examples/workflows/` +- Pester tests: `tests/` --- -## Event Stream / Auditing - -Every run emits structured events (progress, audit, warnings, errors), typically including: - -- `CorrelationId` -- `Actor` -- step name / outcome -- change summaries (plan diffs, applied actions) +## Documentation -This enables integration into logging systems, SIEM, ticketing, or custom dashboards. +Start here: ---- - -## Testing +- `docs/00-index.md` – documentation map +- `docs/01-architecture.md` – architecture and principles +- `docs/02-examples.md` – runnable examples + workflow snippets -Run the full test suite: +Project docs: -```powershell -Invoke-Pester -Path ./tests -``` +- Contributing: `CONTRIBUTING.md` +- Style guide: `STYLEGUIDE.md` --- diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 6166f138..4ccd8f3a 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -101,27 +101,10 @@ Providers: --- -## Documentation +## Documentation Responsibilities -Documentation must be updated when: - -- public APIs change -- workflow behavior changes -- provider auth requirements change - ---- - -## Tooling - -### Recommended IDE - -- Visual Studio Code - -### Extensions - -- PowerShell -- EditorConfig -- Markdown All in One +Documentation *process* lives in **CONTRIBUTING.md**. +This style guide focuses on **in-code documentation rules** (comment-based help, inline comments). --- 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/00_index.md b/docs/00_index.md deleted file mode 100644 index 6fc662e4..00000000 --- a/docs/00_index.md +++ /dev/null @@ -1,7 +0,0 @@ -# Identity Lifecycle Engine (IdLE) Documentation Index - -* Start here: [README](../README.md) -* [Architecture](architecture.md) -* [Examples (Folder)](examples/) -* [Contributing](../CONTRIBUTING.md) -* [Style Guide](../STYLEGUIDE.md) \ No newline at end of file diff --git a/docs/architecture.md b/docs/01-architecture.md similarity index 100% rename from docs/architecture.md rename to docs/01-architecture.md 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/docs/examples.md b/docs/examples.md deleted file mode 100644 index a221a4a8..00000000 --- a/docs/examples.md +++ /dev/null @@ -1,16 +0,0 @@ -# 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.