Devdeer.Caf.psm1
<# .SYNOPSIS Approves a PIM Role assignment request for a single user. .DESCRIPTION Checks if the user is eligible for the role and activates the assignment. .PARAMETER Tenant The tenant id or domain name. .PARAMETER RoleId The id of the role. Default is "Global Administator". .PARAMETER Justification The justification for the approval. .PARAMETER UserId The id of the user who created the approval request. .PARAMETER UserName The starting part of the UPN of the user who created the approval request. We only can provide the startsWith functionallity here because Azure Graph does not expose contains to default callers. .EXAMPLE Approve-CafPimRole -Tenant TODO #> function Approve-PimRole { [CmdLetBinding()] param ( [Parameter(Mandatory = $true)] [string] $Justification, [string] $Tenant, [string] $RoleId = "62e90394-69f5-4237-9190-012177145e10", [string] $UserId, [string] $UserName ) process { if ($UserId -and $UserName) { throw "You cannot query for both, id and name, at the same time." } if (!$UserId -and !$UserName) { throw "You must either specificy UserId or UserName." } $ctx = Use-CafContext if (($ctx.Keys | Measure-Object).Count -eq 0) { # No context was found if ($Tenant.Length -eq 0) { throw "No AZ context was found. You need to provide a tenant!" } Connect-Tenant -TenantId $Tenant if (!$?) { throw "Could not connect to provided tenant." } # use the tenant id from the default Azure context $tenantId = (Get-AzContext).Tenant.Id } else { $tenantId = $ctx.tenantId } # Ensure that Microsoft.Graph.Authentication module is present Enable-Module -ModuleName Microsoft.Graph.Authentication # Ensure that Microsoft.Graph.Authentication module is present Enable-Module -ModuleName Microsoft.Graph.Identity.Governance # Ensure that Microsoft.Resources module is present Enable-Module -ModuleName Az.Resources # All needed modules are present. #Connect to the Graph API $scopes = @( "RoleAssignmentSchedule.ReadWrite.Directory", "PrivilegedAccess.ReadWrite.AzureAD" ) # connect to the Graph API Connect-MgGraph -Scopes $scopes -TenantId $tenantId -NoWelcome if (!$?) { throw "Could not connect to the Graph API." } # Get the current user's principal id $azCtx = Get-AzContext $user = Get-AzADUser -Mail $azCtx.Account.id $principalId = $user.Id # Retrieve active requests for PIM $uri = "/beta/roleManagement/directory/roleAssignmentScheduleRequests?`$filter=(status eq 'PendingApproval') and (principalId ne '$principalId') and (roleDefinitionId eq '$RoleId')" $pendingApprovals = (Invoke-GraphRequest -Method GET -Uri $uri).value if ($pendingApprovals.Count -eq 0) { Write-Host "No matching pending requests found." -ForegroundColor Yellow return } # retrieve the user which is active and matching the criteria $uri = "/beta/users?`$filter=(accountEnabled eq true) and (" if ($UserId) { $uri += "id eq '$UserId'" } if ($UserName) { $uri += "startsWith(userPrincipalName, '$UserName')" } $uri += ")" $matchedUserInfo = (Invoke-GraphRequest ` -ErrorAction SilentlyContinue ` -Method GET ` -Uri $uri).value if (!$? -or $matchedUserInfo.Count -gt 1 -or $matchedUserInfo.Count -eq 0) { throw "No or more than one user was found." } # check if there are pending approvals for the user we want to approve $userInfo = $matchedUserInfo $pendingApprovals = $pendingApprovals | Where-Object principalId -eq $userInfo.Id if ($pendingApprovals.Count -eq 0) { Write-Host "No pending requests found for user '$($userInfo.Id)'." -ForegroundColor Yellow return } # Get approval steps $approvalsAmount = ($pendingApprovals | Measure-Object).Count if ($approvalsAmount -eq 0) { throw "No approval was loaded." } $approval = $approvalsAmount -eq 1 ? $pendingApprovals : $pendingApprovals[0] $approvalId = $approval.approvalId $uri = "/beta/roleManagement/directory/roleAssignmentApprovals/$approvalId" $approvalSteps = (Invoke-GraphRequest ` -Method GET ` -Uri $uri).steps | Where-Object status -eq 'InProgress' if (!$? -or !$approvalSteps) { throw "Could not retrieve approval with id '$approvalId'." } $stepsAmount = ($approvalSteps | Measure-Object).Count if ($stepsAmount -eq 0) { throw "No step in the request '$approvalId' available to allow approvals." } $approvalStep = $stepsAmount -eq 1 ? $approvalSteps : $approvalSteps[0] # Approve the request Write-Host "Approving user '$($userInfo.DisplayName)' ($($userInfo.Id))" $body = @{ reviewResult = 'Approve' justification = $justification } $stepId = $approvalStep.id $uri = "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignmentApprovals/$approvalId/steps/$stepId" Invoke-GraphRequest ` -Method PATCH ` -Uri $uri ` -Body $body | Out-Null if (!$?) { throw "Could not approve user $($userInfo.DisplayName) with id $($userInfo.Id)" } Write-Host "Approved user '$($userInfo.DisplayName)' ($($userInfo.Id))." } } <# .Synopsis Removes all custom firewall rules currently added to the SQL server given with regard to resource group locks. .Description Removes all firewall rules currently added to the SQL server given. Any nodelete lock on the parent resource group will be de- and re-activated automatically using the service principals. You don't need to be elevated for this. The rule to allow Azure resources accessing the server will not be affected by this. .Parameter AzureSqlServerName The name of the SQL server .Parameter TenantId The unique ID of the tenant where the subscription lives in for faster context switch. .Example Clear-CafAllSqlFirewallRules -AzureSqlServerName mySQLServerName #> Function Clear-AllSqlFirewallRules { [CmdLetBinding()] param ( [string] $AzureSqlServerName, [string] $TenantId ) process { $ErrorActionPreference = 'Stop' if (!$TenantId) { $ctx = Use-CafContext if ($ctx.Keys -contains "tenantId") { $Tenant = $ctx.tenantId } if (($ctx.Keys | Measure-Object).Count -eq 0) { # No context was found if ($Tenant.Length -eq 0) { throw "No AZ context was found. You need to provide a tenant!" } } } else { Connect-Tenant -TenantId $TenantId if (!$?) { throw "Could not connect to the provided tenant." } } # If server name not passed in check if .azcontext contains one if ($AzureSqlServerName.Length -eq 0) { if ($ctx.Keys -contains "sqlServerName") { $AzureSqlServerName = $ctx.sqlServerName } else { throw "No SQL server name was provided and no default was found in .azcontext" } } # Check if the SQL server exists $server = Search-AzGraph -Query "where type =~ 'Microsoft.Sql/servers' and name =~ '$AzureSqlServerName'" if (!$? -or $server.Count -gt 1 -or $server.Count -eq 0) { throw "No or more than one resource was found in tenant '$TenantId' with name '$AzureSqlServerName'." } # If the resource is found switch the context to the subscription where the resource is located $subscriptionId = $server[0].SubscriptionId if (!$subscriptionId) { throw "could not retrive the subscription ID for resource '$AzureSqlServerName'." } if (!$TenantId) { Set-AzContext -Tenant $ctx.tenantId -Subscription $subscriptionId | Out-Null } else { Set-AzContext -Tenant $TenantId -Subscription $subscriptionId | Out-Null } if (!$?) { throw "Could not set azcontext to subscription '$subscriptionId'." } # Check if there are any firewall rules to clear $existintRules = Get-AzSqlServerFirewallRule -ServerName $server.name -ResourceGroupName $server.resourceGroup $amount = $existintRules.Length if ($amount -eq 0) { Write-HostMessage -Message "Terminating because no firewall rules where found on Azure SQL $AzureSqlServerName" -Level Warning return } Write-HostMessage "Found $amount firewall rules on server '$AzureSqlServerName'" $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 define resources from the provider 'Microsoft.Authorization/*' .Description When executed inside specific policy type folders (Assignments, Definitions, Initiative) it will read the bicep files and deploy the policies defined in them. .PARAMETER ServicePrincipalType Defines the type of service principal (deploy or ops) should be used (defaults to deploy). .PARAMETER Recurse If this switch is present, the command will recurse into sub directories and delete policies there as well. .PARAMETER WhatIf If this switch is present, the command will not actually delete anything but only show what would be deleted. .Example Deploy-CafPolicyAssignments #> function Deploy-PolicyAssets { [CmdletBinding()] param ( [ValidateSet("All", "None", "RequestContent", "ResponseContent")] [String] $DebugLevel = "All", [Parameter(Mandatory = $false)] [ValidateSet("deploy", "ops")] [string] $ServicePrincipalType = "deploy", [switch] $WhatIf, [switch] $Recurse ) process { $ErrorActionPreference = 'Stop' $root = $PWD.Path $location = 'West Europe' $parameterFile = "$deploymentPath/parameters.json" # get all BICEP files in this and all sub directories if ($Recurse.IsPresent) { $bicepFilesCount = (Get-ChildItem $root -Filter *.bicep -Recurse | Measure-Object).Count } else { $bicepFilesCount = (Get-ChildItem $root -Filter *.bicep | Measure-Object).Count } if ($bicepFilesCount -eq 0) { Write-Host "No BICEP files in target directory. Exiting." return } if ($Recurse.IsPresent) { $bicepFiles = Get-ChildItem $root -Filter *.bicep -Recurse } else { $bicepFiles = Get-ChildItem $root -Filter *.bicep } Write-Host "Found $($bicepFilesCount) policies under $root" foreach ($file in $bicepFiles) { Write-VerboseOnly " $($file)" } $ctx = Get-CafContext # Find all resource definitions not point to existing resources and put the resource type in the # match group with offset 2. $regex = "resource(.*)'Microsoft.Authorization\/(.*)@(.*)'(?:(?!existing).)*?{" # create and start a deployment for each BICEP file found $tasks = @() # collect deployment tasks foreach ($file in $bicepFiles) { # perform regex search of BICEP file content to find out what type of BICEP that is $bicepContent = Get-Content -Raw $file $result = $bicepContent -match $regex if (!$result) { throw "Invalid BICEP at file $file. This is not a policy BICEP!" } $bicepType = $matches[2] $type = $bicepType -eq 'policySetDefinitions' ? 'initiative' : ` $bicepType -eq 'policyDefinitions' ? 'definition' : ` $bicepType -eq 'policyAssignments' ? 'assignment' : ` '' if ($type.Length -eq 0) { # the regex didn't find anything throw "Could not determine policy deployment type from BICEP file $file" } $dateSuffix = Get-Date -Format "yyyy-dd-MM-HH-mm-ss" $deploymentName = $WhatIf.IsPresent ? "deploy-whatif" : "deploy-$type-$dateSuffix" $tasks += @{ DeploymentName = $deploymentName Filename = $file.Name FilePath = $file Directory = $file.Directory Type = $type } } # check if all tasks are of same type $last = '' foreach ($task in $tasks) { if ($last.Length -gt 0 -and $last -ne $task.Type) { throw "You cannot deploy different types of policy assets in one run." } $last = $task.Type } # At this point we know that all tasks are of the same type and we can proceed. $current = 0 $total = $tasks.Length foreach ($task in $tasks) { $current++ Write-Host "($current of $total) Deploying BICEP policy [$($task.Type)] from file [$($task.Filename)] with name [$($task.DeploymentName)]..." $command = 'New-AzManagementGroupDeployment ` -Name "' + $($task.DeploymentName) + '" ` -Location "' + $location + '" ` -ManagementGroupId "' + $ctx.managementGroupId + '" ` -TemplateFile "' + $($task.FilePath) + '" ` -DeploymentDebugLogLevel "' + $DebugLevel + '"' if ($WhatIf) { $command = $command + " -WhatIf" } $parameterFile = "$($task.Directory)/parameters.json" if (Test-Path $parameterFile) { # we need to add the parameters file to the command $command += ' -TemplateParameterFile "' + $parameterFile + '"' } Write-VerboseOnly "Using $ServicePrincipalType service principal for deployment" Start-CafScoped -Command $command -ServicePrincipalType "$ServicePrincipalType" -servicePrincipalScope "ManagementGroup" if (!$?) { throw "Error during deployment of definition in BICEP $($task.File)." } } } } <# .Synopsis Retrieves the combined .azcontext-based settings that are valid in the current directory. .Description Searches for all ".azcontext" files in and above the current PWD and combines the values of them. Keep in mind that it also searches for such a file in the user home directory! .Example $ctx = Get-CafContext #> function Get-Context { [CmdLetBinding()] param ( [switch]$NoLogo ) process { if (!$NoLogo.IsPresent) { Write-Logo } $ErrorActionPreference = 'Stop' $files = Find-FilesByNameUp -FileName '.azcontext' # try to add the file in the users home $file = Join-Path '~' '.azcontext' if (Test-Path $file) { $files.Add($file) } # spit out the results Write-VerboseOnly "Found $($files.Count) context files" $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 AZ-CAF-DeployPrincipals for deployment service principals. .DESCRIPTION Retrieves all service principals in the tenant that are visible to the current user and adds them to their security group AZ-CAF-DeployPrincipals. .EXAMPLE Initialize-CafDeploymentSpGroup #> function Initialize-DeploymentSpGroup { [CmdLetBinding()] param ( ) process { $ErrorActionPreference = 'Stop' $ctx = Use-CafContext if (!$ctx.managementSubscriptionId) { throw "Management subscription not defined in .azcontext" } # Get the sub id of the management subscription which contains the central log analytics workspace $scopeResourceId = "/subscriptions/$($ctx.managementSubscriptionId)" Write-VerboseOnly "Using subscription scope $scopeResourceId for assigning log analytics roles..." # Get all matching deploy SPs $subServicePrincipalNameRegex = Format-NamingConvention -Context $ctx ` -Type "servicePrincipal" -SubType "deploySubscription" -ProjectName ".*" $mgServicePrincipalNameRegex = Format-NamingConvention -Context $ctx ` -Type "servicePrincipal" -SubType "deployManagementGroup" -ProjectName ".*" # Fetch the service principals that match the naming convention $servicePrincipals = Get-AzADServicePrincipal | Where-Object { $_.DisplayName -match $subServicePrincipalNameRegex -or $_.DisplayName -match $mgServicePrincipalNameRegex } if ($servicePrincipals.Count -eq 0) { Write-Host "No deploy service principals where found in the tenant $($ctx.tenantId)." return } Write-Host "Found $($servicePrincipals.Count) matching service principals." # Ensure the security group is present $securityGroupName = Format-NamingConvention -Context $ctx -Type "securityGroup" -SubType "deploySpSecurityGroup" Write-VerboseOnly "Finding security group $securityGroupName..." $securityGroup = Get-AzADGroup -DisplayName $securityGroupName -ErrorAction SilentlyContinue if (!$securityGroup) { Write-Host "Creating security group: $securityGroupName" $securityGroup = New-AzADGroup -DisplayName $securityGroupName -MailNickname $securityGroupName if (!$?) { throw "Could not create security group." } Write-Host "Created security group: $($securityGroup.DisplayName)" } # This is necessary because the SPs for deployment cannot setup diagnostics settings due to PIM # Add service principals to the security group $memberIds = Get-AzAdGroupMember -GroupObjectId $securityGroup.Id -WarningAction SilentlyContinue | Select-Object -ExpandProperty Id foreach ($sp in $servicePrincipals) { if ($sp.Id -in $memberIds) { Write-VerboseOnly "$($sp.DisplayName) already is member of security group." continue } Add-AzADGroupMember -MemberObjectId $sp.Id -TargetGroupObjectId $securityGroup.Id -WarningAction SilentlyContinue if (!$?) { throw "Could not add object $($sp.Id) as member of security group." } Write-Host "Added $($sp.DisplayName) to the security group: $($securityGroup.DisplayName)" } # Assign the role to the security group for the target resource # Define the role id for 'Log Analytics Contributor' $roleId = "92aaf0da-9dab-42b6-94a3-d43ce8d16293" $existing = Get-AzRoleAssignment -Scope $scopeResourceId -ObjectId $securityGroup.Id -RoleDefinitionId $roleId -ErrorAction SilentlyContinue if (!$?) { throw "Could not read role assignments for object $($securityGroup.Id) on scope $scopeResourceId." } if ($existing.Count -eq 0) { New-AzRoleAssignment -RoleDefinitionId $roleId -ObjectId $securityGroup.Id -Scope $scopeResourceId -ErrorAction SilentlyContinue | Out-Null if (!$?) { throw "Could not assign role $roleId for object $($securityGroup.Id) on scope $scopeResourceId. Maybe try to re-run Connect-AzAccount -TenantId $($ctx.tenantId)." } Write-Host "Assigned role to $($securityGroup.DisplayName) for LAW at scope $scopeResourceId" } else { Write-Host "Security Group $($securityGroup.DisplayName) already has the required role at scope $scopeResourceId." } } } <# .SYNOPSIS Initializes the default service principals in all management groups and subscriptions of the tenant. .DESCRIPTION Retrieves all subscriptions in the tenant that are visible to the current user and deploys default service principals to each of them. .PARAMETER DoNotEnsureDeployGroup If provided this function will NOT call Initialize-CafDeploymentSpGroup after SP creation automatically. .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 } # Filter out all the subscriptions that are on the ignore list $ctx.subscriptionsToIgnore | ForEach-Object { $ignoreId = $_ $ignoredSubscription = $subscriptions | Where-Object { $_.Id -eq $ignoreId } if ($ignoredSubscription) { Write-Host "Subscription Name: $($ignoredSubscription.Name) | ID: $($ignoredSubscription.Id) is on the ignore list. Skipping ...." } $subscriptions = $subscriptions | Where-Object { $_.Id -ne $ignoreId } } $subscriptionsCount = ($subscriptions | Measure-Object).Count # Filter out all the management groups that are on the ignore list $managementGroups = Get-AzManagementGroup $ctx.managementGroupsToIgnore | ForEach-Object { $ignoreId = $_ $ignoredManagementGroup = $managementGroups | Where-Object { $_.Name -eq $ignoreId } if ($ignoredManagementGroup) { Write-Host "Management Group Name: $($ignoredManagementGroup.Name) | ID: $($ignoredManagementGroup.Id) is on the ignore list. Skipping ...." } $managementGroups = $managementGroups | Where-Object { $_.Name -ne $ignoreId } } $i = 0 foreach ($subscription in $subscriptions) { $i++ $progress = [Math]::Round($i * 100 / $subscriptionsCount, 0) # Build the subscription(landing zone) name from the az context naming convention $lzSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone" -ProjectName ".*" if (!$lzSubscriptionNameRegex) { throw "Failed to get the naming convention for the landing zone subscription." } # Build the IAM subsciption name from the az context naming convention $iamSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "iamSubscription" -ProjectName ".*" if (!$iamSubscriptionNameRegex) { throw "Failed to get the naming convention for the IAM subscription." } $subscriptionName = $subscription.Name $subscriptionId = $subscription.Id if ($SubscriptionName -notmatch $lzSubscriptionNameRegex -and $SubscriptionName -notmatch $iamSubscriptionNameRegex) { Write-Host "Subscription with Name: '$SubscriptionName' | ID: '$subscriptionId' does not conform with subscription naming conventions '$lzSubscriptionName' or '$iamSubscriptionName'...Skipping." -ForegroundColor Yellow continue } Write-Progress -Activity "Handling subscriptions" -Status "$progress%" -PercentComplete $progress # We use Format-NamingConvention to build the project name pattern and try to find the index of the element "[PROJECT_NAME]" in the pattern. This gives us # the position of the real project name inside the given subscription name. $sep = $ctx.namingConvention.subscription.landingZone.separator $regex = $sep + "?([^-]*)" + $sep + "?" $namePattern = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone" # NOTE: We are doing the conversion into a .NET list because this gives us the FindIndex method which we need to find the index of the project name in the subscription name. $offset = ([Collections.Generic.List[Object]](($namePattern | Select-String -Pattern $regex -AllMatches).Matches)).FindIndex({ $args[0].Groups[1].Value -eq "[PROJECT_NAME]" }) $projectName = $SubscriptionName.Split($sep)[$offset] if ($projectName.Length -eq 0) { throw "Could not deduce project name from subscription name '$SubscriptionName'." } #TODO: -IAM needs to be parameterized. if ($SubscriptionName -match $iamSubscriptionNameRegex) { $projectName = $projectName + "-iam" } Write-VerboseOnly "Project name: $projectName" # create service principals for devops and operations # Calls the Set-CafServicePrincipal to create the service principal for deployment Write-VerboseOnly "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 "Owner" ` -Suffix "deploy" ` -SubscriptionId $subscription.Id # Calls the Set-CafServicePrincipal to create the service principal for operations Write-VerboseOnly "Set-CafServicePrincipal -ScopeType 'Subscription' -ScopeName $projectName -ScopeId '/subscriptions/$($subscription.Id)' -Role 'Contributor' -Suffix 'ops' -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 foreach ($managementGroup in $managementGroups) { $managementGroupName = $managementGroup.Name # the root management group has the tenant id as name if ($managementGroup.Name -eq $ctx.tenantId) { $managementGroupName = "$($ctx.companyName)-root" } # We use Format-NamingConvention to build the management group name pattern and deduce index of the element "[PROJECT_NAME]" in the pattern. # This gives us the position of the real project name location from the Azure queried management group name. $sep = $ctx.namingConvention.ManagementGroup.separator $regex = $sep + "?([^-]*)" + $sep + "?" $namePattern = Format-NamingConvention -Context $ctx -Type "managementGroup" # NOTE: We are doing the conversion into a .NET list because this gives us the FindIndex method which we need to find the index of the project name in the subscription name. $offset = ([Collections.Generic.List[Object]](($namePattern | Select-String -Pattern $regex -AllMatches).Matches)).FindIndex({ $args[0].Groups[1].Value -eq "[PROJECT_NAME]" }) $projectName = $managementGroupName.Split($sep)[$offset] if ($projectName.Length -eq 0) { throw "Could not deduce Management Group name from '$managementGroupName'." } Write-VerboseOnly "Management group name: $projectName" $iamSubscriptionName = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "iamSubscription" -ProjectName $projectName # Find this iamSubscriptionName in the subscriptions list $iamSubscription = $subscriptions | Where-Object { $_.Name -match $iamSubscriptionName } Write-VerboseOnly "IAM Subscription Name: $($iamSubscription.Name) | ID: $($iamSubscription.Id)" # create service principals for devops and operations Set-CafServicePrincipal ` -ScopeType "ManagementGroup" ` -ScopeName $projectName ` -ScopeId $managementGroup.Id ` -Role "Owner" ` -Suffix "deploy" ` -SubscriptionId $iamSubscription.Id # Calls the Set-CafServicePrincipal to create the service principal for operations Set-CafServicePrincipal ` -ScopeType "ManagementGroup" ` -ScopeName $projectName ` -ScopeId $managementGroup.Id ` -Role "Contributor" ` -Suffix "ops" ` -SubscriptionId $iamSubscription.Id } if (!$DoNotEnsureDeployGroup.IsPresent) { Initialize-CafDeploymentSpGroup } } } <# .SYNOPSIS Initializes the subscription management resources for a single subscription. .DESCRIPTION Deploys the subscription management resources to a single subscription. .PARAMETER SubscriptionId The subscription id to use for the deployment. .PARAMETER SubscriptionName The subscription name to use for the deployment. If not specified, the subscription name is retrieved from Azure. .PARAMETER WhatIf If specified, the deployment is only simulated. .PARAMETER DeploymentTechType The deployment technology to use for the deployment. Default is 'bicep'. .PARAMETER RemoveArtifacts If specified, the downloaded assets are removed after the deployment. .PARAMETER ForceAssetDownload If specified, the assets are downloaded again even if they already exist. .Parameter BicepSettingsPath The path to the Bicep settings file. .PARAMETER DeploymentFileName The name of the Bicep or Terraform deployment file. .PARAMETER DeploymentParameterFileName The name of the Bicep or terraform parameter file. .EXAMPLE Initialize-CafSubscription ` -SubscriptionId "00000000-0000-0000-0000-000000000000" ` -SubscriptionName "prefix-companyshort-projectname" ` -WhatIf #> function Initialize-Subscription { [CmdLetBinding()] param ( [string] $BicepSettingsPath = "", [String] $SubscriptionId = "", [String] $SubscriptionName = "", [string] $DeploymentFileName = "main.bicep", [string] $DeploymentParameterFileName = "main.bicepparam", [string] [validateSet("bicep", "terraform")] $DeploymentTechType = "bicep", [string] $KeyVaultName = "", [switch] $WhatIf, [switch] $RemoveArtifacts, [switch] $ForceAssetDownload ) $ErrorActionPreference = 'Stop' if ($DeploymentTechType -eq "bicep") { if (!$DeploymentFileName.EndsWith(".bicep")) { throw "'$DeploymentFileName' is not a valid Bicep file." } if (!$DeploymentParameterFileName.EndsWith(".bicepparam") -and !$DeploymentParameterFileName.EndsWith(".json")) { throw "'$DeploymentParameterFileName' is not a valid Bicep parameter file." } Initialize-SubscriptionUsingBicep -BicepSettingsPath $BicepSettingsPath -SubscriptionId $SubscriptionId -SubscriptionName $SubscriptionName -BicepDeploymentFileName $DeploymentFileName -BicepDeploymentParameterFileName $DeploymentParameterFileName -WhatIf:$WhatIf -RemoveArtifacts:$RemoveArtifacts -ForceAssetDownload:$ForceAssetDownload } if ($DeploymentTechType -eq "terraform") { #TODO remove throw statement after the terraform implementation is done throw "Terraform implementation is not done yet." if (!$DeploymentFileName.EndsWith(".tf")) { throw "'$DeploymentFileName' is not a valid Terraform file." } Initialize-SubscriptionUsingTerraform -SubscriptionId $SubscriptionId -SubscriptionName $SubscriptionName -KeyVaultName $KeyVaultName -RemoveArtifacts:$RemoveArtifacts -WhatIf:$WhatIf -ForceAssetDownload:$ForceAssetDownload } # if the deployment technology is not bicep or terraform, throw an error if ($DeploymentTechType -ne "bicep" -and $DeploymentTechType -ne "terraform") { throw "Deployment technology type '$DeploymentTechType' is not supported. Supported types are 'bicep' and 'terraform'." } } <# .SYNOPSIS Initializes the subscription management resources in all subscriptions of the tenant. .DESCRIPTION Retrieves all subscriptions in the tenant that are visible to the current user and deploys the subscription management resources to each of them. .PARAMETER WhatIf If specified, the deployment is only simulated. .PARAMETER DeploymentTechType The deployment technology to use for the deployment. Default is 'bicep'. .PARAMETER DeploymentFileName The name of the Bicep or terraform deployment file. Default is 'main.bicep'. .PARAMETER DeploymentParameterFileName The name of the Bicep or terraform parameter file. Default is 'main.bicepparam'. .PARAMETER ForceAssetDownload If specified, the latest version of the assets are downloaded again even if they already exist. .EXAMPLE Initialize-CafSubscriptions ` -WhatIf #> function Initialize-Subscriptions { [CmdLetBinding()] param( [string] [validateSet("bicep", "terraform")] $DeploymentTechType = "bicep", [string] $DeploymentFileName = "main.bicep", [string] $DeploymentParameterFileName = "main.bicepparam", [switch] $ForceAssetDownload, [switch] $WhatIf ) process { $ErrorActionPreference = 'Stop' $bicepSettingsPath = Find-FilesByNameUp -FileName 'bicepSettings.json' -ReturnFirstMatch if ($bicepSettingsPath.Length -eq 0) { throw "No 'bicepSettings.json' was found in this or any parent directory." } # get the context from .azcontext files $ctx = Use-CafContext # retrieve all subscriptions in the tenant that are visible to the current user but exclude those which are visible through lighthouse (e.g. not originating in the current tenant). $subscriptions = Get-AzSubscription -TenantId $ctx.tenantId | Where-Object { $_.ExtendedProperties.HomeTenant -eq $ctx.tenantId } # Filter out all the subscriptions that are on the ignore list $ctx.subscriptionsToIgnore | ForEach-Object { $ignoreId = $_ $ignoredSubscription = $subscriptions | Where-Object { $_.Id -eq $ignoreId } if ($ignoredSubscription) { Write-Host "Subscription Name: $($ignoredSubscription.Name) | ID: $($ignoredSubscription.Id) is on the ignore list. Skipping ...." } $subscriptions = $subscriptions | Where-Object { $_.Id -ne $ignoreId } } $subscriptionsCount = ($subscriptions | Measure-Object).Count $i = 0 foreach ($subscription in $subscriptions) { $i++ $progress = [Math]::Round($i * 100 / $subscriptionsCount, 0) $ProgressMessage = if ($progress -lt 50) { "Still a long way to go... maybe grab a cup of coffee?" } elseif ($progress -lt 80) { "More than halfway there... refill your coffee?" } else { "Almost done... last sip of coffee?" } Write-Progress -Activity "Handling subscriptions" -Status "$ProgressMessage ($progress%)" -PercentComplete $progress $subscriptionId = $subscription.Id $subscriptionName = $subscription.Name Write-Host "Handling subscription Name: $subscriptionName | ID: $subscriptionId" -ForegroundColor Green $removeArtifacts = $i -eq ($subscriptions.length); Initialize-CafSubscription ` -BicepSettingsPath $bicepSettingsPath ` -SubscriptionId $subscriptionId ` -SubscriptionName $subscriptionName ` -DeploymentFileName $DeploymentFileName ` -DeploymentParameterFileName $DeploymentParameterFileName ` -DeploymentTechType $DeploymentTechType ` -RemoveArtifacts:$removeArtifacts ` -ForceAssetDownload:$ForceAssetDownload ` -WhatIf:$WhatIf } } } <# .SYNOPSIS Deploys Azure resources by using a Bicep file within an automatically created ops service principal POSH context. .DESCRIPTION Gets the subscription Id from the azcontext file. It uses this Id and retreves the ops service princiapal which has an Owner role assignment on the subscription scope. This service pricipal is then used as context for the deployment. The command has to be run in the project folder which contains the main.bicep file or the SpecificDeploymentFile must be provided. .PARAMETER Stage Specifies the stage of the deployment. Valid values are "int", "test", "prod" and "". The default value is "". .PARAMETER SpecificDeploymentFile Specifies the name of the BICEP file to use. If not set, the command will search for a main.bicep file in the current PWD. If the file is not found, an error will be thrown. .PARAMETER SpecificParameterFile Specifies the name of the parameter file to use. If not set, the command will search for a parameter file in the following order: [stage].bicepparm, parameters.[stage].bicepparm, parameters/[stage].bicepparm, [stage].json, parameters.[stage].json, parameters/[stage].json. If none of these files are found, an error will be thrown. .PARAMETER ResourceGroupName Needs to contain the name of the target resource group if the BICEP target scope is set to resourceGroup. .PARAMETER ResourceGroupLocation Needs to contain the name of the Azure region where to deploy to if the BICEP target scope is set to resourceGroup. .PARAMETER ServicePrincipalType Specifies the type of service principal to use. Valid values are "ops" and "deploy". The default value is "deploy". .PARAMETER PreScriptFile Optional path to a script which needs to be executed before the actual deployment happens. The script must take at least Stage, ParameterFile and WhatIf without any other mandatory as parameters in. .PARAMETER PostScriptFile Optional path to a script which needs to be executed after the actual deployment happens. The script .PARAMETER WhatIf If set, the deployment will be simulated only. .PARAMETER PerformPreDeploymentInCurrentScope If set, the PreScriptFile will be executed in the current POSH session and not in the internally created one. This means it will use the identity which is in the POSH session to access Azure and not the service principal inside the pre-script file. .EXAMPLE New-CafDeployment #> function New-Deployment { [CmdLetBinding()] param ( [Parameter(Mandatory = $false)] [ValidateSet("none", "int", "test", "prod", "")] [String] $Stage = "none", [Parameter(Mandatory = $false)] [String] $SpecificParameterFile, [String] $SpecificDeploymentFile, [Parameter(Mandatory = $false)] [String] $ResourceGroupName, [Parameter(Mandatory = $false)] [String] $ResourceGroupLocation, [Parameter(Mandatory = $false)] [ValidateSet("deploy", "ops")] [string] $ServicePrincipalType = "deploy", [string] $PreScriptFile = "", [string] $PostScriptFile = "", [string] [Parameter(Mandatory = $false)] [validateset("Subscription", "ManagementGroup")] $ServicePrincipalScope = "Subscription", [switch] $WhatIf, [switch] $PerformPreDeploymentInCurrentScope ) process { $ErrorActionPreference = 'Stop' $root = $PWD $bicepFile = "$root/main.bicep" if ($SpecificParameterFile.Length -gt 0) { $bicepFile = $SpecificParameterFile } $deploymentPath = Get-Item -Path $bicepFile if (!(Test-Path $deploymentPath)) { throw "File '$deploymentPath' does not exist. Please use the command in the project infrastructure folder." } $resolvedStage = $Stage.ToLower() if ($Stage -eq "none") { $resolvedStage = "" } if ($SpecificParameterFile.Length -eq 0) { # build our own parameter file and search for it. It can be of type .bicepparam or .json $parameterFile = "$($resolvedStage.Length -gt 0 ? $resolvedStage : "main").bicepparam" if (!(Test-Path $parameterFile)) { $parameterFile = "main.$($resolvedStage.Length -gt 0 ? $resolvedStage : '').bicepparam" } if (!(Test-Path $parameterFile)) { $parameterFile = "parameters/$($resolvedStage.Length -gt 0 ? $resolvedStage : '').bicepparam" } if (!(Test-Path $parameterFile)) { $parameterFile = "$($resolvedStage.Length -gt 0 ? $resolvedStage : "parameters").json" } if (!(Test-Path $parameterFile)) { $parameterFile = "parameters.$($resolvedStage.Length -gt 0 ? $resolvedStage : '').json" } if (!(Test-Path $parameterFile)) { $parameterFile = "parameters/$($resolvedStage.Length -gt 0 ? $resolvedStage : 'parameters').json" } } else { # use the specified parameter file $parameterFile = $SpecificParameterFile } if (!(Test-Path $parameterFile)) { throw "Parameters file not found. Seached locations: [$resolvedStage.bicepparam], [main.$resolvedStage.bicepparam], [parameters/$resolvedStage.biceparam], [$resolvedStage.json], [parameters.$resolvedStage.json], [parameters/$resolvedStage.json]." } # check if the pre-script file exists $usePreScript = $PreScriptFile.Length -gt 0 if ($usePreScript -and !(Test-Path $PreScriptFile)) { throw "Provided pre-script '$PreScriptFile' file not found." } # check if the post-script file exists $usePostScript = $PostScriptFile.Length -gt 0 if ($usePostScript -and !(Test-Path $PostScriptFile)) { throw "Provided post-script '$PostScriptFile' file not found." } # check if the parameter file exists $templateParameterFile = "$root/$parameterFile" Write-VerboseOnly "Using deployment file '$deploymentPath'" if (!(Test-Path $templateParameterFile)) { throw "The parameter file '$templateParameterFile' does not exist." } Write-VerboseOnly "Using parameter file '$templateParameterFile'" # check if the $templateParameterFile is a .json file if ($templateParameterFile -match ".json") { $parameterJson = Get-Content -Path $templateParameterFile -Raw | ConvertFrom-Json if (!($parameterJson.parameters.location)) { throw "No location specified in '$templateParameterFile'." } $location = $parameterJson.parameters.location.value } else { # retreive the location value form the .bicepparam file bicep build-params $templateParameterFile if (!$?) { throw " Install or upgrade the Bicep CLI to the latest version." } # retreive the name of the .bicepparam file from the $templateParameterFile variable $jsonParameterFilename = $templateParameterFile -replace ".bicepparam", ".json" # retreive the location value from the .json file $location = (Get-Content $jsonParameterFilename | ConvertFrom-Json).parameters.location.value Write-VerboseOnly "Location is set to '$location'" # delete the automatically created .json file Remove-Item -Path $jsonParameterFilename Write-VerboseOnly "Removed temporary file '$jsonParameterFilename'." if ($location.Length -eq 0) { throw "No location value found in '$jsonParameterFilename'." } } # read the deployment target type from the bicep $bicepContent = Get-Content -path $deploymentPath -Raw if (!($bicepContent -match "targetScope = '(.*)'")) { throw "No targetScope defined in '$deploymentPath'." } $targetScope = $Matches[1] # we can proceed with the deployment -> find a name for it first $dateSuffix = Get-Date -Format "yyyy-dd-MM-HH-mm" $deploymentName = "deploy-$targetScope-$dateSuffix" $command = '' if ($usePreScript) { $preCommand = "Write-Host 'Starting pre-deployment scipt...';`n& $PreScriptFile -Stage $Stage -ParameterFile $templateParameterFile $($WhatIf.IsPresent ? '-WhatIf' : '');`n" if ($PerformPreDeploymentInCurrentScope.IsPresent) { # Caller wants to execute the pre-script in its own session Invoke-Expression $preCommand } else { # Pre script should be called in CAF scoped session $command += $PreScriptFile } } # deploy if ($targetScope -eq "subscription") { # default deployment for subscription level Write-VerboseOnly "Deploying at subscription level using template $deploymentPath ..." $command += @" New-AzDeployment `` -Name '$deploymentName' `` -Location '$location' `` -TemplateFile '$deploymentPath' `` -TemplateParameterFile '$templateParameterFile' `` -DeploymentDebugLogLevel 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 -ServicePrincipalScope $ServicePrincipalScope -ServicePrincipalType "$ServicePrincipalType" if (!$?) { throw "Error during deployment of resources." } # If the deployment was successful, we can run the post-script if ($usePostScript) { $command = '' $command += "Write-Host 'Starting post-deployment scipt...';`n& $PostScriptFile -Stage $Stage -ParameterFile $templateParameterFile $($WhatIf.IsPresent ? '-WhatIf' : '')`n" Write-VerboseOnly $command Start-CafScoped -Command $command -ServicePrincipalScope $ServicePrincipalScope -ServicePrincipalType "$ServicePrincipalType" if (!$?) { throw "Error during execution of post-script file.." } } } } <# .Synopsis Adds a firewall rule to an Azure SQL Server for a single IP. .Description Adds a firewall rule to an Azure SQL Server for a single IP. .Parameter AzureSqlServerName The name of the SQL server .Parameter TenantId The unique ID of the tenant 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 } } <# .Synopsis Removes locks from the resource group with the given name and retrieves the removed lock objects. .Description This will try to remove all locks which are not defined by Azure internally from the given resource group. It will return an array of all the locks that where removed which can be passed to Restore-CafLocks later. .Parameter ResourceGroupName The name of the resource group. .Parameter AllLockLevels If set all lock levels will be removed. By default only CanNotDelete is removed. #> function Remove-Locks { [CmdLetBinding()] param ( [Parameter(Mandatory = $true)] [string] $ResourceGroupName, [switch]$AllLockLevels ) $locks = Get-AzResourceLock -ResourceGroupName $ResourceGroupName $removedLocks = @() foreach ($lock in $locks) { if ($AllLockLevels.IsPresent -or $lock.Properties.level -eq "CanNotDelete") { Write-VerboseOnly "Removing lock '$($lock.Name)' of level '$($lock.Properties.level)'..." Remove-AzResourceLock -LockName $lock.Name -ResourceGroupName $ResourceGroupName -Force | Out-Null $removedLocks += $lock } } return $removedLocks } <# .Synopsis Restores all the given locks to the specified resource group. .Description After you used Remove-CafLocks you can use it's result (the collection of removed locks) to pass it to this function. This then will try to applying those locks again. Only resource group locks are currently supported. .Parameter ResourceGroupName The name of the resource group. .Parameter Locks The array of locks to restore. #> Function Restore-Locks { [CmdLetBinding()] param ( [Parameter(Mandatory = $true)] [string] $ResourceGroupName, [Parameter(Mandatory = $true)] [object[]] $Locks ) foreach ($lock in $Locks) { if ($lock.ResourceType -eq "Microsoft.Authorization/locks" -and $lock.ResourceGroupName -eq $ResourceGroupName) { Write-VerboseOnly "Re-applying lock $($lock.Name)..." New-AzResourceLock -LockName $lock.Name -LockLevel $lock.Properties.Level -ResourceGroupName $ResourceGroupName -Force | Out-Null } } } <# .SYNOPSIS Creates or updates a service principal matching the conventions for one of the given purposes. .DESCRIPTION Creates a service principal using a random generated password or updates an existing one. Assignes the required roles and stores the credentials in the Azure Key Vault resolved. If it already exists, only roles and credentials are checked and updated, if necessary. YOU NEED TO EXECUTE THIS WITH ELEVATED PERSONAL RIGHTS! .PARAMETER ScopeType The scope type of the service principal. Valid values are "Subscription" and "ManagementGroup". .PARAMETER ScopeName The name of the scope to create the service principal for. Also known as the project name. It is used to deduce naming conventions. .PARAMETER ScopeId The id of the scope to use for the role assignment. .PARAMETER Role The role to assign to the service principal. .PARAMETER Suffix An optional Suffix to append to the service principal name. .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 } # if the scope type is Subscription and the suffix is deploy, we use the deploySubscription naming convention, otherwise the opsSubscription naming convention. if the scope type is ManagementGroup and the suffix is deploy, we use the deployManagementGroup naming convention, otherwise the opsManagementGroup naming convention if ($scopeType -eq "Subscription") { if ($suffix -eq "deploy") { $namingConventionSubType = "deploySubscription" } else { $namingConventionSubType = "opsSubscription" } } elseif ($scopeType -eq "ManagementGroup") { if ($suffix -eq "deploy") { $namingConventionSubType = "deployManagementGroup" } else { $namingConventionSubType = "opsManagementGroup" } } else { throw "Invalid scope type: '$scopeType'." } # build the name of the service principal based on the type and subtype $spName = Format-NamingConvention -Context $ctx -Type "servicePrincipal" -SubType "$namingConventionSubType" -ProjectName $ScopeName Write-VerboseOnly "Service Principal Name: $spName" $keyVault = Get-ManagementKeyVault -ScopeType $ScopeType -ScopeName $ScopeName $keyVaultName = $keyVault.VaultName # check if the service principal already exists $now = Get-Date $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 } else { Write-Host "Role assignment for service principal '$spName' with id '$($sp.Id)' already exists." } # check if the service principal password has expired credentials # a single service principal can have multiple credentials, so we need to iterate over all of them foreach ($credential in $sp.PasswordCredentials) { if (-not $credential.EndDateTime -or $credential.EndDateTime -lt $now) { Write-Host "Updating expired credentials for service principal '$spName' with id '$($sp.Id)'" $newCredential = New-AzADServicePrincipalCredential -ObjectId $sp.Id -EndDate $expiration $secret = ConvertTo-SecureString -String $newCredential.SecretText -AsPlainText -Force Set-AzKeyVaultSecret -VaultName $keyVaultName ` -Name "$spName" ` -SecretValue $secret ` -Expires $expiration ` -Tag @{"ServicePrincipalId" = $($sp.Id); "ServicePrincipalName" = $spName } | Out-Null # Assuming we only need to update once, break after updating break } } # 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 the secret exists, always update the expiration date to 1 year from now 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 } # now, if service principal does not exist, create it and add the role assignment. $sp = New-AzADServicePrincipal -DisplayName $spName -Tag [$name] -Role $Role -Scope $ScopeId Write-Host "Created service principal '$spName' with id '$($sp.Id)'" # store the SP's credentials in the key vault $credential = New-AzADServicePrincipalCredential -ObjectId $sp.Id -EndDate $expiration $secret = ConvertTo-SecureString -String $credential.SecretText -AsPlainText -Force Set-AzKeyVaultSecret -VaultName $keyVaultName ` -Name "$spName" ` -SecretValue $secret ` -Expires $expiration ` -Tag @{"ServicePrincipalId" = $($sp.Id); "ServicePrincipalName" = $spName } | Out-Null Write-Host "Stored service principal credentials in key vault '$keyVaultName' with name '$spName'" } } <# .SYNOPSIS Builds all the naming conventions from the context file and displays them in a table. .DESCRIPTION Displays all the naming conventions defined by the user. The naming conventions are defined in the .azcontext file. .PARAMETER ProjectName The optional project name to use. .EXAMPLE Show-CafNamingConvention -ProjectName spock #> function Show-NamingConvention { [CmdLetBinding()] param ( [string] $ProjectName ) process { # load the context file $ctx = Get-CafContext # create an empty array to store the output $output = @() foreach ($type in $ctx.namingConvention.GetEnumerator()) { if ($type.Value -is [Hashtable] -and $type.Value.ContainsKey('order')) { # This means the type does not have subtypes defined in it. $name = Format-NamingConvention -Context $ctx -Type $type.Name -ProjectName $ProjectName # Add the output to the array $output += New-Object PSObject -Property ([ordered]@{ 'Type' = $type.Name 'Subtype' = $null 'Name' = $name }) } elseif ($type.Value -is [Hashtable]) { foreach ($subtype in $type.Value.GetEnumerator()) { if ($subtype.Value -is [Hashtable] -and $subtype.Value.ContainsKey('order')) { # This means the type has subtypes defined in it. $name = Format-NamingConvention -Context $ctx -Type $type.Name -Subtype $subtype.Name -ProjectName $ProjectName # Add the output to the array $output += New-Object PSObject -Property ([ordered]@{ 'Type' = $type.Name 'Subtype' = $subtype.Name 'Name' = $name }) } } } } # Display the output as a table $output | Format-Table -AutoSize } } <# .SYNOPSIS Assigns the user to a PIM group. .DESCRIPTION Assigns the user to a PIM group. The user must be eligible for the group. Either you run this cmdlet under a .azcontext which defines 'tenantId' and 'adminEntraGroupName' or you provide those values using the parameters. .PARAMETER Tenant The tenant id or domain name. .PARAMETER Reason The reason for the assignment. .PARAMETER DurationHours The duration in hours for the assignment. Default is 1 hour. .PARAMETER GroupName The name of the group. .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 a command or script file in a new posh session which is authenticated with a service principal. .DESCRIPTION Executes the command obtained from the parameter in a specific scope. This keeps the user scope intact even after performing a task which might have altred the scope otherwise. .EXAMPLE Start-CafScoped -Command "./test.ps1" -FileCommand Start-CafScoped -Command "Get-AzContext" #> function Start-Scoped { [CmdLetBinding()] param ( [string]$Command, [switch]$FileCommand, [Parameter(Mandatory = $false)] [ValidateSet("deploy", "ops")] [string]$ServicePrincipalType = "ops", [Parameter(Mandatory = $false)] [ValidateSet("subscription", "managementGroup")] [string]$ServicePrincipalScope = "subscription", [switch]$NoLogo ) process { $ErrorActionPreference = 'Stop' $removeNoLogo = $false if ($NoLogo.IsPresent && !$env:NO_DEVDEER_CAF_LOGO) { $env:NO_DEVDEER_CAF_LOGO = "1" $removeNoLogo = $true } $verbose = $PSBoundParameters['Verbose'] -or $VerbosePreference -eq 'Continue' $command = ' $ErrorActionPreference = "Stop" $command = Get-Command -Name Use-CafServicePrincipal -ErrorAction SilentlyContinue if ($command -eq $null) { throw "CAF modules not installed in this session! Consider importing it in your profile." } Use-CafServicePrincipal ' ` + ($verbose ? '-Verbose' : '') ` + ' -ServicePrincipalType "' + $ServicePrincipalType + '"' ` + ' -ServicePrincipalScope "' + $ServicePrincipalScope + '"' ` + [Environment]::NewLine ` + ($FileCommand.IsPresent ? ' & ' : ' ') + $Command 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" } # if SubscriptionId is not present, use the subscription id from the context file # if $ctx.subscriptionId is not present, try assigning $ctx.managementSubscriptionId if (!$SubscriptionId) { $targetSubscriptionId = $ctx.subscriptionId ?? $ctx.managementSubscriptionId } else { $targetSubscriptionId = $SubscriptionId } $currentContext = Get-AzContext # if no context is present, connect to the tenant if (!$currentContext) { write-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 logging it in using the password in the resolved Azure Key Vault. .DESCRIPTION Gets the subscription Id from the azcontext file. It uses this Id and retreves the deploy service princiapal which has an Owner role assignment on the subscription scope. This service pricipal is then used to retreve its respective keyvault name. .PARAMETER ServicePrincipalType Specifies the type of service principal to use. Valid values are "ops" and "deploy". The default value is "deploy". .PARAMETER ServicePrincipalScope Specifies the scope of the service principal. Valid values are "subscription" and "managementGroup". The default value is "subscription". .EXAMPLE Use-CafServicePrincipal -ServicePrincipalType "deploy" #> function Use-ServicePrincipal { [CmdLetBinding()] param ( [string] [ValidateSet("deploy", "ops")] $ServicePrincipalType = "ops", [string] [validateset("Subscription", "ManagementGroup")] $ServicePrincipalScope = "Subscription" ) process { $ErrorActionPreference = 'Stop' $ctx = Use-CafContext if (!$?) { throw "Could not set azcontext" } $azCtx = Get-AzContext if (!$azCtx) { throw "Could not get azcontext" } # if service principal type is deploy, we use the deploySubscription naming convention, otherwise the opsSubscription naming convention if ($ServicePrincipalScope -eq "Subscription") { if ($ServicePrincipalType -eq "deploy") { $namingConventionSubType = "deploySubscription" } else { $namingConventionSubType = "opsSubscription" } } elseif ($ServicePrincipalScope -eq "ManagementGroup") { if ($ServicePrincipalType -eq "deploy") { $namingConventionSubType = "deployManagementGroup" } else { $namingConventionSubType = "opsManagementGroup" } } else { throw "Invalid scope type: '$scopeType'." } # get the subscription name from current context $subscriptionName = $azCtx.Subscription.Name # Build the subscription(landing zone) name from the az context naming convention $lzSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone" -ProjectName ".*" if (!$lzSubscriptionNameRegex) { throw "Failed to get the naming convention for the landing zone subscription." } # Build the IAM subsciption name from the az context naming convention $iamSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "iamSubscription" -ProjectName ".*" if (!$iamSubscriptionNameRegex) { throw "Failed to get the naming convention for the IAM subscription." } # Ensure that the subscription name conforms to the naming conventions if ($SubscriptionName -notmatch $lzSubscriptionNameRegex -and $SubscriptionName -notmatch $iamSubscriptionNameRegex) { Write-Host "Subscription with Name: '$SubscriptionName' | ID: '$subscriptionId' does not conform with subscription naming conventions '$lzSubscriptionName' or '$iamSubscriptionName'...Skipping." -ForegroundColor Yellow continue } # find what type of subscription is used in the current context and decide the- # -scope of the service principal if its a Subscription or ManagementGroup if ($SubscriptionName -match $iamSubscriptionNameRegex) { $ServicePrincipalScope = "ManagementGroup" } if ($ServicePrincipalScope -eq "ManagementGroup") { # build the service principal name when scope is management group $managementGroupName = $ctx.managementGroupId # We use Format-NamingConvention to build the management group name pattern and deduce index of the element "[PROJECT_NAME]" in the pattern. # This gives us the position of the real project name location from the Azure queried management group name. $sep = $ctx.namingConvention.ManagementGroup.separator $regex = $sep + "?([^-]*)" + $sep + "?" $namePattern = Format-NamingConvention -Context $ctx -Type "managementGroup" # NOTE: We are doing the conversion into a .NET list because this gives us the FindIndex method which we need to find the index of the project name in the subscription name. $offset = ([Collections.Generic.List[Object]](($namePattern | Select-String -Pattern $regex -AllMatches).Matches)).FindIndex({ $args[0].Groups[1].Value -eq "[PROJECT_NAME]" }) $projectName = $managementGroupName.Split($sep)[$offset] if ($projectName.Length -eq 0) { throw "Could not deduce Management Group name from '$managementGroupName'." } Write-VerboseOnly "Management group name: $projectName" # build the service principal name using the naming convention $servicePrincipalName = Format-NamingConvention -Context $ctx -Type "servicePrincipal" -SubType $namingConventionSubType -ProjectName $projectName } else { # the scope is Subscription # We use Format-NamingConvention to build the project name pattern and try to find the index of the element "[PROJECT_NAME]" in the pattern. This gives us # the position of the real project name inside the given subscription name. $sep = $ctx.namingConvention.subscription.landingZone.separator $regex = $sep + "?([^-]*)" + $sep + "?" $namePattern = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone" # NOTE: We are doing the conversion into a .NET list because this gives us the FindIndex method which we need to find the index of the project name in the subscription name. $offset = ([Collections.Generic.List[Object]](($namePattern | Select-String -Pattern $regex -AllMatches).Matches)).FindIndex({ $args[0].Groups[1].Value -eq "[PROJECT_NAME]" }) $projectName = $SubscriptionName.Split($sep)[$offset] if ($projectName.Length -eq 0) { throw "Could not deduce project name from subscription name '$SubscriptionName'." } # TODO: $projectName for IAM subscriptions should already contain projectName-iam in it. # Right now becase of the regex used the seperator defined exludes iam from the projectName. # Maybe it needs to be of the format projectname_iam for IAM subscriptions. # Becasaue of this I have to use the below workaround to add the iam after the project name. # if the subscription name matches the IAM subscription naming convention, we append to the project name if ($SubscriptionName -match $iamSubscriptionNameRegex) { $projectName = $projectName + $ctx.namingConvention.subscription.iamSubscription.separator + $ctx.namingConvention.subscription.iamSubscription.suffix } # build the service principal name when scope is subscription $servicePrincipalName = Format-NamingConvention -Context $ctx -Type "servicePrincipal" -SubType $namingConventionSubType -ProjectName $projectName Write-VerboseOnly "Project name: $projectName" } Write-VerboseOnly "Using Service Principal Name: $servicePrincipalName on scope $ServicePrincipalScope" # get the key vault name $keyVault = Get-ManagementKeyVault -ScopeType $ServicePrincipalScope -ScopeName $projectName $vaultName = $keyVault.VaultName # try to retrieve the service principal $servicePrincipal = Get-AzADServicePrincipal -DisplayName $servicePrincipalName if (!$servicePrincipal) { throw "Service principal with display name '$servicePrincipalName' not found" } if ($servicePrincipal.AppId -eq $azCtx.Account.Id) { Write-Host "Service principal $servicePrincipalName already logged in." return; } Write-VerboseOnly "Service Principal $($servicePrincipal.DisplayName) found" # retrieve the password for the service principal $spSecretName = $servicePrincipal.DisplayName $spSecurePass = Get-AzKeyVaultSecret -VaultName $vaultName -Name $spSecretName if (!$spSecurePass) { throw "Could not get password for service principal" } # login to Azure with the service principal $spId = (Get-AzADServicePrincipal -DisplayName $servicePrincipal.DisplayName).AppId $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $spId, $spSecurePass.SecretValue Connect-AzAccount -Scope Process -ServicePrincipal -TenantId $azCtx.Tenant.Id -Subscription $azCtx.Subscription.Id -Credential $credential | Out-Null if (!$?) { throw "Could not login with service principal $($servicePrincipal.DisplayName)" } else { Write-Host "Switched to service principal '$($servicePrincipal.DisplayName)' on tenant '$($azCtx.Tenant.Id)', subscription '$($azCtx.Subscription.Id)'." } } } <# .Synopsis Clears the environment variables used while performing a terraform deployment. .Description This function clears the environment variables ARM_CLIENT_ID and ARM_CLIENT_SECRET. .Example Clear-TerraformEnvironmentVariables #> function Clear-TerraformEnvironmentVariables { [CmdLetBinding()] param ( ) process { Write-Host $message $env:ARM_CLIENT_ID = "" $env:ARM_CLIENT_SECRET = "" Write-Host "Cleanup of environment finished." -ForegroundColor Yellow } } <# .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 Is able to find files with a specific name up the tree and return all of them or the first hit. .Description This function is able to find files with a specific name up the tree and return all of them or the first hit. .Parameter FilePattern The name of the file to search for up the tree. .Parameter ReturnFirstMatch If this switch is set the function will return the first found file. If not, an array of matching files will be returned. .Example Find-FilesByNameUp -FileName "bicepSettings.json" #> function Find-FilesByNameUp { [CmdletBinding()] param ( $FileName, [switch] $ReturnFirstMatch ) process { $files = New-Object Collections.Generic.List[String] $currentFolder = $PWD while ($true) { $file = Join-Path $currentFolder $FileName if (Test-Path $file) { if ($ReturnFirstMatch) { return $file } $files.Add($file) } $currentFolder = Split-Path $currentFolder if (!$currentFolder) { break } } return $files } } <# .SYNOPSIS Initializes the subscription management resources. .DESCRIPTION Deploys the subscription management resources by using the Bicep deployment technology. .PARAMETER Context The context object which contains the naming conventions and details retrieved from the infrastrucutre project .azcontext file. .PARAMETER Type The type of the naming conventions. Can be subscription or resource group. .PARAMETER SubType The SubType of the naming conventions. can be landing zones or management subscriptions. Default is null. .PARAMETER ProjectName The optional project name to use. .EXAMPLE Format-NamingConvention ` -Context $Context ` -Type "subscription" ` -SubType "landingZone" -ProjectName "spock" #> function Format-NamingConvention { [CmdLetBinding()] param ( [Parameter(Mandatory = $true)] [object] $Context, [string] $Type, [string] $SubType = $null, [string] $ProjectName ) $ErrorActionPreference = 'Stop' $namingConvention = $null if ($null -eq $ProjectName -or $ProjectName.Length -eq 0) { $ProjectName = "[PROJECT_NAME]" } # Check if subtype is set and if it is a valid subtype. also if only type is set check if it is a valid type if ($SubType) { if ($Context.namingConvention.$Type.$SubType) { $namingConvention = $Context.namingConvention.$Type.$SubType } else { throw "Invalid subtype: '$SubType' for type: '$Type'." } } elseif ($Context.namingConvention.$Type) { $namingConvention = $Context.namingConvention.$Type } else { throw "Invalid type: '$Type'." } # Type has been found and we can continue to build the name $nameParts = @() # Build the name parts based on the naming convention and the order defined in the naming convention' foreach ($part in $namingConvention.order) { if ($part -eq 'projectName') { # if $regex is not null then we need to replace the projectName with the regex $nameParts += $ProjectName } if ($part -eq 'companyName') { $nameParts += $Context.companyName } if ($part -eq 'companyShort') { $nameParts += $Context.companyShort } elseif ($namingConvention[$part]) { $nameParts += $namingConvention[$part] } } $builtName = $nameParts -join $namingConvention.separator Write-VerboseOnly "Naming convention name is '$builtName'." return $builtName } <# .SYNOPSIS Tries to retrieve the management key vault for the current CAF context. .DESCRIPTION Because the actual name of a vault depends on naming length limits this function will search for a key vault which exists in the 'rg-management' resource group. This is the one which by default is used to store service principal secrets. This cmdlet will throw an exception if no key vault was resolved and NoException is NOT set. .PARAMETER ScopeType Defines in which scope type (subscription or management group) the key vault should be searched. .PARAMETER ScopeName The name of the scope to create the service principal for. Also known as the project name. It is used to deduce naming conventions. .PARAMETER NoException If set no exception will be thrown if no key vault could be resolved. .EXAMPLE Get-ManagementKeyVault -ScopeType ManagementGroup -ScopeName "Project" #> function Get-ManagementKeyVault { # Parameter help description [CmdletBinding()] param ( [ValidateSet('ManagementGroup', 'Subscription')] [string] $ScopeType = 'Subscription', [string] [Parameter(Mandatory = $true)] $ScopeName, [switch] $NoException ) process { $ctx = Get-CafContext $keyVaultResourceGroupName = Format-NamingConvention -Context $ctx -Type "resourceGroup" -SubType "managementResourceGroup" -ProjectName $ScopeName Write-VerboseOnly "Searching for key vault in resource group '$keyVaultResourceGroupName'." if ($ScopeType -eq 'Subscription') { # Build the key vault name from the naming convention $vaultName = Format-NamingConvention -Context $ctx -Type "resource" -SubType "keyVault" -ProjectName $ScopeName Write-VerboseOnly "Searching for subsciption management key vault '$vaultName'." $keyVault = Get-AzKeyVault -VaultName $vaultName -ResourceGroupName $keyVaultResourceGroupName -ErrorAction SilentlyContinue } else { # the scope is ManagementGroup # Build the IAM key vault name from the naming convention $vaultName = Format-NamingConvention -Context $ctx -Type "resource" -SubType "iamKeyVault" -ProjectName $ScopeName Write-VerboseOnly "Searching for IAM key vault '$vaultName'." $keyVault = Get-AzKeyVault -VaultName $vaultName -ResourceGroupName $keyVaultResourceGroupName -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 Initializes the subscription management resources. .DESCRIPTION Deploys the subscription management resources by using the Bicep deployment technology. .PARAMETER SubscriptionId The subscription id to use for the deployment. .PARAMETER SubscriptionName The subscription name to use for the deployment. If not specified, the subscription name is retrieved from Azure. .PARAMETER WhatIf If specified, the deployment is only simulated. .PARAMETER RemoveArtifacts If specified, the downloaded assets are removed after the deployment. .PARAMETER ForceAssetDownload If specified, the assets are downloaded again even if they already exist. .Parameter BicepSettingsPath The path to the Bicep settings file. .PARAMETER BicepDeploymentFileName The name of the Bicep deployment file. .PARAMETER BicepDeploymentParameterFileName The name of the Bicep parameter file. .PARAMETER Regex The regex used to replace the default place holder [projectName]. .EXAMPLE Initialize-SubscriptionUsingBicep ` -SubscriptionId "00000000-0000-0000-0000-000000000000" ` -SubscriptionName "lz-companyshort-projectname" ` -WhatIf #> function Initialize-SubscriptionUsingBicep { [CmdLetBinding()] param ( [string] $BicepSettingsPath = "", [String] $SubscriptionId = "", [String] $SubscriptionName = "", [string] $BicepDeploymentFileName = "main.bicep", [string] $BicepDeploymentParameterFileName = "main.bicepparam", [switch] $WhatIf, [switch] $ForceAssetDownload, [switch] $RemoveArtifacts ) $ErrorActionPreference = 'Stop' $nugetCliInstalled = Get-Command nuget -ErrorAction SilentlyContinue if (!$nugetCliInstalled) { throw "Nuget CLI was not found in the current PowerShell session. Ensure that it is installed!" } $bicepCliInstalled = Get-Command bicep -ErrorAction SilentlyContinue if (!$bicepCliInstalled) { throw "Bicep CLI was not found in the current PowerShell session. Ensure that it is installed!" } $ctx = Use-CafContext $deploymentTechType = "bicep" # Build the subscription(landing zone) name from the az context naming convention $lzSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone" -ProjectName ".*" if (!$lzSubscriptionNameRegex) { throw "Failed to get the naming convention for the landing zone subscription." } # Build the IAM subsciption name from the az context naming convention $iamSubscriptionNameRegex = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "iamSubscription" -ProjectName ".*" if (!$iamSubscriptionNameRegex) { throw "Failed to get the naming convention for the IAM subscription." } # if the $SubscriptionId is not set, get the subscription id from .azcontext $SubscriptionId = $SubscriptionId.Length -gt 0 ? $SubscriptionId : $ctx.SubscriptionId if ($ctx.subscriptionsToIgnore -and $ctx.subscriptionsToIgnore.Contains($SubscriptionId)) { # the resolved subscription is on the ignore list Write-Host "Subscription is on ignore list...Skipping." -ForegroundColor Yellow return } Write-VerboseOnly "Using subscription '$SubscriptionId'." # perform validity checks if ($BicepSettingsPath.Length -eq 0) { # collect all files starting with the current path working up Write-VerboseOnly "Searching for Bicep settings from $PWD up..." # find the bicep settings file $BicepSettingsPath = Find-FilesByNameUp -FileName 'bicepSettings.json' -ReturnFirstMatch if ($BicepSettingsPath.Length -eq 0) { throw "No 'bicepSettings.json' was found in this or any parent directory. Hint: Use the -BicepSettingsPath parameter to specify the path to the Bicep settings file or navigate to the infrastructure project directory." } } if (!(Test-Path $BicepSettingsPath)) { throw "Could not find Bicep settings at '$BicepSettingsPath'." } else { Write-VerboseOnly "Using Bicep settings from '$BicepSettingsPath'." } # Get the path to the system's temporary directory $tempPath = [System.IO.Path]::GetTempPath() # Concatenate the desired directory name $assetDownloadPath = Join-Path $tempPath "devdeer.caf/assets" $assetsFound = Test-Path $assetDownloadPath if ($ForceAssetDownload.IsPresent -and $assetsFound) { # Caller wants us to download a freah version of the assets no matter what! Write-VerboseOnly "Removing assets at '$assetDownloadPath'." Remove-Item -Force -Recurse $assetDownloadPath $assetsFound = $false } if (!$assetsFound) { # we need to download the assets! Write-VerboseOnly "Downloading assets to '$assetDownloadPath'." Install-CafAssets -DestinationPath $assetDownloadPath -DeploymentTechType $deploymentTechType $configPath = Join-Path $assetDownloadPath "bicep" "bicepSettings.json" Copy-Item $BicepSettingsPath $configPath Write-Verbose "Applied Bicep settings to file '$configPath'." } else { # the assets folder already exists Write-VerboseOnly "Skipping download of assets because path '$assetDownloadPath' already exists." } # check if modules are installed if (!(Test-Path "$assetDownloadPath/$deploymentTechType/modules")) { # Execute module installer in downloaded location Write-Host "Installing DEVDEER Bicep modules..." -ForegroundColor Green & "$assetDownloadPath/$deploymentTechType/install-modules.ps1" | Out-Null } # build the target path for subscription initialization bicep files $subscriptionBicepPath = Join-Path $assetDownloadPath "$deploymentTechType/management-resources/landing-zone" # get the deployment template file $deploymentPath = Join-Path $subscriptionBicepPath $BicepDeploymentFileName if (!(Test-Path $deploymentPath)) { throw "No deployment template found in '$subscriptionBicepPath'." } # get the parameter file $templateParameterFile = Join-Path $subscriptionBicepPath $BicepDeploymentParameterFileName if (!(Test-Path $templateParameterFile)) { throw "No parameter file found in '$subscriptionBicepPath'." } # retreive the location value form the .bicepparam file bicep build-params $templateParameterFile if (!$?) { throw "Install or upgrade the Bicep CLI to the latest version." } # retreive the name of the .bicepparam file from the $templateParameterFile variable $jsonParameterFilename = $templateParameterFile -replace ".bicepparam", ".json" # retreive the location value from the .json file $location = (Get-Content $jsonParameterFilename | convertfrom-json).parameters.location.value if (!$location) { throw "Location parameter not found in '$jsonParameterFilename'." } # load subscription from azure and check if it is enabled $subscription = Get-AzSubscription -TenantId $ctx.tenantId -SubscriptionId $SubscriptionId if ($subscription.State -ne 'Enabled') { Write-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 } # We use Format-NamingConvention to build the project name pattern and try to find the index of the element "[PROJECT_NAME]" in the pattern. This gives us # the position of the real project name inside the given subscription name. $sep = $ctx.namingConvention.subscription.landingZone.separator $regex = $sep + "?([^-]*)" + $sep + "?" $namePattern = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone" # NOTE: We are doing the conversion into a .NET list because this gives us the FindIndex method which we need to find the index of the project name in the subscription name. $offset = ([Collections.Generic.List[Object]](($namePattern | Select-String -Pattern $regex -AllMatches).Matches)).FindIndex({ $args[0].Groups[1].Value -eq "[PROJECT_NAME]" }) $projectName = $SubscriptionName.Split($sep)[$offset] if ($projectName.Length -eq 0) { throw "Could not deduce project name from subscription name '$SubscriptionName'." } Write-VerboseOnly "Deduced project name for Bicep deployment: $projectName" $dateSuffix = Get-Date -Format "yyyy-dd-MM-HH-mm" $deploymentName = "deploy-$dateSuffix" Write-Host "Processing subscription '$SubscriptionName'..." -ForegroundColor Green # check if the subscription name conforms to the naming convention lzSubscriptioName and iamSubscriptionName. return if it does not. if ($SubscriptionName -notmatch $lzSubscriptionNameRegex -and $SubscriptionName -notmatch $iamSubscriptionNameRegex) { Write-Host "Subscription name '$SubscriptionName' does not conform with subscription naming conventions '$lzSubscriptionName' or '$iamSubscriptionName'...Skipping." -ForegroundColor Yellow return } # if the subscription name conforms to the naming convention for a IAM subscription perform a special deployment if ($SubscriptionName -match $iamSubscriptionNameRegex) { # This means that we are dealing with a special IAM subscription # build the target path for management subscription initialization bicep files $mgmtSubscriptionBicepPath = Join-Path $assetDownloadPath "$deploymentTechType/management-resources/management-group" # get the deployment template file $deploymentPath = Join-Path $mgmtSubscriptionBicepPath $BicepDeploymentFileName if (!(Test-Path $deploymentPath)) { throw "No deployment template found in '$mgmtSubscriptionBicepPath'." } # get the parameter file $templateParameterFile = Join-Path $mgmtSubscriptionBicepPath $BicepDeploymentParameterFileName if (!(Test-Path $templateParameterFile)) { throw "No parameter file found in '$mgmtSubscriptionBicepPath'." } # retreive the location value form the main.bicepparam file bicep build-params $templateParameterFile if (!$?) { throw " Unexpected error while executing Bicep CLI." } # retreive the name of the .bicepparam file from the $templateParameterFile variable $jsonParameterFilename = $templateParameterFile -replace ".bicepparam", ".json" # retreive the location value from the .json file $location = (Get-Content $jsonParameterFilename | ConvertFrom-Json).parameters.location.value # Get the project name and append the iam suffix $projectName = $projectName + "-iam" } # print all the collected parameters for debugging Write-VerboseOnly "SubscriptionId:`t$SubscriptionId`nSubscription:`t$SubscriptionName`nProjectName:`t$projectName" # switch to the target subscription and tenant Set-AzContext -Tenant $ctx.tenantId -SubscriptionId $SubscriptionId | Out-Null Write-VerboseOnly "Executing deployment with file '$deploymentPath' and parameters '$templateParameterFile'." try { New-AzDeployment ` -Name $deploymentName ` -Location $location ` -TemplateFile $deploymentPath ` -TemplateParameterFile $templateParameterFile ` -projectName $projectName ` -DeploymentDebugLogLevel None ` -WhatIf:$WhatIf } catch { # catch any errors and print them Write-Host "Error during deployment: $_" -ForegroundColor Red return } if ($RemoveArtifacts.IsPresent) { Write-Host "Deleting artifacts at '$assetDownloadPath'." Remove-Item $assetDownloadPath -Force -Recurse | Out-Null } } <# .SYNOPSIS Initializes the subscription management resources for a single subscription. .DESCRIPTION Deploys the subscription management resources to a single subscription using the terraform deployment technology. .PARAMETER SubscriptionId The subscription id to use for the deployment. .PARAMETER SubscriptionName The subscription name to use for the deployment. If not specified, the subscription name is retrieved from Azure. .PARAMETER WhatIf If specified, the deployment is only simulated. .PARAMETER RemoveArtifacts If specified, the downloaded assets are removed after the deployment. .EXAMPLE Initialize-SubscriptionUsingTerraform ` -SubscriptionId "00000000-0000-0000-0000-000000000000" ` -SubscriptionName "lz-companyshort-projectname" ` -WhatIf #> function Initialize-SubscriptionUsingTerraform { [CmdLetBinding()] param ( [String] $SubscriptionId, [String] $SubscriptionName, [string] $TerraformFileName = "main.tf", [string] $KeyVaultName, [Boolean] $RemoveArtifacts = $false, [switch] $WhatIf ) $ErrorActionPreference = 'Stop' $ctx = Use-CafContext # Build the subscription(landing zone) name from the az context naming convention $lzSubscriptionName = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "landingZone" if (!$lzSubscriptionName) { throw "Failed to get the naming convention for the landing zone subscription." } # Build the iam subsciption name from the az context naming convention $iamSubscriptionName = Format-NamingConvention -Context $ctx -Type "subscription" -SubType "iam" if (!$iamSubscriptionName) { throw "Failed to get the naming convention for the iam subscription." } # if the $SubscriptionId is not set, get the subscription id from Azure $SubscriptionId = $SubscriptionId.Length -gt 0 ? $SubscriptionId : $ctx.SubscriptionId if ($ctx.subscriptionsToIgnore -and $ctx.subscriptionsToIgnore.Contains($SubscriptionId)) { Write-Host "Subscription is on ignore list...Skipping." -ForegroundColor Yellow return } # should use a service principal for login. Set-TerraformEnvironmentVariables -KeyVaultName $KeyVaultName if (!$?) { throw "Failed to set the environment variables required for the terraform deployment." } # TODO we should create a service principal to initialize,validate,plan and apply the terraform file? # Check if a path for management infrastrucutre already exists. If yes, skip downloading the assets from github. $managementTfAssets = $ctx.managementAssetsPath #$managementTfStateAsset = $ctx.tfStateAssetsPath $TerraformFilePath = Join-Path $managementTfAssets $TerraformFileName if (!(Test-Path $TerraformFilePath)) { # we need to download the assets! # Get the path to the system's temporary directory $tempPath = [System.IO.Path]::GetTempPath() # Concatenate the desired directory name $assetDownloadPath = Join-Path $tempPath "devdeer.caf/assets" Write-VerboseOnly -Message "Downloading assets to '$managementTfAssets'." Install-CafAssets -DestinationPath $assetDownloadPath -DeploymentTechType "terraform" $assetDownloadStatus = $true } else { # the assets folder already exists Write-VerboseOnly -Message "Skipping download of assets because path '$managementTfAssets' already exists." } # if the $subscriptionName is not set, get the subscription name from Azure if (-not $SubscriptionName) { $SubscriptionName = Get-AzSubscription -SubscriptionId $SubscriptionId | Select-Object -ExpandProperty Name } Write-Host "Initializing subscription '$SubscriptionName' with id '$SubscriptionId'." # check if the subscription name conforms to the naming convention. if ($SubscriptionName -notmatch $lzSubscriptionName -and $SubscriptionName -notmatch $iamSubscriptionName) { Write-Host "Subscription name '$SubscriptionName' does not conform with subscription naming conventions '$lzSubscriptionName' or '$iamSubscriptionName'...Skipping." -ForegroundColor Yellow return } # if the $subscriptioinName ends with a -iam suffix change the $managementTfAssets with adding management-group path to it. if ($SubscriptionName -match $iamSubscriptionName) { $managementTfAssets = Join-Path $managementTfAssets "management-group" #TODO find the path of the main.tf file in this folder strucutre. $TerraformFilePath = Join-Path $managementTfAssets $TerraformFileName } # TODO: if .terraform is not present go into stateStorage and create the resource group using powershell command called rg-management # run the main.tf file for the state storage. # go back to the $managementTfAssets folder and run the main.tf file. # call the Invoke-TerraformOperations function to validate, plan and apply the $TerraformFilePath Invoke-TerraformOperations -DestinationPath $managementTfAssets -TerraformFilePath $TerraformFilePath -WhatIf:$WhatIf if (!$?) { throw "Failed to initialize the subscription '$SubscriptionName' with id '$SubscriptionId'." } # delete the environment variables ARM_CLIENT_ID and ARM_CLIENT_SECRET no mattrer what the outcome of the terraform operations is. Clear-TerraformEnvironmentVariables if (!$?) { throw "Failed to clean up the environment variables." } # only remove the downloaded assets if the -RemoveArtifacts flag is set and the assets were downloaded if ($RemoveArtifacts -and $assetDownloadStatus) { Write-VerboseOnly -Message "Removing the downloaded assets from '$assetDownloadPath'." Remove-Item -Path $assetDownloadPath -Recurse } } <# .Synopsis Initialize-CafSubscription calls this implicitly with the correct parameters. .Description Installs the management-resources folder to the root of the infrastructure folder. .Parameter DestinationPath The path to the root folder infrastrucutre files. .Parameter DeploymentTechType The deployment technology to use for the deployment. Default is 'bicep'. .Example Install-CafAssets -DestinationPath "(projectName)/infrastrucutre" -DeploymentTechType "bicep" #> function Install-CafAssets { [CmdLetBinding()] param ( [Parameter(Mandatory = $true)] [string] $DestinationPath, [string] [validateSet("bicep", "terraform")] $DeploymentTechType = "bicep" ) process { $cafAssetPath = "$DestinationPath" # remove management-resources folder if it exists if (Test-Path -Path $cafAssetPath) { Write-Host "Removing old CAF infrastructure assets...." -ForegroundColor Yellow Remove-Item -Path $cafAssetPath -Recurse | Out-Null New-Item -Path $cafAssetPath -ItemType Directory | Out-Null } # create the directory if it does not exist else { New-Item -Path $cafAssetPath -ItemType Directory | Out-Null } # Define the URL of the GitHub repository zip $zipUrl = "https://github.com/DEVDEER/CafAssets/archive/refs/heads/main.zip" # Define the path to download the zip file $zipFile = "$cafAssetPath/cafAssets.zip" # Download the zip file from the GitHub repository Invoke-WebRequest $zipUrl -OutFile $zipFile | Out-Null if (!$?) { throw "Failed to download the infrastrucutre assets" } # Extract the zip file Expand-Archive -Path $zipFile -DestinationPath $cafAssetPath -Force | Out-Null if (!$?) { throw "Failed to extract the infrastrucutre assets zip file" } Write-Host "Installed new CAF infrastrucutre assets" -ForegroundColor Green # Move the management-resources folder to the root directory Move-Item -Path "$cafAssetPath/CafAssets-main/infrastructure/$DeploymentTechType" -Destination $cafAssetPath | Out-Null # Remove the downloaded zip file and the extracted repository folder Remove-Item -Path $zipFile -Force | Out-Null Remove-Item -Path "$cafAssetPath/CafAssets-main" -Recurse -Force | Out-Null } } # TODO: Should contain the logic to run Terraform commands i.e. terraform init, terraform plan, terraform validate, terraform apply # Should be called by the Initialize-SubscriptionUsingTerraform function and later the New-CafDeployment function function Invoke-TerraformOperations { [CmdLetBinding()] param ( [Parameter(Mandatory = $true)] [string] $DestinationPath, [string] [validateSet("terraform")] $DeploymentTechType = "terraform", [string] $TerraformFileName = "main.tf", [switch] $WhatIf ) process { # Change the directory to the destination path which contains the terraform file Set-Location $DestinationPath if (!$?) { Write-Error "Failed to change the directory to '$DestinationPath'." return } Write-VerboseOnly "PWD is '$DestinationPath'." Write-VerboseOnly "Terraform file is '$TerraformFileName'." if (!(Test-Path ".terraform" -ErrorAction Ignore)) { Write-Host "Initializing the terraform file '$TerraformFileName'." # terraform init the main.tf file terraform init -input=false if (!$?) { Clear-TerraformEnvironmentVariables Write-Error "Failed to initialize the terraform file '$TerraformFileName'." return } } # terraform validate the main.tf file Write-Host "Validating Terraform for $TerraformFileName..." terraform validate if (!$?) { Clear-TerraformEnvironmentVariables Write-Error "Failed to validate the terraform file '$TerraformFileName'." return } # terraform plan the main.tf file Write-Host "Planning the terraform file '$TerraformFileName'." terraform plan -out main.tfplan -input=false if (!$?) { Clear-TerraformEnvironmentVariables Write-Error "Failed to plan the terraform file '$TerraformFileName'." return } # terraform apply the $TerraformFile (only perform this when the -WhatIf flag is not set) if (!$WhatIf) { Write-Host "Applying the terraform file '$TerraformFileName'." terraform apply -input=false main.tfplan if (!$?) { Clear-TerraformEnvironmentVariables Write-Error "Failed to apply the terraform file '$TerraformFileName'." return } } } } <# .Synopsis Sets the environment variables used while performing a terraform deployment. .Description This function sets the environment variables ARM_CLIENT_ID and ARM_CLIENT_SECRET by reading the information from the central key vault. .PARAMETER KeyVaultName The name of the key vault where the service principal is stored. .PARAMETER SpId The name of the secret in the key vault that contains the service principal id. Default is TerraformDeploySpId. .PARAMETER SpSecret The name of the secret in the key vault that contains the service principal password. Default is TerraformDeploySpPassword. .Example Set-TerraformEnvironmentVariables -KeyVaultName "akv-companyname" #> function Set-TerraformEnvironmentVariables { [CmdLetBinding()] param ( [string] $KeyVaultName, [string] $SpId = "TerraformDeploySpId", [string] $SpSecret = "TerraformDeploySpPassword" ) process { $ErrorActionPreference = 'Stop' # if the key vault name is not provided, use the naming convention to build the key vault name if (!$KeyVaultName) { $ctx = Use-CafContext # TODO use the new naming convention command to build it. # build the name of the key vault. The convention is prefix-companyName $prefix = $ctx.namingConvention.keyVault.prefix $companyName = $ctx.companyName $separator = $ctx.namingConvention.keyVault.separator $KeyVaultName = $prefix + $separator + $companyName } # Ensure that such a key vault exists $KvNameQuery = @" resources | where type == "microsoft.keyvault/vaults" | where name == "$KeyVaultName" "@ $KvResult = Search-AzGraph -Query $KvNameQuery if (!$KvResult) { Write-VerboseOnly "Could not find the key vault: '$KeyVaultName'." Write-Host "Could not find key vault" -ForegroundColor Red return } # Set the environment variables required for the terraform deployment $env:ARM_CLIENT_ID = (Get-AzKeyVaultSecret -VaultName $KeyVaultName -SecretName $SpId -AsPlainText) if (!$?) { Write-VerboseOnly "Could not read the service principal: '$SpId' from Key Vault: '$KeyVaultName'." Write-Host "Could not read service principal id from Key Vault" -ForegroundColor Red return } $env:ARM_CLIENT_SECRET = (Get-AzKeyVaultSecret -VaultName $KeyVaultName -SecretName $SpSecret -AsPlainText) if (!$?) { Write-VerboseOnly "Could not read the service principal password name: '$SpSecret' from Key Vault: '$KeyVaultName'." Clear-TerraformEnvironmentVariables return } Write-Host "Service principal stored in environment." -ForegroundColor Green } } Function Write-HostMessage { [CmdLetBinding()] param ( [Parameter(Mandatory=$true, Position=0)] [string] $Message, [Parameter(Mandatory=$false, Position=1)] [ValidateSet('Error', 'Warning', 'Information', 'Verbose', 'Debug', 'Success')] [string] $Level = 'Information', [switch] $NoNewLine ) begin { $color = (get-host).ui.rawui.ForegroundColor switch ($Level) { 'Error' { $color = 'Red' } 'Warning' { $color = 'Yellow' } 'Information' { $color = 'White' } 'Verbose' { $color = 'Cyan' } 'Success' { $color = 'Green' } 'Debug' { $color = 'DarkGray' } default { throw "Invalid log level: $_" } } } process { Write-Host $Message -ForegroundColor $color -NoNewline:$NoNewLine } } Function Write-HostError { param ( [Parameter(Mandatory=$true, Position=0)] [string] $Message, [switch] $NoNewLine ) process { Write-HostMessage -Message $Message -Level 'Error' -NoNewline:$NoNewLine } } Function Write-HostInfo { param ( [Parameter(Mandatory=$true, Position=0)] [string] $Message, [switch] $NoNewLine ) process { Write-HostMessage -Message $Message -Level 'Information' -NoNewline:$NoNewLine } } Function Write-HostDebug { param ( [Parameter(Mandatory=$true, Position=0)] [string] $Message, [switch] $NoNewLine ) process { Write-HostMessage -Message $Message -Level 'Debug' -NoNewline:$NoNewLine } } Function Write-HostWarning { param ( [Parameter(Mandatory=$true, Position=0)] [string] $Message, [switch] $NoNewLine ) process { Write-HostMessage -Message $Message -Level 'Warning' -NoNewline:$NoNewLine } } Function Write-HostSuccess { param ( [Parameter(Mandatory=$true, Position=0)] [string] $Message, [switch] $NoNewLine ) process { Write-HostMessage -Message $Message -Level 'Success' -NoNewline:$NoNewLine } } <# .Synopsis Writes the DEVDEER logo and module info to the output. .Description This writes a nice ASCII art logo and some module info to the host. You can set $env:NO_DEVDEER_CAF_LOGO to any value to prevent this from happening. .Example Write-Logo #> Function Write-Logo { [CmdLetBinding()] param ( ) process { if ($env:NO_DEVDEER_CAF_LOGO) { return } $set = Get-Variable DEVDEER_CAF_LOGO_WRITTEN -ErrorAction SilentlyContinue if ($set) { return } $module = Get-Module -Name Devdeer.Caf $moduleName = $module.Name $moduleVersion = $module.Version.ToString(); $encoded = 'DQogICAgX19fXyAgX19fX19fXyAgICBfX19fX18gIF9fX19fX19fX19fX19fX18gDQogICAvIF9fIFwvIF9fX18vIHwgIC8gLyBfXyBcLyBfX19fLyBfX19fLyBfXyBcDQogIC8gLyAvIC8gX18vICB8IHwgLyAvIC8gLyAvIF9fLyAvIF9fLyAvIC9fLyAvDQogLyAvXy8gLyAvX19fICB8IHwvIC8gL18vIC8gL19fXy8gL19fXy8gXywgXy8gDQovX19fX18vX19fX18vICB8X19fL19fX19fL19fX19fL19fX19fL18vIHxffA0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIA==' $logo = [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($encoded)) Write-Host $logo -ForegroundColor Blue Write-Host "Module $moduleName | Version $moduleVersion | DEVDEER GmbH | https://devdeer.com" Write-VerboseOnly $module.Description Write-Host Set-Variable DEVDEER_CAF_LOGO_WRITTEN YES -Scope Global } } <# .Synopsis Writes the given message to the host if verbose flag is set. .Description The message only is written if the calling function context was invoked using the PowerShell "-Verbose" switch. .Parameter Message The message to write to the host. .Example Write-VerboseOnly "Hello" #> function Write-VerboseOnly { [CmdLetBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Message, [switch] $NoNewline, [ConsoleColor] $ForegroundColor = [System.ConsoleColor]::Yellow ) $verbose = $PSBoundParameters['Verbose'] -or $VerbosePreference -eq 'Continue' if ($verbose) { $Message = "VERBOSE: $Message" Write-Host $Message -ForegroundColor $ForegroundColor -NoNewline:$NoNewline } } Export-ModuleMember -Function Approve-PimRole Export-ModuleMember -Function Clear-AllSqlFirewallRules Export-ModuleMember -Function Clear-PolicyAssets Export-ModuleMember -Function Deploy-PolicyAssets Export-ModuleMember -Function Get-Context Export-ModuleMember -Function Initialize-DeploymentSpGroup Export-ModuleMember -Function Initialize-ServicePrincipals Export-ModuleMember -Function Initialize-Subscription Export-ModuleMember -Function Initialize-Subscriptions Export-ModuleMember -Function New-Deployment Export-ModuleMember -Function New-SqlFirewallRule Export-ModuleMember -Function Remove-Locks Export-ModuleMember -Function Restore-Locks Export-ModuleMember -Function Set-ServicePrincipal Export-ModuleMember -Function Show-NamingConvention Export-ModuleMember -Function Start-PimGroup Export-ModuleMember -Function Start-PimRole Export-ModuleMember -Function Start-Scoped Export-ModuleMember -Function Stop-PimGroup Export-ModuleMember -Function Stop-PimRole Export-ModuleMember -Function Use-Context Export-ModuleMember -Function Use-ServicePrincipal |