From d29b1ea8b17683e0512f09dbe92e3920b0a9d165 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Tue, 16 Apr 2024 12:20:00 -0700 Subject: [PATCH] Port to Entra authentication --- azure.yaml | 7 ++ .../database/postgresql/flexibleserver.bicep | 103 +++++++++++++----- infra/main.bicep | 32 ++++-- infra/main.parameters.json | 10 +- pyproject.toml | 5 +- scripts/assign_role.py | 59 ++++++++++ scripts/assign_role.sh | 10 ++ src/flaskapp/config/production.py | 10 +- src/requirements.txt | 1 + 9 files changed, 198 insertions(+), 39 deletions(-) create mode 100644 scripts/assign_role.py create mode 100755 scripts/assign_role.sh diff --git a/azure.yaml b/azure.yaml index 58cf92f..8055a72 100644 --- a/azure.yaml +++ b/azure.yaml @@ -8,3 +8,10 @@ services: project: ./src language: py host: appservice +hooks: + postprovision: + posix: + shell: sh + run: ./scripts/assign_role.sh + interactive: true + continueOnError: false diff --git a/infra/core/database/postgresql/flexibleserver.bicep b/infra/core/database/postgresql/flexibleserver.bicep index 8c62661..d1a4583 100644 --- a/infra/core/database/postgresql/flexibleserver.bicep +++ b/infra/core/database/postgresql/flexibleserver.bicep @@ -4,9 +4,32 @@ param tags object = {} param sku object param storage object -param administratorLogin string + +@allowed([ + 'Password' + 'EntraOnly' +]) +param authType string = 'Password' + +param administratorLogin string = '' @secure() -param administratorLoginPassword string +param administratorLoginPassword string = '' + +@description('Entra admin role name') +param entraAdministratorName string = '' + +@description('Entra admin role object ID (in Entra)') +param entraAdministratorObjectId string = '' + +@description('Entra admin user type') +@allowed([ + 'User' + 'Group' + 'ServicePrincipal' +]) +param entraAdministratorType string = 'User' + + param databaseNames array = [] param allowAzureIPsFirewall bool = false param allowAllIPsFirewall bool = false @@ -15,49 +38,79 @@ param allowedSingleIPs array = [] // PostgreSQL version param version string +var authProperties = authType == 'Password' ? { + administratorLogin: administratorLogin + administratorLoginPassword: administratorLoginPassword + authConfig: { + passwordAuth: 'Enabled' + } +} : { + authConfig: { + activeDirectoryAuth: 'Enabled' + passwordAuth: 'Disabled' + } +} + resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-12-01' = { location: location tags: tags name: name sku: sku - properties: { + properties: union(authProperties, { version: version - administratorLogin: administratorLogin - administratorLoginPassword: administratorLoginPassword storage: storage highAvailability: { mode: 'Disabled' } - } + }) resource database 'databases' = [for name in databaseNames: { name: name }] +} - resource firewall_all 'firewallRules' = if (allowAllIPsFirewall) { - name: 'allow-all-IPS' - properties: { - startIpAddress: '0.0.0.0' - endIpAddress: '255.255.255.255' - } +// This must be done separately due to conflicts with the Entra setup +resource firewall_all 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = if (allowAllIPsFirewall) { + parent: postgresServer + name: 'allow-all-IPs' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '255.255.255.255' } +} - resource firewall_azure 'firewallRules' = if (allowAzureIPsFirewall) { - name: 'allow-all-azure-internal-IPs' - properties: { - startIpAddress: '0.0.0.0' - endIpAddress: '255.255.255.255' - } +// This must be done separately due to conflicts with the Entra setup +resource firewall_azure 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = if (allowAzureIPsFirewall) { + parent: postgresServer + name: 'allow-all-azure-internal-IPs' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' } +} - resource firewall_single 'firewallRules' = [for ip in allowedSingleIPs: { - name: 'allow-single-${replace(ip, '.', '')}' - properties: { - startIpAddress: ip - endIpAddress: ip - } - }] +@batchSize(1) +// This must be done separately due to conflicts with the Entra setup +resource firewall_single 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = [for ip in allowedSingleIPs: { + parent: postgresServer + name: 'allow-single-${replace(ip, '.', '')}' + properties: { + startIpAddress: ip + endIpAddress: ip + } +}] +// This must be created *after* the server is created - it cannot be a nested child resource +resource addAddUser 'Microsoft.DBforPostgreSQL/flexibleServers/administrators@2023-03-01-preview' = { + parent: postgresServer + name: entraAdministratorObjectId + properties: { + tenantId: subscription().tenantId + principalType: entraAdministratorType + principalName: entraAdministratorName + } + // This is a workaround for a bug in the API that requires the parent to be fully resolved + dependsOn: [postgresServer] } output POSTGRES_DOMAIN_NAME string = postgresServer.properties.fullyQualifiedDomainName diff --git a/infra/main.bicep b/infra/main.bicep index 7f349ae..83bb40e 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -9,9 +9,19 @@ param name string @description('Primary location for all resources') param location string -@secure() -@description('PostGreSQL Server administrator password') -param postgresAdminPassword string +@description('Entra admin role name') +param postgresEntraAdministratorName string + +@description('Entra admin role object ID (in Entra)') +param postgresEntraAdministratorObjectId string + +@description('Entra admin user type') +@allowed([ + 'User' + 'Group' + 'ServicePrincipal' +]) +param postgresEntraAdministratorType string = 'User' var resourceToken = toLower(uniqueString(subscription().id, name, location)) var tags = { 'azd-env-name': name } @@ -43,13 +53,17 @@ module postgresServer 'core/database/postgresql/flexibleserver.bicep' = { storageSizeGB: 32 } version: '13' - administratorLogin: postgresAdminUser - administratorLoginPassword: postgresAdminPassword + authType: 'EntraOnly' + entraAdministratorName: postgresEntraAdministratorName + entraAdministratorObjectId: postgresEntraAdministratorObjectId + entraAdministratorType: postgresEntraAdministratorType databaseNames: [postgresDatabaseName] allowAzureIPsFirewall: true + allowAllIPsFirewall: true // Necessary for post-provision script, can be disabled after } } +var webAppName = '${prefix}-app-service' module web 'core/host/appservice.bicep' = { name: 'appservice' scope: resourceGroup @@ -63,13 +77,13 @@ module web 'core/host/appservice.bicep' = { scmDoBuildDuringDeployment: true ftpsState: 'Disabled' appCommandLine: 'startup.sh' + managedIdentity: true use32BitWorkerProcess: true alwaysOn: false appSettings: { DBHOST: postgresServerName DBNAME: postgresDatabaseName - DBUSER: postgresAdminUser - DBPASS: postgresAdminPassword + DBUSER: webAppName } } } @@ -98,5 +112,9 @@ module logAnalyticsWorkspace 'core/monitor/loganalytics.bicep' = { } } +output WEB_APP_NAME string = webAppName output WEB_URI string = 'https://${web.outputs.uri}' output AZURE_LOCATION string = location + +output POSTGRES_DOMAIN_NAME string = postgresServer.outputs.POSTGRES_DOMAIN_NAME +output POSTGRES_ADMIN_USERNAME string = postgresEntraAdministratorName diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 5e99a25..96ee922 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -8,8 +8,14 @@ "location": { "value": "${AZURE_LOCATION}" }, - "postgresAdminPassword": { - "value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} postgresAdminPassword)" + "postgresEntraAdministratorName": { + "value": "useradmin" + }, + "postgresEntraAdministratorObjectId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "postgresEntraAdministratorType": { + "value": "User" } } } diff --git a/pyproject.toml b/pyproject.toml index 6ab746e..c12b366 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ [tool.ruff] line-length = 120 target-version = "py38" +src = ["src"] + +[tool.ruff.lint] select = ["E", "F", "I", "UP"] ignore = ["D203"] -show-source = true -src = ["src"] [tool.black] line-length = 120 diff --git a/scripts/assign_role.py b/scripts/assign_role.py new file mode 100644 index 0000000..fe4133b --- /dev/null +++ b/scripts/assign_role.py @@ -0,0 +1,59 @@ +import logging +import os + +import psycopg2 +from azure.identity import DefaultAzureCredential + +logger = logging.getLogger("scripts") + + +def assign_role_for_webapp(postgres_host, postgres_username, app_identity_name): + if not postgres_host.endswith(".database.azure.com"): + logger.info("This script is intended to be used with Azure Database for PostgreSQL.") + logger.info("Please set the environment variable DBHOST to the Azure Database for PostgreSQL server hostname.") + return + + logger.info("Authenticating to Azure Database for PostgreSQL using Azure Identity...") + azure_credential = DefaultAzureCredential() + token = azure_credential.get_token("https://ossrdbms-aad.database.windows.net/.default") + conn = psycopg2.connect( + database="postgres", # You must connect to postgres database when assigning roles + user=postgres_username, + password=token.token, + host=postgres_host, + sslmode="require", + ) + + conn.autocommit = True + cur = conn.cursor() + + cur.execute(f"select * from pgaadauth_list_principals(false) WHERE rolname = '{app_identity_name}'") + + # count number of rows in cur + if len(cur.fetchall()) == 1: + logger.info(f"Found an existing PostgreSQL role for identity {app_identity_name}") + else: + logger.info(f"Creating a PostgreSQL role for identity {app_identity_name}") + cur.execute(f"SELECT * FROM pgaadauth_create_principal('{app_identity_name}', false, false)") + cur.execute(f'GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "{app_identity_name}"') + cur.execute( + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public" + f'GRANT SELECT, UPDATE, INSERT, DELETE ON TABLES TO "{app_identity_name}"' + ) + cur.close() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.WARNING) + logger.setLevel(logging.INFO) + + POSTGRES_HOST = os.getenv("POSTGRES_DOMAIN_NAME") + POSTGRES_USERNAME = os.getenv("POSTGRES_ADMIN_USERNAME") + APP_IDENTITY_NAME = os.getenv("WEB_APP_NAME") + if not POSTGRES_HOST or not POSTGRES_USERNAME or not APP_IDENTITY_NAME: + logger.error( + "Can't find POSTGRES_DOMAIN_NAME, POSTGRES_ADMIN_USERNAME, and WEB_APP_NAME environment variables." + "Make sure you run azd up first." + ) + else: + assign_role_for_webapp(POSTGRES_HOST, POSTGRES_USERNAME, APP_IDENTITY_NAME) diff --git a/scripts/assign_role.sh b/scripts/assign_role.sh new file mode 100755 index 0000000..eea53d2 --- /dev/null +++ b/scripts/assign_role.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +while IFS='=' read -r key value; do + value=$(echo "$value" | sed 's/^"//' | sed 's/"$//') + export "$key=$value" +done <