From b85abdf7f46e3d63becab8a169c085e52fe7e61f Mon Sep 17 00:00:00 2001 From: Taylor Southwick Date: Thu, 9 May 2024 16:10:39 +0000 Subject: [PATCH] Use managed identity for SQL --- developer-experience.md | 15 +- .../database/create-sql-user-and-role.bicep | 19 +-- .../scripts/create-sql-user-and-role.ps1 | 24 ++-- infra/core/database/sql-database.bicep | 22 ++- infra/core/database/sql-server.bicep | 13 +- infra/main.bicep | 13 -- infra/modules/application-post-config.bicep | 19 +-- infra/modules/application-resources.bicep | 12 +- .../devexperience/call-make-sql-account.ps1 | 48 ------- .../devexperience/make-sql-account.ps1 | 132 ------------------ 10 files changed, 42 insertions(+), 275 deletions(-) delete mode 100644 infra/scripts/devexperience/call-make-sql-account.ps1 delete mode 100644 infra/scripts/devexperience/make-sql-account.ps1 diff --git a/developer-experience.md b/developer-experience.md index b6073953..2a2037f9 100644 --- a/developer-experience.md +++ b/developer-experience.md @@ -9,7 +9,6 @@ The dev team uses Visual Studio and they integrate directly with Azure resources Most configurations in the project are stored in Azure App Configuration with secrets saved into Azure Key Vault. To connect to these resources from a developer workstation you need to complete the following steps. -1. Add your identity to the Azure SQL resource 1. Set up front-end web app configuration 1. Set up back-end web app configuration @@ -45,15 +44,7 @@ To support this workflow the following steps will store data in [User Secrets](h Set-AzContext -SubscriptionId $AZURE_SUBSCRIPTION_ID ``` -## 1. Add your identity to the Azure SQL resource - -1. Run the following script to automate the process in docs [Configure and manage Microsoft Entra authentication with Azure SQL](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-configure?view=azuresql&tabs=azure-powershell) - - ```pwsh - ./infra/scripts/devexperience/call-make-sql-account.ps1 - ``` - -## 2. Set up front-end web app configuration +## 1. Set up front-end web app configuration 1. Get the Azure App Configuration URI ```pwsh @@ -83,7 +74,7 @@ To support this workflow the following steps will store data in [User Secrets](h cd ../.. ``` -## 3. Set up back-end web app configuration +## 2. Set up back-end web app configuration ```pwsh cd src/Relecloud.Web.CallCenter.Api @@ -97,7 +88,7 @@ To support this workflow the following steps will store data in [User Secrets](h dotnet user-secrets set "App:AppConfig:Uri" $appConfigurationUri ``` -## 4. Launch the project with Visual Studio +## 3. Launch the project with Visual Studio 1. Open the project in Visual Studio 1. Configure the solution to start both the front-end and back-end web apps diff --git a/infra/core/database/create-sql-user-and-role.bicep b/infra/core/database/create-sql-user-and-role.bicep index d51c7286..6b473a92 100644 --- a/infra/core/database/create-sql-user-and-role.bicep +++ b/infra/core/database/create-sql-user-and-role.bicep @@ -24,15 +24,8 @@ param databaseRoles string = 'db_datareader' @description('The ID of the managed identity to be used to run the script.') param managedIdentityId string -@description('The principal (or object) ID of the user to create.') -param principalId string - -@description('The name of the user to create.') -param principalName string = '' - -@allowed([ 'ServicePrincipal', 'User' ]) -@description('The type of identity referenced by \'principalId\'.') -param principalType string = 'ServicePrincipal' +@description('The object ID of the user to create.') +param objectId string @description('The name of the SQL Database resource.') param sqlDatabaseName string @@ -48,7 +41,7 @@ param uniqueScriptId string = newGuid() // ======================================================================== resource createSqlUserAndRole 'Microsoft.Resources/deploymentScripts@2020-10-01' = { - name: 'createSqlUserAndRole-${principalId}' + name: 'sqlUserRole-${guid(objectId, databaseRoles, sqlServerName, sqlDatabaseName)}' location: location tags: tags kind: 'AzurePowerShell' @@ -66,10 +59,8 @@ resource createSqlUserAndRole 'Microsoft.Resources/deploymentScripts@2020-10-01' arguments: join([ '-SqlServerName \'${sqlServerName}\'' '-SqlDatabaseName \'${sqlDatabaseName}\'' - '-ObjectId \'${principalId}\'' - !empty(principalName) ? '-DisplayName \'${principalName}\'' : '' - principalType == 'ServicePrincipal' ? '-IsServicePrincipal' : '' - '-DatabaseRoles ${databaseRoles}' + '-ObjectId \'${objectId}\'' + '-DatabaseRoles \'${databaseRoles}\'' ], ' ') scriptContent: loadTextContent('./scripts/create-sql-user-and-role.ps1') } diff --git a/infra/core/database/scripts/create-sql-user-and-role.ps1 b/infra/core/database/scripts/create-sql-user-and-role.ps1 index fb30f4c6..01fc84d1 100644 --- a/infra/core/database/scripts/create-sql-user-and-role.ps1 +++ b/infra/core/database/scripts/create-sql-user-and-role.ps1 @@ -15,11 +15,6 @@ The name of the SQL Database resource .PARAMETER ObjectId The Object (Principal) ID of the user to be added. -.PARAMETER DisplayName - The display name of the user to be added. This is optional. If not provided, the Get-AzADUser cmdlet - will be used to retrieve the display name. -.PARAMETER IsServicePrincipal - True if the ObjectId refers to a service principal rather than a user. .PARAMETER DatabaseRoles The comma-separated list of database roles that need to be assigned to the user. #> @@ -29,7 +24,6 @@ Param( [string] $SqlDatabaseName, [string] $ObjectId, [string] $DisplayName, - [switch] $IsServicePrincipal = $false, [string[]] $DatabaseRoles = @('db_datareader','db_datawriter') ) @@ -44,7 +38,6 @@ function Resolve-Module($moduleName) { Import-Module $moduleName } else { Write-Error "Module $moduleName not found" - Write-Host "###vso[task.complete result=Failed;]Failed" [Environment]::exit(1) } } @@ -60,20 +53,22 @@ function ConvertTo-Sid($applicationId) { ### ### MAIN SCRIPT ### +Resolve-Module -moduleName Az.Resources Resolve-Module -moduleName SqlServer -# Get the SID for the ObjectId we are using -$Sid = ConvertTo-Sid -applicationId $ObjectId +# This is just for a display name in SQL and since we just have the object id, we'll prepend an identifier +$DisplayName = "user" + $ObjectId # Construct the SQL to create the user. $sqlList = [System.Collections.ArrayList]@() -$UserCreationOpt = if ($IsServicePrincipal) { "WITH sid = $($Sid), type = E" } else { "FROM EXTERNAL PROVIDER" } +# Use a SID representation of the object id so that SQL doesn't have to be connected to Entra +$Sid = ConvertTo-Sid -applicationId $ObjectId $CreateUserSql = @" IF NOT EXISTS ( SELECT * FROM sys.database_principals WHERE name = N'$($DisplayName)' ) -CREATE USER [$($DisplayName)] $($UserCreationOpt); +CREATE USER [$($DisplayName)] WITH sid = $Sid, type = E; "@ $sqlList.Add($CreateUserSql) | Out-Null @@ -92,17 +87,16 @@ ALTER ROLE $($role) ADD MEMBER [$($DisplayName)]; $sqlList.Add($GrantRoleSql) | Out-Null } +$token = (Get-AzAccessToken -ResourceUrl https://database.windows.net/).Token + # Execute the SQL Command on Azure SQL. foreach ($sqlcmd in $sqlList) { try { $sqlcmd | Write-Output - $token = (Get-AzAccessToken -ResourceUrl https://database.windows.net/).Token Invoke-SqlCmd -ServerInstance "$SqlServerName.database.windows.net" -Database $SqlDatabaseName -AccessToken $token -Query $sqlcmd -ErrorAction 'Stop' -StatisticsVariable 'stats' $stats | ConvertTo-Json -Depth 10 | Write-Output } catch { - Write-Error $_.Exception.Message - Write-Host "###vso[task.complete result=Failed;]Failed" + $_.Exception.Message | Write-Error [Environment]::exit(1) } } - diff --git a/infra/core/database/sql-database.bicep b/infra/core/database/sql-database.bicep index 2e38eda8..1e9f145f 100644 --- a/infra/core/database/sql-database.bicep +++ b/infra/core/database/sql-database.bicep @@ -33,7 +33,7 @@ type DiagnosticSettings = { type PrivateEndpointSettings = { @description('The name of the resource group to hold the Private DNS Zone. By default, this uses the same resource group as the resource.') dnsResourceGroupName: string - + @description('The name of the private endpoint resource. By default, this uses a prefix of \'pe-\' followed by the name of the resource.') name: string @@ -82,6 +82,9 @@ param privateEndpointSettings PrivateEndpointSettings? @description('The service tier to use for the database.') param sku string = 'Basic' +param users string[] = [] +param managedIdentityName string + @description('If true, enable availability zone redundancy.') param zoneRedundant bool = false @@ -107,6 +110,10 @@ var logCategories = [ // AZURE RESOURCES // ======================================================================== +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: managedIdentityName +} + resource sqlServer 'Microsoft.Sql/servers@2021-11-01' existing = { name: sqlServerName } @@ -168,6 +175,19 @@ resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' } } +module sqluser 'create-sql-user-and-role.bicep' = [for user in users: { + name: 'sqluser-${guid(location, user, name, sqlServer.name)}' + params: { + managedIdentityId: managedIdentity.id + objectId: user + sqlDatabaseName: name + location: location + sqlServerName: sqlServer.name + databaseRoles: 'db_owner' + } + dependsOn: [ sqlDatabase ] +}] + // ======================================================================== // OUTPUTS // ======================================================================== diff --git a/infra/core/database/sql-server.bicep b/infra/core/database/sql-server.bicep index 73a7ff5b..4b9187ea 100644 --- a/infra/core/database/sql-server.bicep +++ b/infra/core/database/sql-server.bicep @@ -68,15 +68,6 @@ param enablePublicNetworkAccess bool = true @description('The firewall rules to install on the Key Vault.') param firewallRules FirewallRules? -@secure() -@minLength(8) -@description('The password for the administrator account on the SQL Server.') -param sqlAdministratorPassword string = newGuid() - -@minLength(8) -@description('The username for the administrator account on the SQL Server.') -param sqlAdministratorUsername string = 'adminuser' - // ======================================================================== // VARIABLES // ======================================================================== @@ -100,10 +91,8 @@ resource sqlServer 'Microsoft.Sql/servers@2021-11-01' = { location: location tags: tags properties: { - administratorLogin: sqlAdministratorUsername - administratorLoginPassword: sqlAdministratorPassword administrators: { - azureADOnlyAuthentication: false + azureADOnlyAuthentication: true login: managedIdentity.name principalType: 'User' sid: managedIdentity.properties.principalId diff --git a/infra/main.bicep b/infra/main.bicep index a08474a8..e16f5eb5 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -40,14 +40,6 @@ param principalId string = '' @description('The type of the principal specified in \'principalId\'') param principalType string = 'ServicePrincipal' -/* -** Passwords - specify these! -*/ -@secure() -@minLength(8) -@description('The password for the SQL administrator account. This will be used for the jump box, SQL server, and anywhere else a password is needed for creating a resource.') -param databasePassword string - @secure() @minLength(12) @description('The password for the jump box administrator account.') @@ -442,8 +434,6 @@ module application './modules/application-resources.bicep' = { frontDoorSettings: frontdoor.outputs.settings // Settings - administratorUsername: administratorUsername - databasePassword: databasePassword clientIpAddress: clientIpAddress useCommonAppServicePlan: willDeployCommonAppServicePlan } @@ -468,8 +458,6 @@ module application2 './modules/application-resources.bicep' = if (isMultiLocati frontDoorSettings: frontdoor.outputs.settings // Settings - administratorUsername: administratorUsername - databasePassword: databasePassword clientIpAddress: clientIpAddress useCommonAppServicePlan: willDeployCommonAppServicePlan } @@ -488,7 +476,6 @@ module applicationPostConfiguration './modules/application-post-config.bicep' = deploymentSettings: primaryDeploymentSettings administratorPassword: jumpboxAdministratorPassword administratorUsername: administratorUsername - databasePassword: databasePassword keyVaultName: isNetworkIsolated? hubNetwork.outputs.key_vault_name : application.outputs.key_vault_name kvResourceGroupName: isNetworkIsolated? resourceGroups.outputs.hub_resource_group_name : resourceGroups.outputs.application_resource_group_name readerIdentities: union(application.outputs.service_managed_identities, defaultDeploymentSettings.isMultiLocationDeployment ? application2.outputs.service_managed_identities : []) diff --git a/infra/modules/application-post-config.bicep b/infra/modules/application-post-config.bicep index 0fc79ae4..811709fe 100644 --- a/infra/modules/application-post-config.bicep +++ b/infra/modules/application-post-config.bicep @@ -69,18 +69,13 @@ param deploymentSettings DeploymentSettings */ @secure() @minLength(12) -@description('The password for the administrator account. This will be used for the jump box, SQL server, and anywhere else a password is needed for creating a resource.') +@description('The password for the administrator account. This will be used for the jump box and anywhere else a password is needed for creating a resource.') param administratorPassword string = newGuid() @minLength(8) @description('The username for the administrator account on the jump box.') param administratorUsername string = 'adminuser' -@secure() -@minLength(8) -@description('The password for the administrator account on the SQL Server.') -param databasePassword string - @description('The resource names for the resources to be created.') param resourceNames object @@ -154,18 +149,6 @@ module writeJumpBoxCredentialsToKeyVault '../core/security/key-vault-secrets.bic } } -module writeSqlAdminInfoToKeyVault '../core/security/key-vault-secrets.bicep' = { - name: 'write-sql-admin-info-to-keyvault' - scope: existingKvResourceGroup - params: { - name: existingKeyVault.name - secrets: [ - { key: 'Application--SqlAdministratorUsername', value: administratorUsername } - { key: 'Application--SqlAdministratorPassword', value: databasePassword } - ] - } -} - // ======================================================================== // // Microsoft Entra Application Registration placeholders // ======================================================================== // diff --git a/infra/modules/application-resources.bicep b/infra/modules/application-resources.bicep index e536c3bc..304df2e0 100644 --- a/infra/modules/application-resources.bicep +++ b/infra/modules/application-resources.bicep @@ -118,14 +118,6 @@ param frontDoorSettings FrontDoorSettings /* ** Settings */ -@secure() -@minLength(8) -@description('The password for the administrator account on the SQL Server.') -param databasePassword string - -@minLength(8) -@description('The username for the administrator account on the SQL Server.') -param administratorUsername string @description('The IP address of the current system. This is used to set up the firewall for Key Vault and SQL Server if in development mode.') param clientIpAddress string = '' @@ -293,8 +285,6 @@ module sqlServer '../core/database/sql-server.bicep' = if (createSqlServer) { } : null diagnosticSettings: diagnosticSettings enablePublicNetworkAccess: !deploymentSettings.isNetworkIsolated - sqlAdministratorPassword: databasePassword - sqlAdministratorUsername: administratorUsername } } @@ -321,6 +311,8 @@ module sqlDatabase '../core/database/sql-database.bicep' = { } : null sku: deploymentSettings.isProduction ? 'Premium' : 'Standard' zoneRedundant: deploymentSettings.isProduction + users: deploymentSettings.principalId != null ? [ deploymentSettings.principalId ] : [] + managedIdentityName: ownerManagedIdentity.outputs.name } } diff --git a/infra/scripts/devexperience/call-make-sql-account.ps1 b/infra/scripts/devexperience/call-make-sql-account.ps1 deleted file mode 100644 index 72a6f14d..00000000 --- a/infra/scripts/devexperience/call-make-sql-account.ps1 +++ /dev/null @@ -1,48 +0,0 @@ -<# -.SYNOPSIS -Calls the make-sql-account.ps1 script to create a SQL account for a given resource group, SQL server, and database. - -.DESCRIPTION -This script retrieves the necessary parameters from the AZD environment variables and Key Vault, and then calls the make-sql-account.ps1 script with the appropriate arguments. - -.PARAMETER resourceGroupName -The name of the Azure resource group where the SQL server and database are located. - -.PARAMETER sqlServerName -The name of the SQL server. - -.PARAMETER sqlDatabaseName -The name of the SQL database. - -.PARAMETER keyVaultName -The name of the Azure Key Vault where the SQL administrator credentials are stored. - -.EXAMPLE -./call-make-sql-account.ps1 - -This example demonstrates how to call the script to create a SQL account using the default environment variables and Key Vault. - -#> - -$resourceGroupName = ((azd env get-values --output json | ConvertFrom-Json).AZURE_RESOURCE_GROUP) -$sqlServerName = ((azd env get-values --output json | ConvertFrom-Json).SQL_SERVER_NAME) -$sqlDatabaseName = ((azd env get-values --output json | ConvertFrom-Json).SQL_DATABASE_NAME) -$keyVaultName = ((azd env get-values --output json | ConvertFrom-Json).AZURE_OPS_VAULT_NAME) - -$sqlAdmin = (Get-AzKeyVaultSecret -VaultName $keyVaultName -Name "Application--SqlAdministratorUsername" -AsPlainText) -$secureSqlPassword = ConvertTo-SecureString -String (Get-AzKeyVaultSecret -VaultName $keyVaultName -Name "Application--SqlAdministratorPassword" -AsPlainText) -AsPlainText -Force - -$accountId = (Get-AzContext).Account.ExtendedProperties["HomeAccountId"].Split(".")[0] -$accountAlias = (Get-AzContext).Account.Id - -$Cred = New-Object System.Management.Automation.PSCredential ($sqlAdmin, $secureSqlPassword) - -Write-Host "Calling make-sql-account.ps1 for group:'$resourceGroupName'..." - -./infra/scripts/devexperience/make-sql-account.ps1 ` - -ResourceGroup $resourceGroupName ` - -SqlServerName $sqlServerName ` - -SqlDatabaseName $sqlDatabaseName ` - -AccountAlias $accountAlias ` - -AccountId $accountId ` - -Credential $Cred \ No newline at end of file diff --git a/infra/scripts/devexperience/make-sql-account.ps1 b/infra/scripts/devexperience/make-sql-account.ps1 deleted file mode 100644 index 81b37389..00000000 --- a/infra/scripts/devexperience/make-sql-account.ps1 +++ /dev/null @@ -1,132 +0,0 @@ -<# -.SYNOPSIS -This script creates a SQL account for a specified Entra ID account so that the user can connect to Azure SQL. - -.PARAMETER ResourceGroup -The name of the resource group where the SQL Server is located. - -.PARAMETER SqlServerName -The name of the SQL Server. - -.PARAMETER SqlDatabaseName -The name of the SQL database. - -.PARAMETER AccountAlias -The account alias of the Entra ID account to be added to Azure SQL. - -.PARAMETER AccountId -The ID of the Entra ID account to be added to Azure SQL. - -.EXAMPLE -./make-sql-account.ps1 -ResourceGroup "myResourceGroup" -SqlServerName "mySqlServer" -SqlDatabaseName "mySqlDatabase" -AccountId "mySqlAccount" -Credential $Creds -Creates a SQL account with the specified parameters. - -#> - -Param( - [Parameter(Mandatory=$true)] - [string] $ResourceGroup, - - [Parameter(Mandatory=$true)] - [string] $SqlServerName, - - [Parameter(Mandatory=$true)] - [string] $SqlDatabaseName, - - [Parameter(Mandatory=$true)] - [string] $AccountAlias, - - [Parameter(Mandatory=$true)] - [string] $AccountId, - - [Parameter(Mandatory=$true)] - [System.Management.Automation.PSCredential]$Credential -) - -<# -.SYNOPSIS - Tests to ensure that the Powershell module we need is installed and imported before use. -.PARAMETER ModuleName - The name of the module to test for. -#> -function Test-ModuleImported { - param( - [Parameter(Mandatory=$true)] - [string] $ModuleName - ) - - if ((Get-Module -ListAvailable -Name $ModuleName) -and (Get-Module -Name $ModuleName -ErrorAction SilentlyContinue)) { - Write-Verbose "The '$($ModuleName)' module is installed and imported." - } - else { - $SavedVerbosePreference = $global:VerbosePreference - try { - Write-Verbose "Importing '$($ModuleName)' module" - $global:VerbosePreference = 'SilentlyContinue' - Import-Module -Name $ModuleName -ErrorAction Stop - $global:VerbosePreference = $SavedVerbosePreference - Write-Verbose "The '$($ModuleName)' module is imported successfully." - } - catch { - Write-Error "Failed to import the '$($ModuleName)' module. Please install the '$($ModuleName)' module before running this script." - exit 12 - } - finally { - $global:VerbosePreference = $SavedVerbosePreference - } - } -} - -<# -.SYNOPSIS - Checks to ensure that the user is authenticated with Azure before running the script. -#> -function Test-AzureConnected { - if (Get-AzContext -ErrorAction SilentlyContinue) { - Write-Verbose "The user is authenticated with Azure." - } - else { - Write-Error "You are not authenticated with Azure. Please run 'Connect-AzAccount' to authenticate before running this script." - exit 10 - } -} - -Test-ModuleImported -ModuleName Az.Resources -Test-ModuleImported -ModuleName SqlServer -Test-AzureConnected - -# Prompt formatting features - -$defaultColor = if ($PSVersionTable.PSVersion.Major -ge 6) { "`e[0m" } else { "" } -$successColor = if ($PSVersionTable.PSVersion.Major -ge 6) { "`e[32m" } else { "" } - -[guid]$guid = [System.Guid]::Parse($accountId) - -foreach ($byte in $guid.ToByteArray()) { - $byteGuid += [System.String]::Format("{0:X2}", $byte) -} -$Sid = "0x" + $byteGuid - -$fullyQualifiedDomainName = (Get-AzSqlServer -ResourceGroupName $ResourceGroup -ServerName $SqlServerName).FullyQualifiedDomainName - - -# Prepare SQL cmd to CREATE USER -$createUserSQL = "IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = N'$AccountAlias') create user [$AccountAlias] with sid = $Sid, type = E;" - -# Connect as SQL Admin acct and execute SQL cmd -Invoke-Sqlcmd -ServerInstance $fullyQualifiedDomainName -database $sqlDatabaseName -Credential $Credential -Query $createUserSQL -Write-Host "`tCreated user" - -Invoke-Sqlcmd -ServerInstance $fullyQualifiedDomainName -database 'master' -Credential $Credential -Query $createUserSQL -Write-Host "`tCreated for root db" - -# Prepare SQL cmd to grant db_owner role -$grantDbOwner = "IF NOT EXISTS (SELECT * FROM sys.database_principals p JOIN sys.database_role_members db_owner_role ON db_owner_role.member_principal_id = p.principal_id JOIN sys.database_principals role_names ON role_names.principal_id = db_owner_role.role_principal_id AND role_names.[name] = 'db_owner' WHERE p.[name]=N'$AccountAlias') ALTER ROLE db_owner ADD MEMBER [$AccountAlias];" - -# Connect as SQL Admin acct and execute SQL cmd -Invoke-Sqlcmd -ServerInstance $fullyQualifiedDomainName -database $sqlDatabaseName -Credential $Credential -Query $grantDbOwner - -Write-Host "`tGranted db_owner" - -Write-Host "`nFinished $($successColor)successfully$($defaultColor)." -Write-Host "An account for the current user was created in Azure SQL" \ No newline at end of file