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..9d3dd92a --- /dev/null +++ b/src/content/docs/azure/services/app-configuration.mdx @@ -0,0 +1,1094 @@ +--- +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. 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: + +```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-16T14:11:29.455779+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 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=`. +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="$SECONDARY_KEY_ID" \ + --query "{Name:name, Id:id, ReadOnly:readOnly}" +``` + +```bash title="Output" +{ + "Id": "hSS0LdDUJPo7oR8BXT3BdA", + "Name": "Secondary", + "ReadOnly": false +} +``` + +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`. +::: + +### 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": "fba4697ee4724387aa9b15f061d9b433", + "key": "app/settings/color", + "label": "production", + "lastModified": "2026-09-16T14:11:33.819984+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": "fba4697ee4724387aa9b15f061d9b433", + "key": "app/settings/color", + "label": "production", + "lastModified": "2026-09-16T14:11:33.819984+00:00", + "locked": false, + "tags": { + "tier": "frontend" + }, + "value": "blue" +} +``` + +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": "75505fc1d5004e009e85563ec9b32f0c", + "key": "app/settings/size", + "label": "production", + "lastModified": "2026-09-16T14:11:34.703800+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 +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-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`: + +```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 \ + --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-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 + +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 the current key-value's timestamp and trim it to whole seconds, which is the only precision `--datetime` accepts: + +```bash +LAST_MODIFIED=$(az appconfig kv show \ + --name appconfig-demo-localstack \ + --key "app/settings/color" \ + --label production \ + --query lastModified \ + --output tsv) + +CUTOFF="${LAST_MODIFIED%%.*}" +CUTOFF="${CUTOFF%%+*}Z" +echo "$CUTOFF" +``` + +```bash title="Output" +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: + +```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 "$CUTOFF" \ + --output table +``` + +```bash title="Output" +CONTENT TYPE KEY VALUE LAST MODIFIED TAGS LABEL LOCKED +-------------- ------------------ ------- -------------------- -------------------- ---------- -------- +text/plain app/settings/color red 2026-09-16T14:11:37Z {'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-16T14:11:42Z {'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-16T14:11:43.730861+00:00", + "etag": "49c270f7426ed076c6387c4bdc45afaa", + "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-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" +``` + +The store now holds `violet`, but listing through the snapshot still returns the value captured when it was created: + +```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-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: + +```bash +az appconfig snapshot archive \ + --name appconfig-demo-localstack \ + --snapshot-name baseline \ + --query "{Status:status, Expires:expires}" +``` + +```bash title="Output" +{ + "Expires": "2026-10-16T14:11:53.597151+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. +Resolve the object ID of the principal you are signed in as, then grant it that role: + +```bash +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) + +echo "$SECRET_ID" +``` + +```bash title="Output" +https://kv-appconfig-demo.vault.azure.localhost.localstack.cloud:4566/secrets/db-password/587b6bfb287d49a9bc498eca45f61cd0 +``` + +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 "$SECRET_ID" \ + --yes +``` + +```bash title="Output" +{ + "contentType": "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", + "etag": "2c01f41547944aecad34f963c5477324", + "key": "app/secrets/db-password", + "label": "production", + "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/587b6bfb287d49a9bc498eca45f61cd0\"}" +} +``` + +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-16T14:11:57Z {} 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 and capture its principal ID, which is what role assignments are evaluated against: + +```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": "5886b11c-c8aa-41c7-b5e4-28a74405fd3e", + "Name": "id-appconfig-demo", + "PrincipalId": "6828983e-8850-42cc-904a-c1e04dce057f" +} +``` + +```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 "$IDENTITY_PRINCIPAL_ID" \ + --assignee-principal-type ServicePrincipal \ + --role "App Configuration Data Reader" \ + --scope "$STORE_ID" \ + --query "roleDefinitionName" \ + --output tsv +``` + +```bash title="Output" +App Configuration Data Reader +``` + +Verify the assignment: + +```bash +az role assignment list \ + --assignee "$IDENTITY_PRINCIPAL_ID" \ + --scope "$STORE_ID" \ + --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 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. +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" +``` + +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 +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 "$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 '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. +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 "$STORE_ENDPOINT" \ + --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 '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: + +```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). +::: + +### 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-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-23T14:12:02.287739+00:00", + "systemData": null, + "tags": { + "environment": "demo", + "owner": "platform" + }, + "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..7931ed2f 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. @@ -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.