Devdeer.Caf.psm1
<# .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 -Tenant $($ctx.tenantId)." } Write-Host "Assigned role to $($securityGroup.DisplayName) for LAW: log-$($ctx.companyShort)-management" } else { Write-Host "Security Group $($securityGroup.DisplayName) already has required role at scope." } } } <# .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 $subscriptions = Get-AzSubscription -TenantId $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 if (!$subscriptionName || !$subscriptionName.StartsWith("lz-$($ctx.companyShort)-")) { # skip subscriptions that are not part of the landing zone Write-Information "Skipping subscription $subscriptionName" 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 "management-group" ` -ScopeName $groupName ` -ScopeId $managementGroup.Id ` -Role "Owner" ` -Suffix "deploy" ` -SubscriptionId $ctx.managementSubscriptionId Set-CafServicePrincipal ` -ScopeType "management-group" ` -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 All ` -WhatIf:$WhatIf ` -projectName $projectName } <# .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 $subscriptions = Get-AzSubscription -TenantId $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." } } } <# .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 "management-group". .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("subscription", "management-group")] $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 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" ) process { $ErrorActionPreference = 'Stop' $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 }" } } } <# .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 $currentPoshContextTenant = $currentContext.Tenant.Id $currentPoshContextSubscription = $currentContext.Subscription.Id if ($targetTenantId -ne $currentPoshContextTenant -or ($targetSubscriptionId -and $targetSubscriptionId -ne $currentPoshContextSubscription)) { Write-Host "The target tenant $targetTenantId and/or the target subscription $targetSubscriptionId differ from the current posh context." if ($ctx.forceContext -ne $true) { throw "Cannot proceed on wrong context." } if ($targetSubscriptionId) { # set context to tenant AND subscription Set-AzContext -TenantId $targetTenantId -SubscriptionId $targetSubscriptionId -Force | Out-Null } else { # set context to tenant only Set-AzContext -TenantId $targetTenantId -Force | Out-Null } $newCtx = Get-AzContext if (!$SubscriptionId -and $targetSubscriptionId -ne $newCtx.Subscription.Id) { throw "Could not force context to the target tenant and/or subscription." } } return $ctx } } <# .SYNOPSIS Sets the context for the service princiapl 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 already loged 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 -Tenant $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 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 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('management-group', 'subscription')] [string] $ScopeType = 'subscription', [switch] $NoException ) process { 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 $keyVault = Get-AzKeyVault -VaultName "akv-$($ctx.companyShort)-mgmt-management" -SubscriptionId $SubscriptionId -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 } } <# .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 ) $verbose = $PSBoundParameters['Verbose'] -or $VerbosePreference -eq 'Continue' if ($verbose) { Write-Host $Message -ForegroundColor DarkGray } } 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 Set-ServicePrincipal Export-ModuleMember -Function Start-Scoped Export-ModuleMember -Function Use-Context Export-ModuleMember -Function Use-ServicePrincipal |