Skip to content

Commit 964ba76

Browse files
Merge pull request #35 from localstack-samples/add-keyvault-sample
Integrate Azure Key Vault + secrets into Sample Projects
1 parent 389ed2b commit 964ba76

5 files changed

Lines changed: 198 additions & 14 deletions

File tree

samples/web-app-sql-database/python/bicep/main.bicep

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,8 @@ param username string = 'paolo'
322322
var sqlServerName = '${prefix}-sqlserver-${suffix}'
323323
var webAppName = '${prefix}-webapp-${suffix}'
324324
var appServicePlanName = '${prefix}-app-service-plan-${suffix}'
325+
var keyVaultName = '${prefix}-kv-${suffix}'
326+
var sqlConnectionStringSecretName = 'sql-connection-string'
325327
var identity = {
326328
type: 'SystemAssigned'
327329
}
@@ -429,16 +431,52 @@ resource webApp 'Microsoft.Web/sites@2024-11-01' = {
429431
}
430432
}
431433

434+
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
435+
name: keyVaultName
436+
location: location
437+
tags: tags
438+
properties: {
439+
tenantId: subscription().tenantId
440+
sku: {
441+
family: 'A'
442+
name: 'standard'
443+
}
444+
accessPolicies: [
445+
{
446+
tenantId: subscription().tenantId
447+
objectId: webApp.identity.principalId
448+
permissions: {
449+
secrets: [
450+
'get'
451+
'list'
452+
]
453+
}
454+
}
455+
]
456+
enableRbacAuthorization: false
457+
enableSoftDelete: true
458+
softDeleteRetentionInDays: 7
459+
}
460+
}
461+
462+
resource sqlConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = {
463+
parent: keyVault
464+
name: sqlConnectionStringSecretName
465+
properties: {
466+
value: 'Server=tcp:${sqlServer.properties.fullyQualifiedDomainName},1433;Database=${sqlDatabaseName};User ID=${sqlDatabaseUsername};Password=${sqlDatabasePassword};Encrypt=yes;TrustServerCertificate=no;Connection Timeout=30;'
467+
}
468+
}
469+
432470
resource configAppSettings 'Microsoft.Web/sites/config@2024-11-01' = {
433471
parent: webApp
434472
name: 'appsettings'
435473
properties: {
436474
SCM_DO_BUILD_DURING_DEPLOYMENT: 'true'
437475
ENABLE_ORYX_BUILD: 'true'
438-
SQL_SERVER: sqlServer.properties.fullyQualifiedDomainName
439-
SQL_DATABASE: sqlDatabaseName
440-
SQL_USERNAME: sqlDatabaseUsername
441-
SQL_PASSWORD: sqlDatabasePassword
476+
//Pass Key Vault name and secret name as app settings.
477+
//The Python SDK will retrieve the actual connection string value from Key Vault
478+
KEY_VAULT_NAME: keyVaultName
479+
SECRET_NAME: sqlConnectionStringSecretName
442480
LOGIN_NAME: username
443481
}
444482
}
@@ -459,3 +497,6 @@ output webAppUrl string = webApp.properties.defaultHostName
459497
output sqlServerName string = sqlServer.name
460498
output sqlServerFqdn string = sqlServer.properties.fullyQualifiedDomainName
461499
output sqlDatabaseName string = sqlDatabase.name
500+
output keyVaultName string = keyVault.name
501+
output keyVaultUrl string = keyVault.properties.vaultUri
502+
output sqlConnectionStringSecretUri string = sqlConnectionStringSecret.properties.secretUri

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

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ RUNTIME="python"
2222
RUNTIME_VERSION="3.13"
2323
DEPLOY_APP=1
2424
ENVIRONMENT=$(az account show --query environmentName --output tsv)
25+
KEY_VAULT_NAME="${PREFIX}-kv-${SUFFIX}"
26+
SECRET_NAME="${PREFIX}-secret-${SUFFIX}"
2527

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

310313
if [ $? -eq 0 ]; then
@@ -314,18 +317,79 @@ else
314317
exit 1
315318
fi
316319

320+
# Get Web App principal ID
321+
PRINCIPAL_ID=$($AZ webapp identity show \
322+
--name "$WEB_APP_NAME" \
323+
--resource-group "$RESOURCE_GROUP_NAME" \
324+
--query "principalId" \
325+
--output tsv)
326+
327+
if [ -z "$PRINCIPAL_ID" ]; then
328+
echo "Failed to retrieve principalId for web app [$WEB_APP_NAME]"
329+
exit 1
330+
fi
331+
332+
# Create Key Vault
333+
echo "Creating Key Vault [$KEY_VAULT_NAME]..."
334+
$AZ keyvault create \
335+
--name "$KEY_VAULT_NAME" \
336+
--resource-group "$RESOURCE_GROUP_NAME" \
337+
--location "$LOCATION" \
338+
--enable-rbac-authorization false \
339+
--only-show-errors 1>/dev/null
340+
341+
if [ $? -eq 0 ]; then
342+
echo "Key Vault [$KEY_VAULT_NAME] created successfully."
343+
else
344+
echo "Failed to create Key Vault [$KEY_VAULT_NAME]."
345+
exit 1
346+
fi
347+
348+
# Assign access policy to Web App managed identity
349+
echo "Assigning Key Vault access policy to Web App..."
350+
$AZ keyvault set-policy \
351+
--name "$KEY_VAULT_NAME" \
352+
--object-id "$PRINCIPAL_ID" \
353+
--secret-permissions get \
354+
--only-show-errors 1>/dev/null
355+
356+
if [ $? -eq 0 ]; then
357+
echo "Key Vault access policy assigned successfully."
358+
else
359+
echo "Failed to assign Key Vault access policy."
360+
exit 1
361+
fi
362+
363+
# Build connection string
364+
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;"
365+
366+
# Create secret
367+
echo "Creating secret [$SECRET_NAME] in Key Vault..."
368+
$AZ keyvault secret set \
369+
--vault-name "$KEY_VAULT_NAME" \
370+
--name "$SECRET_NAME" \
371+
--value "$SQL_CONNECTION_STRING" \
372+
--only-show-errors 1>/dev/null
373+
374+
if [ $? -eq 0 ]; then
375+
echo "Secret [$SECRET_NAME] created successfully."
376+
else
377+
echo "Failed to create secret [$SECRET_NAME]."
378+
exit 1
379+
fi
380+
317381
# Set web app settings
382+
# Pass Key Vault name and secret name as app settings.
383+
# The Python SDK will retrieve the actual connection string value from Key Vault.
318384
echo "Setting web app settings for [$WEB_APP_NAME]..."
319385
$AZ webapp config appsettings set \
320386
--name "$WEB_APP_NAME" \
321387
--resource-group "$RESOURCE_GROUP_NAME" \
322388
--settings \
323389
SCM_DO_BUILD_DURING_DEPLOYMENT='true' \
324390
ENABLE_ORYX_BUILD='true' \
325-
SQL_SERVER="$SQL_SERVER_FQDN" \
326-
SQL_DATABASE="$SQL_DATABASE_NAME" \
327-
SQL_USERNAME="$DATABASE_USER_NAME" \
328-
SQL_PASSWORD="$DATABASE_USER_PASSWORD" \
391+
KEY_VAULT_NAME="$KEY_VAULT_NAME" \
392+
SECRET_NAME="$SECRET_NAME" \
329393
LOGIN_NAME="$LOGIN_NAME" \
330394
--only-show-errors 1>/dev/null
331395

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,17 @@ $AZ sql db show \
3939
--name PlannerDB \
4040
--server local-sqlserver-test \
4141
--resource-group local-rg \
42+
--output table
43+
44+
# Check Azure Key Vault
45+
$AZ keyvault show \
46+
--name local-kv-test \
47+
--resource-group local-rg \
48+
--output table
49+
50+
# Check Key Vault secret
51+
$AZ keyvault secret show \
52+
--vault-name local-kv-test \
53+
--name local-secret-test \
54+
--query "{name:name, enabled:attributes.enabled, created:attributes.created}" \
4255
--output table

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

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
Supports both traditional ODBC connections and passwordless Azure AD authentication
44
"""
55

6+
import logging
67
import os
78
import struct
8-
import pyodbc
9-
import logging
10-
from typing import Optional, List, Dict, Any
119
from contextlib import contextmanager
10+
from typing import Any, Dict, List, Optional
11+
12+
import pyodbc
1213
from azure.identity import DefaultAzureCredential
14+
from azure.keyvault.secrets import SecretClient
1315

1416
# Configure logging
1517
logger = logging.getLogger(__name__)
@@ -73,26 +75,89 @@ def from_env(cls) -> 'SqlHelper':
7375
- SQL_USERNAME
7476
- SQL_PASSWORD
7577
"""
78+
key_vault_name = os.environ.get("KEY_VAULT_NAME")
79+
secret_name = os.environ.get("SECRET_NAME")
80+
81+
if key_vault_name and secret_name:
82+
return cls.from_key_vault(key_vault_name, secret_name)
83+
7684
client_id = os.environ.get("AZURE_CLIENT_ID")
7785
client_secret = os.environ.get("AZURE_CLIENT_SECRET")
7886
tenant_id = os.environ.get("AZURE_TENANT_ID")
7987
server = os.environ.get("SQL_SERVER")
8088
database = os.environ.get("SQL_DATABASE")
8189
username = os.environ.get("SQL_USERNAME")
8290
password = os.environ.get("SQL_PASSWORD")
83-
91+
8492
if not any([client_id, client_secret, tenant_id, server, database, username, password]):
8593
raise ValueError("You properly need to define environment variables.")
86-
94+
8795
logger.info("Environment variables loaded successfully")
88-
96+
8997
return cls(
9098
server=server,
9199
database=database,
92100
username=username,
93101
password=password,
94102
use_azure_credential=all([client_id, client_secret, tenant_id])
95103
)
104+
105+
@classmethod
106+
def from_key_vault(cls, vault_name: str, secret_name: str) -> 'SqlHelper':
107+
"""
108+
Create a SqlHelper instance by reading the connection string from Azure Key Vault.
109+
110+
"""
111+
vault_url = f"https://{vault_name}.vault.azure.net"
112+
credential = DefaultAzureCredential(exclude_interactive_browser_credential=False)
113+
client = SecretClient(vault_url=vault_url, credential=credential)
114+
115+
logger.info(f"Retrieving secret [{secret_name}] from Key Vault [{vault_name}]...")
116+
secret = client.get_secret(secret_name)
117+
118+
if not secret.value:
119+
raise ValueError(f"Secret [{secret_name}] in Key Vault [{vault_name}] has no value")
120+
121+
logger.info(f"Secret [{secret_name}] retrieved successfully from Key Vault [{vault_name}]")
122+
return cls.from_connection_string(secret.value)
123+
124+
@classmethod
125+
def from_connection_string(cls, connection_string: str) -> 'SqlHelper':
126+
"""
127+
Create a SqlHelper instance from a connection string.
128+
129+
This is useful when the connection string is stored in an environment variable
130+
(e.g., resolved by Azure App Service from Key Vault via @Microsoft.KeyVault(SecretUri=...)).
131+
132+
"""
133+
parts = {}
134+
for part in connection_string.split(';'):
135+
if '=' in part:
136+
key, value = part.split('=', 1)
137+
parts[key.strip()] = value.strip()
138+
139+
server = parts.get('Server', '').replace('tcp:', '').replace(',1433', '')
140+
database = parts.get('Database')
141+
username = parts.get('User ID')
142+
password = parts.get('Password')
143+
144+
if not all([server, database, username, password]):
145+
raise ValueError(
146+
f"Could not parse all required parameters from connection string. "
147+
f"Found - Server: {bool(server)}, Database: {bool(database)}, "
148+
f"Username: {bool(username)}, Password: {bool(password)}"
149+
)
150+
151+
logger.info("Connection string parsed successfully")
152+
logger.info(f"Server: {server}, Database: {database}, Username: {username}")
153+
154+
return cls(
155+
server=server,
156+
database=database,
157+
username=username,
158+
password=password,
159+
use_azure_credential=False
160+
)
96161

97162
def _build_connection_string(self) -> str:
98163
"""Build the ODBC connection string."""

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ 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

0 commit comments

Comments
 (0)