Skip to content
Merged
49 changes: 45 additions & 4 deletions samples/web-app-sql-database/python/bicep/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ param username string = 'paolo'
var sqlServerName = '${prefix}-sqlserver-${suffix}'
var webAppName = '${prefix}-webapp-${suffix}'
var appServicePlanName = '${prefix}-app-service-plan-${suffix}'
var keyVaultName = '${prefix}-kv-${suffix}'
var sqlConnectionStringSecretName = 'sql-connection-string'
var identity = {
type: 'SystemAssigned'
}
Expand Down Expand Up @@ -429,16 +431,52 @@ resource webApp 'Microsoft.Web/sites@2024-11-01' = {
}
}

resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: keyVaultName
location: location
tags: tags
properties: {
tenantId: subscription().tenantId
sku: {
family: 'A'
name: 'standard'
}
accessPolicies: [
{
tenantId: subscription().tenantId
objectId: webApp.identity.principalId
permissions: {
secrets: [
'get'
'list'
]
}
}
]
enableRbacAuthorization: false
enableSoftDelete: true
softDeleteRetentionInDays: 7
}
}

resource sqlConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = {
parent: keyVault
name: sqlConnectionStringSecretName
properties: {
value: 'Server=tcp:${sqlServer.properties.fullyQualifiedDomainName},1433;Database=${sqlDatabaseName};User ID=${sqlDatabaseUsername};Password=${sqlDatabasePassword};Encrypt=yes;TrustServerCertificate=no;Connection Timeout=30;'
}
}

resource configAppSettings 'Microsoft.Web/sites/config@2024-11-01' = {
parent: webApp
name: 'appsettings'
properties: {
SCM_DO_BUILD_DURING_DEPLOYMENT: 'true'
ENABLE_ORYX_BUILD: 'true'
SQL_SERVER: sqlServer.properties.fullyQualifiedDomainName
SQL_DATABASE: sqlDatabaseName
SQL_USERNAME: sqlDatabaseUsername
SQL_PASSWORD: sqlDatabasePassword
//Pass Key Vault name and secret name as app settings.
//The Python SDK will retrieve the actual connection string value from Key Vault
KEY_VAULT_NAME: keyVaultName
SECRET_NAME: sqlConnectionStringSecretName
LOGIN_NAME: username
}
}
Expand All @@ -459,3 +497,6 @@ output webAppUrl string = webApp.properties.defaultHostName
output sqlServerName string = sqlServer.name
output sqlServerFqdn string = sqlServer.properties.fullyQualifiedDomainName
output sqlDatabaseName string = sqlDatabase.name
output keyVaultName string = keyVault.name
output keyVaultUrl string = keyVault.properties.vaultUri
output sqlConnectionStringSecretUri string = sqlConnectionStringSecret.properties.secretUri
72 changes: 68 additions & 4 deletions samples/web-app-sql-database/python/scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ RUNTIME="python"
RUNTIME_VERSION="3.13"
DEPLOY_APP=1
ENVIRONMENT=$(az account show --query environmentName --output tsv)
KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}"
SECRET_NAME="${PREFIX}-secret-${SUFFIX}"

# Change the current directory to the script's directory
cd "$CURRENT_DIR" || exit
Expand Down Expand Up @@ -305,6 +307,7 @@ $AZ webapp create \
--plan "$APP_SERVICE_PLAN_NAME" \
--name "$WEB_APP_NAME" \
--runtime "$RUNTIME:$RUNTIME_VERSION" \
--assign-identity \
--only-show-errors 1>/dev/null

if [ $? -eq 0 ]; then
Expand All @@ -314,18 +317,79 @@ else
exit 1
fi

# Get Web App principal ID
PRINCIPAL_ID=$($AZ webapp identity show \
--name "$WEB_APP_NAME" \
--resource-group "$RESOURCE_GROUP_NAME" \
--query "principalId" \
--output tsv)

if [ -z "$PRINCIPAL_ID" ]; then
echo "Failed to retrieve principalId for web app [$WEB_APP_NAME]"
exit 1
fi

# Create Key Vault
echo "Creating Key Vault [$KEY_VAULT_NAME]..."
$AZ keyvault create \
--name "$KEY_VAULT_NAME" \
--resource-group "$RESOURCE_GROUP_NAME" \
--location "$LOCATION" \
--enable-rbac-authorization false \
--only-show-errors 1>/dev/null

if [ $? -eq 0 ]; then
echo "Key Vault [$KEY_VAULT_NAME] created successfully."
else
echo "Failed to create Key Vault [$KEY_VAULT_NAME]."
exit 1
fi

# Assign access policy to Web App managed identity
echo "Assigning Key Vault access policy to Web App..."
$AZ keyvault set-policy \
--name "$KEY_VAULT_NAME" \
--object-id "$PRINCIPAL_ID" \
--secret-permissions get \
--only-show-errors 1>/dev/null

if [ $? -eq 0 ]; then
echo "Key Vault access policy assigned successfully."
else
echo "Failed to assign Key Vault access policy."
exit 1
fi

# Build connection string
SQL_CONNECTION_STRING="Server=tcp:${SQL_SERVER_FQDN},1433;Database=${SQL_DATABASE_NAME};User ID=${DATABASE_USER_NAME};Password=${DATABASE_USER_PASSWORD};Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=30;"

# Create secret
echo "Creating secret [$SECRET_NAME] in Key Vault..."
$AZ keyvault secret set \
--vault-name "$KEY_VAULT_NAME" \
--name "$SECRET_NAME" \
--value "$SQL_CONNECTION_STRING" \
--only-show-errors 1>/dev/null

if [ $? -eq 0 ]; then
echo "Secret [$SECRET_NAME] created successfully."
else
echo "Failed to create secret [$SECRET_NAME]."
exit 1
fi

# Set web app settings
# Pass Key Vault name and secret name as app settings.
# The Python SDK will retrieve the actual connection string value from Key Vault.
echo "Setting web app settings for [$WEB_APP_NAME]..."
$AZ webapp config appsettings set \
--name "$WEB_APP_NAME" \
--resource-group "$RESOURCE_GROUP_NAME" \
--settings \
SCM_DO_BUILD_DURING_DEPLOYMENT='true' \
ENABLE_ORYX_BUILD='true' \
SQL_SERVER="$SQL_SERVER_FQDN" \
SQL_DATABASE="$SQL_DATABASE_NAME" \
SQL_USERNAME="$DATABASE_USER_NAME" \
SQL_PASSWORD="$DATABASE_USER_PASSWORD" \
KEY_VAULT_NAME="$KEY_VAULT_NAME" \
SECRET_NAME="$SECRET_NAME" \
LOGIN_NAME="$LOGIN_NAME" \
--only-show-errors 1>/dev/null

Expand Down
13 changes: 13 additions & 0 deletions samples/web-app-sql-database/python/scripts/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,17 @@ $AZ sql db show \
--name PlannerDB \
--server local-sqlserver-test \
--resource-group local-rg \
--output table

# Check Azure Key Vault
$AZ keyvault show \
--name local-kv-test \
--resource-group local-rg \
--output table

# Check Key Vault secret
$AZ keyvault secret show \
--vault-name local-kv-test \
--name local-secret-test \
--query "{name:name, enabled:attributes.enabled, created:attributes.created}" \
--output table
77 changes: 71 additions & 6 deletions samples/web-app-sql-database/python/src/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
Supports both traditional ODBC connections and passwordless Azure AD authentication
"""

import logging
import os
import struct
import pyodbc
import logging
from typing import Optional, List, Dict, Any
from contextlib import contextmanager
from typing import Any, Dict, List, Optional

import pyodbc
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

# Configure logging
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -73,26 +75,89 @@ def from_env(cls) -> 'SqlHelper':
- SQL_USERNAME
- SQL_PASSWORD
"""
key_vault_name = os.environ.get("KEY_VAULT_NAME")
secret_name = os.environ.get("SECRET_NAME")

if key_vault_name and secret_name:
return cls.from_key_vault(key_vault_name, secret_name)

client_id = os.environ.get("AZURE_CLIENT_ID")
client_secret = os.environ.get("AZURE_CLIENT_SECRET")
tenant_id = os.environ.get("AZURE_TENANT_ID")
server = os.environ.get("SQL_SERVER")
database = os.environ.get("SQL_DATABASE")
username = os.environ.get("SQL_USERNAME")
password = os.environ.get("SQL_PASSWORD")

if not any([client_id, client_secret, tenant_id, server, database, username, password]):
raise ValueError("You properly need to define environment variables.")

logger.info("Environment variables loaded successfully")

return cls(
server=server,
database=database,
username=username,
password=password,
use_azure_credential=all([client_id, client_secret, tenant_id])
)

@classmethod
def from_key_vault(cls, vault_name: str, secret_name: str) -> 'SqlHelper':
"""
Create a SqlHelper instance by reading the connection string from Azure Key Vault.

"""
vault_url = f"https://{vault_name}.vault.azure.net"
credential = DefaultAzureCredential(exclude_interactive_browser_credential=False)
client = SecretClient(vault_url=vault_url, credential=credential)

logger.info(f"Retrieving secret [{secret_name}] from Key Vault [{vault_name}]...")
secret = client.get_secret(secret_name)

if not secret.value:
raise ValueError(f"Secret [{secret_name}] in Key Vault [{vault_name}] has no value")

logger.info(f"Secret [{secret_name}] retrieved successfully from Key Vault [{vault_name}]")
return cls.from_connection_string(secret.value)

@classmethod
def from_connection_string(cls, connection_string: str) -> 'SqlHelper':
"""
Create a SqlHelper instance from a connection string.

This is useful when the connection string is stored in an environment variable
(e.g., resolved by Azure App Service from Key Vault via @Microsoft.KeyVault(SecretUri=...)).

"""
parts = {}
for part in connection_string.split(';'):
if '=' in part:
key, value = part.split('=', 1)
parts[key.strip()] = value.strip()

server = parts.get('Server', '').replace('tcp:', '').replace(',1433', '')
database = parts.get('Database')
username = parts.get('User ID')
password = parts.get('Password')

if not all([server, database, username, password]):
raise ValueError(
f"Could not parse all required parameters from connection string. "
f"Found - Server: {bool(server)}, Database: {bool(database)}, "
f"Username: {bool(username)}, Password: {bool(password)}"
)

logger.info("Connection string parsed successfully")
logger.info(f"Server: {server}, Database: {database}, Username: {username}")

return cls(
server=server,
database=database,
username=username,
password=password,
use_azure_credential=False
)

def _build_connection_string(self) -> str:
"""Build the ODBC connection string."""
Expand Down
1 change: 1 addition & 0 deletions samples/web-app-sql-database/python/src/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ azure-identity==1.25.1
pyodbc==5.3.0
gunicorn==20.1.0
python-dotenv==1.1.1
azure-keyvault-secrets