diff --git a/.github/skills/document-powershell-functions/SKILL.md b/.github/skills/document-powershell-functions/SKILL.md new file mode 100644 index 00000000..57ead932 --- /dev/null +++ b/.github/skills/document-powershell-functions/SKILL.md @@ -0,0 +1,135 @@ +--- +name: document-powershell-functions +description: 'Write or complete PowerShell comment-based help. Use when a module contains top-level functions with no help, undocumented parameters, or parameter sets without examples. Preserve complete help and existing documented segments; add only missing documentation and syntax-check each changed file.' +argument-hint: 'Optionally specify files, folders, or function names' +user-invocable: true +--- + +# Document PowerShell Functions + +Create or complete comment-based help for PowerShell functions without rewriting documentation that already satisfies the requirements. + +## Scope + +- Process functions declared with the `function` keyword. +- Exclude functions nested inside another function. +- Respect any file, folder, module, or function scope supplied by the user. +- When no scope is supplied, inspect the current module or workspace surface relevant to the request. +- Change a function only when it has no comment-based help or when required coverage is missing. +- Leave a function's help unchanged when all required sections, parameters, and parameter sets are already documented. + +## Required Help + +Help created for a previously undocumented function contains only these sections: + +- `.SYNOPSIS`: one sentence summarizing what the function does. +- `.DESCRIPTION`: the command's purpose, behavior, filtering or output details, and other facts a user needs. +- `.PARAMETER `: one entry for every declared parameter, explaining its use. Also generate help for `-WhatIf` and `-Confirm` if the command implements ShouldProcess. +- `.EXAMPLE`: at least one example for every parameter set. Put the command first, followed by a blank line and prose explaining what executing it does. + +Do not add `.NOTES`, `.LINK`, `.INPUTS`, `.OUTPUTS`, or any other unrequested help section. + +## Style + +- Write for users of the command, not maintainers of its implementation. +- Keep the synopsis to one direct sentence. +- For Parameters listed in the help `Parameter Help Override` section below, do not invent your own help text and use the override text instead. +- Keep parameter descriptions concise. Do not narrate parameter-set selection unless that fact helps a user invoke the command correctly. Do not narrate whether it accepts input from the pipeline. +- Parameters that have a default value should explain this in a separate line with `Defaults to: {{value}}`. This does not apply to Switch parameters. +- Use concrete, realistic values in examples. +- Use `PS C:\>` as the example prompt. +- Explain the observable result of each example rather than merely repeating its syntax. +- Follow established wording for recurring parameters. For example, when most commands of a module use a `-ServiceMap`` parameter, prefer its existing documentation text and style over inventing a new variant. +- Match the indentation, line endings, spelling, terminology, and voice of nearby manually maintained help. + +## Procedure + +1. Inventory explicit `function` declarations in scope and identify nesting from the PowerShell AST or surrounding syntax. +2. Read each top-level function's current help, attributes, parameter block, parameter-set declarations, default parameter set, and behavior-controlling implementation. +3. Build a coverage list for each function: + - comment-based help is absent; + - `.SYNOPSIS` is absent or empty; + - `.DESCRIPTION` is absent or empty; + - a declared parameter has no matching `.PARAMETER` section or its section is empty; + - an explicitly assigned parameter default is not described; + - a parameter set has no `.EXAMPLE` that demonstrates a valid invocation and explains its result. +4. Skip functions whose coverage list is empty. +5. For a function with no help, add one complete help block containing only the required sections. +6. For partial help, make additive, localized changes only: + - preserve existing wording and formatting; + - add only missing or empty required sections; + - add only missing parameter documentation; + - add only examples needed for uncovered parameter sets; + - amend a parameter description only when needed to document a newly introduced explicit default; + - do not reorder, normalize, or rewrite already documented sections. +7. Place a new help block immediately beneath the line containing the `function` keyword and above attributes such as `[CmdletBinding()]` and the `param` block. Insert missing sections into an existing block without moving that block. +8. After completing all edits, run `.\tests\pester.ps1 -TestFunctions $false -Include Help*` to verify edits. A Test run without errors implies success. In case of error, use the test results to identify which functions' help needs fixing, fix the issue and test again. + +## Parameter-Set Coverage + +- Count named parameter sets declared through `ParameterSetName` and account for `DefaultParameterSetName`. +- Treat parameters without a `ParameterSetName` as common parameters available across sets. +- An example covers a set only when its arguments form a valid invocation of that set. +- A single example may cover only the set selected by its set-specific arguments. If it uses only common parameters and is ambiguous, count it for the default parameter set only. +- When no named parameter sets exist, require at least one example. + +## Preservation Rules + +- Never replace an entire existing help block merely to improve its prose. +- Never change a documented synopsis, description, parameter, or example unless it is empty, factually invalidated by the current function, or must be minimally amended to describe a new explicit default. +- Do not remove extra sections from pre-existing help during a coverage-only update; the instruction against extra sections applies to newly generated content. +- Do not document nested helper functions unless the user explicitly asks for them. +- Do not modify function behavior while documenting it. +- Do not change files that require no documentation additions. + +## Parameter Help Override + +When documenting the following parameters, always use the override text provided under the parameter's section, rather than inventing your own text. + +### WhatIf + +```text +If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. +``` + +### Confirm + +```text +If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. +``` + +### Cmdlet + +```text +The $PSCmdlet variable of the calling command, used to ensure errors and messages happen within the scope of the caller, hiding this internal helper command from the user. +``` + +### EnableException + +```text +This parameters disables user-friendly warnings and enables the throwing of exceptions. +This is less user friendly, but allows catching exceptions in calling scripts. +``` + +## Syntax Validation + +For each changed file, use the PowerShell parser and fail on parser errors. An equivalent validation is: + +```powershell +$tokens = $null +$errors = $null +$null = [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path $file), + [ref] $tokens, + [ref] $errors +) + +if ($errors.Count) { + $errors | ForEach-Object { Write-Error $_.Message } + throw "PowerShell syntax validation failed for $file" +} +``` + +Validation must run after the final edit of all updated files in scope. +Do not run validation earlz, before going through all files. +Do not claim syntax correctness based only on editor diagnostics or visual review. diff --git a/PSFramework/PSFramework.psd1 b/PSFramework/PSFramework.psd1 index b5ccab66..79c920e2 100644 --- a/PSFramework/PSFramework.psd1 +++ b/PSFramework/PSFramework.psd1 @@ -4,7 +4,7 @@ RootModule = 'PSFramework.psm1' # Version number of this module. - ModuleVersion = '1.14.457' + ModuleVersion = '1.15.472' # ID used to uniquely identify this module GUID = '8028b914-132b-431f-baa9-94a6952f21ff' @@ -42,6 +42,7 @@ 'Clear-PSFMessage' 'Clear-PSFresultCache' 'Compare-PSFArray' + 'ConvertFrom-PSFArray' 'ConvertFrom-PSFClixml' 'ConvertTo-PSFClixml' @@ -170,8 +171,10 @@ 'Test-PSFFeature' 'Test-PSFFilter' 'Test-PSFFunctionInterrupt' + 'Test-PSFHashtableEquality' 'Test-PSFLanguageMode' 'Test-PSFParameterBinding' + 'Test-PSFParameterSet' 'Test-PSFPowerShell' 'Test-PSFTaskEngineCache' 'Test-PSFTaskEngineTask' diff --git a/PSFramework/bin/PSFramework.dll b/PSFramework/bin/PSFramework.dll index 2d8de705..7c3e237a 100644 Binary files a/PSFramework/bin/PSFramework.dll and b/PSFramework/bin/PSFramework.dll differ diff --git a/PSFramework/bin/PSFramework.pdb b/PSFramework/bin/PSFramework.pdb index fd54525a..a8eb9821 100644 Binary files a/PSFramework/bin/PSFramework.pdb and b/PSFramework/bin/PSFramework.pdb differ diff --git a/PSFramework/bin/PSFramework.xml b/PSFramework/bin/PSFramework.xml index 91457488..344457d1 100644 --- a/PSFramework/bin/PSFramework.xml +++ b/PSFramework/bin/PSFramework.xml @@ -525,6 +525,21 @@ Enables adding empty keys for explicitly included keys that were not found on the input + + + Exclude keys whose value is null + + + + + Exclude keys whose value is an empty string + + + + + Exclude keys whose value is an empty collection + + Specifically designed for use in a command when passing through parameters with default values. @@ -3524,6 +3539,41 @@ Instance of the Logging Provider with the issue The Issue + + + Helper class used by the FileSystem logging provider to write data at max performance + + + + + The Path to where is written. Changing this recalculates converters and establishes a filestream + + + + + Size of the logfile. + Defaults to 0 if file not present. + Used to determine file-split based on size. + + + + + Writes a line to the logfile + + The line of text to write + + + + Writes an object to the logfile. + Will convert it to csv first. + + The item to write + + + + Clean up both converter and writer + + The data format to log to in the logfile logging provider @@ -3599,6 +3649,11 @@ Each logging provider has a name. Make sure it's unique + + + A description for the logging provider + + Whether the logging provider is enabled. Only when this is set to true will the logging script execute its events. @@ -8380,6 +8435,14 @@ Remove all content in the queue + + + Get all contents as an Array. + Possibly also clearing it out afterwards. + + If true, clear after reading as one threadsafe operation. + To content of the queued items + Take a look without removing anything @@ -12683,6 +12746,37 @@ How many levels to go down the script callstack Metadata describing who called + + + Compare two hashtables. + Value comparison uses C#-based comparison logic. + + First Hashtable to compare + Second Hashtable to compare + Whether the two tables are equal + + + + Compare two hashtables. + Value comparison uses PowerShell-based comparison logic. + + First Hashtable to compare + Second Hashtable to compare + Whether the comparison is case sensitive + Whether to recursively compare nested hashtables + If enabled, all entries in Table1 must be present in Table2, but not necessarily all entries in Table2 in Table1 + Whether the two tables are equal + + + + Checks, whether the specified parameters would resolve to one of the selected parameter sets. + Will return false, if the provided parameters would lead to an ambiguous parameter set, even if the specified sets would be among the still valid sets. + + The command for the parameters of which the test should be performed. + The names of the parameters specified. + The sets checked for. The resultant parameter set must be one of those specified. + Whether the specified parameters would resolve to one of the selected parameter sets + Stored scriptblocks that can be retrieved on demand anywhere within the process diff --git a/PSFramework/changelog.md b/PSFramework/changelog.md index 349198cf..296973aa 100644 --- a/PSFramework/changelog.md +++ b/PSFramework/changelog.md @@ -1,5 +1,23 @@ # CHANGELOG +## 1.15.472 (2026-10-23) + +- New: Logging Provider: Azure LogAnalytics V2 - sends logs to Log Analytics via Data Collection Rule +- New: Test-PSFParameterSet - Tests, whether the command specified would resolve to the specified parameter set with the parameters provided. +- New: Test-PSFHashtableEquality - Compares hashtables on a per-entry (key and value) basis. +- Upd: Logging Provider FileSystem - Significantly improved performance of builtin default debug log. +- Upd: ConvertTo-PSFHashtable - added `-ExcludeNull` parameter, to remove keys whose value is $null +- Upd: ConvertTo-PSFHashtable - added `-ExcludeEmptyString` parameter, to remove keys whose value is an empty string +- Upd: ConvertTo-PSFHashtable - added `-ExcludeEmptyCollection` parameter, to remove keys whose value is an empty collection +- Upd: Register-PSFLoggingProvider - added `-Description` parameter. +- Upd: Logging Providers - updated the `Get-ConfigValue` command available to Logging Providers to support `-Fallback` parameter, defining a fallback value in case the provider setting is $null. +- Fix: Compare-PSFArray - fails to accept empty arrays +- Fix: Read-PSFRunspaceQueue - the `-Continual` parameter will wait and immediately close +- Fix: Read-PSFRunspaceQueue - the `-Continual` parameter will not wait correctly even with the previous issue fixed +- Fix: Read-PSFRunspaceQueue - rare concurrency issue when reading all data from a queue that might have lead to a lost item +- Fix: Invoke-PSFRunspace - specifying a function as scriptblock leads to an error +- Fix: Set-PSFLoggingProvider - tries to register a Logging Provider from config when it doesn't contain a Name. + ## 1.14.457 (2026-07-02) - New: ConfigValidation rstimeout - Validates input as a legal setting for the Runspace Workflow Worker's timeout diff --git a/PSFramework/en-us/stringsUtility.psd1 b/PSFramework/en-us/stringsUtility.psd1 index d50c1211..8382e8ee 100644 --- a/PSFramework/en-us/stringsUtility.psd1 +++ b/PSFramework/en-us/stringsUtility.psd1 @@ -3,8 +3,8 @@ 'Import-PSFPowerShellDataFile.Error.Syntax' = 'File has an invalid PowerShell syntax: {0}' # $LiteralPath 'Import-PSFPowerShellDataFile.Error.Unsafe' = 'File is not safe to execute: {0}' # $LiteralPath - 'New-PSFSupportPackage.Error.PathNotFound' = 'Path not found: "$Path". Ensure the path exists or use -Force to create it.' # $Path - 'New-PSFSupportPackage.Header' = @" + 'New-PSFSupportPackage.Error.PathNotFound' = 'Path not found: "$Path". Ensure the path exists or use -Force to create it.' # $Path + 'New-PSFSupportPackage.Header' = @' Gathering information... Will write the final output to: {0} {1} @@ -13,26 +13,26 @@ Please make sure no sensitive data (such as passwords) can be caught this way. Ideally start a new console, perform the minimal steps required to reproduce the issue, then run this command. This will make it easier for us to troubleshoot and you won't be sending us the keys to your castle. -"@ # $filePathZip, (Get-PSFConfigValue -FullName 'psframework.supportpackage.contactmessage' -Fallback '') - 'New-PSFSupportPackage.Messages' = "Collecting PSFramework logged messages (Get-PSFMessage)" # - 'New-PSFSupportPackage.MsgErrors' = "Collecting PSFramework logged errors (Get-PSFMessage -Errors)" # - 'New-PSFSupportPackage.ConsoleBuffer' = "Trying to collect copy of console buffer (what you can see on your console)" # - 'New-PSFSupportPackage.OperatingSystem' = "Collecting Operating System information (Win32_OperatingSystem)" # - 'New-PSFSupportPackage.CPU' = "Collecting CPU information ({0})" # - 'New-PSFSupportPackage.RAM' = "Collecting Ram information ({0})" # +'@ # $filePathZip, (Get-PSFConfigValue -FullName 'psframework.supportpackage.contactmessage' -Fallback '') + 'New-PSFSupportPackage.Messages' = 'Collecting PSFramework logged messages (Get-PSFMessage)' # + 'New-PSFSupportPackage.MsgErrors' = 'Collecting PSFramework logged errors (Get-PSFMessage -Errors)' # + 'New-PSFSupportPackage.ConsoleBuffer' = 'Trying to collect copy of console buffer (what you can see on your console)' # + 'New-PSFSupportPackage.OperatingSystem' = 'Collecting Operating System information (Win32_OperatingSystem)' # + 'New-PSFSupportPackage.CPU' = 'Collecting CPU information ({0})' # + 'New-PSFSupportPackage.RAM' = 'Collecting Ram information ({0})' # 'New-PSFSupportPackage.PSVersion' = "Collecting PowerShell & .NET Version (`$PSVersionTable)" # - 'New-PSFSupportPackage.History' = "Collecting Input history (Get-History)" # - 'New-PSFSupportPackage.Modules' = "Collecting list of loaded modules (Get-Module)" # - 'New-PSFSupportPackage.Snapins' = "Collecting list of loaded snapins (Get-PSSnapin)" # - 'New-PSFSupportPackage.Assemblies' = "Collecting list of loaded assemblies (Name, Version, and Location)" # - 'New-PSFSupportPackage.Variables' = "Adding variables specified for export: {0}" # ($Variables -join ", ") + 'New-PSFSupportPackage.History' = 'Collecting Input history (Get-History)' # + 'New-PSFSupportPackage.Modules' = 'Collecting list of loaded modules (Get-Module)' # + 'New-PSFSupportPackage.Snapins' = 'Collecting list of loaded snapins (Get-PSSnapin)' # + 'New-PSFSupportPackage.Assemblies' = 'Collecting list of loaded assemblies (Name, Version, and Location)' # + 'New-PSFSupportPackage.Variables' = 'Adding variables specified for export: {0}' # ($Variables -join ", ") 'New-PSFSupportPackage.PSErrors' = "Adding content of `$Error" # - 'New-PSFSupportPackage.DbaTools.Messages' = "Collecting dbatools logged messages (Get-DbatoolsLog)" # - 'New-PSFSupportPackage.DbaTools.Errors' = "Collecting dbatools logged errors (Get-DbatoolsLog -Errors)" # - 'New-PSFSupportPackage.Export.Failed' = "Failed to export dump to file!" # + 'New-PSFSupportPackage.DbaTools.Messages' = 'Collecting dbatools logged messages (Get-DbatoolsLog)' # + 'New-PSFSupportPackage.DbaTools.Errors' = 'Collecting dbatools logged errors (Get-DbatoolsLog -Errors)' # + 'New-PSFSupportPackage.Export.Failed' = 'Failed to export dump to file!' # 'New-PSFSupportPackage.Extension.Collecting' = 'Collecting Extension Debugging Data: {0}' # $pair.Key 'New-PSFSupportPackage.Extension.Collecting.Failed' = 'Failed to collect Extension Debugging Data: {0}' # $pair.Key - 'New-PSFSupportPackage.ZipCompression.Failed' = "Failed to pack dump-file into a zip archive. Please do so manually before submitting the results as the unpacked xml file will be rather large." # + 'New-PSFSupportPackage.ZipCompression.Failed' = 'Failed to pack dump-file into a zip archive. Please do so manually before submitting the results as the unpacked xml file will be rather large.' # 'New-PSFThrottle.Error.InvalidLimit' = 'Invalid throttle limit: {0} / {1}' # $pair.Key, $pair.Value @@ -41,9 +41,13 @@ This will make it easier for us to troubleshoot and you won't be sending us the 'Resolve-PSFItem.Path.Found' = 'Found {1} items under {0}' # $entry, @($results).Count 'Resolve-PSFItem.Path.Summary' = 'Searched {0} paths, finding {1} items. {2} paths could not be resolved.' # (@($Path).Count + @($LiteralPath).Count), $foundItems.Count, $badPaths.Count - 'Resolve-PSFPath.Path.ParentExistsNot' = "Failed to resolve path" # - 'Resolve-PSFPath.Path.MultipleParents' = "Could not resolve to only a single parent path!" # - 'Resolve-PSFPath.Path.WrongProvider' = "Resolved provider is {0} when it should be {1}" # $parentPath.Provider.Name, $Provider - 'Resolve-PSFPath.Path.ExistsNot' = "Failed to resolve path" # - 'Resolve-PSFPath.Path.MultipleItems' = "Could not resolve to only a single path!" # + 'Resolve-PSFPath.Path.ParentExistsNot' = 'Failed to resolve path' # + 'Resolve-PSFPath.Path.MultipleParents' = 'Could not resolve to only a single parent path!' # + 'Resolve-PSFPath.Path.WrongProvider' = 'Resolved provider is {0} when it should be {1}' # $parentPath.Provider.Name, $Provider + 'Resolve-PSFPath.Path.ExistsNot' = 'Failed to resolve path' # + 'Resolve-PSFPath.Path.MultipleItems' = 'Could not resolve to only a single path!' # + + 'Test-PSFParameterSet.Error.FailedToResolve' = 'Unable to resolve command "{0}"! Check your spelling and consider using the "-Command" parameter instead, in case the command is not visible from within PSFramework.' # $Name + 'Test-PSFParameterSet.Error.AliasNullTarget' = 'Unable to resolve command! The Alias "{0}" points to "{1}" which could not be resolved. Ensure the Alias is configured correctly!' # $commandInfo.Name, , $commandInfo.Definition + 'Test-PSFParameterSet.Error.BadCommandType' = 'Unable to evaluate command "{0}". It is of type "{1}", but only Functions, Cmdlets or Aliases pointing at either can have their parametersets tested.' # $commandInfo.Name, $commandInfo.CommandType } \ No newline at end of file diff --git a/PSFramework/functions/logging/Import-PSFLoggingProvider.ps1 b/PSFramework/functions/logging/Import-PSFLoggingProvider.ps1 index 67695f25..54be34a7 100644 --- a/PSFramework/functions/logging/Import-PSFLoggingProvider.ps1 +++ b/PSFramework/functions/logging/Import-PSFLoggingProvider.ps1 @@ -32,7 +32,7 @@ A relative path would be relative to the path of the json file originally retrieved. If calling this command with the "-Data" parameter, relative paths are not supported. The scriptfile must be valid PowerShell code, however the original extension matters not. - The file will be run as untrusted code, so it will fail in Constraiend Language Mode, unless you sign the provider script with a whitelisted publisher certificate. + The file will be run as untrusted code, so it will fail in Constrained Language Mode, unless you sign the provider script with a whitelisted publisher certificate. # ProviderName #--------------- @@ -173,7 +173,7 @@ try { - if ($uri.Scheme -eq 'file') { [string]$dataReceived = Get-Content -Path $uri -ErrorAction Stop -Raw } + if ($uri.Scheme -eq 'file') { [string]$dataReceived = Get-PSFFileContent -Path $uri -ErrorAction Stop -ReadCount -1 } else { [string]$dataReceived = Invoke-WebRequest -Uri $uri -UseBasicParsing -ErrorAction Stop } } catch { throw } @@ -190,7 +190,7 @@ [System.IO.File]::WriteAllText($scriptPath, $dataReceived, $encoding) # Loading a file from within the module context runs the provider script from within that (trusted) context as well. - # This has various nasty consequences in Constrained language Mode + # This has various nasty consequences in Constrained Language Mode # We avoid this by rehoming the scriptblock to the global sessionstate $scriptBlock = { & $args[0] } [PSFramework.Utility.UtilityHost]::ImportScriptBlock($scriptBlock, $true) # $true = Import into global, rather than local sessionstate @@ -237,7 +237,7 @@ } foreach ($datum in $dataReceived) { - try { Import-ProviderData -Data $datum } + try { Import-ProviderData -Data $datum -ConfigPath $effectivePath } catch { Stop-PSFFunction -String 'Import-PSFLoggingProvider.Datum.Error' -EnableException $EnableException -ErrorRecord $_ -Continue -Target $datum } } } diff --git a/PSFramework/functions/logging/Register-PSFLoggingProvider.ps1 b/PSFramework/functions/logging/Register-PSFLoggingProvider.ps1 index 03d3a347..f902a33e 100644 --- a/PSFramework/functions/logging/Register-PSFLoggingProvider.ps1 +++ b/PSFramework/functions/logging/Register-PSFLoggingProvider.ps1 @@ -43,6 +43,10 @@ This reduces the complexity and improves the overall user experience while adding multi-instance capability to the service. All new providers should be built as version2 providers. Generation 1 legacy providers are still supported under the PSFramework Reliability Promise + + .PARAMETER Description + A description for your logging provider. + Helps users understand what the provider is meant for. .PARAMETER Enabled Setting this will enable the provider on registration. @@ -154,6 +158,9 @@ [Parameter(ParameterSetName = 'Version2')] [switch] $Version2, + + [string] + $Description, [switch] $Enabled, @@ -250,6 +257,7 @@ { $provider = New-Object PSFramework.Logging.Provider $provider.Name = $Name + $provider.Description = $Description $provider.BeginEvent = $BeginEvent $provider.StartEvent = $StartEvent $provider.MessageEvent = $MessageEvent @@ -301,6 +309,7 @@ $provider = New-Object PSFramework.Logging.ProviderV2 $provider.Name = $Name + $provider.Description = $Description $provider.ConfigurationRoot = $ConfigurationRoot.Trim('.') $provider.InstanceProperties = $InstanceProperties $provider.ConfigurationDefaultValues = $ConfigurationDefaultValues diff --git a/PSFramework/functions/logging/Set-PSFLoggingProvider.ps1 b/PSFramework/functions/logging/Set-PSFLoggingProvider.ps1 index ff398dec..7adb3e77 100644 --- a/PSFramework/functions/logging/Set-PSFLoggingProvider.ps1 +++ b/PSFramework/functions/logging/Set-PSFLoggingProvider.ps1 @@ -263,7 +263,7 @@ if ($null -eq $entry) { continue } $params = $entry | ConvertTo-PSFHashtable if (-not $params.Name) { - Stop-PSFFunction -String 'Set-PSFLoggingProvider.Error.ConfigNoName' -StringValues $params -EnableException $EnableException -Category InvalidArgument -Target $entry + Stop-PSFFunction -String 'Set-PSFLoggingProvider.Error.ConfigNoName' -StringValues $params -EnableException $EnableException -Category InvalidArgument -Target $entry -Continue } if ($params.Keys -notcontains 'Enabled') { $params.Enabled = $true } if ($Wait -and $entry -eq $last) { $params.Wait = $true } diff --git a/PSFramework/functions/runspace/Invoke-PSFRunspace.ps1 b/PSFramework/functions/runspace/Invoke-PSFRunspace.ps1 index 8fc5c843..efce55a8 100644 --- a/PSFramework/functions/runspace/Invoke-PSFRunspace.ps1 +++ b/PSFramework/functions/runspace/Invoke-PSFRunspace.ps1 @@ -162,7 +162,6 @@ } if ($pair.Value -is [ScriptBlock]) { $runspaceWrapper.AddFunction($pair.Key, $pair.Value) - $functionsResolved[$pair.Key] = $pair.Value continue } $runspaceWrapper.AddFunction($pair.Key, [scriptblock]::Create($pair.Value)) diff --git a/PSFramework/functions/runspace/Read-PSFRunspaceQueue.ps1 b/PSFramework/functions/runspace/Read-PSFRunspaceQueue.ps1 index d49c6ecb..b4635a37 100644 --- a/PSFramework/functions/runspace/Read-PSFRunspaceQueue.ps1 +++ b/PSFramework/functions/runspace/Read-PSFRunspaceQueue.ps1 @@ -82,8 +82,14 @@ #region Continual Streaming Mode if ($Continual) { - $queue = $resolvedWorkflow.Queues.$Name - while ($queue.Count -gt 1 -and -not $queue.Closed) { + $queue = @($resolvedWorkflows)[0].Queues.$Name + while ( + -not $queue.Closed -or + ( + $queue.Closed -and + $queue.Count -gt 0 + ) + ) { $result = $null $success = $queue.TryDequeue([ref]$result) if (-not $success) { @@ -99,9 +105,7 @@ foreach ($resolvedWorkflow in $resolvedWorkflows) { if ($All) { # Cache Results so downstream pipeline commands do not interfere with the clearing - $results = $resolvedWorkflow.Queues.$Name.ToArray() - if (-not $Peek) { $resolvedWorkflow.Queues.$Name.Clear() } - $results + $resolvedWorkflow.Queues.$Name.ToArray((-not $Clear)) continue } if ($Peek) { $result = $resolvedWorkflow.Queues.$Name.Peek() } diff --git a/PSFramework/functions/utility/Compare-PSFArray.ps1 b/PSFramework/functions/utility/Compare-PSFArray.ps1 index 9a12344c..cfd1f410 100644 --- a/PSFramework/functions/utility/Compare-PSFArray.ps1 +++ b/PSFramework/functions/utility/Compare-PSFArray.ps1 @@ -33,10 +33,12 @@ [CmdletBinding()] param ( [Parameter(Mandatory = $true, Position = 0)] + [AllowEmptyCollection()] [object[]] $ReferenceObject, [Parameter(Mandatory = $true, Position = 1)] + [AllowEmptyCollection()] [object[]] $DifferenceObject, diff --git a/PSFramework/functions/utility/Test-PSFHashtableEquality.ps1 b/PSFramework/functions/utility/Test-PSFHashtableEquality.ps1 new file mode 100644 index 00000000..cc0534f9 --- /dev/null +++ b/PSFramework/functions/utility/Test-PSFHashtableEquality.ps1 @@ -0,0 +1,60 @@ +function Test-PSFHashtableEquality { + <# + .SYNOPSIS + Compares hashtables on a per-entry (key and value) basis. + + .DESCRIPTION + Compares hashtables on a per-entry basis. + It ensures both keys and values are equal as PowerShell considers equality. + + .PARAMETER Table1 + The first hashtable to compare. + + .PARAMETER Table2 + The second hashtable to compare. + + .PARAMETER CaseSensitive + Whether the value-comparison is case sensitive. + + .PARAMETER Recurse + Whether nested hashtables should be compared. + Beware: Recursive nesting can lead to an infinite depth recursion. + + .PARAMETER OneDirectional + If enabled, all entries in $Table1 must be present in $Table2, but not necessarily all entries in Table2 in Table1 + By default, all entries in both hashtables must match the other hashtable's. + + .EXAMPLE + PS C:\> Test-PSFHashtableEquality -Table1 $param -Table2 $oldParam + + Returns whether $param is equal to $oldParam on a per-entry key & value basis. + + .EXAMPLE + PS C:\> Test-PSFHashtableEquality -Table1 $param -Table2 $oldParam -Recurse + + Returns whether $param is equal to $oldParam on a per-entry key & value basis. + #> + [OutputType([bool])] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [hashtable] + $Table1, + + [Parameter(Mandatory = $true)] + [hashtable] + $Table2, + + [switch] + $CaseSensitive, + + [switch] + $Recurse, + + [switch] + $OneDirectional + ) + process { + [PSFramework.Utility.UtilityHost]::ComparePS($Table1, $Table2, $CaseSensitive.ToBool(), $Recurse.ToBool(), $OneDirectional.ToBool()) + } +} \ No newline at end of file diff --git a/PSFramework/functions/utility/Test-PSFParameterSet.ps1 b/PSFramework/functions/utility/Test-PSFParameterSet.ps1 new file mode 100644 index 00000000..f62ba892 --- /dev/null +++ b/PSFramework/functions/utility/Test-PSFParameterSet.ps1 @@ -0,0 +1,90 @@ +function Test-PSFParameterSet { + <# + .SYNOPSIS + Tests, whether the command specified would resolve to the specified parameter set with the parameters provided. + + .DESCRIPTION + Tests, whether the command specified would resolve to the specified parameter set with the parameters provided. + This compares the parametersets on the command metadata with the names of the parameters you intend to provide, + then resolves the effective parameterset they would lead to. + + Then compares them to the list of Parametersets provided and returns, whether the resolved parameterset would be one of them. + + Note: + - If the command would not resolve to a legal parameterset, it will return $false. + - If the command would not be able to resolve to exactly one parameterset, it will return $false. This takes the DefaultParameterSetName into account. This is true even if the remaining Parametersets would include one or multiple of the looked-for Parametersets. + + In consequence, this command cannot take input by pipeline into account (but specifying the name you would bind by pipeline as if explicit should work in most scenarios). + This command also cannot reliably evaluate dynamic parameters or Parametersets, as those are determined at runtime, not from Metadata. + + .PARAMETER Name + Name of the command to test Parametersets for. + WARNING: This may easily fail, if PSFramework cannot find the command (e.g. an internal command of another module). + For best reliability, use the "-Command" parameter instead. + + .PARAMETER Command + The command object to evaluate. + Expects an object as returned by "Get-Command". + This is the more reliable option as opposed to "-Name" and generally preferred, if slightly less convenient to use. + + .PARAMETER Parameters + The names of the parameters provided to the command. + These are used to determine, which parameterset would be viable. + + .PARAMETER Sets + The names of the Parametersets to check for. + If the effective Parameterset is any one of them the command returns $true. + + .EXAMPLE + PS C:\> Test-PSFParameterSet -Name Connect-EntraService -Parameters $param.Keys -Sets 'Browser','DeviceCode' + + Returns whether with the specified parameters, Connect-EntraService would end up using either the 'DeviceCode' or 'Browser' Parameterset. + + .EXAMPLE + PS C:\> Test-PSFParameterSet -Command (Get-Command -Name Resolve-TaskCredential) -Parameters $param.Keys -Sets 'Certificate' + + Returns whether with the specified parameters, Resolve-TaskCredential would end up using the 'Certificate' Parameterset. + As the caller is resolving Resolve-TaskCredential before calling Test-PSFParameterSet, the command is resolved within the context of the caller and may be invisible to PSFramework without causing any issues. + #> + [OutputType([bool])] + [CmdletBinding(DefaultParameterSetName = 'ByName')] + param ( + [Parameter(Mandatory = $true, ParameterSetName = 'ByName')] + [string] + $Name, + + [Parameter(Mandatory = $true, ParameterSetName = 'ByCommand')] + [System.Management.Automation.CommandInfo] + $Command, + + [Parameter(Mandatory = $true)] + [string[]] + $Parameters, + + [Parameter(Mandatory = $true)] + [string[]] + $Sets + ) + process { + if ($Command) { + $commandInfo = $Command + } + else { + try { $commandInfo = Get-Command -Name $Name -ErrorAction Stop } + catch { + Stop-PSFFunction -String 'Test-PSFParameterSet.Error.FailedToResolve' -StringValues $Name -EnableException $true -Cmdlet $PSCmdlet -Target $Name + } + } + if ($commandInfo -is [System.Management.Automation.AliasInfo]) { + $commandInfo = $commandInfo.ResolvedCommand + } + if ($null -eq $commandInfo) { + Stop-PSFFunction -String 'Test-PSFParameterSet.Error.AliasNullTarget' -StringValues $commandInfo.Name, $commandInfo.Definition -EnableException $true -Cmdlet $PSCmdlet -Target $commandInfo + } + if ($commandInfo -isnot [System.Management.Automation.CmdletInfo] -and $commandInfo -isnot [System.Management.Automation.FunctionInfo]) { + Stop-PSFFunction -String 'Test-PSFParameterSet.Error.BadCommandType' -StringValues $commandInfo.Name, $commandInfo.CommandType -EnableException $true -Cmdlet $PSCmdlet -Target $commandInfo + } + + [PSFramework.Utility.UtilityHost]::TestParameterSetAny($commandInfo, $Parameters, $Sets) + } +} \ No newline at end of file diff --git a/PSFramework/internal/loggingProviders/azureloganalytics.provider.ps1 b/PSFramework/internal/loggingProviders/azureloganalytics.provider.ps1 index 70f38088..ad144a62 100644 --- a/PSFramework/internal/loggingProviders/azureloganalytics.provider.ps1 +++ b/PSFramework/internal/loggingProviders/azureloganalytics.provider.ps1 @@ -60,10 +60,10 @@ $FunctionDefinitions = { $bodyAsJson = ConvertTo-Json $loggingMessage -Compress $body = [System.Text.Encoding]::UTF8.GetBytes($bodyAsJson) - $restMethod = "POST" - $restContentType = "application/json" - $restResource = "/api/logs" - $date = [DateTime]::UtcNow.ToString("r") + $restMethod = 'POST' + $restContentType = 'application/json' + $restResource = '/api/logs' + $date = [DateTime]::UtcNow.ToString('r') $contentLength = $body.Length $signatureArgs = @{ @@ -81,10 +81,10 @@ $FunctionDefinitions = { # RestAPI headers $headers = @{ - "Authorization" = $signature - "Log-Type" = $logType - "x-ms-date" = $date - "time-generated-field" = "TimeStamp" + 'Authorization' = $signature + 'Log-Type' = $logType + 'x-ms-date' = $date + 'time-generated-field' = 'TimeStamp' } try { @@ -93,23 +93,23 @@ $FunctionDefinitions = { switch ($webResponse.StatusCode) { '400' { switch ($webResponse.StatusDescription) { - 'InactiveCustomer' { throw "Sucessful Post to Azure Workspace" } - 'InvalidApiVersion' { throw "The API version that you specified was not recognized by the service." } - 'InvalidCustomerId' { throw "The workspace ID specified is invalid." } - 'InvalidDataFormat' { throw "Invalid JSON was submitted. The response body might contain more information about how to resolve the error." } - 'InvalidLogType' { throw "The log type specified contained special characters or numerics." } + 'InactiveCustomer' { throw 'Sucessful Post to Azure Workspace' } + 'InvalidApiVersion' { throw 'The API version that you specified was not recognized by the service.' } + 'InvalidCustomerId' { throw 'The workspace ID specified is invalid.' } + 'InvalidDataFormat' { throw 'Invalid JSON was submitted. The response body might contain more information about how to resolve the error.' } + 'InvalidLogType' { throw 'The log type specified contained special characters or numerics.' } 'MissingApiVersion' { throw "The API version wasn't specified." } 'MissingContentType' { throw "The content type wasn't specified." } 'MissingLogType' { throw "The required value log type wasn't specified." } - 'UnsupportedContentType' { throw "The content type was not set to application/json." } + 'UnsupportedContentType' { throw 'The content type was not set to application/json.' } } } - '403' { throw "The service failed to authenticate the request. Verify that the workspace ID and connection key are valid." } - '404' { throw "Either the URL provided is incorrect, or the request is too large." } - '429' { throw "The service is experiencing a high volume of data from your account. Please retry the request later." } - '500' { throw "The service encountered an internal error. Please retry the request." } - '503' { throw "The service currently is unavailable to receive requests. Please retry your request." } + '403' { throw 'The service failed to authenticate the request. Verify that the workspace ID and connection key are valid.' } + '404' { throw 'Either the URL provided is incorrect, or the request is too large.' } + '429' { throw 'The service is experiencing a high volume of data from your account. Please retry the request later.' } + '500' { throw 'The service encountered an internal error. Please retry the request.' } + '503' { throw 'The service currently is unavailable to receive requests. Please retry your request.' } } } catch { throw } @@ -171,7 +171,7 @@ $FunctionDefinitions = { ) process { - $xHeaders = "x-ms-date:" + $DateAndTime + $xHeaders = 'x-ms-date:' + $DateAndTime $stringToHash = $RestMethod + "`n" + $ContentLength + "`n" + $RestContentType + "`n" + $xHeaders + "`n" + $RestResource $bytesToHash = [Text.Encoding]::UTF8.GetBytes($stringToHash) $keyBytes = [Convert]::FromBase64String($sharedKey) @@ -245,17 +245,18 @@ $message_event = { # Configuration values for the logging provider $configuration_Settings = { - Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalytics.WorkspaceId' -Value "" -Initialize -Validation 'secret' -Description "WorkspaceId for the Azure Workspace we are logging our data objects to." - Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalytics.SharedKey' -Value "" -Initialize -Validation 'secret' -Description "SharedId for the Azure Workspace we are logging our data objects to." - Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalytics.LogType' -Value "Message" -Initialize -Validation 'string' -Description "Log type we will log information to." - Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalytics.TimeFormat' -Value "" -Initialize -Validation 'string' -Description "Format timestamps will be written with." - Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalytics.Headers' -Value 'Message', 'Timestamp', 'Level', 'Tags', 'Data', 'ComputerName', 'Runspace', 'UserName', 'ModuleName', 'FunctionName', 'File', 'CallStack', 'TargetObject', 'ErrorRecord' -Initialize -Validation 'stringarray' -Description "The properties of the message to log." + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalytics.WorkspaceId' -Value '' -Initialize -Validation 'secret' -Description 'WorkspaceId for the Azure Workspace we are logging our data objects to.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalytics.SharedKey' -Value '' -Initialize -Validation 'secret' -Description 'SharedId for the Azure Workspace we are logging our data objects to.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalytics.LogType' -Value 'Message' -Initialize -Validation 'string' -Description 'Log type we will log information to.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalytics.TimeFormat' -Value '' -Initialize -Validation 'string' -Description 'Format timestamps will be written with.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalytics.Headers' -Value 'Message', 'Timestamp', 'Level', 'Tags', 'Data', 'ComputerName', 'Runspace', 'UserName', 'ModuleName', 'FunctionName', 'File', 'CallStack', 'TargetObject', 'ErrorRecord' -Initialize -Validation 'stringarray' -Description 'The properties of the message to log.' } # Registered parameters for the logging provider. # ConfigurationDefaultValues are used for all instances of the azure logging provider $paramRegisterPSFAzureLogAnalyticsProvider = @{ - Name = "AzureLogAnalytics" + Name = 'AzureLogAnalytics' + Description = '[Deprecated] Logs to Azure Log Analytics through the legacy Data Collector API' Version2 = $true ConfigurationRoot = 'PSFramework.Logging.AzureLogAnalytics' InstanceProperties = 'WorkspaceId', 'SharedKey', 'LogType', 'TimeFormat', 'Headers' diff --git a/PSFramework/internal/loggingProviders/azureloganalytics2.provider.ps1 b/PSFramework/internal/loggingProviders/azureloganalytics2.provider.ps1 new file mode 100644 index 00000000..1ea916c1 --- /dev/null +++ b/PSFramework/internal/loggingProviders/azureloganalytics2.provider.ps1 @@ -0,0 +1,318 @@ +#region Functions +$functionDefinitions = { + function Update-LAToken { + [CmdletBinding()] + param () + + trap { + [PSFramework.PSFCore.PSFCoreHost]::WriteDebug('Error Getting Tokens', @{ Config = $config; Error = $_; Param = $param; ActualParam = $actualParam }) + throw $_ + } + + $param = @{} + $eaParam = 'ClientID', 'TenantID', 'Scopes', 'Browser', 'RedirectUri', 'RefreshToken', 'RefreshTokenObject', 'Certificate', 'CertificateThumbprint', 'CertificateName', 'CertificatePath', 'CertificatePassword', 'ClientSecret', 'Credential', 'VaultName', 'SecretName', 'Identity', 'IdentityID', 'IdentityType', 'FallBackAzAccount', 'AsAzAccount', 'ShowDialog', 'Federated', 'FederationProvider', 'Assertion', 'Environment', 'AuthenticationUrl', 'AzureToken', 'MonitorToken' + $config = Select-PSFConfig -FullName "$($script:configRoot).*"| Where-Object _FullName -eq $script:configRoot + foreach ($paramName in $eaParam) { + if ($config.$paramName -notin $null, '', @()) { + $param.$paramName = $config.$paramName + } + } + + $isEqual = [PSFramework.Utility.UtilityHost]::ComparePS( + $param, + $script:eaConfig, + $false, # CaseSensitive + $false, # Recursive + $false # Monodirectional + ) + if ($isEqual) { return } + + [PSFramework.PSFCore.PSFCoreHost]::WriteDebug('Recalculating Tokens', @{ New = $param; Old = $script:eaConfig }) + + $actualParam = $param.Clone() + # Cleanup to make sure they are not passed through, but used to determine whether a reconnect should be done (above). + $actualParam.Remove('AzureToken') + $actualParam.Remove('MonitorToken') + + $paramCount = $actualParam.Count + + if (-not $actualParam.ClientID -and -not (Test-PSFParameterSet -Command $script:eaConnectCmd -Parameters $actualParam.Keys -Sets 'RefreshObject', 'Identity', 'AzAccount')) { $actualParam.ClientID = 'Azure' } + if ($actualParam.CertificatePassword -and $actualParam.CertificatePassword -is [string]) { $actualParam.CertificatePassword = $actualParam.CertificatePassword | ConvertTo-SecureString -AsPlainText -Force } + if ($actualParam.CertificatePassword -and $actualParam.CertificatePassword -is [PSCredential]) { $actualParam.CertificatePassword = $actualParam.CertificatePassword.Password } + if ($actualParam.ClientSecret -and $actualParam.ClientSecret -is [string]) { $actualParam.ClientSecret = $actualParam.ClientSecret | ConvertTo-SecureString -AsPlainText -Force } + if ($actualParam.ClientSecret -and $actualParam.ClientSecret -is [PSCredential]) { $actualParam.ClientSecret = $actualParam.ClientSecret.Password } + + $token = $null + $script:eaToken = $null + + if ($config.AzureToken) { + if ('Azure' -ne $config.AzureToken.Service) { Write-Error -Message "Invalid Azure Token | Expected Service: Azure | Received Service: $($config.AzureToken.Service)" -TargetObject $config.AzureToken -ErrorAction Stop } + + try { Import-EntraToken -Token $config.AzureToken -NoRenew -ErrorAction Stop } + catch { Write-Error -Message "Failed to import Azure Token: $_" -TargetObject $config.AzureToken -ErrorAction Stop } + + $azureToken = Get-EntraToken -Service Azure + + # The bare minimum needed to reuse a RefreshToken, or reuse a Certificate/ClientSecret from the Azure token + if ($paramCount -lt 1) { $actualParam.ClientID = $azureToken.ClientID } + if ($paramCount -lt 1) { $actualParam.TenantID = $azureToken.TenantID } + + <# + In case we only specify an Azure token and not a Monitor token. + + - Delegate flows use the refresh token + - Identity flows have no need to specify a token to begin with + #> + if ($azureToken.Certificate -and $paramCount -lt 1) { $actualParam.Certificate = $azureToken.Certificate } + if ($azureToken.ClientSecret -and $paramCount -lt 1) { $actualParam.ClientSecret = $azureToken.ClientSecret } + } + else { + try { Connect-EntraService @actualParam -Service Azure -ErrorAction Stop } + catch { throw } + } + + if (Test-PSFParameterSet -Command $script:eaConnectCmd -Parameters $actualParam.Keys -Sets 'Browser', 'DeviceCode') { + $actualParam.UseRefreshToken = $true + } + + if ($config.MonitorToken) { + try { $token = Import-EntraToken -Token $config.MonitorToken -NoRenew -PassThru -ErrorAction Stop } + catch { Write-Error -Message "Failed to import Azure Monitor Token: $_" -TargetObject $config.MonitorToken -ErrorAction Stop } + } + else { + try { $token = Connect-EntraService @actualParam -Resource 'https://monitor.azure.com/' -ErrorAction Stop } + catch { throw } + } + + if ($token) { + $script:eaToken = $token + $script:eaConfig = $param + } + } + + function Update-LAWorkspacePath { + [CmdletBinding()] + param () + + $param = @{ + Subscription = Get-ConfigValue -Name Subscription + ResourceGroup = Get-ConfigValue -Name ResourceGroup + Stream = Get-ConfigValue -Name Stream + DcrName = Get-ConfigValue -Name DcrName + DceName = Get-ConfigValue -Name DceName + Table = Get-ConfigValue -Name Table + } + + $isEqual = [PSFramework.Utility.UtilityHost]::ComparePS( + $param, # Table1 + $script:laConfig, # Table2 + $false, # CaseSensitive + $false, # Recursive + $false # Monodirectional + ) + if ($isEqual) { return } + + if (-not $param.DcrName) { throw 'No Data Collection Rule (DcrName) specified!' } + if (-not $param.DceName) { throw 'No Data Collection Endpoint (DceName) specified!' } + + [PSFramework.PSFCore.PSFCoreHost]::WriteDebug('Recalculating LA Request Path', @{ New = $param; Old = $script:laConfig }) + + $script:laConfig = @{} + $script:laPath = '' + + try { + $dceObject = Invoke-EntraRequest -Service Azure -Path "subscriptions/$($param.Subscription)/resourceGroups/$($param.ResourceGroup)/providers/Microsoft.Insights/dataCollectionEndpoints/$($param.DceName)" -Query @{ + 'api-version' = '2024-03-11' + } + } + catch { throw "Failed to resolve Data Collection Endpoint $($param.DceName): $_`nPath: subscriptions/$($param.Subscription)/resourceGroups/$($param.ResourceGroup)/providers/Microsoft.Insights/dataCollectionEndpoints/$($param.DceName)" } + + try { + $dcrObject = Invoke-EntraRequest -Service Azure -Path "subscriptions/$($param.Subscription)/resourceGroups/$($param.ResourceGroup)/providers/Microsoft.Insights/dataCollectionRules/$($param.DcrName)" -Query @{ + 'api-version' = '2024-03-11' + } + } + catch { throw "Failed to resolve Data Collection Rule $($param.DcrName): $_`nPath: subscriptions/$($param.Subscription)/resourceGroups/$($param.ResourceGroup)/providers/Microsoft.Insights/dataCollectionRules/$($param.DcrName)" } + + $streamName = $null + if ($param.Stream) { $streamName = $param.Stream } + else { + $streams = $dcrObject.properties.dataFlows.streams | Sort-Object -Unique + if (@($streams).Count -eq 1) { + $streamName = $($streams) + } + elseif ($param.Table -and @($streams | Where-Object { $_ -match $param.Table }).Count -eq 1) { + $streamName = $streams | Where-Object { $_ -match $param.Table } + } + } + + if (-not $streamName) { throw "Unable to resolve stream for $($param.Table) in $($param.DcrName)" } + + $script:laConfig = $param + + $script:laPath = "$($dceObject.properties.logsIngestion.endpoint)/dataCollectionRules/$($dcrObject.properties.ImmutableId)/streams/$streamName" + } + + function Write-LAMessage { + [CmdletBinding()] + param ( + [PSFramework.Message.LogEntry] + $Message, + + [int] + $BatchSize, + + [System.Collections.ArrayList] + $Messages, + + [switch] + $Flush + ) + + # If we failed to calculate the path to push log analytics data, no point in attempting to do so + # The failed Start phase handles the error logging, no point flooding it with extra-messages to muddle the water. + if (-not $script:laPath) { + return + } + + if ($null -ne $Message) { $null = $Messages.Add($Message) } + if (-not $Flush -and $Messages.Count -lt $BatchSize) { + return + } + if ($Flush -and $Messages.Count -lt 1) { return } + + $payload = $Messages | Select-PSFObject -Property $script:laHeaders + $script:lastPayload = $payload + $payloadJson = ConvertTo-Json -InputObject @($payload) -Compress -Depth 3 + $Messages.Clear() + Invoke-EntraRequest -Method Post -Path $script:laPath -Token $script:eaToken -Query @{ + 'api-version' = '2023-01-01' + } -ContentType 'application/json' -Body $payloadJson -Header @{ charset = 'utf-8' } + } +} +#endregion Functions +#region Main Events +$begin_event = { + $modulePath = Join-Path -Path ([PSFramework.PSFCore.PSFCoreHost]::ModuleRoot) -ChildPath 'modules/EntraAuth/*/EntraAuth.psd1' + Import-Module -Name (Get-Item -Path $modulePath).FullName -ErrorAction Stop + + $script:configRoot = $script:Instance.ConfigurationRoot + if ($script:Instance.Name -and $script:Instance.Name -ne 'Default') { + $script:configRoot += ".$($script:Instance.Name)" + } + + $script:eaToken = $null + $script:eaConfig = @{} + $script:laConfig = @{} + $script:laPath = '' + $script:laHeaders = @() + $script:laLastHeaders = @() + $script:eaConnectCmd = Get-Command Connect-EntraService +} +$start_event = { + trap { + throw $_ + } + $script:messages = [System.Collections.ArrayList]@() + $script:batchsize = Get-ConfigValue -Name BatchSize + if (-not $script:batchsize -or $script:batchsize -lt 1) { + $script:batchsize = 25 + } + $newHeaders = Get-ConfigValue -Name Headers + if (-not [System.Management.Automation.LanguagePrimitives]::Equals($newHeaders, $script:laLastHeaders)) { + [PSFramework.PSFCore.PSFCoreHost]::WriteDebug('Recalculating Headers', @{ New = $newHeaders; Old = $script:laLastHeaders }) + $script:laHeaders = $newHeaders | ForEach-Object { + if ($_ -is [hashtable]) { return $_ } + + if ( + $_ -notmatch '^[a-zA-Z\d]+$' -and + $_ -notmatch '^[a-zA-Z\d]+ as [a-zA-Z\d]+$' -and + $_ -notmatch '^[a-zA-Z\d]+ as [a-zA-Z\d]+ to (string|int)$' + ) { throw "Unsupported Header format: $_ | Cannot use special letters, access subproperties or invoke methods or typeconvert to anything other than string or int" } + + switch -regex ($_) { + '^Message$' { $_ -replace 'Message', 'LogMessage as Message'; break } + '^Message' { $_ -replace '^Message', 'LogMessage' } + '^Timestamp$' { 'Timestamp.ToUniversalTime().ToString("o") as Timestamp' } + '^Level$' { 'Level to String' } + '^Callstack$' { 'Callstack to String' } + default { $_ } + } + } + $script:laLastHeaders = $newHeaders + } + + Update-LAToken -ErrorAction Stop + Update-LAWorkspacePath +} +$message_event = { + param ( + $Message + ) + + Write-LAMessage -Message $Message -BatchSize $script:batchsize -Messages $script:messages +} +$end_event = { + Write-LAMessage -Flush -Messages $script:messages +} +#endregion Main Events + +# Configuration values for the logging provider +$configuration_Settings = { + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.Subscription' -Value '' -Initialize -Validation string -Description 'Mandatory. The Subscription (by its ID, not name) the Data Collection Endpoint & Rule are in. Both must be in the same Resource Group.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.ResourceGroup' -Value '' -Initialize -Validation string -Description 'Mandatory. The resource Group the Data Collection Endpoint & Rule are in. Both must be in the same Resource Group.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.DceName' -Value '' -Initialize -Validation string -Description 'Mandatory. Name of the Data Collection Endpoint determining the request url used.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.DcrName' -Value '' -Initialize -Validation string -Description 'Mandatory. Name of the Data Collection Rule to send data to.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.Stream' -Value '' -Initialize -Validation string -Description 'The specific name of the Data Stream on the Data Collection Rule to send data to. Overrides Automatic Stream selection or selection by Table name.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.Table' -Value '' -Initialize -Validation string -Description 'The name of the table in Azure Log Analytics. Optional, used as tie breaker if the Data Collection Rule has multiple dataflows / streams to select from. By default, if the DCR only has one Data Flow configured, this can be omitted.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.Headers' -Value 'Message', 'Timestamp', 'Level', 'Tags', 'Data', 'ComputerName', 'Runspace', 'UserName', 'ModuleName', 'FunctionName', 'File', 'Line', 'CallStack', 'TargetObject', 'ErrorRecord' -Initialize -Validation stringarray -Description 'The properties on the messages to push to Log Analytics table. Supports renaming by specifying properties like this: "Timestamp as Time"' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.BatchSize' -Value 25 -Initialize -Validation integerpositive -Description 'Up to how many messages are uploaded in one request. Larger improves throughput but may lead to lost requests when sending too much data.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.ClientID' -Value $null -Initialize -Validation string -Description 'Authentication Info. The Client ID of the Application to use for authentication. Defaults to the same application used for Connect-AzAccount.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.TenantID' -Value $null -Initialize -Validation string -Description 'Authentication Info. The Tenant ID of the Application to use for authentication. Defaults to the tenant of the user if in delegate mode, must be specified in Application mode, other than for MSI authentication.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.Browser' -Value $null -Initialize -Validation bool -Description 'Authentication Info. Explicitly authenticate in Delegate Authorization Code flow - will use the default browser to have you log in as yourself.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.RedirectUri' -Value $null -Initialize -Validation string -Description 'Authentication Info. Override the automatic redirect to localhost in Authorization Code flow. Usually needs not be adjusted.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.RefreshToken' -Value $null -Initialize -Validation string -Description 'Authentication Info. A refresh token (as string) to use for authenticating by reusing an existing token.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.RefreshTokenObject' -Value $null -Initialize -Description 'Authentication Info. A resfresh token (as EntraAuth token object) to use for authenticating by reusing an existing token.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.Certificate' -Value $null -Initialize -Description 'Authentication Info. A certificate (as X509Certificate2 object) to use for authenticating in application mode.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.CertificateThumbprint' -Value $null -Initialize -Validation string -Description 'Authentication Info. A certificate (selected by its thumbprint from your windows certificate stores) to use for authenticating in application mode.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.CertificateName' -Value $null -Initialize -Validation string -Description 'Authentication Info. A certificate (selected by its CN from your windows certificate stores) to use for authenticating in application mode.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.CertificatePath' -Value $null -Initialize -Validation string -Description 'Authentication Info. Path to .pfx file containing the certificate to use for authenticating in application mode. Use together with CertificatePassword' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.CertificatePassword' -Value $null -Initialize -Validation secret -Description 'Authentication Info. Password to the .pfx file containing the certificate to use for authenticating in application mode. Use together with CertificatePath' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.ClientSecret' -Value $null -Initialize -Validation secret -Description 'Authentication Info. The clientsecret to use for authenticating in application mode.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.Identity' -Value $null -Initialize -Validation bool -Description 'Authentication Info. Authenticate as the Managed Identity - System-Managed or User-Assigned. Will default to System-Managed unloss also specifying an IdentityID' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.IdentityID' -Value $null -Initialize -Validation string -Description 'Authentication Info. Id of the System-Assigned identity to use. Use together with IdentityType to determine whether it is a ClientID, PrincipalID or ResourceID.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.IdentityType' -Value $null -Initialize -Validation string -Description 'Authentication Info. Use together with IdentityID to specify whether the user-assigned identity is a ClientID, PrincipalID or ResourceID.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.FallBackAzAccount' -Value $null -Initialize -Validation bool -Description 'Authentication Info. Fallback to using the currently established AzContext from Connect-AzAccount. Intended for use with Identity & Federated flows as a local fallback when testing on your own machine.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.AsAzAccount' -Value $null -Initialize -Validation bool -Description 'Authentication Info. Reuse the connection established with Connect-AzAccount. Requires the connection having previously established.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.ShowDialog' -Value $null -Initialize -Validation string -Description 'Authentication Info. When using the AzAccount session, should the interactive UI be shown? "auto": Only when needed. "always": Show no matter what. "never": Don''t show, but will error the logging if required.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.Federated' -Value $null -Initialize -Validation bool -Description 'Authentication Info. Use Federated credentials to authenticate. Use when running on a system supporting Managed Identities but your organization requires the use of an App Registration & Enterprise Application.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.FederationProvider' -Value $null -Initialize -Validation string -Description 'Authentication Info. Which Federation Provider to use. Overrides the default autodetection.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.Assertion' -Value $null -Initialize -Validation string -Description 'Authentication Info. The credentials from the federated identity provider to use in an Federated Credentials authentication flow. Highly optional.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.Environment' -Value $null -Initialize -Validation string -Description 'Authentication Info. What environment the tenant runs in. Defaults to "Global", options: Global, USGov, USGovDOD, China.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.AuthenticationUrl' -Value $null -Initialize -Validation string -Description 'Authentication Info. The url used for the authentication requests to retrieve tokens. Generally provided by the Environment parameter, but can be overridden in case of need.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.AzureToken' -Value $null -Initialize -Description 'Authentication Info. A previously established token using Connect-EntraService to the "Azure" service, NOT a plain access token string.' + Set-PSFConfig -Module PSFramework -Name 'Logging.AzureLogAnalyticsV2.MonitorToken' -Value $null -Initialize -Description 'Authentication Info. A previously established token using Connect-EntraService to the "https://monitor.azure.com/" resource (or environment-specific alternatives), NOT a plain access token string.' +} + +# Registered parameters for the logging provider. +# ConfigurationDefaultValues are used for all instances of the azure logging provider +$paramRegisterPSFAzureLogAnalyticsProvider = @{ + Name = 'AzureLogAnalyticsV2' + Version2 = $true + Description = 'Logs to Azure Log Analytics through the Logs Ingestion API' + ConfigurationRoot = 'PSFramework.Logging.AzureLogAnalyticsV2' + InstanceProperties = 'Subscription', 'ResourceGroup', 'DceName', 'DcrName', 'Stream', 'Table', 'Headers', 'BatchSize', 'ClientID', 'TenantID', 'Browser', 'RedirectUri', 'RefreshToken', 'RefreshTokenObject', 'Certificate', 'CertificateThumbprint', 'CertificateName', 'CertificatePath', 'CertificatePassword', 'ClientSecret', 'Identity', 'IdentityID', 'IdentityType', 'FallBackAzAccount', 'AsAzAccount', 'ShowDialog', 'Federated', 'FederationProvider', 'Assertion', 'Environment', 'AuthenticationUrl', 'AzureToken', 'MonitorToken' + BeginEvent = $begin_event + StartEvent = $start_event + MessageEvent = $message_Event + EndEvent = $end_event + ConfigurationSettings = $configuration_Settings + FunctionDefinitions = $functionDefinitions + ConfigurationDefaultValues = @{ + Headers = 'Message', 'Timestamp', 'Level', 'Tags', 'Data', 'ComputerName', 'Runspace', 'UserName', 'ModuleName', 'FunctionName', 'File', 'Line', 'CallStack', 'TargetObject', 'ErrorRecord' + BatchSize = 25 + } +} + +# Register the Azure logging provider +Register-PSFLoggingProvider @paramRegisterPSFAzureLogAnalyticsProvider \ No newline at end of file diff --git a/PSFramework/internal/loggingProviders/console.provider.ps1 b/PSFramework/internal/loggingProviders/console.provider.ps1 index a155d048..b21750b3 100644 --- a/PSFramework/internal/loggingProviders/console.provider.ps1 +++ b/PSFramework/internal/loggingProviders/console.provider.ps1 @@ -3,7 +3,7 @@ $Message ) $style = Get-ConfigValue -Name Style - $string = $style.Replace('%Time%',$Message.Timestamp.ToString('HH:mm:ss.fff')).Replace('%Date%',$Message.Timestamp.ToString('yyyy-MM-dd')).Replace('%Level%', $Message.Level).Replace('%Module%', $Message.ModuleName).Replace('%FunctionName%', $Message.FunctionName).Replace('%Line%', $Message.Line).Replace('%File%', $Message.File).Replace('%Tags%', ($Message.Tags -join ",")).Replace('%Message%', $Message.LogMessage) + $string = $style.Replace('%Time%', $Message.Timestamp.ToString('HH:mm:ss.fff')).Replace('%Date%', $Message.Timestamp.ToString('yyyy-MM-dd')).Replace('%Level%', $Message.Level).Replace('%Module%', $Message.ModuleName).Replace('%FunctionName%', $Message.FunctionName).Replace('%Line%', $Message.Line).Replace('%File%', $Message.File).Replace('%Tags%', ($Message.Tags -join ',')).Replace('%Message%', $Message.LogMessage) [System.Console]::WriteLine($string) } @@ -11,12 +11,13 @@ $configuration_Settings = { Set-PSFConfig -Module 'PSFramework' -Name 'Logging.Console.Style' -Value '%Message%' -Initialize -Validation string -Description 'The style in which the message is printed. Supports several placeholders: %Message%, %Time%, %Date%, %Tags%, %Level%, %Module%, %FunctionName%, %Line%, %File%. Supports newline and tabs.' } $paramRegisterPSFLoggingProvider = @{ - Name = "console" - Version2 = $true - ConfigurationRoot = 'PSFramework.Logging.Console' - InstanceProperties = 'Style' - MessageEvent = $message_Event - ConfigurationSettings = $configuration_Settings + Name = 'console' + Description = 'Writes messages to the console screen. Intended for use in orchestration tools that report what is written to screen.' + Version2 = $true + ConfigurationRoot = 'PSFramework.Logging.Console' + InstanceProperties = 'Style' + MessageEvent = $message_Event + ConfigurationSettings = $configuration_Settings ConfigurationDefaultValues = @{ Style = '%Message%' } diff --git a/PSFramework/internal/loggingProviders/eventlog.provider.ps1 b/PSFramework/internal/loggingProviders/eventlog.provider.ps1 index a129b6c0..c616a252 100644 --- a/PSFramework/internal/loggingProviders/eventlog.provider.ps1 +++ b/PSFramework/internal/loggingProviders/eventlog.provider.ps1 @@ -36,7 +36,7 @@ $Message.Timestamp.ToUniversalTime().ToString((Get-ConfigValue -Name TimeFormat)) $Message.FunctionName $Message.ModuleName - ($Message.Tags -join ",") + ($Message.Tags -join ',') $Message.Level $Message.Runspace $Message.TargetObject @@ -139,7 +139,8 @@ $message_event = { } $paramRegisterPSFLoggingProvider = @{ - Name = "eventlog" + Name = 'eventlog' + Description = 'Sends messages to the Windows Eventlog on the local computer.' Version2 = $true ConfigurationRoot = 'PSFramework.Logging.EventLog' InstanceProperties = 'LogName', 'Source', 'UseFallback', 'Category', 'InfoID', 'WarningID', 'ErrorID', 'ErrorTag', 'TimeFormat', 'NumericTagAsID', 'TagToID' diff --git a/PSFramework/internal/loggingProviders/filesystem.provider.ps1 b/PSFramework/internal/loggingProviders/filesystem.provider.ps1 index 5e56a0c0..b2e58136 100644 --- a/PSFramework/internal/loggingProviders/filesystem.provider.ps1 +++ b/PSFramework/internal/loggingProviders/filesystem.provider.ps1 @@ -7,10 +7,9 @@ $registrationEvent = { # Action that is performed when starting the logging script (or the very first time if enabled after launching the logging script) $begin_event = { #region Helper Functions - function Clean-FileSystemErrorXml - { + function Clean-FileSystemErrorXml { [CmdletBinding()] - Param ( + param ( $Path ) @@ -20,8 +19,7 @@ $begin_event = { if (([PSFramework.Message.LogHost]::MaxErrorFileBytes) -gt $totalLength) { return } $removed = 0 - foreach ($file in $files) - { + foreach ($file in $files) { $removed += $file.Length Remove-Item -Path $file.FullName -Force -Confirm:$false @@ -29,10 +27,9 @@ $begin_event = { } } - function Clean-FileSystemMessageLog - { + function Clean-FileSystemMessageLog { [CmdletBinding()] - Param ( + param ( $Path ) @@ -42,8 +39,7 @@ $begin_event = { if (([PSFramework.Message.LogHost]::MaxMessagefileCount) -ge $files.Count) { return } $removed = 0 - foreach ($file in $files) - { + foreach ($file in $files) { $removed++ Remove-Item -Path $file.FullName -Force -Confirm:$false @@ -51,10 +47,9 @@ $begin_event = { } } - function Clean-FileSystemGlobalLog - { + function Clean-FileSystemGlobalLog { [CmdletBinding()] - Param ( + param ( $Path ) @@ -69,8 +64,7 @@ $begin_event = { if (([PSFramework.Message.LogHost]::MaxTotalFolderSize) -gt $totalLength) { return } $removed = 0 - foreach ($file in $files) - { + foreach ($file in $files) { $removed += $file.Length Remove-Item -Path $file.FullName -Force -Confirm:$false @@ -80,7 +74,7 @@ $begin_event = { #endregion Helper Functions $filesystem_SelectTargetObject = @{ - Name = 'TargetObject' + Name = 'TargetObject' Expression = { if ($null -eq $_.TargetObject) { return } if ([PSFramework.Message.LogHost]::FileSystemSerializationDepth -lt 0) { return $_.TargetObject } @@ -89,69 +83,68 @@ $begin_event = { } } $filesystem_SelectTimestamp = @{ - Name = 'Timestamp' + Name = 'Timestamp' Expression = { $_.Timestamp.ToString([PSFramework.Message.LogHost]::TimeFormat) } } + + $filesystem_selectproperties = 'ComputerName', 'Timestamp', 'Level', 'LogMessage as Message', 'Type', 'FunctionName', 'ModuleName', 'File', 'Line', @{ n = 'Tags'; e = { $_.Tags -join ',' } }, $filesystem_SelectTargetObject, 'Runspace' + if ([PSFramework.Message.LogHost]::FileSystemModernLog) { + $filesystem_selectproperties = 'ComputerName', 'Username', $filesystem_SelectTimestamp, 'Level', 'LogMessage as Message', 'Type', 'FunctionName', 'ModuleName', 'File', 'Line', @{ n = 'Tags'; e = { $_.Tags -join ',' } }, $filesystem_SelectTargetObject, 'Runspace', @{ n = 'Callstack'; e = { $_.CallStack.ToString().Split("`n") -join ' þ ' } } + } + $filesystem_Selecter = { Select-PSFObject -Property $filesystem_selectproperties }.GetSteppablePipeline() + $filesystem_Selecter.Begin($true) + + $filesystem_oldRootPath = $null + $filesystem_writer = New-Object PSFramework.Logging.FileSystemWriter } # Action that is performed at the beginning of each logging cycle $start_event = { $filesystem_path = [PSFramework.Message.LogHost]::LoggingPath - if (-not (Test-Path $filesystem_path)) - { - $filesystem_root = New-Item $filesystem_path -ItemType Directory -Force -ErrorAction Stop + if ($filesystem_oldRootPath -ne $filesystem_path) { + if (-not (Test-Path $filesystem_path)) { + $filesystem_root = New-Item $filesystem_path -ItemType Directory -Force -ErrorAction Stop + } + else { $filesystem_root = Get-Item -Path $filesystem_path } + + try { [int]$filesystem_num_Error = (Get-ChildItem -Path $filesystem_root.FullName -Filter "$($env:ComputerName)_$($pid)_error_*.xml" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern '(\d+)' -AllMatches).Matches[-1].Value } + catch { } + try { [int]$filesystem_num_Message = (Get-ChildItem -Path $filesystem_root.FullName -Filter "$($env:ComputerName)_$($pid)_message_*.log" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern '(\d+)' -AllMatches).Matches[-1].Value } + catch { } + if (-not ($filesystem_num_Error)) { $filesystem_num_Error = 0 } + if (-not ($filesystem_num_Message)) { $filesystem_num_Message = 0 } + $filesystem_CurrentFile = Join-Path $filesystem_root.FullName "$($env:ComputerName)_$($pid)_message_$($filesystem_num_Message).log" + $filesystem_writer.Path = $filesystem_CurrentFile + $filesystem_oldRootPath = $filesystem_path } - else { $filesystem_root = Get-Item -Path $filesystem_path } - - try { [int]$filesystem_num_Error = (Get-ChildItem -Path $filesystem_root.FullName -Filter "$($env:ComputerName)_$($pid)_error_*.xml" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern "(\d+)" -AllMatches).Matches[-1].Value } - catch { } - try { [int]$filesystem_num_Message = (Get-ChildItem -Path $filesystem_root.FullName -Filter "$($env:ComputerName)_$($pid)_message_*.log" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern "(\d+)" -AllMatches).Matches[-1].Value } - catch { } - if (-not ($filesystem_num_Error)) { $filesystem_num_Error = 0 } - if (-not ($filesystem_num_Message)) { $filesystem_num_Message = 0 } } # Action that is performed for each message item that is being logged $message_Event = { - Param ( + param ( $Message ) + + if (-not $Message) { return } - $filesystem_CurrentFile = Join-Path $filesystem_root.FullName "$($env:ComputerName)_$($pid)_message_$($filesystem_num_Message).log" - if (Test-Path $filesystem_CurrentFile) - { - $filesystem_item = Get-Item $filesystem_CurrentFile - if ($filesystem_item.Length -gt ([PSFramework.Message.LogHost]::MaxMessagefileBytes)) - { - $filesystem_num_Message++ - $filesystem_CurrentFile = Join-Path $($filesystem_root.FullName) "$($env:ComputerName)_$($pid)_message_$($filesystem_num_Message).log" - } - } - - if ($Message) - { - if ([PSFramework.Message.LogHost]::FileSystemModernLog) - { - if (-not (Test-Path $filesystem_CurrentFile)) - { - $Message | Select-PSFObject ComputerName, Username, $filesystem_SelectTimestamp, Level, 'LogMessage as Message', Type, FunctionName, ModuleName, File, Line, @{ n = "Tags"; e = { $_.Tags -join "," } }, $filesystem_SelectTargetObject, Runspace, @{ n = "Callstack"; e = { $_.CallStack.ToString().Split("`n") -join " þ "} } | Export-Csv -Path $filesystem_CurrentFile -NoTypeInformation - } - else { Add-Content -Path $filesystem_CurrentFile -Value (ConvertTo-Csv ($Message | Select-PSFObject ComputerName, Username, $filesystem_SelectTimestamp, Level, 'LogMessage as Message', Type, FunctionName, ModuleName, File, Line, @{ n = "Tags"; e = { $_.Tags -join "," } }, $filesystem_SelectTargetObject, Runspace, @{ n = "Callstack"; e = { $_.CallStack.ToString().Split("`n") -join " þ " } }) -NoTypeInformation)[1] } - } - else { Add-Content -Path $filesystem_CurrentFile -Value (ConvertTo-Csv ($Message | Select-PSFObject ComputerName, Timestamp, Level, 'LogMessage as Message', Type, FunctionName, ModuleName, File, Line, @{ n = "Tags"; e = { $_.Tags -join "," } }, $filesystem_SelectTargetObject, Runspace) -NoTypeInformation)[1] } + if ($filesystem_writer.Size -gt ([PSFramework.Message.LogHost]::MaxMessagefileBytes)) { + $filesystem_num_Message++ + $filesystem_CurrentFile = Join-Path $($filesystem_root.FullName) "$($env:ComputerName)_$($pid)_message_$($filesystem_num_Message).log" + $filesystem_writer.Path = $filesystem_CurrentFile } + + $filesystem_writer.WriteObject($filesystem_Selecter.Process($Message)[0]) } # Action that is performed for each error item that is being logged $error_Event = { - Param ( + param ( $ErrorItem ) - if ($ErrorItem) - { + if ($ErrorItem) { $ErrorItem | Export-Clixml -Path (Join-Path $filesystem_root.FullName "$($env:ComputerName)_$($pid)_error_$($filesystem_num_Error).xml") -Depth 3 $filesystem_num_Error++ } @@ -167,43 +160,41 @@ $end_event = { # Action that is performed when stopping the logging script $final_event = { - + $filesystem_Selecter.End() + if ($filesystem_writer) { $filesystem_writer.End() } } #endregion Logging Execution #region Function Extension / Integration # Script that generates the necessary dynamic parameter for Set-PSFLoggingProvider $configurationParameters = { - $configroot = "PSFramework.Logging.FileSystem" + $configroot = 'PSFramework.Logging.FileSystem' $configurations = Get-PSFConfig -FullName "$configroot.*" $RuntimeParamDic = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary - foreach ($config in $configurations) - { + foreach ($config in $configurations) { $ParamAttrib = New-Object System.Management.Automation.ParameterAttribute $ParamAttrib.ParameterSetName = '__AllParameterSets' $AttribColl = New-Object System.Collections.ObjectModel.Collection[System.Attribute] $AttribColl.Add($ParamAttrib) - $RuntimeParam = New-Object System.Management.Automation.RuntimeDefinedParameter(($config.FullName.Replace($configroot, "").Trim(".")), $config.Value.GetType(), $AttribColl) + $RuntimeParam = New-Object System.Management.Automation.RuntimeDefinedParameter(($config.FullName.Replace($configroot, '').Trim('.')), $config.Value.GetType(), $AttribColl) - $RuntimeParamDic.Add(($config.FullName.Replace($configroot, "").Trim(".")), $RuntimeParam) + $RuntimeParamDic.Add(($config.FullName.Replace($configroot, '').Trim('.')), $RuntimeParam) } return $RuntimeParamDic } # Script that is executes when configuring the provider using Set-PSFLoggingProvider $configurationScript = { - $configroot = "PSFramework.Logging.FileSystem" + $configroot = 'PSFramework.Logging.FileSystem' $configurations = Get-PSFConfig -FullName "$configroot.*" - foreach ($config in $configurations) - { - if ($PSBoundParameters.ContainsKey(($config.FullName.Replace($configroot, "").Trim(".")))) - { - Set-PSFConfig -Module $config.Module -Name $config.Name -Value $PSBoundParameters[($config.FullName.Replace($configroot, "").Trim("."))] + foreach ($config in $configurations) { + if ($PSBoundParameters.ContainsKey(($config.FullName.Replace($configroot, '').Trim('.')))) { + Set-PSFConfig -Module $config.Module -Name $config.Name -Value $PSBoundParameters[($config.FullName.Replace($configroot, '').Trim('.'))] } } } @@ -226,40 +217,41 @@ $installationScript = { # Configuration settings to initialize $configuration_Settings = { - Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MaxMessagefileBytes' -Value 5MB -Initialize -Validation "long" -Handler { [PSFramework.Message.LogHost]::MaxMessagefileBytes = $args[0] } -Description "The maximum size of a given logfile. When reaching this limit, the file will be abandoned and a new log created. Set to 0 to not limit the size. This setting is on a per-Process basis. Runspaces share, jobs or other consoles counted separately." - Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MaxMessagefileCount' -Value 5 -Initialize -Validation "integerpositive" -Handler { [PSFramework.Message.LogHost]::MaxMessagefileCount = $args[0] } -Description "The maximum number of logfiles maintained at a time. Exceeding this number will cause the oldest to be culled. Set to 0 to disable the limit. This setting is on a per-Process basis. Runspaces share, jobs or other consoles counted separately." - Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MaxErrorFileBytes' -Value 20MB -Initialize -Validation "long" -Handler { [PSFramework.Message.LogHost]::MaxErrorFileBytes = $args[0] } -Description "The maximum size all error files combined may have. When this number is exceeded, the oldest entry is culled. This setting is on a per-Process basis. Runspaces share, jobs or other consoles counted separately." - Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MaxTotalFolderSize' -Value 100MB -Initialize -Validation "long" -Handler { [PSFramework.Message.LogHost]::MaxTotalFolderSize = $args[0] } -Description "This is the upper limit of length all items in the log folder may have combined across all processes." - Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MaxLogFileAge' -Value (New-TimeSpan -Days 7) -Initialize -Validation "timespan" -Handler { [PSFramework.Message.LogHost]::MaxLogFileAge = $args[0] } -Description "Any logfile older than this will automatically be cleansed. This setting is global." - Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MessageLogFileEnabled' -Value $true -Initialize -Validation "bool" -Handler { [PSFramework.Message.LogHost]::MessageLogFileEnabled = $args[0] } -Description "Governs, whether a log file for the system messages is written. This setting is on a per-Process basis. Runspaces share, jobs or other consoles counted separately." - Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.ErrorLogFileEnabled' -Value $true -Initialize -Validation "bool" -Handler { [PSFramework.Message.LogHost]::ErrorLogFileEnabled = $args[0] } -Description "Governs, whether log files for errors are written. This setting is on a per-Process basis. Runspaces share, jobs or other consoles counted separately." - Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.ModernLog' -Value $false -Initialize -Validation "bool" -Handler { [PSFramework.Message.LogHost]::FileSystemModernLog = $args[0] } -Description "Enables the modern, more powereful version of the filesystem log, including headers and extra columns" - Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.LogPath' -Value $script:path_Logging -Initialize -Validation "string" -Handler { [PSFramework.Message.LogHost]::LoggingPath = $args[0] } -Description "The path where the PSFramework writes all its logs and debugging information." - Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.TimeFormat' -Value "$([System.Globalization.CultureInfo]::CurrentUICulture.DateTimeFormat.ShortDatePattern) $([System.Globalization.CultureInfo]::CurrentUICulture.DateTimeFormat.LongTimePattern)" -Initialize -Validation string -Handler { [PSFramework.Message.LogHost]::TimeFormat = $args[0] } -Description "The format used for timestamps in the logfile" - Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.TargetSerializationDepth' -Value -1 -Initialize -Validation "integer" -Handler { [PSFramework.Message.LogHost]::FileSystemSerializationDepth = $args[0] } -Description "Whether the target object should be stored as a serialized object. 0 or less will see it logged as string, 1 or greater will see it logged as compressed CLIXML." + Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MaxMessagefileBytes' -Value 5MB -Initialize -Validation 'long' -Handler { [PSFramework.Message.LogHost]::MaxMessagefileBytes = $args[0] } -Description 'The maximum size of a given logfile. When reaching this limit, the file will be abandoned and a new log created. Set to 0 to not limit the size. This setting is on a per-Process basis. Runspaces share, jobs or other consoles counted separately.' + Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MaxMessagefileCount' -Value 5 -Initialize -Validation 'integerpositive' -Handler { [PSFramework.Message.LogHost]::MaxMessagefileCount = $args[0] } -Description 'The maximum number of logfiles maintained at a time. Exceeding this number will cause the oldest to be culled. Set to 0 to disable the limit. This setting is on a per-Process basis. Runspaces share, jobs or other consoles counted separately.' + Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MaxErrorFileBytes' -Value 20MB -Initialize -Validation 'long' -Handler { [PSFramework.Message.LogHost]::MaxErrorFileBytes = $args[0] } -Description 'The maximum size all error files combined may have. When this number is exceeded, the oldest entry is culled. This setting is on a per-Process basis. Runspaces share, jobs or other consoles counted separately.' + Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MaxTotalFolderSize' -Value 100MB -Initialize -Validation 'long' -Handler { [PSFramework.Message.LogHost]::MaxTotalFolderSize = $args[0] } -Description 'This is the upper limit of length all items in the log folder may have combined across all processes.' + Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MaxLogFileAge' -Value (New-TimeSpan -Days 7) -Initialize -Validation 'timespan' -Handler { [PSFramework.Message.LogHost]::MaxLogFileAge = $args[0] } -Description 'Any logfile older than this will automatically be cleansed. This setting is global.' + Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.MessageLogFileEnabled' -Value $true -Initialize -Validation 'bool' -Handler { [PSFramework.Message.LogHost]::MessageLogFileEnabled = $args[0] } -Description 'Governs, whether a log file for the system messages is written. This setting is on a per-Process basis. Runspaces share, jobs or other consoles counted separately.' + Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.ErrorLogFileEnabled' -Value $true -Initialize -Validation 'bool' -Handler { [PSFramework.Message.LogHost]::ErrorLogFileEnabled = $args[0] } -Description 'Governs, whether log files for errors are written. This setting is on a per-Process basis. Runspaces share, jobs or other consoles counted separately.' + Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.ModernLog' -Value $false -Initialize -Validation 'bool' -Handler { [PSFramework.Message.LogHost]::FileSystemModernLog = $args[0] } -Description 'Enables the modern, more powereful version of the filesystem log, including headers and extra columns' + Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.LogPath' -Value $script:path_Logging -Initialize -Validation 'string' -Handler { [PSFramework.Message.LogHost]::LoggingPath = $args[0] } -Description 'The path where the PSFramework writes all its logs and debugging information.' + Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.TimeFormat' -Value "$([System.Globalization.CultureInfo]::CurrentUICulture.DateTimeFormat.ShortDatePattern) $([System.Globalization.CultureInfo]::CurrentUICulture.DateTimeFormat.LongTimePattern)" -Initialize -Validation string -Handler { [PSFramework.Message.LogHost]::TimeFormat = $args[0] } -Description 'The format used for timestamps in the logfile' + Set-PSFConfig -Module PSFramework -Name 'Logging.FileSystem.TargetSerializationDepth' -Value -1 -Initialize -Validation 'integer' -Handler { [PSFramework.Message.LogHost]::FileSystemSerializationDepth = $args[0] } -Description 'Whether the target object should be stored as a serialized object. 0 or less will see it logged as string, 1 or greater will see it logged as compressed CLIXML.' - Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.Enabled' -Value $true -Initialize -Validation "bool" -Handler { if ([PSFramework.Logging.ProviderHost]::Providers['filesystem']) { [PSFramework.Logging.ProviderHost]::Providers['filesystem'].Enabled = $args[0] } } -Description "Whether the logging provider should be enabled on registration" - Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.AutoInstall' -Value $false -Initialize -Validation "bool" -Handler { } -Description "Whether the logging provider should be installed on registration" - Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.InstallOptional' -Value $true -Initialize -Validation "bool" -Handler { } -Description "Whether installing the logging provider is mandatory, in order for it to be enabled" - Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.IncludeModules' -Value @() -Initialize -Validation "stringarray" -Handler { if ([PSFramework.Logging.ProviderHost]::Providers['filesystem']) { [PSFramework.Logging.ProviderHost]::Providers['filesystem'].IncludeModules = ($args[0] | Write-Output) } } -Description "Module whitelist. Only messages from listed modules will be logged" - Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.ExcludeModules' -Value @() -Initialize -Validation "stringarray" -Handler { if ([PSFramework.Logging.ProviderHost]::Providers['filesystem']) { [PSFramework.Logging.ProviderHost]::Providers['filesystem'].ExcludeModules = ($args[0] | Write-Output) } } -Description "Module blacklist. Messages from listed modules will not be logged" - Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.IncludeTags' -Value @() -Initialize -Validation "stringarray" -Handler { if ([PSFramework.Logging.ProviderHost]::Providers['filesystem']) { [PSFramework.Logging.ProviderHost]::Providers['filesystem'].IncludeTags = ($args[0] | Write-Output) } } -Description "Tag whitelist. Only messages with these tags will be logged" - Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.ExcludeTags' -Value @() -Initialize -Validation "stringarray" -Handler { if ([PSFramework.Logging.ProviderHost]::Providers['filesystem']) { [PSFramework.Logging.ProviderHost]::Providers['filesystem'].ExcludeTags = ($args[0] | Write-Output) } } -Description "Tag blacklist. Messages with these tags will not be logged" + Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.Enabled' -Value $true -Initialize -Validation 'bool' -Handler { if ([PSFramework.Logging.ProviderHost]::Providers['filesystem']) { [PSFramework.Logging.ProviderHost]::Providers['filesystem'].Enabled = $args[0] } } -Description 'Whether the logging provider should be enabled on registration' + Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.AutoInstall' -Value $false -Initialize -Validation 'bool' -Handler { } -Description 'Whether the logging provider should be installed on registration' + Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.InstallOptional' -Value $true -Initialize -Validation 'bool' -Handler { } -Description 'Whether installing the logging provider is mandatory, in order for it to be enabled' + Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.IncludeModules' -Value @() -Initialize -Validation 'stringarray' -Handler { if ([PSFramework.Logging.ProviderHost]::Providers['filesystem']) { [PSFramework.Logging.ProviderHost]::Providers['filesystem'].IncludeModules = ($args[0] | Write-Output) } } -Description 'Module whitelist. Only messages from listed modules will be logged' + Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.ExcludeModules' -Value @() -Initialize -Validation 'stringarray' -Handler { if ([PSFramework.Logging.ProviderHost]::Providers['filesystem']) { [PSFramework.Logging.ProviderHost]::Providers['filesystem'].ExcludeModules = ($args[0] | Write-Output) } } -Description 'Module blacklist. Messages from listed modules will not be logged' + Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.IncludeTags' -Value @() -Initialize -Validation 'stringarray' -Handler { if ([PSFramework.Logging.ProviderHost]::Providers['filesystem']) { [PSFramework.Logging.ProviderHost]::Providers['filesystem'].IncludeTags = ($args[0] | Write-Output) } } -Description 'Tag whitelist. Only messages with these tags will be logged' + Set-PSFConfig -Module LoggingProvider -Name 'FileSystem.ExcludeTags' -Value @() -Initialize -Validation 'stringarray' -Handler { if ([PSFramework.Logging.ProviderHost]::Providers['filesystem']) { [PSFramework.Logging.ProviderHost]::Providers['filesystem'].ExcludeTags = ($args[0] | Write-Output) } } -Description 'Tag blacklist. Messages with these tags will not be logged' } $paramRegisterPSFLoggingProvider = @{ - Name = "filesystem" - RegistrationEvent = $registrationEvent - BeginEvent = $begin_event - StartEvent = $start_event - MessageEvent = $message_Event - ErrorEvent = $error_Event - EndEvent = $end_event - FinalEvent = $final_event + Name = 'filesystem' + Description = 'The default debuglog, enabled by default.' + RegistrationEvent = $registrationEvent + BeginEvent = $begin_event + StartEvent = $start_event + MessageEvent = $message_Event + ErrorEvent = $error_Event + EndEvent = $end_event + FinalEvent = $final_event ConfigurationParameters = $configurationParameters - ConfigurationScript = $configurationScript - IsInstalledScript = $isInstalledScript - InstallationScript = $installationScript + ConfigurationScript = $configurationScript + IsInstalledScript = $isInstalledScript + InstallationScript = $installationScript InstallationParameters = $installationParameters ConfigurationSettings = $configuration_Settings } diff --git a/PSFramework/internal/loggingProviders/gelf.provider.ps1 b/PSFramework/internal/loggingProviders/gelf.provider.ps1 index 821a2e93..3d0a4ea3 100644 --- a/PSFramework/internal/loggingProviders/gelf.provider.ps1 +++ b/PSFramework/internal/loggingProviders/gelf.provider.ps1 @@ -3,7 +3,7 @@ $start_event = { $script:paramSendPsgelfTcp = @{ 'GelfServer' = Get-ConfigValue -Name 'GelfServer' - 'Port' = Get-ConfigValue -Name 'Port' + 'Port' = Get-ConfigValue -Name 'Port' 'Encrypt' = Get-ConfigValue -Name 'Encrypt' } } @@ -19,8 +19,7 @@ $message_Event = { $gelf_params['HostName'] = $Message.ComputerName $gelf_params['DateTime'] = $Message.Timestamp - $gelf_params['Level'] = switch ($Message.Level) - { + $gelf_params['Level'] = switch ($Message.Level) { 'Critical' { 1 } 'Important' { 1 } 'Output' { 3 } @@ -34,8 +33,7 @@ $message_Event = { default { 7 } } - if ($Message.ErrorRecord) - { + if ($Message.ErrorRecord) { $gelf_params['FullMessage'] = $Message.ErrorRecord | ConvertTo-Json } @@ -45,8 +43,7 @@ $message_Event = { } $gelf_params['AdditionalField'] = @{ } - foreach ($gelf_property in $gelf_properties) - { + foreach ($gelf_property in $gelf_properties) { $gelf_params['AdditionalField'][$gelf_property.Name] = $gelf_property.Value } @@ -65,12 +62,12 @@ $installationParameters = { $validateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute('CurrentUser', 'AllUsers') $attributesCollection.Add($validateSetAttribute) - $runtimeParam = New-Object System.Management.Automation.RuntimeDefinedParameter("Scope", [string], $attributesCollection) - $results.Add("Scope", $runtimeParam) + $runtimeParam = New-Object System.Management.Automation.RuntimeDefinedParameter('Scope', [string], $attributesCollection) + $results.Add('Scope', $runtimeParam) $attributesCollection2 = New-Object System.Collections.ObjectModel.Collection[System.Attribute] - $runtimeParam2 = New-Object System.Management.Automation.RuntimeDefinedParameter("Repository", [string], $attributesCollection2) - $results.Add("Repository", $runtimeParam2) + $runtimeParam2 = New-Object System.Management.Automation.RuntimeDefinedParameter('Repository', [string], $attributesCollection2) + $results.Add('Repository', $runtimeParam2) $results } @@ -97,22 +94,23 @@ $isInstalled_script = { # Configuration settings to initialize $configuration_Settings = { - Set-PSFConfig -Module PSFramework -Name 'Logging.GELF.GelfServer' -Value "" -Initialize -Validation string -Description "The GELF server to send logs to" - Set-PSFConfig -Module PSFramework -Name 'Logging.GELF.Port' -Value "" -Initialize -Validation string -Description "The port number the GELF server listens on" - Set-PSFConfig -Module PSFramework -Name 'Logging.GELF.Encrypt' -Value $true -Initialize -Validation bool -Description "Whether to use TLS encryption when communicating with the GELF server" + Set-PSFConfig -Module PSFramework -Name 'Logging.GELF.GelfServer' -Value '' -Initialize -Validation string -Description 'The GELF server to send logs to' + Set-PSFConfig -Module PSFramework -Name 'Logging.GELF.Port' -Value '' -Initialize -Validation string -Description 'The port number the GELF server listens on' + Set-PSFConfig -Module PSFramework -Name 'Logging.GELF.Encrypt' -Value $true -Initialize -Validation bool -Description 'Whether to use TLS encryption when communicating with the GELF server' } $paramRegisterPSFLoggingProvider = @{ - Name = "gelf" - Version2 = $true - ConfigurationRoot = 'PSFramework.Logging.GELF' - InstanceProperties = 'GelfServer', 'Port', 'Encrypt' - StartEvent = $start_event - MessageEvent = $message_Event - IsInstalledScript = $isInstalled_script - InstallationScript = $installation_script - InstallationParameters = $installationParameters - ConfigurationSettings = $configuration_Settings + Name = 'gelf' + Description = 'Log to a Graylog server' + Version2 = $true + ConfigurationRoot = 'PSFramework.Logging.GELF' + InstanceProperties = 'GelfServer', 'Port', 'Encrypt' + StartEvent = $start_event + MessageEvent = $message_Event + IsInstalledScript = $isInstalled_script + InstallationScript = $installation_script + InstallationParameters = $installationParameters + ConfigurationSettings = $configuration_Settings ConfigurationDefaultValues = @{ Encrypt = $true } diff --git a/PSFramework/internal/loggingProviders/logfile.provider.ps1 b/PSFramework/internal/loggingProviders/logfile.provider.ps1 index 9acbb4e0..1dc4eff8 100644 --- a/PSFramework/internal/loggingProviders/logfile.provider.ps1 +++ b/PSFramework/internal/loggingProviders/logfile.provider.ps1 @@ -68,7 +68,7 @@ #region Type-Based Output switch ($FileType) { #region Csv - "Csv" { + 'Csv' { if (-not $CsvDelimiter) { $CsvDelimiter = ',' } @@ -92,7 +92,7 @@ } #endregion Csv #region Json - "Json" { + 'Json' { if (-not $script:JsonSettings.JsonString) { $data = $Message | ConvertTo-Json -Compress:$script:JsonSettings.JsonCompress } else { $data = $Message | ConvertFrom-Enumeration | ConvertTo-Json -Compress:$script:JsonSettings.JsonCompress } @@ -101,20 +101,20 @@ } else { if (-not $script:firstEntry -or (-not $script:JsonSettings.JsonNoEmptyFirstLine)) { - $script:writer.WriteLine(",") + $script:writer.WriteLine(',') } $script:writer.Write($data) } } #endregion Json #region XML - "XML" { + 'XML' { [xml]$xml = $message | ConvertTo-Xml -NoTypeInformation $script:writer.WriteLine($xml.Objects.InnerXml) } #endregion XML #region Html - "Html" { + 'Html' { [xml]$xml = $message | ConvertTo-Html -Fragment if ($script:firstEntry -and $IncludeHeader) { @@ -124,14 +124,14 @@ } #endregion Html #region CMTrace - "CMTrace" { + 'CMTrace' { $cType = 1 if ($MessageItem.Level -eq 'Warning') { $cType = 2 } if ($MessageItem.ErrorRecord) { $cType = 3 } $fileEntry = '' if ($MessageItem.File) { $fileEntry = Split-Path -Path $MessageItem.File -Leaf } - $component = "{0}:{1} > {2}" -f $fileEntry, $MessageItem.Line, $MessageItem.FunctionName + $component = '{0}:{1} > {2}' -f $fileEntry, $MessageItem.Line, $MessageItem.FunctionName if ($script:cmTraceSettings.CMTraceOverrideComponent -and $MessageItem.Data.Component) { $component = $MessageItem.Data.Component } @@ -189,7 +189,7 @@ #endregion Resolve Paths $minimumRetention = (Get-ConfigValue -Name 'LogRetentionTime') -as [PSFTimeSpan] -as [Timespan] - if (-not $minimumRetention) { throw "No minimum retention defined" } + if (-not $minimumRetention) { throw 'No minimum retention defined' } if ($minimumRetention.TotalSeconds -le 0) { throw "Minimum retention must be positive! Retention: $minimumRetention" } # Don't logrotate more than every 5 minutes @@ -300,7 +300,7 @@ $start_event = { 'Tags' { @{ Name = 'Tags' - Expression = { $_.Tags -join "," } + Expression = { $_.Tags -join ',' } } } 'Message' { @@ -326,13 +326,13 @@ $start_event = { } 'DataCompact' { @{ - Name = "DataCompact" + Name = 'DataCompact' Expression = { if (-not $_.Data) { return } $lines = foreach ($pair in $_.Data.GetEnumerator()) { '{0}={1}' -f $pair.Key, $pair.Value } - $lines -join ", " + $lines -join ', ' } } } @@ -341,7 +341,7 @@ $start_event = { $code = switch ($value) { Tags { - { $_.Tags -join "," } + { $_.Tags -join ',' } } Message { { $_.LogMessage } @@ -364,7 +364,7 @@ $start_event = { $lines = foreach ($pair in $_.Data.GetEnumerator()) { '{0}={1}' -f $pair.Key, $pair.Value } - $lines -join ", " + $lines -join ', ' } } default { [ScriptBlock]::Create("`$_.$value") } @@ -467,32 +467,33 @@ $final_event = { #endregion Events $configuration_Settings = { - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.FilePath' -Value "" -Initialize -Validation string -Description "The path to where the logfile is written. Supports some placeholders such as %Date% to allow for timestamp in the name. For full documentation on the supported wildcards, see the documentation on https://psframework.org" - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.Logname' -Value "" -Initialize -Validation string -Description "A special string you can use as a placeholder in the logfile path (by using '%logname%' as placeholder)" - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.IncludeHeader' -Value $true -Initialize -Validation bool -Description "Whether a written csv file will include headers" - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.Headers' -Value @('ComputerName', 'File', 'FunctionName', 'Level', 'Line', 'Message', 'ModuleName', 'Runspace', 'Tags', 'TargetObject', 'Timestamp', 'Type', 'Username') -Initialize -Validation stringarray -Description "The properties to export, in the order to select them." - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.FileType' -Value "CSV" -Initialize -Validation psframework.logfilefiletype -Description "In what format to write the logfile. Supported styles: CSV, XML, Html, Json, TXT or CMTrace. Html, XML and Json will be written as fragments." - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.CsvDelimiter' -Value "," -Initialize -Validation string -Description "The delimiter to use when writing to csv." - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.TimeFormat' -Value "$([System.Globalization.CultureInfo]::CurrentUICulture.DateTimeFormat.ShortDatePattern) $([System.Globalization.CultureInfo]::CurrentUICulture.DateTimeFormat.LongTimePattern)" -Initialize -Validation string -Description "The format used for timestamps in the logfile" - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.Encoding' -Value "UTF8" -Initialize -Validation string -Description "In what encoding to write the logfile." - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.UTC' -Value $false -Initialize -Validation bool -Description "Whether the timestamp in the logfile should be converted to UTC" - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.LogRotatePath' -Value "" -Initialize -Validation string -Description "The path where to logrotate. Specifying this setting will cause the logging provider to also rotate older logfiles" - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.LogRetentionTime' -Value "30d" -Initialize -Validation timespan -Description "The minimum age for a logfile to be considered for deletion as part of logrotation" - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.LogRotateFilter' -Value "*" -Initialize -Validation string -Description "A filter to apply to all files logrotated" - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.LogRotateRecurse' -Value $false -Initialize -Validation bool -Description "Whether the logrotate aspect should recursively look for files to logrotate" - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.MutexName' -Value '' -Initialize -Validation string -Description "Name of a mutex to use. Use this to handle parallel logging into the same file from multiple processes, by picking the same name in each process." - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.JsonCompress' -Value $false -Initialize -Validation bool -Description "Will compress the json entries, condensing each entry into a single line." + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.FilePath' -Value '' -Initialize -Validation string -Description 'The path to where the logfile is written. Supports some placeholders such as %Date% to allow for timestamp in the name. For full documentation on the supported wildcards, see the documentation on https://psframework.org' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.Logname' -Value '' -Initialize -Validation string -Description "A special string you can use as a placeholder in the logfile path (by using '%logname%' as placeholder)" + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.IncludeHeader' -Value $true -Initialize -Validation bool -Description 'Whether a written csv file will include headers' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.Headers' -Value @('ComputerName', 'File', 'FunctionName', 'Level', 'Line', 'Message', 'ModuleName', 'Runspace', 'Tags', 'TargetObject', 'Timestamp', 'Type', 'Username') -Initialize -Validation stringarray -Description 'The properties to export, in the order to select them.' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.FileType' -Value 'CSV' -Initialize -Validation psframework.logfilefiletype -Description 'In what format to write the logfile. Supported styles: CSV, XML, Html, Json, TXT or CMTrace. Html, XML and Json will be written as fragments.' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.CsvDelimiter' -Value ',' -Initialize -Validation string -Description 'The delimiter to use when writing to csv.' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.TimeFormat' -Value "$([System.Globalization.CultureInfo]::CurrentUICulture.DateTimeFormat.ShortDatePattern) $([System.Globalization.CultureInfo]::CurrentUICulture.DateTimeFormat.LongTimePattern)" -Initialize -Validation string -Description 'The format used for timestamps in the logfile' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.Encoding' -Value 'UTF8' -Initialize -Validation string -Description 'In what encoding to write the logfile.' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.UTC' -Value $false -Initialize -Validation bool -Description 'Whether the timestamp in the logfile should be converted to UTC' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.LogRotatePath' -Value '' -Initialize -Validation string -Description 'The path where to logrotate. Specifying this setting will cause the logging provider to also rotate older logfiles' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.LogRetentionTime' -Value '30d' -Initialize -Validation timespan -Description 'The minimum age for a logfile to be considered for deletion as part of logrotation' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.LogRotateFilter' -Value '*' -Initialize -Validation string -Description 'A filter to apply to all files logrotated' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.LogRotateRecurse' -Value $false -Initialize -Validation bool -Description 'Whether the logrotate aspect should recursively look for files to logrotate' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.MutexName' -Value '' -Initialize -Validation string -Description 'Name of a mutex to use. Use this to handle parallel logging into the same file from multiple processes, by picking the same name in each process.' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.JsonCompress' -Value $false -Initialize -Validation bool -Description 'Will compress the json entries, condensing each entry into a single line.' Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.JsonString' -Value $false -Initialize -Validation bool -Description "Will convert all enumerated properties to string values when converting to json. This causes the level property to be 'Debug','Host', ... rather than 8,2,..." - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.JsonNoComma' -Value $false -Initialize -Validation bool -Description "Prevent adding commas between two json entries." - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.JsonNoEmptyFirstLine' -Value $false -Initialize -Validation bool -Description "Prevent the empty first line when commas have not been disabled." + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.JsonNoComma' -Value $false -Initialize -Validation bool -Description 'Prevent adding commas between two json entries.' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.JsonNoEmptyFirstLine' -Value $false -Initialize -Validation bool -Description 'Prevent the empty first line when commas have not been disabled.' Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.CMTraceOverrideComponent' -Value $false -Initialize -Validation bool -Description 'When Enabled, messages that include a "Data" hashtable with a "Component" entry will use that Entry for the log message Component element, rather than the auto-calculated one.' Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.TXTPattern' -Value '%Timestamp% [%Level%] %Message%' -Initialize -Validation string -Description 'The pattern of any given line in the TXT-based logfile. Use %PROPERTYNAME% as placeholder, e.g. "%Message%". Same properties as with the headers configuration - you need to specify both settings, if your pattern includes non-default properties such as "Data".' - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.MoveOnFinal' -Value '' -Initialize -Validation string -Description "Path to a target folder to move logfiles to when shutting down the logging provider instance. This happens automatically when PSFramework ends or the provider instance is disabled again." - Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.CopyOnFinal' -Value '' -Initialize -Validation string -Description "Path to a target folder to copy logfiles to when shutting down the logging provider instance. This happens automatically when PSFramework ends or the provider instance is disabled again." + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.MoveOnFinal' -Value '' -Initialize -Validation string -Description 'Path to a target folder to move logfiles to when shutting down the logging provider instance. This happens automatically when PSFramework ends or the provider instance is disabled again.' + Set-PSFConfig -Module PSFramework -Name 'Logging.LogFile.CopyOnFinal' -Value '' -Initialize -Validation string -Description 'Path to a target folder to copy logfiles to when shutting down the logging provider instance. This happens automatically when PSFramework ends or the provider instance is disabled again.' } $paramRegisterPSFLoggingProvider = @{ - Name = "logfile" + Name = 'logfile' + Description = 'The universal logfile provider, useful for any given logfile format' Version2 = $true ConfigurationRoot = 'PSFramework.Logging.LogFile' InstanceProperties = 'CsvDelimiter', 'FilePath', 'FileType', 'Headers', 'IncludeHeader', 'Logname', 'TimeFormat', 'Encoding', 'UTC', 'LogRotatePath', 'LogRetentionTime', 'LogRotateFilter', 'LogRotateRecurse', 'MutexName', 'JsonCompress', 'JsonString', 'JsonNoComma', 'JsonNoEmptyFirstLine', 'CMTraceOverrideComponent', 'TXTPattern', 'MoveOnFinal', 'CopyOnFinal' diff --git a/PSFramework/internal/loggingProviders/splunk.provider.ps1 b/PSFramework/internal/loggingProviders/splunk.provider.ps1 index 1df26cf2..fac9d6ea 100644 --- a/PSFramework/internal/loggingProviders/splunk.provider.ps1 +++ b/PSFramework/internal/loggingProviders/splunk.provider.ps1 @@ -1,7 +1,6 @@ $functionDefinitions = { - function Send-SplunkData - { -<# + function Send-SplunkData { + <# .SYNOPSIS Writes data to a splunk http event collector. @@ -47,8 +46,7 @@ [string] $Token ) - process - { + process { # Splunk events can have a 'time' property in epoch time. If it's not set, use current system time. $unixEpochStart = New-Object -TypeName DateTime -ArgumentList 1970, 1, 1, 0, 0, 0, ([DateTimeKind]::Utc) $unixEpochTime = [int]($Timestamp.ToUniversalTime() - $unixEpochStart).TotalSeconds @@ -57,7 +55,7 @@ $eventData = @{ event = $InputObject host = $HostName - time = $unixEpochTime + time = $unixEpochTime } if ($index = Get-ConfigValue -Name Index) { $eventData.index = $index } if ($source = Get-ConfigValue -Name Source) { $eventData.source = $source } @@ -71,8 +69,7 @@ } } - function Invoke-RestMethodCustom - { + function Invoke-RestMethodCustom { [CmdletBinding()] param ( [string] @@ -94,8 +91,7 @@ $IgnoreCert ) - process - { + process { $request = [System.Net.WebRequest]::Create($Uri) foreach ($key in $Headers.Keys) { $request.Headers[$key] = $Headers[$key] } $request.Method = $Method @@ -106,8 +102,7 @@ $requestWriter.Write($Body) $requestWriter.Close() - try - { + try { $responseStream = $request.GetResponse().GetResponseStream() $reader = New-Object System.IO.StreamReader($responseStream) $reader.ReadToEnd() @@ -117,8 +112,7 @@ } } - function Write-SplunkMessage - { + function Write-SplunkMessage { [CmdletBinding()] param ( $Message @@ -129,8 +123,7 @@ $properties = Get-ConfigValue -Name 'Properties' $name = Get-ConfigValue -Name 'LogName' - $selectProps = switch ($properties) - { + $selectProps = switch ($properties) { 'Message' { 'LogMessage as Message' } 'Timestamp' { 'Timestamp.ToUniversalTime().ToString("yyyy-MM-dd_HH:mm:ss.fff") as Timestamp' } 'Level' { 'Level to String' } @@ -164,13 +157,14 @@ $configuration_Settings = { } $paramRegisterPSFLoggingProvider = @{ - Name = "splunk" - Version2 = $true - ConfigurationRoot = 'PSFramework.Logging.Splunk' - InstanceProperties = 'Url', 'Token', 'Properties', 'LogName', 'IgnoreCert', 'Index', 'Source', 'SourceType' - MessageEvent = $message_Event - ConfigurationSettings = $configuration_Settings - FunctionDefinitions = $functionDefinitions + Name = 'splunk' + Description = 'Log to a splunk instance.' + Version2 = $true + ConfigurationRoot = 'PSFramework.Logging.Splunk' + InstanceProperties = 'Url', 'Token', 'Properties', 'LogName', 'IgnoreCert', 'Index', 'Source', 'SourceType' + MessageEvent = $message_Event + ConfigurationSettings = $configuration_Settings + FunctionDefinitions = $functionDefinitions ConfigurationDefaultValues = @{ Properties = 'Timestamp', 'Message', 'Level', 'Tags', 'FunctionName', 'ModuleName', 'Runspace', 'Username', 'ComputerName', 'TargetObject', 'Data' LogName = 'Undefined' diff --git a/PSFramework/internal/loggingProviders/sql.provider.ps1 b/PSFramework/internal/loggingProviders/sql.provider.ps1 index 03cf9477..8fa8f6b6 100644 --- a/PSFramework/internal/loggingProviders/sql.provider.ps1 +++ b/PSFramework/internal/loggingProviders/sql.provider.ps1 @@ -32,7 +32,7 @@ } function Export-DataToSql { - <# + <# .SYNOPSIS Function to send logging data to a Sql database @@ -59,11 +59,11 @@ process { $queryParameters = @{ } - foreach ($header in $script:sql_headers) { - if ($header -is [string]) { $queryParameters[$header] = $($ObjectToProcess.$header) } - elseif ($header.Name) { $queryParameters[$header.Name] = $(([PsfScriptBlock]$header.Expression).InvokeEx($false, $ObjectToProcess, $ObjectToProcess, $ObjectToProcess, $false, $false, $ObjectToProcess)) } - else { $queryParameters[$header.Label] = $(([PsfScriptBlock]$header.Expression).InvokeEx($false, $ObjectToProcess, $ObjectToProcess, $ObjectToProcess, $false, $false, $ObjectToProcess)) } - } + foreach ($header in $script:sql_headers) { + if ($header -is [string]) { $queryParameters[$header] = $($ObjectToProcess.$header) } + elseif ($header.Name) { $queryParameters[$header.Name] = $(([PsfScriptBlock]$header.Expression).InvokeEx($false, $ObjectToProcess, $ObjectToProcess, $ObjectToProcess, $false, $false, $ObjectToProcess)) } + else { $queryParameters[$header.Label] = $(([PsfScriptBlock]$header.Expression).InvokeEx($false, $ObjectToProcess, $ObjectToProcess, $ObjectToProcess, $false, $false, $ObjectToProcess)) } + } $insertQuery = Get-Query -Parameters $queryParameters try { @@ -107,7 +107,7 @@ VALUES ($($propAdd -join ',')) } function New-DefaultSqlDatabaseAndTable { - <# + <# .SYNOPSIS This function will create a default sql database object @@ -142,13 +142,13 @@ VALUES ($($propAdd -join ',')) process { try { $dbaconnection = Get-DatabaseConnection - $database = Get-DbaDatabase -SqlInstance $dbaconnection | Where-Object Name -eq $SqlDatabaseName - if (-NOT $database) { + $database = Get-DbaDatabase -SqlInstance $dbaconnection | Where-Object Name -EQ $SqlDatabaseName + if (-not $database) { $database = New-DbaDatabase -SqlInstance $dbaconnection -Name $SqlDatabaseName } - if (-NOT ($database.Tables | Where-Object Name -eq $SqlTable)) { + if (-not ($database.Tables | Where-Object Name -EQ $SqlTable)) { $createtable = "CREATE TABLE $SqlSchema.$SqlTable (Message VARCHAR(max), Level VARCHAR(max), TimeStamp [DATETIME], FunctionName VARCHAR(max), ModuleName VARCHAR(max), Tags VARCHAR(max), Runspace VARCHAR(36), ComputerName VARCHAR(max), Username VARCHAR(max), TargetObject VARCHAR(max), [File] VARCHAR(max), Line BIGINT, ErrorRecord VARCHAR(max), CallStack VARCHAR(max), [Data] VARCHAR(max))" - Invoke-dbaquery -SQLInstance $dbaconnection -Database $SqlDatabaseName -Query $createtable + Invoke-DbaQuery -SqlInstance $dbaconnection -Database $SqlDatabaseName -Query $createtable } } catch { @@ -169,12 +169,12 @@ $installationParameters = { $validateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute('CurrentUser', 'AllUsers') $attributesCollection.Add($validateSetAttribute) - $runtimeParam = New-Object System.Management.Automation.RuntimeDefinedParameter("Scope", [string], $attributesCollection) - $results.Add("Scope", $runtimeParam) + $runtimeParam = New-Object System.Management.Automation.RuntimeDefinedParameter('Scope', [string], $attributesCollection) + $results.Add('Scope', $runtimeParam) $attributesCollection2 = New-Object System.Collections.ObjectModel.Collection[System.Attribute] - $runtimeParam2 = New-Object System.Management.Automation.RuntimeDefinedParameter("Repository", [string], $attributesCollection2) - $results.Add("Repository", $runtimeParam2) + $runtimeParam2 = New-Object System.Management.Automation.RuntimeDefinedParameter('Repository', [string], $attributesCollection2) + $results.Add('Repository', $runtimeParam2) $results } @@ -230,11 +230,10 @@ $start_event = { if (-not $changePending) { return } $script:sql_headers = switch ($script:cfgHeaders) { - 'Tags' - { + 'Tags' { @{ - Name = 'Tags' - Expression = { ($_.Tags -join ",") -as [string] } + Name = 'Tags' + Expression = { ($_.Tags -join ',') -as [string] } } } 'Message' { @{ Name = 'Message'; Expression = { $_.LogMessage } } } @@ -243,19 +242,17 @@ $start_event = { 'TargetObject' { @{ Name = 'TargetObject'; Expression = { $_.TargetObject -as [string] } } } 'ErrorRecord' { @{ Name = 'ErrorRecord'; Expression = { $_.ErrorRecord -as [string] } } } 'CallStack' { @{ Name = 'CallStack'; Expression = { $_.CallStack -as [string] } } } - 'Timestamp' - { + 'Timestamp' { @{ - Name = 'Timestamp' - Expression = { + Name = 'Timestamp' + Expression = { $_.Timestamp.ToUniversalTime() } } } - 'Data' - { + 'Data' { @{ - Name = 'Data' + Name = 'Data' Expression = { if (-not $_.Data) { return 'null' } $_.Data | ConvertTo-Json -Compress @@ -288,34 +285,35 @@ $final_event = { # Configuration values for the logging provider $configuration_Settings = { - Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.Credential' -Initialize -Validation 'credential' -Description "Credentials used for connecting to the SQL server." - Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.Database' -Value "LoggingDatabase" -Initialize -Validation 'string' -Description "SQL server database." - Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.Table' -Value "LoggingTable" -Initialize -Validation 'string' -Description "SQL server database table." - Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.SqlServer' -Value "" -Initialize -Description "SQL server hosting logs." - Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.Schema' -Value "dbo" -Initialize -Description "SQL server schema." - Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.Headers' -Value 'Message', 'Timestamp', 'Level', 'Tags', 'Data', 'ComputerName', 'Runspace', 'UserName', 'ModuleName', 'FunctionName', 'File', 'Line', 'CallStack', 'TargetObject', 'ErrorRecord' -Initialize -Description "SQL server schema." + Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.Credential' -Initialize -Validation 'credential' -Description 'Credentials used for connecting to the SQL server.' + Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.Database' -Value 'LoggingDatabase' -Initialize -Validation 'string' -Description 'SQL server database.' + Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.Table' -Value 'LoggingTable' -Initialize -Validation 'string' -Description 'SQL server database table.' + Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.SqlServer' -Value '' -Initialize -Description 'SQL server hosting logs.' + Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.Schema' -Value 'dbo' -Initialize -Description 'SQL server schema.' + Set-PSFConfig -Module PSFramework -Name 'Logging.Sql.Headers' -Value 'Message', 'Timestamp', 'Level', 'Tags', 'Data', 'ComputerName', 'Runspace', 'UserName', 'ModuleName', 'FunctionName', 'File', 'Line', 'CallStack', 'TargetObject', 'ErrorRecord' -Initialize -Description 'SQL server schema.' } # Registered parameters for the logging provider. # ConfigurationDefaultValues are used for all instances of the sql log provider $paramRegisterPSFSqlProvider = @{ - Name = "Sql" - Version2 = $true - ConfigurationRoot = 'PSFramework.Logging.Sql' - InstanceProperties = 'Database', 'Schema', 'Table', 'SqlServer', 'Credential', 'Headers' - MessageEvent = $message_Event - BeginEvent = $begin_event - StartEvent = $start_event - EndEvent = $end_event - FinalEvent = $final_event - IsInstalledScript = $isInstalled_script - InstallationScript = $installation_script - ConfigurationSettings = $configuration_Settings - InstallationParameters = $installationParameters - FunctionDefinitions = $functionDefinitions + Name = 'Sql' + Description = 'Use dbatools to log to a MS SQL Database. Creates & configures the table if needed.' + Version2 = $true + ConfigurationRoot = 'PSFramework.Logging.Sql' + InstanceProperties = 'Database', 'Schema', 'Table', 'SqlServer', 'Credential', 'Headers' + MessageEvent = $message_Event + BeginEvent = $begin_event + StartEvent = $start_event + EndEvent = $end_event + FinalEvent = $final_event + IsInstalledScript = $isInstalled_script + InstallationScript = $installation_script + ConfigurationSettings = $configuration_Settings + InstallationParameters = $installationParameters + FunctionDefinitions = $functionDefinitions ConfigurationDefaultValues = @{ - 'Database' = "LoggingDatabase" - 'Table' = "LoggingTable" + 'Database' = 'LoggingDatabase' + 'Table' = 'LoggingTable' 'Schema' = 'dbo' Headers = 'Message', 'Timestamp', 'Level', 'Tags', 'Data', 'ComputerName', 'Runspace', 'UserName', 'ModuleName', 'FunctionName', 'File', 'Line', 'CallStack', 'TargetObject', 'ErrorRecord' } diff --git a/PSFramework/internal/scripts/async-logging2.ps1 b/PSFramework/internal/scripts/async-logging2.ps1 index 2c18dbfe..8a051049 100644 --- a/PSFramework/internal/scripts/async-logging2.ps1 +++ b/PSFramework/internal/scripts/async-logging2.ps1 @@ -3,7 +3,7 @@ { $script:___ScriptName = 'PSFramework.Logging' - Import-Module (Join-Path ([PSFramework.PSFCore.PSFCoreHost]::ModuleRoot) 'PSFramework.psd1') + Import-Module (Join-Path ([PSFramework.PSFCore.PSFCoreHost]::ModuleRoot) 'PSFramework.psd1') -Global while ($true) { diff --git a/PSFramework/internal/scripts/loggingProviderInstanceModuleCode.ps1 b/PSFramework/internal/scripts/loggingProviderInstanceModuleCode.ps1 index 6d63dd3c..7ede0742 100644 --- a/PSFramework/internal/scripts/loggingProviderInstanceModuleCode.ps1 +++ b/PSFramework/internal/scripts/loggingProviderInstanceModuleCode.ps1 @@ -45,7 +45,9 @@ [CmdletBinding()] param ( [string] - $Name + $Name, + + $FallBack ) $rootPath = $script:Instance.ConfigurationRoot @@ -54,7 +56,7 @@ $rootPath += ".$($script:Instance.Name)" } - Get-PSFConfigValue -FullName "$rootPath.$Name" + Get-PSFConfigValue -FullName "$rootPath.$Name" -Fallback $FallBack } Set-Content -Path "function:\$(${ functionNames }.Begin)" -Value $LoggingProviderInstance.Provider.BeginEvent.ToString() diff --git a/PSFramework/modules/EntraAuth.md b/PSFramework/modules/EntraAuth.md new file mode 100644 index 00000000..99bb60d4 --- /dev/null +++ b/PSFramework/modules/EntraAuth.md @@ -0,0 +1,14 @@ +# EntraAuth + +> Source + +https://github.com/FriedrichWeinmann/EntraAuth + +> Purpose + +Used in the Azure LogAnalytics (v2) Logging Provider. + +> Adjustments + +Removed Format-XML file and its reference in the module manifest. +No human is ever going to see the objects from this module, no need to risk a conflict with other EntraAuth versions loaded into the main runspace. diff --git a/PSFramework/modules/EntraAuth/1.8.56/EntraAuth.psd1 b/PSFramework/modules/EntraAuth/1.8.56/EntraAuth.psd1 new file mode 100644 index 00000000..708a2e72 --- /dev/null +++ b/PSFramework/modules/EntraAuth/1.8.56/EntraAuth.psd1 @@ -0,0 +1,139 @@ +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'EntraAuth.psm1' + +# Version number of this module. +ModuleVersion = '1.8.56' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = '3d8957b7-643a-48ad-97af-6a342483d578' + +# Author of this module +Author = 'Friedrich Weinmann' + +# Company or vendor of this module +CompanyName = ' ' + +# Copyright statement for this module +Copyright = '(c) Friedrich Weinmann. All rights reserved.' + +# Description of the functionality provided by this module +Description = 'Get Tokens from Entra ID' + +# Minimum version of the PowerShell engine required by this module +# PowerShellVersion = '' + +# 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 = @('EntraAuth.Formats.ps1xml') + +# 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 = @( + 'Assert-EntraConnection' + 'Connect-EntraService' + 'Get-EntraFederationProvider' + 'Get-EntraService' + 'Get-EntraToken' + 'Import-EntraToken' + 'Invoke-EntraRequest' + 'New-EntraCustomToken' + 'New-EntraFilterBuilder' + 'New-EntraServiceSelector' + 'Register-EntraFederationProvider' + 'Register-EntraService' + 'Resolve-EntraFederationProvider' + 'Set-EntraService' +) + +# 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 applied to this module. These help with module discovery in online galleries. + Tags = @('EntraID', 'Token') + + # A URL to the license for this module. + LicenseUri = 'https://github.com/FriedrichWeinmann/EntraAuth/blob/master/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/FriedrichWeinmann/EntraAuth' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/PSFramework/modules/EntraAuth/1.8.56/EntraAuth.psm1 b/PSFramework/modules/EntraAuth/1.8.56/EntraAuth.psm1 new file mode 100644 index 00000000..effdff8d --- /dev/null +++ b/PSFramework/modules/EntraAuth/1.8.56/EntraAuth.psm1 @@ -0,0 +1,4073 @@ +$script:ModuleRoot = $PSScriptRoot + +class FederationProvider { + [string]$Name + [string]$Description + [int]$Priority + [scriptblock]$Test + [scriptblock]$Code + [string]$Assertion + [string]$Type = 'Custom' + + [string]ToString() { + return $this.Name + } +} + +enum Environment { + Global = 1 + USGov = 2 + USGovDOD = 3 + China = 4 +} + +class FilterBuilder { + [System.Collections.ArrayList]$Entries = @() + [System.Collections.ArrayList]$CustomFilter = @() + + [ValidateSet('AND','OR')][string]$Logic = 'AND' + + FilterBuilder() { } + FilterBuilder([string]$Logic) { + $this.Logic = $Logic + } + + [void]Add([string]$Property, [string]$Operator, $Value) { + $this.Add($Property, $Operator, $Value, $false) + } + [void]Add([string]$Property, [string]$Operator, $Value, [bool]$NoQuotes) { + $null = $this.Entries.Add( + @{ + Property = $Property + Operator = $Operator + Value = $Value + NoQuotes = $NoQuotes + } + ) + } + [void]Add([string]$CustomFilter) { + $null = $this.Entries.Add($CustomFilter) + } + [void]Add([FilterBuilder]$NestedFilter) { + $null = $this.Entries.Add($NestedFilter) + } + + [int]Count() { + $myCount = $this.Entries.Count + if ($this.CustomFilter) { $myCount += $this.CustomFilter.Count } + return $myCount + } + [string]GetHelp() { + return @' +OData Filter Builder Guidance + +This tool _mostly_ maps / implements the OData filter system, as adapted by the Microsoft Graph API. +It may be relevant to any other API supporting OData filters, but that's what it was built for. + +Filter Docs: +https://learn.microsoft.com/en-us/graph/filter-query-parameter + +Adding Filter Conditions: +There are two ways to provide filter conditions: + +A) .Add(property, operator, value) +The .Add method adds individual filter clauses, simple comparisons or some special behaviors. +For these special rules, see below. +But any operator that has a simple " " from this list should be valid: +https://learn.microsoft.com/en-us/graph/filter-query-parameter?tabs=http#operators-and-functions-supported-in-filter-expressions +Strings need not be provided in quotes. + +B) .Add(customFilter) +Specifying a single string allows providing custom filter terms/expressions. +This can be any valid fragment of OData filter, giving you greater control ... but requires you to write the filter yourself. + +All conditions from A) and B) are finally combined with an AND condition. + + +Special Rules: + +eq +The "eq" Operator is converted into a 'startswith' or 'endswith' operator, depending on wildcard use. + +leq +The "leq" Operator is not an actual OData operator, but was added by this tool. +It means "Literal Equals" and is converted into an "eq" operator, but without automatic conversion to 'startswith' or 'endswith'. + +any +The "any" Operator is the OData equivalent to the PowerShell -contains. + +none +The "none" fake-Operator is the OData equivalent to the PowerShell -notcontains. +It translates into an OData all(...) logic with the "ne" operator applied. +'@ + } + [string]Get() { + $segments = :entries foreach ($entry in $this.Entries) { + # Nested Filters + if ($entry -is [FilterBuilder]) { + '(' + $entry.Get() + ')' + continue + } + # Custom Filters + if ($entry -is [string]) { + '(' + $entry + ')' + continue + } + + + $quotes = "'" + if ($entry.NoQuotes) { $quotes = "" } + + $valueString = $entry.Value -as [string] + if ($null -eq $entry.Value) { $valueString = "null" } + if ( + $entry.Value -is [string] -or + $entry.Value -is [guid] + ) { + $valueString = "$($quotes)$($entry.Value)$($quotes)" + } + if ($entry.Value -is [DateTime]) { + $valueString = $entry.Value.ToString('u') -replace ' ', 'T' + } + + switch ($entry.Operator) { + 'eq' { + # Case: eq with Wildcard + if ($entry.Value -match '\*$' -and $entry.Operator -eq 'eq') { + "startswith($($entry.Property), $($quotes)$($entry.Value.TrimEnd('*'))$($quotes))" + continue entries + } + if ($entry.Value -match '^\*' -and $entry.Operator -eq 'eq') { + "endswith($($entry.Property), $($quotes)$($entry.Value.TrimStart('*'))$($quotes))" + continue entries + } + '{0} eq {1}' -f $entry.Property, $valueString + } + 'leq' { + '{0} eq {1}' -f $entry.Property, $valueString + } + 'in' { + '{0} in ({1})' -f $entry.Property, (@($entry.Value).ForEach{ "$($quotes)$_$($quotes)" } -join ', ') + } + 'any' { + '{0}/any(x:x eq {1})' -f $entry.Property, $valueString + } + 'none' { + '{0}/all(x:x ne {1})' -f $entry.Property, $valueString + } + default { + '{0} {1} {2}' -f $entry.Property, $entry.Operator, $valueString + } + } + } + if ($this.CustomFilter) { + if ($segments) { $segments = @($segments) + $this.CustomFilter } + else { $segments = $this.CustomFilter } + } + + if ($this.Logic -eq 'OR') { return $segments -join ' or ' } + return $segments -join ' and ' + } + [hashtable]GetHeader() { + return @{ '$filter' = $this.Get() } + } +} + +class ServiceSelector { + [hashtable]$DefaultServices = @{ } + + ServiceSelector([Hashtable]$Services) { + $this.DefaultServices = $Services + } + + [string]GetService([hashtable]$ServiceMap, [string]$Name) { + if ($ServiceMap[$Name]) { return $ServiceMap[$Name] } + + return $this.DefaultServices[$Name] + } + [hashtable]GetServiceMap([hashtable]$ServiceMap) { + $map = $this.DefaultServices.Clone() + if ($ServiceMap) { + foreach ($pair in $ServiceMap.GetEnumerator()) { + $map[$pair.Key] = $pair.Value + } + } + return $map + } +} + +class EntraToken { + #region Token Data + [string]$AccessToken + [System.DateTime]$ValidAfter + [System.DateTime]$ValidUntil + [string[]]$Scopes + [string]$RefreshToken + [string]$Audience + [string]$Issuer + [PSObject]$TokenData + #endregion Token Data + + #region Connection Data + [string]$Service + [string]$Type + [string]$ClientID + [string]$TenantID + [string]$ServiceUrl + [string]$AuthenticationUrl + [Hashtable]$Header = @{} + [Hashtable]$Query = @{} + [bool]$RawOnly + + [string]$IdentityID + [string]$IdentityType + + # Workflow: Browser + [string]$RedirectUri + + # Workflow: Client Secret + [System.Security.SecureString]$ClientSecret + + # Workflow: Certificate + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + + # Workflow: Username & Password + [PSCredential]$Credential + + # Workflow: Key Vault + [string]$VaultName + [string]$SecretName + + # Workflow: Az.Accounts + [string]$ShowDialog + + # Workflow: Federated + [FederationProvider]$FederationProvider + + # Workflow: Custom Token + [scriptblock]$HeaderCode + [hashtable]$Data = @{} + + #endregion Connection Data + + #region Constructors + EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [Securestring]$ClientSecret, [string]$ServiceUrl, [string]$AuthenticationUrl) { + $this.Service = $Service + $this.ClientID = $ClientID + $this.TenantID = $TenantID + $this.ClientSecret = $ClientSecret + $this.ServiceUrl = $ServiceUrl + $this.AuthenticationUrl = $AuthenticationUrl + $this.Type = 'ClientSecret' + } + + EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [FederationProvider]$Provider, [string]$ServiceUrl, [string]$AuthenticationUrl) { + $this.Service = $Service + $this.ClientID = $ClientID + $this.TenantID = $TenantID + $this.ServiceUrl = $ServiceUrl + $this.AuthenticationUrl = $AuthenticationUrl + $this.FederationProvider = $Provider + $this.Type = 'Federated' + } + + EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [string]$ServiceUrl, [string]$AuthenticationUrl) { + $this.Service = $Service + $this.ClientID = $ClientID + $this.TenantID = $TenantID + $this.Certificate = $Certificate + $this.ServiceUrl = $ServiceUrl + $this.AuthenticationUrl = $AuthenticationUrl + $this.Type = 'Certificate' + } + + EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [pscredential]$Credential, [string]$ServiceUrl, [string]$AuthenticationUrl) { + $this.Service = $Service + $this.ClientID = $ClientID + $this.TenantID = $TenantID + $this.Credential = $Credential + $this.ServiceUrl = $ServiceUrl + $this.AuthenticationUrl = $AuthenticationUrl + $this.Type = 'UsernamePassword' + } + + EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [string]$ServiceUrl, [bool]$IsDeviceCode, [string]$AuthenticationUrl) { + $this.Service = $Service + $this.ClientID = $ClientID + $this.TenantID = $TenantID + $this.ServiceUrl = $ServiceUrl + $this.AuthenticationUrl = $AuthenticationUrl + if ($IsDeviceCode) { $this.Type = 'DeviceCode' } + else { $this.Type = 'Browser' } + } + + EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [string]$ServiceUrl, [string]$VaultName, [string]$SecretName, [string]$AuthenticationUrl) { + $this.Service = $Service + $this.ClientID = $ClientID + $this.TenantID = $TenantID + $this.ServiceUrl = $ServiceUrl + $this.VaultName = $VaultName + $this.SecretName = $SecretName + $this.AuthenticationUrl = $AuthenticationUrl + $this.Type = 'KeyVault' + } + + EntraToken([string]$Service, [string]$ServiceUrl, [string]$IdentityID, [string]$IdentityType) { + $this.Service = $Service + $this.ServiceUrl = $ServiceUrl + $this.Type = 'Identity' + + if ($IdentityID) { + $this.IdentityID = $IdentityID + $this.IdentityType = $IdentityType + } + } + + EntraToken([string]$Service, [string]$ServiceUrl, [string]$ShowDialog) { + $this.Service = $Service + $this.ServiceUrl = $ServiceUrl + $this.ShowDialog = $ShowDialog + $this.Type = 'AzAccount' + } + + # Empty Constructor for Import-EntraToken + EntraToken() {} + #endregion Constructors + + [void]SetTokenMetadata([PSObject] $AuthToken) { + $this.AccessToken = $AuthToken.AccessToken + $this.ValidAfter = $AuthToken.ValidAfter + $this.ValidUntil = $AuthToken.ValidUntil + $this.Scopes = $AuthToken.Scopes + if ($AuthToken.RefreshToken) { $this.RefreshToken = $AuthToken.RefreshToken } + + $tokenPayload = $AuthToken.AccessToken.Split(".")[1].Replace('-', '+').Replace('_', '/') + while ($tokenPayload.Length % 4) { $tokenPayload += "=" } + $bytes = [System.Convert]::FromBase64String($tokenPayload) + $localData = [System.Text.Encoding]::ASCII.GetString($bytes) | ConvertFrom-Json + + if ($localData.roles) { $this.Scopes = $localData.roles } + elseif ($localData.scp) { $this.Scopes = $localData.scp -split " " } + + $this.Audience = $localData.aud + $this.Issuer = $localData.iss + $this.TokenData = $localData + $this.TenantID = $localData.tid + } + + [hashtable]GetHeader() { + if ($this.HeaderCode) { + $newHeader = $this.Header.Clone() + $results = @(& $this.HeaderCode $this)[0] + foreach ($pair in $results.GetEnumerator()) { + $newHeader[$pair.Key] = $pair.Value + } + return $newHeader + } + + if ($this.ValidUntil -lt (Get-Date).AddMinutes(5)) { + $this.RenewToken() + } + + $currentHeader = @{} + if ($this.Header.Count -gt 0) { + $currentHeader = $this.Header.Clone() + } + $currentHeader.Authorization = "Bearer $($this.AccessToken)" + + return $currentHeader + } + + [void]RenewToken() { + $defaultParam = @{ + TenantID = $this.TenantID + ClientID = $this.ClientID + Resource = $this.Audience + AuthenticationUrl = $this.AuthenticationUrl + } + switch ($this.Type) { + Certificate { + $result = Connect-ServiceCertificate @defaultParam -Certificate $this.Certificate + $this.SetTokenMetadata($result) + } + ClientSecret { + $result = Connect-ServiceClientSecret @defaultParam -ClientSecret $this.ClientSecret + $this.SetTokenMetadata($result) + } + UsernamePassword { + $result = Connect-ServicePassword @defaultParam -Credential $this.Credential + $this.SetTokenMetadata($result) + } + DeviceCode { + if ($this.RefreshToken) { + Connect-ServiceRefreshToken -Token $this + return + } + + $result = Connect-ServiceDeviceCode @defaultParam + $this.SetTokenMetadata($result) + } + Browser { + if ($this.RefreshToken) { + Connect-ServiceRefreshToken -Token $this + return + } + + $result = Connect-ServiceBrowser @defaultParam -SelectAccount -RedirectUri $this.RedirectUri + $this.SetTokenMetadata($result) + } + Refresh { + Connect-ServiceRefreshToken -Token $this + } + KeyVault { + $secret = Get-VaultSecret -VaultName $this.VaultName -SecretName $this.SecretName + $result = switch ($secret.Type) { + Certificate { Connect-ServiceCertificate @defaultParam -Certificate $secret.Certificate } + ClientSecret { Connect-ServiceClientSecret @defaultParam -ClientSecret $secret.ClientSecret } + } + $this.SetTokenMetadata($result) + } + Identity { + $result = Connect-ServiceIdentity -Resource $this.Audience -IdentityID $this.IdentityID -IdentityType $this.IdentityType + $this.SetTokenMetadata($result) + } + AzAccount { + $result = Connect-ServiceAzure -Resource $this.Audience -ShowDialog $this.ShowDialog + $this.SetTokenMetadata($result) + } + Federated { + $result, $provider = Connect-ServiceFederated @defaultParam -Provider $this.FederationProvider.Name -Assertion $this.FederationProvider.Assertion + $this.SetTokenMetadata($result) + } + } + } +} + +function Connect-ServiceAzure { + <# + .SYNOPSIS + Authenticates using the established session from Az.Accounts. + + .DESCRIPTION + Authenticates using the established session from Az.Accounts. + This limits the scopes available to what is configured on the Az Application, but makes it easy to authenticate without active interaction. + + Pretty useful for authenticating to custom apps that do not actually implement scopes. + + .PARAMETER Resource + The resource owning the api permissions / scopes requested. + + .PARAMETER ShowDialog + Whether to show a dialog in case of interaction being needed. + Defaults to: auto + + .EXAMPLE + PS C:\> Connect-ServiceAzure -Resource 'https://graph.microsoft.com' + + Connect to graph using the existing az.accounts session. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Resource, + + [ValidateSet('Auto', 'Always', 'Never')] + [string] + $ShowDialog = 'Auto' + ) + process { + try { $azContext = Get-AzContext -ErrorAction Stop } + catch { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message 'Error accessing azure context. Ensure the module "Az.Accounts" is installed and you have connected via "Connect-AzAccount"!' -ErrorRecord $_ } + + try { + $result = [Microsoft.Azure.Commands.Common.Authentication.AzureSession]::Instance.AuthenticationFactory.Authenticate( + $azContext.Account, + $azContext.Environment, + "$($azContext.Tenant.id)", + $null, + $ShowDialog, + $null, + $Resource + ) + + } + catch { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Error retrieving token from Azure for '$Resource': $_" -ErrorRecord $_ } + + $tokenData = Read-TokenData -Token $result.AccessToken + + # A Fake AuthResponse result - Should keep the same layout as the result of Read-AuthResponse + [PSCustomObject]@{ + AccessToken = $result.AccessToken + ValidAfter = Get-Date + ValidUntil = $result.ExpiresOn.LocalDateTime + Scopes = $tokenData.scp -split ' ' + RefreshToken = $null + + # For Initial Connect Metadata + ClientID = $tokenData.appid + TenantID = $tokenData.tid + } + } +} + +function Connect-ServiceBrowser { + <# + .SYNOPSIS + Interactive logon using the Authorization flow and browser. Supports SSO. + + .DESCRIPTION + Interactive logon using the Authorization flow and browser. Supports SSO. + + This flow requires an App Registration configured for the platform "Mobile and desktop applications". + Its redirect Uri must be "http://localhost" + + On successful authentication + + .PARAMETER ClientID + The ID of the registered app used with this authentication request. + + .PARAMETER TenantID + The ID of the tenant connected to with this authentication request. + + .PARAMETER SelectAccount + Forces account selection on logon. + As this flow supports single-sign-on, it will otherwise not prompt for anything if already signed in. + This could be a problem if you want to connect using another (e.g. an admin) account. + + .PARAMETER Scopes + Generally doesn't need to be changed from the default '.default' + + .PARAMETER LocalPort + The local port that should be redirected to. + In order to process the authentication response, we need to listen to a local web request on some port. + Usually needs not be redirected. + Defaults to: 8080 + + .PARAMETER Resource + The resource owning the api permissions / scopes requested. + + .PARAMETER RedirectUri + The Redirect URI to send with the request. + May be different from the actual local port you want to listen on (e.g. when redirecting to an alternative name on top of localhost). + + .PARAMETER Browser + The path to the browser to use for the authentication flow. + Provide the full path to the executable. + The browser must accept the url to open as its only parameter. + Defaults to your default browser. + + .PARAMETER BrowserMode + How the browser used for authentication is selected. + Options: + + Auto (default): Automatically use the default browser. + + PrintLink: The link to open is printed on console and user selects which browser to paste it into (must be used on the same machine) + + .PARAMETER NoReconnect + Disables automatic reconnection. + By default, this module will automatically try to reaquire a new token before the old one expires. + + .PARAMETER AuthenticationUrl + The url used for the authentication requests to retrieve tokens. + + .EXAMPLE + PS C:\> Connect-ServiceBrowser -ClientID '' -TenantID '' + + Connects to the specified tenant using the specified client, prompting the user to authorize via Browser. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $TenantID, + + [Parameter(Mandatory = $true)] + [string] + $ClientID, + + [Parameter(Mandatory = $true)] + [string] + $Resource, + + [switch] + $SelectAccount, + + [AllowEmptyCollection()] + [string[]] + $Scopes, + + [int] + $LocalPort = 8080, + + [AllowEmptyString()] + [string] + $RedirectUri, + + [string] + $Browser, + + [Parameter(ParameterSetName = 'Browser')] + [ValidateSet('Auto', 'PrintLink')] + [string] + $BrowserMode = 'Auto', + + [switch] + $NoReconnect, + + [Parameter(Mandatory = $true)] + [string] + $AuthenticationUrl + ) + process { + Add-Type -AssemblyName System.Web + if (-not $Scopes) { $Scopes = @('.default') } + + $localUri = "http://localhost:$LocalPort" + if (-not $RedirectUri) { $RedirectUri = $localUri } + $actualScopes = $Scopes | Resolve-ScopeName -Resource $Resource + + if (-not $NoReconnect) { + $actualScopes = @($actualScopes) + 'offline_access' + } + + $uri = "$AuthenticationUrl/$TenantID/oauth2/v2.0/authorize?" + $state = Get-Random + $parameters = @{ + client_id = $ClientID + response_type = 'code' + redirect_uri = $RedirectUri + response_mode = 'query' + scope = $actualScopes -join ' ' + state = $state + } + if ($SelectAccount) { + $parameters.prompt = 'select_account' + } + + $paramStrings = foreach ($pair in $parameters.GetEnumerator()) { + $pair.Key, ([System.Web.HttpUtility]::UrlEncode($pair.Value)) -join '=' + } + $uriFinal = $uri + ($paramStrings -join '&') + Write-Verbose "Authorize Uri: $uriFinal" + + #$redirectTo = 'https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/master/nothing-to-see-here.txt' + #$redirectTo = (Join-Path -Path $script:ModuleRoot -ChildPath 'nothing-to-see-here.txt') -replace '\\','/' + $redirectTo = "http://localhost:$(Get-Random -Minimum 9800 -Maximum 9999)/" + if ((Get-Random -Minimum 10 -Maximum 99) -eq 66) { + $redirectTo = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + } + + # Start local server to catch the redirect + $http = [System.Net.HttpListener]::new() + $http.Prefixes.Add(("$RedirectUri/" -replace '//$', '/')) + if ($localUri -ne $RedirectUri) { $http.Prefixes.Add(("$localUri/" -replace '//$', '/')) } + try { $http.Start() } + catch { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Failed to create local http listener on port $LocalPort. Use -LocalPort to select a different port. $_" -Category OpenError } + + switch ($BrowserMode) { + Auto { + # Execute in default browser + if ($Browser) { & $Browser $uriFinal } + else { Start-Process $uriFinal } + } + PrintLink { + Write-Host @" +Ready to authenticate. Paste the following link into the browser of your choice on the local computer: +$uriFinal +"@ + } + } + + # Get Result + $task = $http.GetContextAsync() + $authorizationCode, $stateReturn, $sessionState = $null + try { + while (-not $task.IsCompleted) { + Start-Sleep -Milliseconds 200 + } + $http2 = [System.Net.HttpListener]::new() + $http2.Prefixes.Add($redirectTo) + $http2.Start() + + $context = $task.Result + $context.Response.Redirect($redirectTo) + $context.Response.Close() + $authorizationCode, $stateReturn, $sessionState = $context.Request.Url.Query -split "&" + + $task2 = $http2.GetContextAsync() + while (-not $task2.IsCompleted) { + Start-Sleep -Milliseconds 200 + } + $context2 = $task2.Result + $bytes = [System.Text.Encoding]::UTF8.GetBytes('Authentication flow completed, you can close the tab now.') + $context2.Response.ContentEncoding = [System.Text.Encoding]::UTF8 + $context2.Response.OutputStream.Write($bytes,0,$bytes.Length) + $context2.Response.Close() + } + finally { + $http.Stop() + $http.Dispose() + if ($http2.IsListening) { + $http2.Stop() + $http2.Dispose() + } + } + + if (-not $stateReturn) { + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Authentication failed (see browser for details)" -Category AuthenticationError + } + + if ($stateReturn -match '^error_description=') { + $message = $stateReturn -replace '^error_description=' -replace '\+',' ' + $message = [System.Web.HttpUtility]::UrlDecode($message) + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Error processing the request: $message" -Category InvalidOperation + } + + if ($state -ne $stateReturn.Split("=")[1]) { + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Received invalid authentication result. Likely returned from another flow redirecting to the same local port!" -Category InvalidOperation + } + + $actualAuthorizationCode = $authorizationCode.Split("=")[1] + + $body = @{ + client_id = $ClientID + scope = $actualScopes -join " " + code = $actualAuthorizationCode + redirect_uri = $RedirectUri + grant_type = 'authorization_code' + } + $uri = "$AuthenticationUrl/$TenantID/oauth2/v2.0/token" + try { $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body -ErrorAction Stop } + catch { + if ($_ -notmatch '"error":\s*"invalid_client"') { Invoke-TerminatingException -Cmdlet $PSCmdlet -ErrorRecord $_ } + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "The App Registration $ClientID has not been configured correctly. Ensure you have a 'Mobile and desktop applications' platform with redirect to 'http://localhost' configured (and not a 'Web' Platform). $_" -Category $_.CategoryInfo.Category + } + Read-AuthResponse -AuthResponse $authResponse + } +} + +function Connect-ServiceCertificate { + <# + .SYNOPSIS + Connects to AAD using a application ID and a certificate. + + .DESCRIPTION + Connects to AAD using a application ID and a certificate. + + .PARAMETER Resource + The resource owning the api permissions / scopes requested. + + .PARAMETER Certificate + The certificate to use for authentication. + + .PARAMETER TenantID + The ID of the tenant/directory to connect to. + + .PARAMETER ClientID + The ID of the registered application used to authenticate as. + + .PARAMETER AuthenticationUrl + The url used for the authentication requests to retrieve tokens. + + .EXAMPLE + PS C:\> Connect-ServiceCertificate -Certificate $cert -TenantID $tenantID -ClientID $clientID + + Connects to the specified tenant using the specified app & cert. + + .LINK + https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials +#> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Resource, + + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $Certificate, + + [Parameter(Mandatory = $true)] + [string] + $TenantID, + + [Parameter(Mandatory = $true)] + [string] + $ClientID, + + [Parameter(Mandatory = $true)] + [string] + $AuthenticationUrl + ) + + #region Build Signature Payload + $jwtHeader = @{ + alg = "RS256" + typ = "JWT" + x5t = [Convert]::ToBase64String($Certificate.GetCertHash()) -replace '\+', '-' -replace '/', '_' -replace '=' + } + $encodedHeader = $jwtHeader | ConvertTo-Json | ConvertTo-Base64 + $claims = @{ + aud = "$AuthenticationUrl/$TenantID/oauth2/v2.0/token" + exp = ((Get-Date).AddMinutes(5).ToUniversalTime() - (Get-Date -Date '1970-01-01')).TotalSeconds -as [int] + iss = $ClientID + jti = "$(New-Guid)" + nbf = ((Get-Date).ToUniversalTime() - (Get-Date -Date '1970-01-01')).TotalSeconds -as [int] + sub = $ClientID + } + $encodedClaims = $claims | ConvertTo-Json | ConvertTo-Base64 + $jwtPreliminary = $encodedHeader, $encodedClaims -join "." + $jwtSigned = ($jwtPreliminary | ConvertTo-SignedString -Certificate $Certificate) -replace '\+', '-' -replace '/', '_' -replace '=' + $jwt = $jwtPreliminary, $jwtSigned -join '.' + #endregion Build Signature Payload + + $body = @{ + client_id = $ClientID + client_assertion = $jwt + client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + scope = '{0}/.default' -f $Resource + grant_type = 'client_credentials' + } + $header = @{ + Authorization = "Bearer $jwt" + } + $uri = "$AuthenticationUrl/$TenantID/oauth2/v2.0/token" + + try { $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body -Headers $header -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop } + catch { throw } + + Read-AuthResponse -AuthResponse $authResponse +} + +function Connect-ServiceClientSecret { + <# + .SYNOPSIS + Connets using a client secret. + + .DESCRIPTION + Connets using a client secret. + + .PARAMETER Resource + The resource owning the api permissions / scopes requested. + + .PARAMETER ClientID + The ID of the registered app used with this authentication request. + + .PARAMETER TenantID + The ID of the tenant connected to with this authentication request. + + .PARAMETER ClientSecret + The actual secret used for authenticating the request. + + .PARAMETER AuthenticationUrl + The url used for the authentication requests to retrieve tokens. + + .EXAMPLE + PS C:\> Connect-ServiceClientSecret -ClientID '' -TenantID '' -ClientSecret $secret -Resource '' -AuthenticationUrl 'https://login.microsoftonline.com' + + Connects to the specified tenant using the specified client and secret. +#> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Resource, + + [Parameter(Mandatory = $true)] + [string] + $ClientID, + + [Parameter(Mandatory = $true)] + [string] + $TenantID, + + [Parameter(Mandatory = $true)] + [securestring] + $ClientSecret, + + [Parameter(Mandatory = $true)] + [string] + $AuthenticationUrl + ) + + process { + $body = @{ + resource = $Resource + client_id = $ClientID + client_secret = [PSCredential]::new('NoMatter', $ClientSecret).GetNetworkCredential().Password + grant_type = 'client_credentials' + } + try { $authResponse = Invoke-RestMethod -Method Post -Uri "$AuthenticationUrl/$TenantId/oauth2/token" -Body $body -ErrorAction Stop } + catch { throw } + + Read-AuthResponse -AuthResponse $authResponse + } +} + +function Connect-ServiceDeviceCode { + <# + .SYNOPSIS + Connects to Azure AD using the Device Code authentication workflow. + + .DESCRIPTION + Connects to Azure AD using the Device Code authentication workflow. + + .PARAMETER Resource + The resource owning the api permissions / scopes requested. + + .PARAMETER ClientID + The ID of the registered app used with this authentication request. + + .PARAMETER TenantID + The ID of the tenant connected to with this authentication request. + + .PARAMETER Scopes + The scopes to request. + Automatically scoped to the service specified via Service Url. + Defaults to ".Default" + + .PARAMETER NoReconnect + Disables automatic reconnection. + By default, this module will automatically try to reaquire a new token before the old one expires. + + .PARAMETER AuthenticationUrl + The url used for the authentication requests to retrieve tokens. + + .EXAMPLE + PS C:\> Connect-ServiceDeviceCode -Resource 'https://graph.microsoft.com' -ClientID '' -TenantID '' -AuthenticationUrl 'https://login.microsoftonline.com' + + Connects to the specified tenant using the specified client, prompting the user to authorize via Device Code workflow. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Resource, + + [Parameter(Mandatory = $true)] + [string] + $ClientID, + + [Parameter(Mandatory = $true)] + [string] + $TenantID, + + [AllowEmptyCollection()] + [string[]] + $Scopes, + + [switch] + $NoReconnect, + + [Parameter(Mandatory = $true)] + [string] + $AuthenticationUrl + ) + + if (-not $Scopes) { $Scopes = @('.default') } + $actualScopes = $Scopes | Resolve-ScopeName -Resource $Resource + + if (-not $NoReconnect) { + $actualScopes = @($actualScopes) + 'offline_access' + } + + try { + $initialResponse = Invoke-RestMethod -Method POST -Uri "$AuthenticationUrl/$TenantID/oauth2/v2.0/devicecode" -Body @{ + client_id = $ClientID + scope = $actualScopes -join " " + } -ErrorAction Stop + } + catch { + throw + } + + Write-Host $initialResponse.message + + $paramRetrieve = @{ + Uri = "$AuthenticationUrl/$TenantID/oauth2/v2.0/token" + Method = "POST" + Body = @{ + grant_type = "urn:ietf:params:oauth:grant-type:device_code" + client_id = $ClientID + device_code = $initialResponse.device_code + } + ErrorAction = 'Stop' + } + $limit = (Get-Date).AddSeconds($initialResponse.expires_in) + while ($true) { + if ((Get-Date) -gt $limit) { + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Timelimit exceeded, device code authentication failed" -Category AuthenticationError + } + Start-Sleep -Seconds $initialResponse.interval + try { $authResponse = Invoke-RestMethod @paramRetrieve } + catch { + if ($_ -match '"error":\s*"authorization_pending"') { continue } + $PSCmdlet.ThrowTerminatingError($_) + } + if ($authResponse) { + break + } + } + + Read-AuthResponse -AuthResponse $authResponse +} + +function Connect-ServiceFederated { + <# + .SYNOPSIS + Authenticate using the Federated Credentials flow. + + .DESCRIPTION + Authenticate using the Federated Credentials flow. + + .PARAMETER Resource + The resource owning the api permissions / scopes requested. + + .PARAMETER ClientID + The ID of the registered app used with this authentication request. + + .PARAMETER TenantID + The ID of the tenant connected to with this authentication request. + + .PARAMETER Provider + The name of the provider to use. + Overrides auto-selection if provided. + + .PARAMETER Assertion + An externally provided assertion from the federated authentication provider. + This skips the internal assertion resolution process and moves straight to the authentication to Entra + + .PARAMETER AuthenticationUrl + The url used for the authentication requests to retrieve tokens. + + .EXAMPLE + PS C:\> Connect-ServiceFederated -ClientID '' -TenantID '' -Resource '' -AuthenticationUrl 'https://login.microsoftonline.com' + + Connects to the specified tenant using the specified client and automatically calculated federated credential. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Resource, + + [Parameter(Mandatory = $true)] + [string] + $ClientID, + + [Parameter(Mandatory = $true)] + [string] + $TenantID, + + [AllowEmptyString()] + [string] + $Provider, + + [AllowEmptyString()] + [string] + $Assertion, + + [Parameter(Mandatory = $true)] + [string] + $AuthenticationUrl + ) + process { + $providerObject = $null + $myAssertion = $Assertion + if (-not $myAssertion) { + if ($Provider) { $providerObject = $script:_FederationProviders[$Provider] } + else { $providerObject = Resolve-EntraFederationProvider } + + if (-not $providerObject) { + Invoke-TerminatingException -Cmdlet $PSCmdlet -Category AuthenticationError -Message "Unable to find an applicable Federation Provider! Ensure you have a Federation Provider registered and are running in an environment where detection can work." + } + + try { $myAssertion = & $providerObject.Code } + catch { Invoke-TerminatingException -Cmdlet $PSCmdlet -ErrorRecord $_ -Message "Failed to access a token from the federated identity provider! $_" } + } + + $body = @{ + resource = $Resource + client_id = $ClientID + client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + client_assertion = $myAssertion + grant_type = 'client_credentials' + } + try { $authResponse = Invoke-RestMethod -Method Post -Uri "$AuthenticationUrl/$TenantId/oauth2/token" -Body $body -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop } + catch { throw } + + Read-AuthResponse -AuthResponse $authResponse + if ($providerObject) { return $providerObject } + [FederationProvider]@{ + Assertion = $myAssertion + } + } +} + +function Connect-ServiceIdentity { + <# + .SYNOPSIS + Connect as the current Managed Identity. + + .DESCRIPTION + Connect as the current Managed Identity. + Only works from within the context of a managed environment, such as Azure Functions with enabled MSI. + + .PARAMETER Resource + The resource to get a token for. + + .PARAMETER IdentityID + ID of the User-Managed Identity to connect as. + + .PARAMETER IdentityType + Type of the User-Managed Identity. + + .PARAMETER Cmdlet + The $PSCmdlet of the calling command. + If specified, errors are triggered in the caller's context. + + .EXAMPLE + PS C:\> Connect-ServiceIdentity -Resource 'https://vault.azure.net' + + Connect as the current managed identity, retrieving a token for the Azure Key Vault. + + .LINK + https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Resource, + + [AllowEmptyString()] + [AllowNull()] + [string] + $IdentityID, + + [AllowEmptyString()] + [AllowNull()] + [string] + $IdentityType, + + $Cmdlet = $PSCmdlet + ) + process { + # Logic for Azure VMs + try { + $vmMetadata = $null + $vmMetadata = Invoke-RestMethod -Headers @{Metadata = "true" } -Method GET -NoProxy -Uri "http://169.254.169.254/metadata/instance?api-version=2021-02-01" + } + catch { + $vmMetadata = $null + } + if ($vmMetadata.compute.azEnvironment -like "*Azure*") { + Write-Verbose "We are running on an Azure VM. Setting Environment Variables" + $isAzureVM = $true + $env:IDENTITY_ENDPOINT = "http://169.254.169.254/metadata/identity/oauth2/token" + $env:IDENTITY_API_VERSION = "2018-02-01" + } + + if ((-not $env:IDENTITY_ENDPOINT) -or (-not $env:IDENTITY_HEADER)) { + Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Cannot identify a Managed Identity. MSI logon not possible!" -Category ConnectionError + } + + $apiVersion = $env:IDENTITY_API_VERSION + if (-not $apiVersion) { $apiVersion = '2019-08-01' } + + $url = "$($env:IDENTITY_ENDPOINT)?resource=$Resource&api-version=$apiVersion" + if ($IdentityID) { + $labels = @{ + ClientID = 'client_id' + ResourceID = 'mi_res_id' + PrincipalID = 'principal_id' + } + $url = $url + "&$($labels[$IdentityType])=$($IdentityID)" + } + + try { + Write-Verbose "$url" + if ($isAzureVM) { + $headers = @{Metadata = 'true' } + } + else { + $headers = @{'X-IDENTITY-HEADER' = $env:IDENTITY_HEADER } + } + $authResponse = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop + } + catch { + Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Failed to connect via Managed Identity: $_" -ErrorRecord $_ + } + + Read-AuthResponse -AuthResponse $authResponse + } +} + +function Connect-ServicePassword { + <# + .SYNOPSIS + Connect to graph using username and password. + + .DESCRIPTION + Connect to graph using username and password. + This logs into graph as a user, not as an application. + Only cloud-only accounts can be used for this workflow. + Consent to scopes must be granted before using them, as this command cannot show the consent prompt. + + .PARAMETER Resource + The resource owning the api permissions / scopes requested. + + .PARAMETER Credential + Credentials of the user to connect as. + + .PARAMETER TenantID + The Guid of the tenant to connect to. + + .PARAMETER ClientID + The ClientID / ApplicationID of the application to use. + + .PARAMETER Scopes + The permission scopes to request. + + .PARAMETER AuthenticationUrl + The url used for the authentication requests to retrieve tokens. + + .EXAMPLE + PS C:\> Connect-ServicePassword -Credential max@contoso.com -ClientID $client -TenantID $tenant -Scopes 'user.read','user.readbasic.all' -Resource 'https://graph.microsoft.com' -AuthenticationUrl 'https://login.microsoftonline.com' + + Connect as max@contoso.com with the rights to read user information. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Resource, + + [Parameter(Mandatory = $true)] + [System.Management.Automation.PSCredential] + $Credential, + + [Parameter(Mandatory = $true)] + [string] + $ClientID, + + [Parameter(Mandatory = $true)] + [string] + $TenantID, + + [string[]] + $Scopes = '.default', + + [Parameter(Mandatory = $true)] + [string] + $AuthenticationUrl + ) + + $actualScopes = $Scopes | Resolve-ScopeName -Resource $Resource + + $request = @{ + client_id = $ClientID + scope = $actualScopes -join " " + username = $Credential.UserName + password = $Credential.GetNetworkCredential().Password + grant_type = 'password' + } + + try { $authResponse = Invoke-RestMethod -Method POST -Uri "$AuthenticationUrl/$TenantID/oauth2/v2.0/token" -Body $request -ErrorAction Stop } + catch { throw } + + Read-AuthResponse -AuthResponse $authResponse +} + +function Connect-ServiceRefreshToken { + <# + .SYNOPSIS + Connect with the refresh token provided previously. + + .DESCRIPTION + Connect with the refresh token provided previously. + Used mostly for delegate authentication flows to avoid interactivity. + + Can also be resolved to from the outside, when trying to get multiple tokens with a single delegate flow. + + .PARAMETER Token + The EntraToken object with the refresh token to use. + The token is then refreshed in-place with no output provided. + + .PARAMETER RefreshToken + The RefreshToken to use for authenticating. + + .PARAMETER TenantID + ID of the tenant to connect to. + + .PARAMETER ClientID + ID of the application to connect as. + + .PARAMETER Resource + Resource we want the scopes for. + + .PARAMETER Scopes + Scopes we want to use. + + .PARAMETER AuthenticationUrl + The url used for the authentication requests to retrieve tokens. + + .EXAMPLE + PS C:\> Connect-ServiceRefreshToken -Token $token + + Connect with the refresh token provided previously. + + .EXAMPLE + PS C:\> Connect-ServiceRefreshToken -RefreshToken $refreshToken -TenantID '' -ClientID '' -Resource 'https://graph.microsoft.com' -Scopes @('User.Read', 'Mail.Read') -AuthenticationUrl 'https://login.microsoftonline.com' + + Connect using specific refresh token details with custom scopes for Microsoft Graph. + + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ParameterSetName = 'Token')] + $Token, + + [Parameter(Mandatory = $true, ParameterSetName = 'Details')] + [string] + $RefreshToken, + + [Parameter(Mandatory = $true, ParameterSetName = 'Details')] + [string] + $TenantID, + + [Parameter(Mandatory = $true, ParameterSetName = 'Details')] + [string] + $ClientID, + + [Parameter(Mandatory = $true, ParameterSetName = 'Details')] + [string] + $Resource, + + [Parameter(ParameterSetName = 'Details')] + [string[]] + $Scopes = '.default', + + [Parameter(Mandatory = $true, ParameterSetName = 'Details')] + [string] + $AuthenticationUrl + ) + process { + switch ($PSCmdlet.ParameterSetName) { + 'Token' { + if (-not $Token.RefreshToken) { + throw "Failed to refresh token: No refresh token found!" + } + + $effectiveScopes = $Token.Scopes + + $body = @{ + client_id = $Token.ClientID + scope = @($effectiveScopes).ForEach{"$($Token.Audience)/$($_)"} -join " " + refresh_token = $Token.RefreshToken + grant_type = 'refresh_token' + } + $uri = "$($Token.AuthenticationUrl)/$($Token.TenantID)/oauth2/v2.0/token" + $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body + $Token.SetTokenMetadata((Read-AuthResponse -AuthResponse $authResponse)) + } + 'Details' { + $effectiveScopes = foreach ($scope in $Scopes) { + if ($scope -like "$Resource*") { $scope } + else { "$Resource/$scope" } + } + + $body = @{ + client_id = $ClientID + scope = $effectiveScopes -join " " + refresh_token = $RefreshToken + grant_type = 'refresh_token' + } + $uri = "$($AuthenticationUrl)/$($TenantID)/oauth2/v2.0/token" + $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body + Read-AuthResponse -AuthResponse $authResponse + } + } + } +} + +function Get-VaultSecret { + <# + .SYNOPSIS + Retrieve a secret from Azure Key Vault. + + .DESCRIPTION + Retrieve a secret from Azure Key Vault. + Works for both certificates and secrets. + + Requires one of ... + - An established connection with the AzureKeyVault service. + - An established AZ session via Az.Accounts with the Az.KeyVault module present. + + .PARAMETER VaultName + Name of the Vault to query. + + .PARAMETER SecretName + Name of the Secret to retrieve. + + .PARAMETER Cmdlet + The $PSCmdlet object of the caller, enabling errors to happen within the scope of the caller. + Defaults to the current command's $PSCmdlet + + .EXAMPLE + PS C:\> Get-VaultSecret -VaultName myvault -SecretName mysecret + + Retrieves the latest enabled version of mysecret from myvault + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $VaultName, + + [Parameter(Mandatory = $true)] + [string] + $SecretName, + + $Cmdlet = $PSCmdlet + ) + + process { + #region Via EntraAuth + if (Get-EntraToken -Service AzureKeyVault) { + try { + $secretVersion = Invoke-EntraRequest -Service AzureKeyVault -Path "secrets/$SecretName/versions" -VaultName $VaultName -ErrorAction Stop | Where-Object { + $_.attributes.enabled + } | Sort-Object { $_.attributes.created } -Descending | Select-Object -First 1 + + if (-not $secretVersion) { + Invoke-TerminatingException -Cmdlet $Cmdlet -ErrorRecord $_ -Message "Secret '$SecretName' does not exist in vault '$VaultName'! $_" + } + $secretData = Invoke-EntraRequest -Service AzureKeyVault -Path $secretVersion.id -VaultName $VaultName -ErrorAction Stop + } + catch { + Invoke-TerminatingException -Cmdlet $Cmdlet -ErrorRecord $_ -Message "Failed to retrieve secret '$SecretName' from '$VaultName'! $_" + } + + if ($secretVersion.contentType) { + $secretBytes = [convert]::FromBase64String($secretData) + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($secretBytes) + [PSCustomObject]@{ + Type = 'Certificate' + Certificate = $certificate + ClientSecret = $null + } + } + else { + [PSCustomObject]@{ + Type = 'ClientSecret' + Certificate = $null + ClientSecret = $secretData | ConvertTo-SecureString -AsPlainText -Force + } + } + + return + } + #endregion Via EntraAuth + + #region Via Az.KeyVault + if ((Get-Module Az.Accounts -ListAvailable) -and (Get-AzContext) -and (Get-Module Az.KeyVault -ListAvailable)) { + try { $secret = Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName } + catch { Invoke-TerminatingException -Cmdlet $Cmdlet -ErrorRecord $_ -Message "Error accessing the secret '$Secretname' from Vault '$VaultName'. $_" } + + $type = 'Certificate' + if (-not $secret.ContentType) { $type = 'ClientSecret' } + + $certificate = $null + $clientSecret = $secret.SecretValue + + if ($type -eq 'Certificate') { + $certString = [PSCredential]::New("irrelevant", $secret.SecretValue).GetNetworkCredential().Password + $bytes = [convert]::FromBase64String($certString) + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes) + $clientSecret = $null + } + + [PSCustomObject]@{ + Type = $type + Certificate = $certificate + ClientSecret = $clientSecret + } + + return + } + #endregion Via Az.KeyVault + + Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Not connected to azure yet! Either use 'Connect-EntraService -Service AzureKeyVault' or 'Connect-AzAccount' before trying to connect via KeyVault!" -Category ConnectionError + } +} + +function Read-AuthResponse { + <# + .SYNOPSIS + Produces a standard output representation of the authentication response received. + + .DESCRIPTION + Produces a standard output representation of the authentication response received. + This streamlines the token processing and simplifies the connection code. + + .PARAMETER AuthResponse + The authentication response received. + + .EXAMPLE + PS C:\> Read-AuthResponse -AuthResponse $authResponse + + Reads the authentication details received. + #> + [CmdletBinding()] + param ( + $AuthResponse + ) + process { + if ($AuthResponse.expires_in) { + $after = (Get-Date).AddMinutes(-5) + $until = (Get-Date).AddSeconds($AuthResponse.expires_in) + } + else { + if ($AuthResponse.not_before) { $after = (Get-Date -Date '1970-01-01').AddSeconds($AuthResponse.not_before).ToLocalTime() } + else { $after = Get-Date } + $until = (Get-Date -Date '1970-01-01').AddSeconds($AuthResponse.expires_on).ToLocalTime() + } + $scopes = @() + if ($AuthResponse.scope) { $scopes = $authResponse.scope -split " " } + + # If updating this layout, also update in Connect-ServiceAzure, which fakes this object + [pscustomobject]@{ + AccessToken = $AuthResponse.access_token + ValidAfter = $after + ValidUntil = $until + Scopes = $scopes + RefreshToken = $AuthResponse.refresh_token + } + } +} + +function ConvertTo-Base64 { +<# + .SYNOPSIS + Converts the input-string to its base 64 encoded string form. + + .DESCRIPTION + Converts the input-string to its base 64 encoded string form. + + .PARAMETER Text + The text to convert. + + .PARAMETER Encoding + The encoding of the input text. + Used to correctly translate the input string into bytes before converting those to base 64. + Defaults to UTF8 + + .EXAMPLE + PS C:\> Get-Content .\code.ps1 -Raw | ConvertTo-Base64 + + Reads the input file and converts its content into base64. +#> + [OutputType([string])] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string[]] + $Text, + + [System.Text.Encoding] + $Encoding = [System.Text.Encoding]::UTF8 + ) + + process { + foreach ($entry in $Text) { + $bytes = $Encoding.GetBytes($entry) + [Convert]::ToBase64String($bytes) + } + } +} + +function ConvertTo-Hashtable { + <# + .SYNOPSIS + Converts input objects into hashtables. + + .DESCRIPTION + Converts input objects into hashtables. + Allows explicitly including some properties only and remapping key-names as required. + + .PARAMETER Include + Only select the specified properties. + + .PARAMETER Mapping + Remap hashtable/property keys. + This allows you to rename parameters before passing them through to other commands. + Example: + @{ Select = '$select' } + This will map the "Select"-property/key on the input object to be '$select' on the output item. + + .PARAMETER InputObject + The object to convert. + + .EXAMPLE + PS C:\> $__body = $PSBoundParameters | ConvertTo-Hashtable -Include Name, UserID -Mapping $__mapping + + Converts the object $PSBoundParameters into a hashtable, including the keys "Name" and "UserID" and remapping them as specified in $__mapping + #> + [OutputType([hashtable])] + [CmdletBinding()] + param ( + [AllowEmptyCollection()] + [string[]] + $Include, + + [Hashtable] + $Mapping = @{ }, + + [Parameter(ValueFromPipeline = $true)] + $InputObject + ) + + process { + $result = @{ } + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($pair in $InputObject.GetEnumerator()) { + if ($pair.Key -notin $Include) { continue } + if ($Mapping[$pair.Key]) { $result[$Mapping[$pair.Key]] = $pair.Value } + else { $result[$pair.Key] = $pair.Value } + } + } + else { + foreach ($property in $InputObject.PSObject.Properties) { + if ($property.Name -notin $Include) { continue } + if ($Mapping[$property.Name]) { $result[$Mapping[$property.Name]] = $property.Value } + else { $result[$property.Name] = $property.Value } + } + } + $result + } +} + +function ConvertTo-QueryString { + <# + .SYNOPSIS + Convert conditions in a hashtable to a Query string to append to a webrequest. + + .DESCRIPTION + Convert conditions in a hashtable to a Query string to append to a webrequest. + + .PARAMETER QueryHash + Hashtable of query modifiers - usually filter conditions - to include in a web request. + + .PARAMETER DefaultQuery + Default query parameters defined in the service configuration. + Default query settings are overriden by explicit query parameters. + + .EXAMPLE + PS C:\> ConvertTo-QueryString -QueryHash $Query + + Converts the conditions in the specified hashtable to a Query string to append to a webrequest. + #> + [OutputType([string])] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [Hashtable] + $QueryHash, + + [AllowNull()] + [hashtable] + $DefaultQuery + ) + + process { + if ($DefaultQuery) { $query = $DefaultQuery.Clone() } + else { $query = @{} } + + foreach ($key in $QueryHash.Keys) { + $query[$key] = $QueryHash[$key] + } + if ($query.Count -lt 1) { return '' } + + + $elements = foreach ($pair in $query.GetEnumerator()) { + '{0}={1}' -f $pair.Name, ($pair.Value -join ",") + } + '?{0}' -f ($elements -join '&') + } +} + +function ConvertTo-SignedString { +<# + .SYNOPSIS + Signs input string with the offered certificate. + + .DESCRIPTION + Signs input string with the offered certificate. + + .PARAMETER Text + The text to sign. + + .PARAMETER Certificate + The certificate to sign with. + The Private Key must be available. + + .PARAMETER Padding + What RSA Signature padding to use. + Defaults to Pkcs1 + + .PARAMETER Algorithm + What algorithm to use for signing. + Defaults to SHA256 + + .PARAMETER Encoding + The encoding to use for transforming the text to bytes before signing it. + Defaults to UTF8 + + .EXAMPLE + PS C:\> ConvertTo-SignedString -Text $token + + Signs the specified token +#> + [OutputType([string])] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string[]] + $Text, + + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $Certificate, + + [Security.Cryptography.RSASignaturePadding] + $Padding = [Security.Cryptography.RSASignaturePadding]::Pkcs1, + + [Security.Cryptography.HashAlgorithmName] + $Algorithm = [Security.Cryptography.HashAlgorithmName]::SHA256, + + [System.Text.Encoding] + $Encoding = [System.Text.Encoding]::UTF8 + ) + + begin { + $privateKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) + } + process { + foreach ($entry in $Text) { + $inBytes = $Encoding.GetBytes($entry) + $outBytes = $privateKey.SignData($inBytes, $Algorithm, $Padding) + [convert]::ToBase64String($outBytes) + } + } +} + +function Invoke-TerminatingException { + <# + .SYNOPSIS + Throw a terminating exception in the context of the caller. + + .DESCRIPTION + Throw a terminating exception in the context of the caller. + Masks the actual code location from the end user in how the message will be displayed. + + .PARAMETER Cmdlet + The $PSCmdlet variable of the calling command. + + .PARAMETER Message + The message to show the user. + + .PARAMETER Exception + A nested exception to include in the exception object. + + .PARAMETER Category + The category of the error. + + .PARAMETER ErrorRecord + A full error record that was caught by the caller. + Use this when you want to rethrow an existing error. + + .EXAMPLE + PS C:\> Invoke-TerminatingException -Cmdlet $PSCmdlet -Message 'Unknown calling module' + + Terminates the calling command, citing an unknown caller. +#> + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $true)] + $Cmdlet, + + [string] + $Message, + + [System.Exception] + $Exception, + + [System.Management.Automation.ErrorCategory] + $Category = [System.Management.Automation.ErrorCategory]::NotSpecified, + + [System.Management.Automation.ErrorRecord] + $ErrorRecord + ) + + process { + if ($ErrorRecord -and -not $Message) { + $Cmdlet.ThrowTerminatingError($ErrorRecord) + } + + $exceptionType = switch ($Category) { + default { [System.Exception] } + 'InvalidArgument' { [System.ArgumentException] } + 'InvalidData' { [System.IO.InvalidDataException] } + 'AuthenticationError' { [System.Security.Authentication.AuthenticationException] } + 'InvalidOperation' { [System.InvalidOperationException] } + } + + + if ($Exception) { $newException = $Exception.GetType()::new($Message, $Exception) } + elseif ($ErrorRecord) { + try { $newException = $ErrorRecord.Exception.GetType()::new($Message, $ErrorRecord.Exception) } + catch { $newException = [System.Exception]::new($Message, $ErrorRecord.Exception) } + } + else { $newException = $exceptionType::new($Message) } + $record = [System.Management.Automation.ErrorRecord]::new($newException, (Get-PSCallStack)[1].FunctionName, $Category, $Target) + $Cmdlet.ThrowTerminatingError($record) + } +} + +function Read-TokenData { + <# + .SYNOPSIS + Reads a JWT token and converts it into a custom object showing its properties. + + .DESCRIPTION + Reads a JWT token and converts it into a custom object showing its properties. + + .PARAMETER Token + The JWT Token to parse + + .EXAMPLE + PS C:\> Read-TokenData -Token $authresponse.access_token + + Reads the settings on the returned access token. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)] + [string] + $Token + ) + process { + $tokenPayload = $Token.Split(".")[1].Replace('-', '+').Replace('_', '/') + # Pad with "=" until string length modulus 4 reaches 0 + while ($tokenPayload.Length % 4) { $tokenPayload += "=" } + $bytes = [System.Convert]::FromBase64String($tokenPayload) + [System.Text.Encoding]::ASCII.GetString($bytes) | ConvertFrom-Json + } +} + +function Resolve-Certificate { + <# + .SYNOPSIS + Helper function to resolve certificate input. + + .DESCRIPTION + Helper function to resolve certificate input. + This function expects the full $PSBoundParameters from the calling command and will (in this order) look for these parameter names: + + + Certificate: A full X509Certificate2 object with private key + + CertificateThumbprint: The thumbprint of a certificate to use. Will look first in the user store, then the machine store for it. + + CertificateName: The subject of the certificate to look for. Will look first in the user store, then the machine store for it. Will select the certificate with the longest expiration period. + + CertificatePath: Path to a PFX file to load. Also expects a CertificatePassword parameter to unlock the file. + + .PARAMETER BoundParameters + The $PSBoundParameter variable of the caller to simplify passthrough. + See Description for more details on what the command expects, + + .EXAMPLE + PS C:\> $certificateObject = Resolve-Certificate -BoundParameters $PSBoundParameters + + Resolves the certificate based on the parameters provided to the calling command. + #> + [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] + [CmdletBinding()] + param ( + $BoundParameters + ) + + if ($BoundParameters.Certificate) { return $BoundParameters.Certificate } + if ($BoundParameters.CertificateThumbprint) { + if (Test-Path -Path "cert:\CurrentUser\My\$($BoundParameters.CertificateThumbprint)") { + return Get-Item "cert:\CurrentUser\My\$($BoundParameters.CertificateThumbprint)" + } + if (Test-Path -Path "cert:\LocalMachine\My\$($BoundParameters.CertificateThumbprint)") { + return Get-Item "cert:\LocalMachine\My\$($BoundParameters.CertificateThumbprint)" + } + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Unable to find certificate with thumbprint '$($BoundParameters.CertificateThumbprint)'" + } + if ($BoundParameters.CertificateName) { + if ($certificate = @(Get-ChildItem 'Cert:\CurrentUser\My\').Where{ $_.Subject -eq $BoundParameters.CertificateName -and $_.HasPrivateKey }) { + return $certificate | Sort-Object NotAfter -Descending | Select-Object -First 1 + } + if ($certificate = @(Get-ChildItem 'Cert:\LocalMachine\My\').Where{ $_.Subject -eq $BoundParameters.CertificateName -and $_.HasPrivateKey }) { + return $certificate | Sort-Object NotAfter -Descending | Select-Object -First 1 + } + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Unable to find certificate with subject '$($BoundParameters.CertificateName)'" + } + if ($BoundParameters.CertificatePath) { + try { [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($BoundParameters.CertificatePath, $BoundParameters.CertificatePassword) } + catch { + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Unable to load certificate from file '$($BoundParameters.CertificatePath)': $_" -ErrorRecord $_ + } + } +} + +function Resolve-RequestUri { + <# + .SYNOPSIS + Resolves the actual Uri used for a request in Invoke-EntraRequest. + + .DESCRIPTION + Resolves the actual Uri used for a request in Invoke-EntraRequest. + If the path provided is a full url, it will be returned as is. + Otherwise, any present parameters will be resolved in the base service url before merging it with the specified path. + + .PARAMETER TokenObject + The object representing the token used for the request. + + .PARAMETER ServiceObject + The service object (if any) used with the request. + The parameters to be inserted into the query will be read from here. + + .PARAMETER BoundParameters + The parameters provided to Invoke-EntraRequest. + + .EXAMPLE + PS C:\> Resolve-RequestUri -TokenObject $tokenObject -ServiceObject $script:_EntraEndpoints.$($tokenObject.Service) -BoundParameters $PSBoundParameters + + Resolves the uri for the needed request based on token, service and parameters provided + #> + [OutputType([string])] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + $TokenObject, + + [Parameter(Mandatory = $true)] + [AllowNull()] + $ServiceObject, + + [Parameter(Mandatory = $true)] + $BoundParameters + ) + process { + if ($BoundParameters.Path -match '^https{0,1}://') { + return $BoundParameters.Path + } + + $serviceUrlBase = $TokenObject.ServiceUrl.Trim() + foreach ($key in $ServiceObject.Parameters.Keys) { + $serviceUrlBase = $serviceUrlBase -replace "%$key%", $BoundParameters.$key + } + + "$($serviceUrlBase.TrimEnd('/'))/$($Path.TrimStart('/'))" + } +} + +function Resolve-ScopeName { + <# + .SYNOPSIS + Normalizes scope names. + + .DESCRIPTION + Normalizes scope names. + To help manage correct scopes naming with services that don't map directly to their urls. + + .PARAMETER Scopes + The scopes to normalize. + + .PARAMETER Resource + The Resource the scopes are meant for. + + .EXAMPLE + PS C:\> $scopes | Resolve-ScopeName -Resource $Resource + + Resolves all them scopes + #> + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true)] + [string[]] + $Scopes, + + [Parameter(Mandatory = $true)] + [string] + $Resource + ) + process { + foreach ($scope in $Scopes) { + foreach ($scope in $Scopes) { + if ($scope -like 'https://*/*') { $scope } + elseif ($scope -like 'api:/') { $scope } + else { "{0}/{1}" -f $Resource, $scope } + } + } + } +} + +function Assert-ServiceName { + <# + .SYNOPSIS + Asserts a service name actually exists. + + .DESCRIPTION + Asserts a service name actually exists. + Used in validation scripts to ensure proper service names were provided. + + .PARAMETER Name + The name of the service to verify. + + .PARAMETER IncludeTokens + Also include registered token's services in the assertion. + By default, the assertion will only verify the existence of registered services. + + .EXAMPLE + PS C:\> Assert-ServiceName -Name $_ + + Returns $true if the service exists and throws a terminating exception if not so. + #> + [OutputType([bool])] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [AllowNull()] + [string] + $Name, + + [switch] + $IncludeTokens + ) + process { + if ($script:_EntraEndpoints.Keys -contains $Name) { return $true } + if ($IncludeTokens -and $script:_EntraTokens.Keys -contains $Name) { return $true } + + $serviceNames = $script:_EntraEndpoints.Keys -join ', ' + Write-Warning "Invalid service name: '$Name'. Legal service names: $serviceNames" + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Invalid service name: '$Name'. Legal service names: $serviceNames" + } +} + +function global:Get-ServiceCompletion { + <# + .SYNOPSIS + Returns the values to complete for.service names. + + .DESCRIPTION + Returns the values to complete for.service names. + Use this command in argument completers. + + .PARAMETER ArgumentList + The arguments an argumentcompleter receives. + The third item will be the word to complete. + + .EXAMPLE + PS C:\> Get-ServiceCompletion -ArgumentList $args + + Returns the values to complete for.service names. + #> + [OutputType([System.Management.Automation.CompletionResult])] + [CmdletBinding()] + param ( + $ArgumentList + ) + process { + $wordToComplete = $ArgumentList[2].Trim("'`"") + foreach ($service in Get-EntraService) { + if ($service.Name -notlike "$($wordToComplete)*") { continue } + + $text = if ($service.Name -notmatch '\s') { $service.Name } else { "'$($service.Name)'" } + [System.Management.Automation.CompletionResult]::new( + $text, + $text, + 'Text', + $service.ServiceUrl + ) + } + } +} +$ExecutionContext.InvokeCommand.GetCommand("Get-ServiceCompletion","Function").Visibility = 'Private' + +function Assert-EntraConnection +{ +<# + .SYNOPSIS + Asserts a connection has been established. + + .DESCRIPTION + Asserts a connection has been established. + Fails the calling command in a terminating exception if not connected yet. + + .PARAMETER Service + The service to which a connection needs to be established. + + .PARAMETER Cmdlet + The $PSCmdlet variable of the calling command. + Used to execute the terminating exception in the caller scope if needed. + + .PARAMETER RequiredScopes + Scopes needed, for better error messages. + + .EXAMPLE + PS C:\> Assert-EntraConnection -Service 'Endpoint' -Cmdlet $PSCmdlet + + Silently does nothing if already connected to the specified defender for endpoint service. + Kills the calling command if not yet connected. +#> + [CmdletBinding()] + param ( + [ArgumentCompleter({ Get-ServiceCompletion $args })] + [Parameter(Mandatory = $true)] + [string] + $Service, + + [Parameter(Mandatory = $true)] + $Cmdlet, + + [AllowEmptyCollection()] + [string[]] + $RequiredScopes + ) + + process + { + if ($script:_EntraTokens["$Service"]) { return } + + $message = "Not connected yet! Use Connect-EntraService to establish a connection to '$Service' first." + if ($RequiredScopes) { $message = $message + " Scopes required for this call: $($RequiredScopes -join ', ')"} + Invoke-TerminatingException -Cmdlet $Cmdlet -Message $message -Category ConnectionError + } +} + +function Connect-EntraService { + <# + .SYNOPSIS + Establish a connection to an Entra Service. + + .DESCRIPTION + Establish a connection to an Entra Service. + Prerequisite before executing any requests / commands. + + .PARAMETER ClientID + ID of the registered/enterprise application used for authentication. + + Supports providing special labels as "ID": + + Azure: Resolves to the actual ID of the first party app used by Connect-AzAccount + + Graph: Resolves to the actual ID of the first party app used by Connect-MgGraph + + .PARAMETER TenantID + The ID of the tenant/directory to connect to. + + .PARAMETER Scopes + Any scopes to include in the request. + Only used for interactive/delegate workflows, ignored for Certificate based authentication or when using Client Secrets. + + .PARAMETER Browser + Use an interactive logon in your default browser. + This is the default logon experience. + + .PARAMETER RedirectUri + The Redirect URI to send with the request. + May be different from the actual local port you want to listen on (e.g. when redirecting to an alternative name on top of localhost). + + .PARAMETER BrowserMode + How the browser used for authentication is selected. + Options: + + Auto (default): Automatically use the default browser. + + PrintLink: The link to open is printed on console and user selects which browser to paste it into (must be used on the same machine) + + .PARAMETER DeviceCode + Use the Device Code delegate authentication flow. + This will prompt the user to complete login via browser. + + .PARAMETER RefreshToken + Use an already existing RefreshToken to authenticate. + Can be used to connect to multiple services using a single interactive delegate auth flow. + + .PARAMETER RefreshTokenObject + Use the full token object of a delegate session with a refresh token, to authenticate to another service with this object. + Can be used to connect to multiple services using a single interactive delegate auth flow. + + .PARAMETER Certificate + The Certificate object used to authenticate with. + + Part of the Application Certificate authentication workflow. + + .PARAMETER CertificateThumbprint + Thumbprint of the certificate to authenticate with. + The certificate must be stored either in the user or computer certificate store. + + Part of the Application Certificate authentication workflow. + + .PARAMETER CertificateName + The name/subject of the certificate to authenticate with. + The certificate must be stored either in the user or computer certificate store. + The newest certificate with a private key will be chosen. + + Part of the Application Certificate authentication workflow. + + .PARAMETER CertificatePath + Path to a PFX file containing the certificate to authenticate with. + + Part of the Application Certificate authentication workflow. + + .PARAMETER CertificatePassword + Password to use to read a PFX certificate file. + Only used together with -CertificatePath. + + Part of the Application Certificate authentication workflow. + + .PARAMETER ClientSecret + The client secret configured in the registered/enterprise application. + + Part of the Client Secret Certificate authentication workflow. + + .PARAMETER Credential + The username / password to authenticate with. + + Part of the Resource Owner Password Credential (ROPC) workflow. + + .PARAMETER VaultName + Name of the Azure Key Vault from which to retrieve the certificate or client secret used for the authentication. + Secrets retrieved from the vault are not cached, on token expiration they will be retrieved from the Vault again. + In order for this flow to work, please ensure that you either have an active AzureKeyVault service connection, + or are connected via Connect-AzAccount. + + .PARAMETER SecretName + Name of the secret to use from the Azure Key Vault specified through the '-VaultName' parameter. + In order for this flow to work, please ensure that you either have an active AzureKeyVault service connection, + or are connected via Connect-AzAccount. + Supports specifying _multiple_ secret names, in which case the first one that works will be used. + + .PARAMETER Identity + Log on as the Managed Identity of the current system. + Only works in environments with managed identities, such as Azure Function Apps or Runbooks. + + .PARAMETER IdentityID + ID of the User-Managed Identity to connect as. + https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity + + .PARAMETER IdentityType + Type of the User-Managed Identity. + + .PARAMETER FallBackAzAccount + When logon as Managed Identity fails, try logging in as current AzAccount. + This is intended to allow easier local testing of code intended for an MSI environment, such as an Azure Function App. + + .PARAMETER AsAzAccount + Reuse the existing Az.Accounts session to authenticate. + This is convenient as no further interaction is needed, but also limited in what scopes are available. + This authentication flow requires the 'Az.Accounts' module to be present, loaded and connected. + Use 'Connect-AzAccount' to connect first. + + .PARAMETER ShowDialog + Whether to show an interactive dialog when connecting using the existing Az.Accounts session. + Defaults to: "auto" + + Options: + - auto: Shows dialog only if needed. + - always: Will always show the dialog, forcing interaction. + - never: Will never show the dialog. Authentication will fail if interaction is required. + + .PARAMETER Federated + Use federated credentials to authenticate. + This authentication flow is specific to a given environment and can for example enable a Github Action in a specific repository on a specific branch to authenticate, without needing to provide (and manage) a credential. + Some setup is required. + + By default, this command is going to check all provided configurations ("Federation Providers") registered to EntraAuth and use the first that applies. + Use "-FederationProvider" to pick a specific one to use. + Use "-Assertion" to handle the federated identity provider outside of EntraAuth and simply provide the result for logon. + + .PARAMETER FederationProvider + The name of the Federation Provider to use. Overrides the automatic selection. + Federation Providers are an EntraAuth concept and used to automatically do what is needed to access and use a Federated Credential, based on its environment. + See the documentation on Register-EntraFederationProvider for more details. + + .PARAMETER Assertion + The credentials from the federated identity provider to use in an Federated Credentials authentication flow. + + .PARAMETER Service + The service to connect to. + Individual commands using Invoke-EntraRequest specify the service to use and thus identify the token needed. + Defaults to: Graph + + .PARAMETER ServiceUrl + The base url for requests to the service connecting to. + Overrides the default service url configured with the service settings. + + .PARAMETER Resource + The resource to authenticate to. + Used to authenticate to a service without requiring a full service configuration. + Automatically implies PassThru. + This token is not registered as a service and cannot be implicitly used by Invoke-EntraRequest. + Also provide the "-ServiceUrl" parameter, if you later want to use this token explicitly in Invoke-EntraRequest. + + .PARAMETER UseRefreshToken + Use a refresh token if available. + Only applicable when connecting using a delegate authentication flow. + If specified, it will look to reuse an existing refresh token for that same client ID & tenant ID, if present, + making the authentication process non-interactive. + By default, it would always do the fully interactive authentication flow via Browser. + + .PARAMETER MakeDefault + Makes this service the new default service for all subsequent Connect-EntraService & Invoke-EntraRequest calls. + + .PARAMETER PassThru + Return the token received for the current connection. + + .PARAMETER Environment + What environment this service should connect to. + Defaults to: 'Global' + + .PARAMETER AuthenticationUrl + The url used for the authentication requests to retrieve tokens. + Usually determined by service connected to or the "Environment" parameter, but may be overridden in case of need. + + .EXAMPLE + PS C:\> Connect-EntraService -ClientID $clientID -TenantID $tenantID + + Establish a connection to the graph API, prompting the user for login on their default browser. + + .EXAMPLE + PS C:\> connect-EntraService -AsAzAccount + + Establish a connection to the graph API, using the current Az.Accounts session. + + .EXAMPLE + PS C:\> Connect-EntraService -ClientID $clientID -TenantID $tenantID -Certificate $cert + + Establish a connection to the graph API using the provided certificate. + + .EXAMPLE + PS C:\> Connect-EntraService -ClientID $clientID -TenantID $tenantID -CertificatePath C:\secrets\certs\mde.pfx -CertificatePassword (Read-Host -AsSecureString) + + Establish a connection to the graph API using the provided certificate file. + Prompts you to enter the certificate-file's password first. + + .EXAMPLE + PS C:\> Connect-EntraService -Service Endpoint -ClientID $clientID -TenantID $tenantID -ClientSecret $secret + + Establish a connection to Defender for Endpoint using a client secret. + + .EXAMPLE + PS C:\> Connect-EntraService -ClientID $clientID -TenantID $tenantID -VaultName myVault -Secretname GraphCert + + Establish a connection to the graph API, after retrieving the necessary certificate from the specified Azure Key Vault. +#> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidDefaultValueForMandatoryParameter", "")] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] + [CmdletBinding(DefaultParameterSetName = 'Browser')] + param ( + [Parameter(Mandatory = $true, ParameterSetName = 'Browser')] + [Parameter(Mandatory = $true, ParameterSetName = 'DeviceCode')] + [Parameter(Mandatory = $true, ParameterSetName = 'Refresh')] + [Parameter(Mandatory = $true, ParameterSetName = 'AppCertificate')] + [Parameter(Mandatory = $true, ParameterSetName = 'AppSecret')] + [Parameter(Mandatory = $true, ParameterSetName = 'UsernamePassword')] + [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')] + [Parameter(Mandatory = $true, ParameterSetName = 'Federated')] + [ArgumentCompleter({ 'Graph', 'Azure' })] + [string] + $ClientID, + + [Parameter(ParameterSetName = 'Browser')] + [Parameter(Mandatory = $true, ParameterSetName = 'DeviceCode')] + [Parameter(Mandatory = $true, ParameterSetName = 'Refresh')] + [Parameter(Mandatory = $true, ParameterSetName = 'AppCertificate')] + [Parameter(Mandatory = $true, ParameterSetName = 'AppSecret')] + [Parameter(Mandatory = $true, ParameterSetName = 'UsernamePassword')] + [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')] + [Parameter(Mandatory = $true, ParameterSetName = 'Federated')] + [string] + $TenantID = 'organizations', + + [Parameter(ParameterSetName = 'Browser')] + [Parameter(ParameterSetName = 'DeviceCode')] + [Parameter(ParameterSetName = 'Refresh')] + [Parameter(ParameterSetName = 'RefreshObject')] + [string[]] + $Scopes, + + [Parameter(ParameterSetName = 'Browser')] + [switch] + $Browser, + + [Parameter(ParameterSetName = 'Browser')] + [string] + $RedirectUri, + + [Parameter(ParameterSetName = 'Browser')] + [ValidateSet('Auto', 'PrintLink')] + [string] + $BrowserMode = 'Auto', + + [Parameter(Mandatory = $true, ParameterSetName = 'DeviceCode')] + [switch] + $DeviceCode, + + [Parameter(Mandatory = $true, ParameterSetName = 'Refresh')] + [string] + $RefreshToken, + + [Parameter(Mandatory = $true, ParameterSetName = 'RefreshObject')] + [EntraToken] + $RefreshTokenObject, + + [Parameter(ParameterSetName = 'AppCertificate')] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $Certificate, + + [Parameter(ParameterSetName = 'AppCertificate')] + [string] + $CertificateThumbprint, + + [Parameter(ParameterSetName = 'AppCertificate')] + [string] + $CertificateName, + + [Parameter(ParameterSetName = 'AppCertificate')] + [string] + $CertificatePath, + + [Parameter(ParameterSetName = 'AppCertificate')] + [System.Security.SecureString] + $CertificatePassword, + + [Parameter(Mandatory = $true, ParameterSetName = 'AppSecret')] + [System.Security.SecureString] + $ClientSecret, + + [Parameter(Mandatory = $true, ParameterSetName = 'UsernamePassword')] + [PSCredential] + $Credential, + + [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')] + [string] + $VaultName, + + [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')] + [string[]] + $SecretName, + + [Parameter(Mandatory = $true, ParameterSetName = 'Identity')] + [switch] + $Identity, + + [Parameter(ParameterSetName = 'Identity')] + [string] + $IdentityID, + + [Parameter(ParameterSetName = 'Identity')] + [ValidateSet('ClientID', 'ResourceID', 'PrincipalID')] + [string] + $IdentityType = 'ClientID', + + [Parameter(ParameterSetName = 'Identity')] + [Parameter(ParameterSetName = 'Federated')] + [switch] + $FallBackAzAccount, + + [Parameter(Mandatory = $true, ParameterSetName = 'AzAccount')] + [switch] + $AsAzAccount, + + [Parameter(ParameterSetName = 'AzAccount')] + [ValidateSet('Auto', 'Always', 'Never')] + [string] + $ShowDialog = 'Auto', + + [Parameter(ParameterSetName = 'Federated')] + [switch] + $Federated, + + [Parameter(ParameterSetName = 'Federated')] + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + foreach ($provider in Get-EntraFederationProvider) { + if ($provider.Name -notlike "$wordToComplete*") { continue } + $text = $provider.Name + if ($text -match '\s') { $text = "'$($provider.Name -replace "'", "''")'" } + + [System.Management.Automation.CompletionResult]::new($text, $text, 'ParameterValue', $provider.Description) + } + })] + [ValidateScript({ + $providers = (Get-EntraFederationProvider).Name + if ($_ -in $providers) { return $true } + + $message = "Unknown Federation Provider! '$_' - known providers: $($providers -join ', ')" + Write-Warning $message + throw $message + })] + [string] + $FederationProvider, + + [Parameter(ParameterSetName = 'Federated')] + [string] + $Assertion, + + [ArgumentCompleter({ Get-ServiceCompletion $args })] + [ValidateScript({ Assert-ServiceName -Name $_ })] + [string[]] + $Service = $script:_DefaultService, + + [string] + $ServiceUrl, + + [string] + $Resource, + + [Parameter(ParameterSetName = 'Browser')] + [Parameter(ParameterSetName = 'DeviceCode')] + [switch] + $UseRefreshToken, + + [switch] + $MakeDefault, + + [switch] + $PassThru, + + [Environment] + $Environment, + + [string] + $AuthenticationUrl + ) + begin { + $doRegister = $PSBoundParameters.Keys -notcontains 'Resource' + $doPassThru = $PassThru -or $Resource + + switch ($ClientID) { + 'Graph' { $ClientID = '14d82eec-204b-4c2f-b7e8-296a70dab67e' } + 'Azure' { $ClientID = '1950a258-227b-4e31-a9cf-717495945fc2' } + } + } + process { + #region UseRereshToken + $availableToken = $null + if ($UseRefreshToken) { + $availableToken = Get-EntraToken | Where-Object { + $_.ClientID -eq $ClientID -and + ( + $_.TenantID -eq $TenantID -or + $TenantID -eq 'organizations' + ) -and + $_.RefreshToken + } | Sort-Object ValidUntil -Descending | Select-Object -First 1 + } + if ($availableToken) { + $param = @{ } + foreach ($parameterName in $PSCmdlet.MyInvocation.MyCommand.ParameterSets.Where{ $_.Name -eq 'RefreshObject' }.Parameters.Name) { + if ($PSBoundParameters.Keys -contains $parameterName) { $param[$parameterName] = $PSBoundParameters[$parameterName] } + } + Connect-EntraService @param -RefreshTokenObject $availableToken + return + } + #endregion UseRereshToken + + foreach ($serviceName in $Service) { + $serviceObject = $null + if (-not $Resource) { + $serviceObject = Get-EntraService -Name $serviceName + } + else { + $serviceName = '' + } + + #region AuthenticationUrl + $authUrl = switch ("$Environment") { + 'China' { 'https://login.chinacloudapi.cn' } + 'USGov' { 'https://login.microsoftonline.us' } + 'USGovDOD' { 'https://login.microsoftonline.us' } + default { 'https://login.microsoftonline.com' } + } + if ($AuthenticationUrl) { $authUrl = $AuthenticationUrl.TrimEnd('/') } + + if ( + $serviceObject.AuthenticationUrl -and + $PSBoundParameters.Keys -notcontains 'Environment' -and + $PSBoundParameters.Keys -notcontains 'AuthenticationUrl' + ) { + $authUrl = $serviceObject.AuthenticationUrl + } + #endregion AuthenticationUrl + + $commonParam = @{ + ClientID = $ClientID + TenantID = $TenantID + Resource = $serviceObject.Resource + AuthenticationUrl = $authUrl + } + + #region Service Url + $effectiveServiceUrl = $ServiceUrl + if (-not $ServiceUrl -and $serviceObject) { $effectiveServiceUrl = $serviceObject.ServiceUrl } + if ($Resource) { $commonParam.Resource = $Resource } + + # If users explicitly provide a service URL, who are we to override that? + if (-not $ServiceUrl) { + if ('USGovDOD' -eq $Environment) { $effectiveServiceUrl = $effectiveServiceUrl -replace '^https://graph.microsoft.com', 'https://dod-graph.microsoft.us' -replace '^https://manage.azure.com', 'https://manage.usgovcloudapi.net' } + elseif ($authUrl -eq 'https://login.microsoftonline.us') { $effectiveServiceUrl = $effectiveServiceUrl -replace '^https://graph.microsoft.com', 'https://graph.microsoft.us' -replace '^https://manage.azure.com', 'https://manage.usgovcloudapi.net' } + elseif ($authUrl -eq 'https://login.chinacloudapi.cn') { $effectiveServiceUrl = $effectiveServiceUrl -replace '^https://graph.microsoft.com', 'https://microsoftgraph.chinacloudapi.cn' -replace '^https://manage.azure.com', 'https://management.core.chinacloudapi.cn' } + } + #endregion Service Url + + #region Connection + :main switch ($PSCmdlet.ParameterSetName) { + #region Browser + Browser { + $scopesToUse = $Scopes + if (-not $Scopes) { $scopesToUse = $serviceObject.DefaultScopes } + + Write-Verbose "[$serviceName] Connecting via Browser ($($scopesToUse -join ', '))" + try { $result = Connect-ServiceBrowser @commonParam -SelectAccount -Scopes $scopesToUse -NoReconnect:$($serviceObject.NoRefresh) -BrowserMode $BrowserMode -RedirectUri $RedirectUri -ErrorAction Stop } + catch { + Write-Warning "[$serviceName] Failed to connect: $_" + $PSCmdlet.ThrowTerminatingError($_) + } + + $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $effectiveServiceUrl, $false, $authUrl) + $token.RedirectUri = $RedirectUri + $token.SetTokenMetadata($result) + Write-Verbose "[$serviceName] Connected via Browser ($($token.Scopes -join ', '))" + } + #endregion Browser + + #region DeviceCode + DeviceCode { + $scopesToUse = $Scopes + if (-not $Scopes) { $scopesToUse = $serviceObject.DefaultScopes } + + Write-Verbose "[$serviceName] Connecting via DeviceCode ($($scopesToUse -join ', '))" + try { $result = Connect-ServiceDeviceCode @commonParam -Scopes $scopesToUse -NoReconnect:$($serviceObject.NoRefresh) -ErrorAction Stop } + catch { + Write-Warning "[$serviceName] Failed to connect: $_" + $PSCmdlet.ThrowTerminatingError($_) + } + + $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $effectiveServiceUrl, $true, $authUrl) + $token.SetTokenMetadata($result) + Write-Verbose "[$serviceName] Connected via DeviceCode ($($token.Scopes -join ', '))" + } + #endregion DeviceCode + + #region RefreshToken + Refresh { + $scopesToUse = $Scopes + if (-not $Scopes) { $scopesToUse = $serviceObject.DefaultScopes } + if (-not $scopesToUse) { $scopesToUse = '.default' } + + Write-Verbose "[$serviceName] Connecting via RefreshToken ($($scopesToUse -join ', '))" + try { $result = Connect-ServiceRefreshToken @commonParam -RefreshToken $RefreshToken -Scopes $scopesToUse -ErrorAction Stop } + catch { + Write-Warning "[$serviceName] Failed to connect: $_" + $PSCmdlet.ThrowTerminatingError($_) + } + + $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $effectiveServiceUrl, $false, $authUrl) + $token.Type = 'Refresh' + $token.SetTokenMetadata($result) + Write-Verbose "[$serviceName] Connected via RefreshToken ($($token.Scopes -join ', '))" + } + #endregion RefreshToken + + #region RefreshObject + RefreshObject { + $scopesToUse = $Scopes + if (-not $Scopes) { $scopesToUse = $serviceObject.DefaultScopes } + if (-not $scopesToUse) { $scopesToUse = '.default' } + + Write-Verbose "[$serviceName] Connecting via RefreshToken ($($scopesToUse -join ', '))" + try { $result = Connect-ServiceRefreshToken -ClientID $RefreshTokenObject.ClientID -TenantID $RefreshTokenObject.TenantID -Resource $commonParam.Resource -AuthenticationUrl $RefreshTokenObject.AuthenticationUrl -RefreshToken $RefreshTokenObject.RefreshToken -Scopes $scopesToUse -ErrorAction Stop } + catch { + Write-Warning "[$serviceName] Failed to connect: $_" + $PSCmdlet.ThrowTerminatingError($_) + } + + $token = [EntraToken]::new($serviceName, $RefreshTokenObject.ClientID, $RefreshTokenObject.TenantID, $effectiveServiceUrl, $false, $RefreshTokenObject.AuthenticationUrl) + $token.Type = 'Refresh' + $token.SetTokenMetadata($result) + Write-Verbose "[$serviceName] Connected via RefreshToken ($($token.Scopes -join ', '))" + } + #endregion RefreshObject + + #region ROPC + UsernamePassword { + Write-Verbose "[$serviceName] Connecting via Credential" + try { $result = Connect-ServicePassword @commonParam -Credential $Credential -ErrorAction Stop } + catch { + Write-Warning "[$serviceName] Failed to connect: $_" + $PSCmdlet.ThrowTerminatingError($_) + } + + $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $Credential, $effectiveServiceUrl, $authUrl) + $token.SetTokenMetadata($result) + Write-Verbose "[$serviceName] Connected via Credential ($($token.Scopes -join ', '))" + } + #endregion ROPC + + #region AppSecret + AppSecret { + Write-Verbose "[$serviceName] Connecting via AppSecret" + try { $result = Connect-ServiceClientSecret @commonParam -ClientSecret $ClientSecret -ErrorAction Stop } + catch { + Write-Warning "[$serviceName] Failed to connect: $_" + $PSCmdlet.ThrowTerminatingError($_) + } + + $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $ClientSecret, $effectiveServiceUrl, $authUrl) + $token.SetTokenMetadata($result) + Write-Verbose "[$serviceName] Connected via AppSecret ($($token.Scopes -join ', '))" + } + #endregion AppSecret + + #region AppCertificate + AppCertificate { + Write-Verbose "[$serviceName] Connecting via Certificate" + try { $certificateObject = Resolve-Certificate -BoundParameters $PSBoundParameters } + catch { + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Cannot resolve certificate" -ErrorRecord $_ -Category InvalidArgument + } + + try { $result = Connect-ServiceCertificate @commonParam -Certificate $certificateObject -ErrorAction Stop } + catch { + Write-Warning "[$serviceName] Failed to connect: $_" + $PSCmdlet.ThrowTerminatingError($_) + } + + $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $certificateObject, $effectiveServiceUrl, $authUrl) + $token.SetTokenMetadata($result) + Write-Verbose "[$serviceName] Connected via Certificate ($($token.Scopes -join ', '))" + } + #endregion AppCertificate + + #region KeyVault + KeyVault { + Write-Verbose "[$serviceName] Connecting via KeyVault" + $failure = $null + foreach ($secretEntry in $SecretName) { + try { $secret = Get-VaultSecret -VaultName $VaultName -SecretName $secretEntry } + catch { + Write-Warning "[$serviceName] Failed to retrieve secret from KeyVault: $_" + $PSCmdlet.ThrowTerminatingError($_) + } + try { + $result = switch ($secret.Type) { + Certificate { Connect-ServiceCertificate @commonParam -Certificate $secret.Certificate -ErrorAction Stop } + ClientSecret { Connect-ServiceClientSecret @commonParam -ClientSecret $secret.ClientSecret -ErrorAction Stop } + } + } + catch { + Write-Verbose "[$serviceName] Failed to connect using secret $($secretEntry): $_" + if (-not $failure) { $failure = $_ } + continue + } + $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $effectiveServiceUrl, $VaultName, $secretEntry, $authUrl) + $token.SetTokenMetadata($result) + + Write-Verbose "[$serviceName] Connected via KeyVault ($($token.Scopes -join ', '))" + break main + } + # Only reached if all secrets failed + $PSCmdlet.ThrowTerminatingError($failure) + } + #endregion KeyVault + + #region Identity + Identity { + Write-Verbose "[$serviceName] Connecting via Managed Identity" + + try { $result = Connect-ServiceIdentity -Resource $commonParam.Resource -IdentityID $IdentityID -IdentityType $IdentityType -ErrorAction Stop } + catch { + if (-not $FallBackAzAccount) { $PSCmdlet.ThrowTerminatingError($_) } + + try { + $newParam = @{} + $validParam = $PSCmdlet.MyInvocation.MyCommand.ParameterSets.Where{$_.Name -eq 'AzAccount'}.Parameters.Name + foreach ($pair in $PSBoundParameters.GetEnumerator()) { + if ($pair.Key -notin $validParam) { continue } + $newParam[$pair.Key] = $pair.Value + } + $newParam.AsAzAccount = $true + Connect-EntraService @newParam + + break main # Successfully connected + } + catch { + Write-Warning "Fallback to AzAccount failed: $_" + } + + $PSCmdlet.ThrowTerminatingError($_) + } + + $token = [EntraToken]::new($serviceName, $effectiveServiceUrl, $IdentityID, $IdentityType) + $token.SetTokenMetadata($result) + + Write-Verbose "[$serviceName] Connected via Managed Identity ($($token.Scopes -join ', '))" + } + #endregion Identity + + #region Federated + Federated { + Write-Verbose "[$serviceName] Connecting via Federated Credential" + + try { $result,$provider = Connect-ServiceFederated @commonParam -Assertion $Assertion -Provider $FederationProvider -ErrorAction Stop } + catch { + if (-not $FallBackAzAccount) { $PSCmdlet.ThrowTerminatingError($_) } + + try { + $newParam = @{} + $validParam = $PSCmdlet.MyInvocation.MyCommand.ParameterSets.Where{$_.Name -eq 'AzAccount'}.Parameters.Name + foreach ($pair in $PSBoundParameters.GetEnumerator()) { + if ($pair.Key -notin $validParam) { continue } + $newParam[$pair.Key] = $pair.Value + } + $newParam.AsAzAccount = $true + Connect-EntraService @newParam + + break main # Successfully connected + } + catch { + Write-Warning "Fallback to AzAccount failed: $_" + } + + $PSCmdlet.ThrowTerminatingError($_) + } + + $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $provider, $effectiveServiceUrl, $authUrl) + $token.SetTokenMetadata($result) + + Write-Verbose "[$serviceName] Connected via Federated Credential ($($token.Scopes -join ', '))" + } + #endregion Federated + + #region AzAccount + AzAccount { + Write-Verbose "[$serviceName] Connecting via existing Az.Accounts session" + + try { $result = Connect-ServiceAzure -Resource $commonParam.Resource -ShowDialog $ShowDialog -ErrorAction Stop } + catch { + Write-Warning "[$serviceName] Failed to connect: $_" + $PSCmdlet.ThrowTerminatingError($_) + } + + $token = [EntraToken]::new($serviceName, $effectiveServiceUrl, $ShowDialog) + $token.TenantID = $result.TenantID + $token.ClientID = $result.ClientID + $token.SetTokenMetadata($result) + + Write-Verbose "[$serviceName] Connected via existing Az.Accounts session ($($token.Scopes -join ', '))" + } + #endregion AzAccount + } + #endregion Connection + + #region Copy Service Metadata + if ($serviceObject) { + if ($serviceObject.Query.Count -gt 0) { + $token.Query = $serviceObject.Query.Clone() + } + if ($serviceObject.Header.Count -gt 0) { + $token.Header = $serviceObject.Header.Clone() + } + if ($serviceObject.RawOnly) { + $token.RawOnly = $true + } + } + if ($doRegister) { $script:_EntraTokens[$serviceName] = $token } + #endregion Copy Service Metadata + + if ($MakeDefault -and -not $Resource) { + $script:_DefaultService = $serviceName + } + if ($doPassThru) { $token } + } + } +} + +function Get-EntraService { + <# + .SYNOPSIS + Returns the list of available Entra ID services that can be connected to. + + .DESCRIPTION + Returns the list of available Entra ID services that can be connected to. + Includes for each the endpoint/service url and the default requested scopes. + + .PARAMETER Name + Name of the service to return. + Defaults to: * + + .EXAMPLE + PS C:\> Get-EntraService + + List all available services. + #> + [CmdletBinding()] + param ( + [ArgumentCompleter({ Get-ServiceCompletion $args })] + [string] + $Name = '*' + ) + process { + $script:_EntraEndpoints.Values | Where-Object Name -like $Name + } +} + +function Get-EntraToken { + <# + .SYNOPSIS + Returns the session token of an Entra ID connection. + + .DESCRIPTION + Returns the session token of an Entra ID connection. + The main use for those token objects is calling their "GetHeader()" method to get an authentication header + that automatically refreshes tokens as needed. + + .PARAMETER Service + The service for which to retrieve the token. + Defaults to: * + + .EXAMPLE + PS C:\> Get-EntraToken + + Returns all current session tokens + #> + [CmdletBinding()] + param ( + [ArgumentCompleter({ Get-ServiceCompletion $args })] + [string] + $Service = '*' + ) + process { + $script:_EntraTokens.Values | Where-Object Service -like $Service + } +} + +function Import-EntraToken { + <# + .SYNOPSIS + Imports a token into the local token store. + + .DESCRIPTION + Imports a token into the local token store. + This command is intended for use in a runspace scenario, for passing tokens from the main runspace into background environments. + The input-token object is cloned into a runspace-local token object and registered to its service. + + After performing the conversion, it will try to renew the token object. + + .PARAMETER Token + The token object to import. + Should be a token object created by EntraAuth's Connect-EntraService command. + Usually returned by Get-EntraToken, after finishing the connection. + + .PARAMETER PassThru + Rather than registering the token into the Entra token store in memory, return it as an object. + Useful for ronspace-localizing a token not associated with any given service. + + .PARAMETER NoRenew + Do not renew the token after importing it. + By default, newly localized tokens will try to renew themselves, to avoid parallel use of the same access token instance. + + .EXAMPLE + PS C:\> Import-EntraToken -Token $using:tokens + + Imports all tokens stored in $tokens of the calling runspace. + For use in scenarios such as background runspaces within "ForEach-Object -Parallel" + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [object[]] + $Token, + + [switch] + $PassThru, + + [switch] + $NoRenew + ) + process { + foreach ($tokenObject in $Token) { + $newToken = [EntraToken]::new() + foreach ($propertyName in $newToken.PSObject.Properties.Name) { + if ($null -eq $tokenObject.$propertyName) { continue } + if ($tokenObject.$propertyName -is [hashtable]) { + $newToken.$propertyName = $tokenObject.$propertyName.Clone() + } + else { + $newToken.$propertyName = $tokenObject.$propertyName + } + } + + if ( + -not $newToken.Service -or + -not $newToken.AccessToken -or + -not $newToken.ClientID -or + -not $newToken.TenantID + ) { + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Invalid Input Object! An Entra token object must have a Service, AccessToken, ClientID and TenantID. Item received: $tokenObject" + } + + #region Renew Token + if ( + -not $NoRenew -and + ( + $newToken.Type -notin 'DeviceCode', 'Browser' -or + $newToken.RefreshToken + ) + ) { + $newToken.RenewToken() + } + #endregion Renew Token + + if ($PassThru) { $newToken } + else { $script:_EntraTokens[$newToken.Service] = $newToken } + } + } +} + +function New-EntraCustomToken { + <# + .SYNOPSIS + Create a custom token compatible with EntraAuth. + + .DESCRIPTION + Create a custom token compatible with EntraAuth. + This allows directly integrating APIs that do not operate with default OAuth into the EntraAuth toolset. + + .PARAMETER ServiceUrl + The URL requests are sent against. + + .PARAMETER HeaderCode + The code that calculates the header (including authentication information) to include in the request. + The scriptblock receives one argument: The token itself. + Must return a single hashtable. + + .PARAMETER Service + The name of the service to register the token under. + Does not have to be a formally registered service, can be an arbitrary name. + Specifying this parameter prevents the token from being returned as output, unless combined with -PassThru. + + .PARAMETER PassThru + Return the token as output. + By default, when specifying a service name, this command produces no output. + + .PARAMETER TenantID + TenantID to connect to. + Purely cosmetic, unless accessed from the header code. + + .PARAMETER ClientID + ClientID to connect as. + Purely cosmetic, unless accessed from the header code. + + .PARAMETER Header + Header information to include in all requests against this API. + Additional header entries to include in every request. + Will be added to those returned from the HeaderCode and need not be considered within that code. + + .PARAMETER Query + Additional query parameters to include in all requests against this API. + + .PARAMETER Data + Additional information to store in the token object. + Used by the HeaderCode scriptblock. + Use this parameter to include information such as PATs, API keys or similar pieces of information. + + .PARAMETER RawOnly + All requests throug this token should not use the default response processing. + This will prevent Invoke-EntraRequest from providing most of its usual assistance. + + .EXAMPLE + PS C:\> New-EntraCustomToken -Service AzDevMyProject -ServiceUrl 'https://dev.azure.com/contoso/myproject/_apis/wit' -Data @{ PAT = $pat } -HeaderCode { + param ($Token) + $base64AuthInfo = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(':' + $($Token.Data.PAT | ConvertFrom-SecureString -AsPlainText))) + return @{ + Authorization = "Basic $base64AuthInfo" + 'Content-Type' = 'application/json' + } + } + + Registers a new token for Azure DevOps, using PAT to authenticate. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $ServiceUrl, + + [Parameter(Mandatory = $true)] + [scriptblock] + $HeaderCode, + + [string] + $Service, + + [switch] + $PassThru, + + [string] + $TenantID = '', + + [string] + $ClientID = '', + + [hashtable] + $Header = @{}, + + [hashtable] + $Query = @{}, + + [hashtable] + $Data = @{}, + + [switch] + $RawOnly + ) + process { + $newToken = [EntraToken]::new() + $newToken.Type = 'Custom' + $newToken.ServiceUrl = $ServiceUrl + $newToken.HeaderCode = $HeaderCode + if ($Service) { $newToken.Service = $Service } + else { $newToken.Service = '' } + $newToken.TenantID = $TenantID + $newToken.ClientID = $ClientID + $newToken.Header = $Header.Clone() + $newToken.Query = $Query.Clone() + $newToken.Data = $Data.Clone() + $newToken.RawOnly = $RawOnly.ToBool() + + if ($Service) { + $script:_EntraTokens[$Service] = $newToken + } + if ($PassThru -or -not $Service) { + $newToken + } + } +} + +function Register-EntraService { + <# + .SYNOPSIS + Define a new Entra ID Service to connect to. + + .DESCRIPTION + Define a new Entra ID Service to connect to. + This allows defining new endpoints to connect to ... or overriding existing endpoints to a different configuration. + + .PARAMETER Name + Name of the Service. + + .PARAMETER ServiceUrl + The base Url requests will use. + + .PARAMETER Resource + The Resource ID. Used when connecting to identify which scopes of an App Registration to use. + + .PARAMETER DefaultScopes + Default scopes to request. + Used in interactive delegate flows to provide a good default user experience. + Default scopes should usually include common read scenarios. + + .PARAMETER Header + Header data to include in each request. + + .PARAMETER HelpUrl + Link for more information about this service. + Ideally to documentation that helps setting up the connection. + + .PARAMETER NoRefresh + Delegate authentication flows should not request refresh tokens. + By default, delegate authentication flows will automatically request offline_access to get a refresh token. + This refresh token allows requesting new tokens when the current one is expiring without requiring additional + interactive logon actions. + However, not all services support this scope. + + .PARAMETER Parameters + Extra parameters a request will require. + It expects a hashtable with the key being the parameter name, and the value being a description of that parameter. + The ServiceUrl must include a placeholder for each parameter to insert into it. + + Example: + Parameter: @{ VaultName = 'Name of the Key Vault to execute against' } + ServiceUrl: https://%VAULTNAME%.vault.azure.net + + .PARAMETER Query + Extra Query Parameters to automatically include on all requests. + + .PARAMETER RawOnly + Disable default API response handling. + By default, when executing a request via Invoke-EntraRequest, the response is processed as if it were a default Graph API standard response. + Many other MS APIs follow the same standard, but not all do so. + When enabling this setting on a service, all requests against that service will NOT have that processing applied and instead return raw responses. + + .PARAMETER Environment + What environment this service should connect to. + Defaults to: 'Global' + + .PARAMETER AuthenticationUrl + The url used for the authentication requests to retrieve tokens. + Usually determined by the "Environment" parameter, but may be overridden in case of need. + + .EXAMPLE + PS C:\> Register-EntraService -Name Endpoint -ServiceUrl 'https://api.securitycenter.microsoft.com/api' -Resource 'https://api.securitycenter.microsoft.com' + + Registers the defender for endpoint API as a service. + + .EXAMPLE + PS C:\> Register-EntraService -Name 'PowerBI' -ServiceUrl 'https://api.powerbi.com/v1.0/myorg' -Resource 'https://analysis.windows.net/powerbi/api' -DefaultScopes @('Dataset.Read.All', 'Report.Read.All') -Header @{ 'Accept' = 'application/json' } + + Registers Power BI REST API service with common read scopes and required headers. + + .EXAMPLE + PS C:\> Register-EntraService -Name 'SharePointOnline' -ServiceUrl 'https://%TENANT%.sharepoint.com/_api' -Resource 'https://%TENANT%.sharepoint.com' -Parameters @{ Tenant = 'SharePoint tenant name (e.g., contoso)' } -DefaultScopes @('Sites.Read.All') -Query @{ '$format' = 'json' } + + Registers SharePoint Online REST API with dynamic tenant URL, requiring a tenant parameter and automatically formatting responses as JSON. + + .EXAMPLE + PS C:\> Register-EntraService -Name 'CustomCRM' -ServiceUrl 'https://crm.contoso.com/api/v2' -Resource 'api://contoso-crm-app' -NoRefresh -RawOnly -Header @{ 'X-API-Version' = '2.0'; 'Accept' = 'application/json' } + + Registers a custom CRM API that doesn't support refresh tokens and requires raw response handling with custom API versioning headers. + + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true)] + [string] + $ServiceUrl, + + [Parameter(Mandatory = $true)] + [string] + $Resource, + + [AllowEmptyCollection()] + [string[]] + $DefaultScopes = @(), + + [hashtable] + $Header = @{}, + + [string] + $HelpUrl, + + [switch] + $NoRefresh, + + [hashtable] + $Parameters = @{}, + + [Hashtable] + $Query = @{}, + + [switch] + $RawOnly, + + [Environment] + $Environment = 'Global', + + [string] + $AuthenticationUrl + ) + process { + $command = Get-Command Invoke-EntraRequest + $badParameters = $Parameters.Keys | Where-Object { $_ -in $command.Parameters.Keys } + if ($badParameters) { + Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Cannot define parameters that collide with Invoke-EntraRequest: $($badParameters -join ', ')" + } + $authUrl = switch ("$Environment") { + 'China' { 'https://login.chinacloudapi.cn' } + 'USGov' { 'https://login.microsoftonline.us' } + 'USGovDOD' { 'https://login.microsoftonline.us' } + default { 'https://login.microsoftonline.com' } + } + if ($AuthenticationUrl) { $authUrl = $AuthenticationUrl.TrimEnd('/') } + + $script:_EntraEndpoints[$Name] = [PSCustomObject]@{ + PSTypeName = 'EntraAuth.Service' + Name = $Name + ServiceUrl = $ServiceUrl + Resource = $Resource + DefaultScopes = $DefaultScopes + Header = $Header + HelpUrl = $HelpUrl + NoRefresh = $NoRefresh.ToBool() + Parameters = $Parameters + Query = $Query + RawOnly = $RawOnly.ToBool() + AuthenticationUrl = $authUrl + } + } +} + +function Set-EntraService { + <# + .SYNOPSIS + Modify the settings on an existing Service configuration. + + .DESCRIPTION + Modify the settings on an existing Service configuration. + Service configurations are defined using Register-EntraService and define how connections and requests to a specific API service / endpoint are performed. + + .PARAMETER Name + The name of the already existing Service configuration. + + .PARAMETER ServiceUrl + The base Url requests will use. + + .PARAMETER Resource + The Resource ID. Used when connecting to identify which scopes of an App Registration to use. + + .PARAMETER DefaultScopes + Default scopes to request. + Used in interactive delegate flows to provide a good default user experience. + Default scopes should usually include common read scenarios. + + .PARAMETER Header + Header data to include in each request. + + .PARAMETER HelpUrl + Link for more information about this service. + Ideally to documentation that helps setting up the connection. + + .PARAMETER NoRefresh + Delegate authentication flows should not request refresh tokens. + By default, delegate authentication flows will automatically request offline_access to get a refresh token. + This refresh token allows requesting new tokens when the current one is expiring without requiring additional + interactive logon actions. + However, not all services support this scope. + + .EXAMPLE + PS C:\> Set-EntraService -Name Endpoint -ServiceUrl 'https://api-us.securitycenter.microsoft.com/api' + + Changes the service url for the "Endpoint" service to 'https://api-us.securitycenter.microsoft.com/api'. + Note: It is generally recommened to select the service url most suitable for your tenant, geographically: + https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/api/exposed-apis-list?view=o365-worldwide#versioning + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [ArgumentCompleter({ Get-ServiceCompletion $args })] + [ValidateScript({ Assert-ServiceName -Name $_ })] + [string] + $Name, + + [string] + $ServiceUrl, + + [string] + $Resource, + + [AllowEmptyCollection()] + [string[]] + $DefaultScopes, + + [Hashtable] + $Header, + + [string] + $HelpUrl, + + [switch] + $NoRefresh + ) + process { + $service = $script:_EntraEndpoints.$Name + if ($PSBoundParameters.Keys -contains 'ServiceUrl') { $service.ServiceUrl = $ServiceUrl } + if ($PSBoundParameters.Keys -contains 'Resource') { $service.Resource = $Resource } + if ($PSBoundParameters.Keys -contains 'DefaultScopes') { $service.DefaultScopes = $DefaultScopes } + if ($PSBoundParameters.Keys -contains 'Header') { $service.Header = $Header } + if ($PSBoundParameters.Keys -contains 'HelpUrl') { $service.HelpUrl = $HelpUrl } + if ($PSBoundParameters.Keys -contains 'NoRefresh') { $service.HelpUrl = $NoRefresh.ToBool() } + } +} + +function Invoke-EntraRequest { + <# + .SYNOPSIS + Executes a web request against an entra-based service + + .DESCRIPTION + Executes a web request against an entra-based service + Handles all the authentication details once connected using Connect-EntraService. + + .PARAMETER Path + The relative path of the endpoint to query. + For example, to retrieve Microsoft Graph users, it would be a plain "users". + To access details on a particular defender for endpoint machine instead it would look thus: "machines/1e5bc9d7e413ddd7902c2932e418702b84d0cc07" + + .PARAMETER Body + Any body content needed for the request. + + .PARAMETER Query + Any query content to include in the request. + In opposite to -Body this is attached to the request Url and usually used for filtering. + + .PARAMETER Method + The Rest Method to use. + Defaults to GET + + .PARAMETER RequiredScopes + Any authentication scopes needed. + Used for documentary purposes only. + + .PARAMETER Header + Any additional headers to include on top of authentication and content-type. + + .PARAMETER ContentType + Specify the content-type of this request. + Equivalent to specifying it as a header entry, but added as dedicated parameter for user convenience. + + .PARAMETER Service + Which service to execute against. + Determines the API endpoint called to. + Defaults to "Graph" + + .PARAMETER SerializationDepth + How deeply to serialize the request body when converting it to json. + Defaults to: 99 + + .PARAMETER Token + A Token as created and maintained by this module. + If specified, it will override the -Service parameter. + + .PARAMETER NoPaging + Do not automatically page through responses sets. + By default, Invoke-EntraRequest is going to keep retrieving result pages until all data has been retrieved. + + .PARAMETER Raw + Do not process the response object and instead return the raw result returned by the API. + + .PARAMETER DeltaSession + A hashtable including delta sessions. + Use together with the delta endpoints, e.g. for the Graph API's user delta endpoint: + https://learn.microsoft.com/en-us/graph/api/user-delta + Provide an empty hashtable on the first request, the delta token data will be inserted into it. + Provide the same token for subsequent delta requests. + + This allows retrieving changes over time, without having to reload the entire dataset. + + .PARAMETER MinimalDelta + When receiving delta data, only return the changed properties (plus a unique identifier), rather than the full object. + Only used together with DeltaSession + + .EXAMPLE + PS C:\> Invoke-EntraRequest -Path 'alerts' -RequiredScopes 'Alert.Read' + + Return a list of defender alerts. + + .EXAMPLE + PS C:\> Invoke-EntraRequest -Path 'users/delta' -DeltaSession $delta + + Retrieves all users on first request. + Subsequent calls will only return users that have been changed in the meantime. +#> + [CmdletBinding(DefaultParameterSetName = 'default')] + param ( + [Parameter(Mandatory = $true)] + [string] + $Path, + + $Body, + + [Hashtable] + $Query = @{ }, + + [string] + $Method = 'GET', + + [string[]] + $RequiredScopes, + + [hashtable] + $Header = @{}, + + [string] + $ContentType, + + [ArgumentCompleter({ Get-ServiceCompletion $args })] + [ValidateScript({ Assert-ServiceName -Name $_ -IncludeTokens })] + [string] + $Service = $script:_DefaultService, + + [ValidateRange(1, 666)] + [int] + $SerializationDepth = 99, + + [EntraToken] + $Token, + + [switch] + $NoPaging, + + [switch] + $Raw, + + [hashtable] + $DeltaSession, + + [switch] + $MinimalDelta + ) + + DynamicParam { + if ($Resource) { return } + + $actualService = $Service + if (-not $actualService) { $actualService = $script:_DefaultService } + $serviceObject = $script:_EntraEndpoints.$actualService + if (-not $serviceObject) { return } + if ($serviceObject.Parameters.Count -lt 1) { return } + + $results = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new() + foreach ($pair in $serviceObject.Parameters.GetEnumerator()) { + $parameterAttribute = [System.Management.Automation.ParameterAttribute]::new() + $parameterAttribute.ParameterSetName = '__AllParameterSets' + $parameterAttribute.Mandatory = $true + $parameterAttribute.HelpMessage = $pair.Value + $attributesCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() + $attributesCollection.Add($parameterAttribute) + $RuntimeParam = [System.Management.Automation.RuntimeDefinedParameter]::new($pair.Key, [object], $attributesCollection) + + $results.Add($pair.Key, $RuntimeParam) + } + + $results + } + + begin { + if ($Token) { + $tokenObject = $Token + } + else { + Assert-EntraConnection -Service $Service -Cmdlet $PSCmdlet -RequiredScopes $RequiredScopes + $tokenObject = $script:_EntraTokens.$Service + } + + $serviceObject = $script:_EntraEndpoints.$($tokenObject.Service) + if ($ContentType) { $Header['Content-Type'] = $ContentType } + } + process { + $parameters = @{ + Method = $Method + Uri = Resolve-RequestUri -TokenObject $tokenObject -ServiceObject $serviceObject -BoundParameters $PSBoundParameters + } + $originalUri = $parameters.Uri + + if ($PSBoundParameters.Keys -contains 'Body') { + if ($Body -is [string]) { + $parameters.Body = $Body + } + else { + $parameters.Body = $Body | ConvertTo-Json -Compress -Depth $SerializationDepth + } + } + # In PS5.1, some methods cannot contain a body + $noBodyMethods = 'Default', 'Get', 'Head' + if ($PSVersionTable.PSVersion.Major -lt 6 -and $Method -in $noBodyMethods) { + $parameters.Remove('Body') + } + + $queryClone = $Query.Clone() + if ($DeltaSession -and $DeltaSession[$originalUri].Token) { + $queryClone['$deltaToken'] = $DeltaSession[$originalUri].Token + } + $parameters.Uri += ConvertTo-QueryString -QueryHash $queryClone -DefaultQuery $tokenObject.Query + + do { + $tempHeader = $tokenObject.GetHeader().Clone() # GetHeader() automatically refreshes expired tokens + foreach ($pair in $Header.GetEnumerator()) { $tempHeader[$pair.Key] = $pair.Value } + if ($MinimalDelta) { $tempHeader['Prefer'] = 'return=minimal' } + $parameters.Headers = $tempHeader + Write-Verbose "Executing Request: $($Method) -> $($parameters.Uri)" + try { $result = Invoke-RestMethod @parameters -ErrorAction Stop } + catch { + $letItBurn = $true + $failure = $_ + + if ($_.ErrorDetails.Message) { + try { $details = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop } + catch { $details = $_.ErrorDetails.Message } + if ($details.Error.Code -eq 'TooManyRequests') { + Write-Verbose "Throttling: $($details.error.message)" + $delay = 1 + ($details.error.message -replace '^.+ (\d+) .+$', '$1' -as [int]) + if ($delay -gt 5) { Write-Warning "Request is being throttled for $delay seconds" } + Start-Sleep -Seconds $delay + try { + $result = Invoke-RestMethod @parameters -ErrorAction Stop + $letItBurn = $false + } + catch { + $failure = $_ + } + } + } + + if ($letItBurn) { + Write-Warning "Request failed: $($Method) -> $($parameters.Uri)" + $PSCmdlet.ThrowTerminatingError($failure) + } + } + if (-not $Raw -and -not $tokenObject.RawOnly -and $result.PSObject.Properties.Where{ $_.Name -eq 'value' }) { $result.Value } + else { $result } + $parameters.Uri = $result.'@odata.nextLink' + + if ($DeltaSession -and $result.'@odata.deltaLink') { + $deltaToken = (($result.'@odata.deltaLink' -split '\?')[1] -split '=')[-1] + if (-not $DeltaSession[$originalUri]) { + $DeltaSession[$originalUri] = @{} + } + $DeltaSession[$originalUri]['Token'] = $deltaToken + } + } + while ($parameters.Uri -and -not $NoPaging) + } +} + +function Get-EntraFederationProvider { + <# + .SYNOPSIS + Lists all registered Federation Providers. + + .DESCRIPTION + Lists all registered Federation Providers. + Federation Providers are an EntraAuth concept and used to automatically do what is needed to access and use a Federated Credential, based on its environment. + See the documentation on Register-EntraFederationProvider for more details. + + .PARAMETER Name + The name of the provider to filter by. + Defaults to: * + + .EXAMPLE + PS C:\> Get-EntraFederationProvider + + Lists all registered Federation Providers. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] + [CmdletBinding()] + param ( + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + foreach ($provider in Get-EntraFederationProvider) { + if ($provider.Name -notlike "$wordToComplete*") { continue } + $text = $provider.Name + if ($text -match '\s') { $text = "'$($provider.Name -replace "'", "''")'" } + + [System.Management.Automation.CompletionResult]::new($text, $text, 'ParameterValue', $provider.Description) + } + })] + [string] + $Name = '*' + ) + process { + ($script:_FederationProviders.Values) | Where-Object Name -Like $Name | Sort-Object Priority + } +} + +function Register-EntraFederationProvider { + <# + .SYNOPSIS + Register logic to automatically retrieve the assertion needed in a Federated Credential authentication flow. + + .DESCRIPTION + Register logic to automatically retrieve the assertion needed in a Federated Credential authentication flow. + Federated credentials allow granting access to a trusted entity without requiring a secret. + + For example, with that it becomes possible, to authorize a github action to authenticate to Entra. + However, in order for the Github Action to be able to do that, it first needs to authenticate to Github and get a special token. + + This provider defined here serves to implement that step - and to allow you to do the same for your own providers. + + More Resources on Federated Credentials: + https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust?pivots=identity-wif-apps-methods-azp + https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow?wt.mc_id=SEC-MVP-5004985#third-case-access-token-request-with-a-federated-credential + + .PARAMETER Name + The name of the Federation Provider. + Pick something that represents the context the user would expect (e.g. "GithubAction", "FunctionApp", ...). + It usually would be related to the environment from which you connect. + + .PARAMETER Description + A description of the provider. + Especially useful to explain non-intuitive setup steps that are required to get things started. + + .PARAMETER Priority + How high the priority of the provider is. + The lower the number, the earlier it is tested for. + This property is used when auto-detecting which Federation Provider should be used. + The first match will stop further processing. + + .PARAMETER Test + A piece of code used to test, whether the Federation Provider can be used in the current situation. + This is used when trying to automatically detect, which provider to use. + + .PARAMETER Code + Code that generates a JWT token used as an Assertion for the Entra authentication, essentially serving as its secret. + + .EXAMPLE + PS C:\> Register-EntraFederationProvider -Name GithubAction -Priority 10 -Description 'For use within a Github Action. Remember to configure your workflow to use the "id-token: write" permission' -Test $test -Code $authCode + + Configures the GithubAction Federation Provider. + Assuming the scriptblocks provided work, this will allow simply running something like this within a Github Action: + Connect-EntraService -ClientID $clientID -TenantID $tenantID -Federated + Hint: This provider is preconfigured in EntraAuth, so this should work as it is. Use Get-EntraFederationProvider to inspect the actual implementing code. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [string] + $Description, + + [int] + $Priority = 50, + + [Parameter(Mandatory = $true)] + [scriptblock] + $Test, + + [Parameter(Mandatory = $true)] + [scriptblock] + $Code + ) + process { + $script:_FederationProviders[$Name] = [FederationProvider]@{ + Name = $Name + Description = $Description + Priority = $Priority + Test = $Test + Code = $Code + Type = 'Registered' + } + } +} + +function Resolve-EntraFederationProvider { + <# + .SYNOPSIS + Resolves which Federation Provider to use to calculate the Federation Credentials to use. + + .DESCRIPTION + Resolves which Federation Provider to use to calculate the Federation Credentials to use. + This executes the test code for all registered providers (in the order of their priority) until one applies. + + Federation Providers are an EntraAuth concept and used to automatically do what is needed to access and use a Federated Credential, based on its environment. + See the documentation on Register-EntraFederationProvider for more details. + + .PARAMETER Exclude + The Federation Provider to skip. + + .EXAMPLE + PS C:\> Resolve-EntraFederationProvider + + Resolves which Federation Provider to use to calculate the Federation Credentials to use. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] + [CmdletBinding()] + param ( + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + foreach ($provider in Get-EntraFederationProvider) { + if ($provider.Name -notlike "$wordToComplete*") { continue } + $text = $provider.Name + if ($text -match '\s') { $text = "'$($provider.Name -replace "'", "''")'" } + + [System.Management.Automation.CompletionResult]::new($text, $text, 'ParameterValue', $provider.Description) + } + })] + [string[]] + $Exclude + ) + process { + foreach ($provider in Get-EntraFederationProvider | Sort-Object Priority) { + if ($provider.Name -in $Exclude) { continue } + + if (& $provider.Test) { return $provider } + } + } +} + +function New-EntraFilterBuilder { + <# + .SYNOPSIS + Creates a new OData-Filter construction helper. + + .DESCRIPTION + Creates a new OData-Filter construction helper. + This helper class was designed to simplify the creation of OData filters for APIs such as the Microsoft Graph API. + Call the ".Add(...)" method to specify filter conditions. + + There are three ways to do so: + A) .Add(property, operator, value) + A single property comparison, with a specific operator and value. + + B) .Add(customFilter) + A custom piece of OData filter text. + + C) .Add(filterbuilder) + The output of another filterbuilder becomes part of the conditions for this one. + This allows building complex, nested filter statements. + + Finally, call ".Get()" to retrieve the full OData filter string or ".GetHeader()" to retrieve the filter header hashtable. + + For more in-depth help with the type, call the '.GetHelp()' method on the object. + + Useful Resources: + List of valid filter conditions: + https://learn.microsoft.com/en-us/graph/filter-query-parameter?tabs=http#operators-and-functions-supported-in-filter-expressions + + .PARAMETER Logic + The logic by which individual filter conditions are merged. + Options: + - AND (default) + - OR + + .EXAMPLE + PS C:\> $filter = New-EntraFilterBuilder + PS C:\> $filter.Add('displayName', 'eq', 'John Doe') + PS C:\> $filter.Add('organization', 'in', @('Contoso', 'Fabrikam')) + PS C:\> $filter.Get() + + Will return: "displayName eq 'John Doe' and organization in ('Contoso', 'Fabrikam')" + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] + [OutputType([FilterBuilder])] + [CmdletBinding()] + param ( + [ValidateSet('AND','OR')] + [string] + $Logic = 'AND' + ) + + process { + [FilterBuilder]::new($Logic) + } +} + +function New-EntraServiceSelector { + <# + .SYNOPSIS + Creates a helper type designed to help make a module implementing EntraAuth more flexible about what EntraAUth service to use. + + .DESCRIPTION + Creates a helper type designed to help make a module implementing EntraAuth more flexible about what EntraAUth service to use. + + While a module can easily define what service to use when calling Invoke-EntraRequest, this has some concerns: + + If multiple modules require the same service, they may interfere with each other by trying to use separate applications or having different scope requirements. + + If each module defines their own service instance (e.g. "Graph.MyModule"), then a script using multiple modules needs to authenticate multiple times, even if they all could use the same connection/token. + + The Service Selector aims to be a simple solution to this problem. + It is intended for _Modules_ that implement EntraAuth, not individual scripts. + + To fully execute on this, you will need to implement this in three locations: + - During Module Import: Declare defaults & Selector. + - At the beginning of your functions: Select chosen services. + - When executing requests: Use service as chosen. + + #======================================================================================================= + # During Module Import + $script:_services = @{ Graph = 'Graph'; MDE = 'Endpoint' } + $script:_serviceSelector = New-EntraServiceSelector -DefaultServices $script:_services + + # During the Begin stage of each function using EntraAuth + begin { + $services = $script:_serviceSelector.GetServiceMap($ServiceMap) # $ServiceMap is a hashtable parameter offered by your function + Assert-EntraConnection -Cmdlet $PSCmdlet -Service $services.Graph + } + + # When executing the actual request, later in the function + Invoke-EntraService -Service $services.Graph -Path users + #======================================================================================================= + + With this, somebody could call your command - let's call it "Get-DepartmentUser" - like this: + Get-DepartmentUser -ServiceMap @{ Graph = 'GraphBeta' } + And your function would use the beta version of the Graph api, without affecting any other script or module calling your function. + + Example Implementations: + - During Module Import: + https://github.com/FriedrichWeinmann/EntraAuth.Graph.Application/blob/master/EntraAuth.Graph.Application/internal/scripts/variables.ps1 + - Used in Functions: + https://github.com/FriedrichWeinmann/EntraAuth.Graph.Application/blob/3c5e9f3de31fd7946e6fe9ebdb938986165ff5ca/EntraAuth.Graph.Application/functions/Get-EAGAppRegistration.ps1#L78 + + .PARAMETER DefaultServices + The Default services to use. + Provide a hashtable of Labels mapping to EntraAuth services. + Example: + @{ Graph = 'Graph'; MDE = 'Endpoint' } + The key is what you use in your code as a label, the Value is the actual service in EntraAuth. + + .EXAMPLE + PS C:\> $script:_serviceSelector = New-EntraServiceSelector -DefaultServices $script:_services + + Creates a new ServiceSelector object and stores it in $script:_serviceSelector + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] + [OutputType([ServiceSelector])] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [hashtable] + $DefaultServices + ) + process { + [ServiceSelector]::new($DefaultServices) + } +} + +# Available Tokens +$script:_EntraTokens = @{} + +# Endpoint Configuration for Requests +$script:_EntraEndpoints = @{} + +# Plugins for automatic processing of Federated Authentication in the correct context +$script:_FederationProviders = @{} + +# The default service to connect to +$script:_DefaultService = 'Graph' + +# Registers the default service configurations +$endpointCfg = @{ + Name = 'Endpoint' + ServiceUrl = 'https://api.securitycenter.microsoft.com/api' + Resource = 'https://api.securitycenter.microsoft.com' + DefaultScopes = @() + Header = @{ 'Content-Type' = 'application/json' } + HelpUrl = 'https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/api/apis-intro?view=o365-worldwide' +} +Register-EntraService @endpointCfg + +$securityCfg = @{ + Name = 'Security' + ServiceUrl = 'https://api.security.microsoft.com/api' + Resource = 'https://security.microsoft.com/mtp/' + DefaultScopes = @('AdvancedHunting.Read') + Header = @{ 'Content-Type' = 'application/json' } + HelpUrl = 'https://learn.microsoft.com/en-us/microsoft-365/security/defender/api-create-app-web?view=o365-worldwide' +} +Register-EntraService @securityCfg + +$graphCfg = @{ + Name = 'Graph' + ServiceUrl = 'https://graph.microsoft.com/v1.0' + Resource = 'https://graph.microsoft.com' + DefaultScopes = @() + HelpUrl = 'https://developer.microsoft.com/en-us/graph/quick-start' +} +Register-EntraService @graphCfg + +$graphBetaCfg = @{ + Name = 'GraphBeta' + ServiceUrl = 'https://graph.microsoft.com/beta' + Resource = 'https://graph.microsoft.com' + DefaultScopes = @() + HelpUrl = 'https://developer.microsoft.com/en-us/graph/quick-start' +} +Register-EntraService @graphBetaCfg + +$azureCfg = @{ + Name = 'Azure' + ServiceUrl = 'https://management.azure.com' + Resource = 'https://management.core.windows.net/' + DefaultScopes = @() + HelpUrl = 'https://learn.microsoft.com/en-us/rest/api/azure/?view=rest-resources-2022-12-01' +} +Register-EntraService @azureCfg + +$azureKeyVaultCfg = @{ + Name = 'AzureKeyVault' + ServiceUrl = 'https://%VAULTNAME%.vault.azure.net' + Resource = 'https://vault.azure.net' + DefaultScopes = @() + HelpUrl = 'https://learn.microsoft.com/en-us/rest/api/keyvault/?view=rest-keyvault-secrets-7.4' + Parameters = @{ + VaultName = 'Name of the Key Vault to execute against' + } + Query = @{ + 'api-version' = '7.4' + } +} +Register-EntraService @azureKeyVaultCfg + +$logAnalyticsCfg = @{ + Name = 'LogAnalytics' + ServiceUrl = 'https://api.loganalytics.azure.com' + Resource = 'https://api.loganalytics.io' + DefaultScopes = @() + HelpUrl = 'https://docs.microsoft.com/en-us/azure/azure-monitor/logs/api/overview' + Header = @{ + 'Content-Type' = 'application/json' + } +} +Register-EntraService @logAnalyticsCfg + +# Default Federation Providers, to enable easy automatic Federation Authentication + +#-> Github Actions +$param = @{ + Name = 'GithubAction' + Description = 'For within the runtime of a Github Action runner. The workflow must define the permission "id-token: write", in order for the authentication to be valid.' + Priority = 10 # Lower = Attempted earlier + Test = { + if ($env:ACTIONS_ID_TOKEN_REQUEST_URL -and $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN) { return $true } + + # If running in an Action but no token, warn about the misconfiguration + if (-not $env:GITHUB_ACTION -or -not $env:GITHUB_REPOSITORY) { return } + + Write-Warning @' +Github Action Runner detected, but no token available. +This usually happens based on a misconfiguration, with the Workflow not being granted access to the id-token needed. +Permission needed: + id-token: write +For more information on Github Action permission configuration, see this documentation: +https://docs.github.com/en/actions/how-tos/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token +'@ + } + Code = { + $response = Invoke-RestMethod -Uri "$env:ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" -Headers @{ + Authorization = "Bearer $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN" + } -ErrorAction Stop + $response.Value + } +} +Register-EntraFederationProvider @param + +$param = @{ + Name = 'EntraMSI' + Description = 'Authenticate as the Managed Identity in the current context.' + Priority = 20 + Test = { + try { + $null = Connect-EntraService -Identity -Resource 'api://AzureADTokenExchange' -ErrorAction Stop + $true + } + catch { $false } + } + code = { + (Connect-EntraService -Identity -Resource 'api://AzureADTokenExchange').AccessToken + } +} +Register-EntraFederationProvider @param \ No newline at end of file diff --git a/PSFramework/modules/EntraAuth/1.8.56/LICENSE b/PSFramework/modules/EntraAuth/1.8.56/LICENSE new file mode 100644 index 00000000..194d22bc --- /dev/null +++ b/PSFramework/modules/EntraAuth/1.8.56/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Friedrich Weinmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PSFramework/modules/EntraAuth/1.8.56/changelog.md b/PSFramework/modules/EntraAuth/1.8.56/changelog.md new file mode 100644 index 00000000..a56baab5 --- /dev/null +++ b/PSFramework/modules/EntraAuth/1.8.56/changelog.md @@ -0,0 +1,133 @@ +# Changelog + +## 1.8.56 (2026-09-24) + ++ Fix: Invoke-EntraRequest - fails json conversion if on error the API does not return json error message details + +## 1.8.55 (2026-06-08) + ++ Fix: Service: Azure Log Analytics not registered correctly. + +## 1.8.54 (2026-06-06) + ++ New: Service for connecting to the Log Analytics Api ++ Upd: Invoke-EntraRequest - added integrated support for delta request + +## 1.8.52 (2026-02-20) + ++ Fix: Connect-EntraService - Federated Credentials fail to refresh the token correctly ++ Fix: Filter Builder - custom filter conditions might not be evaluated correctly, if they contain multiple clauses. + +## 1.8.50 (2025-07-21) + ++ Fix: Federation Provider EntraMSI - unable to resolve Federation Provider + +## 1.8.49 (2025-07-21) + ++ New: Federation Provider EntraMSI - added a default federation provider for Managed Identities in Entra. ++ Upd: Invoke-EntraRequest - added parameter `-ContentType` ++ Upd: Connect-EntraService - now supports custom RedirectUris for AuthorizationCode flow + +## 1.8.46 (2025-07-11) + ++ New: Register-EntraFederationProvider - Register logic to automatically retrieve the assertion needed in a Federated Credential authentication flow. ++ New: Get-EntraFederationProvider - Lists all registered Federation Providers. ++ New: Resolve-EntraFederationProvider - Resolves which Federation Provider to use to calculate the Federation Credentials to use. ++ Upd: Connect-EntraService - now supports Federated Credentials flow ++ Upd: Connect-EntraService - AuthorizationCode flows no longer redirects to github at the end ++ Upd: Documentation update to provide better examples to many commands (#42; Many thanks, @Mynster9361) ++ Fix: Connect-EntraService - DeviceCode flow did not require TenantID when it is actually necessary + +## 1.7.39 (2025-04-15) + ++ Upd: New-EntraFilterBuilder - Now supports both AND and OR logic. ++ Upd: New-EntraFilterBuilder - Now supports adding other filter builders as nested filter. ++ Upd: New-EntraFilterBuilder - Now allows specifying, whether values should be put in quotes. ++ Fix: New-EntraFilterBuilder - does not respect order of conditions for custom filters. + +## 1.7.35 (2025-03-25) + ++ New: New-EntraFilterBuilder - Creates a new OData-Filter construction helper. ++ New: New-EntraServiceSelector - Creates a helper type designed to help make a module implementing EntraAuth more flexible about what EntraAUth service to use. + +## 1.6.33 (2025-03-10) + ++ New: New-EntraCustomToken - Create a custom token compatible with EntraAuth. ++ Upd: Register-EntraService - add `RawOnly` parameter to have all requests to that service use raw processing by default. + +## 1.5.31 (2025-03-05) + ++ Upd: Invoke-EntraRequest - now allows overriding default header entries + +## 1.5.30 (2025-03-05) + ++ Upd: Connect-EntraService - now accepts "Graph" or "Azure" as ClientID, resolving the respective first party App IDs. ++ Fix: Connect-EntraService - fails to find a certificate by name, when the cert store contains only a single certificate + +## 1.5.28 (2025-02-14) + ++ New: Import-EntraToken - Imports a token into the local token store. ++ Upd: Connect-EntraService - added `-FallbackAzAccount` parameter to allow MSI authentication to fall back to the existing Az Session in case of trouble. ++ Upd: Connect-EntraService - enabled multiple secret names to be specified when logging in via Key Vault. ++ Upd: Token - default Query values are now copied onto the token from the service configuration. ++ Upd: Invoke-EntraRequest - uses default Query values from the token, rather than the service configuration + +## 1.4.23 (2025-01-14) + ++ Upd: Connect-EntraService - removed TenantID requirement for most delegate flows, defaulting the parameter to "organizations". TenantID on the managed token object is now read from the returned token. + +## 1.4.22 (2024-12-04) + ++ Upd: Connect-EntraService - added `-UseRefreshToken` parameter for delegate flows, showing the interactive prompts only if needed. + +## 1.4.21 (2024-11-26) + ++ Upd: Added support for authenticating using an existing refresh token ++ Fix: Invoke-EntraRequest - "body not supported with this method" error when using Get requests with a body (#23) + +## 1.3.19 (2024-10-13) + ++ Upd: Invoke-EntraRequest - `-Body` parameter now supports raw string or custom objects. + +## 1.3.18 (2024-10-08) + ++ Upd: Added support for authenticating using the current Az.Accounts session ++ Upd: Added support for Sovereign Clouds (USGov, USGovDOD, China) and custom authentication urls. ++ Fix: Certificate Logon fails on timezones after UTC + +## 1.2.15 (2024-07-31) + ++ Fix: Refresh token may fail to authenticate to correct application ++ Fix: Managed Identity authentication fails on Azure VMs + +## 1.2.13 (2024-05-22) + ++ New: Supporting Managed Identity (User-Assigned or System Managed) ++ Upd: Service Configuration - can specify default query parameters. + +## 1.1.11 (2024-05-21) + ++ New: Service configurations - Added configurations for Azure & AzureKeyVault. ++ Upd: Connect-EntraService - Added support for direct Key Vault integration. ++ Upd: Service configurations - Added capability to require additional parameters that modify the base Service Url. ++ Fix: Token Renewal - bad parameter ServiceUrl. ++ Fix: Asset-EntraConnection - bad error message when assertion fails. + +## 1.0.6 (2024-05-15) + ++ Upd: Invoke-EntraRequest - added -NoPaging parameter to support disabling paging. ++ Upd: Invoke-EntraRequest - added -Raw parameter to support returning unprocessed results. + +## 1.0.4 (2024-04-19) + ++ Fix: Connect-EntraService - fails to register new sessions (#10) + +## 1.0.3 (2024-03-24) + ++ Upd: Connect-EntraService - added -Resource parameter to allow creating tokens without requiring a service (#7) ++ Upd: Connect-EntraService - added -BrowserMode parameter to allow pasting the link to whatever browser you prefer (#3) ++ Fix: Connect Browser - unknown command when PSFramework is not installed + +## 1.0.0 (2024-03-20) + ++ Initial Release diff --git a/PSFramework/modules/readme.md b/PSFramework/modules/readme.md new file mode 100644 index 00000000..c712b01b --- /dev/null +++ b/PSFramework/modules/readme.md @@ -0,0 +1,21 @@ +# Modules + +This folder contains modules included within PSFramework. +If possible, avoid including modules, but when not otherwise possible, this is the place to put them in. + +Fundamental rules: + ++ MIT License Modules only ++ Only Modules publicly available through Github or similar ++ Modules must be build-able from source without build dependencies ++ No Breaking Change policy applies to these modules as well ++ Must not lead to any runspace-/process-wide conflicts: No DLLs, no Type Extension XML that conflicts on import ++ Only one version of a given module ++ For each module, create a %MODULENAME%.md document besides this readme, describing ... + + ... what it is needed/used for + + ... any adjustments made from its source + + ... where it is from + +> Remember + +Nested modules are evil and should be avoided! diff --git a/PSFramework/snippets/help_par_EnableException.snippet b/PSFramework/snippets/help_par_EnableException.snippet deleted file mode 100644 index ca529cef..00000000 --- a/PSFramework/snippets/help_par_EnableException.snippet +++ /dev/null @@ -1,20 +0,0 @@ - - -
- help_par_EnableException - - Help for the EnableException parameter - Infernal Associates Ltd. - - Expansion - -
- - - - - -
-
\ No newline at end of file diff --git a/PSFramework/tests/general/FileIntegrity.Exceptions.ps1 b/PSFramework/tests/general/FileIntegrity.Exceptions.ps1 index c98da514..0ed2dda5 100644 --- a/PSFramework/tests/general/FileIntegrity.Exceptions.ps1 +++ b/PSFramework/tests/general/FileIntegrity.Exceptions.ps1 @@ -20,7 +20,7 @@ $global:MayContainCommand = @{ "Write-Host" = @('Write-PSFHostColor.ps1') "Write-Verbose" = @() "Write-Warning" = @('assembly.ps1') - "Write-Error" = @('Import-PSFJson.ps1', 'Import-PSFPowerShellDataFile.ps1', 'Invoke-PSFCommand.ps1', 'Set-PSFFileContent.ps1', 'Stop-PSFFunction.ps1') + "Write-Error" = @('Import-PSFJson.ps1', 'Import-PSFPowerShellDataFile.ps1', 'Invoke-PSFCommand.ps1', 'Set-PSFFileContent.ps1', 'Stop-PSFFunction.ps1', 'azureloganalytics2.provider.ps1') "Write-Output" = @('filesystem.provider.ps1', 'gelf.provider.ps1', 'Import-PSFPowerShellDataFile.ps1', 'logfile.provider.ps1', 'input.ps1', 'teppCoreCode.ps1') "Write-Information" = @() "Write-Debug" = @() diff --git a/PSFramework/xml/PSFramework.Format.ps1xml b/PSFramework/xml/PSFramework.Format.ps1xml index 78e473b1..458f97f8 100644 --- a/PSFramework/xml/PSFramework.Format.ps1xml +++ b/PSFramework/xml/PSFramework.Format.ps1xml @@ -355,11 +355,6 @@ - - - - - @@ -374,22 +369,7 @@ ProviderVersion - IncludeModules - - - ExcludeModules - - - IncludeTags - - - ExcludeTags - - - Initialized - - - InstallationOptional + Description diff --git a/library/PSFramework/Commands/ConvertToPSFHashtableCommand.cs b/library/PSFramework/Commands/ConvertToPSFHashtableCommand.cs index 6a3fa97d..260fc88d 100644 --- a/library/PSFramework/Commands/ConvertToPSFHashtableCommand.cs +++ b/library/PSFramework/Commands/ConvertToPSFHashtableCommand.cs @@ -41,6 +41,24 @@ public class ConvertToPSFHashtableCommand : PSFCmdlet [Parameter()] public SwitchParameter IncludeEmpty; + /// + /// Exclude keys whose value is null + /// + [Parameter()] + public SwitchParameter ExcludeNull; + + /// + /// Exclude keys whose value is an empty string + /// + [Parameter()] + public SwitchParameter ExcludeEmptyString; + + /// + /// Exclude keys whose value is an empty collection + /// + [Parameter()] + public SwitchParameter ExcludeEmptyCollection; + /// /// Specifically designed for use in a command when passing through parameters with default values. /// Enabling this switch has the command inherit values from variables in the caller scope if their name has been specified in the Include parameter. @@ -225,6 +243,27 @@ protected override void ProcessRecord() result[Remap[key]] = value; } } + if (ExcludeNull || ExcludeEmptyString || ExcludeEmptyCollection) + { + object[] keys = result.Keys.Cast().ToArray(); + foreach (object key in keys) { + if (ExcludeNull && result[key] == null) + { + result.Remove(key); + continue; + } + if (ExcludeEmptyString && result[key] != null && result[key] is string && String.IsNullOrEmpty(result[key].ToString())) + { + result.Remove(key); + continue; + } + if (ExcludeEmptyCollection && result[key] != null && result[key] is ICollection && ((ICollection)result[key]).Count == 0) + { + result.Remove(key); + continue; + } + } + } if (AsPsfHashtable.ToBool()) result = new Object.PsfHashtable(result); diff --git a/library/PSFramework/Logging/FileSystemWriter.cs b/library/PSFramework/Logging/FileSystemWriter.cs new file mode 100644 index 00000000..da4ef426 --- /dev/null +++ b/library/PSFramework/Logging/FileSystemWriter.cs @@ -0,0 +1,111 @@ +using PSFramework.Message; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Text; +using System.Threading.Tasks; + +namespace PSFramework.Logging +{ + /// + /// Helper class used by the FileSystem logging provider to write data at max performance + /// + public class FileSystemWriter + { + // Converter Code that generates CSV without headers + private static string CodeNoHeader = "Microsoft.PowerShell.Utility\\ConvertTo-Csv -NoTypeInformation -NoHeader"; + // Converter Code that generates CSV WITH headers + private static string CodeHeader = "Microsoft.PowerShell.Utility\\ConvertTo-Csv -NoTypeInformation"; + + /// + /// The Path to where is written. Changing this recalculates converters and establishes a filestream + /// + public string Path + { + get => _Path; + set + { + if (_Path == value) + return; + + // Close out previous writer and converter + End(); + + if (!LogHost.FileSystemModernLog || System.IO.File.Exists(value)) + _Converter = ScriptBlock.Create(CodeNoHeader).GetSteppablePipeline(); + else + _Converter = ScriptBlock.Create(CodeHeader).GetSteppablePipeline(); + _Converter.Begin(true); + + _Writer = new StreamWriter(value, true, new UTF8Encoding(true)); + _Writer.AutoFlush = true; + + _Path = value; + _Info = new FileInfo(value); + } + } + private string _Path; + + /// + /// Size of the logfile. + /// Defaults to 0 if file not present. + /// Used to determine file-split based on size. + /// + public long Size + { + get + { + _Info.Refresh(); + if (!_Info.Exists) + return 0; + return _Info.Length; + } + } + + private SteppablePipeline _Converter; + + private StreamWriter _Writer; + + private FileInfo _Info; + + /// + /// Writes a line to the logfile + /// + /// The line of text to write + public void WriteLine(string Line) + { + _Writer.WriteLine(Line); + } + + /// + /// Writes an object to the logfile. + /// Will convert it to csv first. + /// + /// The item to write + public void WriteObject(PSObject Item) + { + foreach (object item in _Converter.Process(Item)) + _Writer.WriteLine(item.ToString()); + } + + /// + /// Clean up both converter and writer + /// + public void End() + { + if (null != _Converter) + { + _Converter.End(); + _Converter = null; + } + + if (null != _Writer) + { + _Writer.Dispose(); + _Writer = null; + } + } + } +} diff --git a/library/PSFramework/Logging/Provider.cs b/library/PSFramework/Logging/Provider.cs index e9da6540..a487656f 100644 --- a/library/PSFramework/Logging/Provider.cs +++ b/library/PSFramework/Logging/Provider.cs @@ -17,6 +17,11 @@ public class Provider /// public string Name; + /// + /// A description for the logging provider + /// + public string Description; + /// /// Whether the logging provider is enabled. Only when this is set to true will the logging script execute its events. /// diff --git a/library/PSFramework/PSFramework.csproj b/library/PSFramework/PSFramework.csproj index 3246df40..f2a51c11 100644 --- a/library/PSFramework/PSFramework.csproj +++ b/library/PSFramework/PSFramework.csproj @@ -132,6 +132,7 @@ + diff --git a/library/PSFramework/Runspace/RSQueue.cs b/library/PSFramework/Runspace/RSQueue.cs index 65ad6dd2..df3f99a5 100644 --- a/library/PSFramework/Runspace/RSQueue.cs +++ b/library/PSFramework/Runspace/RSQueue.cs @@ -5,6 +5,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Xml.Schema; namespace PSFramework.Runspace { @@ -53,9 +54,12 @@ public class RSQueue : ConcurrentQueue if (Closed) return; - Interlocked.Increment(ref TotalItemCount); - base.Enqueue(Input); - LastUpdate = DateTime.Now; + lock (this) + { + Interlocked.Increment(ref TotalItemCount); + base.Enqueue(Input); + LastUpdate = DateTime.Now; + } } /// @@ -73,8 +77,12 @@ public void EnqueueBulk(ICollection Input) if (Closed) return; - Interlocked.Increment(ref TotalItemCount); - base.Enqueue(item); + lock (this) + { + Interlocked.Increment(ref TotalItemCount); + base.Enqueue(item); + LastUpdate = DateTime.Now; + } } } @@ -110,6 +118,26 @@ public void Clear() } } + /// + /// Get all contents as an Array. + /// Possibly also clearing it out afterwards. + /// + /// If true, clear after reading as one threadsafe operation. + /// To content of the queued items + public object[] ToArray(bool Clear) + { + if (!Clear) + return ToArray(); + + lock (this) + { + object[] content = ToArray(); + while (Count > 0) + Dequeue(); + return content; + } + } + /// /// Take a look without removing anything /// diff --git a/library/PSFramework/Utility/UtilityHost.cs b/library/PSFramework/Utility/UtilityHost.cs index 02456680..bbe139cd 100644 --- a/library/PSFramework/Utility/UtilityHost.cs +++ b/library/PSFramework/Utility/UtilityHost.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -701,6 +702,137 @@ public static Meta.CallerInfo GetCallerInfo(int Level = 1) } #endregion Callstack Metadata + #region Comparisons + /// + /// Compare two hashtables. + /// Value comparison uses C#-based comparison logic. + /// + /// First Hashtable to compare + /// Second Hashtable to compare + /// Whether the two tables are equal + public static bool Compare(Hashtable Table1, Hashtable Table2) + { + if (Table1 == null && Table2 == null) return true; + if (Table1 == null && Table2 != null) return false; + if (Table1 != null && Table2 == null) return false; + + if (Table1.Count != Table2.Count) return false; + + foreach (DictionaryEntry row in Table1) + { + if (!Table2.ContainsKey(row.Key)) return false; + if (Table2[row.Key] != row.Value) return false; + } + foreach (DictionaryEntry row in Table2) + { + if (!Table1.ContainsKey(row.Key)) return false; + if (Table1[row.Key] != row.Value) return false; + } + + return true; + } + + /// + /// Compare two hashtables. + /// Value comparison uses PowerShell-based comparison logic. + /// + /// First Hashtable to compare + /// Second Hashtable to compare + /// Whether the comparison is case sensitive + /// Whether to recursively compare nested hashtables + /// If enabled, all entries in Table1 must be present in Table2, but not necessarily all entries in Table2 in Table1 + /// Whether the two tables are equal + public static bool ComparePS(Hashtable Table1, Hashtable Table2, bool CaseSensitive, bool Recurse, bool OneDirectional) + { + if (Table1 == null && Table2 == null) return true; + if (Table1 == null && Table2 != null) return false; + if (Table1 != null && Table2 == null) return false; + + if (!OneDirectional && Table1.Count != Table2.Count) return false; + + foreach (DictionaryEntry row in Table1) + { + if (!Table2.ContainsKey(row.Key)) return false; + if (Recurse && !LanguagePrimitives.Equals(Table2[row.Key], row.Value, !CaseSensitive)) + { + if (Table2[row.Key] is Hashtable && row.Value is Hashtable) + { + if (!ComparePS((Hashtable)Table2[row.Key], (Hashtable)row.Value, CaseSensitive, Recurse, OneDirectional)) + return false; + } + else + return false; + } + else if (!LanguagePrimitives.Equals(Table2[row.Key], row.Value, !CaseSensitive)) return false; + } + + if (OneDirectional) + return true; + + foreach (DictionaryEntry row in Table2) + { + if (!Table1.ContainsKey(row.Key)) return false; + if (Recurse && !LanguagePrimitives.Equals(Table1[row.Key], row.Value, !CaseSensitive)) + { + if (Table1[row.Key] is Hashtable && row.Value is Hashtable) + { + if (!ComparePS((Hashtable)Table1[row.Key], (Hashtable)row.Value, CaseSensitive, Recurse, false)) + return false; + } + else + return false; + } + else if (!LanguagePrimitives.Equals(Table1[row.Key], row.Value, !CaseSensitive)) return false; + } + + return true; + } + #endregion Comparisons + + /// + /// Checks, whether the specified parameters would resolve to one of the selected parameter sets. + /// Will return false, if the provided parameters would lead to an ambiguous parameter set, even if the specified sets would be among the still valid sets. + /// + /// The command for the parameters of which the test should be performed. + /// The names of the parameters specified. + /// The sets checked for. The resultant parameter set must be one of those specified. + /// Whether the specified parameters would resolve to one of the selected parameter sets + public static bool TestParameterSetAny(CommandInfo Command, string[] Parameters, string[] Sets) + { + if (null == Command) return false; + if (null == Sets) return false; + if (null == Parameters) + Parameters = new string[0]; + + List matches = new List(); + foreach (CommandParameterSetInfo info in Command.ParameterSets) + { + if (Parameters.Where(p => !info.Parameters.Select(o => o.Name).ToArray().Contains(p, StringComparer.OrdinalIgnoreCase)).Count() > 0) + continue; + matches.Add(info); + } + + CommandParameterSetInfo defaultSet = Command.ParameterSets.Where(s => s.IsDefault).FirstOrDefault(); + string effectiveSet = defaultSet?.Name; + + if (matches.Count == 1) + effectiveSet = matches[0].Name; + else if (matches.Count > 1) + { + if (null == defaultSet) + return false; + if (!matches.Select(o => o.Name).ToArray().Contains(effectiveSet)) + return false; + } + else + return false; + + if (String.IsNullOrEmpty(effectiveSet)) + return false; + + return Sets.Contains(effectiveSet, StringComparer.OrdinalIgnoreCase); + } + /// /// Stored scriptblocks that can be retrieved on demand anywhere within the process ///