Skip to content

Add Azure Managed Redis support alongside Azure Cache for Redis - #1438

Merged
Paul Lizer (paullizer) merged 6 commits into
Developmentfrom
paullizer-azure-managed-redis-migration
Sep 5, 2026
Merged

Add Azure Managed Redis support alongside Azure Cache for Redis#1438
Paul Lizer (paullizer) merged 6 commits into
Developmentfrom
paullizer-azure-managed-redis-migration

Conversation

@paullizer

@paullizer Paul Lizer (paullizer) commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Why

Azure Cache for Redis Basic, Standard, and Premium retire on 2028-09-30 (retirement FAQ), and the Enterprise tiers retire 2027-03-31. Azure Managed Redis (AMR) is the replacement.

The deployer provisioned Azure Cache for Redis Standard C0, and the application hardcoded port 6380 in four places — so it could only ever reach the retiring service.

SKU: Balanced_B0 with high availability

Microsoft's official migration mapping puts Standard C0 → Balanced_B0. That is 2× the memory (0.5 GB vs 0.25 GB) for roughly 40% less cost (~$23/mo vs ~$40/mo HA in East US), and it is the smallest AMR SKU.

What changed

Application — both services, one code path

Azure Cache for Redis Azure Managed Redis
TLS port 6380 10000
Host suffix (public) .redis.cache.windows.net .<region>.redis.azure.net
Host suffix (US Gov / 21Vianet) .redis.cache.usgovcloudapi.net / .chinacloudapi.cn not available
Entra token scope / ACL username redis.azure.com/.default / oid claim identical
Databases up to 16 only db 0
  • New functions_redis_client.py is the single place that resolves service type → port → credentials. Flask session storage, the shared application cache, and the admin connection test all route through it; the four hardcoded 6380 literals are gone.
  • Service is detected from the host name suffix. New redis_service_type and redis_port admin settings override detection when a custom DNS name or private endpoint hides the suffix.
  • An unrecognized host name resolves to port 6380, so existing Azure Cache for Redis deployments behave exactly as before.
  • db=0 is safe on AMR: redis-py only emits SELECT for a non-zero index.
  • Redis Metrics now reports the resolved service and port, and whether that came from detection or an explicit setting. Redis Enterprise returns a different INFO field set, so absent counters render as "Not available".

Entra token refresh on live connections

Managed identity auth now uses redis-entraid, a redis-py streaming credential provider that renews the token in the background and re-issues AUTH on already-open pooled connections — required for AMR. Previously credentials were supplied at connect time only.

The app cache and session clients each get their own provider instance, because EntraIdCredentialsProvider holds a single re-authentication callback slot.

Requires msal 1.31→1.33 and azure-identity 1.23→1.24. The import is defensive: if the package is missing, SimpleChat falls back to the previous in-repo provider rather than failing to start.

Deployer

  • redisCache.bicep provisions Microsoft.Cache/redisEnterprise + databases + accessPolicyAssignments.
  • clusteringPolicy is set to NoCluster explicitly. The service default when omitted is OSSCluster, which requires a cluster-aware client — SimpleChat uses a plain redis.Redis and hands the same client to flask-session. NoCluster is valid to 25 GB and is the only policy changeable in place later.
  • redisCacheKind switch retained (managed default, classic fallback) because AMR is unavailable in Azure Government and Azure operated by 21Vianet.
  • Managed identity deployments disable access keys and grant the built-in default access policy on the database. The control-plane "Redis Cache Contributor" role is dropped for AMR — data access comes from the access policy, not RBAC.
  • postconfig.py retrieves keys with az redisenterprise database list-keys and installs the required CLI extension first.

Migration for existing deployments

Nothing to migrate. SimpleChat uses Redis as a look-aside cache and session store with a Cosmos DB fallback, which Microsoft explicitly sanctions as the "skip data migration" path (RDB export is Premium-only anyway). Cutover is a host name change in Admin Settings; the port follows automatically. Sessions do not survive it, so do it off-hours.

Review findings addressed

A review pass caught four real bugs, all fixed in this branch:

  1. streaming_credentials=False still built a streaming provider — leaked a thread + event loop per admin "Test" click.
  2. A single shared provider across two clients meant the app-cache client silently never got proactive re-AUTH.
  3. az redisenterprise needs a CLI extension nothing installed — postconfig would have hard-failed and written zero settings to Cosmos.
  4. postconfig wrote a definitive redis_service_type even when no Redis was deployed, which could point an existing ACR instance at port 10000.

Testing

  • New functional_tests/test_redis_service_type_detection.py (8 tests) — suffix matrix incl. gov/21Vianet/Enterprise/unknown, overrides, invalid ports, monitoring payload.
  • New functional_tests/test_redis_client_factory.py (8 tests) — client construction per auth type × service, db=0, kwargs passthrough, validation, redis-entraid fallback, per-purpose providers.
  • Updated functional_tests/test_redis_entra_token_auth.py (7 tests) — streaming provider, sovereign-cloud authority.
  • Updated test_cosmos_wave1_cache_fallback.py, which was patching a symbol that no longer existed and therefore attempting real network calls instead of exercising the injected failure.
  • 13 suites pass, Bicep compiles clean with main.json verified in sync, conditional resource guards verified mutually exclusive in the compiled ARM, no circular imports in any load order.

Two test_file_sync_capability.py failures are pre-existing on the base commit and unrelated.

Versions

  • application/single_app/config.py: 0.261.0090.261.011
  • deployers/version.txt: 1.0.261.0.28

Docs: new AZURE_MANAGED_REDIS_SUPPORT.md, plus updates to docs/admin/scale.md, the manual provisioning guide, the deployer README, and release notes.

Reviewer notes

⚠️ The one item that could not be fully confirmed from the ARM reference is the AMR diagnostic-settings shape (AllMetrics on the cluster, allLogs on the database). Worth a what-if against a real subscription before merge.

Azure Cache for Redis Basic, Standard, and Premium retire 2028-09-30, and
Azure Managed Redis is the replacement. The two services listen on different
TLS ports (10000 vs 6380), so the hardcoded 6380 in four call sites could only
ever reach the retiring service.

Application:
- Add functions_redis_client.py as the single place that resolves service type,
  port, and credentials, and route session storage, the shared app cache, and
  the admin connection test through it.
- Detect the service from the host name suffix, with optional redis_service_type
  and redis_port admin overrides for custom DNS and private endpoints. An
  unrecognized host keeps the previous port 6380 behavior, so existing Azure
  Cache for Redis deployments are unaffected.
- Use the redis-entraid streaming credential provider so pooled connections
  re-AUTH before the Entra token expires, with one provider per long-lived
  client because the provider holds a single callback slot. Falls back to the
  in-repo provider when the package is absent so startup cannot break.
- Align the admin connection test on the same token scope and factory the
  application uses; it previously used the legacy cacheinfra endpoint.
- Report the resolved service and port in Redis Metrics.

Deployer:
- Provision Azure Managed Redis Balanced_B0 with high availability, the
  documented replacement for the Standard C0 previously deployed.
- Set clusteringPolicy to NoCluster explicitly; the service default is
  OSSCluster, which requires a cluster-aware client SimpleChat does not use.
- Keep a redisCacheKind switch for Azure Government and 21Vianet, where Azure
  Managed Redis is unavailable.
- Grant data access through the redisEnterprise database access policy
  assignment, and retrieve keys with az redisenterprise.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread deployers/bicep/postconfig.py Fixed
Comment thread application/single_app/route_backend_settings.py Fixed
Comment thread application/single_app/app_settings_cache.py Fixed
Comment thread application/single_app/app_settings_cache.py Fixed
Comment thread application/single_app/app_settings_cache.py Fixed
Comment thread application/single_app/app_settings_cache.py Fixed
Comment thread application/single_app/functions_redis_client.py Dismissed
Comment thread application/single_app/functions_redis_client.py Dismissed
Addresses two CodeQL findings introduced by the Azure Managed Redis change.

py/stack-trace-exposure (route_backend_settings.py): moving Key Vault secret
retrieval into the shared client factory meant the client-construction handler
now catches credential errors, and it returned str(exc) to the browser. That
regressed the original hardening, which deliberately logged Key Vault failures
and replied with a generic message. Validation errors are now caught separately
and still surfaced, while credential errors are logged under [REDIS_TEST] and
answered generically.

py/incomplete-url-substring-sanitization (postconfig.py): the fallback that
infers the Redis offering from a host name used a substring check, so a host
like evil.redis.azure.net.attacker.example.com would be treated as Azure
Managed Redis and configured with port 10000. It now matches the full suffix,
consistent with the application's own detection.

Also removes a redundant function-level import in app_settings_cache.py that
CodeQL flagged as py/import-and-import-from.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread application/single_app/route_backend_settings.py Fixed
Comment thread application/single_app/app_settings_cache.py Fixed
Comment thread application/single_app/app_settings_cache.py Fixed
CodeQL flagged the Redis connection test as an information-exposure and
clear-text-logging source: the validation handler returned the exception text
to the browser, and both handlers interpolated the raw exception into log_event,
which CodeQL traces into the shared Application Insights sinks.

Both handlers now pass the exception through sanitize_log_message before
logging and reply with fixed messages. The route already returns specific 400s
for a missing host, key, or Key Vault secret name before reaching the factory,
so no actionable detail is lost.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
REDIS_TOKEN_REFRESH_BUFFER_SECONDS and _get_redis_entra_token_scope had no
callers outside functions_redis_client, so the back-compat re-export only
produced CodeQL py/unused-import noise. REDIS_ENTRA_TOKEN_SCOPE and
RedisManagedIdentityCredentialProvider are still re-exported because existing
callers and tests reference them through app_settings_cache.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CodeQL traced the interpolated exception message from the Redis connection test
into the shared Application Insights sinks as a clear-text-logging source, since
the failing call chain resolves an access key or Key Vault secret.

Both handlers now interpolate only the exception class name and pass
exceptionTraceback=True, so Application Insights still captures the full
exception and stack for diagnosis while no resolved credential material can
reach the log message. This matches the _safe_error_summary pattern already
used in functions_redis_monitoring.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer

Copy link
Copy Markdown
Contributor Author

CodeQL status

The CodeQL check is red. Here is what I fixed and what is left, so you can make an informed call.

Fixed in this PR (all genuinely introduced by this change)

Rule Where Resolution
py/stack-trace-exposure (medium) route_backend_settings.py Moving Key Vault retrieval into the shared client factory meant the construction handler caught credential errors and returned str(exc) to the browser. This regressed hardening the original code had. Validation errors are now handled separately and both handlers reply with fixed messages.
py/incomplete-url-substring-sanitization (high) deployers/bicep/postconfig.py The host-name fallback used a substring check, so evil.redis.azure.net.attacker.example.com would have been treated as Azure Managed Redis and configured with port 10000. Now matches the full suffix.
py/import-and-import-from (note) app_settings_cache.py Removed a redundant function-level import.
py/unused-import (note) app_settings_cache.py Dropped two back-compat re-exports that had no callers.

I also stopped interpolating exception messages into log_event in the Redis test route, logging only the exception type with exceptionTraceback=True so Application Insights still captures the full exception and stack.

Remaining 5 high alerts are pre-existing, in a file this PR does not touch

All five are py/clear-text-logging-sensitive-data inside functions_appinsights.py:

  • functions_appinsights.py is not in this PR's diff. git diff --name-only Development...HEAD returns nothing for it.
  • Alerts 2548, 2549, and 2550 are open on Development right now — the same alert IDs.
  • Alerts 2370 and 2371 were created 2026-08-06, roughly a month before this branch existed.

They surface as "new in code changed by this pull request" because app_settings_cache.py participates in a dataflow that reaches those existing sinks, so CodeQL re-fingerprints them. I confirmed this by severing the exception-message flow from my own code — the five alerts persisted unchanged.

Clearing them would mean refactoring functions_appinsights.py, the shared logging module used across the entire application. That is unrelated to Redis and would considerably widen the blast radius of this PR, so I left it alone.

The 4 remaining notes are py/cyclic-import, which Development already reports in config.py, functions_settings.py, functions_documents.py, route_backend_chats.py, and about ten other modules. I verified empirically that the new module imports cleanly in every order.

Everything else is green

All 13 other checks pass, including Analyze (python), swagger-route-check, xss-sink-check, broken-access-control-check, and malicious-pr-security-review.

CodeQL py/unused-import: REDIS_ENTRA_TOKEN_SCOPE and
RedisManagedIdentityCredentialProvider were re-exported for backward
compatibility, but no production module imported them through
app_settings_cache either before or after this change - on Development they
were referenced only inside app_settings_cache itself and by one test.

Both are dropped and the test now references them from functions_redis_client,
where they are defined. The create_redis_managed_identity_client wrapper is
kept, since app.py called it before this change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread application/single_app/app_settings_cache.py Dismissed
@paullizer

Copy link
Copy Markdown
Contributor Author

Security review comments resolved

All 12 CodeQL review threads on this PR are now resolved. Final disposition, verified against the CodeQL run on 523ca43f:

Fixed

Rule Severity Where Fix
py/incomplete-url-substring-sanitization high deployers/bicep/postconfig.py Substring check replaced with full-suffix matching, so evil.redis.azure.net.attacker.example.com is no longer treated as Azure Managed Redis and wired to port 10000
py/stack-trace-exposure medium route_backend_settings.py Credential-resolution errors are no longer returned to the browser; only the exception type is logged, with exceptionTraceback=True carrying the detail to Application Insights
py/import-and-import-from note app_settings_cache.py Redundant function-level import removed
py/unused-import note app_settings_cache.py Four re-exported names with no production consumers removed (523ca43f)

Both security-severity alerts were genuinely introduced by this PR, and both are gone.

Explained and resolved

3 × py/cyclic-import (note, no security severity) across functions_redis_client.py and app_settings_cache.py. Each thread has a reply with the specific reasoning. In short: the deferred imports of config and functions_keyvault are what make the cycle safe at runtime, consolidating Key Vault resolution is what fixes the test-route divergence bug in this PR, and CodeQL flags cycle participation regardless of import placement — so no placement clears it without restructuring config/functions_settings/functions_keyvault, which is outside a Redis change. Development already reports this rule on twelve modules.

Pre-existing, not from this PR

5 × py/clear-text-logging-sensitive-data in functions_appinsights.py. That file is not in this PR's diff, alerts 2548/2549/2550 are open on Development under the same IDs, and 2370/2371 were created 2026-08-06. Detail in the earlier comment.

Checks

13 of 14 green. CodeQL remains red solely on the five pre-existing alerts above.

@paullizer
Paul Lizer (paullizer) merged commit a92fcac into Development Sep 5, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants