Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions samples/web-app-sql-database/python/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -43,6 +43,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 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:
Expand Down
55 changes: 54 additions & 1 deletion samples/web-app-sql-database/python/scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,6 +144,14 @@ if [ -z "$SQL_SERVER_FQDN" ]; then
exit 1
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..."
sqlcmd -S "$SQL_SERVER_FQDN" \
Expand Down Expand Up @@ -351,6 +360,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
Expand Down Expand Up @@ -378,6 +388,40 @@ 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 '{
"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]."
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.
Expand All @@ -391,6 +435,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
Expand All @@ -415,7 +461,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]..."
Expand All @@ -433,6 +479,13 @@ 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)

# Remove the zip package of the web app
if [ -f "$ZIPFILE" ]; then
rm "$ZIPFILE"
Expand Down
31 changes: 31 additions & 0 deletions samples/web-app-sql-database/python/scripts/get-web-app-url.sh
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,37 @@ 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")
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_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:]')

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
37 changes: 32 additions & 5 deletions samples/web-app-sql-database/python/src/app.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand Down Expand Up @@ -134,6 +136,26 @@ def update(activity_id: int):

return redirect(url_for('index'))

@app.route('/api/certificate', 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')

if not vault_uri or not cert_name:
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"

Expand All @@ -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=8443, ssl_context=ssl_ctx)
else:
app.run(debug=debug)
98 changes: 98 additions & 0 deletions samples/web-app-sql-database/python/src/certificates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Certificate helper module for Azure Key Vault integration."""
import base64
import hashlib
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)

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,
"subject": cert.policy.subject,
"thumbprint": thumbprint,
}
4 changes: 3 additions & 1 deletion samples/web-app-sql-database/python/src/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
azure-keyvault-secrets
azure-keyvault-certificates
cryptography
2 changes: 2 additions & 0 deletions samples/web-app-sql-database/python/terraform/deploy.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#!/bin/bash
echo "Skipping Terraform deployment (not yet updated for Key Vault integration)."
exit 0

# Variables
PREFIX='websql'
Expand Down
Loading