Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use managed identity for SQL #378

Merged
merged 2 commits into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ Select the subscription that will be used for the deployment:
azd env set AZURE_SUBSCRIPTION_ID $AZURE_SUBSCRIPTION_ID
```

Set your principal name:
twsouthwick marked this conversation as resolved.
Show resolved Hide resolved

```pwsh
azd env set AZURE_PRINCIPAL_NAME (Get-AzContext).Account.Id
```

Set the `AZURE_LOCATION` (Run `(Get-AzLocation).Location` to see a list of locations):

```pwsh
Expand Down
15 changes: 3 additions & 12 deletions developer-experience.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
64 changes: 32 additions & 32 deletions infra/core/database/create-sql-user-and-role.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ param location string
@description('The tags to associate with this resource.')
param tags object = {}

@description('The comma-separated list of database roles to assign to the user.')
param databaseRoles string = 'db_datareader'
@description('The database roles to assign to the user.')
param databaseRoles string[] = ['db_datareader']

@description('The ID of the managed identity to be used to run the script.')
param managedIdentityId string
Expand All @@ -28,11 +28,7 @@ param managedIdentityId string
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'
param principalName string

@description('The name of the SQL Database resource.')
param sqlDatabaseName string
Expand All @@ -47,30 +43,34 @@ param uniqueScriptId string = newGuid()
// AZURE RESOURCES
// ========================================================================

resource createSqlUserAndRole 'Microsoft.Resources/deploymentScripts@2020-10-01' = {
name: 'createSqlUserAndRole-${principalId}'
location: location
tags: tags
kind: 'AzurePowerShell'
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${managedIdentityId}': {}
resource createSqlUserAndRole 'Microsoft.Resources/deploymentScripts@2020-10-01' = [
for databaseRole in databaseRoles: {
name: 'sqlUserRole-${guid(principalId, databaseRole, sqlServerName, sqlDatabaseName)}'
location: location
tags: tags
kind: 'AzurePowerShell'
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${managedIdentityId}': {}
}
}
properties: {
forceUpdateTag: uniqueScriptId
azPowerShellVersion: '7.4'
retentionInterval: 'PT1H'
cleanupPreference: 'OnExpiration'
arguments: join(
[
'-SqlServerName \'${sqlServerName}\''
'-SqlDatabaseName \'${sqlDatabaseName}\''
'-ObjectId \'${principalId}\''
'-DisplayName \'${principalName}\''
'-DatabaseRole \'${databaseRole}\''
],
' '
)
scriptContent: loadTextContent('./scripts/create-sql-user-and-role.ps1')
}
}
properties: {
forceUpdateTag: uniqueScriptId
azPowerShellVersion: '7.4'
retentionInterval: 'PT1H'
cleanupPreference: 'OnExpiration'
arguments: join([
'-SqlServerName \'${sqlServerName}\''
'-SqlDatabaseName \'${sqlDatabaseName}\''
'-ObjectId \'${principalId}\''
!empty(principalName) ? '-DisplayName \'${principalName}\'' : ''
principalType == 'ServicePrincipal' ? '-IsServicePrincipal' : ''
'-DatabaseRoles ${databaseRoles}'
], ' ')
scriptContent: loadTextContent('./scripts/create-sql-user-and-role.ps1')
}
}
]
76 changes: 18 additions & 58 deletions infra/core/database/scripts/create-sql-user-and-role.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,17 @@
.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.
The Object (Principal) display name of the user to be added.
.PARAMETER DatabaseRole
The database role that needs to be assigned to the user.
#>

Param(
[string] $SqlServerName,
[string] $SqlDatabaseName,
[string] $ObjectId,
[string] $DisplayName,
[switch] $IsServicePrincipal = $false,
[string[]] $DatabaseRoles = @('db_datareader','db_datawriter')
[string] $DatabaseRole
)

function Resolve-Module($moduleName) {
Expand All @@ -44,65 +40,29 @@ 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)
}
}

function ConvertTo-Sid($applicationId) {
[System.Guid]$guid = [System.Guid]::Parse($applicationId)
foreach ($byte in $guid.ToByteArray()) {
$byteGuid += [System.String]::Format("{0:X2}", $byte)
}
return "0x" + $byteGuid
}

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

# Construct the SQL to create the user.
$sqlList = [System.Collections.ArrayList]@()

$UserCreationOpt = if ($IsServicePrincipal) { "WITH sid = $($Sid), type = E" } else { "FROM EXTERNAL PROVIDER" }
$CreateUserSql = @"
IF NOT EXISTS (
SELECT * FROM sys.database_principals WHERE name = N'$($DisplayName)'
)
CREATE USER [$($DisplayName)] $($UserCreationOpt);

$sql = @"
DECLARE @username nvarchar(max) = N'$($DisplayName)';
DECLARE @clientId uniqueidentifier = '$($ObjectId)';
DECLARE @sid NVARCHAR(max) = CONVERT(VARCHAR(max), CONVERT(VARBINARY(16), @clientId), 1);
DECLARE @cmd NVARCHAR(max) = N'CREATE USER [' + @username + '] WITH SID = ' + @sid + ', TYPE = E;';
IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = @username)
BEGIN
EXEC(@cmd)
EXEC sp_addrolemember '$($DatabaseRole)', @username;
END
"@
$sqlList.Add($CreateUserSql) | Out-Null

foreach ($role in $DatabaseRoles) {
$GrantRoleSql = @"
IF NOT EXISTS (
SELECT * FROM sys.database_principals p
JOIN sys.database_role_members $($role)_role ON $($role)_role.member_principal_id = p.principal_id
JOIN sys.database_principals role_names ON role_names.principal_id = $($role)_role.role_principal_id AND role_names.[name] = '$($role)'
WHERE p.[name]=N'$($DisplayName)'
)
ALTER ROLE $($role) ADD MEMBER [$($DisplayName)];

"@
$sqlList.Add($GrantRoleSql) | Out-Null
}

# 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"
[Environment]::exit(1)
}
}
Write-Output "`nSQL:`n$($sql)`n`n"

$token = (Get-AzAccessToken -ResourceUrl https://database.windows.net/).Token
Invoke-SqlCmd -ServerInstance "$SqlServerName.database.windows.net" -Database $SqlDatabaseName -AccessToken $token -Query $sql -ErrorAction 'Stop'
33 changes: 32 additions & 1 deletion infra/core/database/sql-database.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,22 @@ type DiagnosticSettings = {
enableMetrics: bool
}

// From: infra/types/UserIdentity.bicep
@description('Type describing a user identity.')
type UserIdentity = {
@description('The ID of the user')
principalId: string

@description('The name of the user')
principalName: string
}

// From: infra/types/PrivateEndpointSettings.bicep
@description('Type describing the private endpoint settings.')
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

Expand Down Expand Up @@ -82,6 +92,9 @@ param privateEndpointSettings PrivateEndpointSettings?
@description('The service tier to use for the database.')
param sku string = 'Basic'

param users UserIdentity[] = []
param managedIdentityName string

@description('If true, enable availability zone redundancy.')
param zoneRedundant bool = false

Expand All @@ -107,6 +120,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
}
Expand Down Expand Up @@ -168,6 +185,20 @@ 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.principalId, user.principalName, name, sqlServer.name)}'
params: {
managedIdentityId: managedIdentity.id
principalId: user.principalId
principalName: user.principalName
sqlDatabaseName: name
location: location
sqlServerName: sqlServer.name
databaseRoles: ['db_owner']
}
dependsOn: [ sqlDatabase ]
}]

// ========================================================================
// OUTPUTS
// ========================================================================
Expand Down
13 changes: 1 addition & 12 deletions infra/core/database/sql-server.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ========================================================================
Expand All @@ -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
Expand Down
17 changes: 4 additions & 13 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,13 @@ param ownerName string
@description('The ID of the running user or service principal. This will be set as the owner when needed.')
param principalId string = ''

@description('The name of the running user or service principal. This will be set as the owner when needed.')
param principalName string = ''

@allowed([ 'ServicePrincipal', 'User' ])
@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
twsouthwick marked this conversation as resolved.
Show resolved Hide resolved

@secure()
@minLength(12)
@description('The password for the jump box administrator account.')
Expand Down Expand Up @@ -141,6 +136,7 @@ var defaultDeploymentSettings = {
isPrimaryLocation: true
location: location
name: environmentName
principalName: principalName
principalId: principalId
principalType: principalType
resourceToken: primaryResourceToken
Expand Down Expand Up @@ -442,8 +438,6 @@ module application './modules/application-resources.bicep' = {
frontDoorSettings: frontdoor.outputs.settings

// Settings
administratorUsername: administratorUsername
databasePassword: databasePassword
clientIpAddress: clientIpAddress
useCommonAppServicePlan: willDeployCommonAppServicePlan
}
Expand All @@ -468,8 +462,6 @@ module application2 './modules/application-resources.bicep' = if (isMultiLocati
frontDoorSettings: frontdoor.outputs.settings

// Settings
administratorUsername: administratorUsername
databasePassword: databasePassword
clientIpAddress: clientIpAddress
useCommonAppServicePlan: willDeployCommonAppServicePlan
}
Expand All @@ -488,7 +480,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 : [])
Expand Down
Loading
Loading