diff --git a/samples/web-app-sql-database/python/README.md b/samples/web-app-sql-database/python/README.md index 9d6485e..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 @@ -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: diff --git a/samples/web-app-sql-database/python/scripts/deploy.sh b/samples/web-app-sql-database/python/scripts/deploy.sh index 036e7c1..e73c8ee 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 @@ -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" \ @@ -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 @@ -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. @@ -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 @@ -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]..." @@ -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" 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..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 @@ -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 \ 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 76608cf..46c762f 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', 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" @@ -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) \ 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..aa19cd1 --- /dev/null +++ b/samples/web-app-sql-database/python/src/certificates.py @@ -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, + } \ 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'