Skip to content

UN-4123 [FIX] A credential-free REDIS_URL no longer connects anonymously - #2299

Draft
muhammad-ali-e wants to merge 11 commits into
mainfrom
UN-4123-redis-url-password-fallback
Draft

muhammad-ali-e wants to merge 11 commits into
mainfrom
UN-4123-redis-url-password-fallback

Conversation

@muhammad-ali-e

Copy link
Copy Markdown
Contributor

What

  • In URL mode, a resolved {prefix}PASSWORD is now applied when the URL itself carries no credentials. Previously it was ignored and the client connected anonymously.
  • The three sample.env recipes show the password as a separate key rather than embedded in the URL.

Why

Follow-up to #2287, which is merged. The gap was not exercised by that PR's live testing against a managed Redis: the URL-mode run embedded the password in the URL, and the discrete run had no URL at all. The defect sits in the third combination — a clean URL plus a separately supplied password — which is the one worth recommending.

It is worth recommending because a URL travels to places a password should not:

Where the URL is printed Redacted?
unstract.redisEndpoint error messages no
ArgoCD ComparisonError condition no
ESO ExternalSecret.spec.target.template.data no — not a Secret

All three were raised in review of the chart-side PR, and all three exist because the password is in a URL. Keeping it in its own key closes the class rather than patching each place a URL gets printed — and this change is what makes that configuration actually work.

The failure also misdirects: the endpoint answers NOAUTH on the first command, which reads as a broken server rather than a dropped password.

How

  • Credentials fill a gap the URL leaves and never override one. ConnectionPool.from_url ends with kwargs.update(url_options), so an existing rediss://:<password>@host URL behaves exactly as before — verified for both the :pw@ and user:pw@ spellings.
  • Gated on the password, with the username riding along. A username alone is not a credential, and values.yaml ships REDIS_USER: default — filling that in on its own would make redis-py send AUTH to the in-cluster server, which has none. I wrote that regression into the first version and caught it by testing the chart's default posture, not by re-reading the diff.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No. The change only adds credentials where there were none. A URL carrying credentials is untouched (the URL still wins), the discrete path is untouched, and a deployment with no password is untouched. The one case that changes is the one that was previously broken.
  • The username-alone case is explicitly pinned by a test, because that is where a careless version of this change would break the default in-cluster deployment.

Database Migrations

  • None.

Env Config

  • No new variables. {prefix}PASSWORD and {prefix}USER already existed; they are now honoured in URL mode.

Relevant Docs

  • backend/sample.env, runner/sample.env, workers/sample.env — recipes updated.

Related Issues or PRs

Dependencies Versions

  • None.

Notes on Testing

  • unstract/core 243 passed, backend 35 passed, ruff clean.
  • Six new cases in TestUrlModeCredentials, mutation-verified: removing the fallback fails two of them, ungating the username fails the third.
  • Cases covered: credential-free URL + password; :pw@ in the URL wins; user:pw@ in the URL wins; username rides with the password; username alone is not a credential (the regression guard); no credentials anywhere stays anonymous.

Screenshots

n/a

Checklist

I have read and understood the Contribution Guidelines.

🤖 Generated with Claude Code

In URL mode the resolved password was never passed to the client: credentials
had to be embedded in the URL, and a {prefix}PASSWORD set beside a
credential-free URL was silently ignored. The client connected ANONYMOUSLY and
the endpoint answered NOAUTH on the first command, which reads as a broken
server rather than a dropped password.

This was not exercised by the live managed-Redis testing on UN-4123, because
both configurations tried there avoided it: the URL-mode run embedded the
password in the URL, and the discrete run had no URL at all. The gap sits in the
third combination, which is the one worth recommending.

WHY IT IS WORTH RECOMMENDING. A URL ends up in places a password should not:
the endpoint helper's error messages, ArgoCD's ComparisonError condition, and —
under ESO — the ExternalSecret's spec.target.template.data, which is not a
Secret and is not redacted. All three were raised in review of the chart-side
PR, and all three exist because the password is in a URL. Keeping it in its own
key closes the class rather than patching each place the URL is printed; this
change is what makes that configuration usable.

Credentials FILL A GAP the URL leaves and never override one.
ConnectionPool.from_url ends with kwargs.update(url_options), so an existing
rediss://:<password>@host URL keeps behaving exactly as before — verified for
both the :pw@ and user:pw@ spellings.

GATED ON THE PASSWORD, with the username riding along. A username alone is not a
credential, and values.yaml ships REDIS_USER: default: filling that in on its own
would make redis-py send AUTH to the in-cluster server, which has none, turning
a working default deployment into a failing one. I wrote that regression into
the first version of this change and caught it by testing the chart's own
default posture rather than by reading the diff.

Six cases added, covering both URL spellings, the gap-filling case, the username
pairing, the username-alone regression, and the legitimately anonymous server.
Mutation-verified: removing the fallback fails two, ungating the username fails
the third.

The three sample.env recipes now show the password as a separate key, and say
that an existing URL-embedded credential still works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerConfidence Score: 3/5

[Critical risk] Fixes Redis authentication when credentials are supplied separately from URL.

The PR does not appear safe to merge while the two outstanding Redis credential-resolution failures remain.

Fix All in Claude CodeFindings

  1. P1 Prefixed URL inherits unrelated password ▶
  2. P1 Username-only URLs drop passwords ▶
Fix with agent prompt
### Issue 1
unstract/core/src/unstract/core/cache/redis_client.py:711-712
If a prefixed client has its own credential-free URL for an anonymous Redis endpoint while `REDIS_PASSWORD` is set for another endpoint, credential resolution falls back to that generic password. Passing it into URL mode now makes the prefixed client send AUTH to the anonymous endpoint, breaking a connection that previously worked.

### Issue 2
unstract/core/src/unstract/core/cache/redis_client.py:undefined-816
If a URL contains an ACL username but no password, such as `redis://alice@host`, and the password is supplied separately, the `@` check skips the fallback. The client then connects without that password and cannot authenticate to a password-protected endpoint.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Summary

The PR makes separately supplied Redis passwords available in URL mode to core clients, the Django cache, and Socket.IO publishing, and updates the sample configurations to show a password outside the URL.

  • The new cache and Socket.IO paths address parts of the split-credential configuration.
  • No distinct new actionable finding was established in the changes since the previous review.

Reviews (2) · Last reviewed commit: "UN-4123 [FIX] Carry the credential fallb..."

Comment thread backend/sample.env
Comment on lines +677 to +678
password=env.get("password"),
username=env.get("username"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Prefixed URL inherits unrelated password

If a prefixed client has its own credential-free URL for an anonymous Redis endpoint while REDIS_PASSWORD is set for another endpoint, credential resolution falls back to that generic password. Passing it into URL mode now makes the prefixed client send AUTH to the anonymous endpoint, breaking a connection that previously worked.

Prompt To Fix With AI
This is a comment left during a code review.
Path: unstract/core/src/unstract/core/cache/redis_client.py
Line: 677-678

Comment:
**Prefixed URL inherits unrelated password**

If a prefixed client has its own credential-free URL for an anonymous Redis endpoint while `REDIS_PASSWORD` is set for another endpoint, credential resolution falls back to that generic password. Passing it into URL mode now makes the prefixed client send AUTH to the anonymous endpoint, breaking a connection that previously worked.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

# not a credential: values.yaml ships REDIS_USER: default, so filling it in
# on its own would make redis-py send AUTH to the in-cluster server, which
# has none — turning a working default deployment into a failing one.
if password and "@" not in parts.netloc:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Username-only URLs drop passwords

If a URL contains an ACL username but no password, such as redis://alice@host, and the password is supplied separately, the @ check skips the fallback. The client then connects without that password and cannot authenticate to a password-protected endpoint.

Prompt To Fix With AI
This is a comment left during a code review.
Path: unstract/core/src/unstract/core/cache/redis_client.py
Line: 782

Comment:
**Username-only URLs drop passwords**

If a URL contains an ACL username but no password, such as `redis://alice@host`, and the password is supplied separately, the `@` check skips the fallback. The client then connects without that password and cannot authenticate to a password-protected endpoint.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

…nsumers

The first commit fixed create_redis_client and stopped there, which left the
recommendation it enables half true. unstract has THREE Redis consumers and a
credential-free URL reached only one of them:

  create_redis_client      backend, workers, runner, platform-service   fixed
  build_socketio_redis_url kombu / Socket.IO                            ANONYMOUS
  Django cache LOCATION    django-redis                                 ANONYMOUS

Both now fill the same gap, on the same terms: credentials are added only when
the URL carries none, and a URL bearing them still wins.

SOCKET.IO. kombu takes a URL and nothing else — no connection kwargs — so a
separately supplied password cannot reach it any other way; the credentials go
into the URL string itself. The password is then in that string, unavoidably.
That is acceptable here and not in a values file: this URL is built in-process
and handed straight to the client, rather than written into a manifest, a Secret
template or an error message, which is the distinction the whole change is
about. An anonymous publisher against an authenticated server does not fail
loudly — Socket.IO events simply stop arriving.

DJANGO CACHE. django-redis reads OPTIONS["PASSWORD"] and feeds it to
ConnectionPool.from_url, which ends with kwargs.update(url_options) — so setting
it is safe even when the URL has its own. It is still skipped when the URL
carries credentials, because passing them twice invites the two to disagree.

Five cases added. The settings harness now splices the urllib import from source
instead of hand-writing it: the hand-written copy had to be remembered the
moment settings started using another name from that module, and the symptom was
a NameError in every case rather than one clear failure. That is the second time
a hand-copied import in this harness has broken; the unstract.core import block
was the first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
❌ frontend unit 0 1 0 0 0.0
✅ unit-backend unit 1323 0 0 1 35.1
✅ unit-connectors unit 72 0 0 0 9.1
✅ unit-core unit 245 0 0 0 2.2
✅ unit-platform-service unit 15 0 0 0 2.1
✅ unit-rig unit 120 0 0 0 3.5
✅ unit-runner unit 10 0 0 0 3.3
✅ unit-sdk1 unit 587 0 0 0 27.3
✅ unit-workers unit 1373 0 0 1 120.2
TOTAL 3745 1 0 2 202.8

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
💤 Covered, but not exercised in this build
  • auth-login — User can log in and obtain a session cookie. (covered by e2e-login; no result reported in this build)
  • adapter-register-llm — Register and validate an LLM adapter. (covered by integration-backend; no result reported in this build)
  • workflow-author — Create a workflow; its source+destination endpoints materialise and are configurable. (covered by integration-backend; no result reported in this build)
  • co-owner-manage — Add/remove co-owners of a shared resource; enforce the last-owner guard. (covered by integration-backend, e2e-coowners; no result reported in this build)
  • workflow-create-execute — Create a workflow, configure source+destination, execute, poll, fetch result. (covered by e2e-workflow; no result reported in this build)
  • api-deployment-provision — Deploying a workflow as an API mints a usable key and a resolvable endpoint. (covered by integration-backend; no result reported in this build)
  • api-deployment-auth — Unauthenticated or mis-scoped API-deployment calls are rejected before dispatch. (covered by integration-backend; no result reported in this build)
  • api-deployment-run — Deploy a workflow as an API, POST a document, receive structured JSON. (covered by e2e-api-deployment; no result reported in this build)
  • mcp-server-auth — Unauthenticated or mis-scoped hosted-MCP calls are rejected before any tool runs. (covered by integration-backend; no result reported in this build)
  • mcp-platform-auth — The org-scoped MCP endpoint stays behind the platform-API-key middleware; unauthenticated or mis-scoped calls reach no tool. (covered by integration-backend; no result reported in this build)
  • platform-key-whoami — A platform API key resolves its own organisation over the org-less whoami endpoint; the org comes from the key row, not the URL. (covered by integration-backend; no result reported in this build)
  • prompt-studio-author — Create a Prompt Studio project and add a prompt to it. (covered by integration-backend; no result reported in this build)
  • prompt-studio-fetch-response — Prompt Studio: create project, add prompt, run a prompt, get response. (covered by e2e-prompt-studio; no result reported in this build)
  • connector-register-test — Connector credentials are validated against the live system and stored encrypted. (covered by integration-backend; no result reported in this build)
  • pipeline-etl-execute — Run an ETL pipeline from source connector to destination. (covered by e2e-etl; no result reported in this build)
  • usage-aggregate-read — Per-run token usage aggregates correctly and stays scoped to its organization. (covered by integration-backend; no result reported in this build)
  • usage-token-tracking — Per-execution token usage is recorded and retrievable. (covered by e2e-api-deployment; no result reported in this build)
  • callback-result-delivery — Async results are posted back via the callback worker. (covered by e2e-api-deployment; no result reported in this build)

@muhammad-ali-e
muhammad-ali-e marked this pull request as draft September 25, 2026 05:07
muhammad-ali-e and others added 9 commits September 25, 2026 10:41
BOTH ARE IN MY OWN FALLBACK, and both are cases the fallback gets wrong rather
than cases it misses.

A URL CARRYING ONLY A USERNAME NO LONGER DROPS THE PASSWORD. The check keyed on
"@" being absent from the netloc, which is not the same question: redis://alice@host
carries an @ and no password. The fallback was skipped, the client got a username
with no password, and could not authenticate. Keyed on urlsplit().password being
None instead, which asks it directly. apply_url_credentials now uses the same
test and keeps a username the URL already carries.

CREDENTIALS RESOLVE AT THE URL'S OWN LEVEL, by the same rule the database
already follows. A prefix that brought its OWN url may point at a DIFFERENT
endpoint — an anonymous one, say — while the generic REDIS_PASSWORD is set for
the primary. Letting that password reach across made the prefixed client send
AUTH to a server with none, turning a working connection into a failing one. An
INHERITED url is the same endpoint as the generic one, so the full fallback
chain still applies there. For env_prefix "REDIS_" the two levels are the same
variable and nothing changes.

That the database rule and the credential rule now say the same sentence is the
point: "at the URL's own level" is one idea an operator can hold, rather than
two resolution orders that happen to differ.

Five cases added, covering the username-only URL, the prefix that must not
inherit, the prefix's own password, the inherited url that still should, and
the Socket.IO builder following the same rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…netloc bug in it

A four-agent standardised review of this PR found two Criticals, both in code I
added in the two commits before it. Every fix below is mutation-verified.

apply_url_credentials CORRUPTED A URL THAT CARRIED A USERNAME. parts.netloc
INCLUDES userinfo, and the function prepended to it — so redis://alice@host with
a separately supplied password became redis://alice:pw@alice@host. Userinfo
splits on the LAST "@", so kombu parsed the password as "pw@alice". Not a
missing credential, a WRONG one: the server answers WRONGPASS, the publisher
dies, and socketio.Server is built with logger=False so Socket.IO events simply
stop. The docstring claimed this function existed to prevent exactly that.

It now rebuilds the netloc from the host part, and reuses a username the URL
already carries VERBATIM rather than re-quoting it — quoting an already-encoded
username turned al%40ice into al%2540ice.

THE DJANGO CACHE KEYED ON "@" BEING ABSENT — the precise heuristic the core
comment rejects, 170 lines away, in the same PR. A username-only URL left the
cache anonymous while create_redis_client authenticated: one process, one
endpoint, two outcomes, which is the sentence this work exists to delete.

Rather than align the third hand-rolled gate, it is gone: the cache LOCATION now
goes through apply_url_credentials, the same helper the Socket.IO URL uses. That
also closes a gap neither gate could — django-redis 5.4.0 discards
OPTIONS["USERNAME"], so an ACL username could not reach this cache at all and it
authenticated as `default` while the other consumers used the configured user.
It reads REDIS_USER straight from os.environ rather than the module-level
fallback of "default", so the cache and the client send the same AUTH form.

EMPTY IS ABSENT. redis://:@host parses to password "", and both redis-py
(`if url.password:`) and kombu (`unquote(password or "") or None`) read that as
no password — so `is None` declined to fill a gap the libraries agree is a gap.
That shape is what Helm emits from redis://:{{ .Values.password }}@host with an
empty value. Truthiness in all three places now.

A DEAD GUARD REMOVED. `and not parts.username` could not fail: from_url ends
with kwargs.update(url_options), so a URL username wins regardless. An
unfalsifiable guard is where a later reader deletes something load-bearing.

TESTS 250 -> 257, and the existing ones were not strong enough. The socketio
credential test asserted a SUBSTRING, which passes on the corrupt URL — that is
why the Critical survived a test written for the same shape. Assertions are on
parsed fields now. Added: username-only URLs on every consumer, double-encoding,
a password containing @ / : (unencoded it reparses with host "ss" — a different
server), the empty-password shape, the {prefix}USERNAME spelling, and the cache
carrying an ACL username. Six mutations verified, including the netloc concat.

COMMENT CORRECTIONS. "kombu takes a URL and NOTHING else — no connection
kwargs" is false in both places it appeared: KombuManager forwards
connection_options to kombu.Connection, which takes userid= and password=. The
URL is where TLS must go; credentials ride along by choice, not necessity. And
"never written into an error message" was wrong — the backend stores the result
as settings.SOCKET_IO_MANAGER_URL, which Django's SafeExceptionReporterFilter
does not redact. Two past-tense comments describing a mid-branch state as
shipped history are now present tense.

The three sample.env recipes now show REDIS_USER= blank, because this change
makes that value load-bearing in URL mode for the first time while the files
still ship REDIS_USER=default a few lines above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eans unset

Second standard-review round. Round 1's two Criticals held up under attack —
apply_url_credentials was fed 16 URL shapes round-tripped through urlsplit,
redis-py and kombu, and all three agree on every one. What round 2 found is a
layer down: the credential CHAIN, and tests that could not fail.

* base.py re-spelled the username chain instead of asking core for it, and
  dropped a leg. The resolver accepts REDIS_USER and REDIS_USERNAME;
  platform-service ships the second, so one shared Redis-credential secret can
  inject it — and the Django cache then authenticated as `default` while
  create_redis_client in the SAME process authenticated as the configured ACL
  user. Where `default` is disabled, the cache alone fails. That is verbatim
  the divergence the round-1 comment says the change exists to delete, so the
  chain now lives in one exported helper both sides call.

* BLANK MEANT SET for credentials, which broke this PR's own recipe.
  workers/sample.env ships `CACHE_REDIS_PASSWORD=` uncommented, and
  `os.getenv(prefixed, os.getenv(generic))` hands back that blank rather than
  consulting REDIS_PASSWORD. An operator following the documented managed-Redis
  recipe — REDIS_URL without credentials plus REDIS_PASSWORD — got url_password
  "" on every prefixed client, apply_url_credentials no-opped, and the worker
  caches connected ANONYMOUSLY. The module already documented blank-means-unset
  for parse_db and _parse_bool; host, port, password and user now follow it.

  This changes a test that asserted the old behaviour. It was a characterisation
  test that said so — "documents a trap rather than endorsing it… this test
  fails if that behaviour ever changes, so the chart-side guarantee can be
  revisited". This is that revisit: the guarantee it rested on (the chart never
  renders an empty credential) is not one sample.env keeps.

* PORT was the one var with no error contract — int() straight off os.getenv,
  so a blank REDIS_PORT= raised at client construction naming neither the
  variable nor the prefix. parse_port now mirrors parse_db.

TESTS — the round-1 lesson had not actually been applied. Four assertions still
compared substrings of a constructed URL, including one two lines below the
test I fixed in round 1, carrying a comment explaining the very bug it was
still vulnerable to. Each is replaced by parsed fields, and the password one by
an unquote() round-trip, since a hostname guard catches only the host half of a
doubled userinfo.

Two mutations survived round 2 and now fail:

* reversing url_user/username precedence — no test set a URL username AND
  REDIS_USER at once, which is the only environment that tells them apart, and
  values.yaml ships REDIS_USER: default so the pairing is ordinary.
* deleting `not parts.password` — harmless for the password (from_url ends with
  kwargs.update(url_options)) but load-bearing for the USERNAME rider, so it
  turned a one-argument AUTH into a two-argument one while the Socket.IO URL
  for the same config kept sending one. The comment above it argued the guard
  was unfalsifiable, which is true of the password half and false of the other
  — i.e. it documented the deletion the suite could not catch.

The _derive harness executes two source ranges with a ~415-line gap between
them, and anything in that gap is invisible to every assertion in the file:
adding `REDIS_PASSWORD = ""` at base.py:251 leaves all 44 tests green while
shipping a broken cache. It now asserts its own coverage — every Redis line in
base.py must fall inside a spliced range — and fails naming the line that
escaped. Verified against that exact insertion.

Also: test_the_shipped_default_user_alone_adds_nothing passed REDIS_PASSWORD=""
and so returned at the `if not password` guard before the username was read,
making the test named for that choice blind to it.

Core 120 -> 126 (263 in the dir), backend 42 -> 44. Six mutations verified
caught. The exposure note now names CACHES["default"]["LOCATION"] as the second
unredacted landing place for the password, and says why it is a deliberate
trade rather than an oversight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…top stripping

Review of the round-2 commit. Two of the three findings are defects that commit
introduced, and one of them made the change actively worse than before it.

* BLANK-MEANS-UNSET WAS APPLIED TO CREDENTIALS BUT NOT TO TLS. Round 2 made a
  blank {prefix}PASSWORD/USER/HOST fall through to the generic level; the four
  TLS vars kept shadowing. So `CACHE_REDIS_SSL=` beside `REDIS_SSL=true` now
  gave the worker cache a real AUTH over an UNENCRYPTED socket — the credential
  round 2 taught it to inherit, on the wire in clear. Before round 2 the
  password was suppressed too and the client simply failed to authenticate.
  Half a convention was worse than none. All four TLS vars go through env_chain
  now, and the comment claiming the rule held "throughout this module" is true
  again rather than aspirational.

* env_chain STRIPPED what it returned, and backend/settings/base.py does not.
  A password or username from a file-mounted secret ends in a newline, so core
  truncated it while the settings module kept it, and the Socket.IO URL and the
  cache LOCATION — built ten lines apart — carried different secrets. Emptiness
  is now tested on the stripped value and the RAW value returned, so the two
  agree again. That is the property round 2 claimed to establish.

* url_username_from_env, the helper round 2 added specifically to unify this
  chain, was itself the one place not honouring the rule: plain `or` with no
  blank handling, so ` alice ` and `appuser\n` resolved differently from every
  other read of the same variable. It calls env_chain now.

Also:

* base.py still did a bare int() on REDIS_PORT, so a blank REDIS_PORT= raised
  during Django settings import — the backend alone failing to start while
  every worker came up fine on 6379. parse_port was exported in round 2 and
  simply not wired up here.

* parse_port and parse_db named the PREFIXED variable in their diagnostics even
  when the bad value came from the generic one, sending an operator to grep for
  a key that is not set. env_chain_named returns the winning name, and the port
  fallback is logged at error rather than warning: the fallback is a PORT, so on
  a host serving both 6379 and 6380 a typo lands the client on the other server
  rather than failing.

TestContainerAllowlists caught something real and deserves the credit: it
derives the container env allowlist by scanning `os.getenv` calls in the source,
so rewriting those reads to go through env_chain made the whole TLS set vanish
from the guard silently. That is the same drift class as the two test harnesses
fixed in round 2. The derivation now scans the arguments of every env-reading
call, still scoped to those call names so a variable merely named in a docstring
is not demanded of every container.

Core 126 -> 137 (274 in the dir). Five mutations verified caught, including both
blank-TLS shadows and the strip asymmetry.

NOT fixed here, recorded deliberately: unstract-cloud's values.yaml pins
CACHE_REDIS_USERNAME: "" with a comment stating the shadowing is intentional and
"must not be fixed". That reasoning is now void — the pin is inert rather than
load-bearing. The wire change is benign (Redis 6+ treats `AUTH default <pw>` and
`AUTH <pw>` alike, and redis-py retries one-arg anyway), but the comment belongs
in the cloud PR, not this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 3 broadened REDIS_SSL parsing in core and left backend/settings/base.py on
its own `== "true"`. _TRUE_LITERALS accepts 1, yes and on, so the two halves of
one process disagreed about whether TLS was on:

  REDIS_SSL=1  ->  every create_redis_client consumer connects rediss://
                   the Django cache builds a redis:// LOCATION
                   and `if REDIS_SSL` never runs, so CONNECTION_POOL_KWARGS
                   carries no cert_reqs, no check_hostname, no CA either

Against a TLS-only managed endpoint the backend cache alone fails; against one
accepting both it authenticates in clear while everything else is encrypted. In
the same process SOCKET_IO_MANAGER_URL — built by core — came back rediss://.
That is verbatim the "one endpoint, one process, two policies" failure this
module was centralised to prevent, reintroduced by centralising only one side.
resolve_ssl_enabled is exported and base.py calls it.

Also:

* The fifth read of SSL_CHECK_HOSTNAME was missed by round 3's conversion. It
  decides whether the value was EXPLICIT, so a blank prefixed value fell
  through for the value but not for the explicitness — on Sentinel + TLS a
  global REDIS_SSL_CHECK_HOSTNAME=true was honoured on the discovery
  connections and silently dropped on the master one.

* parse_port treated "" as unparseable and logged at ERROR. base.py hands it
  the raw env value rather than env_chain's None, so a bare REDIS_PORT= — the
  repo's own "leave the default" spelling, and the wording of the comment round
  3 added right above it — produced a startup error line in the backend and
  nowhere else.

* host is stripped explicitly. env_chain returns the raw value so a credential
  keeps whatever whitespace it was given, but urlsplit() drops a trailing
  newline from a URL host while redis.Redis(host=...) keeps it, so a
  file-mounted REDIS_HOST would fail DNS on the discrete path and succeed on
  the URL one.

Core 137 -> 139 (276 in the dir), backend 44 -> 57. The new backend class
parametrises every true and false literal across both consumers, which is what
pins them together; nothing previously exercised any literal but "true".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four review rounds produced the same finding four times in different clothes —
a setting centralised in unstract.core with the parallel read in base.py left
behind. The "@" credential heuristic, the REDIS_USERNAME spelling,
blank-means-unset, and the SSL true-literals were each fixed with a test for
that one setting, and each time the next round found the next one.

So this pins the property instead. TestTheBackendAgreesWithCoreOnEverySharedSetting
runs 19 environments an operator would actually write and asserts that where the
Django cache ends up — host, port, db, username, password, TLS — is where
create_redis_client in the same process ends up. A setting centralised on one
side only now fails without anyone having to predict which setting it will be.

It found a real divergence on its first run, and not one of the four above: the
DISCRETE path cannot carry an ACL username. django-redis discards
OPTIONS["USERNAME"], so with REDIS_HOST + REDIS_USER + REDIS_PASSWORD the cache
authenticated as the built-in `default` while every other client in the process
authenticated as the configured user — and where `default` is disabled, the
cache alone failed. That is the identical bug the URL branch was fixed for,
left standing on the path the module docstring calls primary.

The comment beside it said this was deliberate ("auth stays password-only as the
built-in `default` user ... this cache never sends one"), and a test pinned it as
a "stated invariant" so a django-redis bump would be visible. Passing a key the
library throws away is not an invariant worth pinning; reaching the same server
as everything else is. Both branches now build the LOCATION through
apply_url_credentials.

The password still goes to OPTIONS["PASSWORD"] when no username is in play:
that key matches Django's SafeExceptionReporterFilter and is masked in a
settings dump, while LOCATION is not — so the extra exposure is paid only where
django-redis leaves no alternative.

Backend 57 -> 77. Three mutations verified caught by the sweep, including two it
was not specifically written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng it

Round 5 found no Critical and no High — the recurring "centralised on one side
only" class has stopped on the path the parity sweep covers, confirmed against
an exhaustive 2160-configuration matrix. These are the Mediums it did raise.

* The sweep reimplemented django-redis's precedence. It parsed the LOCATION
  with urlsplit and hardcoded "userinfo wins, OPTIONS['PASSWORD'] backfills".
  That is correct for 5.4.0 — but it is a second copy of library behaviour
  inside the one test whose stated job is to survive changes it cannot predict,
  so a bump that reversed the precedence would leave it green while the cache
  diverged. It now builds the dict from a real ConnectionFactory, so the rule is
  never restated. (Its pools are memoised by LOCATION, so they are cleared per
  case; leaving that out makes three cases answer with an earlier case's pool,
  verified.)

* An ACL username with NO password reached neither side usefully and reached
  them differently: apply_url_credentials returns the URL untouched when there
  is no password, so the cache went anonymous, while core set username=alice and
  redis-py packed `AUTH alice None` and raised DataError on the FIRST command —
  a type error from inside the library, nowhere near the values file that caused
  it. Redis has no one-argument ACL AUTH, so this is not a configuration that
  exists; it is now reported at startup and the username dropped, which also
  makes the two sides agree. The sweep's 19 cases paired a username with a
  password every time, so it could not see this; two cases added.

* The db relocation warning gave the discrete-mode explanation on both paths —
  it fires in URL mode too, where the previous db came from the URL's own path
  and OPTIONS['DB'] was never the mechanism. The db numbers were right in every
  case; only the stated reason was wrong. Reason dropped, numbers kept.

The Sentinel branch is now marked as OUTSIDE the sweep, with the four
divergences from create_redis_client that live there spelled out — the
"default" username default, the ignored REDIS_USERNAME spelling, no TLS at all,
and a different default port. All four are pre-existing on main and this PR only
replaced an int() there; changing behaviour in a branch no test in this file
executes is its own ticket, not this one.

Backend 77 -> 79. Two mutations verified caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The parity sweep stopped at the Sentinel branch, and I recorded the four
divergences living there as "its own ticket". That was the wrong call twice
over: a guarantee that stops at a mode boundary is a half-truth, and the reason
I gave for deferring — "changing code no test in this file executes" — was not
true. _derive's slice spans BOTH branches; it just needed an env selecting this
one. "No test executes it" and "no test was written for it" are different
statements, and only the second was ever the case.

Four divergences from create_redis_client, all pre-existing on main:

* THE SENTINEL PORT. The module-level REDIS_PORT defaults to 6379 — the
  STANDALONE port — while core's Sentinel path resolves with
  default_port="26379". With REDIS_PORT unset this cache looked for sentinels
  on the wrong port while every other client found them. The chart always sets
  REDIS_PORT, which is why it stayed hidden.

* THE USERNAME, twice. REDIS_USER defaults to "default" at module scope, so
  this cache sent a two-argument ACL AUTH where core sends the one-argument
  form; and it read REDIS_USER directly, so the REDIS_USERNAME spelling
  platform-service ships was ignored. Both now come from the shared resolver,
  and the same value reaches SENTINEL_KWARGS, the LOCATION and the Socket.IO
  URL.

* TLS REACHED NEITHER CONNECTION. A TLS-only Sentinel deployment got a
  plaintext cache while core encrypted both the discovery and the master
  connections. Carrying it in SENTINEL_KWARGS alone would encrypt discovery and
  leave the master in clear, so it goes to CONNECTION_POOL_KWARGS too — core
  builds both from one env dict for exactly this reason.

BEHAVIOUR CHANGES for existing Sentinel deployments, stated rather than buried:
an unset REDIS_PORT now resolves 26379 instead of 6379; no username is sent
unless one is configured (Redis 6+ treats `AUTH default <pw>` and `AUTH <pw>`
as the same command, and redis-py retries one-arg regardless, so this is a wire
change rather than an auth change); and REDIS_SSL now applies here. Flag off is
byte-identical — asserted.

Backend 79 -> 87. Four mutations verified caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant