Devdeer.Caf.psm1

<#
.SYNOPSIS
    Approve the user's PIM Role assignment request.
.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 firewall rules currently added to the SQL server given.
 .Description
 Removes all firewall rules currently added to the SQL server given.
 .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'"
        $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
        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 be of type '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 Azure context settings for 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 = New-Object Collections.Generic.List[String]
        $currentFolder = $PWD
        # collect all files starting with the current path working up
        while ($true) {
            $file = Join-Path $currentFolder '.azcontext'
            if (Test-Path $file) {
                $files.Add($file)
            }
            $currentFolder = Split-Path $currentFolder
            if (!$currentFolder) {
                break
            }
        }
        # 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"
        $hash = @{}
        $isRoot = $false
        foreach ($file in $files) {
            Write-VerboseOnly "Found context file $file"
            $json = Get-Content -Raw $file | ConvertFrom-Json
            $fileHash = ConvertTo-Hashtable -InputObject $json
            foreach ($key in $($fileHash.Keys)) {
                if (!$hash[$key]) {
                    # key does not exist yet, so add it
                    $hash[$key] = $fileHash[$key]
                }
                if ($key -eq "isRoot" -and $fileHash[$key]) {
                    # this is the file where inheritance upwards the folder
                    # structure should end
                    $isRoot = $true
                    $hash["rootPath"] = Split-Path $file
                }
            }
            if ($isRoot) {
                # don't go further down the tree
                break
            }
        }
        return $hash
    }
}


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

function Initialize-DeploymentSpGroup {
    [CmdLetBinding()]
    param (
    )
    process {
        $ErrorActionPreference = 'Stop'
        $ctx = Use-CafContext
        if (!$ctx.managementSubscriptionId) {
            throw "Management subscription not defined in .azcontext"
        }
        $scopeResourceId = "/subscriptions/$($ctx.managementSubscriptionId)"
        Write-VerboseOnly "Using subscription scope $scopeResourceId for assigning log analytics roles..."
        # Get all matching deploy SPs
        $servicePrincipals = Get-AzADServicePrincipal | Where-Object { $_.DisplayName -match '^sp-.*deploy$' }
        if (!$?) {
            throw "Could not query service principals."
        }
        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 = "AZ-CAF-DeployPrincipals"
        $securityGroup = Get-AzADGroup -DisplayName $securityGroupName -ErrorAction SilentlyContinue
        if (!$securityGroup) {
            $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 trie 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 required role at scope $scopeResourceId for LAW."
        }
    }
}

<#
.SYNOPSIS
    Initializes the default service principals in all 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.
.EXAMPLE
    Initialize-CafServicePrincipals
#>

function Initialize-ServicePrincipals {
    [CmdLetBinding()]
    param (
        [switch]
        $DoNotEnsureDeployGroup
    )
    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 }
        $subscriptionsCount = ($subscriptions | Measure-Object).Count
        $i = 0
        foreach ($subscription in $subscriptions) {
            $i++
            $progress = [Math]::Round($i * 100 / $subscriptionsCount, 0)
            Write-Progress -Activity "Handling subscriptions" -Status "$progress%" -PercentComplete $progress
            $subscriptionName = $subscription.Name
            $subscriptionId = $subscription.Id
            if (!$subscriptionName || !$subscriptionName.StartsWith("lz-$($ctx.companyShort)-")) {
                # skip subscriptions that are not part of the landing zone
                Write-Information "Skipping subscription $subscriptionName" -ForegroundColor Yellow
                continue
            }
            if ($ctx.subscriptionsToIgnore -and $ctx.subscriptionsToIgnore.Contains($subscriptionId)) {
                Write-Host "Subscription is on ignore list...Skipping." -ForegroundColor Yellow
                continue
            }
            # Get the project name from the subscription name - this assumes the name "lz-<companyshort>-<projectname>"
            $projectName = $subscriptionName.split("-")[-1]
            # create service principals for devops and operations
            Set-CafServicePrincipal `
                -ScopeType "Subscription" `
                -ScopeName $projectName `
                -ScopeId "/subscriptions/$($subscription.Id)" `
                -Role "Owner" `
                -Suffix "deploy" `
                -SubscriptionId $subscription.Id
            Set-CafServicePrincipal `
                -ScopeType "Subscription" `
                -ScopeName $projectName `
                -ScopeId "/subscriptions/$($subscription.Id)" `
                -Role "Contributor" `
                -Suffix "ops" `
                -SubscriptionId $subscription.Id
        }
        # retrieve all management groups in the tenant
        $managementGroups = Get-AzManagementGroup
        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"
            }
            # Get the group name from the management group name - this assumes the name "<companyname>-<groupname>"
            $groupName = $managementGroupName.split("-")[-1]
            # create service principals for devops and operations
            Set-CafServicePrincipal `
                -ScopeType "ManagementGroup" `
                -ScopeName $groupName `
                -ScopeId $managementGroup.Id `
                -Role "Owner" `
                -Suffix "deploy" `
                -SubscriptionId $ctx.managementSubscriptionId
            Set-CafServicePrincipal `
                -ScopeType "ManagementGroup" `
                -ScopeName $groupName `
                -ScopeId $managementGroup.Id `
                -Role "Contributor" `
                -Suffix "ops" `
                -SubscriptionId $ctx.managementSubscriptionId
        }
        if (!$DoNotEnsureDeployGroup.IsPresent) {
            Initialize-CafDeploymentSpGroup
        }
    }
}

<#
.SYNOPSIS
    Initializes the subscription management resources in a single subscription.
.DESCRIPTION
    Deploys the subscription management resources to a single subscription.
.PARAMETER BicepRootPath
    The path to the root folder of the bicep files.
.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.
.EXAMPLE
    Initialize-CafSubscription `
        -BicepRootPath "C:\git\landing-zone\infrastructure\management-resources" `
        -SubscriptionId "00000000-0000-0000-0000-000000000000" `
        -SubscriptionName "lz-companyshort-projectname" `
        -WhatIf
#>

function Initialize-Subscription {
    [CmdLetBinding()]
    param (
        [String]
        [Parameter(Mandatory = $true)]
        $BicepRootPath,
        [String]
        [Parameter(Mandatory = $true)]
        $SubscriptionId,
        [String]
        $SubscriptionName,
        [switch]
        $WhatIf
    )
    $ErrorActionPreference = 'Stop'
    $ctx = Use-CafContext
    if ($ctx.subscriptionsToIgnore -and $ctx.subscriptionsToIgnore.Contains($SubscriptionId)) {
        Write-Host "Subscription is on ignore list...Skipping." -ForegroundColor Yellow
        return
    }
    # get the deployment template file
    $deploymentPath = Join-Path $BicepRootPath "main.bicep"
    # get the parameter file
    $templateParameterFile = Join-Path $BicepRootPath "parameters.json"
    $parameterJson = Get-Content -Path $templateParameterFile -Raw | ConvertFrom-Json
    if (!($parameterJson.parameters.location)) {
        throw "No location specified in '$templateParameterFile'."
    }
    $location = $parameterJson.parameters.location.value
    # load subscription from azure and check if it is enabled
    $subscription = Get-AzSubscription -TenantId $ctx.tenantId -SubscriptionId $SubscriptionId
    if ($subscription.State -ne 'Enabled') {
        Write-Host "Subscription is disabled...Skipping." -ForegroundColor Yellow
        return
    }
    # subscription found and not disabled
    if (!$SubscriptionName) {
        # if no subscription name is specified, retrieve it from Azure
        $SubscriptionName = $subscription.Name
    }
    if (!$SubscriptionName || !$SubscriptionName.StartsWith("lz-$($ctx.companyShort)-")) {
        # skip subscriptions that are not part of the landing zone
        Write-Information "Subscription $SubscriptionName does not conform to the naming convention 'lz-$($ctx.companyShort)-[projectName]'. Skipping."
        return
    }
    # Get the project name from the subscription name - this assumes the name "lz-<companyshort>-<projectname>"
    $projectName = $SubscriptionName.split("-")[-1]
    $dateSuffix = Get-Date -Format "yyyy-dd-MM-HH-mm"
    $deploymentName = "deploy-$dateSuffix"
    # print all the collected parameters for debugging
    Write-Information "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
    New-AzDeployment `
        -Name $deploymentName `
        -Location $location `
        -TemplateFile $deploymentPath `
        -TemplateParameterFile $templateParameterFile `
        -DeploymentDebugLogLevel None `
        -WhatIf:$WhatIf `
        -projectName $projectName `
        -ErrorAction SilentlyContinue `
        -WarningAction SilentlyContinue | Out-Null
}


<#
.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.
.EXAMPLE
    Initialize-Subscriptions `
        -WhatIf
#>

function Initialize-Subscriptions {
    [CmdLetBinding()]
    param(
        [switch]
        $WhatIf
    )
    process {
        $ErrorActionPreference = 'Stop'
        # get the context from .azcontext files
        $ctx = Use-CafContext
        $root = $ctx.rootPath
        if (!$root) {
            # if no root path is specified, use the current working directory
            $root = $PWD
        }
        $root = Join-Path $root "infrastructure/management-resources"
        if (!(Test-Path $root)) {
            throw "The folder '$root' does not exist. Set a valid root path in the .azcontext file or or execute this from the correct base path."
        }
        # 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 }
        $subscriptionsCount = ($subscriptions | Measure-Object).Count
        $i = 0
        foreach ($subscription in $subscriptions) {
            $i++
            $progress = [Math]::Round($i * 100 / $subscriptionsCount, 0)
            Write-Progress -Activity "Handling subscriptions" -Status "$progress%" -PercentComplete $progress
            $subscriptionId = $subscription.Id
            $subscriptionName = $subscription.Name
            Write-Host "$subscriptionId $subscriptionName"
            Initialize-CafSubscription `
                -BicepRootPath $root `
                -SubscriptionId $subscriptionId `
                -SubscriptionName $subscriptionName `
                -WhatIf:$WhatIf
        }
    }
}


<#
.SYNOPSIS
    Deploys azure resourses by using the bicep file with the ops service principal 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.
.PARAMETER Stage
    TODO
.PARAMETER SpecificParameterFile
    TODO
.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 WhatIf
    TODO
.EXAMPLE
    New-CafDeployment
#>

function New-Deployment {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory=$false)]
        [ValidateSet("none", "int", "test", "prod", "")]
        [String]
        $Stage = "none",
        [Parameter(Mandatory=$false)]
        [String]
        $SpecificParameterFile,
        [Parameter(Mandatory=$false)]
        [String]
        $ResourceGroupName,
        [Parameter(Mandatory=$false)]
        [String]
        $ResourceGroupLocation,
        [Parameter(Mandatory=$false)]
        [ValidateSet("deploy", "ops")]
        [string]
        $ServicePrincipalType = "deploy",
        [string]
        $PreScriptFile = "",
        [switch]
        $WhatIf
    )
    process {
        $ErrorActionPreference = 'Stop'
        Write-Output "Starting deployment..."
        $root = $PWD.Path
        $resolvedStage = $Stage.ToLower()
        if ($Stage -eq "none") {
            $resolvedStage = ""
        }
        if ($SpecificParameterFile.Length -eq 0) {
            # build our own parameter file and search for it
            $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"
            }
            if (!(Test-Path $parameterFile)) {
                throw "Parameters file not found. Seached locations: [$resolvedStage.json], [parameters.$resolvedStage.json], [parameters/$resolvedStage.json]."
            }
        }
        else {
            # use the specified parameter file
            $parameterFile = $SpecificParameterFile
        }
        $deploymentPath = "$root/main.bicep"
        $templateParameterFile = "$root/$parameterFile"
        if (!(Test-Path $deploymentPath)) {
            throw "main.bicep file does not exist. Please use the command in the project infrastructure folder."
        }
        Write-VerboseOnly "Using deployment file '$deploymentPath'"
        if (!(Test-Path $templateParameterFile)) {
            throw "The parameter file '$templateParameterFile' does not exist."
        }
        Write-VerboseOnly "Using parameter file '$templateParameterFile'"
        # read the location from the parameter file
        $parameterJson = Get-Content -Path $templateParameterFile -Raw | ConvertFrom-Json
        if (!($parameterJson.parameters.location)) {
            throw "No location specified in '$templateParameterFile'."
        }
        $location = $parameterJson.parameters.location.value
        Write-VerboseOnly "Location is set to '$location'"
        # 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 ($PreScriptFile.Length -gt 0) {
            # add the pre-script file as the first command
            if (!(Test-Path $PreScriptFile)) {
                throw "Provided pre-script [$PreScriptFile] not found."
            }
            $command += "& $PreScriptFile -Stage $Stage -ParameterFile $templateParameterFile $($WhatIf.IsPresent ? '-WhatIf' : '')`n"
        }
        # 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 All
"@

        }
        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 All ``
                    -Mode Incremental
"@

        }
        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 -ServicePrincipalType "$ServicePrincipalType"
        if (!$?) {
            throw "Error during deployment of resources."
        }
    }
}


Function New-NoDeleteLocksForResourceGroup
{
    param (
        [Parameter(Mandatory=$true)] [string] $ResourceGroupName,
        [Parameter(Mandatory=$true)] [object[]] $Locks
    )    
    foreach ($lock in $Locks) {    
        if ($lock.Properties.level -eq "CanNotDelete") {
            New-AzResourceLock -LockName $lock.Name -LockLevel CanNotDelete -ResourceGroupName $ResourceGroupName -Force | Out-Null
        }
    }    
}

<#
 .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 where the subscription lives in for faster context switch.
 .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
            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 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 $ctx.tenantId -Subscription $subscriptionId | Out-Null
        }
        else {
            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-HostMessage -Message "Firewall rule with name $ruleName successfully created on Azure SQL Server $AzureSqlServerName for IP $IpAddress" -Level Success
    }

}


function Remove-NoDeleteLocksForResourceGroup
{
    param (
        [Parameter(Mandatory=$true)] [string] $ResourceGroupName
    )
    $locks = Get-AzResourceLock -ResourceGroupName $ResourceGroupName
    foreach ($lock in $locks) {
        if ($lock.Properties.level -eq "CanNotDelete") {
            Remove-AzResourceLock -LockName $lock.Name -ResourceGroupName $ResourceGroupName -Force | Out-Null
        }
    }
    return $locks
}

<#
.SYNOPSIS
    Creates a service principal with a random password and stores the credentials in a key vault.
.DESCRIPTION
    Creates a service principal, assignes the required roles and stores the credentials in a key vault.
    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.
.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.
.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
    )
    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
        }
        $scopeShort = $ScopeType -eq "subscription" ? "sub" : "mg"
        $name = "$($scopeShort)-$($ScopeName)"
        $spName = "sp-$name"
        if ($Suffix) {
            $spName = "$spName-$Suffix"
        }
        $keyVault = Get-ManagementKeyVault -ScopeType $ScopeType
        $keyVaultName = $keyVault.VaultName
        # check if the service principal already exists
        $now = Get-Date
        $expiration = $now.AddYears(1)
        $sp = Get-AzADServicePrincipal -DisplayName $spName -ErrorAction SilentlyContinue
        if ($sp) {
            # service principal already exists
            Write-Host "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
                Write-Host "Assigning missing role '$Role' to service principal '$spName' with id '$($sp.Id)'"
                New-AzRoleAssignment -ObjectId $sp.Id -Scope $ScopeId -RoleDefinitionName $Role
            }
            # check if the credentials in the key vault are expired
            if (!$sp.PasswordCredentials.EndDateTime -or $sp.PasswordCredentials.EndDateTime -lt $now) {
                # password is expired, so update it on the service principal and in the key vault
                Write-Host "Updating expired credentials for service principal '$spName' with id '$($sp.Id)'"
                $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
            }
            # check if the key vault secret needs an expiration update
            $kvSecret = Get-AzKeyVaultSecret -VaultName $keyVaultName `
                -Name "$spName"
            if (!$kvSecret) {
                Write-Host "Missing secret for existing service principal '$spName' with id '$($sp.Id)'...Adding."
                $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
                return
            }
            if ($kvSecret.Expires -ne $expiration) {
                Write-Host "Updating key vault secret expiration for '$spName'"
                $kvSecret | Update-AzKeyVaultSecret -Expires $expiration
            }
            Write-Host "Service principal '$spName' with id '$($sp.Id)' is configured correctly."
            return
        }
        $sp = New-AzADServicePrincipal -DisplayName $spName -Tag [$name] -Role $Role -Scope $ScopeId
        # make the credential expire after 1 year
        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 'akv-$($ctx.companyShort)-mgmt-$ScopeName' with name '$spName'"
    }
}

<#
.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.
.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
    )
    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.
        # Connect to the Graph API
        Connect-MgGraph -Scopes "PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup" -TenantId $tenantId -NoWelcome
        if (!$?) {
            throw "Could not connect to the Graph API."
        }
        # 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-Host "The current user is not eligible for membership on group '$GroupName' ($groupId)." -ForegroundColor Yellow
            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-Host "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
        }
        New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $params -ErrorAction SilentlyContinue | Out-Null
        if (!$?) {
            Write-Host $res
            throw "Could not activate assignment."
        }
        Write-Host "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.
.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 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]
        $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.
        # Connect to the Graph API
        Connect-MgGraph -Scopes "PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup" -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
        # 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-Host "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"
                }
            }
        }
        New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $params | Out-Null
        if (!$?) {
            throw "Could not enable role assignment request for role '$roleId'."
        }
        Write-Host "Sucessfully added assignment request for role id '$roleId'."
        if ($Wait.IsPresent) {
            Write-Host "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 the given command by ensuring that it is executed in its own process and therefore
    ensuring that the current Azure context is not changed.
.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
        if ($verbose) {
            Invoke-Expression "pwsh -Command { $command }" -Verbose
        } else {
            Invoke-Expression "pwsh -Command { $command }"
        }
        if ($removeNoLogo) {
            $env:NO_DEVDEER_CAF_LOGO = ""
        }
    }
}


<#
.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.
.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
    )
    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.
        # Connect to the Graph API
        Connect-MgGraph -Scope "PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup" -TenantId $tenantId -NoWelcome
        if (!$?) {
            throw "Could not connect to the Graph API."
        }
        # 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-Host "The current user is not eligible for membership on group '$GroupName' ($groupId)." -ForegroundColor Yellow
            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-Host "Current user is probably not a member of '$GroupName' ($groupId) at the moment." -ForegroundColor Yellow
            return
        }
        # Build request to deactivate the group assignment
        $params = @{
            accessId    = "member"
            principalId = $principalId
            groupId     = $groupId
            action      = "selfDeactivate"
        }
        New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $params | Out-Null
        if (!$?) {
            throw "Could not deactivate assignment."
        }
        Write-Host "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".
.EXAMPLE
    Stop-CafPimRole -Tenant TODO
#>

function Stop-PimRole {
    [CmdLetBinding()]
    param (
        [string]
        $Tenant,
        [string]
        $RoleId = "62e90394-69f5-4237-9190-012177145e10"
    )
    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.
        # Connect to the Graph API
        Connect-MgGraph -Scopes "PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup" -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
        # 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"
        }
        New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $params -ErrorAction SilentlyContinue | Out-Null
        if (!$?) {
            throw "Could not disable assignment to role id '$roleId'."
        }
        Write-Host "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"
        }
        $targetSubscriptionId = $SubscriptionId ? $SubscriptionId : $ctx.subscriptionId
        $currentContext = Get-AzContext
        # if no context is present, connect to the tenant
        if (!$currentContext) {
            write-host "Currently not connected to any Azure tenant."
            # if targetSubscriptionId is present, connect to the tenant and subscription
            if ($null -ne $targetSubscriptionId) {
                write-host "Connecting to tenant $targetTenantId and subscription $targetSubscriptionId"
                Connect-AzAccount -TenantId $targetTenantId -SubscriptionId $targetSubscriptionId -Force | Out-Null
                $currentContext = Get-AzContext
            }
            else {
                write-host "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-Host "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-Host "The target subscription $targetSubscriptionId differ from the current posh context: $currentPoshContextSubscription."
                Write-Host "Setting context to subscription $targetSubscriptionId"
                Set-AzContext -SubscriptionId $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 and its corresponding keyvault name.
.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]
        [Parameter(Mandatory = $false)]
        [ValidateSet("deploy", "ops")]
        $ServicePrincipalType = "ops",
        [string]
        [Parameter(Mandatory = $false)]
        [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"
        }
        # build the Key Vault name
        $subscriptionName = $azCtx.Subscription.Name
        # split the subscription name using the hyphen character "-"
        $nameParts = $subscriptionName -split '-'
        # check if there are exactly 3 parts in the name
        if ($nameParts.Length -ne 3) {
            throw "Subscription $subscriptionName is not valid due to naming convention lz-$($ctx.companyShort)-*"
        }
        # extract the project name part from the subscription name and find out the matching Key Vault
        $projectName = $nameParts[-1]
        $keyVault = Get-ManagementKeyVault -ScopeType $ServicePrincipalScope
        $vaultName = $keyVault.VaultName
        if ($ServicePrincipalScope -eq "ManagementGroup") {
            # build the service principal name when scope is management group
            $managementGroupId = $ctx.managementGroupId
            # split the management group name using the hyphen character "-"
            $nameParts = $managementGroupId -split '-'
            # check if there are exactly two parts in the name
            if ($nameParts.Length -ne 2) {
                throw "Management group $managementGroupId is not valid due to naming convention $($ctx.companyName)-*"
            }
            # extract the project name part from the management group name
            $managementGroupName = $nameParts[-1]
            # build the service principal name
            $servicePrincipalName = "sp-mg-$ManagementGroupName-$ServicePrincipalType"
        }
        else {
            # build the service principal name when scope is subscription
            $servicePrincipalName = "sp-sub-$projectName-$ServicePrincipalType"
        }
        # try to retrieve the SP
        $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 sp
        $spSecretName = $servicePrincipal.DisplayName
        $spSecurePass = Get-AzKeyVaultSecret -VaultName $vaultName -Name $spSecretName
        if (!$spSecurePass) {
            throw "Could not get password for service principal"
        }
        # login to AZ with the sp
        $spId = (Get-AzADServicePrincipal -DisplayName $servicePrincipal.DisplayName).AppId
        $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $spId, $spSecurePass.SecretValue
        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)"
        }
    }
}


<#
 .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 | 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
    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 NoException
    If set no exception will be thrown if no key vault could be resolved.
.EXAMPLE
    Get-KeyVault
    Get-KeyVault -ScopeType ManagementGroup
#>

function Get-ManagementKeyVault {
    # Parameter help description
    [CmdletBinding()]
    param (
        [ValidateSet('ManagementGroup', 'Subscription')]
        [string]
        $ScopeType = 'Subscription',
        [switch]
        $NoException
    )
    process {
        $ctx = Get-CafContext
        if ($ScopeType -eq 'Subscription') {
            # find the management resource group and the first key vault in there
            $resources = Get-AzResource -ResourceGroupName "rg-management" -ErrorAction SilentlyContinue | Where-Object { $_.ResourceType -eq 'Microsoft.KeyVault/vaults' }
            if ($resources.Length -ne 0) {
                $vaultResource = $resources[0]
                $keyVault = Get-AzKeyVault -VaultName $vaultResource.Name -ResourceGroupName $vaultResource.ResourceGroupName
            }
        }
        else {
            # take the central key vault of the tenant
            $vaultName = "akv-$($ctx.companyShort)-mgmt-management"
            $keyVault = Get-AzKeyVault -VaultName $vaultName -SubscriptionId $ctx.managementSubscriptionId -ResourceGroupName rg-management -ErrorAction SilentlyContinue
        }
        if (!$keyVault -and !$NoException.IsPresent) {
            throw "Key Vault not found."
        }
        Write-VerboseOnly "Using key vault '$($keyVault.VaultName)' derived from scope '$ScopeType'"
        return $keyVault
    }
}

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-NoDeleteLocksForResourceGroup
Export-ModuleMember -Function New-SqlFirewallRule
Export-ModuleMember -Function Remove-NoDeleteLocksForResourceGroup
Export-ModuleMember -Function Set-ServicePrincipal
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