Devdeer.Caf.psm1

<#
.SYNOPSIS
    Approves a PIM Role assignment request for a single user.
.DESCRIPTION
    Checks if the user is eligible for the role and activates the assignment.
.PARAMETER Tenant
    The tenant id or domain name.
.PARAMETER RoleId
    The id of the role. Default is "Global Administator".
.PARAMETER Justification
    The justification for the approval.
.PARAMETER UserId
    The id of the user who created the approval request.
.PARAMETER UserName
    The starting part of the UPN of the user who created the approval request.
    We only can provide the startsWith functionallity here because Azure Graph does
    not expose contains to default callers.
.EXAMPLE
    Approve-CafPimRole -Tenant TODO
#>

function  Approve-PimRole {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Justification,
        [string]
        $Tenant,
        [string]
        $RoleId = "62e90394-69f5-4237-9190-012177145e10",
        [string]
        $UserId,
        [string]
        $UserName
    )
    process {
        if ($UserId -and $UserName) {
            throw "You cannot query for both, id and name, at the same time."
        }
        if (!$UserId -and !$UserName) {
            throw "You must either specificy UserId or UserName."
        }
        $ctx = Use-CafContext
        if (($ctx.Keys | Measure-Object).Count -eq 0) {
            # No context was found
            if ($Tenant.Length -eq 0) {
                throw "No AZ context was found. You need to provide a tenant!"
            }
            Connect-Tenant -TenantId $Tenant
            if (!$?) {
                throw "Could not connect to provided tenant."
            }
            # use the tenant id from the default Azure context
            $tenantId = (Get-AzContext).Tenant.Id
        }
        else {
            $tenantId = $ctx.tenantId
        }
        # Ensure that Microsoft.Graph.Authentication module is present
        Enable-Module -ModuleName Microsoft.Graph.Authentication
        # Ensure that Microsoft.Graph.Authentication module is present
        Enable-Module -ModuleName Microsoft.Graph.Identity.Governance
        # Ensure that Microsoft.Resources module is present
        Enable-Module -ModuleName Az.Resources
        # All needed modules are present.
        #Connect to the Graph API
        $scopes = @(
            "RoleAssignmentSchedule.ReadWrite.Directory",
            "PrivilegedAccess.ReadWrite.AzureAD"
        )
        # connect to the Graph API
        Connect-MgGraph -Scopes $scopes -TenantId $tenantId -NoWelcome
        if (!$?) {
            throw "Could not connect to the Graph API."
        }
        # Get the current user's principal id
        $azCtx = Get-AzContext
        $user = Get-AzADUser -Mail $azCtx.Account.id
        $principalId = $user.Id
        # Retrieve active requests for PIM
        $uri = "/beta/roleManagement/directory/roleAssignmentScheduleRequests?`$filter=(status eq 'PendingApproval') and (principalId ne '$principalId') and (roleDefinitionId eq '$RoleId')"
        $pendingApprovals = (Invoke-GraphRequest -Method GET -Uri $uri).value
        if ($pendingApprovals.Count -eq 0) {
            Write-Host "No matching pending requests found." -ForegroundColor Yellow
            return
        }
        # retrieve the user which is active and matching the criteria
        $uri = "/beta/users?`$filter=(accountEnabled eq true) and ("
        if ($UserId) {
            $uri += "id eq '$UserId'"
        }
        if ($UserName) {
            $uri += "startsWith(userPrincipalName, '$UserName')"
        }
        $uri += ")"
        $matchedUserInfo = (Invoke-GraphRequest `
                -ErrorAction SilentlyContinue `
                -Method GET `
                -Uri $uri).value
        if (!$? -or $matchedUserInfo.Count -gt 1 -or $matchedUserInfo.Count -eq 0) {
            throw "No or more than one user was found."
        }
        # check if there are pending approvals for the user we want to approve
        $userInfo = $matchedUserInfo
        $pendingApprovals = $pendingApprovals | Where-Object principalId -eq $userInfo.Id
        if ($pendingApprovals.Count -eq 0) {
            Write-Host "No pending requests found for user '$($userInfo.Id)'." -ForegroundColor Yellow
            return
        }
        # Get approval steps
        $approvalsAmount = ($pendingApprovals | Measure-Object).Count
        if ($approvalsAmount -eq 0) {
            throw "No approval was loaded."
        }
        $approval = $approvalsAmount -eq 1 ? $pendingApprovals : $pendingApprovals[0]
        $approvalId = $approval.approvalId
        $uri = "/beta/roleManagement/directory/roleAssignmentApprovals/$approvalId"
        $approvalSteps = (Invoke-GraphRequest `
                -Method GET `
                -Uri $uri).steps | Where-Object status -eq 'InProgress'
        if (!$? -or !$approvalSteps) {
            throw "Could not retrieve approval with id '$approvalId'."
        }
        $stepsAmount = ($approvalSteps | Measure-Object).Count
        if ($stepsAmount -eq 0) {
            throw "No step in the request '$approvalId' available to allow approvals."
        }
        $approvalStep = $stepsAmount -eq 1 ? $approvalSteps : $approvalSteps[0]
        # Approve the request
        Write-Host "Approving user '$($userInfo.DisplayName)' ($($userInfo.Id))"
        $body = @{
            reviewResult  = 'Approve'
            justification = $justification
        }
        $stepId = $approvalStep.id
        $uri = "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignmentApprovals/$approvalId/steps/$stepId"
        Invoke-GraphRequest `
            -Method PATCH `
            -Uri $uri `
            -Body $body | Out-Null
        if (!$?) {
            throw "Could not approve user $($userInfo.DisplayName) with id $($userInfo.Id)"
        }
        Write-Host "Approved user '$($userInfo.DisplayName)' ($($userInfo.Id))."
    }
}

<#
 .Synopsis
 Removes all custom firewall rules currently added to the SQL server given with regard to resource group locks.
 .Description
 Removes all firewall rules currently added to the SQL server given. Any nodelete lock on the parent resource group
 will be de- and re-activated automatically using the service principals. You don't need to be elevated for this. The
 rule to allow Azure resources accessing the server will not be affected by this.
 .Parameter AzureSqlServerName
 The name of the SQL server
 .Parameter TenantId
 The unique ID of the tenant where the subscription lives in for faster context switch.
 .Example
  Clear-CafAllSqlFirewallRules -AzureSqlServerName mySQLServerName
#>

Function Clear-AllSqlFirewallRules {
    [CmdLetBinding()]
    param (
        [string]
        $AzureSqlServerName,
        [string]
        $TenantId
    )
    process    {
        $ErrorActionPreference = 'Stop'
        if (!$TenantId) {
            $ctx = Use-CafContext
            if ($ctx.Keys -contains "tenantId") {
                $Tenant = $ctx.tenantId
            }
            if (($ctx.Keys | Measure-Object).Count -eq 0) {
                # No context was found
                if ($Tenant.Length -eq 0) {
                    throw "No AZ context was found. You need to provide a tenant!"
                }
            }
        }
        else {
            Connect-Tenant -TenantId $TenantId
            if (!$?) {
                throw "Could not connect to the provided tenant."
            }
        }
        # If server name not passed in check if .azcontext contains one
        if ($AzureSqlServerName.Length -eq 0) {
            if ($ctx.Keys -contains "sqlServerName") {
                $AzureSqlServerName = $ctx.sqlServerName
            }
            else {
                throw "No SQL server name was provided and no default was found in .azcontext"
            }
        }
        # Check if the SQL server exists
        $server = Search-AzGraph -Query "where type =~ 'Microsoft.Sql/servers' and name =~ '$AzureSqlServerName'"
        if (!$? -or $server.Count -gt 1 -or $server.Count -eq 0) {
            throw "No or more than one resource was found in tenant '$TenantId' with name '$AzureSqlServerName'."
        }
        # If the resource is found switch the context to the subscription where the resource is located
        $subscriptionId = $server[0].SubscriptionId
        if (!$subscriptionId) {
            throw "could not retrive the subscription ID for resource '$AzureSqlServerName'."
        }
        if (!$TenantId) {
            Set-AzContext -Tenant $ctx.tenantId -Subscription $subscriptionId | Out-Null
        }
        else {
            Set-AzContext -Tenant $TenantId -Subscription $subscriptionId | Out-Null
        }
        if (!$?) {
            throw "Could not set azcontext to subscription '$subscriptionId'."
        }
        # Check if there are any firewall rules to clear
        $existintRules = Get-AzSqlServerFirewallRule -ServerName $server.name -ResourceGroupName $server.resourceGroup
        $amount = $existintRules.Length
        if ($amount -eq 0) {
            Write-HostMessage -Message "Terminating because no firewall rules where found on Azure SQL $AzureSqlServerName" -Level Warning
            return
        }
        Write-HostMessage "Found $amount firewall rules on server '$AzureSqlServerName'"
        # We download the actual script so that after replacing params we can easilly pass it to Start-CafScoped.
        $script = "script.ps1"
        $key = Get-Date -Format "yyyy-dd-MM-HH-mm-ss"
        Invoke-WebRequest "https://raw.githubusercontent.com/DEVDEER/spock-content/main/static/clear-firewall-rules.ps1?key=$key" -OutFile $script | Out-Null
        $scriptContent = Get-Content -Raw $script
        $scriptContent = $scriptContent.Replace('%RG_NAME%', $server.resourceGroup)
        $scriptContent = $scriptContent.Replace('%SQL_NAME%', $server.name)
        $scriptContent | Set-Content $script
        # Execute the script in the scope.
        Start-CafScoped -FileCommand ".\$script" -ServicePrincipalType "deploy" -ErrorAction SilentlyContinue -NoLogo
        if (!$?) {
            Write-HostMessage "Could not execute script to delete firewall rules." -Level Error
        }
        Remove-Item $script
    }
}

<#
 .Synopsis
  Deletes all policies defined in the BICEP files under the current path.
 .Description
  When executed inside specific policy type folders (Assignments, Definitions, Initiative) it will read
  the bicep files and delete the policies defined in them. The variable policyName must be defined in the
  bicep files for this to work. Genral rule for deleting policies is to delete assignments first, then
  initiatives and finally defnitions.
 .PARAMETER ServicePrincipalType
  Defines the type of service principal (deploy or ops) should be used (defaults to deploy).
 .PARAMETER Recurse
  If this switch is present, the command will recurse into sub directories and delete policies there as well.
 .PARAMETER WhatIf
  If this switch is present, the command will not actually delete anything but only show what would be deleted.
 .PARAMETER Force
  If this switch is present, the command will not ask for confirmation before deleting the policies.
  Clear-CafPolicyAssets
#>

function Clear-PolicyAssets {
    [CmdletBinding()]
    param (
        [ValidateSet("All", "None", "RequestContent", "ResponseContent")]
        [String]
        $DebugLevel = "All",
        [Parameter(Mandatory = $false)]
        [ValidateSet("deploy", "ops")]
        [string]
        $ServicePrincipalType = "deploy",
        [switch]
        $WhatIf,
        [switch]
        $Recurse,
        [switch]
        $Force
    )
    process {
        $ErrorActionPreference = 'Stop'
        $root = $PWD.Path
        $ctx = Get-CafContext
        # get all BICEP files in this and all sub directories
        if ($Recurse.IsPresent) {
            $bicepFilesCount = (Get-ChildItem $root -Filter *.bicep -Recurse | Measure-Object).Count
        } else {
            $bicepFilesCount = (Get-ChildItem $root -Filter *.bicep | Measure-Object).Count
        }
        if ($bicepFilesCount -eq 0) {
            Write-Host "No BICEP files in target directory. Exiting."
            return
        }
        if ($Recurse.IsPresent) {
            $bicepFiles = Get-ChildItem $root -Filter *.bicep -Recurse
        } else {
            $bicepFiles = Get-ChildItem $root -Filter *.bicep
        }
        Write-Host "Found $($bicepFilesCount) items under $root"
        foreach ($file in $bicepFiles) {
            Write-VerboseOnly " $($file)"
        }
        $ctx = Get-CafContext
        # Find all resource definitions not point to existing resources and put the resource type in the
        # match group with offset 2.
        $regex = "resource(.*)'Microsoft.Authorization\/(.*)@(.*)'(?:(?!existing).)*?{"
        # Find the policy name in the BICEP files and put it in the match group with offset 2.
        $policyNameRegex = "var (policyName|policyAssignmentName|policySetName)\s*=\s*'([^']+)'"
        # delete policies defined in BICEP files
        $tasks = @()
        $policyNames = @()
        $resourceIds = @()
        # collect deployment tasks
        foreach ($file in $bicepFiles) {
            $bicepContent = Get-Content -Raw $file
            # perform regex search of BICEP file content to find out what type of BICEP that is
            $result = $bicepContent -match $regex
            if (!$result) {
                throw "Invalid BICEP at file $file. This is not a policy BICEP!"
            }
            $bicepType = $matches[2]
            # resolve the type to use from the regex result
            $type = $bicepType -eq 'policySetDefinitions' ? 'initiative' : `
                $bicepType -eq 'policyDefinitions' ? 'definition' : `
                $bicepType -eq 'policyAssignments' ? 'assignment' : `
                ''
            if ($type.Length -eq 0) {
                # the regex didn't find anything
                throw "Could not determine policy type from BICEP file $file"
            }
            # determining which policy remove command has to be used
            $commandType = $bicepType -eq 'policySetDefinitions' ? 'Remove-AzPolicySetDefinition' : `
                $bicepType -eq 'policyDefinitions' ? 'Remove-AzPolicyDefinition' : `
                $bicepType -eq 'policyAssignments' ? 'Remove-AzPolicyAssignment' : `
                ''
            if ($commandType.Length -eq 0) {
                # the regex didn't find anything
                throw "Could not determine policy type from BICEP file $file"
            }
            # perform regex search of BICEP file content to find out the resources in the file
            $policyNameResult = $bicepContent -match $policyNameRegex
            if (!$policyNameResult) {
                throw "Did not find variable policyName in $file. This is not a valid policy BICEP!"
            }
            $policyNames += $matches[2]
            $tasks += @{
                Filename       = $file.Name
                FilePath       = $file
                Directory      = $file.Directory
                Type           = $type
            }
        }
        # check if all tasks are of same type
        $last = ''
        foreach ($task in $tasks) {
            if ($last.Length -gt 0 -and $last -ne $task.Type) {
                throw "You cannot delete different types of policy assets in one run. Please ensure that you delete
                all policy assignments first, then all policy set definitions and finally all definitions ."

            }
            $last = $task.Type
        }
        #At this point we know that all tasks are of the same type and we can proceed.
        $current = 0
        # get the resource id of the policy assignment
        if ($commandType -eq 'Remove-AzPolicyAssignment') {
            $assignments = Get-AzPolicyAssignment -Scope "/providers/Microsoft.Management/managementgroups/$($ctx.managementGroupId)"
            if ($assignments) {
                foreach ($name in $policyNames) {
                    $assignment = $assignments | Where-Object { $_.Name -eq $name }
                    Write-Host $assignment
                    if ($assignment) {
                        $resourceIds += $assignment.ResourceId
                    }
                    else {
                        Write-Host "No policy assignment found for name $name"
                        # if the name is not found, delete this $name form the policyNames array
                        $policyNames = $policyNames | Where-Object { $_ -ne $name }
                    }
                }
            }
        }
        Write-VerboseOnly "Using [$ServicePrincipalType] service principal for clearing resources."
        if ($WhatIf.IsPresent) {
            Write-Host "The following commands would be executed if -WhatIf wasn't present:"
        }
        $scriptContent = ''
        $total = $policyNames | Measure-Object | Select-Object -ExpandProperty Count
        foreach ($name in $policyNames) {
            $current++
            $command = $commandType
            # build up the command text
            if ($commandType -eq 'Remove-AzPolicyAssignment') {
                # build or skip for assignments
                if ($resourceIds[$current - 1].Length -eq 0) {
                    continue
                }
                $command = $command + ' -ResourceId "' + $resourceIds[$current - 1] + '"'
            }
            else {
                # build for everyting but assignments
                $command += ' -Name "' + $name + '"'
                $command += ' -ManagementGroupName "' + $ctx.managementGroupId + '"'
            }
            if ($Force.IsPresent -and ($commandType -eq 'Remove-AzPolicyDefinition' -or $commandType -eq 'Remove-AzPolicySetDefinition')) {
                $command += " -Force"
            }
            $command += " | Out-Null"
            # build up script content or just inform on host depending on -WhatIf
            if ($WhatIf.IsPresent) {
                Write-Host " $command"
            } else {
                $scriptContent += "Write-Host '($current of $total) Deleting BICEP policy $name...'" + [Environment]::NewLine
                $scriptContent += "$command" + [Environment]::NewLine
            }
        }
        if (!$WhatIf.IsPresent) {
            # build and execute the script contant as a file
            $file = "$PWD/tmp.ps1"
            Set-Content $file $scriptContent
            Start-CafScoped -FileCommand -Command $file -ServicePrincipalType "$ServicePrincipalType" -servicePrincipalScope "ManagementGroup"
            # remove the file
            Remove-Item $file
            if (!$?) {
                throw "Error during clearing of policy $($name). Note that you have to delete all policy assignments first,
                then all policy definitions and finally all policy set definitions."

            }
        }
    }
}

<#
 .Synopsis
 Deploys all BICEP files under the current path considering them to define resources from the provider 'Microsoft.Authorization/*'
 .Description
 When executed inside specific policy type folders (Assignments, Definitions, Initiative)
 it will read the bicep files and deploy the policies defined in them.
 .PARAMETER ServicePrincipalType
 Defines the type of service principal (deploy or ops) should be used (defaults to deploy).
 .PARAMETER Recurse
 If this switch is present, the command will recurse into sub directories and delete policies there as well.
 .PARAMETER WhatIf
 If this switch is present, the command will not actually delete anything but only show what would be deleted.

 .Example
  Deploy-CafPolicyAssignments
#>

function Deploy-PolicyAssets {
    [CmdletBinding()]
    param (
        [ValidateSet("All", "None", "RequestContent", "ResponseContent")]
        [String]
        $DebugLevel = "All",
        [Parameter(Mandatory = $false)]
        [ValidateSet("deploy", "ops")]
        [string]
        $ServicePrincipalType = "deploy",
        [switch]
        $WhatIf,
        [switch]
        $Recurse
    )
    process {
        $ErrorActionPreference = 'Stop'
        $root = $PWD.Path
        $location = 'West Europe'
        $parameterFile = "$deploymentPath/parameters.json"
        # get all BICEP files in this and all sub directories
        if ($Recurse.IsPresent) {
            $bicepFilesCount = (Get-ChildItem $root -Filter *.bicep -Recurse | Measure-Object).Count
        }
        else {
            $bicepFilesCount = (Get-ChildItem $root -Filter *.bicep | Measure-Object).Count
        }
        if ($bicepFilesCount -eq 0) {
            Write-Host "No BICEP files in target directory. Exiting."
            return
        }
        if ($Recurse.IsPresent) {
            $bicepFiles = Get-ChildItem $root -Filter *.bicep -Recurse
        }
        else {
            $bicepFiles = Get-ChildItem $root -Filter *.bicep
        }
        Write-Host "Found $($bicepFilesCount) policies under $root"
        foreach ($file in $bicepFiles) {
            Write-VerboseOnly " $($file)"
        }
        $ctx = Get-CafContext
        # Find all resource definitions not point to existing resources and put the resource type in the
        # match group with offset 2.
        $regex = "resource(.*)'Microsoft.Authorization\/(.*)@(.*)'(?:(?!existing).)*?{"
        # create and start a deployment for each BICEP file found
        $tasks = @()
        # collect deployment tasks
        foreach ($file in $bicepFiles) {
            # perform regex search of BICEP file content to find out what type of BICEP that is
            $bicepContent = Get-Content -Raw $file
            $result = $bicepContent -match $regex
            if (!$result) {
                throw "Invalid BICEP at file $file. This is not a policy BICEP!"
            }
            $bicepType = $matches[2]
            $type = $bicepType -eq 'policySetDefinitions' ? 'initiative' : `
                $bicepType -eq 'policyDefinitions' ? 'definition' : `
                $bicepType -eq 'policyAssignments' ? 'assignment' : `
                ''
            if ($type.Length -eq 0) {
                # the regex didn't find anything
                throw "Could not determine policy deployment type from BICEP file $file"
            }
            $dateSuffix = Get-Date -Format "yyyy-dd-MM-HH-mm-ss"
            $deploymentName = $WhatIf.IsPresent ? "deploy-whatif" : "deploy-$type-$dateSuffix"
            $tasks += @{
                DeploymentName = $deploymentName
                Filename       = $file.Name
                FilePath       = $file
                Directory      = $file.Directory
                Type           = $type
            }
        }
        # check if all tasks are of same type
        $last = ''
        foreach ($task in $tasks) {
            if ($last.Length -gt 0 -and $last -ne $task.Type) {
                throw "You cannot deploy different types of policy assets in one run."
            }
            $last = $task.Type
        }
        # At this point we know that all tasks are of the same type and we can proceed.
        $current = 0
        $total = $tasks.Length
        foreach ($task in $tasks) {
            $current++
            Write-Host "($current of $total) Deploying BICEP policy [$($task.Type)] from file [$($task.Filename)] with name [$($task.DeploymentName)]..."
            $command = 'New-AzManagementGroupDeployment `
                        -Name "'
 + $($task.DeploymentName) + '" `
                        -Location "'
 + $location + '" `
                        -ManagementGroupId "'
 + $ctx.managementGroupId + '" `
                        -TemplateFile "'
 + $($task.FilePath) + '" `
                        -DeploymentDebugLogLevel "'
 + $DebugLevel + '"'
            if ($WhatIf) {
                $command = $command + " -WhatIf"
            }
            $parameterFile = "$($task.Directory)/parameters.json"
            if (Test-Path $parameterFile) {
                # we need to add the parameters file to the command
                $command += ' -TemplateParameterFile "' + $parameterFile + '"'
            }
            Write-VerboseOnly "Using $ServicePrincipalType service principal for deployment"
            Start-CafScoped -Command $command -ServicePrincipalType "$ServicePrincipalType" -servicePrincipalScope "ManagementGroup"
            if (!$?) {
                throw "Error during deployment of definition in BICEP $($task.File)."
            }
        }
    }
}

<#
 .Synopsis
 Retrieves the combined .azcontext-based settings that are valid in the current directory.
 .Description
 Searches for all ".azcontext" files in and above the current PWD and combines the values
 of them. Keep in mind that it also searches for such a file in the user home directory!
 .Example
  $ctx = Get-CafContext
#>

function Get-Context {
    [CmdLetBinding()]
    param (
        [switch]$NoLogo
    )
    process {
        if (!$NoLogo.IsPresent) {
            Write-Logo
        }
        $ErrorActionPreference = 'Stop'
        $files = Find-FilesByNameUp -FileName '.azcontext'
        # try to add the file in the users home
        $file = Join-Path '~' '.azcontext'
        if (Test-Path $file) {
            $files.Add($file)
        }
        # spit out the results
        Write-VerboseOnly "Found $($files.Count) context files"
        $result = @{
            namingConvention = Get-DefaultNamingConventions
        }
        $isRoot = $false
        foreach ($file in $files) {
            Write-VerboseOnly "Found context file $file"
            $json = Get-Content -Raw $file | ConvertFrom-Json
            $loadedValues = ConvertTo-Hashtable -InputObject $json
            foreach ($key in $($loadedValues.Keys)) {
                if (!$result[$key]) {
                    # key does not exist yet, so add it
                    $result[$key] = $loadedValues[$key]
                }
                if ($key -eq 'namingConvention') {
                    if (!$loadedValues[$key]) {
                        # the file set the the conventions to $null
                        continue
                    }
                    # we need to merge the default naming conventions with the given ones
                    $result.namingConvention = Merge-Hashtables -BaseTable $result.namingConvention -TableToMerge $loadedValues[$key]
                }
                if ($key -eq "isRoot" -and $loadedValues[$key]) {
                    # this is the file where inheritance upwards the folder
                    # structure should end
                    $isRoot = $true
                    $result["rootPath"] = Split-Path $file
                }
            }
            if ($isRoot) {
                # don't go further down the tree
                break
            }
        }
        if ($result.forceAzConfig -and !$env:NO_DEVDEER_AZ_CONFIG) {
            # The caller wants us to force update the PowerShell Az config
            $subscriptionId = $result['subscriptionId'] ?? $result['managementSubscriptionId']
            Update-AzConfig -DefaultSubscriptionForLogin $subscriptionId `
                -EnableErrorRecordsPersistence $true `
                -DisplayRegionIdentified $false `
                -EnableDataCollection $false `
                -DisplaySurveyMessage $false | Out-Null
            Write-VerboseOnly "AZ config was updated. Run `Get-AzConfig` to view current values."
            $env:NO_DEVDEER_AZ_CONFIG = 1
        }
        return $result
    }
}


<#
.SYNOPSIS
    Initializes the security group AZ-CAF-DeployPrincipals for deployment service principals.
.DESCRIPTION
    Retrieves all service principals in the tenant that are visible to the current user and adds them to their security group AZ-CAF-DeployPrincipals.
.EXAMPLE
    Initialize-CafDeploymentSpGroup
#>

function Initialize-DeploymentSpGroup {
    [CmdLetBinding()]
    param (
    )
    process {
        $ErrorActionPreference = 'Stop'
        $ctx = Use-CafContext
        if (!$ctx.managementSubscriptionId) {
            throw "Management subscription not defined in .azcontext"
        }
        # Get the sub id of the management subscription which contains the central log analytics workspace
        $scopeResourceId = "/subscriptions/$($ctx.managementSubscriptionId)"
        Write-VerboseOnly "Using subscription scope $scopeResourceId for assigning log analytics roles..."
        # Get all matching deploy SPs
        $subServicePrincipalNameRegex = Format-NamingConvention -Context $ctx `
            -Type "servicePrincipal" -SubType "deploySubscription" -ProjectName ".*"
        $mgServicePrincipalNameRegex = Format-NamingConvention -Context $ctx `
            -Type "servicePrincipal" -SubType "deployManagementGroup" -ProjectName ".*"
        # Fetch the service principals that match the naming convention
        $servicePrincipals = Get-AzADServicePrincipal | Where-Object { 
            $_.DisplayName -match $subServicePrincipalNameRegex -or $_.DisplayName -match $mgServicePrincipalNameRegex }
        if ($servicePrincipals.Count -eq 0) {
            Write-Host "No deploy service principals where found in the tenant $($ctx.tenantId)."
            return
        }
        Write-Host "Found $($servicePrincipals.Count) matching service principals."
        # Ensure the security group is present
        $securityGroupName = Format-NamingConvention -Context $ctx -Type "securityGroup" -SubType "deploySpSecurityGroup"
        Write-VerboseOnly "Finding security group $securityGroupName..."
        $securityGroup = Get-AzADGroup -DisplayName $securityGroupName -ErrorAction SilentlyContinue
        if (!$securityGroup) {
            Write-Host "Creating security group: $securityGroupName"
            $securityGroup = New-AzADGroup -DisplayName $securityGroupName -MailNickname $securityGroupName
            if (!$?) {
                throw "Could not create security group."
            }
            Write-Host "Created security group: $($securityGroup.DisplayName)"
        }
        # This is necessary because the SPs for deployment cannot setup diagnostics settings due to PIM
        # Add service principals to the security group
        $memberIds = Get-AzAdGroupMember -GroupObjectId $securityGroup.Id -WarningAction SilentlyContinue | Select-Object -ExpandProperty Id
        foreach ($sp in $servicePrincipals) {
            if ($sp.Id -in $memberIds) {
                Write-VerboseOnly "$($sp.DisplayName) already is member of security group."
                continue
            }
            Add-AzADGroupMember -MemberObjectId $sp.Id -TargetGroupObjectId $securityGroup.Id -WarningAction SilentlyContinue
            if (!$?) {
                throw "Could not add object $($sp.Id) as member of security group."
            }
            Write-Host "Added $($sp.DisplayName) to the security group: $($securityGroup.DisplayName)"
        }
        # Assign the role to the security group for the target resource
        # Define the role id for 'Log Analytics Contributor'
        $roleId = "92aaf0da-9dab-42b6-94a3-d43ce8d16293"
        $existing = Get-AzRoleAssignment -Scope $scopeResourceId -ObjectId $securityGroup.Id -RoleDefinitionId $roleId -ErrorAction SilentlyContinue
        if (!$?) {
            throw "Could not read role assignments for object $($securityGroup.Id) on scope $scopeResourceId."
        }
        if ($existing.Count -eq 0) {
            New-AzRoleAssignment -RoleDefinitionId $roleId -ObjectId $securityGroup.Id -Scope $scopeResourceId -ErrorAction SilentlyContinue | Out-Null
            if (!$?) {
                throw "Could not assign role $roleId for object $($securityGroup.Id) on scope $scopeResourceId. Maybe try to re-run Connect-AzAccount -TenantId $($ctx.tenantId)."
            }
            Write-Host "Assigned role to $($securityGroup.DisplayName) for LAW at scope $scopeResourceId"
        }
        else {
            Write-Host "Security Group $($securityGroup.DisplayName) already has the required role at scope $scopeResourceId."
        }
    }
}

<#
.SYNOPSIS
    Initializes the default service principals in all management groups and subscriptions of the tenant.
.DESCRIPTION
    Retrieves all subscriptions in the tenant that are visible to the current user and deploys default service principals to each of them.
.PARAMETER DoNotEnsureDeployGroup
    If provided this function will NOT call Initialize-CafDeploymentSpGroup after SP creation automatically.
.PARAMETER WhatIf
    Determines if the actions should be not executed but only reported.
.EXAMPLE
    Initialize-CafServicePrincipals
#>

function Initialize-ServicePrincipals {
    [CmdLetBinding()]
    param (
        [switch]
        $DoNotEnsureDeployGroup,
        [switch]
        $WhatIf
    )
    process {
        $ErrorActionPreference = 'Stop'
        $ctx = Use-CafContext
        # retrieve all subscriptions in the tenant that are visible to the current user but exclude those which are visible through lighthouse (e.g. not originating in the current tenant).
        $subscriptions = Get-AzSubscription -TenantId $ctx.tenantId | Where-Object { $_.ExtendedProperties.HomeTenant -eq $ctx.tenantId -and $_.State -eq 'Enabled' }
        # Filter out all the subscriptions that are on the ignore list
        $ctx.subscriptionsToIgnore | ForEach-Object {
            $ignoreId = $_
            $ignoredSubscription = $subscriptions | Where-Object { $_.Id -eq $ignoreId }
            if ($ignoredSubscription) {
                Write-HostDebug "Subscription $($ignoredSubscription.Name) ($($ignoredSubscription.Id)) is on the ignore list. Skipping."
            }
            $subscriptions = $subscriptions | Where-Object { $_.Id -ne $ignoreId }
        }
        # Filter out all the management groups that are on the ignore list
        $managementGroups = Get-AzManagementGroup
        $ctx.managementGroupsToIgnore | ForEach-Object {
            $ignoreId = $_
            $ignoredManagementGroup = $managementGroups | Where-Object { $_.Name -eq $ignoreId }
            if ($ignoredManagementGroup) {
                if ($ignoredManagementGroup.Name -eq $ctx.tenantId) {
                    Write-HostDebug "Tenant Root Group is skipped."
                }
                else {
                    Write-HostDebug "Management Group $($ignoredManagementGroup.Name) is on the ignore list. Skipping."
                }
            }
            $managementGroups = $managementGroups | Where-Object { $_.Name -ne $ignoreId }
        }
        # SUBSCRIPTIONS
        $actionsToPerform = @()
        foreach ($subscription in $subscriptions) {
            # Build the subscription(landing zone) name from the az context naming convention
            $lzSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone" -ProjectName ".*"
            if (!$lzSubscriptionNameRegex) {
                throw "Failed to get the naming convention for the landing zone subscription."
            }
            # Build the IAM subsciption name from the az context naming convention
            $iamSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "iamSubscription" -ProjectName ".*"
            if (!$iamSubscriptionNameRegex) {
                throw "Failed to get the naming convention for the IAM subscription."
            }
            $subscriptionName = $subscription.Name
            $subscriptionId = $subscription.Id
            if ($SubscriptionName -notmatch $lzSubscriptionNameRegex -and $SubscriptionName -notmatch $iamSubscriptionNameRegex) {
                Write-HostWarning "Subscription with name: '$SubscriptionName' | ID: '$subscriptionId' does not conform with subscription naming conventions '$lzSubscriptionName' or '$iamSubscriptionName'...Skipping."
                continue
            }
            # We use Format-NamingConvention to build the project name pattern and try to find the index of the element "[x]" in the pattern. This gives us
            # the position of the real project name inside the given subscription name.
            $sep = $ctx.namingConvention.subscription.landingZone.separator
            $regex = $sep + "?([^-]*)" + $sep + "?"
            $namePattern = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone"
            # NOTE: We are doing the conversion into a .NET list because this gives us the FindIndex method which we need to find the index of the project name in the subscription name.
            $offset = ([Collections.Generic.List[Object]](($namePattern | Select-String -Pattern $regex -AllMatches).Matches)).FindIndex({ $args[0].Groups[1].Value -eq "[x]" })
            $projectName = $SubscriptionName.Split($sep)[$offset]
            if ($projectName.Length -eq 0) {
                throw "Could not deduce project name from subscription name '$SubscriptionName'."
            }
            #TODO: -IAM needs to be parameterized.
            if ($SubscriptionName -match $iamSubscriptionNameRegex) {
                $projectName = $projectName + "-iam"
            }
            Write-VerboseOnly "Project name: $projectName"
            # create service principals for devops and operations
            # Calls the Set-CafServicePrincipal to create the service principal for deployment
            $actionsToPerform += New-Object PSObject -Property ([ordered]@{
                    'ScopeType'      = "Subscription"
                    'ScopeName'      = $projectName
                    'ScopeId'        = "/subscriptions/$($subscription.Id)"
                    'Role'           = "Owner"
                    'Suffix'         = "deploy"
                    'SubscriptionId' = $subscription.Id
                })
            $actionsToPerform += New-Object PSObject -Property ([ordered]@{
                    'ScopeType'      = "Subscription"
                    'ScopeName'      = $projectName
                    'ScopeId'        = "/subscriptions/$($subscription.Id)"
                    'Role'           = "Contributor"
                    'Suffix'         = "ops"
                    'SubscriptionId' = $subscription.Id
                })
        }
        # MANAGEMENT GROUPS
        foreach ($managementGroup in $managementGroups) {
            $managementGroupName = $managementGroup.Name
            # the root management group has the tenant id as name
            if ($managementGroup.Name -eq $ctx.tenantId) {
                $managementGroupName = "$($ctx.companyName)-root"
            }
            # We use Format-NamingConvention to build the management group name pattern and deduce index of the element "[x]" in the pattern.
            # This gives us the position of the real project name location from the Azure queried management group name.
            $sep = $ctx.namingConvention.ManagementGroup.separator
            $regex = $sep + "?([^-]*)" + $sep + "?"
            $namePattern = Format-NamingConvention -Context $ctx -Type "managementGroup"
            # NOTE: We are doing the conversion into a .NET list because this gives us the FindIndex method which we need to find the index of the project name in the subscription name.
            $offset = ([Collections.Generic.List[Object]](($namePattern | Select-String -Pattern $regex -AllMatches).Matches)).FindIndex({ $args[0].Groups[1].Value -eq "[x]" })
            $projectName = $managementGroupName.Split($sep)[$offset]
            if ($projectName.Length -eq 0) {
                throw "Could not deduce Management Group name from '$managementGroupName'."
            }
            Write-VerboseOnly "Management group name: $projectName"
            $iamSubscriptionName = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "iamSubscription" -ProjectName $projectName
            # Find this iamSubscriptionName in the subscriptions list
            $iamSubscription = $subscriptions | Where-Object { $_.Name -match $iamSubscriptionName }
            $actionsToPerform += New-Object PSObject -Property ([ordered]@{
                    'ScopeType'      = "ManagementGroup"
                    'ScopeName'      = $projectName
                    'ScopeId'        = $managementGroup.Id
                    'Role'           = "Owner"
                    'Suffix'         = "deploy"
                    'SubscriptionId' = $iamSubscription.Id
                })
            $actionsToPerform += New-Object PSObject -Property ([ordered]@{
                    'ScopeType'      = "ManagementGroup"
                    'ScopeName'      = $projectName
                    'ScopeId'        = $managementGroup.Id
                    'Role'           = "Contributor"
                    'Suffix'         = "ops"
                    'SubscriptionId' = $iamSubscription.Id
                })
        }
        # Execute the collection actions now
        $subActions = @()
        $i = 0
        $total = $actionsToPerform.Count
        foreach ($action in $actionsToPerform) {
            $i++
            $progress = [Math]::Round(($i * 100) / $total, 0)
            Write-Progress -Activity "Handling actions" `
                -Status "$progress%" `
                -PercentComplete $progress
            $subActions += Set-CafServicePrincipal `
                -ScopeType $action.ScopeType `
                -ScopeName $action.ScopeName `
                -ScopeId $action.ScopeId `
                -Role $action.Role `
                -Suffix $action.Suffix `
                -SubscriptionId $action.SubscriptionId `
                -WhatIf:$WhatIf
        }
        if (!$WhatIf.IsPresent) {
            # Add the service principals to the central service deployment security group
            if (!$DoNotEnsureDeployGroup.IsPresent) {
                Initialize-CafDeploymentSpGroup
            }
            # Remove the stale role assignments
            Remove-CafStaleRoleAssignments
        }
        else {
            Write-Host ""
            Write-HostInfo "----------"
            Write-HostInfo "Creates: $(($subActions | Where-Object { $_.ActionType -eq 'Create' }).Count)"
            Write-HostInfo "Updates: $(($subActions | Where-Object { $_.ActionType -eq 'Update' }).Count)"
            Write-HostInfo "Deletes: $(($subActions | Where-Object { $_.ActionType -eq 'Delete' }).Count)"
            Write-HostInfo "----------"
            Write-HostInfo "Details:"
            Write-Host ""
            $subActions
        }
        return $subActions
    }
}

<#
.SYNOPSIS
    Initializes the subscription management resources for a single subscription.
.DESCRIPTION
    Deploys the subscription management resources to a single subscription.
.PARAMETER SubscriptionId
    The subscription id to use for the deployment.
.PARAMETER SubscriptionName
    The subscription name to use for the deployment. If not specified, the subscription name is retrieved from Azure.
.PARAMETER WhatIf
    If specified, the deployment is only simulated.
.PARAMETER DeploymentTechType
    The deployment technology to use for the deployment. Default is 'bicep'.
.PARAMETER RemoveArtifacts
    If specified, the downloaded assets are removed after the deployment.
.PARAMETER ForceAssetDownload
    If specified, the assets are downloaded again even if they already exist.
.Parameter BicepSettingsPath
    The path to the Bicep settings file.
.PARAMETER DeploymentFileName
    The name of the Bicep or Terraform deployment file.
.PARAMETER DeploymentParameterFileName
    The name of the Bicep or terraform parameter file.
.EXAMPLE
    Initialize-CafSubscription `
        -SubscriptionId "00000000-0000-0000-0000-000000000000" `
        -SubscriptionName "prefix-companyshort-projectname" `
        -WhatIf
#>

function Initialize-Subscription {
    [CmdLetBinding()]
    param (
        [String]
        $SubscriptionId = "",
        [String]
        $SubscriptionName = "",
        [string]
        $DeploymentFileName = "main.bicep",
        [string]
        $DeploymentParameterFileName = "main.bicepparam",
        [string]
        $BicepSettingsPath = "",
        [string]
        [validateSet("bicep", "terraform")]
        $DeploymentTechType = "bicep",
        [string]
        $KeyVaultName = "",
        [switch]
        $WhatIf,
        [switch]
        $RemoveArtifacts,
        [switch]
        $ForceAssetDownload
    )
    $ErrorActionPreference = 'Stop'
    if ($DeploymentTechType -eq "bicep") {
        if ($BicepSettingsPath.Length -eq 0) {
            $BicepSettingsPath = Find-FilesByNameUp -FileName 'bicepSettings.json' -ReturnFirstMatch
            if ($BicepSettingsPath.Length -eq 0) {
                throw "No 'bicepSettings.json' was found in this or any parent directory."
            }
        }
        if (!$DeploymentFileName.EndsWith(".bicep")) {
            throw "'$DeploymentFileName' is not a valid Bicep file."
        }
        if (!$DeploymentParameterFileName.EndsWith(".bicepparam") -and !$DeploymentParameterFileName.EndsWith(".json")) {
            throw "'$DeploymentParameterFileName' is not a valid Bicep parameter file."
        }
        Initialize-SubscriptionUsingBicep -BicepSettingsPath $BicepSettingsPath `
            -SubscriptionId $SubscriptionId `
            -SubscriptionName $SubscriptionName `
            -BicepDeploymentFileName $DeploymentFileName `
            -BicepDeploymentParameterFileName $DeploymentParameterFileName `
            -WhatIf:$WhatIf `
            -RemoveArtifacts:$RemoveArtifacts `
            -ForceAssetDownload:$ForceAssetDownload
    }
    else {
        Initialize-SubscriptionUsingTerraform -SubscriptionId $SubscriptionId `
            -SubscriptionName $SubscriptionName `
            -KeyVaultName $KeyVaultName `
            -RemoveArtifacts:$RemoveArtifacts `
            -ForceAssetDownload:$ForceAssetDownload `
            -WhatIf:$WhatIf
    }
}

<#
.SYNOPSIS
    Initializes the subscription management resources in all subscriptions of the tenant.
.DESCRIPTION
    Retrieves all subscriptions in the tenant that are visible to the current user and deploys the subscription management resources to each of them.
.PARAMETER WhatIf
    If specified, the deployment is only simulated.
.PARAMETER DeploymentTechType
    The deployment technology to use for the deployment. Default is 'bicep'.
.PARAMETER DeploymentFileName
    The name of the Bicep or terraform deployment file. Default is 'main.bicep'.
.PARAMETER DeploymentParameterFileName
    The name of the Bicep or terraform parameter file. Default is 'main.bicepparam'.
.PARAMETER ForceAssetDownload
    If specified, the latest version of the assets are downloaded again even if they already exist.
.EXAMPLE
    Initialize-CafSubscriptions `
        -WhatIf
#>

function Initialize-Subscriptions {
    [CmdLetBinding()]
    param(
        [string]
        [validateSet("bicep", "terraform")]
        $DeploymentTechType = "bicep",
        [string]
        $DeploymentFileName = "main.bicep",
        [string]
        $DeploymentParameterFileName = "main.bicepparam",
        [switch]
        $ForceAssetDownload,
        [switch]
        $WhatIf
    )
    process {
        $ErrorActionPreference = 'Stop'
        # get the context from .azcontext files
        $ctx = Use-CafContext
        # retrieve all subscriptions in the tenant that are visible to the current user but exclude those which are visible through lighthouse (e.g. not originating in the current tenant).
        $subscriptions = Get-AzSubscription -TenantId $ctx.tenantId | Where-Object { $_.ExtendedProperties.HomeTenant -eq $ctx.tenantId }
        # Filter out all the subscriptions that are on the ignore list
        $ctx.subscriptionsToIgnore | ForEach-Object {
            $ignoreId = $_
            $ignoredSubscription = $subscriptions | Where-Object { $_.Id -eq $ignoreId }
            if ($ignoredSubscription) {
                Write-Host "Subscription Name: $($ignoredSubscription.Name) | ID: $($ignoredSubscription.Id) is on the ignore list. Skipping ...."
            }
            $subscriptions = $subscriptions | Where-Object { $_.Id -ne $ignoreId }
        }
        $subscriptionsCount = ($subscriptions | Measure-Object).Count
        $i = 0
        foreach ($subscription in $subscriptions) {
            $i++
            $progress = [Math]::Round($i * 100 / $subscriptionsCount, 0)
            $ProgressMessage = if ($progress -lt 50) {
                "Still a long way to go... maybe grab a cup of coffee?"
            }
            elseif ($progress -lt 80) {
                "More than halfway there... refill your coffee?"
            }
            else {
                "Almost done... last sip of coffee?"
            }
            # Show the progress bar only for bicep deployments.
            # Terraform deployment console outputs are too large and the progress bar is not stable.
            if ($DeploymentTechType -eq "bicep") {
                Write-Progress -Activity "Handling subscriptions" -Status "$ProgressMessage ($progress%)" -PercentComplete $progress
            }
            $subscriptionId = $subscription.Id
            $subscriptionName = $subscription.Name
            Write-VerboseOnly "Handling subscription Name: $subscriptionName | ID: $subscriptionId"
            $removeArtifacts = $i -eq ($subscriptions.length);
            Initialize-CafSubscription `
                -SubscriptionId $subscriptionId `
                -SubscriptionName $subscriptionName `
                -DeploymentFileName $DeploymentFileName `
                -DeploymentParameterFileName $DeploymentParameterFileName `
                -DeploymentTechType $DeploymentTechType `
                -RemoveArtifacts:$removeArtifacts `
                -ForceAssetDownload:$ForceAssetDownload `
                -WhatIf:$WhatIf
        }
    }
}

<#
.SYNOPSIS
    Deploys Azure resources by using a Bicep file within an automatically created ops service principal POSH context.
.DESCRIPTION
    Gets the subscription Id from the azcontext file. It uses this Id and retreves the
    ops service princiapal which has an Owner role assignment on the subscription scope.
    This service pricipal is then used as context for the deployment. The command has to
    be run in the project folder which contains the main.bicep file or the SpecificDeploymentFile
    must be provided.
.PARAMETER Stage
    Specifies the stage of the deployment. Valid values are "int", "test", "prod" and "".
    The default value is "".
.PARAMETER SpecificDeploymentFile
    Specifies the name of the BICEP file to use. If not set, the command will search for a
    main.bicep file in the current PWD.
    If the file is not found, an error will be thrown.
.PARAMETER SpecificParameterFile
    Specifies the name of the parameter file to use. If not set, the command will search for a
    parameter file in the following order: [stage].bicepparm, parameters.[stage].bicepparm, parameters/[stage].bicepparm, [stage].json, parameters.[stage].json, parameters/[stage].json.
    If none of these files are found, an error will be thrown.
.PARAMETER ResourceGroupName
    Needs to contain the name of the target resource group if the BICEP target scope is set to
    resourceGroup.
.PARAMETER ResourceGroupLocation
    Needs to contain the name of the Azure region where to deploy to if the BICEP target scope is set to
    resourceGroup.
.PARAMETER ServicePrincipalType
    Specifies the type of service principal to use. Valid values are "ops" and "deploy".
    The default value is "deploy".
.PARAMETER PreScriptFile
    Optional path to a script which needs to be executed before the actual deployment happens. The script
    must take at least Stage, ParameterFile and WhatIf without any other mandatory as parameters in.
.PARAMETER PostScriptFile
    Optional path to a script which needs to be executed after the actual deployment happens. The script
.PARAMETER WhatIf
    If set, the deployment will be simulated only.
.PARAMETER PerformPreDeploymentInCurrentScope
    If set, the PreScriptFile will be executed in the current POSH session and not in the internally created one. This means it will
    use the identity which is in the POSH session to access Azure and not the service principal inside the pre-script file.
.PARAMETER WhatIfResultFormat
    Controls the output format of the WhatIf results.
.PARAMETER WhatIfExcludeChangeType
    Comma-separated resource change types to be excluded from What-If results. Applicable when the -WhatIf or -Confirm switch is set.
.EXAMPLE
    New-CafDeployment
#>

function New-Deployment {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [ValidateSet("none", "int", "test", "prod", "")]
        [String]
        $Stage = "none",
        [Parameter(Mandatory = $false)]
        [String]
        $SpecificParameterFile,
        [String]
        $SpecificDeploymentFile,
        [Parameter(Mandatory = $false)]
        [String]
        $ResourceGroupName,
        [Parameter(Mandatory = $false)]
        [String]
        $ResourceGroupLocation,
        [Parameter(Mandatory = $false)]
        [ValidateSet("deploy", "ops")]
        [string]
        $ServicePrincipalType = "deploy",
        [string]
        $PreScriptFile = "",
        [string]
        $PostScriptFile = "",
        [string]
        [Parameter(Mandatory = $false)]
        [ValidateSet("Subscription", "ManagementGroup")]
        $ServicePrincipalScope = "Subscription",
        [ValidateSet("All", "None", "RequestContent", "ResponseContent")]
        $DeploymentDebugLogLevel = 'None',
        [ValidateSet('FullResourcePayloads', 'ResourceIdOnly')]
        $WhatIfResultFormat = 'FullResourcePayloads',
        [ValidateSet('Ignore', 'NoChange', 'Deploy', 'Create', 'Modify', 'Delete', 'None')]
        $WhatIfExcludeChangeType = 'None',
        [switch]
        $WhatIf,
        [switch]
        $PerformPreDeploymentInCurrentScope
    )
    process {
        $ErrorActionPreference = 'Stop'
        $root = $PWD
        $bicepFile = "$root/main.bicep"
        if ($SpecificDeploymentFile.Length -gt 0) {
            $bicepFile = $SpecificDeploymentFile
        }
        $deploymentPath = Get-Item -Path $bicepFile
        if (!(Test-Path $deploymentPath)) {
            throw "File '$deploymentPath' does not exist. Please use the command in the project infrastructure folder."
        }
        $resolvedStage = $Stage.ToLower()
        if ($Stage -eq "none") {
            $resolvedStage = ""
        }
        if ($SpecificParameterFile.Length -eq 0) {
            # build our own parameter file and search for it. It can be of type .bicepparam or .json
            $parameterFile = "$($resolvedStage.Length -gt 0 ? $resolvedStage : "main").bicepparam"
            if (!(Test-Path $parameterFile)) {
                $parameterFile = "main.$($resolvedStage.Length -gt 0 ? $resolvedStage : '').bicepparam"
            }
            if (!(Test-Path $parameterFile)) {
                $parameterFile = "parameters/$($resolvedStage.Length -gt 0 ? $resolvedStage : '').bicepparam"
            }
            if (!(Test-Path $parameterFile)) {
                $parameterFile = "$($resolvedStage.Length -gt 0 ? $resolvedStage : "parameters").json"
            }
            if (!(Test-Path $parameterFile)) {
                $parameterFile = "parameters.$($resolvedStage.Length -gt 0 ? $resolvedStage : '').json"
            }
            if (!(Test-Path $parameterFile)) {
                $parameterFile = "parameters/$($resolvedStage.Length -gt 0 ? $resolvedStage : 'parameters').json"
            }
        }
        else {
            # use the specified parameter file
            $parameterFile = $SpecificParameterFile
        }
        if (!(Test-Path $parameterFile)) {
            throw "Parameters file not found. Seached locations: [$resolvedStage.bicepparam], [main.$resolvedStage.bicepparam], [parameters/$resolvedStage.biceparam], [$resolvedStage.json], [parameters.$resolvedStage.json], [parameters/$resolvedStage.json]."
        }
        # check if the pre-script file exists
        $usePreScript = $PreScriptFile.Length -gt 0
        if ($usePreScript -and !(Test-Path $PreScriptFile)) {
            throw "Provided pre-script '$PreScriptFile' file not found."
        }
        # check if the post-script file exists
        $usePostScript = $PostScriptFile.Length -gt 0
        if ($usePostScript -and !(Test-Path $PostScriptFile)) {
            throw "Provided post-script '$PostScriptFile' file not found."
        }
        # check if the parameter file exists
        $templateParameterFile = "$root/$parameterFile"
        Write-VerboseOnly "Using deployment file '$deploymentPath'"
        if (!(Test-Path $templateParameterFile)) {
            throw "The parameter file '$templateParameterFile' does not exist."
        }
        Write-VerboseOnly "Using parameter file '$templateParameterFile'"
        # check if the $templateParameterFile is a .json file
        if ($templateParameterFile -match ".json") {
            $parameterJson = Get-Content -Path $templateParameterFile -Raw | ConvertFrom-Json
            if (!($parameterJson.parameters.location)) {
                throw "No location specified in '$templateParameterFile'."
            }
            $location = $parameterJson.parameters.location.value
        }
        else {
            # retreive the location value form the .bicepparam file
            bicep build-params $templateParameterFile
            if (!$?) {
                throw " Install or upgrade the Bicep CLI to the latest version."
            }
            # retreive the name of the .bicepparam file from the $templateParameterFile variable
            $jsonParameterFilename = $templateParameterFile -replace ".bicepparam", ".json"
            # retreive the location value from the .json file
            $location = (Get-Content $jsonParameterFilename | ConvertFrom-Json).parameters.location.value
            Write-VerboseOnly "Location is set to '$location'"
            # delete the automatically created .json file
            Remove-Item -Path $jsonParameterFilename
            Write-VerboseOnly "Removed temporary file '$jsonParameterFilename'."
            if ($location.Length -eq 0) {
                throw "No location value found in '$jsonParameterFilename'."
            }
        }
        # read the deployment target type from the bicep
        $bicepContent = Get-Content -path $deploymentPath -Raw
        if (!($bicepContent -match "targetScope = '(.*)'")) {
            throw "No targetScope defined in '$deploymentPath'."
        }
        $targetScope = $Matches[1]
        # we can proceed with the deployment -> find a name for it first
        $dateSuffix = Get-Date -Format "yyyy-dd-MM-HH-mm"
        $deploymentName = "deploy-$targetScope-$dateSuffix"
        $command = ''
        if ($usePreScript) {
            $preCommand = "Write-Host 'Starting pre-deployment scipt...';`n& $PreScriptFile -Stage $Stage -ParameterFile $templateParameterFile $($WhatIf.IsPresent ? '-WhatIf' : '');`n"
            if ($PerformPreDeploymentInCurrentScope.IsPresent) {
                # Caller wants to execute the pre-script in its own session
                Invoke-Expression $preCommand
            }
            else {
                # Pre script should be called in CAF scoped session
                $command += $PreScriptFile
            }
        }
        # deploy
        if ($targetScope -eq "subscription") {
            # default deployment for subscription level
            Write-VerboseOnly "Deploying at subscription level using template $deploymentPath ..."
            $command += @"
                New-AzDeployment ``
                    -Name '$deploymentName' ``
                    -Location '$location' ``
                    -TemplateFile '$deploymentPath' ``
                    -TemplateParameterFile '$templateParameterFile' ``
                    -DeploymentDebugLogLevel $DeploymentDebugLogLevel ``
                    -WhatIfResultFormat $WhatIfResultFormat ``
"@

            if ($WhatIfResultFormat -ne 'None') {
                $command += @"
                    -WhatIfExcludeChangeType $WhatIfExcludeChangeType
"@

            }
        }
        elseif ($targetScope -eq "resourceGroup") {
            # unusual direct deployment to a pre-existing resource group
            if ($ResourceGroupName.Length -eq 0) {
                throw "No resource group name was specified."
            }
            if ($ResourceGroupLocation.Length -eq 0) {
                throw "No resource group location was specified."
            }
            $rg = Get-AzResourceGroup -Name $ResourceGroupName -Location $ResourceGroupLocation
            if (!($rg)) {
                throw "'$ResourceGroupName' was not found."
            }
            Write-VerboseOnly "Deploying at resource group level using template $deploymentPath ..."
            $command += @"
                New-AzResourceGroupDeployment ``
                    -Name '$deploymentName' ``
                    -Location '$location' ``
                    -ResourceGroupName '$($rg.ResourceGroupName)' ``
                    -TemplateFile '$deploymentPath' ``
                    -TemplateParameterFile '$templateParameterFile' ``
                    -DeploymentDebugLogLevel $DeploymentDebugLogLevel ``
                    -WhatIfResultFormat $WhatIfResultFormat
"@

            if ($WhatIfResultFormat -ne 'None') {
                $command += @"
                    -WhatIfExcludeChangeType $WhatIfExcludeChangeType
"@

            }
        }
        else {
            throw "Unsupported target scope $targetScope."
        }
        if ($WhatIf) {
            $command = $command + " -WhatIf"
        }
        # check the type of servie principal to use (default is 'deploy')
        Write-VerboseOnly "Using '$ServicePrincipalType' service principal for deployment with command:`n"
        Write-VerboseOnly $command
        Write-Host "Starting deployment session..."
        Start-CafScoped -Command $command `
            -ServicePrincipalScope $ServicePrincipalScope `
            -ServicePrincipalType $ServicePrincipalType
        if (!$?) {
            throw "Error during deployment of resources."
        }
        # If the deployment was successful, we can run the post-script
        if ($usePostScript) {
            $command = ''
            $command += "Write-Host 'Starting post-deployment scipt...';`n& $PostScriptFile -Stage $Stage -ParameterFile $templateParameterFile $($WhatIf.IsPresent ? '-WhatIf' : '')`n"
            Write-VerboseOnly $command
            Start-CafScoped -Command $command `
                -ServicePrincipalScope $ServicePrincipalScope `
                -ServicePrincipalType $ServicePrincipalType
            if (!$?) {
                throw "Error during execution of post-script file.."
            }
        }
    }
}


<#
 .Synopsis
    Adds a firewall rule to an Azure SQL Server for a single IP.
 .Description
    Adds a firewall rule to an Azure SQL Server for a single IP.
 .Parameter AzureSqlServerName
    The name of the SQL server
 .Parameter TenantId
    The unique ID of the tenant which only should be defined if you want to perform the
    operation outside of a CAF controlled directory (.azcontext file).
 .Parameter IpAddress
    The IP address for which to set the rule. If omitted the current machine public IP will be determined and used.
 .EXAMPLE
    New-CafSqlFirewallRule
 .EXAMPLE
    New-CafSqlFirewallRule -AzureSqlServerName mySqlServerName
 .EXAMPLE
    New-CafSqlFirewallRule -AzureSqlServerName mySqlServerName -TenantId [Id]
 .EXAMPLE
    New-CafSqlFirewallRule -AzureSqlServerName mySqlServerName
 .EXAMPLE
    New-CafSqlFirewallRule -AzureSqlServerName mySqlServerName -IpAddress 10.12.22.222
#>

Function New-SqlFirewallRule {
    [CmdLetBinding()]
    param (
        [string]
        $AzureSqlServerName,
        [string]
        $TenantId,
        [string]
        $IpAddress
    )
    process {
        $ErrorActionPreference = 'Stop'
        if (!$TenantId) {
            $ctx = Use-CafContext
        }
        else {
            $ctx = Get-CafContext
        }
        $ctx = Use-CafContext
        # If server name not passed in check if .azcontext contains one
        if ($AzureSqlServerName.Length -eq 0) {
            if ($ctx.Keys -contains "sqlServerName") {
                $AzureSqlServerName = $ctx.sqlServerName
            }
            else {
                throw "No SQL server name was provided and no default was found in .azcontext"
            }
        }
        # Check if the SQL server exists
        $server = Search-AzGraph -Query "where type =~ 'Microsoft.Sql/servers' and name =~ '$AzureSqlServerName'"
        if (!$? -or $server.Count -gt 1 -or $server.Count -eq 0) {
            throw "No or more than one resource was found in teanat '$TenantId' with name '$AzureSqlServerName'."
        }
        # If the resource is found switch the context to the subscription where the resource is located
        $subscriptionId = $server[0].SubscriptionId
        if (!$subscriptionId) {
            throw "could not retrive the subscription ID for resource '$AzureSqlServerName'."
        }
        if ($TenantId) {
            Set-AzContext -Tenant $TenantId -Subscription $subscriptionId | Out-Null
            if (!$?) {
                throw "Could not set azcontext to subscription '$subscriptionId'."
            }
        }
        # Build IpAddress if not provided
        if (!$IpAddress) {
            Write-VerboseOnly "Retrieving public IP address..." -NoNewLine
            $IpAddress = (Invoke-WebRequest -uri "http://api.ipify.org?format=text").Content
            Write-VerboseOnly $IpAddress
        }
        Write-Host "Using IP address $IpAddress"
        # Check if the firewall rule already exists
        $existingRule = Get-AzSqlServerFirewallRule -ServerName $server.name -ResourceGroupName $server.resourceGroup | Where-Object -Property StartIpAddress -EQ $IpAddress
        if ($existingRule) {
            $ruleName = $existingRule.FirewallRuleName
            Write-HostMessage -Message "Skipping because firewall rule for your IP '$IpAddress' already exists on server '$AzureSqlServerName' : $ruleName" -Level Warning
            return
        }
        $ruleName = "ClientIpAddress_" + (Get-Date).ToString("yyyy_MM_dd_HH_mm_ss")
        # Create the firewall rule
        $command += @"
            New-AzSqlServerFirewallRule ``
                -ServerName '$($server.name)' ``
                -ResourceGroupName '$($server.resourceGroup)' ``
                -FirewallRuleName '$ruleName' ``
                -StartIpAddress '$IpAddress' ``
                -EndIpAddress '$IpAddress' | Out-Null
"@

        Write-VerboseOnly "Using ops service principal for deployment with command:`n"
        Write-VerboseOnly $command
        Write-Host "Starting deployment session..."
        # Call the CafScoped command to execute the built command with the ops service principal
        Start-CafScoped -Command $command -ServicePrincipalType "ops" -NoLogo
        if (!$?) {
            Write-HostError "Failed to add SQL Server firewall rule: $_"
        }
        Write-HostSuccess "Firewall rule with name $ruleName successfully created on Azure SQL Server $AzureSqlServerName for IP $IpAddress"
    }
}


<#
 .Synopsis
    Removes locks from the resource group with the given name and retrieves the removed lock objects.
 .Description
    This will try to remove all locks which are not defined by Azure internally from the given resource
    group. It will return an array of all the locks that where removed which can be passed to Restore-CafLocks
    later.
 .Parameter ResourceGroupName
    The name of the resource group.
.Parameter Scope
    The scope of the lock. If this is set, the ResourceGroupName parameter will be ignored.
 .Parameter AllLockLevels
    If set all lock levels will be removed. By default only CanNotDelete is removed.
#>

function Remove-Locks {
    [CmdLetBinding()]
    param (
        [string]
        $ResourceGroupName = $null,
        [string]
        $Scope,
        [switch]
        $AllLockLevels
    )
    $useScope = $Scope -match '^/subscriptions/[a-fA-F0-9-]+$'
    $locks = Get-Locks -ResourceGroupName $ResourceGroupName -Scope $Scope -AllLockLevels:$AllLockLevels
    $removedLocks = @()
    foreach ($lock in $locks) {
        Write-VerboseOnly "Removing lock '$($lock.Name)' of level '$($lock.Properties.level)'..."
        if ($useScope) {
            Remove-AzResourceLock -LockId $lock.LockId -Force | Out-Null
        }
        else {
            Remove-AzResourceLock -LockName $lock.Name -ResourceGroupName $ResourceGroupName -Force | Out-Null
        }
        $removedLocks += $lock
    }
    return $removedLocks
}

<#
.SYNOPSIS
    Deletes leftover role assignments for already deleted service principals.
.DESCRIPTION
    Removes the unknown objects from the role assignments through the tenant.
.PARAMETER -WhatIf
    Shows what would happen if the command runs.
.EXAMPLE
    Remove-CafStaleRoleAssignments -WhatIf
#>

function Remove-StaleRoleAssignments {
    [CmdLetBinding()]
    param (
        [switch]
        $WhatIf
    )
    process {
        $ctx = Use-CafContext
        # retrieve all subscriptions in the tenant that are enabled and visible to the current user but exclude those which are visible through lighthouse (e.g. not originating in the current tenant).
        $subscriptions = Get-AzSubscription -TenantId $ctx.tenantId | Where-Object { $_.ExtendedProperties.HomeTenant -eq $ctx.tenantId -and $_.State -eq 'Enabled' }
        # Filter out all the subscriptions that are on the ignore list
        $ctx.subscriptionsToIgnore | ForEach-Object {
            $ignoreId = $_
            $ignoredSubscription = $subscriptions | Where-Object { $_.Id -eq $ignoreId }
            if ($ignoredSubscription) {
                Write-HostDebug "Subscription $($ignoredSubscription.Name) ($($ignoredSubscription.Id)) is on the ignore list. Skipping."
            }
            $subscriptions = $subscriptions | Where-Object { $_.Id -ne $ignoreId }
        }
        # Then, prepend "/subscriptions/" to each remaining subscription ID
        $subscriptions = $subscriptions | ForEach-Object { "/subscriptions/$($_.Id)" }
        # Retrieve all management groups in the tenant.
        $managementGroups = Get-AzManagementGroup
        # Filter out all the management groups that are on the ignore list.
        $ctx.managementGroupsToIgnore | ForEach-Object {
            $ignoreId = $_
            $ignoredManagementGroup = $managementGroups | Where-Object { $_.Name -eq $ignoreId }
            if ($ignoredManagementGroup) {
                if ($ignoredManagementGroup.Name -eq $ctx.tenantId) {
                    Write-HostDebug "Tenant Root Group is skipped."
                }
                else {
                    Write-HostDebug "Management Group $($ignoredManagementGroup.Name) is on the ignore list. Skipping."
                }
            }
            $managementGroups = $managementGroups | Where-Object { $_.Name -ne $ignoreId }
        }
        $ids = @()
        # Add ids of all managements groups and ids of subscriptions to the IDs to process.
        $ids += $managementGroups.Id
        $ids += $subscriptions
        $roleAssignmentsToRemove = @()
        # Retrieve all role assignments where object type of the SP is 'unknown'.
        foreach ($id in $ids) {
            Write-VerboseOnly "Checking scope '$id'."
            $roleAssignments = Get-AzRoleAssignment -Scope $id | Where-Object { $_.ObjectType -eq "unknown" }
            if (!$?) {
                throw "Could not retreive role assignments."
            }
            foreach ($roleAssignment in $roleAssignments) {
                if (($roleAssignmentsToRemove | Where-Object { $_.RoleAssignmentId -eq $roleAssignment.RoleAssignmentId }).Count -eq 0) {
                    # Only add this assignment if it is not already part of the detected assignements.
                    $roleAssignmentsToRemove += $roleAssignment
                }
            }
        }
        # Get the CSP service principal ID from the .azcontext file make an exception and skip them from the delete list.
        $cspServicePrincipalId = $ctx.cspServicePrincipalId
        if ($cspServicePrincipalId) {
            $roleAssignmentsToRemove = $roleAssignmentsToRemove | Where-Object { $_.ObjectId -ne $cspServicePrincipalId }
        }
        $roleAssignmentsToRemoveCount = ($roleAssignmentsToRemove | Measure-Object).Count
        if ($roleAssignmentsToRemoveCount -eq 0) {
            Write-HostInfo "No stale role assignments found in tenant '$($ctx.tenantId)'."
            return
        }
        $output = @()
        $roleAssignmentsToRemove | ForEach-Object {
            $output += New-Object PSObject -Property ([ordered]@{
                    'Role'         = $_.RoleDefinitionName
                    'AssignmentId' = $_.RoleAssignmentId
                    'Scope'        = $_.Scope
                })
        }
        Write-HostInfo "Found $($roleAssignmentsToRemoveCount) stale role assignments in tenant '$($ctx.tenantId)'."
        if ($WhatIf.IsPresent) {
            # If -WhatIf is present, only simulate the deletion
            $output | Sort-Object -Property RoleDefinitionName, Scope, ObjectId | Format-Table -AutoSize
            return
        }
        # Order and display the output
        $i = 0
        foreach ($roleAssignment in $roleAssignmentsToRemove) {
            # code to display the progress bar
            $i++
            $progress = [Math]::Round($i * 100 / $roleAssignmentsToRemoveCount, 0)
            Write-Progress -Activity "Deleting role assignemnts" -Status "$($progress)%" -PercentComplete $progress
            # Get the scope of the role assignments
            $scope = $roleAssignment.Scope
            Write-HostInfo "Checking for locks..."
            # If scope starts with "/providers/Microsoft.Management/managementGroups/" then it is a management group.
            # Skip the locks check in that case.
            if ($scope -notlike "/providers/Microsoft.Management/managementGroups/*") {
                # Trim the scope to get only the part till the resource group. This is the case for resources like KeyVaults.
                $scope = $scope -replace "/providers/.*$"
                # Find and delete the locks on either the resource group or the subscription.
                $locks = Remove-CafLocks -Scope $scope
                if (!$?) {
                    Write-HostWarning "Could not remove delete locks for scope '$scope'. Skipping."
                    continue
                }
                # Wait until the locks are removed
                Write-VerboseOnly "Removed $($locks.Count) locks from scope '$scope'."
                if ($locks.Count -gt 0) {
                    # Wait for some time for the locks to be removed in Azure
                    $success = $false
                    $counter = 0
                    while ($true) {
                        $counter++
                        if ($counter -eq 10) {
                            break
                        }
                        $checkedLocks = Get-Locks -Scope $scope
                        if ($checkedLocks.Count -eq 0) {
                            # The locks are gone -> we are done.
                            $success = $true
                            break
                        }
                        # Give Azure some time.
                        Start-Sleep -Seconds 1
                    }
                    if (!$success) {
                        throw "The locks where not removed from scope '$scope'."
                    }
                }
            }
            try {
                # remove the role assignment
                Write-HostInfo "Removing role assignments..." -NoNewline
                Remove-AzRoleAssignment -ObjectId $roleAssignment.ObjectId -RoleDefinitionName $roleAssignment.RoleDefinitionName -Scope $roleAssignment.Scope | Out-Null
                Write-HostSuccess "Sucess"
                $output | Sort-Object -Property RoleDefinitionName, Scope, ObjectId | Format-Table -AutoSize
            }
            catch {
                Write-HostError "Failure"
            }
            finally {
                if ($locks.Count -gt 0) {
                    # At least 1 lock was removed so restore all locks now.
                    Restore-CafLocks -Locks $locks
                    if (!$?) {
                        Write-HostError "Could not reapply locks for scope '$scope'."
                    }
                    else {
                        Write-HostSuccess "Locks re-applied for scope '$scope'."
                    }
                }
            }
        }
    }
}


<#
 .Synopsis
    Restores all the given locks to the specified resource group.
 .Description
    After you used Remove-CafLocks you can use it's result (the collection of removed locks) to pass it to this
    function. This then will try to applying those locks again. Only resource group locks are currently supported.
 .Parameter ResourceGroupName
    The name of the resource group.
 .Parameter Locks
    The array of locks to restore.
#>

Function Restore-Locks {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [object[]]
        $Locks
    )
    foreach ($lock in $Locks) {
        $scope = $lock.ResourceId.Substring(0, $lock.ResourceId.IndexOf("/providers"))
        Write-VerboseOnly "Re-applying lock $($lock.Name) for scope '$scope'..."
        New-AzResourceLock -LockLevel $lock.Properties.Level -LockId $lock.LockId -Force | Out-Null
    }
}

<#
.SYNOPSIS
    Creates or updates a service principal matching the conventions for one of the given purposes.
.DESCRIPTION
    Creates a service principal using a random generated password or updates an existing one. Assignes the required roles
    and stores the credentials in the Azure Key Vault resolved. If it already exists, only roles and credentials are checked
    and updated, if necessary.

    YOU NEED TO EXECUTE THIS WITH ELEVATED PERSONAL RIGHTS!
.PARAMETER ScopeType
    The scope type of the service principal. Valid values are "Subscription" and "ManagementGroup".
.PARAMETER ScopeName
    The name of the scope to create the service principal for. Also known as the project name. It is used to deduce naming conventions.
.PARAMETER ScopeId
    The id of the scope to use for the role assignment.
.PARAMETER Role
    The role to assign to the service principal.
.PARAMETER Suffix
    An optional Suffix to append to the service principal name.
.PARAMETER WhatIf
    Determines if the actions should be not executed but only reported.
.EXAMPLE
    Set-CafServicePrincipal `
        -ScopeType "subscription" `
        -ScopeName "connectivity" `
        -ScopeId "/subscriptions/00000000-0000-0000-0000-000000000000" `
        -Role "Contributor" `
        -suffix "deploy"
#>

function Set-ServicePrincipal {
    [CmdLetBinding()]
    param (
        [String]
        [Parameter(Mandatory = $true)]
        [ValidateSet('ManagementGroup', 'Subscription')]
        $ScopeType,
        [String]
        [Parameter(Mandatory = $true)]
        $ScopeName,
        [String]
        $ScopeId,
        [String]
        $Role,
        [string]
        $SubscriptionId,
        [String]
        [ValidateSet("deploy", "ops")]
        $Suffix,
        [switch]
        $WhatIf
    )
    process {
        $ErrorActionPreference = 'Stop'
        $ctx = Use-CafContext -SubscriptionId $SubscriptionId
        if ($ctx.subscriptionsToIgnore -and $ctx.subscriptionsToIgnore.Contains($SubscriptionId)) {
            Write-Host "Subscription is on ignore list...Skipping." -ForegroundColor Yellow
            return
        }
        # If the scope type is Subscription and the suffix is deploy, we use the deploySubscription naming convention, otherwise the opsSubscription naming convention. if the scope type is ManagementGroup and the suffix is deploy, we use the deployManagementGroup naming convention, otherwise the opsManagementGroup naming convention
        if ($scopeType -eq "Subscription") {
            if ($suffix -eq "deploy") {
                $namingConventionSubType = "deploySubscription"
            }
            else {
                $namingConventionSubType = "opsSubscription"
            }
        }
        elseif ($scopeType -eq "ManagementGroup") {
            if ($suffix -eq "deploy") {
                $namingConventionSubType = "deployManagementGroup"
            }
            else {
                $namingConventionSubType = "opsManagementGroup"
            }
        }
        else {
            throw "Invalid scope type: '$scopeType'."
        }
        # Build the name of the service principal based on the type and subtype
        $actions = @()
        $spName = Format-NamingConvention -Context $ctx -Type "servicePrincipal" -SubType "$namingConventionSubType" -ProjectName $ScopeName
        Write-VerboseOnly "Service Principal Name: $spName"
        $keyVault = Get-ManagementKeyVault -ScopeType $ScopeType -ScopeName $ScopeName
        $keyVaultName = $keyVault.VaultName
        # Check if the service principal already exists
        $now = Get-Date
        $gracePeriodDays = -60
        $expiration = $now.AddYears(1)
        $sp = Get-AzADServicePrincipal -DisplayName $spName -ErrorAction SilentlyContinue
        if ($sp) {
            # Service principal already exists
            if (!$WhatIf.IsPresent) {
                Write-HostDebug "Service principal '$spName' with id '$($sp.Id)' already exists. Skipping creation, ensuring configuration instead..."
            }
            # Check if the role assignment is correct
            $roleAssignment = Get-AzRoleAssignment -ObjectId $sp.Id -Scope $ScopeId -RoleDefinitionName $Role -ErrorAction SilentlyContinue
            if (!$roleAssignment) {
                # Role assignment is missing
                if (!$WhatIf.IsPresent) {
                    Write-Host "Assigning missing role '$Role' to service principal '$spName' with id '$($sp.Id)'."
                    New-AzRoleAssignment -ObjectId $sp.Id -Scope $ScopeId -RoleDefinitionName $Role
                }
                else {
                    $actions += New-Object PSObject -Property ([ordered]@{
                            'ActionType'     = "Create"
                            'ObjectType'     = "RoleAssignment"
                            'ActionDetails'  = "RoleAssignment"
                            'TargetObjectId' = $sp.Id
                        })
                }
            }
            else {
                if (!$WhatIf.IsPresent) {
                    Write-Host "Role assignment for service principal '$spName' with id '$($sp.Id)' already exists."
                }
            }
            # Check if the service principal has password credentials
            if (!$sp.PasswordCredentials -or $sp.PasswordCredentials.Count -eq 0) {
                if (!$WhatIf.IsPresent) {
                    Write-Host "Service principal '$spName' with id '$($sp.Id)' has no credentials. Adding..."
                    $newCredential = New-AzADServicePrincipalCredential -ObjectId $sp.Id -EndDate $expiration
                    $secret = ConvertTo-SecureString -String $newCredential.SecretText -AsPlainText -Force
                    Set-AzKeyVaultSecret -VaultName $keyVaultName `
                        -Name "$spName" `
                        -SecretValue $secret `
                        -Expires $expiration `
                        -Tag @{"ServicePrincipalId" = $($sp.Id); "ServicePrincipalName" = $spName } | Out-Null
                }
                else {
                    $actions += New-Object PSObject -Property ([ordered]@{
                            'ActionType'     = "Create"
                            'ObjectType'     = "ServicePrincipalCredential"
                            'ActionDetails'  = "Password"
                            'TargetObjectId' = "$spName"
                        })
                }
            }
            # Check if the service principal password has expired credentials
            # A single service principal can have multiple credentials, so we need to iterate over all of them
            $credCounter = 0
            foreach ($credential in $sp.PasswordCredentials) {
                $credCounter++
                # Condition to check the expiration of the credential but only for the first one. If there are more than one credential, we delete the old ones.
                if ($credCounter -eq 1) {
                    # Check if the credential is expired.
                    if ($credential.EndDateTime.AddDays($gracePeriodDays) -lt $now) {
                        if (!$WhatIf.IsPresent) {
                            Write-Host "Updating expired credentials for service principal '$spName' with id '$($sp.Id)'"
                            $newCredential = New-AzADServicePrincipalCredential -ObjectId $sp.Id -EndDate $expiration
                            $secret = ConvertTo-SecureString -String $newCredential.SecretText -AsPlainText -Force
                            Set-AzKeyVaultSecret -VaultName $keyVaultName `
                                -Name "$spName" `
                                -SecretValue $secret `
                                -Expires $expiration `
                                -Tag @{"ServicePrincipalId" = $($sp.Id); "ServicePrincipalName" = $spName } | Out-Null
                        }
                        else {
                            $actions += New-Object PSObject -Property ([ordered]@{
                                    'ActionType'     = "Update"
                                    'ObjectType'     = "ServicePrincipal"
                                    'ActionDetails'  = "Secret"
                                    'TargetObjectId' = $spName
                                })
                            $actions += New-Object PSObject -Property ([ordered]@{
                                    'ActionType'     = "Update"
                                    'ObjectType'     = "KeyVaultSecret"
                                    'ActionDetails'  = "Secret"
                                    'TargetObjectId' = "$keyVaultName/$spName"
                                })
                        }
                    }
                }
                else {
                    if (!$WhatIf.IsPresent) {
                        # Lets delete the old credentials
                        Write-HostInfo "Removing old credential for service principal '$spName' with key '$($credential.KeyId)'..." -NoNewLine
                        Connect-MgGraph -TenantId $ctx.tenantId -Scopes "Directory.ReadWrite.All,Application.ReadWrite.All" -NoWelcome
                        Remove-MgServicePrincipalPassword -ServicePrincipalId $sp.Id -KeyId $credential.KeyId
                        Start-Sleep -Seconds 1
                        Write-HostSuccess "Done"
                    }
                    else {
                        $actions += New-Object PSObject -Property ([ordered]@{
                                'ActionType'     = "Delete"
                                'ObjectType'     = "ServicePrincipalCredential"
                                'ActionDetails'  = "Credential"
                                'TargetObjectId' = "$spName/$($credential.KeyId)"
                            })
                    }
                }
            }
            # Check if the key vault secret exists
            $kvSecret = Get-AzKeyVaultSecret -VaultName $keyVaultName `
                -Name "$spName"
            if (!$kvSecret) {
                if (!$WhatIf.IsPresent) {
                    Write-HostInfo "Missing secret for existing service principal '$spName' with id '$($sp.Id)' on Key Vault '$keyVaultName'. Adding..." -NoNewline
                    $credential = New-AzADServicePrincipalCredential -ObjectId $sp.Id -EndDate $expiration
                    $secret = ConvertTo-SecureString -String $credential.SecretText -AsPlainText -Force
                    Set-AzKeyVaultSecret -VaultName $keyVaultName `
                        -Name "$spName" `
                        -SecretValue $secret `
                        -Expires $expiration `
                        -Tag @{"ServicePrincipalId" = $($sp.Id); "ServicePrincipalName" = $spName } | Out-Null
                    Write-HostSuccess "Done"
                }
                else {
                    $actions += New-Object PSObject -Property ([ordered]@{
                            'ActionType'     = "Create"
                            'ObjectType'     = "KeyVaultSecret"
                            'ActionDetails'  = "Secret"
                            'TargetObjectId' = "$keyVaultName/$spName"
                        })
                }
                return $actions
            }
            if (!$WhatIf.IsPresent) {
                Write-HostSuccess "Service principal '$spName' with id '$($sp.Id)' is configured correctly."
            }
            return $actions
        }
        # Now, if service principal does not exist, create it and add the role assignment.
        if (!$WhatIf.IsPresent) {
            $sp = New-AzADServicePrincipal -DisplayName $spName -Tag [$name] -Role $Role -Scope $ScopeId
            Write-Host "Created service principal '$spName' with id '$($sp.Id)'"
            # Store the SP's credentials in the key vault
            $credential = New-AzADServicePrincipalCredential -ObjectId $sp.Id -EndDate $expiration
            $secret = ConvertTo-SecureString -String $credential.SecretText -AsPlainText -Force
            Set-AzKeyVaultSecret -VaultName $keyVaultName `
                -Name "$spName" `
                -SecretValue $secret `
                -Expires $expiration `
                -Tag @{"ServicePrincipalId" = $($sp.Id); "ServicePrincipalName" = $spName } | Out-Null
            Write-Host "Stored service principal credentials in key vault '$keyVaultName' with name '$spName'"
        }
        else {
            $actions += New-Object PSObject -Property ([ordered]@{
                    'ActionType'     = "Create"
                    'ObjectType'     = "ServicePrincipal"
                    'ActionDetails'  = "ServicePrincipal"
                    'TargetObjectId' = "$spName"
                })
            $actions += New-Object PSObject -Property ([ordered]@{
                    'ActionType'     = "Create"
                    'ObjectType'     = "KeyVaultSecret"
                    'ActionDetails'  = "KeyVaultSecret"
                    'TargetObjectId' = "$keyVaultName/$spName"
                })
        }
        return $actions
    }
}


<#
.SYNOPSIS
    Builds all the naming conventions from the context file and displays them in a table.
.DESCRIPTION
    Displays all the naming conventions defined by the user. The naming conventions are defined in the .azcontext file.
.PARAMETER ProjectName
    The optional project name to use.
.EXAMPLE
    Show-CafNamingConvention -ProjectName spock
#>

function Show-NamingConvention {
    [CmdLetBinding()]
    param (
        [string]
        $ProjectName
    )
    process {
        # load the context file
        $ctx = Get-CafContext
        # create an empty array to store the output
        $output = @()
        foreach ($type in $ctx.namingConvention.GetEnumerator()) {
            if ($type.Value -is [Hashtable] -and $type.Value.ContainsKey('order')) {
                # This means the type does not have subtypes defined in it.
                $name = Format-NamingConvention -Context $ctx -Type $type.Name -ProjectName $ProjectName
                # Add the output to the array
                $output += New-Object PSObject -Property ([ordered]@{
                        'Type'    = $type.Name
                        'Subtype' = $null
                        'Name'    = $name
                    })
            }
            elseif ($type.Value -is [Hashtable]) {
                foreach ($subtype in $type.Value.GetEnumerator()) {
                    if ($subtype.Value -is [Hashtable] -and $subtype.Value.ContainsKey('order')) {
                        # This means the type has subtypes defined in it.
                        $name = Format-NamingConvention -Context $ctx -Type $type.Name -Subtype $subtype.Name -ProjectName $ProjectName
                        # Add the output to the array
                        $output += New-Object PSObject -Property ([ordered]@{
                                'Type'    = $type.Name
                                'Subtype' = $subtype.Name
                                'Name'    = $name
                            })
                    }
                }
            }
        }
        # Display the output as a table
        $output | Format-Table -AutoSize
    }
}

<#
.SYNOPSIS
    Assigns the user to a PIM group.
.DESCRIPTION
    Assigns the user to a PIM group. The user must be eligible for the group. Either you run
    this cmdlet under a .azcontext which defines 'tenantId' and 'adminEntraGroupName' or you provide those
    values using the parameters.
.PARAMETER Tenant
    The tenant id or domain name.
.PARAMETER Reason
    The reason for the assignment.
.PARAMETER DurationHours
    The duration in hours for the assignment. Default is 1 hour.
.PARAMETER GroupName
    The name of the group.
.PARAMETER NoMsalFallback
    If set the logic will not retry to force the current user to provide MFA using MSAL.PS.
.PARAMETER ShowMsalErrors
    If set the raw MSAL errors will be shown in the output.
.EXAMPLE
    Start-CafPimGroup
.EXAMPLE
    Start-CafPimGroup -Reason "Need to perform service actions on resource X."
.EXAMPLE
    Start-CafPimGroup -Tenant {NAME_OR_ID}
.EXAMPLE
    Start-CafPimGroup -Tenant {NAME_OR_ID} -GroupName AZ-Admins
#>

function Start-PimGroup {
    [CmdLetBinding()]
    param (
        [string]
        $Tenant,
        [string]
        $Justification = "Eligible assignment activated through CAF",
        [int]
        $DurationHours = 1,
        [string]
        $GroupName,
        [switch]
        $NoMsalFallback,
        [switch]
        $ShowMsalErrors
    )
    process {
        # Check the current context and if it is not the right tenant, connect to the right one.
        if (!$Tenant) {
            $ctx = Use-CafContext
            if (($ctx.Keys | Measure-Object).Count -eq 0) {
                # No context was found
                throw "No AZ context was found. You need to provide a tenant!"
            }
        }
        else {
            Connect-Tenant -TenantId $Tenant
            if (!$?) {
                throw "Could not connect to the provided tenant."
            }
        }
        # use the tenant id from the default Azure context
        $tenantId = (Get-AzContext).Tenant.Id
        if ($GroupName.Length -eq 0) {
            if ($ctx.adminEntraGroupName.Length -eq 0) {
                throw "No administrative group retrieved from AZ context. You need to provide one by using the -GroupName parameter."
            }
            # use the group name from the AZ context
            $GroupName = $ctx.adminEntraGroupName
        }
        # Ensure that Microsoft.Graph.Authentication module is present
        Enable-Module -ModuleName Microsoft.Graph.Authentication
        # Ensure that Microsoft.Graph.Authentication module is present
        Enable-Module -ModuleName Microsoft.Graph.Identity.Governance
        # Ensure that Microsoft.Graph.Groups module is present
        Enable-Module -ModuleName Microsoft.Graph.Groups
        # Ensure that Microsoft.Resources module is present
        Enable-Module -ModuleName Az.Resources
        # All needed modules are present.
        $attempts = 0
        $success = $false
        while ($success -eq $false -and $attempts -lt 3) {
            # Connect to the Graph API
            $attempts++
            # Connect to the Graph API
            if ($attempts -gt 1) {
                # This is the second run
                Enable-Module -ModuleName MSAL.PS
                $clientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"
                $userPrincipalName = (Get-AzContext).Account.Id
                Write-HostInfo "Trying to obtain MSAL token for user $userPrincipalName. You should be asked to provide password and MFA soon."
                $msalToken = Get-MSALToken -Scopes @("https://graph.microsoft.com/.default") `
                    -ClientId $ClientId `
                    -RedirectUri http://localhost `
                    -Authority https://login.microsoftonline.com/$TenantId `
                    -Interactive `
                    -ExtraQueryParameters @{claims = '{"access_token" : {"amr": { "values": ["mfa"] }}}' } `
                    -LoginHint $UserPrincipalName
                $token = ConvertTo-SecureString -String $($msalToken.AccessToken) -AsPlainText -Force
                Connect-MgGraph -AccessToken $token -NoWelcome
            }
            else {
                Connect-MgGraph -Scopes "PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup" -TenantId $tenantId -NoWelcome
            }
            if (!$?) {
                throw "Could not connect to the Graph API."
            }
            $mgCtx = Get-MgContext
            Write-VerboseOnly "Connection to MS Graph established using $($mgCtx.TokenCredentialType)."
            # Retrieve group id
            $entraGroup = Get-AzAdGroup -DisplayName $GroupName
            if ($null -eq $entraGroup) {
                throw "Group with name '$GroupName' not found in tenant."
            }
            # Ensure that the current user has eligibility for the group
            $groupId = $entraGroup.Id
            # Ensure that the current user has eligibility for the group
            $availableAssignments = Invoke-MgFilterIdentityGovernancePrivilegedAccessGroupEligibilityScheduleInstanceByCurrentUser -On "principal" -Filter "GroupId eq '$groupId'"
            # is the command does not return any arrays or gives exception? If so, the user is not eligible for the group
            if ($null -eq $availableAssignments) {
                Write-HostWarning "The current user is not eligible for membership on group '$GroupName' ($groupId)."
                return
            }
            # Get the current user's principal id
            $azCtx = Get-AzContext
            $user = Get-AzADUser -Mail $azCtx.Account.id
            $principalId = $user.Id
            # Checking if user is already member of target group!!!
            Get-MgGroupMember -GroupId $groupId -Filter "id eq '$principalId'" -ErrorAction SilentlyContinue | Out-Null
            if ($?) {
                Write-HostError "User is already a member of $GroupName ($groupId)."
                return
            }
            # build request to activate the group assignment
            $params = @{
                accessId      = "member"
                principalId   = $principalId
                groupId       = $groupId
                action        = "selfActivate"
                scheduleInfo  = @{
                    startDateTime = Get-Date
                    expiration    = @{
                        type     = "afterDuration"
                        duration = "PT$($DurationHours)H"
                    }
                }
                justification = $Justification
            }
            $err = @()
            New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $params -ErrorVariable err -ErrorAction SilentlyContinue | Out-Null
            if ($err.count -eq 0) {
                # The operation succeeded
                $success = $true
            }
            else {
                if ($ShowMsalErrors.IsPresent) {
                    Write-HostWarning "The $($mgCtx.TokenCredentialType) authorization failed with error '$($err[0].ErrorDetails.Message)'."
                }
                if ($NoMsalFallback.IsPresent) {
                    break;
                }
            }
        }
        if (!$success) {
            throw "Could not enable role assignment request for role '$roleId'."
        }
        Write-HostSuccess "Sucessfully enabled PIM group membership to '$GroupName' ($groupId)."
    }
}

<#
.SYNOPSIS
    Activates the user's PIM Role assignment.
.DESCRIPTION
    Checks if the user is eligible for the role and activates the assignment. This needs the modules Microsoft.Graph, Az and MSAL.PS to be installed.
.PARAMETER Justification
    The reason for the assignment.
.PARAMETER TenantId
    The tenant id.
.PARAMETER RoleId
    The id of the role. Default is "Global Administator".
.PARAMETER DurationHours
    The duration in hours for the assignment. Default is 1 hour.
.PARAMETER NoMsalFallback
    If set the logic will not retry to force the current user to provide MFA using MSAL.PS.
.PARAMETER ShowMsalErrors
    If set the raw MSAL errors will be shown in the output.
.PARAMETER Wait
    If set this will ensure that the execution continues after the request was approved AND the user is member of the role.
.EXAMPLE
    Start-CafPimRole -Tenant TODO
#>

function  Start-PimRole {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Justification,
        [string]
        $TenantId,
        [int]
        $DurationHours = 1,
        [string]
        $RoleId = "62e90394-69f5-4237-9190-012177145e10",
        [switch]
        $NoMsalFallback,
        [switch]
        $ShowMsalErrors,
        [switch]
        $Wait
    )
    process {
        if (!$TenantId) {
            $ctx = Use-CafContext
            if (($ctx.Keys | Measure-Object).Count -eq 0) {
                # No context was found
                throw "No AZ context was found. You need to provide a tenant!"
            }
        }
        else {
            Connect-Tenant -TenantId $TenantId
            if (!$?) {
                throw "Could not connect to the provided tenant."
            }
        }
        # use the tenant id from the default Azure context
        $tenantId = (Get-AzContext).Tenant.Id
        # Ensure that Microsoft.Graph.Authentication module is present
        Enable-Module -ModuleName Microsoft.Graph.Authentication
        # Ensure that Microsoft.Graph.Authentication module is present
        Enable-Module -ModuleName Microsoft.Graph.Identity.Governance
        # Ensure that Microsoft.Resources module is present
        Enable-Module -ModuleName Az.Resources
        # All needed modules are present.
        $attempts = 0
        $success = $false
        while ($success -eq $false -and $attempts -lt 3) {
            $attempts++
            # Connect to the Graph API
            if ($attempts -gt 1) {
                # This is the second run
                Enable-Module -ModuleName MSAL.PS
                # This is the app id of the MG Command Line Tool of the users home tenant.
                $clientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"
                $userPrincipalName = (Get-AzContext).Account.Id
                Write-HostInfo "Trying to obtain MSAL token for user $userPrincipalName. You should be asked to provide password and MFA soon."
                $msalToken = Get-MSALToken -Scopes @("https://graph.microsoft.com/.default") `
                    -ClientId $ClientId `
                    -RedirectUri http://localhost `
                    -Authority https://login.microsoftonline.com/$TenantId `
                    -Interactive `
                    -ExtraQueryParameters @{claims = '{"access_token" : {"amr": { "values": ["mfa"] }}}' } `
                    -LoginHint $UserPrincipalName
                $token = ConvertTo-SecureString -String $($msalToken.AccessToken) -AsPlainText -Force
                Connect-MgGraph -AccessToken $token -NoWelcome
            }
            else {
                Connect-MgGraph -Scopes "PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup" -TenantId $tenantId -NoWelcome
            }
            if (!$?) {
                throw "Could not connect to the Graph API."
            }
            $mgCtx = Get-MgContext
            Write-VerboseOnly "Connection to MS Graph established using $($mgCtx.TokenCredentialType)."
            # Get the current user's principal id
            $azCtx = Get-AzContext
            $user = Get-AzADUser -Mail $azCtx.Account.id
            $principalId = $user.Id
            # Ensure that the current user has eligibility for privileged roles
            $userEligibilty = Get-MgRoleManagementDirectoryRoleEligibilityScheduleInstance -Filter "principalId eq '$principalId' and roleDefinitionId eq '$roleId'"
            if ($null -eq $userEligibilty) {
                # if the user is not eligible directly for the role, check if the user is eligible through a group
                $groups = Get-MgUserMemberOf -UserId $principalId
                foreach ($group in $groups) {
                    $groupEligibilty = Get-MgRoleManagementDirectoryRoleEligibilityScheduleInstance -Filter "principalId eq '$($group.Id)' and roleDefinitionId eq '$roleId'"
                    if ($null -eq $groupEligibilty) {
                        Write-HostError "The user is not eligible for the role with id '$roleId'."
                        return
                    }
                }
            }
            #Build request to activate the role assignment
            $params = @{
                principalId      = $principalId
                roleDefinitionId = $roleId
                justification    = $Justification
                directoryScopeId = "/"
                action           = "SelfActivate"
                scheduleInfo     = @{
                    startDateTime = Get-Date
                    expiration    = @{
                        type     = "AfterDuration"
                        duration = "PT$($DurationHours)H"
                    }
                }
            }
            $err = @()
            New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $params -ErrorVariable err -ErrorAction SilentlyContinue | Out-Null
            if ($err.count -eq 0) {
                # The operation succeeded
                $success = $true
            }
            else {
                if ($ShowMsalErrors.IsPresent) {
                    Write-HostWarning "The $($mgCtx.TokenCredentialType) authorization failed with error '$($err[0].ErrorDetails.Message)'."
                }
                if ($NoMsalFallback.IsPresent) {
                    break;
                }
            }
        }
        if (!$success) {
            throw "Could not enable role assignment request for role '$roleId'."
        }
        Write-HostSuccess "Sucessfully added assignment request for role id '$roleId'."
        if ($Wait.IsPresent) {
            Write-HostInfo "Waiting for approval. Press Ctrl+C to exit the wait loop."
            $count = 0
            while ($true) {
                $uri = "https://graph.microsoft.com/beta/roleManagement/directory/transitiveRoleAssignments?`$count=true&`$filter=principalId eq '$principalId' and roleDefinitionId eq '$roleId'"
                $assignments = (Invoke-MgGraphRequest -Uri $uri -Headers @{'ConsistencyLevel' = 'eventual' } -Method GET -Body $null).value | Measure-Object
                if ($assignments.Count -gt 0) {
                    Write-Host "`b`bYou now are member of the role with id '$roleId'."
                    break
                }
                $count += $count -eq 1 ? -1 : 1
                $text = ($count -eq 0) ? "`b`bâ–  " : "`b`b â– "
                Write-Host $text -NoNewline
                Start-Sleep 1
            }
        }
    }
}

<#
.SYNOPSIS
    Executes a command or script file in a new posh session which is authenticated with a service principal.
.DESCRIPTION
    Executes the command obtained from the parameter in a specific scope. This keeps the user
    scope intact even after performing a task which might have altred the scope otherwise.
.EXAMPLE
    Start-CafScoped -Command "./test.ps1" -FileCommand
    Start-CafScoped -Command "Get-AzContext"
#>


function Start-Scoped {
    [CmdLetBinding()]
    param (
        [string]$Command,
        [switch]$FileCommand,
        [Parameter(Mandatory = $false)]
        [ValidateSet("deploy", "ops")]
        [string]$ServicePrincipalType = "ops",
        [Parameter(Mandatory = $false)]
        [ValidateSet("subscription", "managementGroup")]
        [string]$ServicePrincipalScope = "subscription",
        [switch]$NoLogo
    )
    process {
        $ErrorActionPreference = 'Stop'
        $removeNoLogo = $false
        if ($NoLogo.IsPresent && !$env:NO_DEVDEER_CAF_LOGO) {
            $env:NO_DEVDEER_CAF_LOGO = "1"
            $removeNoLogo = $true
        }
        $verbose = $PSBoundParameters['Verbose'] -or $VerbosePreference -eq 'Continue'
        $command = '
            $ErrorActionPreference = "Stop"
            $command = Get-Command -Name Use-CafServicePrincipal -ErrorAction SilentlyContinue
            if ($command -eq $null) {
                throw "CAF modules not installed in this session! Consider importing it in your profile."
            }
            Use-CafServicePrincipal '
 `
            + ($verbose ? '-Verbose' : '') `
            + ' -ServicePrincipalType "' + $ServicePrincipalType + '"' `
            + ' -ServicePrincipalScope "' + $ServicePrincipalScope + '"' `
            + [Environment]::NewLine `
            + ($FileCommand.IsPresent ? ' & ' : ' ') + $Command
        $global:LASTEXITCODE = 0
        if ($verbose) {
            Invoke-Expression "pwsh -Command { $command }" -Verbose
        }
        else {
            Invoke-Expression "pwsh -Command { $command }"
        }
        if ($removeNoLogo) {
            $env:NO_DEVDEER_CAF_LOGO = ""
        }
        if ($LASTEXITCODE -ne 0) {
            throw "Command failed with exit code $LASTEXITCODE."
        }       
    }
}


<#
.SYNOPSIS
    Deactivates the user's assignment to a PIM group.
.DESCRIPTION
    Deactivates the user's assignment to a PIM group. The user must be eligible for the group. Either you run
    this cmdlet under a .azcontext which defines 'tenantId' and 'adminEntraGroupName' or you provide those
    values using the parameters.
.PARAMETER Tenant
    The tenant id or domain name.
.PARAMETER GroupName
    The name of the PIM group.
.PARAMETER NoMsalFallback
    If set the logic will not retry to force the current user to provide MFA using MSAL.PS.
.PARAMETER ShowMsalErrors
    If set the raw MSAL errors will be shown in the output.
.EXAMPLE
    Stop-CafPimGroup
.EXAMPLE
    Stop-CafPimGroup -Tenant {NAME_OR_ID}
.EXAMPLE
    Stop-CafPimGroup -Tenant {NAME_OR_ID} -GroupName AZ-Admins
#>

function Stop-PimGroup {
    [CmdLetBinding()]
    param (
        [string]
        $Tenant,
        [string]
        $GroupName,
        [switch]
        $NoMsalFallback,
        [switch]
        $ShowMsalErrors
    )
    process {
        # Check the current context and if it is not the right tenant, connect to the right one.
        $ctx = Use-CafContext
        if (($ctx.Keys | Measure-Object).Count -eq 0) {
            # No context was found
            if ($Tenant.Length -eq 0) {
                throw "No AZ context was found. You need to provide a tenant!"
            }
            Connect-Tenant -TenantId $Tenant
            if (!$?) {
                throw "Could not connect to provided tenant."
            }
            # use the tenant id from the default Azure context
            $tenantId = (Get-AzContext).Tenant.Id
        }
        else {
            $tenantId = $ctx.tenantId
            if ($GroupName.Length -eq 0) {
                if ($ctx.adminEntraGroupName.Length -eq 0) {
                    throw "No administrative group retrieved from AZ context. You need to provide one"
                }
                # use the group name from the AZ context
                $GroupName = $ctx.adminEntraGroupName
            }
        }
        # Ensure that Microsoft.Graph.Authentication module is present
        Enable-Module -ModuleName Microsoft.Graph.Authentication
        # Ensure that Microsoft.Graph.Authentication module is present
        Enable-Module -ModuleName Microsoft.Graph.Identity.Governance
        # Ensure that Microsoft.Resources module is present
        Enable-Module -ModuleName Az.Resources
        # All needed modules are present.
        $attempts = 0
        $success = $false
        while ($success -eq $false -and $attempts -lt 3) {
            $attempts++
            # Connect to the Graph API
            if ($attempts -gt 1) {
                # This is the second run
                Enable-Module -ModuleName MSAL.PS
                $clientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"
                $userPrincipalName = (Get-AzContext).Account.Id
                Write-HostInfo "Trying to obtain MSAL token for user $userPrincipalName. You should be asked to provide password and MFA soon."
                $msalToken = Get-MSALToken -Scopes @("https://graph.microsoft.com/.default") `
                    -ClientId $ClientId `
                    -RedirectUri http://localhost `
                    -Authority https://login.microsoftonline.com/$TenantId `
                    -Interactive `
                    -ExtraQueryParameters @{claims = '{"access_token" : {"amr": { "values": ["mfa"] }}}' } `
                    -LoginHint $UserPrincipalName
                $token = ConvertTo-SecureString -String $($msalToken.AccessToken) -AsPlainText -Force
                Connect-MgGraph -AccessToken $token -NoWelcome
            }
            else {
                Connect-MgGraph -Scopes "PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup" -TenantId $tenantId -NoWelcome
            }
            if (!$?) {
                throw "Could not connect to the Graph API."
            }
            $mgCtx = Get-MgContext
            Write-VerboseOnly "Connection to MS Graph established using $($mgCtx.TokenCredentialType)."
            # Retrieve group id
            $entraGroup = Get-AzAdGroup -DisplayName $GroupName
            if ($null -eq $entraGroup) {
                throw "Group with name '$GroupName' not found in tenant."
            }
            # Ensure that the current user has eligibility for the group
            $groupId = $entraGroup.Id
            $availableAssignments = Invoke-MgFilterIdentityGovernancePrivilegedAccessGroupEligibilityScheduleInstanceByCurrentUser -On "principal" -Filter "GroupId eq '$groupId'"
            # is the command does not return any arrays or gives exception? If so, the user is not eligible for the group
            if ($null -eq $availableAssignments) {
                Write-HostWarning "The current user is not eligible for membership on group '$GroupName' ($groupId)."
                return
            }
            # Get the current user's principal id
            $azCtx = Get-AzContext
            $user = Get-AzADUser -Mail $azCtx.Account.id
            $principalId = $user.Id
            # Get the group id
            $group = Get-AzADGroup | Where-Object { $_.DisplayName -eq $GroupName }
            if (!$group) {
                throw "Group '$GroupName' not found."
            }
            $groupId = $group.Id
            # Check if the user is part of the group
            Get-MgGroupMember -GroupId $groupId -Filter "id eq '$principalId'" -ErrorAction SilentlyContinue | Out-Null
            if (!$?) {
                Write-HostWarning "Current user is probably not a member of '$GroupName' ($groupId) at the moment."
                return
            }
            # Build request to deactivate the group assignment
            $params = @{
                accessId    = "member"
                principalId = $principalId
                groupId     = $groupId
                action      = "selfDeactivate"
            }
            $err = @()
            New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $params -ErrorVariable err -ErrorAction SilentlyContinue | Out-Null
            if ($err.count -eq 0) {
                # The operation succeeded
                $success = $true
            }
            else {
                if ($err[0].ErrorDetails.Message.Contains("too short")) {
                    Write-HostError "Wait at least 5 minutes before stopping a group assignment."
                    break;
                }
                if ($err[0].ErrorDetails.Message.Contains("already a member")) {
                    Write-HostError "User is already member of that group."
                    break;
                }
                if ($ShowMsalErrors.IsPresent) {
                    Write-HostWarning "The $($mgCtx.TokenCredentialType) authorization failed with error '$($err[0].ErrorDetails.Message)'."
                }
                if ($NoMsalFallback.IsPresent) {
                    break;
                }
            }
        }
        if (!$success) {
            throw "Could not disable PIM group membership."
        }
        Write-HostSuccess "Sucessfully disabled PIM group membership for group '$GroupName' ($groupId)."
    }
}

<#
.SYNOPSIS
    Deactivates the user's PIM Role assignment.
.DESCRIPTION
    Checks if the user is eligible for the role and deactivates the assignment.
.PARAMETER Tenant
    The tenant id or domain name.
.PARAMETER RoleId
    The id of the role. Default is "Global Administator".
.PARAMETER NoMsalFallback
    If set the logic will not retry to force the current user to provide MFA using MSAL.PS.
.PARAMETER ShowMsalErrors
    If set the raw MSAL errors will be shown in the output.
.EXAMPLE
    Stop-CafPimRole -Tenant TODO
#>

function Stop-PimRole {
    [CmdLetBinding()]
    param (
        [string]
        $Tenant,
        [string]
        $RoleId = "62e90394-69f5-4237-9190-012177145e10",
        [switch]
        $NoMsalFallback,
        [switch]
        $ShowMsalErrors
    )
    process {
        $ctx = Use-CafContext
        if (($ctx.Keys | Measure-Object).Count -eq 0) {
            # No context was found
            if ($Tenant.Length -eq 0) {
                throw "No AZ context was found. You need to provide a tenant!"
            }
            Connect-Tenant -TenantId $Tenant
            if (!$?) {
                throw "Could not connect to provided tenant."
            }
            # use the tenant id from the default Azure context
            $tenantId = (Get-AzContext).Tenant.Id
        }
        else {
            $tenantId = $ctx.tenantId
        }
        # Ensure that Microsoft.Graph.Authentication module is present
        Enable-Module -ModuleName Microsoft.Graph.Authentication
        # Ensure that Microsoft.Graph.Authentication module is present
        Enable-Module -ModuleName Microsoft.Graph.Identity.Governance
        # Ensure that Microsoft.Resources module is present
        Enable-Module -ModuleName Az.Resources
        # All needed modules are present.
        $attempts = 0
        $success = $false
        while ($success -eq $false -and $attempts -lt 3) {
            # Connect to the Graph API
            $attempts++
            # Connect to the Graph API
            if ($attempts -gt 1) {
                # This is the second run
                Enable-Module -ModuleName MSAL.PS
                $clientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"
                $userPrincipalName = (Get-AzContext).Account.Id
                Write-HostInfo "Trying to obtain MSAL token for user $userPrincipalName. You should be asked to provide password and MFA soon."
                $msalToken = Get-MSALToken -Scopes @("https://graph.microsoft.com/.default") `
                    -ClientId $ClientId `
                    -RedirectUri http://localhost `
                    -Authority https://login.microsoftonline.com/$TenantId `
                    -Interactive `
                    -ExtraQueryParameters @{claims = '{"access_token" : {"amr": { "values": ["mfa"] }}}' } `
                    -LoginHint $UserPrincipalName
                $token = ConvertTo-SecureString -String $($msalToken.AccessToken) -AsPlainText -Force
                Connect-MgGraph -AccessToken $token -NoWelcome
            }
            else {
                Connect-MgGraph -Scopes "PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup" -TenantId $tenantId -NoWelcome
            }
            if (!$?) {
                throw "Could not connect to the Graph API."
            }
            $mgCtx = Get-MgContext
            Write-VerboseOnly "Connection to MS Graph established using $($mgCtx.TokenCredentialType)."
            # Get the current user's principal id
            $azCtx = Get-AzContext
            $user = Get-AzADUser -Mail $azCtx.Account.id
            $principalId = $user.Id
            # Ensure that the current user has eligibility for the role
            $userEligibilty = Get-MgRoleManagementDirectoryRoleEligibilityScheduleInstance -Filter "principalId eq '$principalId' and roleDefinitionId eq '$roleId'"
            # is the command does not return any arrays or gives exception? If so, the user is not eligible for the group
            if ($null -eq $userEligibilty) {
                Write-Host "The current user is not eligible for any role"
                return
            }
            # Build request to deactivate the group assignment
            $params = @{
                principalId      = $principalId
                roleDefinitionId = $RoleId
                directoryScopeId = "/"
                action           = "selfDeactivate"
            }
            $err = @()
            New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $params -ErrorAction SilentlyContinue | Out-Null
            if ($err.count -eq 0) {
                # The operation succeeded
                $success = $true
            }
            else {
                if ($err[0].ErrorDetails.Message.Contains("too short")) {
                    Write-HostError "Wait at least 5 minutes before stopping a group assignment."
                    break;
                }
                if ($ShowMsalErrors.IsPresent) {
                    Write-HostWarning "The $($mgCtx.TokenCredentialType) authorization failed with error '$($err[0].ErrorDetails.Message)'."
                }
                if ($NoMsalFallback.IsPresent) {
                    break;
                }
            }
        }
        if (!$success) {
            throw "Could not disable assignment to role id '$roleId'."
        }
        Write-HostSuccess "Sucessfully disabled assignment for role id '$roleId'."
    }
}

<#
 .Synopsis
  Ensures that the current posh context for Azure is aligned with Get-CafContext.
 .Description
  This command will use Get-CafContext to get the target tenant and subscription
  id and compares this with the current posh context. If they differ, the command
  will set the posh context to the values specified in the .azcontext file.
  If the .azcontext file does not exist, the command will fail.
 .Example
  Use-CafContext
#>

function Use-Context {
    [CmdLetBinding()]
    param (
        [string]
        $SubscriptionId
    )
    process {
        # get the context from .azcontext files
        $ErrorActionPreference = 'Stop'
        $ctx = Get-CafContext
        $targetTenantId = $ctx.tenantId
        if (!$targetTenantId) {
            throw "No tenant id specified in .azcontext"
        }
        # if SubscriptionId is not present, use the subscription id from the context file
        # if $ctx.subscriptionId is not present, try assigning $ctx.managementSubscriptionId
        if (!$SubscriptionId) {
            $targetSubscriptionId = $ctx.subscriptionId ?? $ctx.managementSubscriptionId
        }
        else {
            $targetSubscriptionId = $SubscriptionId
        }
        $currentContext = Get-AzContext
        # if no context is present, connect to the tenant
        if (!$currentContext) {
            Write-HostDebug "Currently not connected to any Azure tenant."
            # if targetSubscriptionId is present, connect to the tenant and subscription
            if ($null -ne $targetSubscriptionId) {
                Write-HostDebug "Connecting to tenant $targetTenantId and subscription $targetSubscriptionId"
                Connect-AzAccount -TenantId $targetTenantId -SubscriptionId $targetSubscriptionId -Force | Out-Null
                $currentContext = Get-AzContext
            }
            else {
                Write-HostDebug "Connecting to tenant $targetTenantId"
                Connect-AzAccount -TenantId $targetTenantId -Force | Out-Null
                $currentContext = Get-AzContext
            }
        }
        $currentPoshContextTenant = $currentContext.Tenant.Id
        $currentPoshContextSubscription = $currentContext.Subscription.Id
        Write-VerboseOnly "Current tenant: $currentPoshContextTenant"
        Write-VerboseOnly "Target tenant: $targetTenantId"
        Write-VerboseOnly "Current subscription: $currentPoshContextSubscription"
        Write-VerboseOnly "Target subscription: $targetSubscriptionId"
        # If the user is already connected to a teant, but the tenant differs from the target tenant, connect to the target tenant
        if ($targetTenantId -and $targetTenantId -ne $currentPoshContextTenant) {
            Write-VerboseOnly "The target tenant $targetTenantId differ from the current posh context: $currentPoshContextTenant."
            if ($ctx.forceContext -ne $true) {
                throw "Cannot proceed on wrong context."
            }
            # if both $targetSubscriptionId and $targetTenantId are present, connect to the tenant and subscription
            if ($targetTenantId -and $targetSubscriptionId) {
                Connect-AzAccount -TenantId $targetTenantId -SubscriptionId $targetSubscriptionId -Force | Out-Null
            }
            else {
                # set context to tenant only
                Connect-AzAccount -TenantId $targetTenantId -Force | Out-Null
            }
        }
        # when tenants are same, but subscriptions differ, set the subscription
        else {
            if ($targetSubscriptionId -and $targetSubscriptionId -ne $currentPoshContextSubscription) {
                if ($ctx.forceContext -ne $true) {
                    throw "Cannot proceed on wrong context."
                }
                Write-VerboseOnly "The target subscription $targetSubscriptionId differ from the current posh context: $currentPoshContextSubscription."
                Write-HostDebug "Setting context to subscription $targetSubscriptionId"
                Set-AzContext -Tenant $targetTenantId -Subscription $targetSubscriptionId | Out-Null
            }
        }
        $newCtx = Get-AzContext
        if ($targetTenantId -ne $newCtx.Tenant.Id) {
            throw "Could not force context to the target tenant."
        }
        if ($targetSubscriptionId -and $targetSubscriptionId -ne $newCtx.Subscription.Id) {
            throw "Could not force context to the target subscription."
        }
        return $ctx
    }
}

<#
.SYNOPSIS
    Sets the context for the service principal logging it in using the password in the resolved Azure Key Vault.
.DESCRIPTION
   Gets the subscription Id from the azcontext file. It uses this Id and retreves the
   deploy service princiapal which has an Owner role assignment on the subscription scope.
   This service pricipal is then used to retreve its respective keyvault name.
.PARAMETER ServicePrincipalType
    Specifies the type of service principal to use. Valid values are "ops" and "deploy". The default value is "deploy".
.PARAMETER ServicePrincipalScope
    Specifies the scope of the service principal. Valid values are "subscription" and "managementGroup". The default value is "subscription".
.EXAMPLE
    Use-CafServicePrincipal -ServicePrincipalType "deploy"
#>


function Use-ServicePrincipal {
    [CmdLetBinding()]
    param (
        [string]
        [ValidateSet("deploy", "ops")]
        $ServicePrincipalType = "ops",
        [string]
        [validateset("Subscription", "ManagementGroup")]
        $ServicePrincipalScope = "Subscription"
    )
    process {
        $ErrorActionPreference = 'Stop'
        $ctx = Use-CafContext
        if (!$?) {
            throw "Could not set azcontext"
        }
        $azCtx = Get-AzContext
        if (!$azCtx) {
            throw "Could not get azcontext"
        }
        # get the subscription name from current context
        $subscriptionName = $azCtx.Subscription.Name
        # Build the subscription(landing zone) name from the az context naming convention
        $lzSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone" -ProjectName ".*"
        if (!$lzSubscriptionNameRegex) {
            throw "Failed to get the naming convention for the landing zone subscription."
        }
        # Build the IAM subsciption name from the az context naming convention
        $iamSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "iamSubscription" -ProjectName ".*"
        if (!$iamSubscriptionNameRegex) {
            throw "Failed to get the naming convention for the IAM subscription."
        }
        # find what type of subscription is used in the current context and decide the-
        # -scope of the service principal if its a Subscription or ManagementGroup
        if ($SubscriptionName -match $iamSubscriptionNameRegex) {
            $ServicePrincipalScope = "ManagementGroup"
        }
        # if service principal type is deploy, we use the deploySubscription naming convention, otherwise the opsSubscription naming convention
        if ($ServicePrincipalScope -eq "Subscription") {
            if ($ServicePrincipalType -eq "deploy") {
                $namingConventionSubType = "deploySubscription"
            }
            else {
                $namingConventionSubType = "opsSubscription"
            }
        }
        elseif ($ServicePrincipalScope -eq "ManagementGroup") {
            if ($ServicePrincipalType -eq "deploy") {
                $namingConventionSubType = "deployManagementGroup"
            }
            else {
                $namingConventionSubType = "opsManagementGroup"
            }
        }
        else {
            throw "Invalid scope type: '$scopeType'."
        }
        Write-VerboseOnly "Service principal type deduced: $namingConventionSubType"
        # Ensure that the subscription name conforms to the naming conventions
        if ($SubscriptionName -notmatch $lzSubscriptionNameRegex -and $SubscriptionName -notmatch $iamSubscriptionNameRegex) {
            Write-Host "Subscription with Name: '$SubscriptionName' | ID: '$subscriptionId' does not conform with subscription naming conventions '$lzSubscriptionName' or '$iamSubscriptionName'...Skipping." -ForegroundColor Yellow
            continue
        }
        if ($ServicePrincipalScope -eq "ManagementGroup") {
            # build the service principal name when scope is management group
            $managementGroupName = $ctx.managementGroupId
            # We use Format-NamingConvention to build the management group name pattern and deduce index of the element "[x]" in the pattern.
            # This gives us the position of the real project name location from the Azure queried management group name.
            $sep = $ctx.namingConvention.ManagementGroup.separator
            $regex = $sep + "?([^-]*)" + $sep + "?"
            $namePattern = Format-NamingConvention -Context $ctx -Type "managementGroup"
            # NOTE: We are doing the conversion into a .NET list because this gives us the FindIndex method which we need to find the index of the project name in the subscription name.
            $offset = ([Collections.Generic.List[Object]](($namePattern | Select-String -Pattern $regex -AllMatches).Matches)).FindIndex({ $args[0].Groups[1].Value -eq "[x]" })
            $projectName = $managementGroupName.Split($sep)[$offset]
            if ($projectName.Length -eq 0) {
                throw "Could not deduce Management Group name from '$managementGroupName'."  
            }
            Write-VerboseOnly "Management group name: $projectName"
            # build the service principal name using the naming convention
            $servicePrincipalName = Format-NamingConvention -Context $ctx -Type "servicePrincipal" -SubType $namingConventionSubType -ProjectName $projectName
        }
        else {
            # the scope is Subscription
            # We use Format-NamingConvention to build the project name pattern and try to find the index of the element "[x]" in the pattern. This gives us
            # the position of the real project name inside the given subscription name.
            $sep = $ctx.namingConvention.subscription.landingZone.separator
            $regex = $sep + "?([^-]*)" + $sep + "?"
            $namePattern = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone"
            # NOTE: We are doing the conversion into a .NET list because this gives us the FindIndex method which we need to find the index of the project name in the subscription name.
            $offset = ([Collections.Generic.List[Object]](($namePattern | Select-String -Pattern $regex -AllMatches).Matches)).FindIndex({ $args[0].Groups[1].Value -eq "[x]" })
            $projectName = $SubscriptionName.Split($sep)[$offset]
            if ($projectName.Length -eq 0) {
                throw "Could not deduce project name from subscription name '$SubscriptionName'."  
            }
            # TODO: $projectName for IAM subscriptions should already contain projectName-iam in it.
            # Right now becase of the regex used the separator defined exludes iam from the projectName.
            # Maybe it needs to be of the format projectname_iam for IAM subscriptions.
            # Becasaue of this I have to use the below workaround to add the iam after the project name.
            # if the subscription name matches the IAM subscription naming convention, we append to the project name
            if ($SubscriptionName -match $iamSubscriptionNameRegex) {
                $projectName = $projectName + $ctx.namingConvention.subscription.iamSubscription.separator + $ctx.namingConvention.subscription.iamSubscription.suffix
            }
            # build the service principal name when scope is subscription
            $servicePrincipalName = Format-NamingConvention -Context $ctx -Type "servicePrincipal" -SubType $namingConventionSubType -ProjectName $projectName
            Write-VerboseOnly "Project name: $projectName"
        }
        Write-VerboseOnly "Using Service Principal Name: $servicePrincipalName on scope $ServicePrincipalScope"
        # get the key vault name
        $keyVault = Get-ManagementKeyVault -ScopeType $ServicePrincipalScope -ScopeName $projectName
        $vaultName = $keyVault.VaultName
        # try to retrieve the service principal
        $servicePrincipal = Get-AzADServicePrincipal -DisplayName $servicePrincipalName
        if (!$servicePrincipal) {
            throw "Service principal with display name '$servicePrincipalName' not found"
        }
        if ($servicePrincipal.AppId -eq $azCtx.Account.Id) {
            Write-Host "Service principal $servicePrincipalName already logged in."
            return;
        }
        Write-VerboseOnly "Service Principal $($servicePrincipal.DisplayName) found"
        # retrieve the password for the service principal
        $spSecretName = $servicePrincipal.DisplayName
        $spSecurePass = Get-AzKeyVaultSecret -VaultName $vaultName -Name $spSecretName
        if (!$spSecurePass) {
            throw "Could not get password for service principal '$spSecretName' from key vault '$vaultName'"
        }
        # login to Azure with the service principal
        $spId = (Get-AzADServicePrincipal -DisplayName $servicePrincipal.DisplayName).AppId
        $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $spId, $spSecurePass.SecretValue
        Write-VerboseOnly "Connect-AzAccount -Scope Process -ServicePrincipal -TenantId $($azCtx.Tenant.Id) -Subscription $($azCtx.Subscription.Id) -Credential $credential"
        Connect-AzAccount -Scope Process -ServicePrincipal -TenantId $azCtx.Tenant.Id -Subscription $azCtx.Subscription.Id -Credential $credential | Out-Null
        if (!$?) {
            throw "Could not login with service principal $($servicePrincipal.DisplayName)"
        }
        else {
            Write-Host "Switched to service principal '$($servicePrincipal.DisplayName)' on tenant '$($azCtx.Tenant.Id)', subscription '$($azCtx.Subscription.Id)'."
        }
    }
}


<#
 .Synopsis
    Clears the environment variables used while performing a terraform deployment.
 .Description
    This function clears the environment variables ARM_CLIENT_ID and ARM_CLIENT_SECRET.
 .Example
    Clear-TerraformEnvironmentVariables
#>

function Clear-TerraformEnvironmentVariables {
   [CmdLetBinding()]
   param (
   )
   process {
      $ErrorActionPreference = 'Stop'
      $env:ARM_CLIENT_ID = ""
      $env:ARM_CLIENT_SECRET = ""
      $env:ARM_TENANT_ID = ""
      $env:ARM_SUBSCRIPTION_ID = ""
      Write-HostInfo "Cleanup of environment variables finished."
   }
}

<#
 .Synopsis
 TODO
 .Description
 TODO It will throw an exception if connecting to the provided tenant is not possible.
 .Parameter TenantId
 The id of the desired tenant.
 .Example
  Enable-Tenant -TenantId $TENANT_ID
#>

function Connect-Tenant {
    [CmdletBinding()]
    param (
        $TenantId
    )
    process {
        $context = Get-AzContext
        if ($context.Tenant.Id -eq $TenantId) {
            Write-VerboseOnly "Tenant already connected."
            return
        }
        Write-Host "Current tenant context is wrong. Connecting to '$TenantId' ..." -NoNewline
        Connect-AzAccount -TenantId $TenantId -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null
        if (!$?) {
            Write-Host "Error" -ForegroundColor Red
            throw "Could not connect to tenant '$TenantId'."
        } else {
            Write-Host "Done" -ForegroundColor Green
        }
    }
}

<#
 .Synopsis
 Converts a given input into a hash table
 .Description
 This is used to recursively iterate through the given input object
 and try to generate a hash table out of it.
 .Parameter InputObject
 Could be a enumeration, psobject or hashtable.
 .Example
  $hashTable = ConvertTo-HashTable -InputObject $json
#>

function ConvertTo-Hashtable {
    [CmdletBinding()]
    [OutputType('hashtable')]
    param (
        [Parameter(ValueFromPipeline)]
        $InputObject
    )
    process {
        ## Return null if the input is null. This can happen when calling the function
        ## recursively and a property is null
        if ($null -eq $InputObject) {
            return $null
        }
        ## Check if the input is an array or collection. If so, we also need to convert
        ## those types into hash tables as well. This function will convert all child
        ## objects into hash tables (if applicable)
        if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
            $collection = @(
                foreach ($object in $InputObject) {
                    ConvertTo-Hashtable -InputObject $object
                }
            )
            ## Return the array but don't enumerate it because the object may be pretty complex
            Write-Output -NoEnumerate $collection
        } elseif ($InputObject -is [psobject]) {
            ## If the object has properties that need enumeration
            ## Convert it to its own hash table and return it
            $hash = @{}
            foreach ($property in $InputObject.PSObject.Properties) {
                $hash[$property.Name] = ConvertTo-Hashtable -InputObject $property.Value
            }
            $hash
        } else {
            ## If the object isn't an array, collection, or other object, it's already a hash table
            ## So just return it.
            $InputObject
        }
    }
}

<#
 .Synopsis
 Ensures that the given module is imported into the current session.
 .Description
 This script tries to ensure that the module with the given name is part of the current session
 and usable. It will throw an exception if importing the provided module is not possible.
 .Parameter ModuleName
 The name of the module to be imported in the current session after this command.
 .Example
  Enable-CafModule -ModuleName Microsoft.Graph
#>

function Enable-Module {
    [CmdletBinding()]
    param (
        $ModuleName
    )
    process {
        if ((Get-InstalledModule -Name $ModuleName -ErrorAction SilentlyContinue | Measure-Object).Count -eq 0) {
            # module not installed
            Write-VerboseOnly "Installing module $ModuleName..." -NoNewline
            Install-Module $ModuleName -Force -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null
            if (!$?) {
                Write-VerboseOnly "Error" -ForegroundColor Red
                throw "Could not import module $ModuleName."
            }
            else {
                Write-VerboseOnly "Done" -ForegroundColor Green
            }
        }
        else {
            # module already installed
            Write-VerboseOnly "Powershell module $ModuleName is already installed."
        }
        if ((Get-Module -Name $ModuleName | Measure-Object).Count -eq 0) {
            # module not imported yet
            Write-VerboseOnly "Importing module $ModuleName..."  -NoNewline
            Import-Module $ModuleName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null
            if (!$?) {
                Write-VerboseOnly "Error" -ForegroundColor Red
                throw "Could not import module $ModuleName."
            }
            else {
                Write-VerboseOnly "Done" -ForegroundColor Green
            }
        }
        else {
            # module imported
            Write-VerboseOnly "Powershell module $ModuleName is already imported."
        }
    }
}

<#
 .Synopsis
 Is able to find files with a specific name up the tree and return all of them or the first hit.
 .Description
 This function is able to find files with a specific name up the tree and return all of them or the first hit.
 .Parameter FilePattern
 The name of the file to search for up the tree.
 .Parameter ReturnFirstMatch
 If this switch is set the function will return the first found file. If not, an array of matching files will be returned.
 .Example
    Find-FilesByNameUp -FileName "bicepSettings.json"
#>

function Find-FilesByNameUp {
    [CmdletBinding()]
    param (
        $FileName,
        [switch]
        $ReturnFirstMatch
    )
    process {
        $files = New-Object Collections.Generic.List[String]
        $currentFolder = $PWD
        while ($true) {
            $file = Join-Path $currentFolder $FileName
            if (Test-Path $file) {
                if ($ReturnFirstMatch) {
                    return $file
                }
                $files.Add($file)
            }
            $currentFolder = Split-Path $currentFolder
            if (!$currentFolder) {
                break
            }
        }
        return $files
    }
}

<#
.SYNOPSIS
    Initializes the subscription management resources.
.DESCRIPTION
    Deploys the subscription management resources by using the Bicep deployment technology.
.PARAMETER Context
    The context object which contains the naming conventions and details retrieved from the infrastrucutre project .azcontext file.
.PARAMETER Type
    The type of the naming conventions. Can be subscription or resource group.
.PARAMETER SubType
    The SubType of the naming conventions. can be landing zones or management subscriptions. Default is null.
.PARAMETER ProjectName
    The optional project name to use.
.EXAMPLE
    Format-NamingConvention `
        -Context $Context `
        -Type "subscription" `
        -SubType "landingZone"
        -ProjectName "spock"
#>

function Format-NamingConvention {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [object]
        $Context,
        [string]
        $Type,
        [string]
        $SubType = $null,
        [string]
        $ProjectName
    )
    $ErrorActionPreference = 'Stop'
    $namingConvention = $null
    if ($null -eq $ProjectName -or $ProjectName.Length -eq 0) {
        $ProjectName = "[x]"
    }
    # Check if subtype is set and if it is a valid subtype. also if only type is set check if it is a valid type
    if ($SubType) {
        if ($Context.namingConvention.$Type.$SubType) {
            $namingConvention = $Context.namingConvention.$Type.$SubType
        }
        else {
            throw "Invalid subtype: '$SubType' for type: '$Type'."
        }
    }
    elseif ($Context.namingConvention.$Type) {
        $namingConvention = $Context.namingConvention.$Type
    }
    else {
        throw "Invalid type: '$Type'."
    }
    # Type has been found and we can continue to build the name
    $nameParts = @()
    # Build the name parts based on the naming convention and the order defined in the naming convention'
    foreach ($part in $namingConvention.order) {
        if ($part -eq 'projectName') {
            # if $regex is not null then we need to replace the projectName with the regex
            $nameParts += Invoke-NamingTransformer -Value $ProjectName -Transformer $namingConvention.transformer
        }
        if ($part -eq 'companyName') {
            $nameParts += Invoke-NamingTransformer -Value $Context.companyName -Transformer $namingConvention.transformer
        }
        if ($part -eq 'companyShort') {
            $nameParts += Invoke-NamingTransformer -Value $Context.companyShort -Transformer $namingConvention.transformer
        }
        elseif ($namingConvention[$part]) {
            $nameParts += $namingConvention[$part]
        }
    }
    $builtName = $nameParts -join $namingConvention.separator
    # if max length is defined for a resource then we need to trim the built string to the max length
    if ($namingConvention.maxLength -and $builtName.Length -gt $namingConvention.maxLength) {
        $builtName = $builtName.Substring(0, $namingConvention.maxLength)
    }
    Write-VerboseOnly "Naming convention name is '$builtName'."
    return $builtName
}



<#
.SYNOPSIS
    Retrieves the default naming conventions which later can be overridden in the .azcontext file.
.DESCRIPTION
    This function is just statically returning the default conventions.
.EXAMPLE
    Get-DefaultNamingConventions
#>

function Get-DefaultNamingConventions {
    # Parameter help description
    [CmdletBinding()]
    param (
    )
    process {
        return @{
            managementGroup  = @{
                transformer = "uppercase"
                prefix      = ""
                suffix      = ""
                separator   = "-"
                order       = @("companyName", "projectName")
            }
            subscription     = @{
                landingZone            = @{
                    transformer = "lowercase"
                    prefix      = "lz"
                    suffix      = ""
                    separator   = "-"
                    order       = @("prefix", "companyShort", "projectName")
                }
                managementSubscription = @{
                    transformer = "lowercase"
                    prefix      = "lz"
                    suffix      = "mgmt"
                    separator   = "-"
                    order       = @("prefix", "companyShort", "projectName", "suffix")
                }
                iamSubscription        = @{
                    transformer = "lowercase"
                    prefix      = "lz"
                    suffix      = "iam"
                    separator   = "-"
                    order       = @("prefix", "companyShort", "projectName", "suffix")
                }
            }
            resourceGroup    = @{
                projectResourceGroup    = @{
                    transformer = "lowercase"
                    prefix      = "rg"
                    suffix      = ""
                    separator   = "-"
                    order       = @("prefix", "projectName", "suffix")
                }
                managementResourceGroup = @{
                    transformer = "lowercase"
                    prefix      = "rg"
                    suffix      = "management"
                    separator   = "-"
                    order       = @("prefix", "suffix")
                }
            }
            resource         = @{
                keyVault            = @{
                    transformer = "lowercase"
                    prefix      = "akv"
                    infix       = "mgmt"
                    suffix      = ""
                    separator   = "-"
                    maxLength   = "24"
                    order       = @("prefix", "companyShort", "infix", "projectName")
                }
                iamKeyVault         = @{
                    transformer = "lowercase"
                    prefix      = "akv"
                    infix       = "mgmt"
                    suffix      = "iam"
                    separator   = "-"
                    maxLength   = "24"
                    order       = @("prefix", "companyShort", "infix", "projectName", "suffix")
                }
                infraKeyVault       = @{
                    transformer = "lowercase"
                    prefix      = "akv"
                    infix       = ""
                    suffix      = "infrastate"
                    separator   = "-"
                    maxLength   = "24"
                    order       = @("prefix", "companyShort", "suffix")
                }
                storageAccount      = @{
                    transformer = "lowercase"
                    prefix      = "sto"
                    infix       = "mgmt"
                    suffix      = ""
                    separator   = ""
                    order       = @("prefix", "companyShort", "infix", "projectName")
                }
                networkWatcher      = @{
                    transformer = "lowercase"
                    prefix      = "nw"
                    infix       = "mgmt"
                    suffix      = ""
                    separator   = "-"
                    order       = @("prefix", "companyShort", "infix", "projectName")
                }
                centralLogAnalytics = @{
                    transformer = "lowercase"
                    prefix      = "log"
                    suffix      = "management"
                    separator   = "-"
                    order       = @("prefix", "companyShort", "suffix")
                }
            }
            servicePrincipal = @{
                opsSubscription       = @{
                    transformer = "lowercase"
                    prefix      = "sp-sub"
                    suffix      = "ops"
                    separator   = "-"
                    order       = @("prefix", "projectName", "suffix")
                }
                deploySubscription    = @{
                    transformer = "lowercase"
                    prefix      = "sp-sub"
                    suffix      = "deploy"
                    separator   = "-"
                    order       = @("prefix", "projectName", "suffix")
                }
                opsManagementGroup    = @{
                    transformer = "lowercase"
                    prefix      = "sp-mg"
                    suffix      = "ops"
                    separator   = "-"
                    order       = @("prefix", "projectName", "suffix")
                }
                deployManagementGroup = @{
                    transformer = "lowercase"
                    prefix      = "sp-mg"
                    suffix      = "deploy"
                    separator   = "-"
                    order       = @("prefix", "projectName", "suffix")
                }
            }
            securityGroup    = @{
                deploySpSecurityGroup = @{
                    transformer = "uppercase"
                    prefix      = "AZ"
                    suffix      = "CAF-DeployPrincipals"
                    separator   = "-"
                    order       = @("prefix", "suffix")
                }
            }
        }
    }
}

<#
 .Synopsis
    Retrieves locks from the resource group with the given name.
 .Description
    This will retrieve all locks from the given resource group. If the scope is set, the ResourceGroupName
    parameter will be ignored.
 .Parameter ResourceGroupName
    The name of the resource group.
.Parameter Scope
    The scope of the lock. If this is set, the ResourceGroupName parameter will be ignored.
 .Parameter AllLockLevels
    If set all lock levels will be retrieved. By default only CanNotDelete is retrieved.
#>

function Get-Locks {
    [CmdLetBinding()]
    param (
        [string]
        $ResourceGroupName,
        [string]
        $Scope,
        [switch]
        $AllLockLevels
    )
    $useScope = $Scope -match '^/subscriptions/[a-fA-F0-9-]+$'
    if ($ResourceGroupName -eq $null) { 
        $locks = Get-AzResourceLock -ResourceGroupName $ResourceGroupName
    }
    elseif ($Scope) {
        # Subscription level locks do not have a resource group name
        if ($useScope) {
            $locks = Get-AzResourceLock -Scope $Scope | Where-Object { $null -eq $_.ResourceGroupName }
        }
        else {
            # handling resource group locks.
            $locks = Get-AzResourceLock -Scope $Scope
        }
    }
    else {
        throw "Either ResourceGroupName or Scope must be defined."
    }
    if (!$AllLockLevels.IsPresent) {
        # Filter out everything but delete locks from the result.
        $locks = $locks | Where-Object { $_.Properties.level -eq "CanNotDelete" }
    }
    return $locks
}

<#
.SYNOPSIS
    Tries to retrieve the management key vault for the current CAF context.
.DESCRIPTION
    Because the actual name of a vault depends on naming length limits this function will
    search for a key vault which exists in the 'rg-management' resource group. This is the
    one which by default is used to store service principal secrets. This cmdlet will
    throw an exception if no key vault was resolved and NoException is NOT set.
.PARAMETER ScopeType
    Defines in which scope type (subscription or management group) the key vault should be searched.
.PARAMETER ScopeName
    The name of the scope to create the service principal for. Also known as the project name. It is used to deduce naming conventions.
.PARAMETER NoException
    If set no exception will be thrown if no key vault could be resolved.
.EXAMPLE
    Get-ManagementKeyVault -ScopeType ManagementGroup -ScopeName "Project"
#>

function Get-ManagementKeyVault {
    # Parameter help description
    [CmdletBinding()]
    param (
        [ValidateSet('ManagementGroup', 'Subscription')]
        [string]
        $ScopeType = 'Subscription',
        [string]
        [Parameter(Mandatory = $true)]
        $ScopeName,
        [switch]
        $NoException
    )
    process {
        $ctx = Get-CafContext
        $keyVaultResourceGroupName = Format-NamingConvention -Context $ctx -Type "resourceGroup" -SubType "managementResourceGroup" -ProjectName $ScopeName
        Write-VerboseOnly "Searching for key vault in resource group '$keyVaultResourceGroupName'."
        if ($ScopeType -eq 'Subscription') {
            # Build the key vault name from the naming convention
            $vaultName = Format-NamingConvention -Context $ctx -Type "resource" -SubType "keyVault" -ProjectName $ScopeName
            Write-VerboseOnly "Searching for subsciption management key vault '$vaultName'."
            $keyVault = Get-AzKeyVault -VaultName $vaultName -ResourceGroupName $keyVaultResourceGroupName -ErrorAction SilentlyContinue
        }
        else {
            # the scope is ManagementGroup
            # Build the IAM key vault name from the naming convention
            $vaultName = Format-NamingConvention -Context $ctx -Type "resource" -SubType "iamKeyVault" -ProjectName $ScopeName
            Write-VerboseOnly "Searching for IAM key vault '$vaultName'."
            $iamSubscriptionName = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "iamSubscription" -ProjectName $ScopeName
            $iamSubscription = Get-AzSubscription -TenantId $ctx.tenantId -SubscriptionName $iamSubscriptionName
            # Switch to the IAM subscription bevause -SubscriptionId is not working on Get-AzKeyVault
            Write-HostDebug "Switching to subscription '$($iamSubscription.Id)' to search for IAM key vault."
            Set-AzContext -Subscription $iamSubscription.Id -Tenant $ctx.tenantId
            $keyVault = Get-AzKeyVault -VaultName $vaultName -SubscriptionId $iamSubscription.Id -ResourceGroupName $keyVaultResourceGroupName -ErrorAction SilentlyContinue
            # Go back to the original context
            Use-CafContext
        }
        if (!$keyVault -and !$NoException.IsPresent) {
            throw "Key Vault '$vaultName' in '$keyVaultResourceGroupName' not found. Maybe subscription in not CAF initialized or the naming conventions are not correct."
        }
        Write-VerboseOnly "Using key vault '$($keyVault.VaultName)' derived from scope '$ScopeType'"
        return $keyVault
    }
}

<#
.SYNOPSIS
    Initializes the subscription management resources.
.DESCRIPTION
    Deploys the subscription management resources by using the Bicep deployment technology.
.PARAMETER SubscriptionId
    The subscription id to use for the deployment.
.PARAMETER SubscriptionName
    The subscription name to use for the deployment. If not specified, the subscription name is retrieved from Azure.
.PARAMETER WhatIf
    If specified, the deployment is only simulated.
.PARAMETER RemoveArtifacts
    If specified, the downloaded assets are removed after the deployment.
.PARAMETER ForceAssetDownload
    If specified, the assets are downloaded again even if they already exist.
.Parameter BicepSettingsPath
    The path to the Bicep settings file.
.PARAMETER BicepDeploymentFileName
    The name of the Bicep deployment file.
.PARAMETER BicepDeploymentParameterFileName
    The name of the Bicep parameter file.
.PARAMETER Regex
    The regex used to replace the default place holder [projectName].
.EXAMPLE
    Initialize-SubscriptionUsingBicep `
        -SubscriptionId "00000000-0000-0000-0000-000000000000" `
        -SubscriptionName "lz-companyshort-projectname" `
        -WhatIf
#>

function Initialize-SubscriptionUsingBicep {
    [CmdLetBinding()]
    param (
        [string]
        $BicepSettingsPath = "",
        [String]
        $SubscriptionId = "",
        [String]
        $SubscriptionName = "",
        [string]
        $BicepDeploymentFileName = "main.bicep",
        [string]
        $BicepDeploymentParameterFileName = "main.bicepparam",
        [switch]
        $WhatIf,
        [switch]
        $ForceAssetDownload,
        [switch]
        $RemoveArtifacts
    )
    $ErrorActionPreference = 'Stop'
    $nugetCliInstalled = Get-Command nuget -ErrorAction SilentlyContinue
    if (!$nugetCliInstalled) {
        throw "Nuget CLI was not found in the current PowerShell session. Ensure that it is installed!"
    }
    $bicepCliInstalled = Get-Command bicep -ErrorAction SilentlyContinue
    if (!$bicepCliInstalled) {
        throw "Bicep CLI was not found in the current PowerShell session. Ensure that it is installed!"
    }
    $ctx = Use-CafContext
    $deploymentTechType = "bicep"
    # Build the subscription(landing zone) name from the az context naming convention
    $lzSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone" -ProjectName ".*"
    if (!$lzSubscriptionNameRegex) {
        throw "Failed to get the naming convention for the landing zone subscription."
    }
    # Build the IAM subsciption name from the az context naming convention
    $iamSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "iamSubscription" -ProjectName ".*"
    if (!$iamSubscriptionNameRegex) {
        throw "Failed to get the naming convention for the IAM subscription."
    }
    # if the $SubscriptionId is not set, get the subscription id from .azcontext
    $SubscriptionId = $SubscriptionId.Length -gt 0 ? $SubscriptionId : $ctx.SubscriptionId
    if ($ctx.subscriptionsToIgnore -and $ctx.subscriptionsToIgnore.Contains($SubscriptionId)) {
        # the resolved subscription is on the ignore list
        Write-HostWarning "Subscription is on ignore list...Skipping."
        return
    }
    Write-VerboseOnly "Using subscription '$SubscriptionId'."
    # perform validity checks
    if ($BicepSettingsPath.Length -eq 0) {
        # collect all files starting with the current path working up
        Write-VerboseOnly "Searching for Bicep settings from $PWD up..."
        # find the bicep settings file
        $BicepSettingsPath = Find-FilesByNameUp -FileName 'bicepSettings.json' -ReturnFirstMatch
        if ($BicepSettingsPath.Length -eq 0) {
            throw "No 'bicepSettings.json' was found in this or any parent directory. Hint: Use the -BicepSettingsPath parameter to specify the path to the Bicep settings file or navigate to the infrastructure project directory."
        }
    }
    if (!(Test-Path $BicepSettingsPath)) {
        throw "Could not find Bicep settings at '$BicepSettingsPath'."
    }
    else {
        Write-VerboseOnly "Using Bicep settings from '$BicepSettingsPath'."
    }
    # Get the path to the system's temporary directory
    $tempPath = [System.IO.Path]::GetTempPath()
    # Concatenate the desired directory name
    $assetDownloadPath = Join-Path $tempPath "devdeer.caf/assets"
    $assetsFound = Test-Path $assetDownloadPath
    if ($ForceAssetDownload.IsPresent -and $assetsFound) {
        # Caller wants us to download a freah version of the assets no matter what!
        Write-VerboseOnly "Removing assets at '$assetDownloadPath'."
        Remove-Item -Force -Recurse $assetDownloadPath
        $assetsFound = $false
    }
    if (!$assetsFound) {
        # we need to download the assets!
        Write-VerboseOnly "Downloading '$deploymentTechType' assets to '$assetDownloadPath'."
        Install-CafAssets -DestinationPath $assetDownloadPath -DeploymentTechType $deploymentTechType
        $configPath = Join-Path $assetDownloadPath "bicep" "bicepSettings.json"
        Copy-Item $BicepSettingsPath $configPath
        Write-VerboseOnly "Applied Bicep settings to file '$configPath'."
    }
    else {
        # the assets folder already exists
        Write-VerboseOnly "Skipping download of assets because path '$assetDownloadPath' already exists."
    }
    # check if modules are installed
    if (!(Test-Path "$assetDownloadPath/$deploymentTechType/modules")) {
        # Execute module installer in downloaded location
        Write-HostInfo "Installing DEVDEER Bicep modules..."
        & "$assetDownloadPath/$deploymentTechType/install-modules.ps1" | Out-Null
    }
    # build the target path for subscription initialization bicep files
    $subscriptionBicepPath = Join-Path $assetDownloadPath "$deploymentTechType/management-resources/landing-zone"
    # get the deployment template file
    $deploymentPath = Join-Path $subscriptionBicepPath $BicepDeploymentFileName
    if (!(Test-Path $deploymentPath)) {
        throw "No deployment template found in '$subscriptionBicepPath'."
    }
    # get the parameter file
    $templateParameterFile = Join-Path $subscriptionBicepPath $BicepDeploymentParameterFileName
    if (!(Test-Path $templateParameterFile)) {
        throw "No parameter file found in '$subscriptionBicepPath'."
    }
    # retreive the location value form the .bicepparam file
    bicep build-params $templateParameterFile
    if (!$?) {
        throw "Install or upgrade the Bicep CLI to the latest version."
    }
    # retreive the name of the .bicepparam file from the $templateParameterFile variable
    $jsonParameterFilename = $templateParameterFile -replace ".bicepparam", ".json"
    # retreive the location value from the .json file
    $location = (Get-Content $jsonParameterFilename | convertfrom-json).parameters.location.value
    if (!$location) {
        throw "Location parameter not found in '$jsonParameterFilename'."
    }
    # load subscription from azure and check if it is enabled
    $subscription = Get-AzSubscription -TenantId $ctx.tenantId -SubscriptionId $SubscriptionId
    if ($subscription.State -ne 'Enabled') {
        Write-HostWarning "Subscription is disabled...Skipping."
        return
    }
    # subscription found and not disabled
    if (!$SubscriptionName) {
        # if no subscription name is specified, retrieve it from Azure
        $SubscriptionName = $subscription.Name
    }
    # We use Format-NamingConvention to build the project name pattern and try to find the index of the element "[x]" in the pattern. This gives us
    # the position of the real project name inside the given subscription name.
    $sep = $ctx.namingConvention.subscription.landingZone.separator
    $regex = $sep + "?([^-]*)" + $sep + "?"
    $namePattern = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone"
    # NOTE: We are doing the conversion into a .NET list because this gives us the FindIndex method which we need to find the index of the project name in the subscription name.
    $offset = ([Collections.Generic.List[Object]](($namePattern | Select-String -Pattern $regex -AllMatches).Matches)).FindIndex({ $args[0].Groups[1].Value -eq "[x]" })
    $projectName = $SubscriptionName.Split($sep)[$offset]
    if ($projectName.Length -eq 0) {
        throw "Could not deduce project name from subscription name '$SubscriptionName'."
    }
    Write-VerboseOnly "Deduced project name for Bicep deployment: $projectName"
    $dateSuffix = Get-Date -Format "yyyy-dd-MM-HH-mm"
    $deploymentName = "deploy-$dateSuffix"
    Write-HostInfo "Processing subscription '$SubscriptionName'..."
    # check if the subscription name conforms to the naming convention lzSubscriptioName and iamSubscriptionName. return if it does not.
    if ($SubscriptionName -notmatch $lzSubscriptionNameRegex -and $SubscriptionName -notmatch $iamSubscriptionNameRegex) {
        Write-HostWarning "Subscription name '$SubscriptionName' does not conform with subscription naming conventions '$lzSubscriptionNameRegex' or '$iamSubscriptionNameRegex'...Skipping."
        return
    }
    # if the subscription name conforms to the naming convention for a IAM subscription perform a special deployment
    if ($SubscriptionName -match $iamSubscriptionNameRegex) {
        # This means that we are dealing with a special IAM subscription
        # build the target path for management subscription initialization bicep files
        $mgmtSubscriptionBicepPath = Join-Path $assetDownloadPath "$deploymentTechType/management-resources/management-group"
        # get the deployment template file
        $deploymentPath = Join-Path $mgmtSubscriptionBicepPath $BicepDeploymentFileName
        if (!(Test-Path $deploymentPath)) {
            throw "No deployment template found in '$mgmtSubscriptionBicepPath'."
        }
        # get the parameter file
        $templateParameterFile = Join-Path $mgmtSubscriptionBicepPath $BicepDeploymentParameterFileName
        if (!(Test-Path $templateParameterFile)) {
            throw "No parameter file found in '$mgmtSubscriptionBicepPath'."
        }
        # retreive the location value form the main.bicepparam file
        bicep build-params $templateParameterFile
        if (!$?) {
            throw " Unexpected error while executing Bicep CLI."
        }
        # retreive the name of the .bicepparam file from the $templateParameterFile variable
        $jsonParameterFilename = $templateParameterFile -replace ".bicepparam", ".json"
        # retreive the location value from the .json file
        $location = (Get-Content $jsonParameterFilename | ConvertFrom-Json).parameters.location.value
        # Get the project name and append the iam suffix
        $projectName = $projectName + "-iam"
    }
    # print all the collected parameters for debugging
    Write-VerboseOnly "SubscriptionId:`t$SubscriptionId`nSubscription:`t$SubscriptionName`nProjectName:`t$projectName"
    # switch to the target subscription and tenant
    Set-AzContext -Tenant $ctx.tenantId -SubscriptionId $SubscriptionId | Out-Null
    Write-VerboseOnly "Executing deployment with file '$deploymentPath' and parameters '$templateParameterFile'."
    try {
        New-AzDeployment `
            -Name $deploymentName `
            -Location $location `
            -TemplateFile $deploymentPath `
            -TemplateParameterFile $templateParameterFile `
            -projectName $projectName `
            -DeploymentDebugLogLevel None `
            -WhatIf:$WhatIf
    }
    catch {
        # catch any errors and print them
        Write-HostError "Error during deployment: $_"
        return
    }
    if ($RemoveArtifacts.IsPresent) {
        Write-HostInfo "Deleting artifacts at '$assetDownloadPath'."
        Remove-Item $assetDownloadPath -Force -Recurse | Out-Null
    }
}


<#
.SYNOPSIS
    Initializes the subscription management resources for a single subscription.
.DESCRIPTION
    Deploys the subscription management resources to a single subscription using the terraform deployment technology.
.PARAMETER SubscriptionId
    The subscription id to use for the deployment.
.PARAMETER SubscriptionName
    The subscription name to use for the deployment. If not specified, the subscription name is retrieved from Azure.
.PARAMETER WhatIf
    If specified, the deployment is only simulated.
.PARAMETER RemoveArtifacts
    If specified, the downloaded assets are removed after the deployment.
.PARAMETER ForceAssetDownload
    If specified, the assets are downloaded again even if they already exist.
.PARAMETER TerraformFileName
    The name of the Terraform deployment file. Default is 'main.tf'.
.PARAMETER KeyVaultName
    The name of the key vault that contains the service principal client id, secret and the storage account access key.
.EXAMPLE
    Initialize-SubscriptionUsingTerraform `
        -SubscriptionId "00000000-0000-0000-0000-000000000000" `
        -SubscriptionName "lz-companyshort-projectname" `
        -WhatIf
#>

function Initialize-SubscriptionUsingTerraform {
    [CmdLetBinding()]
    param (
        [String]
        $SubscriptionId,
        [String]
        $SubscriptionName,
        [string]
        $TerraformFileName = "main.tf",
        [string]
        $KeyVaultName,
        [Boolean]
        $RemoveArtifacts = $false,
        [switch]
        $ForceAssetDownload,
        [switch]
        $WhatIf
    )
    $ErrorActionPreference = 'Stop'
    $ctx = Use-CafContext
    $deploymentTechType = "terraform"
    # Build the subscription(landing zone) name from the az context naming convention
    $lzSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone" -ProjectName ".*"
    if (!$lzSubscriptionNameRegex) {
        throw "Failed to get the naming convention for the landing zone subscription."
    }
    # Build the IAM subsciption name from the az context naming convention
    $iamSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "iamSubscription" -ProjectName ".*"
    if (!$iamSubscriptionNameRegex) {
        throw "Failed to get the naming convention for the IAM subscription."
    }
    # if the $SubscriptionId is not set, get the subscription id from Azure
    $SubscriptionId = $SubscriptionId.Length -gt 0 ? $SubscriptionId : $ctx.SubscriptionId
    # Check if the subscription is on the ignore list
    if ($ctx.subscriptionsToIgnore -and $ctx.subscriptionsToIgnore.Contains($SubscriptionId)) {
        Write-HostWarning "Subscription is on ignore list...Skipping."
        return
    }
    # if the $subscriptionName is not set, get the subscription name from Azure
    if ($SubscriptionName.Length -eq 0) {
        $subscription = Get-AzSubscription -TenantId $ctx.tenantId -SubscriptionId $SubscriptionId
        $SubscriptionName = $subscription.Name
    }
    Write-HostInfo "Initializing subscription '$SubscriptionName' with id '$SubscriptionId'."
    # load subscription from azure and check if it is enabled
    if ($subscription.State -ne 'Enabled') {
        Write-HostWarning "Subscription is disabled...Skipping."
        return
    }
    # check if the subscription name conforms to the naming convention.
    if ($SubscriptionName -notmatch $lzSubscriptionNameRegex -and $SubscriptionName -notmatch $iamSubscriptionNameRegex) {
        Write-HostWarning "Subscription name '$SubscriptionName' does not conform with subscription naming conventions '$lzSubscriptionNameRegex' or '$iamSubscriptionNameRegex'...Skipping."
        return
    }
    # Subscription name format is a match and its not on the ignore list. continue with the deployment.
    # Should use a service principal for authentication.
    Set-TerraformEnvironmentVariables -KeyVaultName $KeyVaultName -SubscriptionId $SubscriptionId
    if (!$?) {
        Write-HostError "Failed to set the environment variables required for the terraform deployment."
        Clear-TerraformEnvironmentVariables
        return
    }
    # Get the path to the system's temporary directory
    $tempPath = [System.IO.Path]::GetTempPath()
    # Concatenate the desired directory name
    $assetDownloadPath = Join-Path $tempPath "devdeer.caf/assets"
    $assetsFound = Test-Path $assetDownloadPath
    if ($ForceAssetDownload.IsPresent -and $assetsFound) {
        # Caller wants us to download a freah version of the assets no matter what!
        Write-VerboseOnly "Removing assets at '$assetDownloadPath'."
        Remove-Item -Force -Recurse $assetDownloadPath
        $assetsFound = $false
    }
    if (!$assetsFound) {
        # we need to download the assets!
        Write-VerboseOnly -Message "Downloading '$deploymentTechType' assets to '$assetDownloadPath'."
        Install-CafAssets -DestinationPath $assetDownloadPath -DeploymentTechType $deploymentTechType
    }
    else {
        # the assets folder already exists
        Write-VerboseOnly "Skipping download of assets because path '$assetDownloadPath' already exists."
    }    
    # We use Format-NamingConvention to build the project name pattern and try to find the index of the element "[x]" in the pattern. This gives us
    # the position of the real project name inside the given subscription name.
    $sep = $ctx.namingConvention.subscription.landingZone.separator
    $regex = $sep + "?([^-]*)" + $sep + "?"
    $namePattern = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone"
    # NOTE: We are doing the conversion into a .NET list because this gives us the FindIndex method which we need to find the index of the project name in the subscription name.
    $offset = ([Collections.Generic.List[Object]](($namePattern | Select-String -Pattern $regex -AllMatches).Matches)).FindIndex({ $args[0].Groups[1].Value -eq "[x]" })
    $projectName = $SubscriptionName.Split($sep)[$offset]
    if ($projectName.Length -eq 0) {
        throw "Could not deduce project name from subscription name '$SubscriptionName'."  
    }
    Write-VerboseOnly "Deduced project name for infrastrucutre deployment by terraform: $projectName"
    # When subscription is a landing zone subscription
    if ($SubscriptionName -match $lzSubscriptionNameRegex) {
        # build the target path for subscription initialization bicep files
        $managementTfAssets = Join-Path $assetDownloadPath "$deploymentTechType/management-resources/landing-zone"
        Write-VerboseOnly "Using the landing zone infra assets in '$managementTfAssets'."
    }
    # When subscription is an IAM subscription
    elseif ($SubscriptionName -match $iamSubscriptionNameRegex) {
        $managementTfAssets = Join-Path $assetDownloadPath "$deploymentTechType/management-resources/management-group"
        Write-VerboseOnly "Using the management group infra assets in '$managementTfAssets'."
    }
    else {
        throw "Could not determine the subscription type from the subscription name '$SubscriptionName'."
    }
    # Call the Invoke-TerraformOperations function to validate, plan and apply the $terraformFilePath
    Invoke-TerraformOperations -SubscriptionId $SubscriptionId -ProjectName $projectName -DestinationPath $managementTfAssets -TerraformFileName $TerraformFileName -WhatIf:$WhatIf
    if (!$?) {
        Clear-TerraformEnvironmentVariables
        throw "Failed to initialize the subscription '$SubscriptionName' with id '$SubscriptionId'."
    }
    # Delete all the the environment variables after the deployment
    Clear-TerraformEnvironmentVariables
    if (!$?) {
        throw "Failed to clean up the environment variables."
    }
    # Delete the terraform artifacts after the deployment
    if ($RemoveArtifacts.IsPresent) {
        Write-VerboseOnly "Deleting artifacts at '$assetDownloadPath'."
        Write-HostInfo "Deleting artifacts at '$assetDownloadPath'."
        Remove-Item $assetDownloadPath -Force -Recurse | Out-Null
    }
}

<#
 .Synopsis
    Initialize-CafSubscription calls this implicitly with the correct parameters.
 .Description
    Installs the management-resources folder to the root of the infrastructure folder.
 .Parameter DestinationPath
    The path to the root folder infrastrucutre files.
 .Parameter DeploymentTechType
    The deployment technology to use for the deployment. Default is 'bicep'.
 .Example
    Install-CafAssets -DestinationPath "(projectName)/infrastrucutre" -DeploymentTechType "bicep"
#>

function Install-CafAssets {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $DestinationPath,
        [string]
        [validateSet("bicep", "terraform")]
        $DeploymentTechType = "bicep"
    )
    process {
        $cafAssetPath = "$DestinationPath"
        # remove management-resources folder if it exists
        if (Test-Path -Path $cafAssetPath) {
            Write-Host "Removing old CAF infrastructure assets...." -ForegroundColor Yellow
            Remove-Item -Path $cafAssetPath -Recurse | Out-Null
            New-Item -Path $cafAssetPath -ItemType Directory | Out-Null
        }
        # create the directory if it does not exist
        else {
            New-Item -Path $cafAssetPath -ItemType Directory | Out-Null
        }
        # Define the URL of the GitHub repository zip
        $zipUrl = "https://github.com/DEVDEER/CafAssets/archive/refs/heads/main.zip"
        # Define the path to download the zip file
        $zipFile = "$cafAssetPath/cafAssets.zip"
        # Download the zip file from the GitHub repository
        Invoke-WebRequest $zipUrl -OutFile $zipFile | Out-Null
        if (!$?) {
            throw "Failed to download the infrastrucutre assets"
        }
        # Extract the zip file
        Expand-Archive -Path $zipFile -DestinationPath $cafAssetPath -Force | Out-Null
        if (!$?) {
            throw "Failed to extract the infrastrucutre assets zip file"
        }
        Write-HostSuccess "Installed CAF infrastrucutre assets for $DeploymentTechType technology."
        # Move the management-resources folder to the root directory
        Move-Item -Path "$cafAssetPath/CafAssets-main/infrastructure/$DeploymentTechType" -Destination $cafAssetPath | Out-Null
        # Remove the downloaded zip file and the extracted repository folder
        Remove-Item -Path $zipFile -Force | Out-Null
        Remove-Item -Path "$cafAssetPath/CafAssets-main" -Recurse -Force | Out-Null
    }
}

<#
 .Synopsis
    Applies the given Transformer to the given Value.
 .Description
    If an invalid Transformer is passed the result will be the Value.
 .Parameter Value
    The path to the root folder infrastrucutre files.
 .Parameter Transformer
    A string which represents the operation to be executed on the Value. Supported values are:
    - uppercase
    - lowercase
 .Example
    Invoke-NamingTransformer -Value hello -Transfomer 'uppercase'
#>

function Invoke-NamingTransformer {
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Value,
        [string]
        $Transformer
    )
    if ($Transformer -eq 'uppercase') {
        return $Value.ToUpperInvariant()
    }
    elseif ($Transformer -eq 'lowercase') {
        return $Value.ToLowerInvariant()
    }
    return $Value
}

<#
 .Synopsis
    Runs the terraform commands on the main.tf file.
 .Description
    This function builds and runs the terraform commands on the main.tf file. The terraform commands are:
    - terraform fmt
    - terraform init
    - terraform validate
    - terraform plan
    - terraform apply
 .Parameter DestinationPath
    The path to the directory where the main.tf file is located.
 .Parameter SubscriptionId
    The subscription id to use for the deployment.
 .Parameter ProjectName
    The name of the project to use for the deployment.
 .Parameter TerraformFileName
    The name of the terraform file. Default is 'main.tf'.
 .Parameter WhatIf
    If specified, the deployment is only simulated.
 .Example
    Invoke-TerraformOperations -DestinationPath "C:\path\to\main.tf" -SubscriptionId "00000000-0000-0000-0000-000000000000" -ProjectName "projectName" -WhatIf
#>

function Invoke-TerraformOperations {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $DestinationPath,
        [Parameter(Mandatory = $true)]
        [string]
        $SubscriptionId,
        [string]
        $ProjectName,
        [string]    
        $TerraformFileName = "main.tf",
        [switch]
        $WhatIf
    )
    process {
        $ErrorActionPreference = 'Stop'
        # retrieve the company short from the context
        $ctx = Get-CafContext
        $companyShort = $ctx.companyShort
        if (!$companyShort) {
            throw "Failed to get the company short from the azContext."
        }
        # Define the backend configuration for the terraform file
        $resourceGroupName = "rg-infrastructure-management"
        $storageAccountName = "stocafinfrastate"
        $containerName = "tfstate"
        # Define the path to the main.tf file
        $TerraformFilePath = Join-Path -Path $DestinationPath -ChildPath $TerraformFileName 
        # Check if the terraform file exists in the destination path
        if (!(Test-Path $TerraformFilePath)) {
            Write-HostError "The terraform file '$TerraformFileName' does not exist in the destination path '$DestinationPath'."
            return
        }
        Write-VerboseOnly "Terraform file '$TerraformFileName' found in the destination path '$DestinationPath'."
        try {
            # Run terraform fmt command on the terraform file
            Write-HostSuccess "Formatting the terraform file '$TerraformFileName'."
            terraform -chdir="$DestinationPath" fmt
            if (!$?) {
                Write-HostError "Failed to format the terraform file '$TerraformFileName'."
                Clear-TerraformEnvironmentVariables
                return
            }
            # Run terraform init command
            Write-HostSuccess "Running terraform init command..."
            # terraform init the main.tf file with project added to the name of the storage account blob
            terraform -chdir="$DestinationPath" init -input=false -backend-config="key=$SubscriptionId.tfstate" -backend-config="resource_group_name=$resourceGroupName" -backend-config="storage_account_name=$storageAccountName" -backend-config="container_name=$containerName"
            if (!$?) {
                Clear-TerraformEnvironmentVariables
                Write-HostError "Failed to initialize the terraform file '$TerraformFileName'."
                return
            }
            # terraform validate the main.tf file
            Write-HostSuccess "Validating the terraform file $TerraformFileName..."
            terraform -chdir="$DestinationPath" validate
            if (!$?) {
                Clear-TerraformEnvironmentVariables
                Write-HostError "Failed to validate the terraform file '$TerraformFileName'."
                return
            }
            # terraform plan the main.tf file
            Write-HostSuccess "Planning the terraform file '$TerraformFileName'..."
            terraform -chdir="$DestinationPath" plan -var projectName=$ProjectName -var companyShort="$companyShort" -out=tfplan -input=false
            if (!$?) {
                Clear-TerraformEnvironmentVariables
                Write-HostError "Failed to plan the terraform file '$TerraformFileName'."
                return
            }
            # terraform apply the $TerraformFile (only perform this when the -WhatIf flag is not set)
            if (!$WhatIf) {
                Write-HostSuccess "Applying the terraform file '$TerraformFileName'..."
                terraform -chdir="$DestinationPath" apply -input=false tfplan
                if (!$?) {
                    Clear-TerraformEnvironmentVariables
                    Write-HostError "Failed to apply the terraform file '$TerraformFileName'."
                    return
                }
            }
        }
        catch {
            Write-HostError $_.Exception.Message
            Clear-TerraformEnvironmentVariables
        }
        finally {
            # Clean up local lock files and .terraform folders
            Write-HostInfo "Deleting local terraform support artifacts..."
            Remove-Item -Recurse -Force "$DestinationPath/.terraform"
            Remove-Item -Force "$DestinationPath/.terraform.lock.hcl"
            Remove-Item -Force "$DestinationPath/tfplan"
        }
    }
        
}


<#
 .Synopsis
    Taken from https://www.powershellgallery.com/packages/PSDataKit/1.0.1/Content/functions%5CMerge-Hashtables.ps1
    Merges the given BaseTable hashtable with the TableToMerge on the first level.
 .Description
    This is NOT working recursively!
    This was taken from https://stackoverflow.com/questions/8800375/merging-hashtables-in-powershell-how.
 .Parameter BaseTable
    The hashtable which holds the default values.
 .Parameter TableToMerge
    The hashtable which should be merged into the BaseTable.
 .Example
    $defaults = @{
        enable32BitAppOnWin64 = $false;
        runtime = "v4.0";
        pipeline = 1;
        idleTimeout = "1.00:00:00";
    }
    $options1 = @{ pipeline = 0; }
    Merge-HashTable -BaseTable $defaults -TableToMerge $options1
#>

function Merge-Hashtables {

    [CmdletBinding()]
    [OutputType([hashtable])]
    Param
    (
        [Parameter(Mandatory = $true)]
        [hashtable] $BaseTable,

        [Parameter(Mandatory = $true)]
        [hashtable] $TableToMerge,

        [Parameter(Mandatory = $false)]
        $paths = @()
    )
    # the result is a copy of the base table
    if ($paths) {
        foreach ($path in $paths) {
            if ($TableToMerge[$path]) {
                $BaseTable = $BaseTable[$path]
                $TableToMerge = $TableToMerge[$path]
            }
        }
    }
    $result = $BaseTable.Clone()
    foreach ($key in $TableToMerge.Keys) {
        $valueType = $TableToMerge[$key].GetType().Name
        if ($valueType -eq 'Hashtable') {
            $paths += $key
            $result[$key] = Merge-Hashtables $BaseTable $TableToMerge $paths
        }
        else {
            $result[$key] = $TableToMerge[$key]
        }
    }
    return $result
}

<#
 .Synopsis
    Sets the environment variables used while performing a terraform deployment.
 .Description
    This function sets the environment variables ARM_CLIENT_ID and ARM_CLIENT_SECRET by reading the information from the central key vault.
 .PARAMETER KeyVaultName
    The name of the key vault where the service principal is stored.
 .PARAMETER SpId
    The name of the secret in the key vault that contains the service principal id. Default is TerraformClientSpId.
 .PARAMETER SpSecret
    The name of the secret in the key vault that contains the service principal password. Default is TerraformClientSpPassword.
.Parameter SubscriptionId
    The subscription id to use for the deployment. Is mandatory and passed in by the Initialize-SubscriptionUsingTerraform command.
.PARAMETER TfStorageAccountKeyName
    The name of the secret in the key vault that contains the storage account access key. Default is tfStorageAccountAccessKey.
 .Example
    Set-TerraformEnvironmentVariables -SubscriptionId "00000000-0000-0000-0000-000000000000" -KeyVaultName "lz-companyshort-projectname"
#>

function Set-TerraformEnvironmentVariables {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $SubscriptionId,
        [string]
        $KeyVaultName,
        [string]
        $SpId = "TerraformClientSpId",
        [string]
        $SpSecret = "TerraformClientSpPassword",
        [string]
        $TfStorageAccountKeyName = "tfStorageAccountAccessKey"
    )
    process {
        $ErrorActionPreference = 'Stop'
        $ctx = Get-CafContext
        # if the key vault name is not provided, use the naming convention to build the key vault name
        if (!$KeyVaultName) {
            # TODO parametrizxe the project name. or always keep it constant?
            $KeyVaultName = Format-NamingConvention -Context $ctx -Type "resource" -SubType "infraKeyVault"
            if ($KeyVaultName.Length -eq 0) {
                throw "Failed to get the naming convention for the terraform key vault. Key vault name is required."
            }
        }
        # Ensure that such a key vault exists
        $kvNameQuery = "Resources | where type == 'microsoft.keyvault/vaults' | where name == '$KeyVaultName'"
        $kvResult = Search-AzGraph -Query $kvNameQuery
        # If the key vault does not exist, return
        if (!$kvResult) {
            Write-HostError "Key Vault '$KeyVaultName' does not exist."
        }
        # Set the environment variables required for the terraform deployment
        $env:ARM_CLIENT_ID = (Get-AzKeyVaultSecret -VaultName $KeyVaultName -SecretName $SpId -AsPlainText)
        $armClientId = $env:ARM_CLIENT_ID
        # if arm client id is not set, return
        if ($armClientId.Length -eq 0) {
            Write-HostError "Could not read the service principal id name: '$SpId' from Key Vault: '$KeyVaultName'."
            Clear-TerraformEnvironmentVariables
            return
        }
        # Set the environment variable for the service principal password
        $env:ARM_CLIENT_SECRET = (Get-AzKeyVaultSecret -VaultName $KeyVaultName -SecretName $SpSecret -AsPlainText)
        $armClientSecret = $env:ARM_CLIENT_SECRET
        # if arm client secret is not set, return
        if ($armClientSecret.Length -eq 0) {
            Write-HostError "Could not read the service principal password name: '$SpSecret' from Key Vault: '$KeyVaultName'."
            Clear-TerraformEnvironmentVariables
            return
        }
        # Set the storage account key environment variable
        $env:ARM_ACCESS_KEY = (Get-AzKeyVaultSecret -VaultName $KeyVaultName -SecretName "$TfStorageAccountKeyName" -AsPlainText)
        $tfStorageAccountKey = $env:ARM_ACCESS_KEY
        # if storage account key is not set, return
        if ($tfStorageAccountKey.Length -eq 0) {
            Write-HostError "Could not read the storage account key with name: '$TfStorageAccountKeyName' from Key Vault: '$KeyVaultName'."
            Clear-TerraformEnvironmentVariables
            return
        }
        # Set the subscription id Environment variable
        # TODO: when all the subscriptions are initialized in a loop we only need to keep updating the subscription id and not all env variables.
        $env:ARM_SUBSCRIPTION_ID = $SubscriptionId
        # Set the tenant id Environment variable
        $env:ARM_TENANT_ID = $ctx.tenantId
        Write-HostSuccess "Environment variables for the service principal:'$armClientId' have been successfully set."
    }   
}

Function Write-HostMessage {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string] $Message,
        [Parameter(Mandatory = $false, Position = 1)]
        [ValidateSet('Error', 'Warning', 'Information', 'Verbose', 'Debug', 'Success')]
        [string] $Level = 'Information',
        [switch] $NoNewLine
    )
    begin {
        $color = (Get-Host).ui.rawui.ForegroundColor
        switch ($Level) {
            'Error' { $color = 'Red' }
            'Warning' { $color = 'Yellow' }
            'Information' { $color = 'White' }
            'Verbose' { $color = 'Cyan' }
            'Success' { $color = 'Green' }
            'Debug' { $color = 'DarkGray' }
            default { throw "Invalid log level: $_" }
        }
    }
    process {
        Write-Host $Message -ForegroundColor $color -NoNewline:$NoNewLine
    }
}

Function Write-HostError {
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string] $Message,
        [switch] $NoNewLine
    )
    process {
        Write-HostMessage -Message $Message -Level 'Error' -NoNewline:$NoNewLine
    }
}

Function Write-HostInfo {
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string] $Message,
        [switch] $NoNewLine
    )
    process {
        Write-HostMessage -Message $Message -Level 'Information' -NoNewline:$NoNewLine
    }
}

Function Write-HostDebug {
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string] $Message,
        [switch] $NoNewLine
    )
    process {
        Write-HostMessage -Message $Message -Level 'Debug' -NoNewline:$NoNewLine
    }
}

Function Write-HostWarning {
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string] $Message,
        [switch] $NoNewLine
    )
    process {
        Write-HostMessage -Message $Message -Level 'Warning' -NoNewline:$NoNewLine
    }
}

Function Write-HostSuccess {
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string] $Message,
        [switch] $NoNewLine
    )
    process {
        Write-HostMessage -Message $Message -Level 'Success' -NoNewline:$NoNewLine
    }
}

<#
 .Synopsis
 Writes the DEVDEER logo and module info to the output.
 .Description
 This writes a nice ASCII art logo and some module info to the host. You can set $env:NO_DEVDEER_CAF_LOGO to any value to prevent this from happening.
  .Example
  Write-Logo
#>

Function Write-Logo {
    [CmdLetBinding()]
    param (
    )
    process {
        if ($env:NO_DEVDEER_CAF_LOGO) {
            return
        }
        $set = Get-Variable DEVDEER_CAF_LOGO_WRITTEN -ErrorAction SilentlyContinue
        if ($set) {
            return
        }
        $module = Get-Module -Name Devdeer.Caf
        $moduleName = $module.Name
        $moduleVersion = $module.Version.ToString();
        $encoded = 'DQogICAgX19fXyAgX19fX19fXyAgICBfX19fX18gIF9fX19fX19fX19fX19fX18gDQogICAvIF9fIFwvIF9fX18vIHwgIC8gLyBfXyBcLyBfX19fLyBfX19fLyBfXyBcDQogIC8gLyAvIC8gX18vICB8IHwgLyAvIC8gLyAvIF9fLyAvIF9fLyAvIC9fLyAvDQogLyAvXy8gLyAvX19fICB8IHwvIC8gL18vIC8gL19fXy8gL19fXy8gXywgXy8gDQovX19fX18vX19fX18vICB8X19fL19fX19fL19fX19fL19fX19fL18vIHxffA0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIA=='
        $logo = [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($encoded))
        Write-Host $logo -ForegroundColor Blue
        Write-Host "Module $moduleName | Version $moduleVersion | DEVDEER GmbH | https://devdeer.com"
        Write-VerboseOnly $module.Description
        Write-Host
        Set-Variable DEVDEER_CAF_LOGO_WRITTEN YES -Scope Global
    }
}



<#
 .Synopsis
 Writes the given message to the host if verbose flag is set.
 .Description
 The message only is written if the calling function context was invoked
 using the PowerShell "-Verbose" switch.
 .Parameter Message
 The message to write to the host.
 .Example
  Write-VerboseOnly "Hello"
#>

function Write-VerboseOnly {
    [CmdLetBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Message,
        [switch]
        $NoNewline,
        [ConsoleColor]
        $ForegroundColor = [System.ConsoleColor]::Yellow
    )
    $verbose = $PSBoundParameters['Verbose'] -or $VerbosePreference -eq 'Continue'
    if ($verbose) {
        $Message = "VERBOSE: $Message"
        Write-Host $Message -ForegroundColor $ForegroundColor -NoNewline:$NoNewline
    }
}

Export-ModuleMember -Function Approve-PimRole
Export-ModuleMember -Function Clear-AllSqlFirewallRules
Export-ModuleMember -Function Clear-PolicyAssets
Export-ModuleMember -Function Deploy-PolicyAssets
Export-ModuleMember -Function Get-Context
Export-ModuleMember -Function Initialize-DeploymentSpGroup
Export-ModuleMember -Function Initialize-ServicePrincipals
Export-ModuleMember -Function Initialize-Subscription
Export-ModuleMember -Function Initialize-Subscriptions
Export-ModuleMember -Function New-Deployment
Export-ModuleMember -Function New-SqlFirewallRule
Export-ModuleMember -Function Remove-Locks
Export-ModuleMember -Function Remove-StaleRoleAssignments
Export-ModuleMember -Function Restore-Locks
Export-ModuleMember -Function Set-ServicePrincipal
Export-ModuleMember -Function Show-NamingConvention
Export-ModuleMember -Function Start-PimGroup
Export-ModuleMember -Function Start-PimRole
Export-ModuleMember -Function Start-Scoped
Export-ModuleMember -Function Stop-PimGroup
Export-ModuleMember -Function Stop-PimRole
Export-ModuleMember -Function Use-Context
Export-ModuleMember -Function Use-ServicePrincipal