Pamela Fox
commited on
Commit
·
f24be86
1
Parent(s):
cd77237
Port to passwordless
Browse files- azure.yaml +7 -0
- infra/core/database/postgresql/flexibleserver.bicep +93 -27
- infra/core/security/keyvault.bicep +1 -9
- infra/core/security/role.bicep +21 -0
- infra/main.bicep +53 -19
- infra/main.parameters.json +11 -2
- scripts/load_python_env.sh +7 -0
- scripts/requirements.txt +2 -0
- scripts/setup_postgres_azurerole.ps1 +18 -0
- scripts/setup_postgres_azurerole.sh +12 -0
- src/quizsite/postgresql/__init__.py +0 -0
- src/quizsite/postgresql/base.py +12 -0
- src/quizsite/settings.py +3 -2
- src/requirements.txt +1 -0
- src/setup_postgres_azurerole.py +66 -0
azure.yaml
CHANGED
|
@@ -8,3 +8,10 @@ services:
|
|
| 8 |
project: ./src
|
| 9 |
language: py
|
| 10 |
host: appservice
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
project: ./src
|
| 9 |
language: py
|
| 10 |
host: appservice
|
| 11 |
+
hooks:
|
| 12 |
+
postprovision:
|
| 13 |
+
posix:
|
| 14 |
+
shell: sh
|
| 15 |
+
run: ./scripts/setup_postgres_azurerole.sh
|
| 16 |
+
interactive: true
|
| 17 |
+
continueOnError: false
|
infra/core/database/postgresql/flexibleserver.bicep
CHANGED
|
@@ -4,9 +4,32 @@ param tags object = {}
|
|
| 4 |
|
| 5 |
param sku object
|
| 6 |
param storage object
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
@secure()
|
| 9 |
-
param administratorLoginPassword string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
param databaseNames array = []
|
| 11 |
param allowAzureIPsFirewall bool = false
|
| 12 |
param allowAllIPsFirewall bool = false
|
|
@@ -15,50 +38,93 @@ param allowedSingleIPs array = []
|
|
| 15 |
// PostgreSQL version
|
| 16 |
param version string
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-12-01' = {
|
| 20 |
location: location
|
| 21 |
tags: tags
|
| 22 |
name: name
|
| 23 |
sku: sku
|
| 24 |
-
properties: {
|
| 25 |
version: version
|
| 26 |
-
administratorLogin: administratorLogin
|
| 27 |
-
administratorLoginPassword: administratorLoginPassword
|
| 28 |
storage: storage
|
| 29 |
highAvailability: {
|
| 30 |
mode: 'Disabled'
|
| 31 |
}
|
| 32 |
-
}
|
| 33 |
|
| 34 |
resource database 'databases' = [for name in databaseNames: {
|
| 35 |
name: name
|
| 36 |
}]
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
| 44 |
}
|
|
|
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
| 52 |
}
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
param sku object
|
| 6 |
param storage object
|
| 7 |
+
|
| 8 |
+
@allowed([
|
| 9 |
+
'Password'
|
| 10 |
+
'EntraOnly'
|
| 11 |
+
])
|
| 12 |
+
param authType string = 'Password'
|
| 13 |
+
|
| 14 |
+
param administratorLogin string = ''
|
| 15 |
@secure()
|
| 16 |
+
param administratorLoginPassword string = ''
|
| 17 |
+
|
| 18 |
+
@description('Entra admin role name')
|
| 19 |
+
param entraAdministratorName string = ''
|
| 20 |
+
|
| 21 |
+
@description('Entra admin role object ID (in Entra)')
|
| 22 |
+
param entraAdministratorObjectId string = ''
|
| 23 |
+
|
| 24 |
+
@description('Entra admin user type')
|
| 25 |
+
@allowed([
|
| 26 |
+
'User'
|
| 27 |
+
'Group'
|
| 28 |
+
'ServicePrincipal'
|
| 29 |
+
])
|
| 30 |
+
param entraAdministratorType string = 'User'
|
| 31 |
+
|
| 32 |
+
|
| 33 |
param databaseNames array = []
|
| 34 |
param allowAzureIPsFirewall bool = false
|
| 35 |
param allowAllIPsFirewall bool = false
|
|
|
|
| 38 |
// PostgreSQL version
|
| 39 |
param version string
|
| 40 |
|
| 41 |
+
var authProperties = authType == 'Password' ? {
|
| 42 |
+
administratorLogin: administratorLogin
|
| 43 |
+
administratorLoginPassword: administratorLoginPassword
|
| 44 |
+
authConfig: {
|
| 45 |
+
passwordAuth: 'Enabled'
|
| 46 |
+
}
|
| 47 |
+
} : {
|
| 48 |
+
authConfig: {
|
| 49 |
+
activeDirectoryAuth: 'Enabled'
|
| 50 |
+
passwordAuth: 'Disabled'
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-12-01' = {
|
| 55 |
location: location
|
| 56 |
tags: tags
|
| 57 |
name: name
|
| 58 |
sku: sku
|
| 59 |
+
properties: union(authProperties, {
|
| 60 |
version: version
|
|
|
|
|
|
|
| 61 |
storage: storage
|
| 62 |
highAvailability: {
|
| 63 |
mode: 'Disabled'
|
| 64 |
}
|
| 65 |
+
})
|
| 66 |
|
| 67 |
resource database 'databases' = [for name in databaseNames: {
|
| 68 |
name: name
|
| 69 |
}]
|
| 70 |
+
}
|
| 71 |
|
| 72 |
+
// This must be done separately due to conflicts with the Entra setup
|
| 73 |
+
resource firewall_all 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = if (allowAllIPsFirewall) {
|
| 74 |
+
parent: postgresServer
|
| 75 |
+
name: 'allow-all-IPs'
|
| 76 |
+
properties: {
|
| 77 |
+
startIpAddress: '0.0.0.0'
|
| 78 |
+
endIpAddress: '255.255.255.255'
|
| 79 |
}
|
| 80 |
+
}
|
| 81 |
|
| 82 |
+
// This must be done separately due to conflicts with the Entra setup
|
| 83 |
+
resource firewall_azure 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = if (allowAzureIPsFirewall) {
|
| 84 |
+
parent: postgresServer
|
| 85 |
+
name: 'allow-all-azure-internal-IPs'
|
| 86 |
+
properties: {
|
| 87 |
+
startIpAddress: '0.0.0.0'
|
| 88 |
+
endIpAddress: '0.0.0.0'
|
| 89 |
}
|
| 90 |
+
}
|
| 91 |
|
| 92 |
+
@batchSize(1)
|
| 93 |
+
// This must be done separately due to conflicts with the Entra setup
|
| 94 |
+
resource firewall_single 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = [for ip in allowedSingleIPs: {
|
| 95 |
+
parent: postgresServer
|
| 96 |
+
name: 'allow-single-${replace(ip, '.', '')}'
|
| 97 |
+
properties: {
|
| 98 |
+
startIpAddress: ip
|
| 99 |
+
endIpAddress: ip
|
| 100 |
+
}
|
| 101 |
+
}]
|
| 102 |
|
| 103 |
+
// This must be created *after* the server is created - it cannot be a nested child resource
|
| 104 |
+
resource addAddUser 'Microsoft.DBforPostgreSQL/flexibleServers/administrators@2023-03-01-preview' = {
|
| 105 |
+
parent: postgresServer
|
| 106 |
+
name: entraAdministratorObjectId
|
| 107 |
+
properties: {
|
| 108 |
+
tenantId: subscription().tenantId
|
| 109 |
+
principalType: entraAdministratorType
|
| 110 |
+
principalName: entraAdministratorName
|
| 111 |
+
}
|
| 112 |
+
// This is a workaround for a bug in the API that requires the parent to be fully resolved
|
| 113 |
+
dependsOn: [postgresServer, firewall_all, firewall_azure]
|
| 114 |
}
|
| 115 |
|
| 116 |
+
// Workaround issue https://github.com/Azure/bicep-types-az/issues/1507
|
| 117 |
+
resource configurations 'Microsoft.DBforPostgreSQL/flexibleServers/configurations@2023-03-01-preview' = {
|
| 118 |
+
name: 'azure.extensions'
|
| 119 |
+
parent: postgresServer
|
| 120 |
+
properties: {
|
| 121 |
+
value: 'vector'
|
| 122 |
+
source: 'user-override'
|
| 123 |
+
}
|
| 124 |
+
dependsOn: [
|
| 125 |
+
addAddUser, firewall_all, firewall_azure, firewall_single
|
| 126 |
+
]
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
output POSTGRES_DOMAIN_NAME string = postgresServer.properties.fullyQualifiedDomainName
|
infra/core/security/keyvault.bicep
CHANGED
|
@@ -2,8 +2,6 @@ param name string
|
|
| 2 |
param location string = resourceGroup().location
|
| 3 |
param tags object = {}
|
| 4 |
|
| 5 |
-
param principalId string = ''
|
| 6 |
-
|
| 7 |
resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = {
|
| 8 |
name: name
|
| 9 |
location: location
|
|
@@ -11,13 +9,7 @@ resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = {
|
|
| 11 |
properties: {
|
| 12 |
tenantId: subscription().tenantId
|
| 13 |
sku: { family: 'A', name: 'standard' }
|
| 14 |
-
|
| 15 |
-
{
|
| 16 |
-
objectId: principalId
|
| 17 |
-
permissions: { secrets: [ 'get', 'list' ] }
|
| 18 |
-
tenantId: subscription().tenantId
|
| 19 |
-
}
|
| 20 |
-
] : []
|
| 21 |
}
|
| 22 |
}
|
| 23 |
|
|
|
|
| 2 |
param location string = resourceGroup().location
|
| 3 |
param tags object = {}
|
| 4 |
|
|
|
|
|
|
|
| 5 |
resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = {
|
| 6 |
name: name
|
| 7 |
location: location
|
|
|
|
| 9 |
properties: {
|
| 10 |
tenantId: subscription().tenantId
|
| 11 |
sku: { family: 'A', name: 'standard' }
|
| 12 |
+
enableRbacAuthorization: true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
}
|
| 15 |
|
infra/core/security/role.bicep
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
metadata description = 'Creates a role assignment for a service principal.'
|
| 2 |
+
param principalId string
|
| 3 |
+
|
| 4 |
+
@allowed([
|
| 5 |
+
'Device'
|
| 6 |
+
'ForeignGroup'
|
| 7 |
+
'Group'
|
| 8 |
+
'ServicePrincipal'
|
| 9 |
+
'User'
|
| 10 |
+
])
|
| 11 |
+
param principalType string = 'ServicePrincipal'
|
| 12 |
+
param roleDefinitionId string
|
| 13 |
+
|
| 14 |
+
resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
|
| 15 |
+
name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId)
|
| 16 |
+
properties: {
|
| 17 |
+
principalId: principalId
|
| 18 |
+
principalType: principalType
|
| 19 |
+
roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId)
|
| 20 |
+
}
|
| 21 |
+
}
|
infra/main.bicep
CHANGED
|
@@ -9,9 +9,19 @@ param name string
|
|
| 9 |
@description('Primary location for all resources')
|
| 10 |
param location string
|
| 11 |
|
| 12 |
-
@
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
@description('Id of the user or app to assign application roles')
|
| 17 |
param principalId string = ''
|
|
@@ -20,6 +30,9 @@ param principalId string = ''
|
|
| 20 |
@description('Django SECRET_KEY for cryptographic signing')
|
| 21 |
param djangoSecretKey string
|
| 22 |
|
|
|
|
|
|
|
|
|
|
| 23 |
var resourceToken = toLower(uniqueString(subscription().id, name, location))
|
| 24 |
var tags = { 'azd-env-name': name }
|
| 25 |
|
|
@@ -32,7 +45,6 @@ resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = {
|
|
| 32 |
var prefix = '${name}-${resourceToken}'
|
| 33 |
|
| 34 |
var postgresServerName = '${prefix}-postgresql'
|
| 35 |
-
var postgresAdminUser = 'admin${uniqueString(resourceGroup.id)}'
|
| 36 |
var postgresDatabaseName = 'django'
|
| 37 |
|
| 38 |
module postgresServer 'core/database/postgresql/flexibleserver.bicep' = {
|
|
@@ -50,18 +62,22 @@ module postgresServer 'core/database/postgresql/flexibleserver.bicep' = {
|
|
| 50 |
storageSizeGB: 32
|
| 51 |
}
|
| 52 |
version: '14'
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
databaseNames: [ postgresDatabaseName ]
|
| 56 |
allowAzureIPsFirewall: true
|
|
|
|
| 57 |
}
|
| 58 |
}
|
| 59 |
|
|
|
|
| 60 |
module web 'core/host/appservice.bicep' = {
|
| 61 |
name: 'appservice'
|
| 62 |
scope: resourceGroup
|
| 63 |
params: {
|
| 64 |
-
name:
|
| 65 |
location: location
|
| 66 |
tags: union(tags, { 'azd-service-name': 'web' })
|
| 67 |
appServicePlanId: appServicePlan.outputs.id
|
|
@@ -74,10 +90,9 @@ module web 'core/host/appservice.bicep' = {
|
|
| 74 |
appSettings: {
|
| 75 |
ADMIN_URL: 'admin${uniqueString(appServicePlan.outputs.id)}'
|
| 76 |
DBENGINE: 'django.db.backends.postgresql'
|
| 77 |
-
DBHOST: '${postgresServerName}.postgres.database.azure.com'
|
| 78 |
DBNAME: postgresDatabaseName
|
| 79 |
-
DBUSER:
|
| 80 |
-
DBPASS: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=postgresAdminPassword)'
|
| 81 |
DBSSL: 'require'
|
| 82 |
STATIC_BACKEND: 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
| 83 |
SECRET_KEY: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=djangoSecretKey)'
|
|
@@ -116,7 +131,26 @@ module keyVault './core/security/keyvault.bicep' = {
|
|
| 116 |
name: '${take(replace(prefix, '-', ''), 17)}-vault'
|
| 117 |
location: location
|
| 118 |
tags: tags
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
principalId: principalId
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
}
|
| 121 |
}
|
| 122 |
|
|
@@ -125,14 +159,6 @@ var secrets = [
|
|
| 125 |
name: 'djangoSecretKey'
|
| 126 |
value: djangoSecretKey
|
| 127 |
}
|
| 128 |
-
{
|
| 129 |
-
name: 'postgresAdminUser'
|
| 130 |
-
value: postgresAdminUser
|
| 131 |
-
}
|
| 132 |
-
{
|
| 133 |
-
name: 'postgresAdminPassword'
|
| 134 |
-
value: postgresAdminPassword
|
| 135 |
-
}
|
| 136 |
]
|
| 137 |
|
| 138 |
@batchSize(1)
|
|
@@ -146,6 +172,8 @@ module keyVaultSecrets './core/security/keyvault-secret.bicep' = [for secret in
|
|
| 146 |
}
|
| 147 |
}]
|
| 148 |
|
|
|
|
|
|
|
| 149 |
module logAnalyticsWorkspace 'core/monitor/loganalytics.bicep' = {
|
| 150 |
name: 'loganalytics'
|
| 151 |
scope: resourceGroup
|
|
@@ -156,6 +184,12 @@ module logAnalyticsWorkspace 'core/monitor/loganalytics.bicep' = {
|
|
| 156 |
}
|
| 157 |
}
|
| 158 |
|
|
|
|
| 159 |
output WEB_URI string = 'https://${web.outputs.uri}'
|
|
|
|
|
|
|
| 160 |
output AZURE_LOCATION string = location
|
| 161 |
-
output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
@description('Primary location for all resources')
|
| 10 |
param location string
|
| 11 |
|
| 12 |
+
@description('Entra admin role name')
|
| 13 |
+
param postgresEntraAdministratorName string
|
| 14 |
+
|
| 15 |
+
@description('Entra admin role object ID (in Entra)')
|
| 16 |
+
param postgresEntraAdministratorObjectId string
|
| 17 |
+
|
| 18 |
+
@description('Entra admin user type')
|
| 19 |
+
@allowed([
|
| 20 |
+
'User'
|
| 21 |
+
'Group'
|
| 22 |
+
'ServicePrincipal'
|
| 23 |
+
])
|
| 24 |
+
param postgresEntraAdministratorType string = 'User'
|
| 25 |
|
| 26 |
@description('Id of the user or app to assign application roles')
|
| 27 |
param principalId string = ''
|
|
|
|
| 30 |
@description('Django SECRET_KEY for cryptographic signing')
|
| 31 |
param djangoSecretKey string
|
| 32 |
|
| 33 |
+
@description('Running on GitHub Actions?')
|
| 34 |
+
param runningOnGh bool = false
|
| 35 |
+
|
| 36 |
var resourceToken = toLower(uniqueString(subscription().id, name, location))
|
| 37 |
var tags = { 'azd-env-name': name }
|
| 38 |
|
|
|
|
| 45 |
var prefix = '${name}-${resourceToken}'
|
| 46 |
|
| 47 |
var postgresServerName = '${prefix}-postgresql'
|
|
|
|
| 48 |
var postgresDatabaseName = 'django'
|
| 49 |
|
| 50 |
module postgresServer 'core/database/postgresql/flexibleserver.bicep' = {
|
|
|
|
| 62 |
storageSizeGB: 32
|
| 63 |
}
|
| 64 |
version: '14'
|
| 65 |
+
authType: 'EntraOnly'
|
| 66 |
+
entraAdministratorName: postgresEntraAdministratorName
|
| 67 |
+
entraAdministratorObjectId: postgresEntraAdministratorObjectId
|
| 68 |
+
entraAdministratorType: postgresEntraAdministratorType
|
| 69 |
databaseNames: [ postgresDatabaseName ]
|
| 70 |
allowAzureIPsFirewall: true
|
| 71 |
+
allowAllIPsFirewall: true // Necessary for post-provision script, can be disabled after
|
| 72 |
}
|
| 73 |
}
|
| 74 |
|
| 75 |
+
var webAppName = '${prefix}-app-service'
|
| 76 |
module web 'core/host/appservice.bicep' = {
|
| 77 |
name: 'appservice'
|
| 78 |
scope: resourceGroup
|
| 79 |
params: {
|
| 80 |
+
name: webAppName
|
| 81 |
location: location
|
| 82 |
tags: union(tags, { 'azd-service-name': 'web' })
|
| 83 |
appServicePlanId: appServicePlan.outputs.id
|
|
|
|
| 90 |
appSettings: {
|
| 91 |
ADMIN_URL: 'admin${uniqueString(appServicePlan.outputs.id)}'
|
| 92 |
DBENGINE: 'django.db.backends.postgresql'
|
| 93 |
+
DBHOST: '${postgresServerName}.postgres.database.azure.com' // todo replace with ouput
|
| 94 |
DBNAME: postgresDatabaseName
|
| 95 |
+
DBUSER: webAppName
|
|
|
|
| 96 |
DBSSL: 'require'
|
| 97 |
STATIC_BACKEND: 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
| 98 |
SECRET_KEY: '@Microsoft.KeyVault(VaultName=${keyVault.outputs.name};SecretName=djangoSecretKey)'
|
|
|
|
| 131 |
name: '${take(replace(prefix, '-', ''), 17)}-vault'
|
| 132 |
location: location
|
| 133 |
tags: tags
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
module userKeyVaultAccess 'core/security/role.bicep' = {
|
| 138 |
+
name: 'user-keyvault-access'
|
| 139 |
+
scope: resourceGroup
|
| 140 |
+
params: {
|
| 141 |
principalId: principalId
|
| 142 |
+
principalType: runningOnGh ? 'ServicePrincipal' : 'User'
|
| 143 |
+
roleDefinitionId: '00482a5a-887f-4fb3-b363-3b7fe8e74483'
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
module backendKeyVaultAccess 'core/security/role.bicep' = {
|
| 148 |
+
name: 'backend-keyvault-access'
|
| 149 |
+
scope: resourceGroup
|
| 150 |
+
params: {
|
| 151 |
+
principalId: web.outputs.identityPrincipalId
|
| 152 |
+
principalType: 'ServicePrincipal'
|
| 153 |
+
roleDefinitionId: '00482a5a-887f-4fb3-b363-3b7fe8e74483'
|
| 154 |
}
|
| 155 |
}
|
| 156 |
|
|
|
|
| 159 |
name: 'djangoSecretKey'
|
| 160 |
value: djangoSecretKey
|
| 161 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
]
|
| 163 |
|
| 164 |
@batchSize(1)
|
|
|
|
| 172 |
}
|
| 173 |
}]
|
| 174 |
|
| 175 |
+
|
| 176 |
+
|
| 177 |
module logAnalyticsWorkspace 'core/monitor/loganalytics.bicep' = {
|
| 178 |
name: 'loganalytics'
|
| 179 |
scope: resourceGroup
|
|
|
|
| 184 |
}
|
| 185 |
}
|
| 186 |
|
| 187 |
+
output WEB_APP_NAME string = webAppName
|
| 188 |
output WEB_URI string = 'https://${web.outputs.uri}'
|
| 189 |
+
output SERVICE_WEB_IDENTITY_NAME string = webAppName
|
| 190 |
+
|
| 191 |
output AZURE_LOCATION string = location
|
| 192 |
+
output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name
|
| 193 |
+
|
| 194 |
+
output POSTGRES_HOST string = postgresServer.outputs.POSTGRES_DOMAIN_NAME
|
| 195 |
+
output POSTGRES_USERNAME string = postgresEntraAdministratorName
|
infra/main.parameters.json
CHANGED
|
@@ -11,11 +11,20 @@
|
|
| 11 |
"principalId": {
|
| 12 |
"value": "${AZURE_PRINCIPAL_ID}"
|
| 13 |
},
|
| 14 |
-
"
|
| 15 |
-
"value": "$
|
| 16 |
},
|
| 17 |
"djangoSecretKey": {
|
| 18 |
"value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} djangoSecretKey)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
}
|
| 21 |
}
|
|
|
|
| 11 |
"principalId": {
|
| 12 |
"value": "${AZURE_PRINCIPAL_ID}"
|
| 13 |
},
|
| 14 |
+
"runningOnGh": {
|
| 15 |
+
"value": "${GITHUB_ACTIONS}"
|
| 16 |
},
|
| 17 |
"djangoSecretKey": {
|
| 18 |
"value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} djangoSecretKey)"
|
| 19 |
+
},
|
| 20 |
+
"postgresEntraAdministratorName": {
|
| 21 |
+
"value": "useradmin"
|
| 22 |
+
},
|
| 23 |
+
"postgresEntraAdministratorObjectId": {
|
| 24 |
+
"value": "${AZURE_PRINCIPAL_ID}"
|
| 25 |
+
},
|
| 26 |
+
"postgresEntraAdministratorType": {
|
| 27 |
+
"value": "User"
|
| 28 |
}
|
| 29 |
}
|
| 30 |
}
|
scripts/load_python_env.sh
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
|
| 3 |
+
echo 'Creating Python virtual environment in ".venv"...'
|
| 4 |
+
python3 -m venv .venv
|
| 5 |
+
|
| 6 |
+
echo 'Installing dependencies from "scripts/requirements.txt" into virtual environment (in quiet mode)...'
|
| 7 |
+
.venv/bin/python -m pip --quiet --disable-pip-version-check install -r scripts/requirements.txt
|
scripts/requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
psycopg2==2.9.9
|
| 2 |
+
azure-identity==1.16.0
|
scripts/setup_postgres_azurerole.ps1
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
. ./scripts/load_python_env.ps1
|
| 2 |
+
|
| 3 |
+
$POSTGRES_HOST = ((azd env get-values | Select-String -Pattern "POSTGRES_HOST") -replace '^POSTGRES_HOST=', '')
|
| 4 |
+
$POSTGRES_USERNAME = ((azd env get-values | Select-String -Pattern "POSTGRES_USERNAME") -replace '^POSTGRES_USERNAME=', '')
|
| 5 |
+
$APP_IDENTITY_NAME = ((azd env get-values | Select-String -Pattern "SERVICE_WEB_IDENTITY_NAME") -replace '^SERVICE_WEB_IDENTITY_NAME=', '')
|
| 6 |
+
|
| 7 |
+
if ([string]::IsNullOrEmpty($POSTGRES_HOST) -or [string]::IsNullOrEmpty($POSTGRES_USERNAME) -or [string]::IsNullOrEmpty($APP_IDENTITY_NAME)) {
|
| 8 |
+
Write-Host "Can't find POSTGRES_HOST, POSTGRES_USERNAME, and SERVICE_WEB_IDENTITY_NAME environment variables. Make sure you run azd up first."
|
| 9 |
+
exit 1
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
$venvPythonPath = "./.venv/scripts/python.exe"
|
| 13 |
+
if (Test-Path -Path "/usr") {
|
| 14 |
+
# fallback to Linux venv path
|
| 15 |
+
$venvPythonPath = "./.venv/bin/python"
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
Start-Process -FilePath $venvPythonPath -ArgumentList "./src/setup_postgres_azurerole.py", "--host", $POSTGRES_HOST, "--username", $POSTGRES_USERNAME, "--app-identity-name", $APP_IDENTITY_NAME -Wait -NoNewWindow
|
scripts/setup_postgres_azurerole.sh
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
POSTGRES_HOST=$(azd env get-values | grep POSTGRES_HOST | sed 's/="/=/' | sed 's/"$//' | sed 's/^POSTGRES_HOST=//')
|
| 2 |
+
POSTGRES_USERNAME=$(azd env get-values | grep POSTGRES_USERNAME | sed 's/="/=/' | sed 's/"$//' | sed 's/^POSTGRES_USERNAME=//')
|
| 3 |
+
APP_IDENTITY_NAME=$(azd env get-values | grep SERVICE_WEB_IDENTITY_NAME | sed 's/="/=/' | sed 's/"$//' | sed 's/^SERVICE_WEB_IDENTITY_NAME=//')
|
| 4 |
+
|
| 5 |
+
if [ -z "$POSTGRES_HOST" ] || [ -z "$POSTGRES_USERNAME" ] || [ -z "$APP_IDENTITY_NAME" ]; then
|
| 6 |
+
echo "Can't find POSTGRES_HOST, POSTGRES_USERNAME, and SERVICE_WEB_IDENTITY_NAME environment variables. Make sure you run azd up first."
|
| 7 |
+
exit 1
|
| 8 |
+
fi
|
| 9 |
+
|
| 10 |
+
. ./scripts/load_python_env.sh
|
| 11 |
+
|
| 12 |
+
./.venv/bin/python ./src/setup_postgres_azurerole.py --host $POSTGRES_HOST --username $POSTGRES_USERNAME --app-identity-name $APP_IDENTITY_NAME
|
src/quizsite/postgresql/__init__.py
ADDED
|
File without changes
|
src/quizsite/postgresql/base.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from azure.identity import DefaultAzureCredential
|
| 2 |
+
from django.db.backends.postgresql import base
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class DatabaseWrapper(base.DatabaseWrapper):
|
| 6 |
+
def get_connection_params(self):
|
| 7 |
+
params = super().get_connection_params()
|
| 8 |
+
if params.get("host", "").endswith(".database.azure.com"):
|
| 9 |
+
azure_credential = DefaultAzureCredential()
|
| 10 |
+
dbpass = azure_credential.get_token("https://ossrdbms-aad.database.windows.net/.default").token
|
| 11 |
+
params["password"] = dbpass
|
| 12 |
+
return params
|
src/quizsite/settings.py
CHANGED
|
@@ -108,12 +108,13 @@ WSGI_APPLICATION = "quizsite.wsgi.application"
|
|
| 108 |
|
| 109 |
DATABASES = {
|
| 110 |
"default": {
|
| 111 |
-
"ENGINE":
|
| 112 |
"NAME": env("DBNAME"),
|
| 113 |
"HOST": env("DBHOST"),
|
| 114 |
"USER": env("DBUSER"),
|
| 115 |
-
"PASSWORD": env("DBPASS"),
|
| 116 |
"OPTIONS": {"sslmode": env("DBSSL")},
|
|
|
|
| 117 |
}
|
| 118 |
}
|
| 119 |
|
|
|
|
| 108 |
|
| 109 |
DATABASES = {
|
| 110 |
"default": {
|
| 111 |
+
"ENGINE": "quizsite.postgresql",
|
| 112 |
"NAME": env("DBNAME"),
|
| 113 |
"HOST": env("DBHOST"),
|
| 114 |
"USER": env("DBUSER"),
|
| 115 |
+
"PASSWORD": env("DBPASS", default="PASSWORD_WILL_BE_SET_LATER"),
|
| 116 |
"OPTIONS": {"sslmode": env("DBSSL")},
|
| 117 |
+
"CONN_MAX_AGE": 60 * 60 * 6, # 6 hours
|
| 118 |
}
|
| 119 |
}
|
| 120 |
|
src/requirements.txt
CHANGED
|
@@ -3,3 +3,4 @@ psycopg2==2.9.9
|
|
| 3 |
python-dotenv==1.0.1
|
| 4 |
whitenoise[brotli]==6.6.0
|
| 5 |
django-environ==0.11.2
|
|
|
|
|
|
| 3 |
python-dotenv==1.0.1
|
| 4 |
whitenoise[brotli]==6.6.0
|
| 5 |
django-environ==0.11.2
|
| 6 |
+
azure-identity==1.16.0
|
src/setup_postgres_azurerole.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
import psycopg2
|
| 5 |
+
from azure.identity import DefaultAzureCredential
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("scripts")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def assign_role_for_webapp(postgres_host, postgres_username, app_identity_name):
|
| 11 |
+
if not postgres_host.endswith(".database.azure.com"):
|
| 12 |
+
logger.info("This script is intended to be used with Azure Database for PostgreSQL.")
|
| 13 |
+
logger.info("Please set the environment variable DBHOST to the Azure Database for PostgreSQL server hostname.")
|
| 14 |
+
return
|
| 15 |
+
|
| 16 |
+
logger.info("Authenticating to Azure Database for PostgreSQL using Azure Identity...")
|
| 17 |
+
azure_credential = DefaultAzureCredential()
|
| 18 |
+
token = azure_credential.get_token("https://ossrdbms-aad.database.windows.net/.default")
|
| 19 |
+
conn = psycopg2.connect(
|
| 20 |
+
database="postgres", # You must connect to postgres database when assigning roles
|
| 21 |
+
user=postgres_username,
|
| 22 |
+
password=token.token,
|
| 23 |
+
host=postgres_host,
|
| 24 |
+
sslmode="require",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
conn.autocommit = True
|
| 28 |
+
cur = conn.cursor()
|
| 29 |
+
|
| 30 |
+
cur.execute(f"select * from pgaadauth_list_principals(false) WHERE rolname = '{app_identity_name}'")
|
| 31 |
+
identities = cur.fetchall()
|
| 32 |
+
if len(identities) > 0:
|
| 33 |
+
logger.info(f"Found an existing PostgreSQL role for identity {app_identity_name}")
|
| 34 |
+
else:
|
| 35 |
+
logger.info(f"Creating a PostgreSQL role for identity {app_identity_name}")
|
| 36 |
+
cur.execute(f"SELECT * FROM pgaadauth_create_principal('{app_identity_name}', false, false)")
|
| 37 |
+
|
| 38 |
+
logger.info(f"Granting permissions to {app_identity_name}")
|
| 39 |
+
# set role to azure_pg_admin
|
| 40 |
+
cur.execute(f'GRANT USAGE ON SCHEMA public TO "{app_identity_name}"')
|
| 41 |
+
cur.execute(f'GRANT CREATE ON SCHEMA public TO "{app_identity_name}"')
|
| 42 |
+
cur.execute(f'GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "{app_identity_name}"')
|
| 43 |
+
cur.execute(
|
| 44 |
+
f"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
|
| 45 |
+
f'GRANT SELECT, UPDATE, INSERT, DELETE ON TABLES TO "{app_identity_name}"'
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
cur.close()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
|
| 53 |
+
logging.basicConfig(level=logging.WARNING)
|
| 54 |
+
logger.setLevel(logging.INFO)
|
| 55 |
+
parser = argparse.ArgumentParser(description="Create database schema")
|
| 56 |
+
parser.add_argument("--host", type=str, help="Postgres host")
|
| 57 |
+
parser.add_argument("--username", type=str, help="Postgres username")
|
| 58 |
+
parser.add_argument("--app-identity-name", type=str, help="Azure App Service identity name")
|
| 59 |
+
|
| 60 |
+
args = parser.parse_args()
|
| 61 |
+
if not args.host.endswith(".database.azure.com"):
|
| 62 |
+
logger.info("This script is intended to be used with Azure Database for PostgreSQL, not local PostgreSQL.")
|
| 63 |
+
exit(1)
|
| 64 |
+
|
| 65 |
+
assign_role_for_webapp(args.host, args.username, args.app_identity_name)
|
| 66 |
+
logger.info("Role created successfully.")
|