Skip to content

Commit 366ecf5

Browse files
robgrameCopilot
andcommitted
fix(azure): end-to-end deploy verified — KV purge-protection conditional, AppConfig local-auth pragmatic, ARM output-name normalization
- infra/modules/keyvault.bicep: enablePurgeProtection now parameterized (default true, null when false to avoid setting the irreversible flag). - infra/main.bicep: pass enablePurgeProtection=false for environmentName starting with 'dev' so iterative dev deploys can hard-delete the vault. - infra/modules/appconfig.bicep: keep disableLocalAuth=false during initial provision (MI workloads still use Entra RBAC). Pass-through auth was blocked by tenant Conditional Access (Forbidden on data-plane writes even with App Configuration Data Owner). Documented inline. - Deploy-Azure.ps1: query each ARM output individually with the ARM-normalized lowercase-prefix-but-keep-last-upper name (AZURE_LOCATION -> azurE_LOCATION, FUNCTIONS_URI -> functionS_URI, etc.). PSObject-based parsing was unreliable inside the script's pipeline context. - docs/AZURE-DEPLOY.md: troubleshooting table updated with the 4 issues discovered during this run. End-to-end verified: 33 resources Provisioned in italynorth, Function App + Web App returning HTTP 200, App Configuration seeded with 9 keys + 1 feature flag, KV secret 'sql-connection-string' written. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 834b57e commit 366ecf5

5 files changed

Lines changed: 69 additions & 18 deletions

File tree

Deploy-Azure.ps1

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -201,29 +201,56 @@ if ($SkipDeploy) {
201201

202202
Write-Step "6/8 Deploying (az deployment sub create)"
203203
Write-Host " Deployment name: $DeploymentName"
204-
$deploy = az deployment sub create `
204+
az deployment sub create `
205205
--name $DeploymentName `
206206
--location $Location `
207207
--template-file $BicepEntry `
208208
--parameters "@$paramFile" `
209-
--output json | ConvertFrom-Json
210-
211-
if ($LASTEXITCODE -ne 0 -or $deploy.properties.provisioningState -ne 'Succeeded') {
212-
throw "Deployment failed (state: $($deploy.properties.provisioningState))."
213-
}
214-
215-
Write-Host " Deployment state: $($deploy.properties.provisioningState)"
216-
217-
$outputs = $deploy.properties.outputs
209+
--output none
210+
if ($LASTEXITCODE -ne 0) { throw "az deployment sub create exited with $LASTEXITCODE" }
211+
212+
# Fetch deployment status (just to confirm it succeeded; outputs are read individually below).
213+
$provState = az deployment sub show --name $DeploymentName --query "properties.provisioningState" -o tsv 2>$null
214+
if ($LASTEXITCODE -ne 0) { throw "az deployment sub show failed for $DeploymentName" }
215+
if ($provState -ne 'Succeeded') { throw "Deployment failed (state: $provState)." }
216+
Write-Host " Deployment state: $provState"
217+
218+
# Read each declared output via az --query to bypass JSON enumeration entirely.
219+
# (Earlier attempts that parsed properties.outputs in this script context
220+
# consistently saw zero keys despite the file on disk being valid — likely a
221+
# PowerShell pipeline-context interaction. The direct --query path is robust.)
222+
$outputNames = @(
223+
'AZURE_LOCATION', 'AZURE_TENANT_ID', 'AZURE_RESOURCE_GROUP',
224+
'AZURE_APP_CONFIG_ENDPOINT', 'AZURE_KEY_VAULT_NAME', 'AZURE_KEY_VAULT_URI',
225+
'AZURE_SERVICE_BUS_FQDN', 'AZURE_SQL_SERVER_FQDN', 'AZURE_SQL_DATABASE_NAME',
226+
'WEB_URI', 'FUNCTIONS_URI', 'WEB_IDENTITY_CLIENT_ID', 'FUNC_IDENTITY_CLIENT_ID'
227+
)
218228
$script:Outputs = @{}
219-
foreach ($prop in $outputs.PSObject.Properties) {
220-
$script:Outputs[$prop.Name] = $prop.Value.value
229+
foreach ($name in $outputNames) {
230+
# ARM normalizes output names by lower-casing every leading uppercase letter
231+
# EXCEPT the last one before a lowercase/underscore boundary. So:
232+
# AZURE_LOCATION -> azurE_LOCATION
233+
# FUNCTIONS_URI -> functionS_URI
234+
# WEB_URI -> weB_URI
235+
# Build the ARM-side name by finding the run of leading uppercase letters,
236+
# lower-casing all but the LAST one, then concatenating the remainder.
237+
$i = 0
238+
while ($i -lt $name.Length -and [char]::IsUpper($name[$i])) { $i++ }
239+
if ($i -gt 1) {
240+
# Lower-case chars 0..($i-2), keep char ($i-1) and onwards.
241+
$armName = $name.Substring(0, $i - 1).ToLower() + $name.Substring($i - 1)
242+
}
243+
else {
244+
$armName = $name.Substring(0, 1).ToLower() + $name.Substring(1)
245+
}
246+
$val = az deployment sub show --name $DeploymentName --query "properties.outputs.$armName.value" -o tsv 2>$null
247+
if (-not [string]::IsNullOrWhiteSpace($val)) { $script:Outputs[$name] = $val }
221248
}
222249

223250
Write-Host ""
224-
Write-Host "Deployment outputs:" -ForegroundColor Green
225-
$script:Outputs.GetEnumerator() | Sort-Object Name | ForEach-Object {
226-
Write-Host (" {0,-32} = {1}" -f $_.Key, $_.Value)
251+
Write-Host "Deployment outputs ($($script:Outputs.Count)):" -ForegroundColor Green
252+
foreach ($key in ($script:Outputs.Keys | Sort-Object)) {
253+
Write-Host (" {0,-32} = {1}" -f $key, $script:Outputs[$key])
227254
}
228255

229256
# --- 7. Optional: populate SQL connection string in Key Vault ----------------

docs/AZURE-DEPLOY.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,17 @@ Entrambi i percorsi sono supportati. **`azure.yaml` è mantenuto** per chi prefe
115115
| Errore | Causa probabile | Soluzione |
116116
|--------|-----------------|-----------|
117117
| `Authorization_RequestDenied` su `signed-in-user` | L'utente non ha permessi per leggere il suo profilo Graph | `az ad signed-in-user show` deve funzionare; in alternativa, passare `-SqlAdminPrincipalId <object-id>` esplicitamente |
118-
| `VaultAlreadyExists` su redeploy | Il nome del KV in soft-delete | Esegui `az keyvault purge --name <kv-name> --location <location>` |
118+
| `VaultAlreadyExists` su redeploy | Il nome del KV in soft-delete | Esegui `az keyvault purge --name <kv-name> --location <location>`. Se la subscription **vieta DeletedVaultPurge** (tipico delle MCAPS sandbox), cambia `-EnvironmentName` per ottenere un `resourceToken` nuovo (es. `dev``dev2`) |
119119
| `RoleAssignmentExists` | Re-deploy dopo cambio di tenant | Cancella manualmente i role assignment in conflitto, oppure cambia il `resourceToken` (rinominare environment) |
120+
| App Configuration `keyValues` falliscono con `Forbidden` / *"local authentication is disabled"* | `disableLocalAuth=true` impedisce ad ARM di scrivere le seed key, e su molti tenant la modalità *Pass-through* è bloccata da Conditional Access | Nel Bicep tenere `disableLocalAuth: false` durante il provision iniziale (le MI usano comunque RBAC Entra). Disabilitare le local key in un secondo step post-deploy con `az appconfig update --disable-local-auth true` quando le seed sono complete |
121+
| Key Vault `EnablePurgeProtectionRequired` | `enablePurgeProtection: true` è **irreversibile** una volta impostato | Per ambienti dev, in `main.bicep` la flag viene passata come `!startsWith(environmentName, 'dev')` → off per `dev*`, on per `prod*`. NON metterla a `true` finché non sei pronto a vivere con la protezione |
120122
| `BadRequest` su SQL `azureADOnlyAuthentication: true` | L'utente AAD admin non è risolvibile | Verifica che `-SqlAdminPrincipalId` punti a un utente o gruppo Entra ID esistente nel **medesimo tenant** della subscription |
121123
| What-if mostra molti `Unsupported (Diagnostics)` | Normale: role assignment dipende da MI principalId calcolato a runtime | Ignorabile; il deploy reale risolve correttamente |
124+
| `Function App zip deploy failed.` con `--resource-group` vuoto | Bug nel parsing degli output ARM dello script (storico) | Risolto in commit successivo: ARM normalizza i nomi output (es. `AZURE_RESOURCE_GROUP``azurE_RESOURCE_GROUP`); lo script ora ricostruisce il nome ARM esatto prima di interrogare ogni output via `az deployment sub show --query` |
125+
126+
### Idempotenza e ri-deploy
127+
128+
- Lo script è completamente idempotente: rilanciarlo sullo stesso `EnvironmentName` aggiorna (non ricrea) le risorse.
129+
- Per ripartire da zero su un ambiente, fai prima `az group delete -n rg-blkmon-<env>-<location> --yes` e poi rilancia.
130+
- **Attenzione**: KV e App Configuration entrano in soft-delete; se nel tenant non puoi `purge`, cambia `-EnvironmentName` per ottenere un nuovo `resourceToken`.
131+

infra/main.bicep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ module keyvault 'modules/keyvault.bicep' = {
7373
identity.outputs.webIdentityPrincipalId
7474
identity.outputs.funcIdentityPrincipalId
7575
]
76+
enablePurgeProtection: !startsWith(environmentName, 'dev')
7677
}
7778
}
7879

infra/modules/appconfig.bicep

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,24 @@ resource appConfig 'Microsoft.AppConfiguration/configurationStores@2024-05-01' =
2626
name: 'standard'
2727
}
2828
properties: {
29-
disableLocalAuth: true
29+
// NOTE: local auth is intentionally LEFT ENABLED for the initial deploy.
30+
// Rationale:
31+
// - ARM-driven keyValues seeding (the resources below) uses the store's local access keys.
32+
// - Pass-through + caller Entra ID was attempted but blocked by tenant policy (HTTP 403
33+
// on data-plane writes even with App Configuration Data Owner role assigned).
34+
// - Runtime workloads (Functions, Web) still authenticate via Managed Identity + Entra RBAC
35+
// (App Configuration Data Reader) — they DO NOT use the access keys.
36+
// - For hardened production: a post-deploy step can set disableLocalAuth=true via
37+
// `az appconfig update --disable-local-auth true` once seeding is complete.
38+
disableLocalAuth: false
3039
enablePurgeProtection: false
3140
publicNetworkAccess: 'Enabled'
3241
softDeleteRetentionInDays: 7
3342
}
3443
}
3544

45+
// Grant the deployer App Configuration Data Owner so that an operator can manage keyValues
46+
// post-deploy via Entra ID (in tenants where data-plane Entra auth is permitted).
3647
resource ownerRa 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(deployerPrincipalId)) {
3748
scope: appConfig
3849
name: guid(appConfig.id, deployerPrincipalId, dataOwnerRoleId)

infra/modules/keyvault.bicep

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ param tenantId string
66
param deployerPrincipalId string
77
@description('Object IDs of workload identities (web, func) that need wrapKey/unwrapKey for the KEK and Get for secrets resolved via App Configuration KV-references.')
88
param consumerPrincipalIds array
9+
@description('Enable purge protection. Once enabled, CANNOT be disabled. Keep true for prod, set false for ephemeral dev environments.')
10+
param enablePurgeProtection bool = true
911

1012
var kvAdminRoleId = '00482a5a-887f-4fb3-b363-3b7fe8e74483' // Key Vault Administrator
1113
var kvCryptoUserRoleId = '12338af0-0e69-4776-bea7-57ae8d297424' // Key Vault Crypto User
@@ -24,7 +26,7 @@ resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' = {
2426
enableRbacAuthorization: true
2527
enableSoftDelete: true
2628
softDeleteRetentionInDays: 7
27-
enablePurgeProtection: true
29+
enablePurgeProtection: enablePurgeProtection ? true : null
2830
publicNetworkAccess: 'Enabled'
2931
networkAcls: {
3032
bypass: 'AzureServices'

0 commit comments

Comments
 (0)