From c7f4af597da9902285db6b492ec3f97b17f129db Mon Sep 17 00:00:00 2001 From: William Grant Date: Thu, 8 Oct 2026 10:55:03 +0000 Subject: [PATCH] fix: derive per-role Postgres passwords from the shared RLS secret Role passwords were derived from DATASOURCE_SECRET_KEY, the key that also encrypts stored data source options. Platform derives the same passwords from the shared RLS secret, and on deployments where the two values differ Redash cannot log in as an RLS role. Read the secret from REDASH_RLS_SECRET instead and raise if it is unset, rather than falling back to a key that would derive wrong passwords. DATASOURCE_SECRET_KEY keeps encrypting data source options unchanged. A test pins the derivation to the vector platform's gen_role_pass produces. --- redash/query_runner/pg.py | 10 ++++++++-- redash/settings/__init__.py | 5 +++++ tests/query_runner/test_pg.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/redash/query_runner/pg.py b/redash/query_runner/pg.py index de7385f823..229a919777 100644 --- a/redash/query_runner/pg.py +++ b/redash/query_runner/pg.py @@ -254,9 +254,15 @@ def _get_tables(self, schema): def _gen_role_pass(self, role_name: str) -> str: """ - Generate a password for a given role using the datasource secret and role name. + Generate a password for a given role using the shared RLS secret and role name. + + Must match stacklet.shared.sql.rls.gen_role_pass in the platform. """ - secret = settings.DATASOURCE_SECRET_KEY + secret = settings.RLS_SECRET + if not secret: + raise ValueError( + f"REDASH_RLS_SECRET is not set, so the password for role {role_name} can't be derived" + ) return hashlib.sha256(f"{secret}:{role_name}".encode("utf-8")).hexdigest() @inject_iam_auth diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py index 9ed74113a4..78e0c38d1c 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -68,6 +68,11 @@ # The secret key to use when encrypting data source options DATASOURCE_SECRET_KEY = os.environ.get("REDASH_SECRET_KEY", SECRET_KEY) +# The platform's shared RLS secret, from which per-role Postgres passwords are derived. +# Deliberately separate from DATASOURCE_SECRET_KEY, which must keep decrypting stored +# data source options. +RLS_SECRET = os.environ.get("REDASH_RLS_SECRET") + # Whether and how to redirect non-HTTP requests to HTTPS. Disabled by default. ENFORCE_HTTPS = parse_boolean(os.environ.get("REDASH_ENFORCE_HTTPS", "false")) ENFORCE_HTTPS_PERMANENT = parse_boolean(os.environ.get("REDASH_ENFORCE_HTTPS_PERMANENT", "false")) diff --git a/tests/query_runner/test_pg.py b/tests/query_runner/test_pg.py index e72001b0a9..1206d0f4f7 100644 --- a/tests/query_runner/test_pg.py +++ b/tests/query_runner/test_pg.py @@ -1,6 +1,7 @@ from unittest import TestCase +from unittest.mock import patch -from redash.query_runner.pg import build_schema +from redash.query_runner.pg import PostgreSQL, build_schema class TestBuildSchema(TestCase): @@ -41,3 +42,30 @@ def test_build_schema_with_data_types(self): self.assertListEqual( schema["main.users"]["columns"], [{"name": "id", "type": "integer"}, {"name": "name", "type": "varchar"}] ) + + +class TestGenRolePass(TestCase): + # The same vector is pinned against stacklet.shared.sql.rls.gen_role_pass in the + # platform; the two must derive identical passwords. + ROLE = "sso_alice_at_example_com_a1b2c3" + SECRET = "rls-test-secret" + EXPECTED = "30f1f1a4d09bae826d882a048b1ea7bcc6354e710dd4d7faf4aefd55e07913c9" + + def setUp(self): + self.runner = PostgreSQL({}) + + def test_matches_platform_vector(self): + with patch("redash.settings.RLS_SECRET", self.SECRET): + self.assertEqual(self.runner._gen_role_pass(self.ROLE), self.EXPECTED) + + def test_ignores_datasource_secret_key(self): + with ( + patch("redash.settings.RLS_SECRET", self.SECRET), + patch("redash.settings.DATASOURCE_SECRET_KEY", "something-else"), + ): + self.assertEqual(self.runner._gen_role_pass(self.ROLE), self.EXPECTED) + + def test_unset_secret_raises(self): + with patch("redash.settings.RLS_SECRET", None): + with self.assertRaisesRegex(ValueError, "REDASH_RLS_SECRET"): + self.runner._gen_role_pass(self.ROLE)