diff --git a/run-samples.sh b/run-samples.sh index 4cf1569..3a5281a 100755 --- a/run-samples.sh +++ b/run-samples.sh @@ -53,6 +53,8 @@ SAMPLES=( "samples/web-app-custom-image/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/aci-blob-storage/python|bash scripts/deploy.sh|bash scripts/validate.sh" "samples/container-apps-blob-storage/python|bash scripts/deploy.sh|bash scripts/validate.sh" + "samples/container-apps-blob-storage/python|MANAGED_IDENTITY_TYPE=SystemAssigned bash scripts/deploy.sh|MANAGED_IDENTITY_TYPE=SystemAssigned bash scripts/validate.sh" + "samples/container-apps-blob-storage/python|MANAGED_IDENTITY_TYPE=UserAssigned bash scripts/deploy.sh|MANAGED_IDENTITY_TYPE=UserAssigned bash scripts/validate.sh" "samples/url-shortener/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" ) @@ -79,6 +81,8 @@ TERRAFORM_SAMPLES=( "samples/web-app-app-configuration/dotnet/terraform|bash deploy.sh" "samples/aci-blob-storage/python/terraform|bash deploy.sh" "samples/container-apps-blob-storage/python/terraform|bash deploy.sh" + "samples/container-apps-blob-storage/python/terraform|MANAGED_IDENTITY_TYPE=SystemAssigned bash deploy.sh" + "samples/container-apps-blob-storage/python/terraform|MANAGED_IDENTITY_TYPE=UserAssigned bash deploy.sh" "samples/url-shortener/python/terraform|bash deploy.sh|bash ../scripts/validate.sh" ) @@ -105,6 +109,8 @@ BICEP_SAMPLES=( "samples/web-app-app-configuration/dotnet/bicep|bash deploy.sh" "samples/aci-blob-storage/python/bicep|bash deploy.sh" "samples/container-apps-blob-storage/python/bicep|bash deploy.sh" + "samples/container-apps-blob-storage/python/bicep|MANAGED_IDENTITY_TYPE=SystemAssigned bash deploy.sh" + "samples/container-apps-blob-storage/python/bicep|MANAGED_IDENTITY_TYPE=UserAssigned bash deploy.sh" "samples/url-shortener/python/bicep|bash deploy.sh|bash ../scripts/validate.sh" ) @@ -196,7 +202,7 @@ host_arch() { if [[ "${1:-}" == "--list" ]]; then echo "[" for (( i=0; i/dev/null if [ $? -eq 0 ]; then diff --git a/samples/container-apps-blob-storage/python/README.md b/samples/container-apps-blob-storage/python/README.md index 0e48dad..f710fe6 100644 --- a/samples/container-apps-blob-storage/python/README.md +++ b/samples/container-apps-blob-storage/python/README.md @@ -30,6 +30,7 @@ flowchart TB ``` - **Deployment flow:** The deploy script creates Storage first, then ACR, builds and pushes the container image, creates a Container Apps managed environment, and finally creates a container app that pulls from ACR. The storage connection string is stored as a Container Apps secret and injected into the container through a `secretref` environment variable. +- **Managed identity (optional):** With `MANAGED_IDENTITY_TYPE=SystemAssigned` or `UserAssigned`, the app gets a managed identity with the `Storage Blob Data Contributor` role on the storage account instead of the connection string secret, and authenticates to Blob Storage with `DefaultAzureCredential`. - **At runtime:** The Flask app reads the storage connection string from its environment, connects to Blob Storage, and provides a web UI for signing and reading the guestbook. The revision that served each response is shown in the UI, so rolling out a new revision with `az containerapp update` is observable over HTTP. ## Prerequisites @@ -56,6 +57,15 @@ bash scripts/deploy.sh bash scripts/validate.sh ``` +### Managed Identity + +Every deployment method accepts `MANAGED_IDENTITY_TYPE` (`None` by default, `SystemAssigned` or `UserAssigned`) to have the app reach Blob Storage with a managed identity instead of a connection string: + +```bash +MANAGED_IDENTITY_TYPE=UserAssigned bash scripts/deploy.sh +MANAGED_IDENTITY_TYPE=UserAssigned bash scripts/validate.sh +``` + ## Alternative Deployments ### Bicep @@ -90,13 +100,15 @@ The Guestbook is a Flask web application that lets visitors sign a guestbook. En | `/` | GET | View all guestbook entries | | `/` | POST | Sign the guestbook | | `/delete/` | POST | Delete an entry | -| `/health` | GET | Health check (reports the serving revision) | +| `/health` | GET | Health check (reports the serving revision and the storage auth mode) | ### Environment Variables | Variable | Description | |----------|-------------| | `AZURE_STORAGE_CONNECTION_STRING` | Blob Storage connection string (injected via `secretref:storage-conn`) | +| `AZURE_STORAGE_ACCOUNT_URL` | Blob endpoint reached with the managed identity (set instead of the connection string) | +| `AZURE_CLIENT_ID` | Client ID of the user-assigned managed identity (`UserAssigned` only) | | `BLOB_CONTAINER_NAME` | Name of the blob container for entries | | `APP_REVISION` | Revision label shown in the UI and `/health` (default: "v1") | @@ -117,6 +129,7 @@ The Guestbook is a Flask web application that lets visitors sign a guestbook. En | Managed environment create | deploy.sh | | App create from a private registry (ACR) | deploy.sh | | Secrets (`--secrets` + `secretref:` env var) | deploy.sh | +| System-assigned / user-assigned managed identity + RBAC on Storage | deploy.sh (`MANAGED_IDENTITY_TYPE`) | | External HTTP ingress + FQDN | deploy.sh | | Multiple revisions mode + revision suffix | deploy.sh | | Min / max replicas + HTTP scale rule | deploy.sh | diff --git a/samples/container-apps-blob-storage/python/bicep/README.md b/samples/container-apps-blob-storage/python/bicep/README.md index 11e8103..ecd2743 100644 --- a/samples/container-apps-blob-storage/python/bicep/README.md +++ b/samples/container-apps-blob-storage/python/bicep/README.md @@ -49,6 +49,12 @@ cd samples/container-apps-blob-storage/python bash bicep/deploy.sh ``` +To have the app reach Blob Storage with a managed identity instead of the connection string secret, set `MANAGED_IDENTITY_TYPE` (`SystemAssigned` or `UserAssigned`), which deploy.sh passes as the `managedIdentityType` parameter. The template then creates the identity (user-assigned only), grants it the `Storage Blob Data Contributor` role on the storage account, and passes the app the blob endpoint, plus `AZURE_CLIENT_ID` for a user-assigned identity: + +```bash +MANAGED_IDENTITY_TYPE=UserAssigned bash bicep/deploy.sh +``` + ## Cleanup ```bash diff --git a/samples/container-apps-blob-storage/python/bicep/deploy.sh b/samples/container-apps-blob-storage/python/bicep/deploy.sh index ad713ae..73267c3 100644 --- a/samples/container-apps-blob-storage/python/bicep/deploy.sh +++ b/samples/container-apps-blob-storage/python/bicep/deploy.sh @@ -16,11 +16,11 @@ SUBSCRIPTION_NAME=$(az account show --query name --output tsv) CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" IMAGE_NAME="guestbook" IMAGE_TAG="v1" +MANAGED_IDENTITY_TYPE="${MANAGED_IDENTITY_TYPE:-None}" echo "==================================================" echo "DEBUG: Starting bicep deployment for container-apps-blob-storage" echo "DEBUG: Resource Group: $RESOURCE_GROUP_NAME" -echo "DEBUG: Environment: $ENVIRONMENT" echo "==================================================" # Change the current directory to the script's directory @@ -126,6 +126,7 @@ if [[ $VALIDATE_TEMPLATE == 1 ]]; then --parameters location=$LOCATION \ prefix=$PREFIX \ suffix=$SUFFIX \ + managedIdentityType=$MANAGED_IDENTITY_TYPE \ --only-show-errors if [[ $? == 0 ]]; then @@ -144,6 +145,7 @@ if [[ $VALIDATE_TEMPLATE == 1 ]]; then --parameters location=$LOCATION \ prefix=$PREFIX \ suffix=$SUFFIX \ + managedIdentityType=$MANAGED_IDENTITY_TYPE \ --only-show-errors) if [[ $? == 0 ]]; then @@ -166,6 +168,7 @@ if DEPLOYMENT_OUTPUTS=$(az deployment group create \ --parameters location=$LOCATION \ prefix=$PREFIX \ suffix=$SUFFIX \ + managedIdentityType=$MANAGED_IDENTITY_TYPE \ --query 'properties.outputs' -o json); then echo "Bicep template [$TEMPLATE] deployed successfully. Outputs:" # Strip any non-JSON prefix (e.g. Bicep CLI messages) before parsing @@ -176,12 +179,15 @@ if DEPLOYMENT_OUTPUTS=$(az deployment group create \ ENVIRONMENT_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.environmentName.value') APP_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.appName.value') FQDN=$(echo "$DEPLOYMENT_JSON" | jq -r '.fqdn.value') + PRINCIPAL_ID=$(echo "$DEPLOYMENT_JSON" | jq -r '.principalId.value') echo "Deployment details:" echo "- storageAccountName: $STORAGE_ACCOUNT_NAME" echo "- acrLoginServer: $ACR_LOGIN_SERVER" echo "- environmentName: $ENVIRONMENT_NAME" echo "- appName: $APP_NAME" echo "- fqdn: $FQDN" + echo "- managedIdentityType: $MANAGED_IDENTITY_TYPE" + echo "- principalId: $PRINCIPAL_ID" else echo "Failed to deploy Bicep template [$TEMPLATE]" exit 1 diff --git a/samples/container-apps-blob-storage/python/bicep/main.bicep b/samples/container-apps-blob-storage/python/bicep/main.bicep index a45aff3..315064d 100644 --- a/samples/container-apps-blob-storage/python/bicep/main.bicep +++ b/samples/container-apps-blob-storage/python/bicep/main.bicep @@ -46,6 +46,14 @@ param maxReplicas int = 3 @description('Specifies the revision suffix of the container app template.') param revisionSuffix string = 'v1' +@description('Specifies how the app authenticates to Blob Storage: None uses a connection string secret, SystemAssigned or UserAssigned a managed identity.') +@allowed([ + 'None' + 'SystemAssigned' + 'UserAssigned' +]) +param managedIdentityType string = 'None' + @description('Specifies the tags to be applied to the resources.') param tags object = { environment: 'test' @@ -56,6 +64,8 @@ var storageAccountName = '${prefix}acastorage${suffix}' var acrName = '${prefix}acaacr${suffix}' var environmentName = '${prefix}-aca-env-${suffix}' var appName = '${prefix}-aca-guestbook-${suffix}' +var managedIdentityName = '${prefix}-aca-identity-${suffix}' +var storageBlobDataContributorRoleId = 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' // Storage Account resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = { @@ -81,6 +91,22 @@ resource blobContainer 'Microsoft.Storage/storageAccounts/blobServices/container name: containerName } +resource userAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = if (managedIdentityType == 'UserAssigned') { + name: managedIdentityName + location: location + tags: tags +} + +resource userAssignedRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (managedIdentityType == 'UserAssigned') { + name: guid(storageAccount.id, managedIdentityName, storageBlobDataContributorRoleId) + scope: storageAccount + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataContributorRoleId) + principalId: userAssignedIdentity!.properties.principalId + principalType: 'ServicePrincipal' + } +} + // Container Registry resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { name: acrName @@ -107,20 +133,35 @@ resource containerApp 'Microsoft.App/containerApps@2025-07-01' = { name: appName location: location tags: tags + identity: managedIdentityType == 'None' + ? null + : { + type: managedIdentityType + userAssignedIdentities: managedIdentityType == 'UserAssigned' ? { '${userAssignedIdentity!.id}': {} } : null + } + dependsOn: [ + userAssignedRoleAssignment + ] properties: { managedEnvironmentId: managedEnvironment.id configuration: { activeRevisionsMode: 'Multiple' - secrets: [ - { - name: 'storage-conn' - value: 'DefaultEndpointsProtocol=http;AccountName=${storageAccountName};AccountKey=${storageAccount.listKeys().keys[0].value};BlobEndpoint=${storageAccount.properties.primaryEndpoints.blob}' - } - { - name: 'registry-password' - value: containerRegistry.listCredentials().passwords[0].value - } - ] + secrets: concat( + managedIdentityType == 'None' + ? [ + { + name: 'storage-conn' + value: 'DefaultEndpointsProtocol=http;AccountName=${storageAccountName};AccountKey=${storageAccount.listKeys().keys[0].value};BlobEndpoint=${storageAccount.properties.primaryEndpoints.blob}' + } + ] + : [], + [ + { + name: 'registry-password' + value: containerRegistry.listCredentials().passwords[0].value + } + ] + ) registries: [ { server: containerRegistry.properties.loginServer @@ -151,20 +192,39 @@ resource containerApp 'Microsoft.App/containerApps@2025-07-01' = { cpu: json(containerCpu) memory: containerMemory } - env: [ - { - name: 'AZURE_STORAGE_CONNECTION_STRING' - secretRef: 'storage-conn' - } - { - name: 'BLOB_CONTAINER_NAME' - value: containerName - } - { - name: 'APP_REVISION' - value: revisionSuffix - } - ] + env: concat( + managedIdentityType == 'None' + ? [ + { + name: 'AZURE_STORAGE_CONNECTION_STRING' + secretRef: 'storage-conn' + } + ] + : [ + { + name: 'AZURE_STORAGE_ACCOUNT_URL' + value: storageAccount.properties.primaryEndpoints.blob + } + ], + managedIdentityType == 'UserAssigned' + ? [ + { + name: 'AZURE_CLIENT_ID' + value: userAssignedIdentity!.properties.clientId + } + ] + : [], + [ + { + name: 'BLOB_CONTAINER_NAME' + value: containerName + } + { + name: 'APP_REVISION' + value: revisionSuffix + } + ] + ) } ] scale: { @@ -185,6 +245,16 @@ resource containerApp 'Microsoft.App/containerApps@2025-07-01' = { } } +resource systemAssignedRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (managedIdentityType == 'SystemAssigned') { + name: guid(storageAccount.id, appName, storageBlobDataContributorRoleId) + scope: storageAccount + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataContributorRoleId) + principalId: containerApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + output storageAccountName string = storageAccount.name output acrName string = containerRegistry.name output acrLoginServer string = containerRegistry.properties.loginServer @@ -192,3 +262,4 @@ output environmentName string = managedEnvironment.name output appName string = containerApp.name output fqdn string = containerApp.properties.configuration.ingress.fqdn output latestRevisionName string = containerApp.properties.latestRevisionName +output principalId string = managedIdentityType == 'None' ? '' : (managedIdentityType == 'SystemAssigned' ? containerApp.identity.principalId : userAssignedIdentity!.properties.principalId) diff --git a/samples/container-apps-blob-storage/python/scripts/README.md b/samples/container-apps-blob-storage/python/scripts/README.md index 73a2c58..d547c6b 100644 --- a/samples/container-apps-blob-storage/python/scripts/README.md +++ b/samples/container-apps-blob-storage/python/scripts/README.md @@ -32,12 +32,28 @@ cd samples/container-apps-blob-storage/python bash scripts/deploy.sh ``` +### Managed Identity + +By default the app authenticates to Blob Storage with a connection string stored as a Container Apps secret. Set `MANAGED_IDENTITY_TYPE` to use a [managed identity](https://learn.microsoft.com/en-us/azure/container-apps/managed-identity) instead: + +- `SystemAssigned`: the app is created with a system-assigned identity, which is then granted the `Storage Blob Data Contributor` role on the storage account. +- `UserAssigned`: a user-assigned identity is created and granted the role first, then attached to the app; its client ID is passed as `AZURE_CLIENT_ID`. + +In both cases the app gets the blob endpoint (`AZURE_STORAGE_ACCOUNT_URL`) instead of the `storage-conn` secret, and `DefaultAzureCredential` gets its token from the identity endpoint Container Apps injects into the container. The ACR admin password used to pull the image remains a Container Apps secret. + +```bash +MANAGED_IDENTITY_TYPE=SystemAssigned bash scripts/deploy.sh +MANAGED_IDENTITY_TYPE=SystemAssigned bash scripts/validate.sh +``` + ## Validation ```bash bash scripts/validate.sh ``` +Pass the same `MANAGED_IDENTITY_TYPE` used for the deployment. + ## Cleanup ```bash diff --git a/samples/container-apps-blob-storage/python/scripts/cleanup.sh b/samples/container-apps-blob-storage/python/scripts/cleanup.sh index 35564ca..e79eab2 100644 --- a/samples/container-apps-blob-storage/python/scripts/cleanup.sh +++ b/samples/container-apps-blob-storage/python/scripts/cleanup.sh @@ -14,6 +14,7 @@ ACA_APP_NAME="${PREFIX}-aca-guestbook" ACA_ENV_NAME="${PREFIX}-aca-env" ACR_NAME="${PREFIX}acaacr" STORAGE_ACCOUNT_NAME="${PREFIX}acastorage" +MANAGED_IDENTITY_NAME="${PREFIX}-aca-identity" echo "============================================================" echo "Cleaning up Container Apps Guestbook Resources" @@ -21,7 +22,7 @@ echo "============================================================" echo "" # 1. Delete the container app -echo "[1/5] Deleting container app [$ACA_APP_NAME]..." +echo "[1/6] Deleting container app [$ACA_APP_NAME]..." az containerapp delete \ --name "$ACA_APP_NAME" \ --resource-group "$RESOURCE_GROUP_NAME" \ @@ -30,7 +31,7 @@ az containerapp delete \ echo "" # 2. Delete the Container Apps environment -echo "[2/5] Deleting Container Apps environment [$ACA_ENV_NAME]..." +echo "[2/6] Deleting Container Apps environment [$ACA_ENV_NAME]..." az containerapp env delete \ --name "$ACA_ENV_NAME" \ --resource-group "$RESOURCE_GROUP_NAME" \ @@ -39,7 +40,7 @@ az containerapp env delete \ echo "" # 3. Delete ACR -echo "[3/5] Deleting ACR [$ACR_NAME]..." +echo "[3/6] Deleting ACR [$ACR_NAME]..." az acr delete \ --name "$ACR_NAME" \ --resource-group "$RESOURCE_GROUP_NAME" \ @@ -48,7 +49,7 @@ az acr delete \ echo "" # 4. Delete Storage Account -echo "[4/5] Deleting Storage Account [$STORAGE_ACCOUNT_NAME]..." +echo "[4/6] Deleting Storage Account [$STORAGE_ACCOUNT_NAME]..." az storage account delete \ --name "$STORAGE_ACCOUNT_NAME" \ --resource-group "$RESOURCE_GROUP_NAME" \ @@ -56,8 +57,16 @@ az storage account delete \ --only-show-errors 2>/dev/null && echo " Deleted: $STORAGE_ACCOUNT_NAME" || echo " Skipped: $STORAGE_ACCOUNT_NAME (not found)" echo "" -# 5. Delete Resource Group -echo "[5/5] Deleting Resource Group [$RESOURCE_GROUP_NAME]..." +# 5. Delete the user-assigned managed identity (MANAGED_IDENTITY_TYPE=UserAssigned) +echo "[5/6] Deleting managed identity [$MANAGED_IDENTITY_NAME]..." +az identity delete \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --only-show-errors 2>/dev/null && echo " Deleted: $MANAGED_IDENTITY_NAME" || echo " Skipped: $MANAGED_IDENTITY_NAME (not found)" +echo "" + +# 6. Delete Resource Group +echo "[6/6] Deleting Resource Group [$RESOURCE_GROUP_NAME]..." az group delete \ --name "$RESOURCE_GROUP_NAME" \ --yes \ diff --git a/samples/container-apps-blob-storage/python/scripts/deploy.sh b/samples/container-apps-blob-storage/python/scripts/deploy.sh index 79060be..48426ac 100644 --- a/samples/container-apps-blob-storage/python/scripts/deploy.sh +++ b/samples/container-apps-blob-storage/python/scripts/deploy.sh @@ -9,8 +9,11 @@ # 3. Azure Container Apps - Runs the containerized Flask app behind the # managed environment's HTTP ingress # -# The storage connection string is stored as a Container Apps secret and -# injected into the container through a secretref environment variable. +# By default the storage connection string is stored as a Container Apps +# secret and injected into the container through a secretref environment +# variable. Set MANAGED_IDENTITY_TYPE to SystemAssigned or UserAssigned to have +# the app reach Blob Storage with a managed identity instead, granted the +# Storage Blob Data Contributor role on the storage account. # ============================================================================= # Variables @@ -24,10 +27,42 @@ ACA_ENV_NAME="${PREFIX}-aca-env" ACA_APP_NAME="${PREFIX}-aca-guestbook" IMAGE_NAME="guestbook" IMAGE_TAG="v1" +MANAGED_IDENTITY_TYPE="${MANAGED_IDENTITY_TYPE:-None}" +MANAGED_IDENTITY_NAME="${PREFIX}-aca-identity" +STORAGE_ROLE="Storage Blob Data Contributor" CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" +case "$MANAGED_IDENTITY_TYPE" in +None | SystemAssigned | UserAssigned) ;; +*) + echo "MANAGED_IDENTITY_TYPE must be None, SystemAssigned or UserAssigned, not [$MANAGED_IDENTITY_TYPE]." + exit 1 + ;; +esac + # Change the current directory to the script's directory cd "$CURRENT_DIR" || exit +# Assigns the storage role to a managed identity, retrying while a newly +# created principal is still replicating to Microsoft Entra ID. +assign_storage_role() { + local principal_id="$1" + for attempt in 1 2 3 4 5; do + if az role assignment create \ + --assignee-object-id "$principal_id" \ + --assignee-principal-type ServicePrincipal \ + --role "$STORAGE_ROLE" \ + --scope "$STORAGE_ACCOUNT_ID" \ + --only-show-errors 1>/dev/null; then + echo "Role [$STORAGE_ROLE] assigned to principal [$principal_id]." + return 0 + fi + echo "Role assignment attempt $attempt failed. Retrying in 10 seconds..." + sleep 10 + done + echo "Failed to assign role [$STORAGE_ROLE] to principal [$principal_id]." + return 1 +} + # ============================================================================= # Step 1: Create Resource Group # ============================================================================= @@ -109,21 +144,9 @@ else exit 1 fi -# Build the connection string using the original blob endpoint (resolvable from the host). STORAGE_CONN_STRING="DefaultEndpointsProtocol=http;AccountName=${STORAGE_ACCOUNT_NAME};AccountKey=${STORAGE_ACCOUNT_KEY};BlobEndpoint=${BLOB_ENDPOINT}" echo "Connection string built successfully." -# For LocalStack, the Container Apps runtime configures the cluster with LocalStack's -# DNS server, so *.localhost.localstack.cloud resolves to the LocalStack container. -# We only need to downgrade HTTPS to HTTP (containers don't have the LS TLS cert). -if [[ $ENVIRONMENT == "LocalStack" ]]; then - CONTAINER_BLOB_ENDPOINT="${BLOB_ENDPOINT/https:\/\//http:\/\/}" - CONTAINER_CONN_STRING="DefaultEndpointsProtocol=http;AccountName=${STORAGE_ACCOUNT_NAME};AccountKey=${STORAGE_ACCOUNT_KEY};BlobEndpoint=${CONTAINER_BLOB_ENDPOINT}" - echo "Container blob endpoint: $CONTAINER_BLOB_ENDPOINT" -else - CONTAINER_CONN_STRING="$STORAGE_CONN_STRING" -fi - # ============================================================================= # Step 5: Create Blob Container # ============================================================================= @@ -145,6 +168,39 @@ else exit 1 fi +# ============================================================================= +# Step 5b: Create the User-Assigned Managed Identity (UserAssigned only) +# ============================================================================= +STORAGE_ACCOUNT_ID=$(az storage account show \ + --name $STORAGE_ACCOUNT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --query "id" \ + --output tsv \ + --only-show-errors) + +if [[ $MANAGED_IDENTITY_TYPE == "UserAssigned" ]]; then + echo "" + echo "============================================================" + echo "Step 5b: Creating managed identity [$MANAGED_IDENTITY_NAME]..." + echo "============================================================" + read -r IDENTITY_ID IDENTITY_CLIENT_ID IDENTITY_PRINCIPAL_ID < <(az identity create \ + --name "$MANAGED_IDENTITY_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --location "$LOCATION" \ + --query "[id, clientId, principalId]" \ + --output tsv \ + --only-show-errors | tr '\n' ' ') + + if [ -n "$IDENTITY_PRINCIPAL_ID" ]; then + echo "Managed identity [$MANAGED_IDENTITY_NAME] created. Client ID: $IDENTITY_CLIENT_ID" + else + echo "Failed to create managed identity [$MANAGED_IDENTITY_NAME]." + exit 1 + fi + + assign_storage_role "$IDENTITY_PRINCIPAL_ID" || exit 1 +fi + # ============================================================================= # Step 6: Create Azure Container Registry (ACR) # ============================================================================= @@ -285,8 +341,25 @@ echo "Step 10: Creating container app [$ACA_APP_NAME]..." echo "============================================================" # The registry credentials are passed explicitly: the CLI only infers them from # ARM when the server ends in ".azurecr.io", and LocalStack's loginServer is its -# own host. The storage connection string becomes the Container Apps secret -# [storage-conn], referenced from the container via a secretref env var. +# own host. Without a managed identity the storage connection string becomes +# the Container Apps secret [storage-conn], referenced from the container via a +# secretref env var; with one, the app gets the blob endpoint (HTTPS, as a +# token credential requires) instead of the storage secret. +case "$MANAGED_IDENTITY_TYPE" in +None) + STORAGE_ARGS=(--secrets storage-conn="$STORAGE_CONN_STRING") + STORAGE_ENV_VARS=(AZURE_STORAGE_CONNECTION_STRING=secretref:storage-conn) + ;; +SystemAssigned) + STORAGE_ARGS=(--system-assigned) + STORAGE_ENV_VARS=(AZURE_STORAGE_ACCOUNT_URL="$BLOB_ENDPOINT") + ;; +UserAssigned) + STORAGE_ARGS=(--user-assigned "$IDENTITY_ID") + STORAGE_ENV_VARS=(AZURE_STORAGE_ACCOUNT_URL="$BLOB_ENDPOINT" AZURE_CLIENT_ID="$IDENTITY_CLIENT_ID") + ;; +esac + az containerapp create \ --name "$ACA_APP_NAME" \ --resource-group "$RESOURCE_GROUP_NAME" \ @@ -296,9 +369,9 @@ az containerapp create \ --registry-server "$LOGIN_SERVER" \ --registry-username "$ACR_USERNAME" \ --registry-password "$ACR_PASSWORD" \ - --secrets storage-conn="$CONTAINER_CONN_STRING" \ + "${STORAGE_ARGS[@]}" \ --env-vars \ - AZURE_STORAGE_CONNECTION_STRING=secretref:storage-conn \ + "${STORAGE_ENV_VARS[@]}" \ BLOB_CONTAINER_NAME="$BLOB_CONTAINER_NAME" \ APP_REVISION="$IMAGE_TAG" \ --ingress external \ @@ -321,6 +394,25 @@ else exit 1 fi +# A system-assigned identity only exists once the app does, so it is granted +# access afterwards. Until the role propagates, Blob Storage rejects the app's +# requests; validate.sh retries the round trip for that reason. +if [[ $MANAGED_IDENTITY_TYPE == "SystemAssigned" ]]; then + APP_PRINCIPAL_ID=$(az containerapp show \ + --name "$ACA_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query "identity.principalId" \ + --output tsv \ + --only-show-errors) + + if [ -z "$APP_PRINCIPAL_ID" ]; then + echo "Container app [$ACA_APP_NAME] has no system-assigned identity." + exit 1 + fi + echo "System-assigned identity principal ID: $APP_PRINCIPAL_ID" + assign_storage_role "$APP_PRINCIPAL_ID" || exit 1 +fi + FQDN=$(az containerapp show \ --name "$ACA_APP_NAME" \ --resource-group "$RESOURCE_GROUP_NAME" \ @@ -342,6 +434,7 @@ echo "ACR: $ACR_NAME ($LOGIN_SERVER)" echo "ACA Environment: $ACA_ENV_NAME" echo "Container App: $ACA_APP_NAME" echo "Image: $FULL_IMAGE" +echo "Managed Identity: $MANAGED_IDENTITY_TYPE" echo "Ingress FQDN: $FQDN" if [[ "$FQDN" == *"localhost.localstack.cloud" ]]; then echo "App URL: http://${FQDN}:4566/" diff --git a/samples/container-apps-blob-storage/python/scripts/validate.sh b/samples/container-apps-blob-storage/python/scripts/validate.sh index 4017672..7bb9035 100644 --- a/samples/container-apps-blob-storage/python/scripts/validate.sh +++ b/samples/container-apps-blob-storage/python/scripts/validate.sh @@ -17,6 +17,9 @@ BLOB_CONTAINER_NAME="guestbook" ACR_NAME="${PREFIX}acaacr" ACA_ENV_NAME="${PREFIX}-aca-env" ACA_APP_NAME="${PREFIX}-aca-guestbook" +MANAGED_IDENTITY_TYPE="${MANAGED_IDENTITY_TYPE:-None}" +MANAGED_IDENTITY_NAME="${PREFIX}-aca-identity" +STORAGE_ROLE="Storage Blob Data Contributor" PASS_COUNT=0 FAIL_COUNT=0 @@ -124,17 +127,54 @@ check_output "target port is 8080" \ "8080" echo "" -# 7. Secrets -echo "[7] Secrets" -check_output "secret [storage-conn] exists" \ - "az containerapp secret list --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query '[].name' --output tsv" \ - "storage-conn" -check_output "secret value is readable" \ - "az containerapp secret show --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME --secret-name storage-conn --query 'value' --output tsv" \ - "AccountName=${STORAGE_ACCOUNT_NAME}" -check_output "env var is wired to the secret" \ - "$APP_SHOW --query \"properties.template.containers[0].env[?name=='AZURE_STORAGE_CONNECTION_STRING'].secretRef | [0]\" --output tsv" \ - "storage-conn" +# 7. Storage access: a secret-backed connection string, or a managed identity +if [[ $MANAGED_IDENTITY_TYPE == "None" ]]; then + echo "[7] Secrets" + check_output "secret [storage-conn] exists" \ + "az containerapp secret list --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query '[].name' --output tsv" \ + "storage-conn" + check_output "secret value is readable" \ + "az containerapp secret show --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME --secret-name storage-conn --query 'value' --output tsv" \ + "AccountName=${STORAGE_ACCOUNT_NAME}" + check_output "env var is wired to the secret" \ + "$APP_SHOW --query \"properties.template.containers[0].env[?name=='AZURE_STORAGE_CONNECTION_STRING'].secretRef | [0]\" --output tsv" \ + "storage-conn" +else + echo "[7] Managed Identity ($MANAGED_IDENTITY_TYPE)" + check_output "identity type is $MANAGED_IDENTITY_TYPE" \ + "$APP_SHOW --query 'identity.type' --output tsv" \ + "^${MANAGED_IDENTITY_TYPE}$" + if [[ $MANAGED_IDENTITY_TYPE == "SystemAssigned" ]]; then + PRINCIPAL_ID=$(eval "$APP_SHOW --query 'identity.principalId' --output tsv" 2>/dev/null) + else + PRINCIPAL_ID=$(az identity show --name "$MANAGED_IDENTITY_NAME" --resource-group "$RESOURCE_GROUP_NAME" --query 'principalId' --output tsv 2>/dev/null) + CLIENT_ID=$(az identity show --name "$MANAGED_IDENTITY_NAME" --resource-group "$RESOURCE_GROUP_NAME" --query 'clientId' --output tsv 2>/dev/null) + check_output "identity [$MANAGED_IDENTITY_NAME] is attached to the app" \ + "$APP_SHOW --query 'keys(identity.userAssignedIdentities)' --output tsv" \ + "$MANAGED_IDENTITY_NAME" + check_output "AZURE_CLIENT_ID selects the identity" \ + "$APP_SHOW --query \"properties.template.containers[0].env[?name=='AZURE_CLIENT_ID'].value | [0]\" --output tsv" \ + "^${CLIENT_ID:-missing}$" + fi + echo -n " Checking principal ID is set... " + if [ -n "$PRINCIPAL_ID" ]; then + echo "OK ($PRINCIPAL_ID)" + PASS_COUNT=$((PASS_COUNT + 1)) + else + echo "FAIL" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi + STORAGE_ACCOUNT_ID=$(az storage account show --name $STORAGE_ACCOUNT_NAME --resource-group $RESOURCE_GROUP_NAME --query 'id' --output tsv 2>/dev/null) + check_output "identity has [$STORAGE_ROLE] on the storage account" \ + "az role assignment list --assignee-object-id ${PRINCIPAL_ID:-missing} --scope $STORAGE_ACCOUNT_ID --query '[].roleDefinitionName' --output tsv" \ + "$STORAGE_ROLE" + check_output "env var carries the blob endpoint" \ + "$APP_SHOW --query \"properties.template.containers[0].env[?name=='AZURE_STORAGE_ACCOUNT_URL'].value | [0]\" --output tsv" \ + "^https://" + check_output "no storage secret is configured" \ + "az containerapp secret list --name $ACA_APP_NAME --resource-group $RESOURCE_GROUP_NAME --query \"length([?name=='storage-conn'])\" --output tsv" \ + "^0$" +fi echo "" # 8. Scale @@ -225,9 +265,20 @@ fi check_output "storage is configured in the app" \ "curl -s --max-time 5 $APP_URL/health" \ '"storage_configured": *true' +if [[ $MANAGED_IDENTITY_TYPE == "None" ]]; then + EXPECTED_AUTH_MODE="connection_string" +else + EXPECTED_AUTH_MODE="managed_identity" +fi +check_output "app authenticates with $EXPECTED_AUTH_MODE" \ + "curl -s --max-time 5 $APP_URL/health" \ + "\"auth_mode\": *\"$EXPECTED_AUTH_MODE\"" echo "" -# 12. Guestbook round trip (POST an entry, read it back) +# 12. Guestbook round trip (POST an entry, read it back). The app answers the +# POST with a redirect even when the write fails, so with a managed identity the +# entry is posted again until it is served back: on Azure a role granted to the +# identity can take up to 10 minutes to take effect. echo "[12] Guestbook Round Trip" ENTRY_AUTHOR="validate-sh" ENTRY_MESSAGE="Hello from validate.sh at $(date +%s)" @@ -243,9 +294,25 @@ else echo "FAIL (HTTP $HTTP_CODE)" FAIL_COUNT=$((FAIL_COUNT + 1)) fi -check_output "entry is served back from Blob Storage" \ - "curl -s --max-time 10 $APP_URL/" \ - "$ENTRY_MESSAGE" +echo -n " Checking entry is served back from Blob Storage... " +if [[ $MANAGED_IDENTITY_TYPE != "None" ]]; then + for i in $(seq 1 60); do + if curl -s --max-time 10 "$APP_URL/" 2>/dev/null | grep -q "$ENTRY_MESSAGE"; then + break + fi + sleep 10 + curl -s -o /dev/null --max-time 10 -X POST "$APP_URL/" \ + --data-urlencode "author=$ENTRY_AUTHOR" \ + --data-urlencode "message=$ENTRY_MESSAGE" 2>/dev/null + done +fi +if curl -s --max-time 10 "$APP_URL/" 2>/dev/null | grep -q "$ENTRY_MESSAGE"; then + echo "OK" + PASS_COUNT=$((PASS_COUNT + 1)) +else + echo "FAIL" + FAIL_COUNT=$((FAIL_COUNT + 1)) +fi echo "" # ============================================================================= @@ -257,16 +324,20 @@ echo "" # 13. Roll out a new revision (v2) with an updated APP_REVISION env var. # The secretref is re-stated alongside the changed var so the update payload # carries the secret wiring explicitly rather than relying on the CLI's -# read-modify-write merge of the env list. +# read-modify-write merge of the env list. A managed-identity app has no secret +# to re-state. echo "[13] Revision Update" echo -n " Rolling out revision v2... " +if [[ $MANAGED_IDENTITY_TYPE == "None" ]]; then + REVISION_ENV_VARS=(APP_REVISION=v2 AZURE_STORAGE_CONNECTION_STRING=secretref:storage-conn) +else + REVISION_ENV_VARS=(APP_REVISION=v2) +fi az containerapp update \ --name "$ACA_APP_NAME" \ --resource-group "$RESOURCE_GROUP_NAME" \ --revision-suffix v2 \ - --set-env-vars \ - APP_REVISION=v2 \ - AZURE_STORAGE_CONNECTION_STRING=secretref:storage-conn \ + --set-env-vars "${REVISION_ENV_VARS[@]}" \ --only-show-errors 1>/dev/null 2>&1 if [ $? -eq 0 ]; then diff --git a/samples/container-apps-blob-storage/python/src/app.py b/samples/container-apps-blob-storage/python/src/app.py index c04db01..837ac80 100644 --- a/samples/container-apps-blob-storage/python/src/app.py +++ b/samples/container-apps-blob-storage/python/src/app.py @@ -87,6 +87,7 @@ def health(): "status": "healthy", "revision": app_revision, "storage_configured": guestbook_client is not None, + "auth_mode": guestbook_client.auth_mode if guestbook_client else None, } ), 200 diff --git a/samples/container-apps-blob-storage/python/src/blob_storage_client.py b/samples/container-apps-blob-storage/python/src/blob_storage_client.py index 3cb6b13..a61cd5e 100644 --- a/samples/container-apps-blob-storage/python/src/blob_storage_client.py +++ b/samples/container-apps-blob-storage/python/src/blob_storage_client.py @@ -19,6 +19,7 @@ ResourceModifiedError, ResourceNotFoundError, ) +from azure.identity import DefaultAzureCredential from azure.storage.blob import BlobServiceClient logger = logging.getLogger(__name__) @@ -45,9 +46,10 @@ class BlobGuestbookClient: entries.json """ - def __init__(self, connection_string: str, container_name: str): - self.blob_service = BlobServiceClient.from_connection_string(connection_string) + def __init__(self, blob_service: BlobServiceClient, container_name: str, auth_mode: str): + self.blob_service = blob_service self.container_client = self.blob_service.get_container_client(container_name) + self.auth_mode = auth_mode @classmethod def from_env(cls) -> "BlobGuestbookClient | None": @@ -55,20 +57,34 @@ def from_env(cls) -> "BlobGuestbookClient | None": Required env vars: AZURE_STORAGE_CONNECTION_STRING - Blob Storage connection string + or AZURE_STORAGE_ACCOUNT_URL - Blob endpoint, reached with DefaultAzureCredential BLOB_CONTAINER_NAME - Name of the blob container + + Optional env vars: + AZURE_CLIENT_ID - Client ID of a user-assigned managed identity """ connection_string = os.environ.get("AZURE_STORAGE_CONNECTION_STRING") + account_url = os.environ.get("AZURE_STORAGE_ACCOUNT_URL") container_name = os.environ.get("BLOB_CONTAINER_NAME") - if not connection_string or not container_name: + if not (connection_string or account_url) or not container_name: logger.warning( - "AZURE_STORAGE_CONNECTION_STRING or BLOB_CONTAINER_NAME not set. " + "AZURE_STORAGE_CONNECTION_STRING (or AZURE_STORAGE_ACCOUNT_URL) or BLOB_CONTAINER_NAME not set. " "Blob storage client not initialized." ) return None - logger.info("Initializing Blob Storage client for container: %s", container_name) - return cls(connection_string, container_name) + if connection_string: + blob_service = BlobServiceClient.from_connection_string(connection_string) + auth_mode = "connection_string" + else: + # The managed identity credential reads IDENTITY_ENDPOINT/IDENTITY_HEADER, which Container Apps + # injects only when the app has an identity; AZURE_CLIENT_ID selects a user-assigned one. + blob_service = BlobServiceClient(account_url, credential=DefaultAzureCredential()) + auth_mode = "managed_identity" + + logger.info("Initializing Blob Storage client for container: %s (auth: %s)", container_name, auth_mode) + return cls(blob_service, container_name, auth_mode) def _read_blob(self) -> tuple[list[dict[str, str]], str | None]: """Download and parse the JSON blob, together with its ETag. diff --git a/samples/container-apps-blob-storage/python/src/requirements.txt b/samples/container-apps-blob-storage/python/src/requirements.txt index 9e4913b..58f0c49 100644 --- a/samples/container-apps-blob-storage/python/src/requirements.txt +++ b/samples/container-apps-blob-storage/python/src/requirements.txt @@ -1,2 +1,3 @@ Flask==3.1.3 +azure-identity==1.25.3 azure-storage-blob==12.25.1 diff --git a/samples/container-apps-blob-storage/python/terraform/README.md b/samples/container-apps-blob-storage/python/terraform/README.md index 8b380df..954a1d1 100644 --- a/samples/container-apps-blob-storage/python/terraform/README.md +++ b/samples/container-apps-blob-storage/python/terraform/README.md @@ -48,6 +48,12 @@ cd samples/container-apps-blob-storage/python bash terraform/deploy.sh ``` +To have the app reach Blob Storage with a managed identity instead of the connection string secret, set `MANAGED_IDENTITY_TYPE` (`SystemAssigned` or `UserAssigned`). The module then creates the identity (user-assigned only), grants it the `Storage Blob Data Contributor` role on the storage account, and passes the app the blob endpoint, plus `AZURE_CLIENT_ID` for a user-assigned identity: + +```bash +MANAGED_IDENTITY_TYPE=UserAssigned bash terraform/deploy.sh +``` + ## Cleanup ```bash diff --git a/samples/container-apps-blob-storage/python/terraform/deploy.sh b/samples/container-apps-blob-storage/python/terraform/deploy.sh index 2e436c0..fff2931 100644 --- a/samples/container-apps-blob-storage/python/terraform/deploy.sh +++ b/samples/container-apps-blob-storage/python/terraform/deploy.sh @@ -6,6 +6,7 @@ SUFFIX='test' LOCATION='eastus' IMAGE_NAME='guestbook' IMAGE_TAG='v1' +MANAGED_IDENTITY_TYPE="${MANAGED_IDENTITY_TYPE:-None}" CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)" # Change the current directory to the script's directory @@ -95,6 +96,7 @@ terraform import \ -var "location=$LOCATION" \ -var "image_name=$IMAGE_NAME" \ -var "image_tag=$IMAGE_TAG" \ + -var "managed_identity_type=$MANAGED_IDENTITY_TYPE" \ azurerm_resource_group.example \ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/${RESOURCE_GROUP_NAME}" 2>/dev/null || true @@ -105,7 +107,8 @@ terraform plan -out=tfplan \ -var "suffix=$SUFFIX" \ -var "location=$LOCATION" \ -var "image_name=$IMAGE_NAME" \ - -var "image_tag=$IMAGE_TAG" + -var "image_tag=$IMAGE_TAG" \ + -var "managed_identity_type=$MANAGED_IDENTITY_TYPE" # Apply the Terraform configuration echo "Applying Terraform configuration..." @@ -123,6 +126,7 @@ ACR_NAME=$(terraform output -raw acr_name) ENVIRONMENT_NAME=$(terraform output -raw environment_name) APP_NAME=$(terraform output -raw app_name) FQDN=$(terraform output -raw fqdn) +PRINCIPAL_ID=$(terraform output -raw principal_id) echo "" echo "============================================================" @@ -134,4 +138,5 @@ echo "ACR: $ACR_NAME" echo "ACA Environment: $ENVIRONMENT_NAME" echo "Container App: $APP_NAME" echo "Ingress FQDN: $FQDN" +echo "Managed Identity: $MANAGED_IDENTITY_TYPE $PRINCIPAL_ID" echo "============================================================" diff --git a/samples/container-apps-blob-storage/python/terraform/main.tf b/samples/container-apps-blob-storage/python/terraform/main.tf index 27743f6..c7d69ab 100644 --- a/samples/container-apps-blob-storage/python/terraform/main.tf +++ b/samples/container-apps-blob-storage/python/terraform/main.tf @@ -5,6 +5,8 @@ locals { acr_name = "${var.prefix}acaacr${var.suffix}" environment_name = "${var.prefix}-aca-env-${var.suffix}" app_name = "${var.prefix}-aca-guestbook-${var.suffix}" + identity_name = "${var.prefix}-aca-identity-${var.suffix}" + storage_role_name = "Storage Blob Data Contributor" # Secret NAMES referenced from more than one block, kept in one place so the # references never drift apart. @@ -51,6 +53,29 @@ resource "azurerm_storage_container" "example" { container_access_type = "private" } +# Create the user-assigned managed identity +resource "azurerm_user_assigned_identity" "example" { + count = var.managed_identity_type == "UserAssigned" ? 1 : 0 + name = local.identity_name + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + tags = var.tags + + lifecycle { + ignore_changes = [ + tags + ] + } +} + +resource "azurerm_role_assignment" "user_assigned" { + count = var.managed_identity_type == "UserAssigned" ? 1 : 0 + scope = azurerm_storage_account.example.id + role_definition_name = local.storage_role_name + principal_id = azurerm_user_assigned_identity.example[0].principal_id + principal_type = "ServicePrincipal" +} + # Reference the pre-created ACR (created by deploy.sh before terraform apply) data "azurerm_container_registry" "example" { name = local.acr_name @@ -79,11 +104,22 @@ resource "azurerm_container_app" "example" { revision_mode = "Multiple" tags = var.tags + dynamic "identity" { + for_each = var.managed_identity_type == "None" ? [] : [var.managed_identity_type] + content { + type = identity.value + identity_ids = identity.value == "UserAssigned" ? [azurerm_user_assigned_identity.example[0].id] : null + } + } + # The storage connection string is a Container Apps secret, referenced from # the container below via secret_name. - secret { - name = local.storage_conn_secret_name - value = "DefaultEndpointsProtocol=http;AccountName=${azurerm_storage_account.example.name};AccountKey=${azurerm_storage_account.example.primary_access_key};BlobEndpoint=${azurerm_storage_account.example.primary_blob_endpoint}" + dynamic "secret" { + for_each = var.managed_identity_type == "None" ? [local.storage_conn_secret_name] : [] + content { + name = secret.value + value = "DefaultEndpointsProtocol=http;AccountName=${azurerm_storage_account.example.name};AccountKey=${azurerm_storage_account.example.primary_access_key};BlobEndpoint=${azurerm_storage_account.example.primary_blob_endpoint}" + } } secret { @@ -120,9 +156,28 @@ resource "azurerm_container_app" "example" { cpu = var.cpu_cores memory = var.memory - env { - name = "AZURE_STORAGE_CONNECTION_STRING" - secret_name = local.storage_conn_secret_name + dynamic "env" { + for_each = var.managed_identity_type == "None" ? [local.storage_conn_secret_name] : [] + content { + name = "AZURE_STORAGE_CONNECTION_STRING" + secret_name = env.value + } + } + + dynamic "env" { + for_each = var.managed_identity_type == "None" ? [] : [azurerm_storage_account.example.primary_blob_endpoint] + content { + name = "AZURE_STORAGE_ACCOUNT_URL" + value = env.value + } + } + + dynamic "env" { + for_each = var.managed_identity_type == "UserAssigned" ? [azurerm_user_assigned_identity.example[0].client_id] : [] + content { + name = "AZURE_CLIENT_ID" + value = env.value + } } env { @@ -142,9 +197,19 @@ resource "azurerm_container_app" "example" { } } + depends_on = [azurerm_role_assignment.user_assigned] + lifecycle { ignore_changes = [ tags ] } } + +resource "azurerm_role_assignment" "system_assigned" { + count = var.managed_identity_type == "SystemAssigned" ? 1 : 0 + scope = azurerm_storage_account.example.id + role_definition_name = local.storage_role_name + principal_id = azurerm_container_app.example.identity[0].principal_id + principal_type = "ServicePrincipal" +} diff --git a/samples/container-apps-blob-storage/python/terraform/outputs.tf b/samples/container-apps-blob-storage/python/terraform/outputs.tf index 0b5557e..e849072 100644 --- a/samples/container-apps-blob-storage/python/terraform/outputs.tf +++ b/samples/container-apps-blob-storage/python/terraform/outputs.tf @@ -29,3 +29,7 @@ output "fqdn" { output "latest_revision_name" { value = azurerm_container_app.example.latest_revision_name } + +output "principal_id" { + value = var.managed_identity_type == "None" ? "" : (var.managed_identity_type == "SystemAssigned" ? azurerm_container_app.example.identity[0].principal_id : azurerm_user_assigned_identity.example[0].principal_id) +} diff --git a/samples/container-apps-blob-storage/python/terraform/variables.tf b/samples/container-apps-blob-storage/python/terraform/variables.tf index c417a31..7d9b96d 100644 --- a/samples/container-apps-blob-storage/python/terraform/variables.tf +++ b/samples/container-apps-blob-storage/python/terraform/variables.tf @@ -102,6 +102,17 @@ variable "max_replicas" { default = 3 } +variable "managed_identity_type" { + description = "(Optional) Specifies how the app authenticates to Blob Storage: None uses a connection string secret, SystemAssigned or UserAssigned a managed identity." + type = string + default = "None" + + validation { + condition = contains(["None", "SystemAssigned", "UserAssigned"], var.managed_identity_type) + error_message = "The managed_identity_type must be one of: None, SystemAssigned, UserAssigned." + } +} + variable "tags" { description = "(Optional) Specifies the tags to be applied to the resources." type = map(string)