From 2fc85ab7ff2b59a7144243574fece9acb3ded25e Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Wed, 14 Jan 2026 12:12:20 +0100 Subject: [PATCH 01/12] add Azure Key Vault integration and connection string management --- .../python/bicep/main.bicep | 42 +++++++++++ .../python/scripts/deploy.sh | 69 +++++++++++++++++++ .../python/scripts/validate.sh | 13 ++++ .../python/src/database.py | 43 ++++++++++++ 4 files changed, 167 insertions(+) diff --git a/samples/web-app-sql-database/python/bicep/main.bicep b/samples/web-app-sql-database/python/bicep/main.bicep index 7b55e73..a8f6269 100644 --- a/samples/web-app-sql-database/python/bicep/main.bicep +++ b/samples/web-app-sql-database/python/bicep/main.bicep @@ -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' } @@ -429,6 +431,42 @@ 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' @@ -439,6 +477,7 @@ resource configAppSettings 'Microsoft.Web/sites/config@2024-11-01' = { SQL_DATABASE: sqlDatabaseName SQL_USERNAME: sqlDatabaseUsername SQL_PASSWORD: sqlDatabasePassword + SQL_CONNECTION_STRING: '@Microsoft.KeyVault(SecretUri=${sqlConnectionStringSecret.properties.secretUri})' LOGIN_NAME: username } } @@ -459,3 +498,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 diff --git a/samples/web-app-sql-database/python/scripts/deploy.sh b/samples/web-app-sql-database/python/scripts/deploy.sh index fc1c538..720f936 100755 --- a/samples/web-app-sql-database/python/scripts/deploy.sh +++ b/samples/web-app-sql-database/python/scripts/deploy.sh @@ -22,6 +22,8 @@ RUNTIME="python" RUNTIME_VERSION="3.13" DEPLOY_APP=1 ENVIRONMENT=$(az account show --query environmentName --output tsv) +KEYVAULT_NAME="${PREFIX}-kv-${SUFFIX}" +SECRET_NAME="SqlConnectionString" # Change the current directory to the script's directory cd "$CURRENT_DIR" || exit @@ -298,6 +300,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 @@ -307,6 +310,72 @@ 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) + +# 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-soft-delete true \ + --retention-days 7 \ + --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 + +# Get Secret URI +SECRET_URI=$($AZ keyvault secret show \ + --vault-name "$KEY_VAULT_NAME" \ + --name "$SECRET_NAME" \ + --query "id" \ + --output tsv) + +echo "Secret URI: $SECRET_URI" + # Set web app settings echo "Setting web app settings for [$WEB_APP_NAME]..." $AZ webapp config appsettings set \ diff --git a/samples/web-app-sql-database/python/scripts/validate.sh b/samples/web-app-sql-database/python/scripts/validate.sh index 47bf78a..b56cbd0 100644 --- a/samples/web-app-sql-database/python/scripts/validate.sh +++ b/samples/web-app-sql-database/python/scripts/validate.sh @@ -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 sql-connection-string \ +--query "{name:name, enabled:attributes.enabled, created:attributes.created}" \ --output table \ No newline at end of file diff --git a/samples/web-app-sql-database/python/src/database.py b/samples/web-app-sql-database/python/src/database.py index d8c69e0..e7a85c2 100644 --- a/samples/web-app-sql-database/python/src/database.py +++ b/samples/web-app-sql-database/python/src/database.py @@ -3,6 +3,7 @@ Supports both traditional ODBC connections and passwordless Azure AD authentication """ +from multiprocessing import connection import os import struct import pyodbc @@ -73,6 +74,10 @@ def from_env(cls) -> 'SqlHelper': - SQL_USERNAME - SQL_PASSWORD """ + connection_string = os.environ.get("SQL_CONNECTION_STRING") + if connection_string: + return cls.from_connection_string(connection_string) + client_id = os.environ.get("AZURE_CLIENT_ID") client_secret = os.environ.get("AZURE_CLIENT_SECRET") tenant_id = os.environ.get("AZURE_TENANT_ID") @@ -94,6 +99,44 @@ def from_env(cls) -> 'SqlHelper': use_azure_credential=all([client_id, client_secret, tenant_id]) ) + @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.debug(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.""" conn_str = ( From 6d4bb8a4e8015c0a2f30c727023be58ba58c9500 Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Thu, 5 Feb 2026 11:33:28 +0100 Subject: [PATCH 02/12] fix variable name typo and update SQL connection string to use Azure Key Vault --- samples/web-app-sql-database/python/scripts/deploy.sh | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/samples/web-app-sql-database/python/scripts/deploy.sh b/samples/web-app-sql-database/python/scripts/deploy.sh index fa7ba31..f66b0f5 100755 --- a/samples/web-app-sql-database/python/scripts/deploy.sh +++ b/samples/web-app-sql-database/python/scripts/deploy.sh @@ -22,7 +22,7 @@ RUNTIME="python" RUNTIME_VERSION="3.13" DEPLOY_APP=1 ENVIRONMENT=$(az account show --query environmentName --output tsv) -KEYVAULT_NAME="${PREFIX}-kv-${SUFFIX}" +KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" SECRET_NAME="SqlConnectionString" # Change the current directory to the script's directory @@ -330,8 +330,6 @@ $AZ keyvault create \ --name "$KEY_VAULT_NAME" \ --resource-group "$RESOURCE_GROUP_NAME" \ --location "$LOCATION" \ - --enable-soft-delete true \ - --retention-days 7 \ --only-show-errors 1>/dev/null if [ $? -eq 0 ]; then @@ -391,10 +389,7 @@ $AZ webapp config appsettings set \ --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" \ + SQL_CONNECTION_STRING="@Microsoft.KeyVault(SecretUri=${SECRET_URI})" \ LOGIN_NAME="$LOGIN_NAME" \ --only-show-errors 1>/dev/null From 46fa956e1f767bd53e49637b02314d60aa119932 Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Thu, 5 Feb 2026 11:59:08 +0100 Subject: [PATCH 03/12] work in progress --- samples/web-app-sql-database/python/scripts/deploy.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/samples/web-app-sql-database/python/scripts/deploy.sh b/samples/web-app-sql-database/python/scripts/deploy.sh index f66b0f5..351f661 100755 --- a/samples/web-app-sql-database/python/scripts/deploy.sh +++ b/samples/web-app-sql-database/python/scripts/deploy.sh @@ -324,12 +324,18 @@ PRINCIPAL_ID=$($AZ webapp identity show \ --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 From 1ec302fe09fe49b628da30058ec9875a855b63d3 Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Thu, 5 Feb 2026 12:31:07 +0100 Subject: [PATCH 04/12] Update Key Vault secret names in deploy and validate scripts --- samples/web-app-sql-database/python/scripts/deploy.sh | 2 +- samples/web-app-sql-database/python/scripts/validate.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/web-app-sql-database/python/scripts/deploy.sh b/samples/web-app-sql-database/python/scripts/deploy.sh index 351f661..95757da 100755 --- a/samples/web-app-sql-database/python/scripts/deploy.sh +++ b/samples/web-app-sql-database/python/scripts/deploy.sh @@ -23,7 +23,7 @@ RUNTIME_VERSION="3.13" DEPLOY_APP=1 ENVIRONMENT=$(az account show --query environmentName --output tsv) KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" -SECRET_NAME="SqlConnectionString" +SECRET_NAME="${PREFIX}-secret-${SUFFIX}" # Change the current directory to the script's directory cd "$CURRENT_DIR" || exit diff --git a/samples/web-app-sql-database/python/scripts/validate.sh b/samples/web-app-sql-database/python/scripts/validate.sh index b56cbd0..d7f22c0 100644 --- a/samples/web-app-sql-database/python/scripts/validate.sh +++ b/samples/web-app-sql-database/python/scripts/validate.sh @@ -50,6 +50,6 @@ $AZ keyvault show \ # Check Key Vault secret $AZ keyvault secret show \ --vault-name local-kv-test \ ---name sql-connection-string \ +--name local-secret-test \ --query "{name:name, enabled:attributes.enabled, created:attributes.created}" \ --output table \ No newline at end of file From 471af0511bb4115ed1e08806e2ce72f2efdfe42e Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Fri, 6 Feb 2026 17:51:35 +0100 Subject: [PATCH 05/12] Integrate Azure Key Vault for SQL connection string management in web app --- .../python/bicep/main.bicep | 9 +-- .../python/scripts/deploy.sh | 14 +--- .../python/src/database.py | 75 +++++++++---------- .../python/src/requirements.txt | 1 + 4 files changed, 43 insertions(+), 56 deletions(-) diff --git a/samples/web-app-sql-database/python/bicep/main.bicep b/samples/web-app-sql-database/python/bicep/main.bicep index a8f6269..73580ad 100644 --- a/samples/web-app-sql-database/python/bicep/main.bicep +++ b/samples/web-app-sql-database/python/bicep/main.bicep @@ -473,11 +473,10 @@ resource configAppSettings 'Microsoft.Web/sites/config@2024-11-01' = { 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 - SQL_CONNECTION_STRING: '@Microsoft.KeyVault(SecretUri=${sqlConnectionStringSecret.properties.secretUri})' + //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 } } diff --git a/samples/web-app-sql-database/python/scripts/deploy.sh b/samples/web-app-sql-database/python/scripts/deploy.sh index 95757da..036e7c1 100755 --- a/samples/web-app-sql-database/python/scripts/deploy.sh +++ b/samples/web-app-sql-database/python/scripts/deploy.sh @@ -378,16 +378,9 @@ else exit 1 fi -# Get Secret URI -SECRET_URI=$($AZ keyvault secret show \ - --vault-name "$KEY_VAULT_NAME" \ - --name "$SECRET_NAME" \ - --query "id" \ - --output tsv) - -echo "Secret URI: $SECRET_URI" - # 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" \ @@ -395,7 +388,8 @@ $AZ webapp config appsettings set \ --settings \ SCM_DO_BUILD_DURING_DEPLOYMENT='true' \ ENABLE_ORYX_BUILD='true' \ - SQL_CONNECTION_STRING="@Microsoft.KeyVault(SecretUri=${SECRET_URI})" \ + KEY_VAULT_NAME="$KEY_VAULT_NAME" \ + SECRET_NAME="$SECRET_NAME" \ LOGIN_NAME="$LOGIN_NAME" \ --only-show-errors 1>/dev/null diff --git a/samples/web-app-sql-database/python/src/database.py b/samples/web-app-sql-database/python/src/database.py index e7a85c2..dcbdcb1 100644 --- a/samples/web-app-sql-database/python/src/database.py +++ b/samples/web-app-sql-database/python/src/database.py @@ -3,14 +3,15 @@ Supports both traditional ODBC connections and passwordless Azure AD authentication """ -from multiprocessing import connection +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__) @@ -61,44 +62,36 @@ def __init__( @classmethod def from_env(cls) -> 'SqlHelper': """ - Create a SqlHelper instance from environment variables. - - You can either pass the following set of variables, if you plan to use DefaultAzureCredential: - - AZURE_CLIENT_ID - - AZURE_CLIENT_SECRET - - AZURE_TENANT_ID - - Instead, if you plan to use the connection string directly, you can set: - - SQL_SERVER - - SQL_DATABASE - - SQL_USERNAME - - SQL_PASSWORD + Create a SqlHelper instance using KEY_VAULT_NAME and SECRET_NAME environment variables. + The secret must contain a SQL connection string. Uses DefaultAzureCredential for authentication. """ - connection_string = os.environ.get("SQL_CONNECTION_STRING") - if connection_string: - return cls.from_connection_string(connection_string) + key_vault_name = os.environ.get("KEY_VAULT_NAME") + secret_name = os.environ.get("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]) - ) - + if not key_vault_name or not secret_name: + raise ValueError("KEY_VAULT_NAME and SECRET_NAME environment variables are required") + + return cls.from_key_vault(key_vault_name, secret_name) + + @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': """ @@ -127,7 +120,7 @@ def from_connection_string(cls, connection_string: str) -> 'SqlHelper': ) logger.info("Connection string parsed successfully") - logger.debug(f"Server: {server}, Database: {database}, Username: {username}") + logger.info(f"Server: {server}, Database: {database}, Username: {username}") return cls( server=server, diff --git a/samples/web-app-sql-database/python/src/requirements.txt b/samples/web-app-sql-database/python/src/requirements.txt index 6fab8cd..b7005ba 100644 --- a/samples/web-app-sql-database/python/src/requirements.txt +++ b/samples/web-app-sql-database/python/src/requirements.txt @@ -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 \ No newline at end of file From 4e76bad01cf0d742d0971fd6f3088b28d1891de1 Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Fri, 6 Feb 2026 18:11:00 +0100 Subject: [PATCH 06/12] work in progress --- .../python/src/database.py | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/samples/web-app-sql-database/python/src/database.py b/samples/web-app-sql-database/python/src/database.py index dcbdcb1..ba58461 100644 --- a/samples/web-app-sql-database/python/src/database.py +++ b/samples/web-app-sql-database/python/src/database.py @@ -62,16 +62,43 @@ def __init__( @classmethod def from_env(cls) -> 'SqlHelper': """ - Create a SqlHelper instance using KEY_VAULT_NAME and SECRET_NAME environment variables. - The secret must contain a SQL connection string. Uses DefaultAzureCredential for authentication. + Create a SqlHelper instance from environment variables. + + You can either pass the following set of variables, if you plan to use DefaultAzureCredential: + - AZURE_CLIENT_ID + - AZURE_CLIENT_SECRET + - AZURE_TENANT_ID + + Instead, if you plan to use the connection string directly, you can set: + - SQL_SERVER + - SQL_DATABASE + - SQL_USERNAME + - SQL_PASSWORD """ key_vault_name = os.environ.get("KEY_VAULT_NAME") secret_name = os.environ.get("SECRET_NAME") - if not key_vault_name or not secret_name: - raise ValueError("KEY_VAULT_NAME and SECRET_NAME environment variables are required") + 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") - return cls.from_key_vault(key_vault_name, secret_name) + if not any([client_id, client_secret, tenant_id, server, database, username, password]): + raise ValueError("You properly need to define environment variables.") + + 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': From e0c15d17d5afce6c5c1e475b8f2ff7881e37a15a Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Fri, 6 Feb 2026 18:11:58 +0100 Subject: [PATCH 07/12] add missing logger --- samples/web-app-sql-database/python/src/database.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/samples/web-app-sql-database/python/src/database.py b/samples/web-app-sql-database/python/src/database.py index ba58461..cd3d3d6 100644 --- a/samples/web-app-sql-database/python/src/database.py +++ b/samples/web-app-sql-database/python/src/database.py @@ -92,6 +92,8 @@ def from_env(cls) -> 'SqlHelper': 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, From 9c581c13665d0b65a91308c751b27574e8bf13e1 Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Wed, 11 Feb 2026 14:33:56 +0100 Subject: [PATCH 08/12] work in progress --- samples/web-app-sql-database/python/scripts/deploy.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/samples/web-app-sql-database/python/scripts/deploy.sh b/samples/web-app-sql-database/python/scripts/deploy.sh index 036e7c1..9d8b664 100755 --- a/samples/web-app-sql-database/python/scripts/deploy.sh +++ b/samples/web-app-sql-database/python/scripts/deploy.sh @@ -142,6 +142,15 @@ if [ -z "$SQL_SERVER_FQDN" ]; then echo "Failed to retrieve the fullyQualifiedDomainName of the SQL server" exit 1 fi +SQL_SERVER_FQDN_INTERNAL="$SQL_SERVER_FQDN" + +if [[ $ENVIRONMENT == "LocalStack" ]]; then + MSSQL_HOST_PORT=$(docker ps --filter "ancestor=mcr.microsoft.com/mssql/server:2022-latest" --format "{{.Ports}}" | grep -oP '0\.0\.0\.0:\K[0-9]+(?=->1433)' | head -1) + if [ -n "$MSSQL_HOST_PORT" ]; then + SQL_SERVER_FQDN="127.0.0.1,$MSSQL_HOST_PORT" + echo "Using local SQL Server at [$SQL_SERVER_FQDN]" + fi +fi # Create server-level login echo "Creating login [$DATABASE_USER_NAME] at server level..." @@ -361,7 +370,7 @@ else 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;" +SQL_CONNECTION_STRING="Server=tcp:${SQL_SERVER_FQDN_INTERNAL},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..." From 2216fc6ffe560de744bcf86572eea44d10f5f1b4 Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Wed, 11 Feb 2026 16:49:19 +0100 Subject: [PATCH 09/12] Integrate Azure Key Vault for certificate management and validation in web app --- samples/web-app-sql-database/python/README.md | 7 ++ .../python/scripts/deploy.sh | 70 ++++++++++++++- .../web-app-sql-database/python/src/app.py | 37 ++++++-- .../python/src/certificates.py | 86 +++++++++++++++++++ .../python/src/requirements.txt | 4 +- .../python/terraform/deploy.sh | 2 + 6 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 samples/web-app-sql-database/python/src/certificates.py diff --git a/samples/web-app-sql-database/python/README.md b/samples/web-app-sql-database/python/README.md index 67dc512..49a3539 100644 --- a/samples/web-app-sql-database/python/README.md +++ b/samples/web-app-sql-database/python/README.md @@ -41,6 +41,13 @@ The Vacation Planner Web App supports two common approaches for accessing Azure This flexibility allows the app to run securely in Azure or in emulated environments like [LocalStack for Azure](https://azure.localstack.cloud/). The client code supports both authentication modes using [`ClientSecretCredential`](https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.clientsecretcredential?view=azure-python) or [`DefaultAzureCredential`](https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.defaultazurecredential?view=azure-python) from the Azure SDK. +## Azure Key Vault Integration +The application integrates with Azure Key Vault for managing secrets and certificates: + +Secrets: The SQL connection string is stored as a secret in Key Vault. At runtime, the app retrieves it using the Azure Key Vault Secrets SDK. This is configured via the KEY_VAULT_NAME and SECRET_NAME environment variables. + +Certificates: A self-signed certificate is created in Key Vault during deployment. The app exposes a GET /api/certificate/validate endpoint that retrieves the certificate using the Azure Key Vault Certificates SDK and returns its name, confirming the integration works. This is configured via the KEYVAULT_URI and CERT_NAME environment variables. + ## Deployment Set up the Azure emulator using the LocalStack for Azure Docker image. Before starting, ensure you have a valid `LOCALSTACK_AUTH_TOKEN` to access the Azure emulator. Refer to the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/?__hstc=108988063.8aad2b1a7229945859f4d9b9bb71e05d.1743148429561.1758793541854.1758810151462.32&__hssc=108988063.3.1758810151462&__hsfp=3945774529) to obtain your Auth Token and set it in the `LOCALSTACK_AUTH_TOKEN` environment variable. The Azure Docker image is available on the [LocalStack Docker Hub](https://hub.docker.com/r/localstack/localstack-azure-alpha). To pull the image, execute: diff --git a/samples/web-app-sql-database/python/scripts/deploy.sh b/samples/web-app-sql-database/python/scripts/deploy.sh index 9d8b664..a40933c 100755 --- a/samples/web-app-sql-database/python/scripts/deploy.sh +++ b/samples/web-app-sql-database/python/scripts/deploy.sh @@ -24,6 +24,7 @@ DEPLOY_APP=1 ENVIRONMENT=$(az account show --query environmentName --output tsv) KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}" SECRET_NAME="${PREFIX}-secret-${SUFFIX}" +CERT_NAME="${PREFIX}-cert-${SUFFIX}" # Change the current directory to the script's directory cd "$CURRENT_DIR" || exit @@ -360,6 +361,7 @@ $AZ keyvault set-policy \ --name "$KEY_VAULT_NAME" \ --object-id "$PRINCIPAL_ID" \ --secret-permissions get \ + --certificate-permissions get \ --only-show-errors 1>/dev/null if [ $? -eq 0 ]; then @@ -387,6 +389,35 @@ else exit 1 fi +# Create certificate in Key Vault +echo "Creating certificate [$CERT_NAME] in Key Vault [$KEY_VAULT_NAME]..." +$AZ keyvault certificate create \ + --vault-name "$KEY_VAULT_NAME" \ + --name "$CERT_NAME" \ + --policy "$(az keyvault certificate get-default-policy)" \ + --only-show-errors 1>/dev/null + +if [ $? -eq 0 ]; then + echo "Certificate [$CERT_NAME] created successfully in Key Vault [$KEY_VAULT_NAME]." +else + echo "Failed to create certificate [$CERT_NAME] in Key Vault [$KEY_VAULT_NAME]." + exit 1 +fi + +# Get Key Vault URI +echo "Retrieving Key Vault URI..." +KEYVAULT_URI=$($AZ keyvault show \ + --name "$KEY_VAULT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "properties.vaultUri" \ + --output tsv) + +if [ -z "$KEYVAULT_URI" ]; then + echo "Failed to retrieve Key Vault URI." + exit 1 +fi +echo "Key Vault URI: [$KEYVAULT_URI]" + # 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. @@ -400,6 +431,8 @@ $AZ webapp config appsettings set \ KEY_VAULT_NAME="$KEY_VAULT_NAME" \ SECRET_NAME="$SECRET_NAME" \ LOGIN_NAME="$LOGIN_NAME" \ + KEYVAULT_URI="$KEYVAULT_URI" \ + CERT_NAME="$CERT_NAME" \ --only-show-errors 1>/dev/null if [ $? -eq 0 ]; then @@ -424,7 +457,7 @@ fi # Create the zip package of the web app echo "Creating zip package of the web app..." -zip -r "$ZIPFILE" app.py activities.py database.py static templates requirements.txt +zip -r "$ZIPFILE" app.py activities.py database.py certificates.py static templates requirements.txt # Deploy the web app echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..." @@ -442,6 +475,41 @@ else exit 1 fi +# Get web app URL +WEB_APP_URL=$($AZ webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "defaultHostName" \ + --output tsv) + +# Wait for web app to be ready +echo "Waiting for web app to be ready..." +MAX_RETRIES=10 +for i in $(seq 1 $MAX_RETRIES); do + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$WEB_APP_URL" --insecure) + if [ "$HTTP_STATUS" -eq 200 ]; then + echo "Web app is responding with HTTP 200" + break + fi + echo "Attempt $i/$MAX_RETRIES - HTTP $HTTP_STATUS. Retrying in 5 seconds..." + sleep 5 +done + +if [ "$HTTP_STATUS" -ne 200 ]; then + echo "Web app failed to respond with HTTP 200 after $MAX_RETRIES attempts" + exit 1 +fi + +echo "Validating certificate from Key Vault..." +CERT_NAME_RESPONSE=$(curl -s "https://$WEB_APP_URL/api/certificate/validate" --insecure | jq -r '.name') + +if [ "$CERT_NAME_RESPONSE" == "$CERT_NAME" ]; then + echo "Certificate [$CERT_NAME] validated successfully from web app." +else + echo "Certificate validation failed. Expected [$CERT_NAME], got [$CERT_NAME_RESPONSE]." + exit 1 +fi + # Remove the zip package of the web app if [ -f "$ZIPFILE" ]; then rm "$ZIPFILE" diff --git a/samples/web-app-sql-database/python/src/app.py b/samples/web-app-sql-database/python/src/app.py index 76608cf..1921616 100644 --- a/samples/web-app-sql-database/python/src/app.py +++ b/samples/web-app-sql-database/python/src/app.py @@ -1,9 +1,11 @@ """Flask application for managing vacation activities using Azure SQL Database.""" -import os import logging +import os from typing import List, Tuple -from flask import Flask, render_template, request, redirect, url_for + from activities import ActivitiesHelper +from certificates import get_certificate_info, get_ssl_context_from_keyvault +from flask import Flask, jsonify, redirect, render_template, request, url_for # Initialize Flask application app: Flask = Flask(__name__) @@ -134,6 +136,26 @@ def update(activity_id: int): return redirect(url_for('index')) +@app.route('/api/certificate/validate', methods=['GET']) +def validate_certificate(): + """ + Downloads the certificate from Key Vault, loads it as X509, + and returns its properties to validate that Key Vault certificate + emulation works correctly. + """ + vault_uri = os.environ.get('KEYVAULT_URI') + cert_name = os.environ.get('CERT_NAME', 'test-cert') + + if not vault_uri: + return jsonify({"error": "KEYVAULT_URI not configured"}), 500 + + try: + info = get_certificate_info(vault_uri, cert_name) + return jsonify(info), 200 + except Exception as e: + logger.error("Error validating certificate: %s", e) + return jsonify({"error": str(e)}), 500 + # Read debug environment variable debug = os.environ.get("DEBUG", "false").lower() == "true" @@ -156,6 +178,11 @@ def update(activity_id: int): # Run the Flask application if __name__ == '__main__': - app.run(debug=debug) - - + vault_uri = os.environ.get('KEYVAULT_URI') + cert_name = os.environ.get('CERT_NAME') + + if vault_uri and cert_name: + ssl_ctx = get_ssl_context_from_keyvault(vault_uri, cert_name) + app.run(host='0.0.0.0', port=443, ssl_context=ssl_ctx) + else: + app.run(debug=debug) \ No newline at end of file diff --git a/samples/web-app-sql-database/python/src/certificates.py b/samples/web-app-sql-database/python/src/certificates.py new file mode 100644 index 0000000..d4aeb5c --- /dev/null +++ b/samples/web-app-sql-database/python/src/certificates.py @@ -0,0 +1,86 @@ +"""Certificate helper module for Azure Key Vault integration.""" +import base64 +import logging +import os +import ssl +import tempfile + +from azure.identity import DefaultAzureCredential +from azure.keyvault.certificates import CertificateClient +from azure.keyvault.secrets import SecretClient +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + pkcs12, +) + +logger = logging.getLogger(__name__) + + +def get_ssl_context_from_keyvault(vault_url: str, cert_name: str) -> ssl.SSLContext: + """ + Downloads a certificate from Key Vault and creates an SSLContext for Flask. + The certificate's private key is stored as a linked secret in Key Vault. + + Returns: + An SSLContext configured with the certificate and private key. + """ + credential = DefaultAzureCredential() + secret_client = SecretClient(vault_url=vault_url, credential=credential) + secret = secret_client.get_secret(cert_name) + + if not secret.value: + raise ValueError(f"Secret [{cert_name}] has no value") + + # Key Vault returns PFX as base64 or PEM as plain text + if secret.properties.content_type == "application/x-pkcs12": + pfx_bytes = base64.b64decode(secret.value) + private_key, certificate, chain = pkcs12.load_key_and_certificates(pfx_bytes, None) + + if not certificate: + raise ValueError(f"Certificate [{cert_name}] could not be loaded") + + if not private_key: + raise ValueError(f"Private key for [{cert_name}] could not be loaded") + + cert_pem = certificate.public_bytes(Encoding.PEM) + key_pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()) + + # Include chain certs if present + if chain: + for ca_cert in chain: + cert_pem += ca_cert.public_bytes(Encoding.PEM) + else: + # PEM format - value contains cert + key concatenated + cert_pem = secret.value.encode() + key_pem = secret.value.encode() + + # Write to temp files (ssl module needs file paths) + cert_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pem") + key_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pem") + + cert_file.write(cert_pem) + key_file.write(key_pem) + cert_file.close() + key_file.close() + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(certfile=cert_file.name, keyfile=key_file.name) + + # Clean up temp files after loading + os.unlink(cert_file.name) + os.unlink(key_file.name) + + logger.info("SSL context created successfully from Key Vault certificate [%s]", cert_name) + return ctx + + +def get_certificate_info(vault_url: str, cert_name: str) -> dict: + credential = DefaultAzureCredential() + cert_client = CertificateClient(vault_url=vault_url, credential=credential) + cert = cert_client.get_certificate(cert_name) + + return { + "name": cert.name + } \ No newline at end of file diff --git a/samples/web-app-sql-database/python/src/requirements.txt b/samples/web-app-sql-database/python/src/requirements.txt index b7005ba..a18632b 100644 --- a/samples/web-app-sql-database/python/src/requirements.txt +++ b/samples/web-app-sql-database/python/src/requirements.txt @@ -3,4 +3,6 @@ azure-identity==1.25.1 pyodbc==5.3.0 gunicorn==20.1.0 python-dotenv==1.1.1 -azure-keyvault-secrets \ No newline at end of file +azure-keyvault-secrets +azure-keyvault-certificates +cryptography \ No newline at end of file diff --git a/samples/web-app-sql-database/python/terraform/deploy.sh b/samples/web-app-sql-database/python/terraform/deploy.sh index d598156..1b29fe9 100755 --- a/samples/web-app-sql-database/python/terraform/deploy.sh +++ b/samples/web-app-sql-database/python/terraform/deploy.sh @@ -1,4 +1,6 @@ #!/bin/bash +echo "Skipping Terraform deployment (not yet updated for Key Vault integration)." +exit 0 # Variables PREFIX='websql' From 5b10710b2a89de0723c86a7a731bc60a87a0e23d Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Fri, 13 Feb 2026 14:45:13 +0100 Subject: [PATCH 10/12] add validate cert --- .../python/scripts/deploy.sh | 41 ++++----------- .../python/scripts/get-web-app-url.sh | 51 +++++++++++++++++++ .../web-app-sql-database/python/src/app.py | 2 +- .../python/src/certificates.py | 14 ++++- 4 files changed, 74 insertions(+), 34 deletions(-) diff --git a/samples/web-app-sql-database/python/scripts/deploy.sh b/samples/web-app-sql-database/python/scripts/deploy.sh index a40933c..8528d99 100755 --- a/samples/web-app-sql-database/python/scripts/deploy.sh +++ b/samples/web-app-sql-database/python/scripts/deploy.sh @@ -392,10 +392,15 @@ fi # Create certificate in Key Vault echo "Creating certificate [$CERT_NAME] in Key Vault [$KEY_VAULT_NAME]..." $AZ keyvault certificate create \ - --vault-name "$KEY_VAULT_NAME" \ - --name "$CERT_NAME" \ - --policy "$(az keyvault certificate get-default-policy)" \ - --only-show-errors 1>/dev/null + --vault-name "$KEY_VAULT_NAME" \ + --name "$CERT_NAME" \ + --policy '{ + "issuerParameters": {"name": "Self"}, + "keyProperties": {"exportable": true, "keySize": 2048, "keyType": "RSA", "reuseKey": false}, + "secretProperties": {"contentType": "application/x-pkcs12"}, + "x509CertificateProperties": {"subject": "CN=sample-web-app-sql", "validityInMonths": 12} + }' \ + --only-show-errors if [ $? -eq 0 ]; then echo "Certificate [$CERT_NAME] created successfully in Key Vault [$KEY_VAULT_NAME]." @@ -482,34 +487,6 @@ WEB_APP_URL=$($AZ webapp show \ --query "defaultHostName" \ --output tsv) -# Wait for web app to be ready -echo "Waiting for web app to be ready..." -MAX_RETRIES=10 -for i in $(seq 1 $MAX_RETRIES); do - HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$WEB_APP_URL" --insecure) - if [ "$HTTP_STATUS" -eq 200 ]; then - echo "Web app is responding with HTTP 200" - break - fi - echo "Attempt $i/$MAX_RETRIES - HTTP $HTTP_STATUS. Retrying in 5 seconds..." - sleep 5 -done - -if [ "$HTTP_STATUS" -ne 200 ]; then - echo "Web app failed to respond with HTTP 200 after $MAX_RETRIES attempts" - exit 1 -fi - -echo "Validating certificate from Key Vault..." -CERT_NAME_RESPONSE=$(curl -s "https://$WEB_APP_URL/api/certificate/validate" --insecure | jq -r '.name') - -if [ "$CERT_NAME_RESPONSE" == "$CERT_NAME" ]; then - echo "Certificate [$CERT_NAME] validated successfully from web app." -else - echo "Certificate validation failed. Expected [$CERT_NAME], got [$CERT_NAME_RESPONSE]." - exit 1 -fi - # Remove the zip package of the web app if [ -f "$ZIPFILE" ]; then rm "$ZIPFILE" diff --git a/samples/web-app-sql-database/python/scripts/get-web-app-url.sh b/samples/web-app-sql-database/python/scripts/get-web-app-url.sh index 6b22943..69594c6 100644 --- a/samples/web-app-sql-database/python/scripts/get-web-app-url.sh +++ b/samples/web-app-sql-database/python/scripts/get-web-app-url.sh @@ -65,6 +65,28 @@ get_docker_container_port_mapping() { echo "$host_port" } +wait_for_http_response() { + local url="$1" + local description="$2" + local max_retries="${3:-5}" + local retry_interval="${4:-5}" + + echo "Waiting for [$description] to respond at [$url]..." + + for i in $(seq 1 $max_retries); do + http_status=$(curl -s -o /dev/null -w "%{http_code}" "$url" --max-time 5) + if [ "$http_status" -eq 200 ]; then + echo "[$description] is responding with HTTP 200" + return 0 + fi + echo "Attempt $i/$max_retries - HTTP $http_status. Retrying in ${retry_interval}s..." + sleep $retry_interval + done + + echo "Error: [$description] failed to respond with HTTP 200 after $max_retries attempts" >&2 + return 1 +} + call_web_app() { # Get the web app name echo "Getting web app name..." @@ -180,6 +202,35 @@ call_web_app() { else echo "Failed to retrieve host port" fi + + echo "Validating certificate from Key Vault..." + KV_RESPONSE=$(curl -sk "https://$container_ip:8443/api/certificate/validate") + KV_THUMBPRINT=$(echo "$KV_RESPONSE" | jq -r '.thumbprint') + KV_NAME=$(echo "$KV_RESPONSE" | jq -r '.name') + KV_SUBJECT=$(echo "$KV_RESPONSE" | jq -r '.subject') + + SSL_THUMBPRINT=$(echo | openssl s_client -connect "$container_ip:8443" 2>/dev/null \ + | openssl x509 -fingerprint -noout -sha1 \ + | sed 's/.*=//;s/://g' \ + | tr '[:upper:]' '[:lower:]') + + if [ "$KV_THUMBPRINT" == "$SSL_THUMBPRINT" ]; then + echo "Certificate [$KV_NAME] validated: SSL cert matches Key Vault cert." + else + echo "Certificate mismatch! KV: $KV_THUMBPRINT, SSL: $SSL_THUMBPRINT" + exit 1 + fi + + SSL_SUBJECT=$(echo "$SSL_CERT" \ + | openssl x509 -noout -subject \ + | sed 's/subject=//') + + if echo "$SSL_SUBJECT" | grep -q "$KV_SUBJECT"; then + echo "Certificate subject [$KV_SUBJECT] matches SSL certificate." + else + echo "Certificate subject mismatch! KV: $KV_SUBJECT, SSL: $SSL_SUBJECT" + exit 1 + fi } call_web_app \ No newline at end of file diff --git a/samples/web-app-sql-database/python/src/app.py b/samples/web-app-sql-database/python/src/app.py index 1921616..8f9e1e1 100644 --- a/samples/web-app-sql-database/python/src/app.py +++ b/samples/web-app-sql-database/python/src/app.py @@ -183,6 +183,6 @@ def validate_certificate(): if vault_uri and cert_name: ssl_ctx = get_ssl_context_from_keyvault(vault_uri, cert_name) - app.run(host='0.0.0.0', port=443, ssl_context=ssl_ctx) + app.run(host='0.0.0.0', port=8443, ssl_context=ssl_ctx) else: app.run(debug=debug) \ No newline at end of file diff --git a/samples/web-app-sql-database/python/src/certificates.py b/samples/web-app-sql-database/python/src/certificates.py index d4aeb5c..aa19cd1 100644 --- a/samples/web-app-sql-database/python/src/certificates.py +++ b/samples/web-app-sql-database/python/src/certificates.py @@ -1,5 +1,6 @@ """Certificate helper module for Azure Key Vault integration.""" import base64 +import hashlib import logging import os import ssl @@ -81,6 +82,17 @@ def get_certificate_info(vault_url: str, cert_name: str) -> dict: cert_client = CertificateClient(vault_url=vault_url, credential=credential) cert = cert_client.get_certificate(cert_name) + x509_cert_bytes = cert.cer + if x509_cert_bytes is None: + raise ValueError(f"Certificate '{cert_name}' has no public bytes (cer is None)") + + if cert.policy is None: + raise ValueError(f"Certificate '{cert_name}' has no policy") + + thumbprint = hashlib.sha1(x509_cert_bytes).hexdigest() + return { - "name": cert.name + "name": cert.name, + "subject": cert.policy.subject, + "thumbprint": thumbprint, } \ No newline at end of file From da18fc2f9e13cae6c7895b98aee31361dd3de3cb Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Fri, 13 Feb 2026 16:44:08 +0100 Subject: [PATCH 11/12] refactor SQL connection handling --- .../python/scripts/deploy.sh | 17 ++++++++--------- .../python/scripts/get-web-app-url.sh | 6 ++++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/samples/web-app-sql-database/python/scripts/deploy.sh b/samples/web-app-sql-database/python/scripts/deploy.sh index 8528d99..e73c8ee 100755 --- a/samples/web-app-sql-database/python/scripts/deploy.sh +++ b/samples/web-app-sql-database/python/scripts/deploy.sh @@ -143,15 +143,14 @@ if [ -z "$SQL_SERVER_FQDN" ]; then echo "Failed to retrieve the fullyQualifiedDomainName of the SQL server" exit 1 fi -SQL_SERVER_FQDN_INTERNAL="$SQL_SERVER_FQDN" -if [[ $ENVIRONMENT == "LocalStack" ]]; then - MSSQL_HOST_PORT=$(docker ps --filter "ancestor=mcr.microsoft.com/mssql/server:2022-latest" --format "{{.Ports}}" | grep -oP '0\.0\.0\.0:\K[0-9]+(?=->1433)' | head -1) - if [ -n "$MSSQL_HOST_PORT" ]; then - SQL_SERVER_FQDN="127.0.0.1,$MSSQL_HOST_PORT" - echo "Using local SQL Server at [$SQL_SERVER_FQDN]" - fi -fi +#if [[ $ENVIRONMENT == "LocalStack" ]]; then +# MSSQL_HOST_PORT=$(docker ps --filter "ancestor=mcr.microsoft.com/mssql/server:2022-latest" --format "{{.Ports}}" | grep -oP '0\.0\.0\.0:\K[0-9]+(?=->1433)' | head -1) +# if [ -n "$MSSQL_HOST_PORT" ]; then +# SQL_SERVER_FQDN_WITH_PORT="127.0.0.1,$MSSQL_HOST_PORT" +# echo "Using local SQL Server at [$SQL_SERVER_FQDN_WITH_PORT]" +# fi +#fi # Create server-level login echo "Creating login [$DATABASE_USER_NAME] at server level..." @@ -372,7 +371,7 @@ else fi # Build connection string -SQL_CONNECTION_STRING="Server=tcp:${SQL_SERVER_FQDN_INTERNAL},1433;Database=${SQL_DATABASE_NAME};User ID=${DATABASE_USER_NAME};Password=${DATABASE_USER_PASSWORD};Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=30;" +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..." diff --git a/samples/web-app-sql-database/python/scripts/get-web-app-url.sh b/samples/web-app-sql-database/python/scripts/get-web-app-url.sh index 69594c6..2acd1a5 100644 --- a/samples/web-app-sql-database/python/scripts/get-web-app-url.sh +++ b/samples/web-app-sql-database/python/scripts/get-web-app-url.sh @@ -209,7 +209,9 @@ call_web_app() { KV_NAME=$(echo "$KV_RESPONSE" | jq -r '.name') KV_SUBJECT=$(echo "$KV_RESPONSE" | jq -r '.subject') - SSL_THUMBPRINT=$(echo | openssl s_client -connect "$container_ip:8443" 2>/dev/null \ + SSL_CERT=$(echo | openssl s_client -connect "$container_ip:8443" 2>/dev/null | openssl x509) + + SSL_THUMBPRINT=$(echo "$SSL_CERT" \ | openssl x509 -fingerprint -noout -sha1 \ | sed 's/.*=//;s/://g' \ | tr '[:upper:]' '[:lower:]') @@ -220,7 +222,7 @@ call_web_app() { echo "Certificate mismatch! KV: $KV_THUMBPRINT, SSL: $SSL_THUMBPRINT" exit 1 fi - + SSL_SUBJECT=$(echo "$SSL_CERT" \ | openssl x509 -noout -subject \ | sed 's/subject=//') From 5fa8b9ee34f76fc7f206d1d4c2722fb64e9ec3a4 Mon Sep 17 00:00:00 2001 From: Bryan Sanchez Date: Fri, 13 Feb 2026 17:54:05 +0100 Subject: [PATCH 12/12] update README --- samples/web-app-sql-database/python/README.md | 6 ++--- .../python/scripts/get-web-app-url.sh | 24 +------------------ .../web-app-sql-database/python/src/app.py | 6 ++--- 3 files changed, 7 insertions(+), 29 deletions(-) diff --git a/samples/web-app-sql-database/python/README.md b/samples/web-app-sql-database/python/README.md index 879bb89..a784eca 100644 --- a/samples/web-app-sql-database/python/README.md +++ b/samples/web-app-sql-database/python/README.md @@ -1,6 +1,6 @@ # Azure Web App with Azure SQL Database and Azure Key Vault -This sample demonstrates a Python Flask single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in an `activities` table within the `sampledb` database on an [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/) instance. The connection string of the SQL database is stored as a secret in [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview). +This sample demonstrates a Python Flask single-page web application called *Vacation Planner* hosted on an [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview). The app runs on an Azure App Service Plan and stores activity data in an `activities` table within the `sampledb` database on an [Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/) instance. The connection string of the SQL database is stored as a secret in [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview). The application also retrieves its certificate from Key Vault to serve traffic over HTTPS. ## Architecture @@ -12,7 +12,7 @@ The following diagram illustrates the architecture of the solution: - **Azure Web App**: Hosts the Python Flask application - **Azure App Service Plan**: Provides compute resources for the web app - **Azure SQL Database**: Stores activity data in a relational table -- **Azure Key Vault**: Stores the database connection string +- **Azure Key Vault**: Stores the database connection string and the certificate used to secure HTTPS traffic ## Prerequisites @@ -48,7 +48,7 @@ The application integrates with Azure Key Vault for managing secrets and certifi Secrets: The SQL connection string is stored as a secret in Key Vault. At runtime, the app retrieves it using the Azure Key Vault Secrets SDK. This is configured via the KEY_VAULT_NAME and SECRET_NAME environment variables. -Certificates: A self-signed certificate is created in Key Vault during deployment. The app exposes a GET /api/certificate/validate endpoint that retrieves the certificate using the Azure Key Vault Certificates SDK and returns its name, confirming the integration works. This is configured via the KEYVAULT_URI and CERT_NAME environment variables. +Certificates: A self-signed certificate is created in Key Vault during deployment. The app exposes a GET /api/certificate endpoint that retrieves the certificate using the Azure Key Vault Certificates SDK and returns its name, confirming the integration works. This is configured via the KEYVAULT_URI and CERT_NAME environment variables. ## Deployment diff --git a/samples/web-app-sql-database/python/scripts/get-web-app-url.sh b/samples/web-app-sql-database/python/scripts/get-web-app-url.sh index 2acd1a5..735555f 100644 --- a/samples/web-app-sql-database/python/scripts/get-web-app-url.sh +++ b/samples/web-app-sql-database/python/scripts/get-web-app-url.sh @@ -65,28 +65,6 @@ get_docker_container_port_mapping() { echo "$host_port" } -wait_for_http_response() { - local url="$1" - local description="$2" - local max_retries="${3:-5}" - local retry_interval="${4:-5}" - - echo "Waiting for [$description] to respond at [$url]..." - - for i in $(seq 1 $max_retries); do - http_status=$(curl -s -o /dev/null -w "%{http_code}" "$url" --max-time 5) - if [ "$http_status" -eq 200 ]; then - echo "[$description] is responding with HTTP 200" - return 0 - fi - echo "Attempt $i/$max_retries - HTTP $http_status. Retrying in ${retry_interval}s..." - sleep $retry_interval - done - - echo "Error: [$description] failed to respond with HTTP 200 after $max_retries attempts" >&2 - return 1 -} - call_web_app() { # Get the web app name echo "Getting web app name..." @@ -204,7 +182,7 @@ call_web_app() { fi echo "Validating certificate from Key Vault..." - KV_RESPONSE=$(curl -sk "https://$container_ip:8443/api/certificate/validate") + KV_RESPONSE=$(curl -sk "https://$container_ip:8443/api/certificate") KV_THUMBPRINT=$(echo "$KV_RESPONSE" | jq -r '.thumbprint') KV_NAME=$(echo "$KV_RESPONSE" | jq -r '.name') KV_SUBJECT=$(echo "$KV_RESPONSE" | jq -r '.subject') diff --git a/samples/web-app-sql-database/python/src/app.py b/samples/web-app-sql-database/python/src/app.py index 8f9e1e1..46c762f 100644 --- a/samples/web-app-sql-database/python/src/app.py +++ b/samples/web-app-sql-database/python/src/app.py @@ -136,7 +136,7 @@ def update(activity_id: int): return redirect(url_for('index')) -@app.route('/api/certificate/validate', methods=['GET']) +@app.route('/api/certificate', methods=['GET']) def validate_certificate(): """ Downloads the certificate from Key Vault, loads it as X509, @@ -144,9 +144,9 @@ def validate_certificate(): emulation works correctly. """ vault_uri = os.environ.get('KEYVAULT_URI') - cert_name = os.environ.get('CERT_NAME', 'test-cert') + cert_name = os.environ.get('CERT_NAME') - if not vault_uri: + if not vault_uri or not cert_name: return jsonify({"error": "KEYVAULT_URI not configured"}), 500 try: