Skip to content

Commit 5128883

Browse files
Integrate Azure Key Vault Certificates into Sample Projects (#36)
* add Azure Key Vault integration and connection string management * fix variable name typo and update SQL connection string to use Azure Key Vault * work in progress * Update Key Vault secret names in deploy and validate scripts * Integrate Azure Key Vault for SQL connection string management in web app * work in progress * add missing logger * work in progress * Integrate Azure Key Vault for certificate management and validation in web app * add validate cert * refactor SQL connection handling * update README
1 parent 84bfb12 commit 5128883

7 files changed

Lines changed: 229 additions & 9 deletions

File tree

samples/web-app-sql-database/python/README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Azure Web App with Azure SQL Database and Azure Key Vault
22

3-
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).
3+
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.
44

55

66
## Architecture
@@ -12,7 +12,7 @@ The following diagram illustrates the architecture of the solution:
1212
- **Azure Web App**: Hosts the Python Flask application
1313
- **Azure App Service Plan**: Provides compute resources for the web app
1414
- **Azure SQL Database**: Stores activity data in a relational table
15-
- **Azure Key Vault**: Stores the database connection string
15+
- **Azure Key Vault**: Stores the database connection string and the certificate used to secure HTTPS traffic
1616

1717
## Prerequisites
1818

@@ -43,6 +43,13 @@ The Vacation Planner Web App supports two common approaches for accessing Azure
4343

4444
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.
4545

46+
## Azure Key Vault Integration
47+
The application integrates with Azure Key Vault for managing secrets and certificates:
48+
49+
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.
50+
51+
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.
52+
4653
## Deployment
4754

4855
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:

samples/web-app-sql-database/python/scripts/deploy.sh

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ DEPLOY_APP=1
2424
ENVIRONMENT=$(az account show --query environmentName --output tsv)
2525
KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}"
2626
SECRET_NAME="${PREFIX}-secret-${SUFFIX}"
27+
CERT_NAME="${PREFIX}-cert-${SUFFIX}"
2728

2829
# Change the current directory to the script's directory
2930
cd "$CURRENT_DIR" || exit
@@ -143,6 +144,14 @@ if [ -z "$SQL_SERVER_FQDN" ]; then
143144
exit 1
144145
fi
145146

147+
#if [[ $ENVIRONMENT == "LocalStack" ]]; then
148+
# 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)
149+
# if [ -n "$MSSQL_HOST_PORT" ]; then
150+
# SQL_SERVER_FQDN_WITH_PORT="127.0.0.1,$MSSQL_HOST_PORT"
151+
# echo "Using local SQL Server at [$SQL_SERVER_FQDN_WITH_PORT]"
152+
# fi
153+
#fi
154+
146155
# Create server-level login
147156
echo "Creating login [$DATABASE_USER_NAME] at server level..."
148157
sqlcmd -S "$SQL_SERVER_FQDN" \
@@ -351,6 +360,7 @@ $AZ keyvault set-policy \
351360
--name "$KEY_VAULT_NAME" \
352361
--object-id "$PRINCIPAL_ID" \
353362
--secret-permissions get \
363+
--certificate-permissions get \
354364
--only-show-errors 1>/dev/null
355365

356366
if [ $? -eq 0 ]; then
@@ -378,6 +388,40 @@ else
378388
exit 1
379389
fi
380390

391+
# Create certificate in Key Vault
392+
echo "Creating certificate [$CERT_NAME] in Key Vault [$KEY_VAULT_NAME]..."
393+
$AZ keyvault certificate create \
394+
--vault-name "$KEY_VAULT_NAME" \
395+
--name "$CERT_NAME" \
396+
--policy '{
397+
"issuerParameters": {"name": "Self"},
398+
"keyProperties": {"exportable": true, "keySize": 2048, "keyType": "RSA", "reuseKey": false},
399+
"secretProperties": {"contentType": "application/x-pkcs12"},
400+
"x509CertificateProperties": {"subject": "CN=sample-web-app-sql", "validityInMonths": 12}
401+
}' \
402+
--only-show-errors
403+
404+
if [ $? -eq 0 ]; then
405+
echo "Certificate [$CERT_NAME] created successfully in Key Vault [$KEY_VAULT_NAME]."
406+
else
407+
echo "Failed to create certificate [$CERT_NAME] in Key Vault [$KEY_VAULT_NAME]."
408+
exit 1
409+
fi
410+
411+
# Get Key Vault URI
412+
echo "Retrieving Key Vault URI..."
413+
KEYVAULT_URI=$($AZ keyvault show \
414+
--name "$KEY_VAULT_NAME" \
415+
--resource-group "$RESOURCE_GROUP_NAME" \
416+
--query "properties.vaultUri" \
417+
--output tsv)
418+
419+
if [ -z "$KEYVAULT_URI" ]; then
420+
echo "Failed to retrieve Key Vault URI."
421+
exit 1
422+
fi
423+
echo "Key Vault URI: [$KEYVAULT_URI]"
424+
381425
# Set web app settings
382426
# Pass Key Vault name and secret name as app settings.
383427
# The Python SDK will retrieve the actual connection string value from Key Vault.
@@ -391,6 +435,8 @@ $AZ webapp config appsettings set \
391435
KEY_VAULT_NAME="$KEY_VAULT_NAME" \
392436
SECRET_NAME="$SECRET_NAME" \
393437
LOGIN_NAME="$LOGIN_NAME" \
438+
KEYVAULT_URI="$KEYVAULT_URI" \
439+
CERT_NAME="$CERT_NAME" \
394440
--only-show-errors 1>/dev/null
395441

396442
if [ $? -eq 0 ]; then
@@ -415,7 +461,7 @@ fi
415461

416462
# Create the zip package of the web app
417463
echo "Creating zip package of the web app..."
418-
zip -r "$ZIPFILE" app.py activities.py database.py static templates requirements.txt
464+
zip -r "$ZIPFILE" app.py activities.py database.py certificates.py static templates requirements.txt
419465

420466
# Deploy the web app
421467
echo "Deploying web app [$WEB_APP_NAME] with zip file [$ZIPFILE]..."
@@ -433,6 +479,13 @@ else
433479
exit 1
434480
fi
435481

482+
# Get web app URL
483+
WEB_APP_URL=$($AZ webapp show \
484+
--name "$WEB_APP_NAME" \
485+
--resource-group "$RESOURCE_GROUP_NAME" \
486+
--query "defaultHostName" \
487+
--output tsv)
488+
436489
# Remove the zip package of the web app
437490
if [ -f "$ZIPFILE" ]; then
438491
rm "$ZIPFILE"

samples/web-app-sql-database/python/scripts/get-web-app-url.sh

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,37 @@ call_web_app() {
180180
else
181181
echo "Failed to retrieve host port"
182182
fi
183+
184+
echo "Validating certificate from Key Vault..."
185+
KV_RESPONSE=$(curl -sk "https://$container_ip:8443/api/certificate")
186+
KV_THUMBPRINT=$(echo "$KV_RESPONSE" | jq -r '.thumbprint')
187+
KV_NAME=$(echo "$KV_RESPONSE" | jq -r '.name')
188+
KV_SUBJECT=$(echo "$KV_RESPONSE" | jq -r '.subject')
189+
190+
SSL_CERT=$(echo | openssl s_client -connect "$container_ip:8443" 2>/dev/null | openssl x509)
191+
192+
SSL_THUMBPRINT=$(echo "$SSL_CERT" \
193+
| openssl x509 -fingerprint -noout -sha1 \
194+
| sed 's/.*=//;s/://g' \
195+
| tr '[:upper:]' '[:lower:]')
196+
197+
if [ "$KV_THUMBPRINT" == "$SSL_THUMBPRINT" ]; then
198+
echo "Certificate [$KV_NAME] validated: SSL cert matches Key Vault cert."
199+
else
200+
echo "Certificate mismatch! KV: $KV_THUMBPRINT, SSL: $SSL_THUMBPRINT"
201+
exit 1
202+
fi
203+
204+
SSL_SUBJECT=$(echo "$SSL_CERT" \
205+
| openssl x509 -noout -subject \
206+
| sed 's/subject=//')
207+
208+
if echo "$SSL_SUBJECT" | grep -q "$KV_SUBJECT"; then
209+
echo "Certificate subject [$KV_SUBJECT] matches SSL certificate."
210+
else
211+
echo "Certificate subject mismatch! KV: $KV_SUBJECT, SSL: $SSL_SUBJECT"
212+
exit 1
213+
fi
183214
}
184215

185216
call_web_app

samples/web-app-sql-database/python/src/app.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Flask application for managing vacation activities using Azure SQL Database."""
2-
import os
32
import logging
3+
import os
44
from typing import List, Tuple
5-
from flask import Flask, render_template, request, redirect, url_for
5+
66
from activities import ActivitiesHelper
7+
from certificates import get_certificate_info, get_ssl_context_from_keyvault
8+
from flask import Flask, jsonify, redirect, render_template, request, url_for
79

810
# Initialize Flask application
911
app: Flask = Flask(__name__)
@@ -134,6 +136,26 @@ def update(activity_id: int):
134136

135137
return redirect(url_for('index'))
136138

139+
@app.route('/api/certificate', methods=['GET'])
140+
def validate_certificate():
141+
"""
142+
Downloads the certificate from Key Vault, loads it as X509,
143+
and returns its properties to validate that Key Vault certificate
144+
emulation works correctly.
145+
"""
146+
vault_uri = os.environ.get('KEYVAULT_URI')
147+
cert_name = os.environ.get('CERT_NAME')
148+
149+
if not vault_uri or not cert_name:
150+
return jsonify({"error": "KEYVAULT_URI not configured"}), 500
151+
152+
try:
153+
info = get_certificate_info(vault_uri, cert_name)
154+
return jsonify(info), 200
155+
except Exception as e:
156+
logger.error("Error validating certificate: %s", e)
157+
return jsonify({"error": str(e)}), 500
158+
137159
# Read debug environment variable
138160
debug = os.environ.get("DEBUG", "false").lower() == "true"
139161

@@ -156,6 +178,11 @@ def update(activity_id: int):
156178

157179
# Run the Flask application
158180
if __name__ == '__main__':
159-
app.run(debug=debug)
160-
161-
181+
vault_uri = os.environ.get('KEYVAULT_URI')
182+
cert_name = os.environ.get('CERT_NAME')
183+
184+
if vault_uri and cert_name:
185+
ssl_ctx = get_ssl_context_from_keyvault(vault_uri, cert_name)
186+
app.run(host='0.0.0.0', port=8443, ssl_context=ssl_ctx)
187+
else:
188+
app.run(debug=debug)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Certificate helper module for Azure Key Vault integration."""
2+
import base64
3+
import hashlib
4+
import logging
5+
import os
6+
import ssl
7+
import tempfile
8+
9+
from azure.identity import DefaultAzureCredential
10+
from azure.keyvault.certificates import CertificateClient
11+
from azure.keyvault.secrets import SecretClient
12+
from cryptography.hazmat.primitives.serialization import (
13+
Encoding,
14+
NoEncryption,
15+
PrivateFormat,
16+
pkcs12,
17+
)
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
def get_ssl_context_from_keyvault(vault_url: str, cert_name: str) -> ssl.SSLContext:
23+
"""
24+
Downloads a certificate from Key Vault and creates an SSLContext for Flask.
25+
The certificate's private key is stored as a linked secret in Key Vault.
26+
27+
Returns:
28+
An SSLContext configured with the certificate and private key.
29+
"""
30+
credential = DefaultAzureCredential()
31+
secret_client = SecretClient(vault_url=vault_url, credential=credential)
32+
secret = secret_client.get_secret(cert_name)
33+
34+
if not secret.value:
35+
raise ValueError(f"Secret [{cert_name}] has no value")
36+
37+
# Key Vault returns PFX as base64 or PEM as plain text
38+
if secret.properties.content_type == "application/x-pkcs12":
39+
pfx_bytes = base64.b64decode(secret.value)
40+
private_key, certificate, chain = pkcs12.load_key_and_certificates(pfx_bytes, None)
41+
42+
if not certificate:
43+
raise ValueError(f"Certificate [{cert_name}] could not be loaded")
44+
45+
if not private_key:
46+
raise ValueError(f"Private key for [{cert_name}] could not be loaded")
47+
48+
cert_pem = certificate.public_bytes(Encoding.PEM)
49+
key_pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption())
50+
51+
# Include chain certs if present
52+
if chain:
53+
for ca_cert in chain:
54+
cert_pem += ca_cert.public_bytes(Encoding.PEM)
55+
else:
56+
# PEM format - value contains cert + key concatenated
57+
cert_pem = secret.value.encode()
58+
key_pem = secret.value.encode()
59+
60+
# Write to temp files (ssl module needs file paths)
61+
cert_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pem")
62+
key_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pem")
63+
64+
cert_file.write(cert_pem)
65+
key_file.write(key_pem)
66+
cert_file.close()
67+
key_file.close()
68+
69+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
70+
ctx.load_cert_chain(certfile=cert_file.name, keyfile=key_file.name)
71+
72+
# Clean up temp files after loading
73+
os.unlink(cert_file.name)
74+
os.unlink(key_file.name)
75+
76+
logger.info("SSL context created successfully from Key Vault certificate [%s]", cert_name)
77+
return ctx
78+
79+
80+
def get_certificate_info(vault_url: str, cert_name: str) -> dict:
81+
credential = DefaultAzureCredential()
82+
cert_client = CertificateClient(vault_url=vault_url, credential=credential)
83+
cert = cert_client.get_certificate(cert_name)
84+
85+
x509_cert_bytes = cert.cer
86+
if x509_cert_bytes is None:
87+
raise ValueError(f"Certificate '{cert_name}' has no public bytes (cer is None)")
88+
89+
if cert.policy is None:
90+
raise ValueError(f"Certificate '{cert_name}' has no policy")
91+
92+
thumbprint = hashlib.sha1(x509_cert_bytes).hexdigest()
93+
94+
return {
95+
"name": cert.name,
96+
"subject": cert.policy.subject,
97+
"thumbprint": thumbprint,
98+
}

samples/web-app-sql-database/python/src/requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ azure-identity==1.25.1
33
pyodbc==5.3.0
44
gunicorn==20.1.0
55
python-dotenv==1.1.1
6-
azure-keyvault-secrets
6+
azure-keyvault-secrets
7+
azure-keyvault-certificates
8+
cryptography

samples/web-app-sql-database/python/terraform/deploy.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#!/bin/bash
2+
echo "Skipping Terraform deployment (not yet updated for Key Vault integration)."
3+
exit 0
24

35
# Variables
46
PREFIX='websql'

0 commit comments

Comments
 (0)