Devdeer.Azure.psm1

<#
 .Synopsis
 Removes all firewall rules currently added to the SQL server given.
 .Description
 Removes all firewall rules currently added to the SQL server given.
 .Parameter SubscriptionId
 The unique ID of the Azure subscription where the SQL Azure Server is located.
 .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-AzdAllSqlFirewallRules -SubscriptionId [Id] -SqlServerName mySQLServerName
  Removes all existing firewall rules from server `mySQLServerName`
#>

Function Clear-AllSqlFirewallRules {
    [CmdLetBinding()]
    Param (        
        [Parameter(Mandatory = $true)] [string] $AzureSqlServerName,        
        [Parameter(Mandatory = $true)] [string] $SubscriptionId,
        [Parameter(Mandatory = $false)] [string] $TenantId,
        [switch] $NoLogo    
    )
    begin {
        if (!$NoLogo.IsPresent) {
            Write-Logo $MyInvocation.MyCommand            
        }
        Import-Module Az        
        # ensure that we are at the correct subscription
        Set-SubscriptionContext -TenantId $TenantId -SubscriptionId $SubscriptionId -NoLogo
        if (!$?) {
            Write-HostError "Could not set context." 
            return
        }
    }
    process    {                
        $server = Get-AzSqlServer | Where-Object -Property ServerName -EQ $AzureSqlServerName
        if (!$server) {
            Write-HostError "Could not find SQL Azure Server $AzureSqlServerName in subscription $SubscriptionId"
            return
        }
        $existintRules = Get-AzSqlServerFirewallRule -ServerName $server.ServerName -ResourceGroupName $server.ResourceGroupName
        $amount = $existintRules.Length
        if ($amount -eq 0) {
            Write-HostError "Terminating because no firewall rules where found on Azure SQL $AzureSqlServerName"
            return
        }    
        Write-HostInfo "Found $amount firewall rules on server $AzureSqlServerName"
        Write-HostDebug "Removing no-delete-rules from resource group"
        $locks = Remove-NoDeleteLocksForResourceGroup -ResourceGroupName $server.ResourceGroupName
        foreach ($rule in $existintRules) {
            $ruleName = $rule.FirewallRuleName
            if ($ruleName -ne "AllowAllWindowsAzureIps") {
                Remove-AzSqlServerFirewallRule -ServerName $server.ServerName -ResourceGroupName $server.ResourceGroupName -FirewallRuleName $ruleName | Out-Null
                if (!$?) {
                    Write-HostError "Failed to remove firewall rules: $_"
                }
                Write-Host "Removed rule $ruleName" -ForegroundColor Cyan
            }
            else {
                Write-HostDebug "Ignoring default rule $ruleName" 
            }
        }    
    }
    end {
        Write-HostSuccess "Removed all firewall rules from server $AzureSqlServerName"        
        if ($locks) {
            Write-HostDebug "Re-adding no-delete-rules for resource group" -NoNewline
            New-NoDeleteLocksForResourceGroup -ResourceGroupName $server.ResourceGroupName -Locks $locks 
            Write-HostSuccess "Done"            
        } else {
            Write-HostDebug "Skipping re-adding of locks because no locks where found prior to the operation."
        }
    }
}

<#
 .Synopsis
 Syncs a local folder to an Azure storage account recursively.
 .Description
 The app is registered in the given tenant including reply URL and unique ID and gets access
 service principal. Optionally, permissions to external APIs can be added and granted.
 .Parameter TenantId
 The GUID of the AAD where you want to create the app registration in.
 .Parameter SubscriptionId
 The GUID of the Azure Subscription the target storage account is located at.
 .Parameter AccountName
 The unique name of the storage account.
 .Parameter ContainerName
 The name of the storage container where the files should be synced to.
 .Parameter SourceDir
 The path to the local directory or an UNC path where the files are located at.
 .Parameter ExpiryHours
 The expiration time for the SAS token in hours (defaults to 1).
 .Example
 Sync-AzdStorageContainer -TenantId 00000-00000000-000000-000 -SubscriptionId 00000-00000000-000000-000 -AccountName stoddyourname -AccountKey SECRETKEY== -ContainerName uploads -SourceDir C:\Temp\
 Sync local folder C:\temp recursively to storage container
#>

Function Copy-ToStorageContainer {
    [CmdLetBinding()]
    param (        
        [Parameter(Mandatory = $true)] [string] $TenantId,
        [Parameter(Mandatory = $true)] [string] $SubscriptionId,
        [Parameter(Mandatory = $true)] [string] $AccountName,        
        [Parameter(Mandatory = $true)] [string] $ContainerName,                                
        [Parameter(Mandatory = $true)] [string] $SourceDir,
        [int] $ExpiryHours = 1,
        [switch] $NoLogo
    )
    begin {
        if (!$NoLogo.IsPresent) {
            Write-Logo $MyInvocation.MyCommand            
        }
        Import-Module Az        
        # ensure that we are at the correct subscription
        Set-SubscriptionContext -TenantId $TenantId -SubscriptionId $SubscriptionId -NoLogo
        if (!$?) {
            Write-HostError "Could not set context." 
            return
        }    
    }
    process {
        # get storage context
        $ctx = New-AzStorageContext -StorageAccountName $AccountName -UseConnectedAccount
        # calculate time stamps
        $startTime = Get-Date
        $expiryTime = $startTime.AddHours($ExpiryHours)
        # use context to retrieve SAS token
        $sas = Get-AzStorageContainer -Container $ContainerName -Context $ctx | New-AzStorageContainerSASToken -Permission rwdl -StartTime $startTime -ExpiryTime $expiryTime
        # perform the sync
        azcopy sync $SourceDir "https://$AccountName.blob.core.windows.net/$ContainerName$sas" --recursive=true  
    }
}

<#
 .Synopsis
 Replaces a function call in a JSON file with the contents of a given file.
 .Description
 It is a specialized version of Devdeer.Tools.Invoke-JsonContentReplacement which assumes that the
 JSON proviced in the remote file represents an ARM definition JSON including one JSON-section named
 `definition`.
 .Parameter File
 The path to the file in which the replacement should happen and which has the $FunctionName defined.
 .Parameter FunctionName
 The name of the function inside the $File (e.g. `[FunctionName('filePath')]`)
 .OUTPUTS
 System.String. Import-ArmTemplateJson returns the original content of the file so that the caller can undo replacement later.
 .Example
 Import-AzdArmTemplateJson -File .\parameters.json -FunctionName getJson
 Insert JSON based on the function `getJson` in the `parameters.json` file. You would use
 `[getJson('sample.json')]` to define the replace position and the source file for the JSON.
#>

Function Import-ArmTemplateJson {
    param (        
        [Parameter(Mandatory = $true)] [string] $File,
        [Parameter(Mandatory = $true)] [string] $FunctionName,
        [bool] $MakeOneLine = $true    
    )
    $ParameterFileContent = Get-Content $File
    $fileName = [regex]::Matches($ParameterFileContent, "$FunctionName\('(.*?)'").captures.groups[1].value
    $jsonContent = (Get-Content $fileName -Raw)    
    if ($MakeOneLine) {
        $jsonContent = $jsonContent.Replace("`n", "").Replace(" ", "")
    }
    $jsonContent = [regex]::Matches($jsonContent, '{"definition":(.*)}{1}$').captures.groups[1].value
    $jsonContent = $jsonContent.Replace('"', '\"')
    $result = [regex]::Replace($ParameterFileContent, "[\[]$FunctionName\('(.*?)'\)[\]]", $jsonContent)
    Set-Content $File -Value $result
    return $oldContent    
}

<#
 .Synopsis
 Adds specific AAD API permissions for a single API to an existing app.
 .Description
 Adds specific AAD API permissions for a single API to an existing app. If the app does not exist the step just does nothing.
 .Parameter AppObjectId
 The Object ID of the app for which to add the permission.
 .Parameter RequiredPermissionsAppDisplayName
 Display name of a service principal (provider) in AAD which should be added to the app permissions.
 .Parameter RequiredPermissions
 Array of permissions which should be granted on the app.
 .Example
 New-AzdAppPermission -AppObjectId 00000-00000000-000000-000 -RequiredPermissionsAppDisplayName 'Microsoft Graph' -RequiredPermissions 'User.Read'
 Add permission for Graph
#>

Function New-AppPermission {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $true)] [string] $TenantId,
        [Parameter(Mandatory = $true)] [string] $AppObjectId,
        [Parameter(Mandatory = $true)] [string] $RequiredPermissionsAppDisplayName,
        [Parameter(Mandatory = $true)] $RequiredPermissions,
        [switch] $NoLogo
    )    
    begin {
        if (!$NoLogo.IsPresent) {
            Write-Logo $MyInvocation.MyCommand            
        }
        Import-Module Az
        Set-SubscriptionContext -TenantId $TenantId -NoLogo
        if (!$?) {
            Write-HostError "Could not set context." 
            return
        }    
    }
    process {            
        $app = Get-ADApplication | Where-Object { $_.ObjectId -eq $AppObjectId }
        if ($app) {
            Add-AppPermission -app $app -permissionPrincipalDisplayName $RequiredPermissionsAppDisplayName -permissions $RequiredPermissions
        }
    }
}

<#
 .Synopsis
 Creates an AAD app registration following DEVDEER's conventions.
 .Description
 The app is registered in the given tenant including reply URL and unique ID and gets access
 service principal. Optionally, permissions to external APIs can be added and granted.
 .Parameter TenantId
  The GUID of the AAD where you want to create the app registration in.
 .Parameter DisplayName
 A string which will appear as a human-readable name in AAD.
 .Parameter ReplyUrl
 An URI that will be used as the reply URL (aka redirect URI).
 .Parameter IdentifierUri
 A URI formatted unique identifier for the app.
 .Parameter AllowImplicitFlow
 If set to $true implicit flow (ID tokens) will be enabled.
 .Parameter RequiredPermissionsAppDisplayName
 Optional display name of a service principal (provider) in AAD which should be added to the app permissions.
 .Parameter RequiredPermissions
 Optional array of permissions which should be granted on the TargetServicePrincipalName.
 .Parameter Homepage
 Optional URI of the providers homepage.
 .Parameter LogoFilePath
 Optional URI to a local file for the logo.
 .Example
 New-AzdAppRegistration -TenantId 00000-00000000-000000-000 -DisplayName 'MyFirstApp' -ReplyUrl 'https://myapp.com/signin-oidc' -IdentifierUri 'ui://myapp.com' -LogoFilePath 'c:\temp\logo.jpg' -Homepage 'https://company.com'
 Create app without permissions
 .Example
 New-AzdAppRegistration -TenantId 00000-00000000-000000-000 -DisplayName 'MyFirstApp' -ReplyUrl 'https://myapp.com/signin-oidc' -IdentifierUri 'ui://myapp.com' -RequiredPermissionsAppDisplayName 'Microsoft Graph' -RequiredPermissions 'User.Read'
 Create app with permissions on Microsoft Graph
#>

Function New-AppRegistration {
    [CmdLetBinding()]
    param (        
        [Parameter(Mandatory = $true)] [string] $TenantId,
        [Parameter(Mandatory = $true)] [string] $DisplayName,
        [Parameter(Mandatory = $true)] [string] $ReplyUrl,                                
        [Parameter(Mandatory = $true)] [bool] $AllowImplicitFlow,
        [Parameter(Mandatory = $false)] [bool] $GenerateSecret = $false,
        [Parameter(Mandatory = $false)] [string] $IdentifierUri,
        [Parameter(Mandatory = $false)] [string] $RequiredPermissionsAppDisplayName,
        [Parameter(Mandatory = $false)] $RequiredPermissions,
        [Parameter(Mandatory = $false)] [string] $Homepage,
        [Parameter(Mandatory = $false)] [string] $LogoFilePath,
        [switch] $NoLogo
    )
    begin {
        if (!$NoLogo.IsPresent) {
            Write-Logo $MyInvocation.MyCommand            
        }
        Import-Module Az        
        Set-SubscriptionContext -TenantId $TenantId -NoLogo
        if (!$?) {
            Write-HostError "Could not set context." 
            return
        }    
    }
    process {
        $app = New-App -displayName $DisplayName -replyUrls $ReplyUrl -identifierUri $IdentifierUri -homePage $Homepage -allowImplicitFlow $AllowImplicitFlow -logoFilePath $LogoFilePath -generateSecret $GenerateSecret
        Write-Host " "        
        Out-MultiColor     -firstPart "Application " -firstColor Gray -secondPart $DisplayName -secondColor White
        Write-Host "--------------------------------------------------------" -ForegroundColor DarkGray
        Out-MultiColor     -firstPart "Client ID: " -firstColor Gray -secondPart $app.AppId -secondColor Yellow
        Remove-AppExposedScopeIfExists -app $app -scopeName 'user_impersonation'
        Add-AppExposedScope -app $app -scopeName 'full'
        Add-AppPermission -app $app -permissionPrincipalDisplayName $RequiredPermissionsAppDisplayName -permissions $RequiredPermissions
        return $app
    }
}

<#
.Synopsis
Starts an Azure ARM Template deployment with a scope defined in the template.
.Description
This CmdLet will wrap the complete logic and preparation for a deployment in a single command. It uses New-AzResourceGroupDeployment internally.
.Parameter Stage
 The short name of the stage with a capitalized first letter (e.g. 'Test', 'Prod', 'Demo', 'Int')
.Parameter TenantId
The GUID of the Azure Tenant in which the subscription resides.
.Parameter SubscriptionId
The GUID of the subscription to which the deployment should be applied.
.Parameter ProjectName
The name of the project which will be used to build the name of the resource group and the resources. Leave this empty if your template parameter
file contane of the following keys defining the name: project-name, projectName, ProjectName, project or Project.
.Parameter ResourceGroupLocation
The Azure location for the resource group (defaults to 'West Europe').
.Parameter TemplateFile
The path to the template file (if empty the script searches for 'azuredeploy.json' in the current directory).
.Parameter TemplateParameterFile
Optional path to the template parameter file in JSON format.
.Parameter WhatIf
If set to $true a WhatIf-deployment fill be performed.
.Example
New-AzdArmDeployment -Stage Test -TenantId 00000-00000-00000 -SubscriptionId 000000-00000-000000-00000 -WhatIf -TemplateFile c:\temp\azuredeploy.json
Execute an ARM deployment for the Test stage using a deployment file in c:\temp folder
#>

Function New-ArmDeployment {
    [CmdLetBinding()]
    param (        
        [Parameter(Mandatory = $true)] [string] $Stage,        
        [Parameter(Mandatory = $true)] [string] $TenantId,
        [Parameter(Mandatory = $true)] [string] $SubscriptionId,
        [string] $ProjectName,
        [string] $Location = "West Europe",
        [string] $TemplateFile = '.\azuredeploy.json',
        [string] $TemplateParameterFile,
        [switch] $WhatIf,
        [switch] $NoLogo
    )
    begin {
        if (!$NoLogo.IsPresent) {
            Write-Logo $MyInvocation.MyCommand            
        }
        Import-Module Az
        try {
            [Microsoft.Azure.Common.Authentication.AzureSession]::ClientFactory.AddUserAgent("VSAzureTools-$UI$($host.name)".replace(' ', '_'), '3.0.0')
        } 
        catch {
        }    
        Set-StrictMode -Version 3
        # check if deployment file exists
        $exists = Test-Path $TemplateFile -PathType Leaf
        if (!$exists) {
            throw "File $TemplateFile not found." 
        }                        
        # build deployment name
        $DeploymentName = ((Get-ChildItem $TemplateFile).BaseName + '-' + ((Get-Date).ToUniversalTime()).ToString('MMdd-HHmm'));
        # ensure Azure context
        Write-HostDebug 'Setting Azure context...'
        Set-SubscriptionContext -TenantId $TenantId -SubscriptionId $SubscriptionId -NoLogo
        if (!$?) {
            Write-HostError "Could not set context." 
            return
        }        
    }
    process {
        # build resource group name
        try {
            New-AzDeployment `
                -Name $DeploymentName `
                -Location $Location `
                -TemplateFile $TemplateFile `
                -TemplateParameterFile $TemplateParameterFile `
                -Verbose `
                -WhatIf:$WhatIf    
                return 1
        } catch {
            return -1
        }    
    }
}

<#
.Synopsis
Starts an Azure ARM Template deployment for a single resource group.
.Description
This CmdLet will wrap the complete logic and preparation for a deployment in a single command. It uses New-AzResourceGroupDeployment internally.
.Parameter TenantId
The GUID of the Azure Tenant in which the subscription resides.
.Parameter SubscriptionId
The GUID of the subscription to which the deployment should be applied.
.Parameter Stage
The short name of the stage with a capitalized first letter (e.g. 'Test', 'Prod', 'Demo', 'Int')
.Parameter ResourceGroupTags
The tag that shall be assigned as purpose to the created resource group.
.Parameter ResourceGroupName
Optional
.Parameter DeleteOnFailure
Indicates if the resource group should be deleted on any error.
.Parameter DoNotSetResourceGroupLock
Indicates if a no-delete-lock should NOT be applied to the ressource group.
.Parameter ProjectName
The name of the project which will be used to build the name of the resource group and the resources. Leave this empty if your template parameter
file contains one of the following keys defining the name: project-name, projectName, ProjectName, project or Project.
.Parameter ResourceGroupLocation
The Azure location for the resource group (defaults to 'West Europe').
.Parameter TemplateFile
The path to the template file (ARM-JSON or BICEP) (if empty the script searches for 'azuredeploy.(json)' in the current directory).
.Parameter TemplateParameterFile
Optional path to the template parameter file in JSON format (if empty the script searches for the matching file built from template file and stage).
.Parameter WhatIf
If set to $true a WhatIf-deployment fill be performed.
.Parameter NoParams
If set to $true the script will assume that no template parameter file is needed.
.Example
New-AzdArmGroupmDeployment -Stage Test -TenantId 00000-00000-00000 -SubscriptionId 000000-00000-000000-00000 -WhatIf -TemplateFile c:\temp\azuredeploy.json
Execute an ARM deployment for the Test stage using a deployment file in c:\temp folder
#>

Function New-ArmGroupDeployment {
    [CmdLetBinding()]
    Param (                
        [Parameter(Mandatory = $true)] [string] $TenantId,
        [Parameter(Mandatory = $true)] [string] $SubscriptionId,
        [string] $Stage,
        [Hashtable] $ResourceGroupTags,
        [string] $ResourceGroupName,
        [switch] $DoNotSetResourceGroupLock,
        [switch] $DeleteOnFailure,
        [string] $ProjectName,
        [string] $ResourceGroupLocation = "West Europe",
        [string] $TemplateFile = '.\azuredeploy.json',
        [string] $TemplateParameterFile,
        [switch] $WhatIf,
        [switch] $NoParams,
        [switch] $NoLogo
    )
    begin {
        if (!$NoLogo.IsPresent) {
            Write-Logo $MyInvocation.MyCommand            
        }
        Import-Module Az
        try {
            [Microsoft.Azure.Common.Authentication.AzureSession]::ClientFactory.AddUserAgent("VSAzureTools-$UI$($host.name)".replace(' ', '_'), '3.0.0')
        } 
        catch {
        }    
        Set-StrictMode -Version 3
        # if stage is empty AND no group name was defined this script cannot work
        if ([string]::IsNullOrEmpty($Stage) -and [string]::IsNullOrEmpty($ResourceGroupName)) {
            throw "If you want to deploy without staging you have to define the resource group name explicitely." 
        }
        # check if deployment file exists
        $exists = Test-Path $TemplateFile -PathType Leaf
        if (!$exists) {
            throw "Template file $TemplateFile not found." 
        }        
        if (!$NoParams.IsPresent) {
            # get base directory and parameter file name for stage
            $item = Get-Item $TemplateFile         
            $path = $item.DirectoryName        
            $fileName = $item.Name.Substring(0, $item.Name.Length - $item.Extension.Length)    
            if ([string]::IsNullOrEmpty($TemplateParameterFile)) {
                $TemplateParameterFile = "$path\$fileName.parameters.$Stage.json"
            }
            # check if parameter file exists
            $exists = Test-Path $TemplateParameterFile -PathType Leaf
            if (!$exists) {
                throw "File $TemplateParameterFile not found." 
            }
        }
        # build resource group tags
        $tagsDefined = $false
        if ($ResourceGroupTags) {
            if($ResourceGroupTags.Count -eq 0) {                
                $ResourceGroupTags = @{ purpose = $Stage }
                $tagsDefined = $true
            }
        }
        if([string]::IsNullOrEmpty($Stage) -and !$tagsDefined) {
            throw "You must define a stage or tags."
        }
        # build deployment name
        $DeploymentName = ((Get-ChildItem $TemplateFile).BaseName + '-' + ((Get-Date).ToUniversalTime()).ToString('MMdd-HHmm'));
        # ensure Azure context
        Write-Information 'Setting Azure context...'
        Set-SubscriptionContext -Subscription $SubscriptionId -Tenant $TenantId -NoLogo
        if (!$?) {        
            Write-HostError "Could not set subscription context."
            return
        }
        # build resource group name
        Write-HostDebug 'Determining resource group name...'    
        if ($ProjectName.Length -eq 0) {
            # try to read project name from the parameter file
            Write-HostInfo "Trying to read project name from $TemplateParameterFile"
            $json = Get-Content $TemplateParameterFile -Raw  | ConvertFrom-Json            
            $projectNameKeys = @( "project-name", "projectName", "ProjectName", "project", "Project" )
            foreach ($key in $projectNameKeys) {
                $tmp = $null
                try {
                    $tmp = $json.parameters.$key.value
                }
                catch {
                    Write-HostDebug "Key '$key' not found in parameter file" 
                }
                if ($tmp) {
                    Write-HostInfo "Found entry with key '$key' and value '$tmp'"
                    $ProjectName = $tmp
                    break
                }
            }
        }
        if ($ProjectName.Length -eq 0 -and $ResourceGroupName.Length -eq 0) {    
            throw "No project name or resource group name set."
        }
        if ($ResourceGroupName.Length -eq 0) {
            # build resource group name
            $ResourceGroupName = "rg-$ProjectName-$Stage".ToLowerInvariant()    
        }
        Write-HostDebug "Using resource group name $ResourceGroupName."        
        if (!$WhatIf.IsPresent) {
            # Create the resource group only when it doesn't already exist
            if ($null -eq (Get-AzResourceGroup -Name $ResourceGroupName -Location $ResourceGroupLocation -Verbose -ErrorAction SilentlyContinue)) {
                Write-HostDebug "Creating resource group $ResourceGroupName..."
                New-AzResourceGroup -Name $ResourceGroupName -Location $ResourceGroupLocation -Tags $ResourceGroupTags -Verbose -Force -ErrorAction Stop 
                
                Write-HostInfo "Resource group created."
            
                if (!$DoNotSetResourceGroupLock.IsPresent) {
                    # caller wants us to ensure the delete lock
                    Write-HostDebug "Set No-Delete-Lock for $ResourceGroupName..."
                    New-AzResourceLock -LockName no-delete -LockLevel CanNotDelete -ResourceGroupName $ResourceGroupName -Force -ErrorAction Stop
                    Write-HostInfo "No-Delete Lock is set."
                }
            } 
            else {
                Write-HostDebug "Resource group $ResourceGroupName already exists."
            }
        }
    }
    process {
        Write-HostDebug "Starting template deployment with template $TemplateParameterFile ..."
        try {
            New-AzResourceGroupDeployment `
                -Name $DeploymentName `
                -ResourceGroupName $ResourceGroupName `
                -TemplateFile $TemplateFile `
                -TemplateParameterFile $TemplateParameterFile `
                -Force -Verbose `
                -WhatIf:$WhatIf
            return 1
        }
        catch {
            if (!$WhatIf.IsPresent -and $DeleteOnFailure) {                
                Write-HostInfo "Are you sure you want to delete the resource group now? (y/n)" -NoNewline
                $hostInput = Read-Host
                if ($hostInput -eq 'y') {
                    # user wants us to delete the resource group if the deployment failed
                    Write-HostDebug "Removing resource group $ResourceGroupName..."
                    Get-AzResourceLock -ResourceGroupName $ResourceGroupName -AtScope | Remove-AzResourceLock -Force
                    Remove-AzResourceGroup -Name $ResourceGroupName -Force
                    Write-HostInfo "Resource group deleted" 
                }
            }
            return -1
        }    
    }
}

<#
 .Synopsis
 Ensures that a no-delete lock is directly on a resource group in Azure and creates one if none is existing.
 .Description
 Removes all firewall rules currently added to the SQL server given.
 .Parameter SubscriptionId
 The unique ID of the Azure subscription where the SQL Azure Server is located.
 .Parameter ResourceGroupName
 The name of the resource group
 .Parameter ResourceGroupLocation
 The Azure location of the resource group.
 .Parameter TenantId
 The unique ID of the tenant where the subscription lives in for faster context switch.
 .Example
  Set-AzdResourceGroupDeleteLock -SubscriptionId [Id] -ResourceGroupName rg-test -ResourceGroupLocation westeurope
  Ensures that a no-delete lock is directly on the resource group.
#>

Function New-ResourceGroupDeleteLock {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $true)] [string] $SubscriptionId,
        [Parameter(Mandatory = $true)] [string] $ResourceGroupName,        
        [Parameter(Mandatory = $true)] [string] $ResourceGroupLocation,
        [Parameter(Mandatory = $false)] [string] $TenantId,
        [switch] $NoLogo
    )
    begin {
        if (!$NoLogo.IsPresent) {
            Write-Logo $MyInvocation.MyCommand            
        }
    }
    process {
        if ($null -ne (Get-AzResourceGroup -Name $ResourceGroupName -Location $ResourceGroupLocation -Verbose -ErrorAction SilentlyContinue)) {                        
            if ($null -eq (Get-AzResourceLock -AtScope -ResourceGroupName $ResourceGroupName | Where-Object { $_.Properties.level -eq "CanNotDelete" })) {
                Write-HostDebug "Set No-Delete-Lock for $ResourceGroupName..."
                New-AzResourceLock -LockName nodelete -LockLevel CanNotDelete -ResourceGroupName $ResourceGroupName -Force -ErrorAction Stop | Out-Null
                Write-HostSuccess "Delete Lock is set."
            }
            else {
                Write-HostInfo "Skipping becazse another no-delete lock was found."
            }
        }
        else {
            Write-HostError "Resource group $ResourceGroupName not found."     
        }
    }
}

<#
 .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 SubscriptionId
 The unique ID of the Azure subscription where the SQL Azure Server is located.
 .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-AzdSqlFirewallRule -SubscriptionId [ID] -SqlServerName mySQLServerName
 Add firewall rule for current IP
#>

Function New-SqlFirewallRule {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory = $true)] [string] $SubscriptionId,
        [Parameter(Mandatory = $true)] [string] $AzureSqlServerName,        
        [string] $TenantId,
        [string] $IpAddress,
        [switch] $NoLogo
    )    
    begin {
        if (!$NoLogo.IsPresent) {
            Write-Logo $MyInvocation.MyCommand            
        }
        Import-Module Az        
        # ensure that we are at the correct subscription
        Set-SubscriptionContext -TenantId $TenantId -SubscriptionId $SubscriptionId -NoLogo        
        if (!$?) {
            Write-HostError "Could not set context."
            return
        }
    }
    process {                    
        if (!$IpAddress) {
            Write-HostDebug "Retrieving public IP address..."
            $IpAddress = (Invoke-WebRequest -uri "https://ifconfig.me/ip").Content    
        }
        Write-HostDebug "Using IP address $IpAddress"
        $server = Get-AzSqlServer | Where-Object -Property ServerName -EQ $AzureSqlServerName
        if (!$server) {
            throw "Could not find SQL Azure Server $AzureSqlServerName in subscription $SubscriptionId"            
        }
        $existingRule = Get-AzSqlServerFirewallRule -ServerName $server.ServerName -ResourceGroupName $server.ResourceGroupName | Where-Object -Property StartIpAddress -EQ $IpAddress
        if ($existingRule) {
            $ruleName = $existingRule.FirewallRuleName
            Write-HostDebug "Skipping because firewall rule for your IP $IpAddress already exists on server $AzureSqlServerName : $ruleName"
            return
        }
        $ruleName = "ClientIpAddress_" + (Get-Date).ToString("yyyy_MM_dd_HH_mm_ss")
        New-AzSqlServerFirewallRule -ServerName $server.ServerName -ResourceGroupName $server.ResourceGroupName -FirewallRuleName $ruleName -StartIpAddress $IpAddress -EndIpAddress $IpAddress
        if (!$?) {
            Write-HostError "Failed to add SQL Server firewall rule: $_"
        }
        Write-HostSuccess "Firewall rule with name $ruleName successfully created on Azure SQL Server $AzureSqlServerName for IP $IpAddress"
    }
}

<#
 .Synopsis
 Ensures that a given subscription id is the currently selected context in Az.
 .Description
 Optimizes the way the context switcj is happening by only performing the costly operation if the
 current context differs from the one which is currently in the context.
 .Parameter SubscriptionId
 The unique ID of the Azure subscription where the SQL Azure Server is located.
 .Parameter TenantId
 The unique ID of the tenant where the subscription lives in for faster context switch.
 .Example
 Set-AzdSubscriptionContext -SubscriptionId 12345 -TenantId 56789
 Ensures that the context is on subscription with ID 12345 with tenant 56789
 .Example
 Set-AzdSubscriptionContext -SubscriptionId 12345
 Ensures that the context is on subscription with ID 12345 without defining the tenant ID.
#>

Function Set-SubscriptionContext
{
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory=$true)] [string] $SubscriptionId,
        [Parameter(Mandatory=$false)] [string] $TenantId,
        [switch] $NoLogo
    )
    begin {
        if (!$NoLogo.IsPresent) {
            Write-Logo $MyInvocation.MyCommand            
        }
        Import-Module Az
        Write-HostDebug "Retrieving current context..."
        $current = Get-AzContext 
    }
    process {
        if (!$?) {
            throw "Could not retrieve current Azure context. Maybe perform Login-AzAccount first."        
        }
        if ($current.Subscription.Id -eq $SubscriptionId) {
            Write-HostInfo "Reusing context."
            return
        }
        if ($TenantId) {
            Set-AzContext -Subscription $SubscriptionId -Tenant $TenantId -ErrorAction SilentlyContinue 
        } else     {
            Set-AzContext -Subscription $SubscriptionId -ErrorAction SilentlyContinue
        }
        if (!$?) {            
            throw "Could not set context."
        }
        Write-HostSuccess "Changed context."
    }
}

Function Add-AppExposedScope
{
    param (
        [Parameter(Mandatory=$true)] $app,
        [Parameter(Mandatory=$true)] [string] $scopeName
    )    
    $permissions = $app.Oauth2Permissions 
    $perm = ($permissions | Where-Object { $_.Value -eq $scopeName })
    if (!$perm) {
        $perm = New-Object -TypeName "Microsoft.Open.AzureAD.Model.OAuth2Permission"
        $perm.Type = 'User'
        $perm.UserConsentDisplayName = 'Grant access to ' + $app.DisplayName
        $perm.UserConsentDescription = 'Do you really want to give ' + $app.DisplayName + ' access?'
        $perm.AdminConsentDisplayName = 'Grant access to ' + $app.DisplayName
        $perm.AdminConsentDescription = 'Do you really want to give ' + $app.DisplayName + ' access?'
        $perm.Value = $scopeName
        $perm.Id = [guid]::NewGuid() 
        $permissions.Add($perm)
        Set-ADApplication -ObjectId $app.ObjectId -Oauth2Permissions $permissions
        Write-Output "Scope '$scopeName' defined"
    }
}

Function Add-AppPermission
{
    param (
        [Parameter(Mandatory=$true)] $app,
        [Parameter(Mandatory=$true)] [string] $permissionPrincipalDisplayName,
        [Parameter(Mandatory=$true)] $permissions
    )    
    $requiredResources = $app.RequiredResourceAccess    
    $remotePrincipal = Get-ADServicePrincipal -Filter "DisplayName eq '$permissionPrincipalDisplayName'"    
    foreach($permission in $permissions) {
        $requiredPermission = $remotePrincipal.Oauth2Permissions | Where-Object { $_.Value -eq $permission } 
        if ($requiredPermission) {
            $newAccess = New-Object -TypeName "Microsoft.Open.AzureAD.Model.ResourceAccess"
            $newAccess.Id = $requiredPermission.Id
            $newAccess.Type = 'Scope' 
            $newAccessRequired = New-Object -TypeName "Microsoft.Open.AzureAD.Model.RequiredResourceAccess"
            $newAccessRequired.ResourceAppId = $remotePrincipal.AppId    
            $newAccessRequired.ResourceAccess = $newAccess
            Out-MultiColorExtended -firstPart "Permissions for API " -firstColor Gray -secondPart $permissionPrincipalDisplayName -secondColor Yellow -thirdPart " with " -thirdColor Gray -fourthPart $permission -fourthColor Yellow -NoNewline
            if (!($requiredResources | Where-Object { $_.ResourceAppId -eq $remotePrincipal.AppId  -and $_.ResourceAccess.Id -eq $requiredPermission.Id })) {
                $requiredResources.Add($newAccessRequired)            
                Set-ADApplication -ObjectId $app.ObjectId -RequiredResourceAccess $requiredResources                
                Out-MultiColor -firstPart " -> " -firstColor Gray -secondPart "added" -secondColor Green
            } else {
                Out-MultiColor -firstPart " -> " -firstColor Gray -secondPart "existing" -secondColor Yellow
            }
        }
    }
}

Function Create-App
{
    param (
        [Parameter(Mandatory=$true)] $displayName,
        [Parameter(Mandatory=$true)] $replyUrls,
        [Parameter(Mandatory=$true)] [bool] $allowImplicitFlow,
        [Parameter(Mandatory=$false)] [string] $identifierUri,
        [Parameter(Mandatory=$false)] [string] $homePage,        
        [Parameter(Mandatory=$false)] [string] $logoFilePath,
        [Parameter(Mandatory=$false)] [bool] $generateSecret

    )    
    if (!(Get-ADApplication -SearchString $displayName)) {   
        if ([string]::IsNullOrEmpty($identifierUri)) {
            # the user didn't set an identifier URI so it is built from the scheme api://{AppId}
            $app = New-ADApplication -DisplayName $displayName -ReplyUrls $replyUrls -Homepage $homePage -Oauth2AllowImplicitFlow $allowImplicitFlow
            $identifierUri = "api://" + $app.AppId
            Write-Host $identifierUri -ForegroundColor Cyan
            Set-ADApplication -ObjectId $app.ObjectId -IdentifierUris $identifierUri
        } else {
            $app = New-ADApplication -DisplayName $displayName -ReplyUrls $replyUrls -IdentifierUris $identifierUri -Homepage $homePage -Oauth2AllowImplicitFlow $allowImplicitFlow
        }
        New-ADServicePrincipal -AppId $app.AppId | Out-Null     
        if ($app) {
            if ($GenerateSecret) {
                $secret = New-AppSecret -app $app
                Write-Host "🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥"                 
                Out-MultiColor     -firstPart "Client secret: " -firstColor Gray -secondPart $secret -secondColor Green                
                Write-Host "🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥"
            }        
            if (!([string]::IsNullOrEmpty($logoFilePath))) {
                Set-ADApplicationLogo -ObjectId $app.ObjectId -FilePath $logoFilePath | Out-Null        
            }
        }
    } else {
        $app = Get-ADApplication -SearchString $displayName        
    }    
    return $app
}

Function New-AppSecret
{
    param (
        [Parameter(Mandatory=$true)] $app        
    )
    $cred = New-ADApplicationPasswordCredential -ObjectId $app.ObjectId -StartDate ([DateTime]::Now) -EndDate ([DateTime]::Now.AddYears(100))    
    return $cred.Value
}

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

Function Out-MultiColor
{
    param (
        [string] $firstPart,
        [ConsoleColor] $firstColor,
        [string] $secondPart,
        [ConsoleColor] $secondColor,
        [switch] $NoNewline
    )
    Write-Host $firstPart -NoNewLine -ForegroundColor $firstColor
    if ($NoNewline) {
        Write-Host $secondPart -NoNewline -ForegroundColor $secondColor
    } else {
        Write-Host $secondPart -ForegroundColor $secondColor
    }
}

Function Out-MultiColorExtended
{
    param (
        [string] $firstPart,
        [ConsoleColor] $firstColor,
        [string] $secondPart,
        [ConsoleColor] $secondColor,
        [string] $thirdPart,
        [ConsoleColor] $thirdColor,
        [string] $fourthPart,
        [ConsoleColor] $fourthColor,
        [switch] $NoNewline
    )
    Write-Host $firstPart -NoNewLine -ForegroundColor $firstColor
    Write-Host $secondPart -NoNewline -ForegroundColor $secondColor
    Write-Host $thirdPart -NoNewLine -ForegroundColor $thirdColor
    if ($NoNewline) {
        Write-Host $fourthPart -NoNewline -ForegroundColor $fourthColor
    } else {
        Write-Host $fourthPart -ForegroundColor $fourthColor
    }
}

Function Remove-AppExposedScopeIfExists
{
    param (
        [Parameter(Mandatory=$true)] $app,
        [Parameter(Mandatory=$true)] [string] $scopeName
    )
    $permissions = $app.Oauth2Permissions    
    $perm = ($permissions | Where-Object { $_.Value -eq $scopeName })
    if ($perm) {
        $perm.IsEnabled = $false
        Set-ADApplication -ObjectId $app.ObjectId -Oauth2Permissions $permissions
        $permissions.Remove($perm);
        Set-ADApplication -ObjectId $app.ObjectId -Oauth2Permissions $permissions
    }
}


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

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
    }
}

Function Write-Logo {
    [CmdLetBinding()]
    param (
        [Parameter(Mandatory=$true, Position=0)]    
        [string] $FunctionName        
    )
    process {
        $encoded = 'DQogICAgX19fXyAgX19fX19fXyAgICBfX19fX18gIF9fX19fX19fX19fX19fX18gDQogICAvIF9fIFwvIF9fX18vIHwgIC8gLyBfXyBcLyBfX19fLyBfX19fLyBfXyBcDQogIC8gLyAvIC8gX18vICB8IHwgLyAvIC8gLyAvIF9fLyAvIF9fLyAvIC9fLyAvDQogLyAvXy8gLyAvX19fICB8IHwvIC8gL18vIC8gL19fXy8gL19fXy8gXywgXy8gDQovX19fX18vX19fX18vICB8X19fL19fX19fL19fX19fL19fX19fL18vIHxffA0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIA=='        
        $logo = [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($encoded))
        Write-Host $logo -ForegroundColor Blue
        Write-Host "$FunctionName" -ForegroundColor Blue
        Write-Host "Module DEVDEER Azure | DEVDEER GmbH | https://devdeer.com"
        Write-Host
    }
}



Export-ModuleMember -Function Clear-AllSqlFirewallRules
Export-ModuleMember -Function Copy-ToStorageContainer
Export-ModuleMember -Function Import-ArmTemplateJson
Export-ModuleMember -Function New-AppPermission
Export-ModuleMember -Function New-AppRegistration
Export-ModuleMember -Function New-ArmDeployment
Export-ModuleMember -Function New-ArmGroupDeployment
Export-ModuleMember -Function New-ResourceGroupDeleteLock
Export-ModuleMember -Function New-SqlFirewallRule
Export-ModuleMember -Function Set-SubscriptionContext