From 8858d6367a8dff9a5d532592f91ba016112aa762 Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Wed, 16 Sep 2026 12:06:48 +0200 Subject: [PATCH 1/4] Add Azure App Configuration service documentation Document the Azure App Configuration emulator, which had no article despite broad support in the emulator. Commit 7579b0cd8e in localstack-pro closed the remaining gaps, adding snapshots, point-in-time reads, data-plane RBAC, private endpoint connections and serving replica endpoints, none of which were documented. The article covers the store lifecycle, access keys, key-values with labels and revisions, locks, point-in-time reads, snapshots, Key Vault references, RBAC with a user-assigned managed identity, and the soft-delete lifecycle. Features and Limitations are drawn from the emulator source and its parity tests rather than from the coverage data alone. Every command was validated against both the emulator and real Azure using md/APP_CONFIG_DOCS_VALIDATION.sh, and every output block is captured from a real emulator run. Behavioral parity was exact on all documented features; the divergences are environmental only (hostnames, identifier formats, timestamp precision). Also add App Configuration to the list of data planes covered by LS_AZURE_ENFORCE_RBAC in the role assignment article, which the same commit made accurate. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/azure/services/app-configuration.mdx | 828 ++++++++++++++++++ .../docs/azure/services/role-assignment.mdx | 2 +- 2 files changed, 829 insertions(+), 1 deletion(-) create mode 100644 src/content/docs/azure/services/app-configuration.mdx diff --git a/src/content/docs/azure/services/app-configuration.mdx b/src/content/docs/azure/services/app-configuration.mdx new file mode 100644 index 00000000..8a43e30d --- /dev/null +++ b/src/content/docs/azure/services/app-configuration.mdx @@ -0,0 +1,828 @@ +--- +title: "App Configuration" +description: Get started with Azure App Configuration on LocalStack +template: doc +--- + +import AzureFeatureCoverage from "../../../../components/feature-coverage/AzureFeatureCoverage"; + +## Introduction + +Azure App Configuration is a managed service for centralizing application settings and feature flags. +It stores configuration as key-values that can be filtered by label, versioned through revisions, frozen into point-in-time snapshots, and referenced against secrets held in Azure Key Vault. +For more information, see [What is Azure App Configuration](https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview). + +LocalStack for Azure provides a local environment for building and testing applications that make use of Azure App Configuration. +The supported APIs are available on our [API Coverage section](#api-coverage), which provides information on the extent of App Configuration's integration with LocalStack. + +## Getting started + +This guide is designed for users new to App Configuration and assumes basic knowledge of the Azure CLI and our `lstk az` proxy. + +Launch LocalStack using your preferred method. For more information, see [Introduction to LocalStack for Azure](/azure/getting-started/). Once the container is running, enable Azure CLI interception by running: + +```bash +lstk az start-interception +``` + +This command points the `az` CLI away from the public Azure management REST API and toward the LocalStack for Azure emulator API. +To revert this configuration, run: + +```bash +lstk az stop-interception +``` + +This reconfigures the `az` CLI to send commands to the official Azure management REST API. + +### Create a resource group + +Create a resource group to hold all resources created in this guide: + +```bash +az group create \ + --name rg-appconfig-demo \ + --location westeurope +``` + +```bash title="Output" +{ + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo", + "location": "westeurope", + "name": "rg-appconfig-demo", + "properties": { + "provisioningState": "Succeeded" + }, + "type": "Microsoft.Resources/resourceGroups" +} +``` + +### Create an App Configuration store + +Create a configuration store. The store name is a DNS label: alphanumerics and hyphens only, between 5 and 50 characters, and globally unique: + +```bash +az appconfig create \ + --name appconfig-demo-localstack \ + --resource-group rg-appconfig-demo \ + --location westeurope \ + --sku Standard \ + --retention-days 7 \ + --tags environment=demo +``` + +```bash title="Output" +{ + "creationDate": "2026-09-16T09:42:23.149075+00:00", + "defaultKeyValueRevisionRetentionPeriodInSeconds": 2592000, + "disableLocalAuth": false, + "enablePurgeProtection": false, + "endpoint": "https://appconfig-demo-localstack.azure.localhost.localstack.cloud:4566", + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack", + "location": "westeurope", + "name": "appconfig-demo-localstack", + "provisioningState": "Succeeded", + "resourceGroup": "rg-appconfig-demo", + "sku": { + "name": "standard" + }, + "softDeleteRetentionInDays": 7, + "tags": { + "environment": "demo" + }, + "type": "Microsoft.AppConfiguration/configurationStores" + ... +} +``` + +The `endpoint` property is the data-plane address that SDKs and configuration providers use verbatim. +Clients should read it back from the service rather than build it from the store name. + +:::note +The SKU determines whether the store can be restored after deletion. `Free` and `Developer` stores are deleted permanently, while `Standard` and `Premium` stores are soft-deleted and can be recovered. This guide uses `Standard` so that the [soft-delete lifecycle](#delete-and-verify) works as documented. +::: + +### Show and list stores + +Retrieve a single store: + +```bash +az appconfig show \ + --name appconfig-demo-localstack \ + --resource-group rg-appconfig-demo \ + --query "{Name:name, Sku:sku.name, Endpoint:endpoint, Retention:softDeleteRetentionInDays}" +``` + +```bash title="Output" +{ + "Endpoint": "https://appconfig-demo-localstack.azure.localhost.localstack.cloud:4566", + "Name": "appconfig-demo-localstack", + "Retention": 7, + "Sku": "standard" +} +``` + +List every store in a resource group: + +```bash +az appconfig list \ + --resource-group rg-appconfig-demo \ + --query "[].{Name:name, Location:location, Sku:sku.name, State:provisioningState}" \ + --output table +``` + +```bash title="Output" +Name Location Sku State +------------------------- ---------- -------- --------- +appconfig-demo-localstack westeurope standard Succeeded +``` + +### Update a store + +Update the store to change its tags: + +```bash +az appconfig update \ + --name appconfig-demo-localstack \ + --resource-group rg-appconfig-demo \ + --tags environment=demo owner=platform \ + --query "tags" +``` + +```bash title="Output" +{ + "environment": "demo", + "owner": "platform" +} +``` + +:::note +`az appconfig update --tags` replaces the entire tag collection rather than merging into it. Any tag omitted from the command is removed from the store. +::: + +### Manage access keys + +Every store is created with four access keys: a primary and secondary pair with read and write access, and a read-only pair. +List them: + +```bash +az appconfig credential list \ + --name appconfig-demo-localstack \ + --resource-group rg-appconfig-demo \ + --query "[].{Name:name, Id:id, ReadOnly:readOnly}" \ + --output table +``` + +```bash title="Output" +Name Id ReadOnly +------------------- ---------------------- ---------- +Primary -oTv1eiT12tN8wwGvOx_LQ False +Secondary 9JmbCnOYmau91__KDrKUfQ False +Primary Read Only QXx2QCncjcS5UQNXw7jEkg True +Secondary Read Only XQeRDcSd_vza4cUX7epAoA True +``` + +Each key carries a ready-to-use connection string in the form `Endpoint=;Id=;Secret=`. +Regenerate a key by its identifier: + +```bash +az appconfig credential regenerate \ + --name appconfig-demo-localstack \ + --resource-group rg-appconfig-demo \ + --id=9JmbCnOYmau91__KDrKUfQ \ + --query "{Name:name, Id:id, ReadOnly:readOnly}" +``` + +```bash title="Output" +{ + "Id": "xQ8DD_I7dDlp1dbditHy1w", + "Name": "Secondary", + "ReadOnly": false +} +``` + +Regenerating a key issues a new identifier and a new secret for that slot. The other three keys are unaffected, so a running application using the primary key keeps working. + +:::note +Access key identifiers are base64url encoded and can begin with a hyphen, as `Primary` does above. Pass them with the equals form, `--id=`, so that the Azure CLI does not mistake the value for the start of another argument. +::: + +### Set and read key-values + +A key-value is identified by its key and an optional label. The same key can carry a different value under each label, which is how per-environment configuration is modelled: + +```bash +az appconfig kv set \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --value blue \ + --content-type "text/plain" \ + --tags tier=frontend \ + --yes +``` + +```bash title="Output" +{ + "contentType": "text/plain", + "etag": "1ee85f14edb04fddbeca40ecb552678a", + "key": "app/settings/color", + "label": "production", + "lastModified": "2026-09-16T09:42:27.265085+00:00", + "locked": false, + "tags": { + "tier": "frontend" + }, + "value": "blue" +} +``` + +Read a single key-value back: + +```bash +az appconfig kv show \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production +``` + +```bash title="Output" +{ + "contentType": "text/plain", + "etag": "1ee85f14edb04fddbeca40ecb552678a", + "key": "app/settings/color", + "label": "production", + "lastModified": "2026-09-16T09:42:27.265085+00:00", + "locked": false, + "tags": { + "tier": "frontend" + }, + "value": "blue" +} +``` + +List key-values, filtering by a key prefix. Both `--key` and `--label` accept a star as a wildcard, and `--label` also accepts a comma-separated list: + +```bash +az appconfig kv list \ + --name appconfig-demo-localstack \ + --key "app/settings/*" \ + --label production \ + --all \ + --output table +``` + +```bash title="Output" +CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED +-------------- ------------------ ------- -------------------- -------------------- ---------- -------- +text/plain app/settings/color blue 2026-09-16T09:42:27Z {'tier': 'frontend'} production False + app/settings/size large 2026-09-16T09:42:27Z {} production False +``` + +Every write creates a new revision. After updating `app/settings/color` to `red`, both versions are visible, newest first: + +```bash +az appconfig revision list \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --all \ + --output table +``` + +```bash title="Output" +CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED +-------------- ------------------ ------- -------------------- -------------------- ---------- -------- +text/plain app/settings/color red 2026-09-16T09:42:29Z {'tier': 'frontend'} production False +text/plain app/settings/color blue 2026-09-16T09:42:27Z {'tier': 'frontend'} production False +``` + +### Lock and unlock a key-value + +Locking a key-value makes it read-only, which protects a setting from accidental change: + +```bash +az appconfig kv lock \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --yes \ + --query "locked" +``` + +```bash title="Output" +true +``` + +A write to a locked key-value is rejected: + +```bash +az appconfig kv set \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --value green \ + --yes +``` + +```bash title="Output" +ERROR: Failed to update read only key-value. Unlock the key-value before updating it. +``` + +Unlock it to allow writes again: + +```bash +az appconfig kv unlock \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --yes \ + --query "locked" +``` + +```bash title="Output" +false +``` + +### Read key-values as of a past instant + +Because every write is retained as a revision, the store can be read as it stood at an earlier moment. +Capture a timestamp from an existing key-value, then change it: + +```bash +az appconfig kv show \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --query "lastModified" \ + --output tsv +``` + +```bash title="Output" +2026-09-16T09:42:29.201564+00:00 +``` + +Change the value, so there is something to look back past: + +```bash +az appconfig kv set \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --value amber \ + --yes \ + --query "value" +``` + +```bash title="Output" +"amber" +``` + +Pass the captured instant to `--datetime` to read the store as it stood before that change: + +```bash +az appconfig kv list \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --datetime "2026-09-16T09:42:29Z" \ + --output table +``` + +```bash title="Output" +CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED +-------------- ------------------ ------- -------------------- -------------------- ---------- -------- +text/plain app/settings/color red 2026-09-16T09:42:29Z {'tier': 'frontend'} production False +``` + +The same listing without `--datetime` returns the current value: + +```bash +az appconfig kv list \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --output table +``` + +```bash title="Output" +CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED +-------------- ------------------ ------- -------------------- -------------------- ---------- -------- +text/plain app/settings/color amber 2026-09-16T09:42:32Z {'tier': 'frontend'} production False +``` + +:::note +`--datetime` accepts only whole seconds, in the format `YYYY-MM-DDThh:mm:ss` with an optional `Z` or timezone offset. A whole-second value covers the whole of that second, so a cutoff copied from a key-value's own `lastModified` returns that write rather than the one before it. +::: + +### Create and manage snapshots + +A snapshot freezes the key-values matching a filter at the moment it is created, giving an application a configuration set that cannot shift underneath it: + +```bash +az appconfig snapshot create \ + --name appconfig-demo-localstack \ + --snapshot-name baseline \ + --filters '{"key":"app/settings/*","label":"production"}' +``` + +```bash title="Output" +{ + "compositionType": "key", + "created": "2026-09-16T09:42:33.673308+00:00", + "etag": "af4015401768d2f925421b7ddc19cc31", + "expires": null, + "filters": [ + { + "key": "app/settings/*", + "label": "production" + } + ], + "itemsCount": 2, + "name": "baseline", + "retentionPeriod": 2592000, + "size": 1000, + "status": "ready", + "tags": {} +} +``` + +:::note +The label in a snapshot filter is matched exactly, and omitting it selects the *null* label rather than every label. This is the opposite of `az appconfig kv list`, where an omitted label means any label. A wildcard label is rejected with `Unexpected label which could match multiple values for a key`. A filter that matches nothing still produces a `ready` snapshot, so check `itemsCount` to confirm the filter was correct. +::: + +List the snapshots on a store: + +```bash +az appconfig snapshot list \ + --name appconfig-demo-localstack \ + --query "[].{Name:name, Status:status, Items:itemsCount, Created:created}" \ + --output table +``` + +```bash title="Output" +Name Status Items Created +-------- -------- ------- -------------------------------- +baseline ready 2 2026-09-16T09:42:33.673308+00:00 +``` + +A snapshot is materialized once, when it is created. Changing a key-value afterwards does not affect it, so listing through the snapshot still returns the captured values: + +```bash +az appconfig kv list \ + --name appconfig-demo-localstack \ + --snapshot baseline \ + --output table +``` + +```bash title="Output" +CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED +-------------- ------------------ ------- -------------------- -------------------- ---------- -------- +text/plain app/settings/color amber 2026-09-16T09:42:32Z {'tier': 'frontend'} production False + app/settings/size large 2026-09-16T09:42:27Z {} production False +``` + +Archive a snapshot to mark it for expiry, and recover it to cancel that: + +```bash +az appconfig snapshot archive \ + --name appconfig-demo-localstack \ + --snapshot-name baseline \ + --query "{Status:status, Expires:expires}" +``` + +```bash title="Output" +{ + "Expires": "2026-10-16T09:42:36.920469+00:00", + "Status": "archived" +} +``` + +```bash +az appconfig snapshot recover \ + --name appconfig-demo-localstack \ + --snapshot-name baseline \ + --query "{Status:status, Expires:expires}" +``` + +```bash title="Output" +{ + "Expires": null, + "Status": "ready" +} +``` + +An archived snapshot still serves its contents. Archiving starts the retention clock; recovering stops it. + +### Reference a Key Vault secret + +Rather than storing a secret in App Configuration, store a reference to it in Key Vault. +Create a vault that uses Azure RBAC, which is the permission model the `Key Vault Secrets User` role requires: + +```bash +az keyvault create \ + --name kv-appconfig-demo \ + --resource-group rg-appconfig-demo \ + --location westeurope \ + --enable-rbac-authorization true \ + --retention-days 7 \ + --query "{Name:name, Uri:properties.vaultUri, Rbac:properties.enableRbacAuthorization}" +``` + +```bash title="Output" +{ + "Name": "kv-appconfig-demo", + "Rbac": true, + "Uri": "https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566" +} +``` + +Writing a secret to an RBAC-enabled vault requires the `Key Vault Secrets Officer` role on the vault. +Grant it to the principal you are signed in as, then write the secret: + +```bash +az keyvault secret set \ + --vault-name kv-appconfig-demo \ + --name db-password \ + --value "P@ssw0rd-from-key-vault" \ + --query "id" \ + --output tsv +``` + +```bash title="Output" +https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/05d54ad451f04a36b4e4ef01cee1481a +``` + +Create the reference using that secret identifier: + +```bash +az appconfig kv set-keyvault \ + --name appconfig-demo-localstack \ + --key "app/secrets/db-password" \ + --label production \ + --secret-identifier "https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/05d54ad451f04a36b4e4ef01cee1481a" \ + --yes +``` + +```bash title="Output" +{ + "contentType": "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", + "etag": "6d4adda466f6431186de555bf6bb4b37", + "key": "app/secrets/db-password", + "label": "production", + "lastModified": "2026-09-16T09:42:41.763599+00:00", + "locked": false, + "tags": {}, + "value": "{\"uri\": \"https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/05d54ad451f04a36b4e4ef01cee1481a\"}" +} +``` + +A Key Vault reference is an ordinary key-value whose content type is `application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8` and whose value is a JSON document holding the secret identifier. +Reading it back plainly returns that identifier, not the secret. +Pass `--resolve-keyvault` to have the CLI fetch the secret from Key Vault with your credentials: + +```bash +az appconfig kv list \ + --name appconfig-demo-localstack \ + --key "app/secrets/db-password" \ + --label production \ + --resolve-keyvault \ + --all \ + --output table +``` + +```bash title="Output" +CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED +------------------------------------------------------------------ ----------------------- ----------------------- -------------------- ------ ---------- -------- +application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8 app/secrets/db-password P@ssw0rd-from-key-vault 2026-09-16T09:42:41Z {} production False +``` + +:::note +Resolution happens in the client, not in the service, so the caller needs read access to the secret in Key Vault as well as to the store. Do not combine `--resolve-keyvault` with `--fields`: `--fields` makes the CLI request only the named fields from the service, and it then cannot build a reference whose value was not returned. Use `--query` to shape the output instead. A secret identifier that includes a version pins the reference to that version, while one without a version always follows the latest. +::: + +### Control data-plane access with RBAC + +An application should read configuration with its own identity rather than an access key. +Create a user-assigned managed identity: + +```bash +az identity create \ + --name id-appconfig-demo \ + --resource-group rg-appconfig-demo \ + --location westeurope \ + --query "{Name:name, ClientId:clientId, PrincipalId:principalId}" +``` + +```bash title="Output" +{ + "ClientId": "ead6c4b4-334e-4f52-bc05-f093c5dff112", + "Name": "id-appconfig-demo", + "PrincipalId": "0ea49181-12cd-44b1-93ae-82e3ccd1271b" +} +``` + +Grant it the `App Configuration Data Reader` role at the scope of the store. The assignment targets the identity's principal ID, not its client ID: + +```bash +az role assignment create \ + --assignee-object-id 0ea49181-12cd-44b1-93ae-82e3ccd1271b \ + --assignee-principal-type ServicePrincipal \ + --role "App Configuration Data Reader" \ + --scope "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack" +``` + +Verify the assignment: + +```bash +az role assignment list \ + --assignee 0ea49181-12cd-44b1-93ae-82e3ccd1271b \ + --scope "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack" \ + --query "[].{Role:roleDefinitionName, PrincipalType:principalType}" \ + --output table +``` + +```bash title="Output" +Role PrincipalType +----------------------------- ---------------- +App Configuration Data Reader ServicePrincipal +``` + +An application running in Azure obtains a token for this identity from the instance metadata service, and the token carries the identity's principal ID, which is what role assignments are evaluated against. + +By default LocalStack stores role assignments without enforcing them, so every data-plane request succeeds. +Start the emulator with `LS_AZURE_ENFORCE_RBAC` set to `1` to turn enforcement on: + +```bash +IMAGE_NAME=localstack/localstack-azure localstack start -d -e LS_AZURE_ENFORCE_RBAC=1 +``` + +To observe enforcement from the command line, sign in as a service principal that holds no administrative role, because the Azure CLI cannot acquire a managed identity token outside Azure compute: + +```bash +az login --service-principal -u appcs-workload -p any-pass --tenant any-tenant +``` + +Read a key-value through the data-plane endpoint with `--auth-mode login`. A caller holding no data role is refused: + +```bash +az appconfig kv show \ + --endpoint https://appconfig-demo-localstack.azure.localhost.localstack.cloud:4566 \ + --key "app/settings/color" \ + --auth-mode login +``` + +```bash title="Output" +ERROR: Failed to retrieve key-values from config store. Operation returned an invalid status 'FORBIDDEN' +Content: {"type": "https://azconfig.io/errors/forbidden", "title": "Forbidden.", "detail": "The principal 'd8acaaa7-8ac1-4a65-b8bd-c23550d4708b' does not have the required permission 'Microsoft.AppConfiguration/configurationStores/keyValues/read' on '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack'.", "status": 403} +``` + +The denial names the principal, the permission it lacked, and the scope it was evaluated at. +After assigning `App Configuration Data Reader` to that principal, the same read succeeds, while a write is still refused because the role grants no write permission: + +```bash +az appconfig kv set \ + --endpoint https://appconfig-demo-localstack.azure.localhost.localstack.cloud:4566 \ + --key "app/settings/color" \ + --value red \ + --auth-mode login \ + --yes +``` + +```bash title="Output" +ERROR: Failed to set the key-value due to an exception: Operation returned an invalid status 'FORBIDDEN' +Content: {"type": "https://azconfig.io/errors/forbidden", "title": "Forbidden.", "detail": "The principal 'd8acaaa7-8ac1-4a65-b8bd-c23550d4708b' does not have the required permission 'Microsoft.AppConfiguration/configurationStores/keyValues/write' on '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack'.", "status": 403} +``` + +Assigning `App Configuration Data Owner` instead allows both reads and writes. +A managed identity is evaluated in exactly the same way, against the principal ID its token carries. + +:::note +Address the data plane with `--endpoint` rather than `--name` when using `--auth-mode login`. With `--name`, the CLI first resolves the store through Azure Resource Manager, which requires the separate control-plane permission `Microsoft.AppConfiguration/configurationStores/read`. Azure behaves the same way. Note also that App Configuration has no `enableRbacAuthorization` switch: Microsoft Entra data-plane RBAC is always active on a store, and only enforcement inside the emulator is opt-in. For more information, see [Role Assignment: Enabling RBAC enforcement](/azure/services/role-assignment/#enabling-rbac-enforcement). +::: + +Access keys are not affected by role assignments. A client authenticating with a connection string is authenticated by the key it signed with and is never evaluated against RBAC, exactly as in Azure. + +### Delete and verify + +Delete an individual key-value: + +```bash +az appconfig kv delete \ + --name appconfig-demo-localstack \ + --key "app/settings/size" \ + --label production \ + --yes \ + --query "[].key" +``` + +```bash title="Output" +[ + "app/settings/size" +] +``` + +Delete the store. On the `Standard` tier this is a soft delete: + +```bash +az appconfig delete \ + --name appconfig-demo-localstack \ + --resource-group rg-appconfig-demo \ + --yes +``` + +The store no longer appears in `az appconfig list`, but it is retained until its scheduled purge date: + +```bash +az appconfig show-deleted --name appconfig-demo-localstack +``` + +```bash title="Output" +{ + "configurationStoreId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack", + "deletionDate": "2026-09-16T09:42:47.587008+00:00", + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.AppConfiguration/locations/westeurope/deletedConfigurationStores/appconfig-demo-localstack", + "location": "westeurope", + "name": "appconfig-demo-localstack", + "purgeProtectionEnabled": false, + "scheduledPurgeDate": "2026-09-23T09:42:47.587008+00:00", + "type": "Microsoft.AppConfiguration/deletedConfigurationStores" + ... +} +``` + +Restore it with `az appconfig recover`, which takes no resource group because a deleted store is addressed at subscription scope: + +```bash +az appconfig recover --name appconfig-demo-localstack --yes +``` + +To remove a store permanently, delete it and then purge it. A soft-deleted store keeps its globally unique name reserved, so purging is what allows the same name to be reused: + +```bash +az appconfig delete \ + --name appconfig-demo-localstack \ + --resource-group rg-appconfig-demo \ + --yes + +az appconfig purge --name appconfig-demo-localstack --yes +``` + +Finally, remove the resource group and confirm it is gone: + +```bash +az group delete --name rg-appconfig-demo --yes + +az group exists --name rg-appconfig-demo +``` + +```bash title="Output" +false +``` + +## Features + +The App Configuration emulator supports the following features: + +- **Configuration stores**: Create, get, list by subscription and by resource group, update, and delete stores, across the `Free`, `Developer`, `Standard`, and `Premium` SKUs. +- **Soft delete and purge**: The full lifecycle of deleting, listing and showing deleted stores, recovering them, and purging them, including purge protection and a configurable retention period. +- **Access keys**: List the four access keys of a store, regenerate any of them individually, and use the generated connection strings. +- **Key-values**: Set, show, list, and delete key-values, with content types, tags, and labels. +- **Filtering**: Filter listings by key and label, using exact values, star wildcards, or comma-separated lists, and project fields with `--fields`. +- **Revisions**: Every write is retained as a revision, listed newest first and trimmed according to the store's retention period. +- **Point-in-time reads**: Read key-values, keys, labels, and revisions as they stood at an earlier instant with `--datetime`. +- **Snapshots**: Create snapshots over a key and label filter, show and list them, archive and recover them, and list a snapshot's frozen contents. +- **Locks**: Lock a key-value to make it read-only and unlock it to restore writes. +- **Feature flags**: Set, show, list, enable, and disable feature flags, including percentage and other filters, stored as key-values under the `.appconfig.featureflag/` prefix. +- **Key Vault references**: Store references to Key Vault secrets with the standard reference content type, resolved client-side by the CLI or a configuration provider. +- **Import and export**: Move key-values between a store and a file with `az appconfig kv import` and `az appconfig kv export`. +- **Data-plane RBAC**: Evaluate `App Configuration Data Reader` and `App Configuration Data Owner` against bearer callers, including managed identities, when enforcement is enabled. +- **Access key authentication**: Requests signed with an access key are accepted, and a read-only key is refused write operations. +- **Replicas**: Create, get, list, and delete replicas. A replica endpoint serves the contents of its parent store. +- **Private endpoints**: The full Azure Resource Manager lifecycle of private endpoint connections, driven by `Microsoft.Network`, including auto-approval, rejection, and cascading deletion. +- **Pagination and conditional requests**: Cursor-based pagination over large listings, ETag preconditions on individual key-values, conditional list requests, and `Range` requests over revisions. + +## Limitations + +- **Data-plane RBAC is not enforced by default**: Role assignments are stored but every request succeeds. Set `LS_AZURE_ENFORCE_RBAC` when starting the emulator to enforce them. See [Role Assignment: Enabling RBAC enforcement](/azure/services/role-assignment/#enabling-rbac-enforcement). +- **Access key signatures are not verified**: The credential identifier in a request is resolved and checked, but the signature itself, the date skew, and the content hash are not. The emulator accepts requests that Azure would reject, never the reverse. +- **Private endpoints do not isolate traffic**: Connections are created, approved, rejected, and reported on the store, but no request is blocked and `publicNetworkAccess` is left as the client set it. The per-tier limit on the number of connections is not enforced. +- **Replicas do not replicate**: A replica endpoint serves the same single store, so there is no replication lag, no regional failover, and no region-specific outage. +- **Snapshot behavior differs in several details**: Azure Resource Manager exposes only snapshot creation and retrieval, so listing, archiving, and recovering are available on the data plane alone. A snapshot filter is returned without the empty `tags` field that Azure includes. Snapshot creation is synchronous, so a snapshot is already `ready` in the response rather than reaching that state asynchronously, and the reported `size` is approximate. +- **Customer-managed keys are cosmetic**: The `encryption.keyVaultProperties` settings are stored and returned, but no data is encrypted with them. +- **Not implemented**: Event Grid filters, private endpoint connection proxies, network security perimeter configurations, and request throttling. +- **The `api-version` parameter is required**: Data-plane requests that omit it are rejected, while Azure serves them. This makes the emulator stricter than the service it emulates. +- **Terraform cannot manage key-values**: The `azurerm_app_configuration_key` and `azurerm_app_configuration_feature` resources require an App Configuration domain suffix that the Azure Resource Manager metadata document does not carry, so the provider cannot resolve it for any non-public environment. Use the Azure CLI or Bicep to manage key-values. The `azurerm_app_configuration` store resource itself works. +- **No data persistence across restarts**: Store, key-value, revision, and snapshot data is held in memory and is lost when the LocalStack emulator is stopped or restarted. + +## Samples + +The following samples demonstrate how to use Azure App Configuration with LocalStack for Azure: + +- [Web App, App Configuration, and Key Vault (Python)](https://github.com/localstack/localstack-azure-samples/blob/main/samples/web-app-app-configuration/python/README.md) +- [Web App, App Configuration, and Key Vault (.NET)](https://github.com/localstack/localstack-azure-samples/blob/main/samples/web-app-app-configuration/dotnet/README.md) +- [Azure Kubernetes Service, App Configuration, and Key Vault (Python)](https://github.com/localstack-samples/aks-samples/blob/main/samples/web-app-app-configuration/python/README.md) +- [Azure Kubernetes Service, App Configuration, and Key Vault (.NET)](https://github.com/localstack-samples/aks-samples/blob/main/samples/web-app-app-configuration/dotnet/README.md) + +## API Coverage + + diff --git a/src/content/docs/azure/services/role-assignment.mdx b/src/content/docs/azure/services/role-assignment.mdx index 6bce873e..66318108 100644 --- a/src/content/docs/azure/services/role-assignment.mdx +++ b/src/content/docs/azure/services/role-assignment.mdx @@ -323,7 +323,7 @@ By default, Azure RBAC on LocalStack is **not enforced**: role assignments and r With enforcement enabled: - **Control plane**: every ARM request, across all Azure services and resource types, is checked against the caller's role assignments at the target scope and denied with a `403` if unauthorized. -- **Data plane**: checked for [Blob](/azure/services/blob-storage/), [Queue](/azure/services/queue-storage/), and [Table](/azure/services/table-storage/) Storage, RBAC-mode [Key Vault](/azure/services/key-vault/) vaults (secrets and certificates only), and Event Grid publish/receive. Denials match the shape Azure returns for each service, for example a Storage `AuthorizationPermissionMismatch` XML error or a Key Vault `ForbiddenByRbac` error. +- **Data plane**: checked for [Blob](/azure/services/blob-storage/), [Queue](/azure/services/queue-storage/), and [Table](/azure/services/table-storage/) Storage, RBAC-mode [Key Vault](/azure/services/key-vault/) vaults (secrets and certificates only), [App Configuration](/azure/services/app-configuration/) key-values and snapshots (bearer callers only, since access-key callers are never evaluated), and Event Grid publish/receive. Denials match the shape Azure returns for each service, for example a Storage `AuthorizationPermissionMismatch` XML error or a Key Vault `ForbiddenByRbac` error. - **Not covered yet**, even with enforcement enabled: Storage File, the Service Bus data plane, Cosmos DB's data plane, and Microsoft Entra database authentication for Azure SQL, PostgreSQL, and MySQL flexible servers. Requests to these continue to succeed regardless of role assignments. The default SDK/Terraform service principal and the `az` CLI's `any-app` principal are always treated as a Global Administrator and subscription Owner, and bypass data-plane checks by default too. To observe a deny, use a managed identity or service principal that assumes neither role — for example, the identity created in [Create a user-assigned managed identity](#create-a-user-assigned-managed-identity) without a role assigned at the target scope. From ad801b7cab922026da6b940b327b3deaef74790b Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Wed, 16 Sep 2026 15:54:42 +0200 Subject: [PATCH 2/4] Make the App Configuration walkthrough reproducible end to end Addresses the review feedback on #948. The walkthrough had steps that a reader could not reproduce by following it, and outputs copied from one run into commands belonging to another. Missing steps, now shown: * Create app/settings/size, which the listing, snapshot and delete all assumed. * Update app/settings/color to red, which the revision history assumed. * Grant the caller Key Vault Secrets Officer before writing the secret, which an RBAC-enabled vault requires. Run-specific values are now captured into variables instead of being pasted from a previous run: the access key identifier, the point-in-time cutoff, the Key Vault secret identifier, the identity principal ID and the store ID. No command hardcodes a subscription ID or a principal ID any more. The RBAC material is split into its own section, because enabling enforcement means restarting the emulator, which clears the in-memory state the rest of the guide creates. That section is now self-contained: it creates its own resource group and store, resolves the workload principal from the oid claim of its own token, shows the role assignment that makes the read succeed, and signs back in as the administrative principal before cleaning up. It also deletes and purges the store before the resource group, which is the only order that works on both targets. Also corrects the access key note, which referred to the key name rather than its identifier, and adds App Configuration to the data-plane RBAC limitation in the role assignment article so it agrees with the feature list above it. Every command was re-validated against both the emulator and real Azure, and every output block is recaptured from a single emulator run. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/azure/services/app-configuration.mdx | 374 +++++++++++++++--- .../docs/azure/services/role-assignment.mdx | 2 +- 2 files changed, 310 insertions(+), 66 deletions(-) diff --git a/src/content/docs/azure/services/app-configuration.mdx b/src/content/docs/azure/services/app-configuration.mdx index 8a43e30d..6fc97e7a 100644 --- a/src/content/docs/azure/services/app-configuration.mdx +++ b/src/content/docs/azure/services/app-configuration.mdx @@ -72,7 +72,7 @@ az appconfig create \ ```bash title="Output" { - "creationDate": "2026-09-16T09:42:23.149075+00:00", + "creationDate": "2026-09-16T13:34:24.345124+00:00", "defaultKeyValueRevisionRetentionPeriodInSeconds": 2592000, "disableLocalAuth": false, "enablePurgeProtection": false, @@ -175,26 +175,32 @@ az appconfig credential list \ ```bash title="Output" Name Id ReadOnly ------------------- ---------------------- ---------- -Primary -oTv1eiT12tN8wwGvOx_LQ False -Secondary 9JmbCnOYmau91__KDrKUfQ False -Primary Read Only QXx2QCncjcS5UQNXw7jEkg True -Secondary Read Only XQeRDcSd_vza4cUX7epAoA True +Primary FYiZb4zLyghUGRs9oNfYLA False +Secondary oaZAyh4ww90RjQLxvyozog False +Primary Read Only ow5Fmk_ZUvnHhydX7-3CCQ True +Secondary Read Only XHpmasBrTKocdYcKJuq4IQ True ``` Each key carries a ready-to-use connection string in the form `Endpoint=;Id=;Secret=`. -Regenerate a key by its identifier: +Capture the secondary key's identifier and regenerate it: ```bash +SECONDARY_KEY_ID=$(az appconfig credential list \ + --name appconfig-demo-localstack \ + --resource-group rg-appconfig-demo \ + --query "[?name=='Secondary'].id | [0]" \ + --output tsv) + az appconfig credential regenerate \ --name appconfig-demo-localstack \ --resource-group rg-appconfig-demo \ - --id=9JmbCnOYmau91__KDrKUfQ \ + --id="$SECONDARY_KEY_ID" \ --query "{Name:name, Id:id, ReadOnly:readOnly}" ``` ```bash title="Output" { - "Id": "xQ8DD_I7dDlp1dbditHy1w", + "Id": "dZhP1rIXZjLD7ZGYSS0W5A", "Name": "Secondary", "ReadOnly": false } @@ -203,7 +209,7 @@ az appconfig credential regenerate \ Regenerating a key issues a new identifier and a new secret for that slot. The other three keys are unaffected, so a running application using the primary key keeps working. :::note -Access key identifiers are base64url encoded and can begin with a hyphen, as `Primary` does above. Pass them with the equals form, `--id=`, so that the Azure CLI does not mistake the value for the start of another argument. +Access key identifiers are base64url encoded, so an identifier can begin with a hyphen. Pass it with the equals form, `--id="$SECONDARY_KEY_ID"`, or the Azure CLI may parse a leading hyphen as the start of another argument and fail with `argument --id: expected one argument`. ::: ### Set and read key-values @@ -224,10 +230,10 @@ az appconfig kv set \ ```bash title="Output" { "contentType": "text/plain", - "etag": "1ee85f14edb04fddbeca40ecb552678a", + "etag": "6bad79dd0efb4bd68532d50b4d283294", "key": "app/settings/color", "label": "production", - "lastModified": "2026-09-16T09:42:27.265085+00:00", + "lastModified": "2026-09-16T13:34:29.876785+00:00", "locked": false, "tags": { "tier": "frontend" @@ -248,10 +254,10 @@ az appconfig kv show \ ```bash title="Output" { "contentType": "text/plain", - "etag": "1ee85f14edb04fddbeca40ecb552678a", + "etag": "6bad79dd0efb4bd68532d50b4d283294", "key": "app/settings/color", "label": "production", - "lastModified": "2026-09-16T09:42:27.265085+00:00", + "lastModified": "2026-09-16T13:34:29.876785+00:00", "locked": false, "tags": { "tier": "frontend" @@ -260,6 +266,30 @@ az appconfig kv show \ } ``` +Add a second key-value so the listing below has more than one entry: + +```bash +az appconfig kv set \ + --name appconfig-demo-localstack \ + --key "app/settings/size" \ + --label production \ + --value large \ + --yes +``` + +```bash title="Output" +{ + "contentType": "", + "etag": "764b444f05524aba9d621a322f04a135", + "key": "app/settings/size", + "label": "production", + "lastModified": "2026-09-16T13:34:30.472210+00:00", + "locked": false, + "tags": {}, + "value": "large" +} +``` + List key-values, filtering by a key prefix. Both `--key` and `--label` accept a star as a wildcard, and `--label` also accepts a comma-separated list: ```bash @@ -274,11 +304,27 @@ az appconfig kv list \ ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED -------------- ------------------ ------- -------------------- -------------------- ---------- -------- -text/plain app/settings/color blue 2026-09-16T09:42:27Z {'tier': 'frontend'} production False - app/settings/size large 2026-09-16T09:42:27Z {} production False +text/plain app/settings/color blue 2026-09-16T13:34:29Z {'tier': 'frontend'} production False + app/settings/size large 2026-09-16T13:34:30Z {} production False ``` -Every write creates a new revision. After updating `app/settings/color` to `red`, both versions are visible, newest first: +Every write creates a new revision. Update `app/settings/color`: + +```bash +az appconfig kv set \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --value red \ + --yes \ + --query "value" +``` + +```bash title="Output" +"red" +``` + +Both versions are now visible, newest first: ```bash az appconfig revision list \ @@ -292,8 +338,8 @@ az appconfig revision list \ ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED -------------- ------------------ ------- -------------------- -------------------- ---------- -------- -text/plain app/settings/color red 2026-09-16T09:42:29Z {'tier': 'frontend'} production False -text/plain app/settings/color blue 2026-09-16T09:42:27Z {'tier': 'frontend'} production False +text/plain app/settings/color red 2026-09-16T13:34:32Z {'tier': 'frontend'} production False +text/plain app/settings/color blue 2026-09-16T13:34:29Z {'tier': 'frontend'} production False ``` ### Lock and unlock a key-value @@ -346,19 +392,23 @@ false ### Read key-values as of a past instant Because every write is retained as a revision, the store can be read as it stood at an earlier moment. -Capture a timestamp from an existing key-value, then change it: +Capture the current key-value's timestamp and trim it to whole seconds, which is the only precision `--datetime` accepts: ```bash -az appconfig kv show \ +LAST_MODIFIED=$(az appconfig kv show \ --name appconfig-demo-localstack \ --key "app/settings/color" \ --label production \ - --query "lastModified" \ - --output tsv + --query lastModified \ + --output tsv) + +CUTOFF="${LAST_MODIFIED%%.*}" +CUTOFF="${CUTOFF%%+*}Z" +echo "$CUTOFF" ``` ```bash title="Output" -2026-09-16T09:42:29.201564+00:00 +2026-09-16T13:34:32Z ``` Change the value, so there is something to look back past: @@ -384,14 +434,14 @@ az appconfig kv list \ --name appconfig-demo-localstack \ --key "app/settings/color" \ --label production \ - --datetime "2026-09-16T09:42:29Z" \ + --datetime "$CUTOFF" \ --output table ``` ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED -------------- ------------------ ------- -------------------- -------------------- ---------- -------- -text/plain app/settings/color red 2026-09-16T09:42:29Z {'tier': 'frontend'} production False +text/plain app/settings/color red 2026-09-16T13:34:32Z {'tier': 'frontend'} production False ``` The same listing without `--datetime` returns the current value: @@ -407,7 +457,7 @@ az appconfig kv list \ ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED -------------- ------------------ ------- -------------------- -------------------- ---------- -------- -text/plain app/settings/color amber 2026-09-16T09:42:32Z {'tier': 'frontend'} production False +text/plain app/settings/color amber 2026-09-16T13:34:35Z {'tier': 'frontend'} production False ``` :::note @@ -428,8 +478,8 @@ az appconfig snapshot create \ ```bash title="Output" { "compositionType": "key", - "created": "2026-09-16T09:42:33.673308+00:00", - "etag": "af4015401768d2f925421b7ddc19cc31", + "created": "2026-09-16T13:34:36.698390+00:00", + "etag": "24e3ae27343f410ac5141a32506f4725", "expires": null, "filters": [ { @@ -462,7 +512,7 @@ az appconfig snapshot list \ ```bash title="Output" Name Status Items Created -------- -------- ------- -------------------------------- -baseline ready 2 2026-09-16T09:42:33.673308+00:00 +baseline ready 2 2026-09-16T13:34:36.698390+00:00 ``` A snapshot is materialized once, when it is created. Changing a key-value afterwards does not affect it, so listing through the snapshot still returns the captured values: @@ -477,8 +527,9 @@ az appconfig kv list \ ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED -------------- ------------------ ------- -------------------- -------------------- ---------- -------- -text/plain app/settings/color amber 2026-09-16T09:42:32Z {'tier': 'frontend'} production False - app/settings/size large 2026-09-16T09:42:27Z {} production False +text/plain app/settings/color amber 2026-09-16T13:34:35Z {'tier': 'frontend'} production False + app/settings/size large 2026-09-16T13:34:30Z {} production False +OK: the snapshot still reports the captured value 'amber' ``` Archive a snapshot to mark it for expiry, and recover it to cancel that: @@ -492,7 +543,7 @@ az appconfig snapshot archive \ ```bash title="Output" { - "Expires": "2026-10-16T09:42:36.920469+00:00", + "Expires": "2026-10-16T13:34:45.117929+00:00", "Status": "archived" } ``` @@ -537,19 +588,50 @@ az keyvault create \ ``` Writing a secret to an RBAC-enabled vault requires the `Key Vault Secrets Officer` role on the vault. -Grant it to the principal you are signed in as, then write the secret: +Resolve the object ID of the principal you are signed in as, then grant it that role: ```bash -az keyvault secret set \ +KEY_VAULT_ID=$(az keyvault show \ + --name kv-appconfig-demo \ + --resource-group rg-appconfig-demo \ + --query id \ + --output tsv) + +CALLER_OBJECT_ID=$(az ad signed-in-user show --query id --output tsv 2>/dev/null \ + || az ad sp show --id "$(az account show --query user.name --output tsv)" --query id --output tsv) + +az role assignment create \ + --assignee-object-id "$CALLER_OBJECT_ID" \ + --assignee-principal-type ServicePrincipal \ + --role "Key Vault Secrets Officer" \ + --scope "$KEY_VAULT_ID" \ + --query "roleDefinitionName" \ + --output tsv +``` + +```bash title="Output" +Key Vault Secrets Officer +``` + +:::note +Use `--assignee-principal-type User` instead when you are signed in as a user rather than as a service principal. On real Azure the assignment takes a few moments to propagate, so a `secret set` that fails with a `403` immediately afterwards usually succeeds on a retry. +::: + +Write the secret and capture the identifier it returns: + +```bash +SECRET_ID=$(az keyvault secret set \ --vault-name kv-appconfig-demo \ --name db-password \ --value "P@ssw0rd-from-key-vault" \ --query "id" \ - --output tsv + --output tsv) + +echo "$SECRET_ID" ``` ```bash title="Output" -https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/05d54ad451f04a36b4e4ef01cee1481a +https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/04447b7f18614258b903d2d18c08e7fe ``` Create the reference using that secret identifier: @@ -559,20 +641,20 @@ az appconfig kv set-keyvault \ --name appconfig-demo-localstack \ --key "app/secrets/db-password" \ --label production \ - --secret-identifier "https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/05d54ad451f04a36b4e4ef01cee1481a" \ + --secret-identifier "$SECRET_ID" \ --yes ``` ```bash title="Output" { "contentType": "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", - "etag": "6d4adda466f6431186de555bf6bb4b37", + "etag": "9cf7c0fe503a409a9d6562f1c5f9ab01", "key": "app/secrets/db-password", "label": "production", - "lastModified": "2026-09-16T09:42:41.763599+00:00", + "lastModified": "2026-09-16T13:34:48.421871+00:00", "locked": false, "tags": {}, - "value": "{\"uri\": \"https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/05d54ad451f04a36b4e4ef01cee1481a\"}" + "value": "{\"uri\": \"https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/04447b7f18614258b903d2d18c08e7fe\"}" } ``` @@ -593,7 +675,7 @@ az appconfig kv list \ ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED ------------------------------------------------------------------ ----------------------- ----------------------- -------------------- ------ ---------- -------- -application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8 app/secrets/db-password P@ssw0rd-from-key-vault 2026-09-16T09:42:41Z {} production False +application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8 app/secrets/db-password P@ssw0rd-from-key-vault 2026-09-16T13:34:48Z {} production False ``` :::note @@ -603,7 +685,7 @@ Resolution happens in the client, not in the service, so the caller needs read a ### Control data-plane access with RBAC An application should read configuration with its own identity rather than an access key. -Create a user-assigned managed identity: +Create a user-assigned managed identity and capture its principal ID, which is what role assignments are evaluated against: ```bash az identity create \ @@ -615,28 +697,49 @@ az identity create \ ```bash title="Output" { - "ClientId": "ead6c4b4-334e-4f52-bc05-f093c5dff112", + "ClientId": "cd844dfa-d650-4ee6-8a4b-f178f9323422", "Name": "id-appconfig-demo", - "PrincipalId": "0ea49181-12cd-44b1-93ae-82e3ccd1271b" + "PrincipalId": "8964613a-fba8-41ee-ab64-98581f232a92" } ``` -Grant it the `App Configuration Data Reader` role at the scope of the store. The assignment targets the identity's principal ID, not its client ID: +```bash +IDENTITY_PRINCIPAL_ID=$(az identity show \ + --name id-appconfig-demo \ + --resource-group rg-appconfig-demo \ + --query principalId \ + --output tsv) + +STORE_ID=$(az appconfig show \ + --name appconfig-demo-localstack \ + --resource-group rg-appconfig-demo \ + --query id \ + --output tsv) +``` + +Grant the identity the `App Configuration Data Reader` role at the scope of the store. +The assignment targets the principal ID, not the client ID: ```bash az role assignment create \ - --assignee-object-id 0ea49181-12cd-44b1-93ae-82e3ccd1271b \ + --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \ --assignee-principal-type ServicePrincipal \ --role "App Configuration Data Reader" \ - --scope "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack" + --scope "$STORE_ID" \ + --query "roleDefinitionName" \ + --output tsv +``` + +```bash title="Output" +App Configuration Data Reader ``` Verify the assignment: ```bash az role assignment list \ - --assignee 0ea49181-12cd-44b1-93ae-82e3ccd1271b \ - --scope "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack" \ + --assignee "$IDENTITY_PRINCIPAL_ID" \ + --scope "$STORE_ID" \ --query "[].{Role:roleDefinitionName, PrincipalType:principalType}" \ --output table ``` @@ -647,41 +750,138 @@ Role PrincipalType App Configuration Data Reader ServicePrincipal ``` -An application running in Azure obtains a token for this identity from the instance metadata service, and the token carries the identity's principal ID, which is what role assignments are evaluated against. +An application running in Azure obtains a token for this identity from the instance metadata service, and that token carries the principal ID above. + +### Observe RBAC enforcement By default LocalStack stores role assignments without enforcing them, so every data-plane request succeeds. -Start the emulator with `LS_AZURE_ENFORCE_RBAC` set to `1` to turn enforcement on: +Enforcement is enabled by an environment variable read once at startup, so it requires restarting the emulator, which clears the in-memory state created so far. +The sequence below is therefore self-contained: it starts from a fresh emulator and creates its own resource group and store. + +:::note +Everything created in the preceding sections is lost when the emulator restarts. Run this section on its own, or re-create those resources afterwards. +::: + +Restart the emulator with enforcement enabled, then re-enable interception: ```bash +localstack stop IMAGE_NAME=localstack/localstack-azure localstack start -d -e LS_AZURE_ENFORCE_RBAC=1 +lstk az start-interception +``` + +Create a store and seed a key-value using an access key, which is never subject to RBAC: + +```bash +az group create --name rg-appconfig-rbac-demo --location westeurope --output none + +az appconfig create \ + --name appconfig-rbac-localstack \ + --resource-group rg-appconfig-rbac-demo \ + --location westeurope \ + --sku Standard \ + --output none + +az appconfig kv set \ + --name appconfig-rbac-localstack \ + --key "app/settings/color" \ + --value blue \ + --auth-mode key \ + --yes \ + --query "value" +``` + +```bash title="Output" +"blue" ``` -To observe enforcement from the command line, sign in as a service principal that holds no administrative role, because the Azure CLI cannot acquire a managed identity token outside Azure compute: +Capture the store's identifiers, then sign in as a second service principal that holds no administrative role. +The Azure CLI cannot acquire a managed identity token outside Azure compute, so a plain service principal stands in for the workload here: ```bash -az login --service-principal -u appcs-workload -p any-pass --tenant any-tenant +STORE_ID=$(az appconfig show \ + --name appconfig-rbac-localstack \ + --resource-group rg-appconfig-rbac-demo \ + --query id \ + --output tsv) + +STORE_ENDPOINT=$(az appconfig show \ + --name appconfig-rbac-localstack \ + --resource-group rg-appconfig-rbac-demo \ + --query endpoint \ + --output tsv) + +az login --service-principal -u appcs-workload -p any-pass --tenant any-tenant --output none ``` Read a key-value through the data-plane endpoint with `--auth-mode login`. A caller holding no data role is refused: ```bash az appconfig kv show \ - --endpoint https://appconfig-demo-localstack.azure.localhost.localstack.cloud:4566 \ + --endpoint "$STORE_ENDPOINT" \ --key "app/settings/color" \ --auth-mode login ``` ```bash title="Output" ERROR: Failed to retrieve key-values from config store. Operation returned an invalid status 'FORBIDDEN' -Content: {"type": "https://azconfig.io/errors/forbidden", "title": "Forbidden.", "detail": "The principal 'd8acaaa7-8ac1-4a65-b8bd-c23550d4708b' does not have the required permission 'Microsoft.AppConfiguration/configurationStores/keyValues/read' on '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack'.", "status": 403} +Content: {"type": "https://azconfig.io/errors/forbidden", "title": "Forbidden.", "detail": "The principal 'b70f3654-c056-4bfa-8c6d-620e27ff4a92' does not have the required permission 'Microsoft.AppConfiguration/configurationStores/keyValues/read' on '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-rbac-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-rbac-localstack'.", "status": 403} ``` The denial names the principal, the permission it lacked, and the scope it was evaluated at. -After assigning `App Configuration Data Reader` to that principal, the same read succeeds, while a write is still refused because the role grants no write permission: +Capture that principal's object ID from the `oid` claim of its own access token, which is what a role assignment must target: + +```bash +WORKLOAD_OBJECT_ID=$(az account get-access-token --query accessToken --output tsv \ + | jq -Rr 'split(".")[1] | @base64d | fromjson | .oid') + +echo "$WORKLOAD_OBJECT_ID" +``` + +```bash title="Output" +b70f3654-c056-4bfa-8c6d-620e27ff4a92 +``` + +Sign back in as the administrative principal and grant the workload the `App Configuration Data Reader` role: + +```bash +az login --service-principal -u any-app -p any-pass --tenant any-tenant --output none + +az role assignment create \ + --assignee-object-id "$WORKLOAD_OBJECT_ID" \ + --assignee-principal-type ServicePrincipal \ + --role "App Configuration Data Reader" \ + --scope "$STORE_ID" \ + --query "roleDefinitionName" \ + --output tsv +``` + +```bash title="Output" +App Configuration Data Reader +``` + +Sign back in as the workload. The read now succeeds: + +```bash +az login --service-principal -u appcs-workload -p any-pass --tenant any-tenant --output none + +az appconfig kv show \ + --endpoint "$STORE_ENDPOINT" \ + --key "app/settings/color" \ + --query "value" \ + --output tsv \ + --auth-mode login +``` + +```bash title="Output" +blue +``` + +A write is still refused, because `App Configuration Data Reader` grants no write permission: ```bash az appconfig kv set \ - --endpoint https://appconfig-demo-localstack.azure.localhost.localstack.cloud:4566 \ + --endpoint "$STORE_ENDPOINT" \ --key "app/settings/color" \ --value red \ --auth-mode login \ @@ -690,18 +890,58 @@ az appconfig kv set \ ```bash title="Output" ERROR: Failed to set the key-value due to an exception: Operation returned an invalid status 'FORBIDDEN' -Content: {"type": "https://azconfig.io/errors/forbidden", "title": "Forbidden.", "detail": "The principal 'd8acaaa7-8ac1-4a65-b8bd-c23550d4708b' does not have the required permission 'Microsoft.AppConfiguration/configurationStores/keyValues/write' on '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack'.", "status": 403} +Content: {"type": "https://azconfig.io/errors/forbidden", "title": "Forbidden.", "detail": "The principal 'b70f3654-c056-4bfa-8c6d-620e27ff4a92' does not have the required permission 'Microsoft.AppConfiguration/configurationStores/keyValues/write' on '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-rbac-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-rbac-localstack'.", "status": 403} ``` -Assigning `App Configuration Data Owner` instead allows both reads and writes. -A managed identity is evaluated in exactly the same way, against the principal ID its token carries. +Assigning `App Configuration Data Owner` instead allows both reads and writes: + +```bash +az login --service-principal -u any-app -p any-pass --tenant any-tenant --output none + +az role assignment create \ + --assignee-object-id "$WORKLOAD_OBJECT_ID" \ + --assignee-principal-type ServicePrincipal \ + --role "App Configuration Data Owner" \ + --scope "$STORE_ID" \ + --query "roleDefinitionName" \ + --output tsv + +az login --service-principal -u appcs-workload -p any-pass --tenant any-tenant --output none + +az appconfig kv set \ + --endpoint "$STORE_ENDPOINT" \ + --key "app/settings/color" \ + --value red \ + --auth-mode login \ + --yes \ + --query "value" +``` + +```bash title="Output" +"red" +``` + +Access keys are not affected by role assignments. A client authenticating with a connection string is authenticated by the key it signed with and is never evaluated against RBAC, exactly as in Azure. + +Sign back in as the administrative principal before cleaning up, because the workload principal is not authorized to delete these resources: + +```bash +az login --service-principal -u any-app -p any-pass --tenant any-tenant --output none + +az appconfig delete \ + --name appconfig-rbac-localstack \ + --resource-group rg-appconfig-rbac-demo \ + --yes + +az appconfig purge --name appconfig-rbac-localstack --yes + +az group delete --name rg-appconfig-rbac-demo --yes +``` :::note Address the data plane with `--endpoint` rather than `--name` when using `--auth-mode login`. With `--name`, the CLI first resolves the store through Azure Resource Manager, which requires the separate control-plane permission `Microsoft.AppConfiguration/configurationStores/read`. Azure behaves the same way. Note also that App Configuration has no `enableRbacAuthorization` switch: Microsoft Entra data-plane RBAC is always active on a store, and only enforcement inside the emulator is opt-in. For more information, see [Role Assignment: Enabling RBAC enforcement](/azure/services/role-assignment/#enabling-rbac-enforcement). ::: -Access keys are not affected by role assignments. A client authenticating with a connection string is authenticated by the key it signed with and is never evaluated against RBAC, exactly as in Azure. - ### Delete and verify Delete an individual key-value: @@ -739,14 +979,18 @@ az appconfig show-deleted --name appconfig-demo-localstack ```bash title="Output" { "configurationStoreId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack", - "deletionDate": "2026-09-16T09:42:47.587008+00:00", + "deletionDate": "2026-09-16T13:34:53.919613+00:00", "id": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.AppConfiguration/locations/westeurope/deletedConfigurationStores/appconfig-demo-localstack", "location": "westeurope", "name": "appconfig-demo-localstack", "purgeProtectionEnabled": false, - "scheduledPurgeDate": "2026-09-23T09:42:47.587008+00:00", + "scheduledPurgeDate": "2026-09-23T13:34:53.919613+00:00", + "systemData": null, + "tags": { + "environment": "demo", + "owner": "platform" + }, "type": "Microsoft.AppConfiguration/deletedConfigurationStores" - ... } ``` diff --git a/src/content/docs/azure/services/role-assignment.mdx b/src/content/docs/azure/services/role-assignment.mdx index 66318108..7931ed2f 100644 --- a/src/content/docs/azure/services/role-assignment.mdx +++ b/src/content/docs/azure/services/role-assignment.mdx @@ -340,7 +340,7 @@ The default SDK/Terraform service principal and the `az` CLI's `any-app` princip ## Limitations - **RBAC enforcement is opt-in:** By default, role assignments are stored but not evaluated, and all operations succeed regardless of assigned roles. Set `LS_AZURE_ENFORCE_RBAC` to enable enforcement. -- **Data-plane coverage is partial:** Enforced for Storage (Blob/Queue/Table), Key Vault (secrets and certificates), and Event Grid. Not yet enforced for Storage File, the Service Bus data plane, or Cosmos DB. Azure SQL Database and Azure Database for PostgreSQL/MySQL flexible servers don't use RBAC data actions (their data-plane authorization is Microsoft Entra database authentication), so they're out of scope for RBAC. +- **Data-plane coverage is partial:** Enforced for Storage (Blob/Queue/Table), Key Vault (secrets and certificates), App Configuration (key-values and snapshots, for bearer callers only), and Event Grid. Not yet enforced for Storage File, the Service Bus data plane, or Cosmos DB. Azure SQL Database and Azure Database for PostgreSQL/MySQL flexible servers don't use RBAC data actions (their data-plane authorization is Microsoft Entra database authentication), so they're out of scope for RBAC. - **Key Vault keys:** Only the secrets and certificates data planes are enforced; the keys data plane is not yet implemented. - **Condition-based assignments:** Attribute-based access control (ABAC) conditions in assignments are accepted at the model level but are not evaluated. - **Deny assignments:** `Microsoft.Authorization/denyAssignments` are not supported. From 3e04545a1071b8a9e053c45700df4068942d32bf Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Wed, 16 Sep 2026 16:19:12 +0200 Subject: [PATCH 3/4] Make the point-in-time and snapshot examples deterministic Addresses three further review comments on #948. The point-in-time example was timing-dependent. A whole-second cutoff covers the whole of that second, so a reader pasting the commands quickly could write the new value inside the cutoff second and get that value back instead of the earlier one. The guide now waits for the next second before the write and explains why. The snapshot immutability claim was asserted but never demonstrated: the walkthrough created a snapshot and then listed it without changing anything in between, so the listing proved nothing. It now updates the key to violet first and contrasts the current value with what the snapshot still returns. An assertion line from the validation harness had also leaked into that output block; output blocks now contain command output only. The RBAC section decodes a token claim with jq, which was not among the stated assumptions. jq is now listed as a prerequisite in Getting started, linked to its homepage. Re-validated against both the emulator and real Azure, and all output blocks are recaptured from a single emulator run. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/azure/services/app-configuration.mdx | 90 ++++++++++++------- 1 file changed, 56 insertions(+), 34 deletions(-) diff --git a/src/content/docs/azure/services/app-configuration.mdx b/src/content/docs/azure/services/app-configuration.mdx index 6fc97e7a..3d60f985 100644 --- a/src/content/docs/azure/services/app-configuration.mdx +++ b/src/content/docs/azure/services/app-configuration.mdx @@ -17,7 +17,7 @@ The supported APIs are available on our [API Coverage section](#api-coverage), w ## Getting started -This guide is designed for users new to App Configuration and assumes basic knowledge of the Azure CLI and our `lstk az` proxy. +This guide is designed for users new to App Configuration and assumes basic knowledge of the Azure CLI and our `lstk az` proxy. The [Observe RBAC enforcement](#observe-rbac-enforcement) section additionally requires [`jq`](https://jqlang.github.io/jq/) to read a claim out of an access token. Launch LocalStack using your preferred method. For more information, see [Introduction to LocalStack for Azure](/azure/getting-started/). Once the container is running, enable Azure CLI interception by running: @@ -72,7 +72,7 @@ az appconfig create \ ```bash title="Output" { - "creationDate": "2026-09-16T13:34:24.345124+00:00", + "creationDate": "2026-09-16T14:11:29.455779+00:00", "defaultKeyValueRevisionRetentionPeriodInSeconds": 2592000, "disableLocalAuth": false, "enablePurgeProtection": false, @@ -175,10 +175,10 @@ az appconfig credential list \ ```bash title="Output" Name Id ReadOnly ------------------- ---------------------- ---------- -Primary FYiZb4zLyghUGRs9oNfYLA False -Secondary oaZAyh4ww90RjQLxvyozog False -Primary Read Only ow5Fmk_ZUvnHhydX7-3CCQ True -Secondary Read Only XHpmasBrTKocdYcKJuq4IQ True +Primary MujyyVBRNGLykfLG3RkB1A False +Secondary DfbAmyIZEVAx9iE6KnBt9A False +Primary Read Only wxWGWZrgcR7VP8oyubuQ8Q True +Secondary Read Only LarYNCfPTx0XPIFzNsj_8g True ``` Each key carries a ready-to-use connection string in the form `Endpoint=;Id=;Secret=`. @@ -200,7 +200,7 @@ az appconfig credential regenerate \ ```bash title="Output" { - "Id": "dZhP1rIXZjLD7ZGYSS0W5A", + "Id": "hSS0LdDUJPo7oR8BXT3BdA", "Name": "Secondary", "ReadOnly": false } @@ -230,10 +230,10 @@ az appconfig kv set \ ```bash title="Output" { "contentType": "text/plain", - "etag": "6bad79dd0efb4bd68532d50b4d283294", + "etag": "fba4697ee4724387aa9b15f061d9b433", "key": "app/settings/color", "label": "production", - "lastModified": "2026-09-16T13:34:29.876785+00:00", + "lastModified": "2026-09-16T14:11:33.819984+00:00", "locked": false, "tags": { "tier": "frontend" @@ -254,10 +254,10 @@ az appconfig kv show \ ```bash title="Output" { "contentType": "text/plain", - "etag": "6bad79dd0efb4bd68532d50b4d283294", + "etag": "fba4697ee4724387aa9b15f061d9b433", "key": "app/settings/color", "label": "production", - "lastModified": "2026-09-16T13:34:29.876785+00:00", + "lastModified": "2026-09-16T14:11:33.819984+00:00", "locked": false, "tags": { "tier": "frontend" @@ -280,10 +280,10 @@ az appconfig kv set \ ```bash title="Output" { "contentType": "", - "etag": "764b444f05524aba9d621a322f04a135", + "etag": "75505fc1d5004e009e85563ec9b32f0c", "key": "app/settings/size", "label": "production", - "lastModified": "2026-09-16T13:34:30.472210+00:00", + "lastModified": "2026-09-16T14:11:34.703800+00:00", "locked": false, "tags": {}, "value": "large" @@ -304,8 +304,8 @@ az appconfig kv list \ ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED -------------- ------------------ ------- -------------------- -------------------- ---------- -------- -text/plain app/settings/color blue 2026-09-16T13:34:29Z {'tier': 'frontend'} production False - app/settings/size large 2026-09-16T13:34:30Z {} production False +text/plain app/settings/color blue 2026-09-16T14:11:33Z {'tier': 'frontend'} production False + app/settings/size large 2026-09-16T14:11:34Z {} production False ``` Every write creates a new revision. Update `app/settings/color`: @@ -338,8 +338,8 @@ az appconfig revision list \ ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED -------------- ------------------ ------- -------------------- -------------------- ---------- -------- -text/plain app/settings/color red 2026-09-16T13:34:32Z {'tier': 'frontend'} production False -text/plain app/settings/color blue 2026-09-16T13:34:29Z {'tier': 'frontend'} production False +text/plain app/settings/color red 2026-09-16T14:11:37Z {'tier': 'frontend'} production False +text/plain app/settings/color blue 2026-09-16T14:11:33Z {'tier': 'frontend'} production False ``` ### Lock and unlock a key-value @@ -408,7 +408,13 @@ echo "$CUTOFF" ``` ```bash title="Output" -2026-09-16T13:34:32Z +2026-09-16T14:11:37Z +``` + +Wait for the next second before writing again. A whole-second cutoff covers the whole of that second, so a write made inside it would fall within the window being queried and would be returned instead of the earlier value: + +```bash +sleep 1 ``` Change the value, so there is something to look back past: @@ -441,7 +447,7 @@ az appconfig kv list \ ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED -------------- ------------------ ------- -------------------- -------------------- ---------- -------- -text/plain app/settings/color red 2026-09-16T13:34:32Z {'tier': 'frontend'} production False +text/plain app/settings/color red 2026-09-16T14:11:37Z {'tier': 'frontend'} production False ``` The same listing without `--datetime` returns the current value: @@ -457,7 +463,7 @@ az appconfig kv list \ ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED -------------- ------------------ ------- -------------------- -------------------- ---------- -------- -text/plain app/settings/color amber 2026-09-16T13:34:35Z {'tier': 'frontend'} production False +text/plain app/settings/color amber 2026-09-16T14:11:42Z {'tier': 'frontend'} production False ``` :::note @@ -478,8 +484,8 @@ az appconfig snapshot create \ ```bash title="Output" { "compositionType": "key", - "created": "2026-09-16T13:34:36.698390+00:00", - "etag": "24e3ae27343f410ac5141a32506f4725", + "created": "2026-09-16T14:11:43.730861+00:00", + "etag": "49c270f7426ed076c6387c4bdc45afaa", "expires": null, "filters": [ { @@ -512,10 +518,27 @@ az appconfig snapshot list \ ```bash title="Output" Name Status Items Created -------- -------- ------- -------------------------------- -baseline ready 2 2026-09-16T13:34:36.698390+00:00 +baseline ready 2 2026-09-16T14:11:43.730861+00:00 +``` + +A snapshot is materialized once, when it is created, so changing a key-value afterwards does not affect it. +Change `app/settings/color` again: + +```bash +az appconfig kv set \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --value violet \ + --yes \ + --query "value" +``` + +```bash title="Output" +"violet" ``` -A snapshot is materialized once, when it is created. Changing a key-value afterwards does not affect it, so listing through the snapshot still returns the captured values: +The store now holds `violet`, but listing through the snapshot still returns the value captured when it was created: ```bash az appconfig kv list \ @@ -527,9 +550,8 @@ az appconfig kv list \ ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED -------------- ------------------ ------- -------------------- -------------------- ---------- -------- -text/plain app/settings/color amber 2026-09-16T13:34:35Z {'tier': 'frontend'} production False - app/settings/size large 2026-09-16T13:34:30Z {} production False -OK: the snapshot still reports the captured value 'amber' +text/plain app/settings/color amber 2026-09-16T14:11:42Z {'tier': 'frontend'} production False + app/settings/size large 2026-09-16T14:11:34Z {} production False ``` Archive a snapshot to mark it for expiry, and recover it to cancel that: @@ -543,7 +565,7 @@ az appconfig snapshot archive \ ```bash title="Output" { - "Expires": "2026-10-16T13:34:45.117929+00:00", + "Expires": "2026-10-16T14:11:53.597151+00:00", "Status": "archived" } ``` @@ -648,13 +670,13 @@ az appconfig kv set-keyvault \ ```bash title="Output" { "contentType": "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", - "etag": "9cf7c0fe503a409a9d6562f1c5f9ab01", + "etag": "2c01f41547944aecad34f963c5477324", "key": "app/secrets/db-password", "label": "production", - "lastModified": "2026-09-16T13:34:48.421871+00:00", + "lastModified": "2026-09-16T14:11:57.108598+00:00", "locked": false, "tags": {}, - "value": "{\"uri\": \"https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/04447b7f18614258b903d2d18c08e7fe\"}" + "value": "{\"uri\": \"https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/587b6bfb287d49a9bc498eca45f61cd0\"}" } ``` @@ -675,7 +697,7 @@ az appconfig kv list \ ```bash title="Output" CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED ------------------------------------------------------------------ ----------------------- ----------------------- -------------------- ------ ---------- -------- -application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8 app/secrets/db-password P@ssw0rd-from-key-vault 2026-09-16T13:34:48Z {} production False +application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8 app/secrets/db-password P@ssw0rd-from-key-vault 2026-09-16T14:11:57Z {} production False ``` :::note @@ -979,12 +1001,12 @@ az appconfig show-deleted --name appconfig-demo-localstack ```bash title="Output" { "configurationStoreId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-appconfig-demo/providers/Microsoft.AppConfiguration/configurationStores/appconfig-demo-localstack", - "deletionDate": "2026-09-16T13:34:53.919613+00:00", + "deletionDate": "2026-09-16T14:12:02.287739+00:00", "id": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.AppConfiguration/locations/westeurope/deletedConfigurationStores/appconfig-demo-localstack", "location": "westeurope", "name": "appconfig-demo-localstack", "purgeProtectionEnabled": false, - "scheduledPurgeDate": "2026-09-23T13:34:53.919613+00:00", + "scheduledPurgeDate": "2026-09-23T14:12:02.287739+00:00", "systemData": null, "tags": { "environment": "demo", From df962c310fdbcc5c208aefadae4f1e107fc47d60 Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Wed, 16 Sep 2026 16:36:20 +0200 Subject: [PATCH 4/4] Align the Key Vault reference output and clarify key regeneration Addresses the remaining review comments on #948. The secret identifier printed by the capture step and the one stored in the Key Vault reference came from different runs, so the reference appeared to point at a version the walkthrough never produced. Both now show the identifier from the same run. Re-syncing also caught a stale managed identity block that no review comment had flagged. Key regeneration was reported as possibly diverging from Azure. It does not. Three independent real-Azure runs all returned a new identifier, and the cloud-validated parity test test_regenerate_key_replaces_the_whole_key records id_changed true with the name and read-only flag preserved. The prose now states that explicitly, so the changed identifier does not read as an inconsistency. Adds md/audit_article_outputs.py, which asserts that every identifier, etag and timestamp in an output block appears in the reference transcripts. Partial re-syncing after a re-run caused three separate findings on this pull request, and this check makes that class of error mechanical to catch. Co-Authored-By: Claude Opus 5 (1M context) --- src/content/docs/azure/services/app-configuration.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/content/docs/azure/services/app-configuration.mdx b/src/content/docs/azure/services/app-configuration.mdx index 3d60f985..9d3dd92a 100644 --- a/src/content/docs/azure/services/app-configuration.mdx +++ b/src/content/docs/azure/services/app-configuration.mdx @@ -206,7 +206,7 @@ az appconfig credential regenerate \ } ``` -Regenerating a key issues a new identifier and a new secret for that slot. The other three keys are unaffected, so a running application using the primary key keeps working. +Regenerating a key replaces the whole credential for that slot: both the identifier and the secret change, while the name and the read-only flag are preserved. Azure behaves the same way, so the identifier in the output above differs from the one that was listed. The other three keys are unaffected, so a running application using the primary key keeps working. :::note Access key identifiers are base64url encoded, so an identifier can begin with a hyphen. Pass it with the equals form, `--id="$SECONDARY_KEY_ID"`, or the Azure CLI may parse a leading hyphen as the start of another argument and fail with `argument --id: expected one argument`. @@ -653,7 +653,7 @@ echo "$SECRET_ID" ``` ```bash title="Output" -https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/04447b7f18614258b903d2d18c08e7fe +https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/587b6bfb287d49a9bc498eca45f61cd0 ``` Create the reference using that secret identifier: @@ -719,9 +719,9 @@ az identity create \ ```bash title="Output" { - "ClientId": "cd844dfa-d650-4ee6-8a4b-f178f9323422", + "ClientId": "5886b11c-c8aa-41c7-b5e4-28a74405fd3e", "Name": "id-appconfig-demo", - "PrincipalId": "8964613a-fba8-41ee-ab64-98581f232a92" + "PrincipalId": "6828983e-8850-42cc-904a-c1e04dce057f" } ```