From e5add658cca5b0ae290bceb72931498ee223ddd5 Mon Sep 17 00:00:00 2001 From: Marius Solbakken Mellum Date: Tue, 17 Mar 2026 10:45:19 +0100 Subject: [PATCH 1/2] feat: Initial release --- .github/copilot-instructions.md | 5 + .github/dependabot.yml | 6 + .github/release-please-config.json | 8 ++ .github/release-please.yml | 2 + .../psscriptanalyzer-expected-results.json | 1 + .github/workflows/psscriptanalyzer.yml | 63 +++++++++++ .github/workflows/publish.yml | 36 ++++++ .github/workflows/release-please.yml | 106 ++++++++++++++++++ .gitignore | 1 + .release-please-manifest.json | 3 + Example.ps1 | 3 + LCWMermaidGenerator/LCWMermaidGenerator.psd1 | 103 +++++++++++++++++ LCWMermaidGenerator/LCWMermaidGenerator.psm1 | 17 +++ .../Private/ConvertTo-MermaidNodeId.ps1 | 7 ++ .../Private/ConvertTo-MermaidSafeText.ps1 | 16 +++ .../Private/ConvertTo-OrderedArgumentMap.ps1 | 13 +++ .../Private/ConvertTo-WorkflowConsoleText.ps1 | 42 +++++++ .../Private/ConvertTo-WorkflowMarkdown.ps1 | 60 ++++++++++ .../Private/ConvertTo-WorkflowMermaid.ps1 | 62 ++++++++++ .../Private/Format-NullableValue.ps1 | 21 ++++ .../Private/Get-ScopeSummary.ps1 | 16 +++ .../Private/Get-TaskRecord.ps1 | 46 ++++++++ .../Private/Get-TriggerSummary.ps1 | 28 +++++ .../Private/Get-WorkflowRecord.ps1 | 34 ++++++ .../Public/Invoke-LCWMermaidGenerator.ps1 | 59 ++++++++++ 25 files changed, 758 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/dependabot.yml create mode 100644 .github/release-please-config.json create mode 100644 .github/release-please.yml create mode 100644 .github/workflows/psscriptanalyzer-expected-results.json create mode 100644 .github/workflows/psscriptanalyzer.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .gitignore create mode 100644 .release-please-manifest.json create mode 100644 Example.ps1 create mode 100644 LCWMermaidGenerator/LCWMermaidGenerator.psd1 create mode 100644 LCWMermaidGenerator/LCWMermaidGenerator.psm1 create mode 100644 LCWMermaidGenerator/Private/ConvertTo-MermaidNodeId.ps1 create mode 100644 LCWMermaidGenerator/Private/ConvertTo-MermaidSafeText.ps1 create mode 100644 LCWMermaidGenerator/Private/ConvertTo-OrderedArgumentMap.ps1 create mode 100644 LCWMermaidGenerator/Private/ConvertTo-WorkflowConsoleText.ps1 create mode 100644 LCWMermaidGenerator/Private/ConvertTo-WorkflowMarkdown.ps1 create mode 100644 LCWMermaidGenerator/Private/ConvertTo-WorkflowMermaid.ps1 create mode 100644 LCWMermaidGenerator/Private/Format-NullableValue.ps1 create mode 100644 LCWMermaidGenerator/Private/Get-ScopeSummary.ps1 create mode 100644 LCWMermaidGenerator/Private/Get-TaskRecord.ps1 create mode 100644 LCWMermaidGenerator/Private/Get-TriggerSummary.ps1 create mode 100644 LCWMermaidGenerator/Private/Get-WorkflowRecord.ps1 create mode 100644 LCWMermaidGenerator/Public/Invoke-LCWMermaidGenerator.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..8ba0e31 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,5 @@ +This repository contains a PowerShell module used for documenting life cycle workflows in Entra ID. + +All PowerShell code in this repository should be compatible with PowerShell 7.1 or later, and should follow best practices for PowerShell development, including using proper naming conventions, commenting code, and writing unit tests where possible. + +The module is published to powershellgallery.com. \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1230149 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/release-please-config.json b/.github/release-please-config.json new file mode 100644 index 0000000..79be783 --- /dev/null +++ b/.github/release-please-config.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "simple" + } + } +} \ No newline at end of file diff --git a/.github/release-please.yml b/.github/release-please.yml new file mode 100644 index 0000000..2ea4395 --- /dev/null +++ b/.github/release-please.yml @@ -0,0 +1,2 @@ +releaseType: simple +handleGHRelease: true \ No newline at end of file diff --git a/.github/workflows/psscriptanalyzer-expected-results.json b/.github/workflows/psscriptanalyzer-expected-results.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/.github/workflows/psscriptanalyzer-expected-results.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.github/workflows/psscriptanalyzer.yml b/.github/workflows/psscriptanalyzer.yml new file mode 100644 index 0000000..0b9846b --- /dev/null +++ b/.github/workflows/psscriptanalyzer.yml @@ -0,0 +1,63 @@ +name: Run PowerShell Script Analyzer + +on: + pull_request: + +jobs: + build-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 2 + ref: ${{ github.event.pull_request.head.ref }} + - name: PowerShell Script Analyzer + shell: pwsh + run: | + Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -Confirm:$false + $ExpectedResults = @() + $file = "./.github/workflows/psscriptanalyzer-expected-results.json" + if((Test-Path $file)) { + $ExpectedResults = Get-Content $file | ConvertFrom-Json + } else { + Write-Host "No psscriptanalyzer-expected-results.json file found, assuming no expected results. File: $file" + } + + $Results = Get-ChildItem -Recurse -Include *.psd1 | + ForEach-Object {Invoke-ScriptAnalyzer -Path ($_.FullName | Split-Path -Parent) -Recurse -ExcludeRule @( + "PSAvoidTrailingWhitespace" + "PSUseToExportFieldsInManifest" + "PSAvoidUsingWriteHost" + )} | + Where-Object ScriptName -notlike "*.tests.*" # Ignore test files, as these do all kinds of crazy things that trigger warnings + + $IssuesFound = $false + $Results | + Where-Object {!($ExpectedResults | Where-Object RuleName -eq $_.RuleName | Where-Object ScriptName -eq $_.ScriptName | Where-Object Line -eq $_.Line)} | + ForEach-Object -Begin {"=================================`nThe following results were found that were not expected, and must either be fixed or added to psscriptanalyzer-expected-results.json:`n"} -Process { + $IssuesFound = $true + "$($PSStyle.Foreground.Yellow)============== " + $_.Severity + " ==============$($PSStyle.Reset)" + $_.ScriptName + " (Line " + $_.Line + "):" + $_.Message + "" + "If this issue is acceptable, add the following entry to psscriptanalyzer-expected-results.json:`n" + [ordered] @{RuleName=$_.RuleName; ScriptName=$_.ScriptName; Line=$_.Line} | ConvertTo-Json -Depth 3 + "" + + } -End {} + + $ExpectedResults | + Where-Object {!($Results | Where-Object RuleName -eq $_.RuleName | Where-Object ScriptName -eq $_.ScriptName | Where-Object Line -eq $_.Line)} | + ForEach-Object -Begin {"=================================`nThe following results were expected, but not found, and should therefore be removed from psscriptanalyzer-expected-results.json:`n"} -Process { + $IssuesFound = $true + [ordered] @{RuleName=$_.RuleName; ScriptName=$_.ScriptName; Line=$_.Line} | ConvertTo-Json -Depth 3 + "" + } -End {"================================="} + + if($IssuesFound) { + throw "PowerShell Script Analyzer found unexpected results." + } \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..03f0204 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,36 @@ +name: Publish Module +permissions: + contents: read + +on: + push: + tags: + - v*.*.* + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Publish Module to PowerShell Gallery + shell: pwsh + run: | + $psd1 = Get-ChildItem -Recurse -Include *.psd1 + $RequiredModules = [ScriptBlock]::Create((Get-Content -Raw $psd1.FullName)).Invoke().RequiredModules + if($RequiredModules) { + $RequiredModules | ForEach-Object { + Write-Host "Installing required module - $($_)" + Install-Module -Name $_ -Force -Confirm:$false + } + } + + $psd1 | + ForEach-Object { + Test-ModuleManifest $_.FullName + + $ModulePath = Split-Path -Parent $_.FullName + $ModuleName = $_.BaseName + Write-Host "Publishing module $ModuleName" + Publish-Module -Path $ModulePath -NuGetApiKey '${{ secrets.POWERSHELLGALLERY_API_KEY }}' -Verbose + } \ No newline at end of file diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..6529246 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,106 @@ +name: ReleasePlease +on: + repository_dispatch: + types: [create-pull-request] + pull_request: + push: + branches: [ "main" ] + +permissions: + pull-requests: write + contents: write + +jobs: + + update-readme: + if: github.event_name == 'pull_request' + name: Update psd1 and documentation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 2 + ref: ${{ github.event.pull_request.head.ref }} + - name: Update documentation.md and psd1 version + shell: pwsh + run: | + $Path = ".\Documentation.md" + + # Load platyPS module for generating markdown documentation + Install-Module platyPS -Scope CurrentUser -Confirm:$false -Force + Import-Module platyPS + + # Locate the module manifest file + $psd1 = Get-ChildItem -Recurse -Include *.psd1 + $count = ($psd1 | Measure-Object).Count + if($count -ne 1) { + Write-Error "Wrong number of psd1 files found ($count)" + exit 1 + } + + # Import the module + $RequiredModules = [ScriptBlock]::Create((Get-Content -Raw $psd1.FullName)).Invoke().RequiredModules + if($RequiredModules) { + $RequiredModules | ForEach-Object { + Write-Host "Installing required module $_" + Install-Module -Name $_ -Scope CurrentUser -Confirm:$false -Force + } + } + $ModulePath = $psd1.FullName | Split-Path -Parent + $Module = Import-Module $ModulePath -Verbose -Force -PassThru + + # Update the psd1 file with the release-please version and public cmdlets + $releasePleaseVersion = $null + if((Test-Path ".release-please-manifest.json")) { + $releasePleaseVersion = (Get-Content ".release-please-manifest.json" | ConvertFrom-Json -AsHashTable)."." + if($releasePleaseVersion -notlike "*.*.*") { + Write-Error "Invalid release-please version '($releasePleaseVersion)' found" + exit 1 + } + + # Replace the ModuleVersion line in the psd1 file + (Get-Content $psd1.FullName | + ForEach-Object {if($_ -like "*ModuleVersion*=*"){" ModuleVersion = '$releasePleaseVersion'"} else {$_}} | + ForEach-Object {if($_ -like "*CmdletsToExport*'`*'*" -or $_ -like "*CmdletsToExport*@(*)*"){" CmdletsToExport = @('{0}')" -f ($module.ExportedFunctions.Keys -join "','")} else {$_}} | + Out-String).Trim() | + Set-Content $psd1.FullName + } + + # Generate README.md + { + "# Documentation for module $($Module.Name)" + "" + $Module.Description + "" + "| Metadata | Information |" + "| --- | --- |" + "| Version | {0} |" -f ($releasePleaseVersion ?? $Module.Version) + if($Module.RequiredModules) {"| Required modules | $($Module.RequiredModules) |"} + if($Module.Author) {"| Author | $($Module.Author) |"} + if($Module.CompanyName) {"| Company name | $($Module.CompanyName) |"} + if($Module.PowerShellVersion) {"| PowerShell version | $($Module.PowerShellVersion) |"} + "" + + New-MarkdownHelp -Module $Module.Name -OutputFolder "$($ENV:RUNNER_TEMP)/generateddocs" -Force -NoMetadata | + Get-Content | + ForEach-Object {$_ -replace "^#","##"} | + ForEach-Object {$_ -replace "{{ Fill [\w\s]+ Description }}"} + }.Invoke() | Out-File -FilePath $Path -Encoding utf8 -Force + - name: Push back to PR + shell: pwsh + run: | + # Commit and push the changes back to the PR + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git add --all + git commit --signoff -m "chore: Update generated content" + git push origin ${{ github.event.pull_request.head.ref }} + release-please: + runs-on: ubuntu-latest + if: github.event_name == 'push' + steps: + - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + with: + token: ${{ secrets.POSTMAN_PAT }} + config-file: .github/release-please-config.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3e9b29 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +LifecycleWorkflowsReport.md diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..1332969 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.1" +} \ No newline at end of file diff --git a/Example.ps1 b/Example.ps1 new file mode 100644 index 0000000..561fa5b --- /dev/null +++ b/Example.ps1 @@ -0,0 +1,3 @@ +Import-Module "./LCWMermaidGenerator" -Force + +Invoke-LCWMermaidGenerator \ No newline at end of file diff --git a/LCWMermaidGenerator/LCWMermaidGenerator.psd1 b/LCWMermaidGenerator/LCWMermaidGenerator.psd1 new file mode 100644 index 0000000..be720f5 --- /dev/null +++ b/LCWMermaidGenerator/LCWMermaidGenerator.psd1 @@ -0,0 +1,103 @@ +# +# Module manifest for module 'LCWMermaidGenerator' +# + +@{ + + # Script module or binary module file associated with this manifest. + RootModule = 'LCWMermaidGenerator.psm1' + + # Version number of this module. + ModuleVersion = '0.0.1' + + # Supported PSEditions + CompatiblePSEditions = @('Core') + + # ID used to uniquely identify this module + GUID = '6b730f1c-4346-49ae-9b2d-805d4e684cdb' + + # Author of this module + Author = 'Marius Solbakken Mellum' + + # Company or vendor of this module + CompanyName = 'Fortytwo Technologies AS' + + # Copyright statement for this module + Copyright = '(c) Fortytwo Technologies AS' + + # Description of the functionality provided by this module + Description = 'A module for simplifying the process of getting an access token from Entra ID' + + # Minimum version of the PowerShell engine required by this module + PowerShellVersion = '7.1' + + # Name of the PowerShell host required by this module + # PowerShellHostName = '' + + # Minimum version of the PowerShell host required by this module + # PowerShellHostVersion = '' + + # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. + # DotNetFrameworkVersion = '' + + # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. + # ClrVersion = '' + + # Processor architecture (None, X86, Amd64) required by this module + # ProcessorArchitecture = '' + + # Modules that must be imported into the global environment prior to importing this module + # RequiredModules = @() + + # Assemblies that must be loaded prior to importing this module + # RequiredAssemblies = @() + + # Script files (.ps1) that are run in the caller's environment prior to importing this module. + # ScriptsToProcess = @() + + # Type files (.ps1xml) to be loaded when importing this module + # TypesToProcess = @() + + # Format files (.ps1xml) to be loaded when importing this module + # FormatsToProcess = @() + + # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess + # NestedModules = @() + + # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. + FunctionsToExport = '*' + + # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. + CmdletsToExport = '*' + + # Variables to export from this module + VariablesToExport = @() + + # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. + AliasesToExport = '*' + + # DSC resources to export from this module + # DscResourcesToExport = @() + + # List of all modules packaged with this module + # ModuleList = @() + + # List of all files packaged with this module + # FileList = @() + + # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. + PrivateData = @{ + PSData = @{ + Tags = @( + "Microsoft" + "Microsoft365" + "EntraID" + "Authentication" + "Governance" + ) + ProjectUri = "https://github.com/goodworkaround/LCWMermaidGenerator" + LicenseUri = "https://github.com/goodworkaround/LCWMermaidGenerator/tree/main?tab=MIT-1-ov-file" + ReleaseNotes = "https://github.com/goodworkaround/LCWMermaidGenerator/releases" + } + } +} diff --git a/LCWMermaidGenerator/LCWMermaidGenerator.psm1 b/LCWMermaidGenerator/LCWMermaidGenerator.psm1 new file mode 100644 index 0000000..f000c0b --- /dev/null +++ b/LCWMermaidGenerator/LCWMermaidGenerator.psm1 @@ -0,0 +1,17 @@ +# Inspiration: https://github.com/RamblingCookieMonster/PSStackExchange/blob/master/PSStackExchange/PSStackExchange.psm1 + +# Get public and private function definition files. +$Private = (Test-Path $PSScriptRoot\Private) ? @( Get-ChildItem -Path $PSScriptRoot\Private\*.ps1 -ErrorAction SilentlyContinue ) : @() +$Public = @( Get-ChildItem -Path $PSScriptRoot\Public\*.ps1 -ErrorAction SilentlyContinue -Exclude *.Tests.ps1 ) + +# Dot source the files in order to define all cmdlets +Foreach ($import in @($Public + $Private)) { + Try { + . $import.fullname + } + Catch { + Write-Error -Message "Failed to import function $($import.fullname): $_" + } +} + +Export-ModuleMember -Function $Public.Basename \ No newline at end of file diff --git a/LCWMermaidGenerator/Private/ConvertTo-MermaidNodeId.ps1 b/LCWMermaidGenerator/Private/ConvertTo-MermaidNodeId.ps1 new file mode 100644 index 0000000..88420b6 --- /dev/null +++ b/LCWMermaidGenerator/Private/ConvertTo-MermaidNodeId.ps1 @@ -0,0 +1,7 @@ +function ConvertTo-MermaidNodeId { + param( + [string]$Seed + ) + + return ([regex]::Replace($Seed, '[^A-Za-z0-9_]', '_')) +} \ No newline at end of file diff --git a/LCWMermaidGenerator/Private/ConvertTo-MermaidSafeText.ps1 b/LCWMermaidGenerator/Private/ConvertTo-MermaidSafeText.ps1 new file mode 100644 index 0000000..5908109 --- /dev/null +++ b/LCWMermaidGenerator/Private/ConvertTo-MermaidSafeText.ps1 @@ -0,0 +1,16 @@ +function ConvertTo-MermaidSafeText { + param( + [AllowNull()] + [string]$Value + ) + + $text = Format-NullableValue -Value $Value + $text = $text.Replace('"', "'") + $text = $text.Replace('[', '(') + $text = $text.Replace(']', ')') + $text = $text.Replace('{', '(') + $text = $text.Replace('}', ')') + $text = $text.Replace("`r", ' ') + $text = $text.Replace("`n", '
') + return $text +} \ No newline at end of file diff --git a/LCWMermaidGenerator/Private/ConvertTo-OrderedArgumentMap.ps1 b/LCWMermaidGenerator/Private/ConvertTo-OrderedArgumentMap.ps1 new file mode 100644 index 0000000..49e8f77 --- /dev/null +++ b/LCWMermaidGenerator/Private/ConvertTo-OrderedArgumentMap.ps1 @@ -0,0 +1,13 @@ +function ConvertTo-OrderedArgumentMap { + param( + [AllowNull()] + $Arguments + ) + + $map = [ordered]@{} + foreach ($argument in @($Arguments)) { + $map[$argument.Name] = $argument.Value + } + + return $map +} \ No newline at end of file diff --git a/LCWMermaidGenerator/Private/ConvertTo-WorkflowConsoleText.ps1 b/LCWMermaidGenerator/Private/ConvertTo-WorkflowConsoleText.ps1 new file mode 100644 index 0000000..b15fd4d --- /dev/null +++ b/LCWMermaidGenerator/Private/ConvertTo-WorkflowConsoleText.ps1 @@ -0,0 +1,42 @@ +function ConvertTo-WorkflowConsoleText { + param( + $WorkflowRecord + ) + + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('-----------------------------------') + $lines.Add("Name: $(Format-NullableValue -Value $WorkflowRecord.DisplayName)") + $lines.Add("ID: $(Format-NullableValue -Value $WorkflowRecord.Id)") + $lines.Add("Description: $(Format-NullableValue -Value $WorkflowRecord.Description)") + $lines.Add("Category: $(Format-NullableValue -Value $WorkflowRecord.Category)") + $lines.Add("Enabled: $(Format-NullableValue -Value $WorkflowRecord.IsEnabled)") + $lines.Add("Scheduling enabled: $(Format-NullableValue -Value $WorkflowRecord.IsSchedulingEnabled)") + $lines.Add("Created: $(Format-NullableValue -Value $WorkflowRecord.CreatedDateTime)") + $lines.Add("Last modified: $(Format-NullableValue -Value $WorkflowRecord.LastModifiedDateTime)") + $lines.Add("Trigger: $(Format-NullableValue -Value $WorkflowRecord.TriggerSummary)") + $lines.Add("Scope: $(Format-NullableValue -Value $WorkflowRecord.ScopeSummary)") + $lines.Add('Tasks:') + + foreach ($task in $WorkflowRecord.Tasks) { + $status = if ($task.IsEnabled) { 'enabled' } else { 'disabled' } + $definitionName = Format-NullableValue -Value $task.DefinitionDisplayName + $taskName = Format-NullableValue -Value $task.DisplayName + $lines.Add(" [$($task.ExecutionSequence)] $taskName ($definitionName, $status)") + + if ($task.CustomExtension) { + $lines.Add(" Custom extension: $(Format-NullableValue -Value $task.CustomExtension.DisplayName)") + $lines.Add(" Logic App: $(Format-NullableValue -Value $task.CustomExtension.LogicAppName)") + $lines.Add(" Resource group: $(Format-NullableValue -Value $task.CustomExtension.ResourceGroupName)") + } + + foreach ($argument in $task.Arguments.GetEnumerator()) { + if ($argument.Key -eq 'customTaskExtensionID') { + continue + } + + $lines.Add(" $($argument.Key): $(Format-NullableValue -Value $argument.Value)") + } + } + + return ($lines -join [Environment]::NewLine) +} \ No newline at end of file diff --git a/LCWMermaidGenerator/Private/ConvertTo-WorkflowMarkdown.ps1 b/LCWMermaidGenerator/Private/ConvertTo-WorkflowMarkdown.ps1 new file mode 100644 index 0000000..6ef152d --- /dev/null +++ b/LCWMermaidGenerator/Private/ConvertTo-WorkflowMarkdown.ps1 @@ -0,0 +1,60 @@ +function ConvertTo-WorkflowMarkdown { + param( + $WorkflowRecord + ) + + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add("## $($WorkflowRecord.DisplayName)") + $lines.Add('') + $lines.Add('| Property | Value |') + $lines.Add('| --- | --- |') + $lines.Add("| ID | $(Format-NullableValue -Value $WorkflowRecord.Id) |") + $lines.Add("| Description | $(Format-NullableValue -Value $WorkflowRecord.Description) |") + $lines.Add("| Category | $(Format-NullableValue -Value $WorkflowRecord.Category) |") + $lines.Add("| Enabled | $(Format-NullableValue -Value $WorkflowRecord.IsEnabled) |") + $lines.Add("| Scheduling enabled | $(Format-NullableValue -Value $WorkflowRecord.IsSchedulingEnabled) |") + $lines.Add("| Created | $(Format-NullableValue -Value $WorkflowRecord.CreatedDateTime) |") + $lines.Add("| Last modified | $(Format-NullableValue -Value $WorkflowRecord.LastModifiedDateTime) |") + $lines.Add("| Trigger | $(Format-NullableValue -Value $WorkflowRecord.TriggerSummary) |") + $lines.Add("| Scope | $(Format-NullableValue -Value $WorkflowRecord.ScopeSummary) |") + $lines.Add('') + $lines.Add('### Tasks') + $lines.Add('') + + if ($WorkflowRecord.Tasks.Count -eq 0) { + $lines.Add('No tasks found.') + } + else { + foreach ($task in $WorkflowRecord.Tasks) { + $status = if ($task.IsEnabled) { 'enabled' } else { 'disabled' } + $lines.Add("- [$($task.ExecutionSequence)] $(Format-NullableValue -Value $task.DisplayName) ($status)") + $lines.Add(" Definition: $(Format-NullableValue -Value $task.DefinitionDisplayName)") + + if ($task.CustomExtension) { + $lines.Add(" Custom extension: $(Format-NullableValue -Value $task.CustomExtension.DisplayName)") + $lines.Add(" Logic App: $(Format-NullableValue -Value $task.CustomExtension.LogicAppName)") + $lines.Add(" Resource group: $(Format-NullableValue -Value $task.CustomExtension.ResourceGroupName)") + $lines.Add(" Subscription: $(Format-NullableValue -Value $task.CustomExtension.SubscriptionId)") + } + + foreach ($argument in $task.Arguments.GetEnumerator()) { + if ($argument.Key -eq 'customTaskExtensionID') { + continue + } + + $lines.Add(" Argument: $($argument.Key) = $(Format-NullableValue -Value $argument.Value)") + } + + $lines.Add('') + } + } + + $lines.Add('### Mermaid') + $lines.Add('') + $lines.Add('```mermaid') + $lines.Add((ConvertTo-WorkflowMermaid -WorkflowRecord $WorkflowRecord)) + $lines.Add('```') + $lines.Add('') + + return ($lines -join [Environment]::NewLine) +} \ No newline at end of file diff --git a/LCWMermaidGenerator/Private/ConvertTo-WorkflowMermaid.ps1 b/LCWMermaidGenerator/Private/ConvertTo-WorkflowMermaid.ps1 new file mode 100644 index 0000000..65b7e7f --- /dev/null +++ b/LCWMermaidGenerator/Private/ConvertTo-WorkflowMermaid.ps1 @@ -0,0 +1,62 @@ +function ConvertTo-WorkflowMermaid { + param( + $WorkflowRecord + ) + + $workflowNodeId = ConvertTo-MermaidNodeId -Seed ("workflow_$($WorkflowRecord.Id)") + $triggerNodeId = ConvertTo-MermaidNodeId -Seed ("trigger_$($WorkflowRecord.Id)") + $scopeNodeId = ConvertTo-MermaidNodeId -Seed ("scope_$($WorkflowRecord.Id)") + $diagramLines = [System.Collections.Generic.List[string]]::new() + + $diagramLines.Add('flowchart TD') + $diagramLines.Add((' {0}["{1}
{2}"]' -f $workflowNodeId, (ConvertTo-MermaidSafeText -Value $WorkflowRecord.DisplayName), (ConvertTo-MermaidSafeText -Value $WorkflowRecord.Category))) + $diagramLines.Add((' {0}["Trigger
{1}"]' -f $triggerNodeId, (ConvertTo-MermaidSafeText -Value $WorkflowRecord.TriggerSummary))) + $diagramLines.Add((' {0}["Scope
{1}"]' -f $scopeNodeId, (ConvertTo-MermaidSafeText -Value $WorkflowRecord.ScopeSummary))) + $diagramLines.Add(" $workflowNodeId --> $triggerNodeId") + $diagramLines.Add(" $workflowNodeId --> $scopeNodeId") + + $previousTaskNodeId = $null + foreach ($task in $WorkflowRecord.Tasks) { + $taskNodeId = ConvertTo-MermaidNodeId -Seed ("task_$($WorkflowRecord.Id)_$($task.ExecutionSequence)") + $taskType = if ($task.CustomExtension) { + 'Custom extension' + } + else { + $task.DefinitionDisplayName + } + + $taskLabelParts = [System.Collections.Generic.List[string]]::new() + $taskLabelParts.Add("[$($task.ExecutionSequence)] $(ConvertTo-MermaidSafeText -Value $task.DisplayName)") + $taskLabelParts.Add((ConvertTo-MermaidSafeText -Value $taskType)) + if ($task.CustomExtension) { + $taskLabelParts.Add("Logic App: $(ConvertTo-MermaidSafeText -Value $task.CustomExtension.LogicAppName)") + } + + $diagramLines.Add((' {0}["{1}"]' -f $taskNodeId, ($taskLabelParts -join '
'))) + if ($null -eq $previousTaskNodeId) { + $diagramLines.Add(" $workflowNodeId --> $taskNodeId") + } + else { + $diagramLines.Add(" $previousTaskNodeId --> $taskNodeId") + } + + $className = if ($task.IsEnabled) { 'enabledTask' } else { 'disabledTask' } + if ($task.CustomExtension) { + $className = 'customTask' + } + + $diagramLines.Add(" class $taskNodeId $className") + $previousTaskNodeId = $taskNodeId + } + + $diagramLines.Add(" class $workflowNodeId workflowNode") + $diagramLines.Add(" class $triggerNodeId metaNode") + $diagramLines.Add(" class $scopeNodeId metaNode") + $diagramLines.Add(' classDef workflowNode fill:#16324f,color:#ffffff,stroke:#16324f,stroke-width:2px') + $diagramLines.Add(' classDef metaNode fill:#eef4ed,color:#132a13,stroke:#4f772d,stroke-width:1px') + $diagramLines.Add(' classDef enabledTask fill:#d9f2e6,color:#0b3d2e,stroke:#2d6a4f,stroke-width:1px') + $diagramLines.Add(' classDef disabledTask fill:#f3f4f6,color:#3f3f46,stroke:#a1a1aa,stroke-dasharray: 5 5') + $diagramLines.Add(' classDef customTask fill:#ffe8cc,color:#7c2d12,stroke:#c2410c,stroke-width:1px') + + return ($diagramLines -join [Environment]::NewLine) +} \ No newline at end of file diff --git a/LCWMermaidGenerator/Private/Format-NullableValue.ps1 b/LCWMermaidGenerator/Private/Format-NullableValue.ps1 new file mode 100644 index 0000000..d79379a --- /dev/null +++ b/LCWMermaidGenerator/Private/Format-NullableValue.ps1 @@ -0,0 +1,21 @@ +function Format-NullableValue { + param( + [AllowNull()] + $Value + ) + + if ($null -eq $Value) { + return '-' + } + + if ($Value -is [datetime]) { + return $Value.ToString('yyyy-MM-dd HH:mm:ss') + } + + $text = [string]$Value + if ([string]::IsNullOrWhiteSpace($text)) { + return '-' + } + + return $text +} \ No newline at end of file diff --git a/LCWMermaidGenerator/Private/Get-ScopeSummary.ps1 b/LCWMermaidGenerator/Private/Get-ScopeSummary.ps1 new file mode 100644 index 0000000..3d938a8 --- /dev/null +++ b/LCWMermaidGenerator/Private/Get-ScopeSummary.ps1 @@ -0,0 +1,16 @@ +function Get-ScopeSummary { + param( + [AllowNull()] + $Scope + ) + + if ($null -eq $Scope) { + return 'None' + } + + if ($Scope.rule) { + return "Rule: $($Scope.rule)" + } + + return ($Scope | ConvertTo-Json -Depth 10 -Compress) +} \ No newline at end of file diff --git a/LCWMermaidGenerator/Private/Get-TaskRecord.ps1 b/LCWMermaidGenerator/Private/Get-TaskRecord.ps1 new file mode 100644 index 0000000..ab0f472 --- /dev/null +++ b/LCWMermaidGenerator/Private/Get-TaskRecord.ps1 @@ -0,0 +1,46 @@ +function Get-TaskRecord { + param( + $Workflow, + $Task, + $TaskDefinitions, + $CustomTaskExtensions + ) + + $taskDefinition = $TaskDefinitions[$Task.TaskDefinitionId] + $arguments = ConvertTo-OrderedArgumentMap -Arguments $Task.Arguments + $isCustomExtensionTask = $Task.TaskDefinitionId -eq '4262b724-8dba-4fad-afc3-43fcbb497a0e' + $customExtension = $null + + if ($isCustomExtensionTask) { + $customTaskExtensionId = $arguments['customTaskExtensionID'] + if ([string]::IsNullOrWhiteSpace($customTaskExtensionId)) { + Write-Warning "Unable to find custom task extension ID for task $($Task.Id) in workflow $($Workflow.DisplayName)." + } + else { + $customExtension = $CustomTaskExtensions[$customTaskExtensionId] + } + } + + return [pscustomobject]@{ + Id = $Task.Id + DefinitionId = $Task.TaskDefinitionId + DefinitionDisplayName = $taskDefinition.DisplayName + DisplayName = $Task.DisplayName + IsEnabled = [bool]$Task.IsEnabled + ExecutionSequence = [int]$Task.ExecutionSequence + Arguments = $arguments + CustomExtension = if ($customExtension) { + [pscustomobject]@{ + Id = $customExtension.Id + DisplayName = $customExtension.DisplayName + Description = $customExtension.Description + LogicAppName = $customExtension.EndpointConfiguration.AdditionalProperties.logicAppWorkflowName + ResourceGroupName = $customExtension.EndpointConfiguration.AdditionalProperties.resourceGroupName + SubscriptionId = $customExtension.EndpointConfiguration.AdditionalProperties.subscriptionId + } + } + else { + $null + } + } +} \ No newline at end of file diff --git a/LCWMermaidGenerator/Private/Get-TriggerSummary.ps1 b/LCWMermaidGenerator/Private/Get-TriggerSummary.ps1 new file mode 100644 index 0000000..09ae03d --- /dev/null +++ b/LCWMermaidGenerator/Private/Get-TriggerSummary.ps1 @@ -0,0 +1,28 @@ +function Get-TriggerSummary { + param( + [AllowNull()] + $Trigger + ) + + if ($null -eq $Trigger) { + return 'None' + } + + $triggerType = $Trigger.'@odata.type' + switch ($triggerType) { + '#microsoft.graph.identityGovernance.timeBasedAttributeTrigger' { + return "Time-based: $($Trigger.timeBasedAttribute) ($($Trigger.offsetInDays) days)" + } + '#microsoft.graph.identityGovernance.attributeChangeTrigger' { + $attributeNames = @($Trigger.triggerAttributes | ForEach-Object { $_.name }) -join ', ' + if ([string]::IsNullOrWhiteSpace($attributeNames)) { + $attributeNames = 'attribute change' + } + + return "Attribute change: $attributeNames" + } + default { + return ($Trigger | ConvertTo-Json -Depth 10 -Compress) + } + } +} \ No newline at end of file diff --git a/LCWMermaidGenerator/Private/Get-WorkflowRecord.ps1 b/LCWMermaidGenerator/Private/Get-WorkflowRecord.ps1 new file mode 100644 index 0000000..c32e1a5 --- /dev/null +++ b/LCWMermaidGenerator/Private/Get-WorkflowRecord.ps1 @@ -0,0 +1,34 @@ +function Get-WorkflowRecord { + param( + $Workflow, + $TaskDefinitions, + $CustomTaskExtensions + ) + + $fullWorkflow = Get-MgIdentityGovernanceLifecycleWorkflow -WorkflowId $Workflow.Id + $trigger = $fullWorkflow.ExecutionConditions.AdditionalProperties.trigger + $scope = $fullWorkflow.ExecutionConditions.AdditionalProperties.scope + $tasks = @( + $fullWorkflow.Tasks | + Sort-Object ExecutionSequence | + ForEach-Object { + Get-TaskRecord -Workflow $fullWorkflow -Task $_ -TaskDefinitions $TaskDefinitions -CustomTaskExtensions $CustomTaskExtensions + } + ) + + return [pscustomobject]@{ + Id = $fullWorkflow.Id + DisplayName = $fullWorkflow.DisplayName + Description = $fullWorkflow.Description + Category = $fullWorkflow.Category + IsEnabled = [bool]$fullWorkflow.IsEnabled + IsSchedulingEnabled = [bool]$fullWorkflow.IsSchedulingEnabled + CreatedDateTime = $fullWorkflow.CreatedDateTime + LastModifiedDateTime = $fullWorkflow.LastModifiedDateTime + Trigger = $trigger + TriggerSummary = Get-TriggerSummary -Trigger $trigger + Scope = $scope + ScopeSummary = Get-ScopeSummary -Scope $scope + Tasks = $tasks + } +} \ No newline at end of file diff --git a/LCWMermaidGenerator/Public/Invoke-LCWMermaidGenerator.ps1 b/LCWMermaidGenerator/Public/Invoke-LCWMermaidGenerator.ps1 new file mode 100644 index 0000000..3f73a56 --- /dev/null +++ b/LCWMermaidGenerator/Public/Invoke-LCWMermaidGenerator.ps1 @@ -0,0 +1,59 @@ +function Invoke-LCWMermaidGenerator { + [CmdletBinding()] + param ( + [string] $ReportPath = 'LifecycleWorkflowsReport.md' + ) + + process { + $context = Get-MgContext + + if(!$context) { + Write-Error "Please connect to Microsoft Graph using Connect-MgGraph before invoking this command: Connect-MgGraph -Scopes 'LifecycleWorkflows.Read.All'" + } + + if( + !( + $context.scopes -contains "lifecycleworkflows.read.all" -or + $context.scopes -contains "lifecycleworkflows.readwrite.all" + ) + ) { + Write-Warning "Current context does not have required permissions to read lifecycle workflows. Please connect with a context that has either 'LifecycleWorkflows.Read.All' or 'LifecycleWorkflows.ReadWrite.All' permissions." + } + + $workflows = Get-MgIdentityGovernanceLifecycleWorkflow -All + + if(!$workflows) { + Write-Warning "No lifecycle workflows found in the tenant." + return + } + + $taskDefinitions = Get-MgIdentityGovernanceLifecycleWorkflowTaskDefinition -All | Group-Object -AsHashTable -Property Id + $customTaskExtensions = Get-MgIdentityGovernanceLifecycleWorkflowCustomTaskExtension -All | Group-Object -AsHashTable -Property Id + + $workflowRecords = @( + $workflows | ForEach-Object { + Get-WorkflowRecord -Workflow $_ -TaskDefinitions $taskDefinitions -CustomTaskExtensions $customTaskExtensions + } + ) + + foreach ($workflowRecord in $workflowRecords) { + Write-Host (ConvertTo-WorkflowConsoleText -WorkflowRecord $workflowRecord) + } + + $reportSections = [System.Collections.Generic.List[string]]::new() + $reportSections.Add('# Lifecycle Workflows Report') + $reportSections.Add('') + $reportSections.Add("Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") + $reportSections.Add('') + + foreach ($workflowRecord in $workflowRecords) { + $reportSections.Add((ConvertTo-WorkflowMarkdown -WorkflowRecord $workflowRecord)) + } + + $reportContent = $reportSections -join [Environment]::NewLine + Set-Content -Path $ReportPath -Value $reportContent -Encoding UTF8 + + Write-Host '' + Write-Host "Markdown report written to: $ReportPath" + } +} \ No newline at end of file From ecc031129be3971b68d7cc448c1e735be4ac8bc5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:45:58 +0000 Subject: [PATCH 2/2] chore: Update generated content Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- Documentation.md | 78 ++++++++++++++++++++ LCWMermaidGenerator/LCWMermaidGenerator.psd1 | 2 +- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 Documentation.md diff --git a/Documentation.md b/Documentation.md new file mode 100644 index 0000000..fbc15e4 --- /dev/null +++ b/Documentation.md @@ -0,0 +1,78 @@ +# Documentation for module LCWMermaidGenerator + +A module for simplifying the process of getting an access token from Entra ID + +| Metadata | Information | +| --- | --- | +| Version | 0.0.1 | +| Author | Marius Solbakken Mellum | +| Company name | Fortytwo Technologies AS | +| PowerShell version | 7.1 | + +## Invoke-LCWMermaidGenerator + +### SYNOPSIS +{{ Fill in the Synopsis }} + +### SYNTAX + +``` +Invoke-LCWMermaidGenerator [[-ReportPath] ] [-ProgressAction ] [] +``` + +### DESCRIPTION + + +### EXAMPLES + +#### Example 1 +```powershell +PS C:\> {{ Add example code here }} +``` + +{{ Add example description here }} + +### PARAMETERS + +#### -ReportPath + + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 0 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +#### -ProgressAction + + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +#### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +### INPUTS + +#### None +### OUTPUTS + +#### System.Object +### NOTES + +### RELATED LINKS diff --git a/LCWMermaidGenerator/LCWMermaidGenerator.psd1 b/LCWMermaidGenerator/LCWMermaidGenerator.psd1 index be720f5..9b86e6e 100644 --- a/LCWMermaidGenerator/LCWMermaidGenerator.psd1 +++ b/LCWMermaidGenerator/LCWMermaidGenerator.psd1 @@ -68,7 +68,7 @@ FunctionsToExport = '*' # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. - CmdletsToExport = '*' + CmdletsToExport = @('Invoke-LCWMermaidGenerator') # Variables to export from this module VariablesToExport = @()