Skip to content

Commit

Permalink
Merge pull request #58 from pamelafox/entra-bicep
Browse files Browse the repository at this point in the history
Port from passwords to passwordless (Entra) auth
  • Loading branch information
pamelafox authored Apr 19, 2024
2 parents 26f00b8 + ef9bb67 commit f4db3cc
Show file tree
Hide file tree
Showing 9 changed files with 201 additions and 41 deletions.
7 changes: 7 additions & 0 deletions azure.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
103 changes: 78 additions & 25 deletions infra/core/database/postgresql/flexibleserver.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, firewall_all, firewall_azure]
}

output POSTGRES_DOMAIN_NAME string = postgresServer.properties.fullyQualifiedDomainName
36 changes: 27 additions & 9 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -43,18 +53,22 @@ 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}-appservice'
module web 'core/host/appservice.bicep' = {
name: 'appservice'
scope: resourceGroup
params: {
name: '${prefix}-appservice'
name: webAppName
location: location
tags: union(tags, { 'azd-service-name': 'web' })
appServicePlanId: appServicePlan.outputs.id
Expand All @@ -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
}
}
}
Expand All @@ -82,7 +96,7 @@ module appServicePlan 'core/host/appserviceplan.bicep' = {
location: location
tags: tags
sku: {
name: 'F1'
name: 'B1'
}
reserved: true
}
Expand All @@ -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
10 changes: 8 additions & 2 deletions infra/main.parameters.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
60 changes: 60 additions & 0 deletions scripts/assign_role.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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)")
logger.info(f"Granting permissions to {app_identity_name}")
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)
10 changes: 10 additions & 0 deletions scripts/assign_role.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/bin/sh

while IFS='=' read -r key value; do
value=$(echo "$value" | sed 's/^"//' | sed 's/"$//')
export "$key=$value"
done <<EOF
$(azd env get-values)
EOF

python ./scripts/assign_role.py
10 changes: 7 additions & 3 deletions src/flaskapp/config/production.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import os

from azure.identity import DefaultAzureCredential

DEBUG = False

if "WEBSITE_HOSTNAME" in os.environ:
ALLOWED_HOSTS = [os.environ["WEBSITE_HOSTNAME"]]
else:
ALLOWED_HOSTS = []

# Configure Postgres database; the full username for PostgreSQL flexible server is
# username (not @sever-name).
dbuser = os.environ["DBUSER"]
dbpass = os.environ["DBPASS"]
azure_credential = DefaultAzureCredential()
dbpass = azure_credential.get_token("https://ossrdbms-aad.database.windows.net/.default").token
dbhost = os.environ["DBHOST"] + ".postgres.database.azure.com"
dbname = os.environ["DBNAME"]

DATABASE_URI = f"postgresql+psycopg2://{dbuser}:{dbpass}@{dbhost}/{dbname}"

# TODO: SSL not needed?
1 change: 1 addition & 0 deletions src/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ psycopg2-binary==2.9.9
python-dotenv==1.0.1
SQLAlchemy==2.0.29
gunicorn==21.2.0
azure-identity==1.16.0

0 comments on commit f4db3cc

Please sign in to comment.