diff --git a/compose.yaml b/compose.yaml index 426a35148e..6627b9958b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,6 +13,7 @@ x-redash-environment: &redash-environment REDASH_HOST: http://localhost:5001 REDASH_LOG_LEVEL: "INFO" REDASH_REDIS_URL: "redis://redis:6379/0" + ASSETDB_DATABASE_URI: "postgresql://postgres@postgres/postgres" REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres" REDASH_RATELIMIT_ENABLED: "false" REDASH_MAIL_DEFAULT_SENDER: "redash@example.com" diff --git a/pyproject.toml b/pyproject.toml index d5e93a84ad..5050ad5145 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,7 @@ [project] requires-python = ">=3.8" +name = "redash" +version = "25.06.0-dev" [tool.black] target-version = ['py38'] diff --git a/redash/handlers/query_results.py b/redash/handlers/query_results.py index 08a7352f03..4dd3cfd6ca 100644 --- a/redash/handlers/query_results.py +++ b/redash/handlers/query_results.py @@ -56,7 +56,7 @@ def error_response(message, http_status=400): } -def run_query(query, parameters, data_source, query_id, should_apply_auto_limit, max_age=0): +def run_query(query, parameters, data_source, query_id, should_apply_auto_limit, max_age=0, db_role=None): if not data_source: return error_messages["no_data_source"] @@ -81,7 +81,7 @@ def run_query(query, parameters, data_source, query_id, should_apply_auto_limit, if max_age == 0: query_result = None else: - query_result = models.QueryResult.get_latest(data_source, query_text, max_age) + query_result = models.QueryResult.get_latest(data_source, query_text, max_age, db_role=db_role) record_event( current_user.org, @@ -185,6 +185,7 @@ def post(self): query_id, should_apply_auto_limit, max_age, + db_role=getattr(self.current_user, "db_role", None), ) @@ -274,6 +275,7 @@ def post(self, query_id): query_id, should_apply_auto_limit, max_age, + db_role=getattr(self.current_user, "db_role", None), ) else: if not query.parameterized.is_safe: diff --git a/redash/models/__init__.py b/redash/models/__init__.py index a6b054e187..d38e5fefe7 100644 --- a/redash/models/__init__.py +++ b/redash/models/__init__.py @@ -348,7 +348,7 @@ def unused(cls, days=7): ) @classmethod - def get_latest(cls, data_source, query, max_age=0, is_hash=False): + def get_latest(cls, data_source, query, max_age=0, is_hash=False, db_role=None): if is_hash: query_hash = query else: @@ -358,11 +358,16 @@ def get_latest(cls, data_source, query, max_age=0, is_hash=False): max_age = settings.QUERY_RESULTS_EXPIRED_TTL if max_age == -1: - query = cls.query.filter(cls.query_hash == query_hash, cls.data_source == data_source) + query = cls.query.filter( + cls.query_hash == query_hash, + cls.data_source == data_source, + cls.db_role.is_(None) if db_role is None else (cls.db_role == db_role), + ) else: query = cls.query.filter( cls.query_hash == query_hash, cls.data_source == data_source, + cls.db_role.is_(None) if db_role is None else (cls.db_role == db_role), ( db.func.timezone("utc", cls.retrieved_at) + datetime.timedelta(seconds=max_age) >= db.func.timezone("utc", db.func.now()) diff --git a/redash/serializers/__init__.py b/redash/serializers/__init__.py index ae5b4fb486..aac4479b6c 100644 --- a/redash/serializers/__init__.py +++ b/redash/serializers/__init__.py @@ -126,26 +126,38 @@ def serialize_query( else: d["last_modified_by_id"] = query.last_modified_by_id - if with_stats: - if query.latest_query_data is not None: - d["retrieved_at"] = query.retrieved_at - d["runtime"] = query.runtime - else: - d["retrieved_at"] = None - d["runtime"] = None - if with_visualizations: d["visualizations"] = [serialize_visualization(vis, with_query=False) for vis in query.visualizations] - if getattr(current_user, "db_role", None): - # Override the latest_query_data_id for users with a db_role because - # they may not actually be able to see that one due to their db_role - # and may have one specific to them instead. + if with_stats: + # If we're serializing queries with stats, we can show last run + # data even if it's not specific to the current user's db_role. + # The downside is that the latest run may come from another + # permission level, but the upside is that we don't need to + # fetch latest runs on demand for each query. + # + # This affects API paths such like: + # - /api/queries + # - /api/queries/my + # - /api/queries/favorites + d["retrieved_at"] = query.latest_query_data and query.retrieved_at or None + d["runtime"] = query.latest_query_data and query.runtime or None + else: + # When we're _not_ fetching stats, we care more about the details + # of a latest run. This affects API paths like: + # + # - /api/queries/ + # + # Override the latest_query_data_id to use results matching the active + # user's db_role. This ensures that users with full row-level access + # get complete result sets, and users with restrictions only get result + # sets with those same restrictions applied. latest_result = models.QueryResult.get_latest( data_source=query.data_source, query=query.query_hash, max_age=-1, is_hash=True, + db_role=getattr(current_user, "db_role", None), ) d["latest_query_data_id"] = latest_result and latest_result.id or None diff --git a/tests/models/test_query_results.py b/tests/models/test_query_results.py index 16ea2de3d7..85a3219596 100644 --- a/tests/models/test_query_results.py +++ b/tests/models/test_query_results.py @@ -48,6 +48,17 @@ def test_get_latest_returns_the_most_recent_result(self): self.assertEqual(found_query_result.id, qr.id) + def test_get_latest_returns_results_per_db_role(self): + before = utcnow() - datetime.timedelta(seconds=30) + qr = self.factory.create_query_result(retrieved_at=before) + limited_role_qr = self.factory.create_query_result(db_role='limited') + + default_role_latest_results = models.QueryResult.get_latest(qr.data_source, qr.query_text, 60) + limited_role_latest_results = models.QueryResult.get_latest(qr.data_source, qr.query_text, 60, db_role="limited") + + self.assertEqual(qr.id, default_role_latest_results.id) + self.assertEqual(limited_role_qr.id, limited_role_latest_results.id) + def test_get_latest_returns_the_last_cached_result_for_negative_ttl(self): yesterday = utcnow() + datetime.timedelta(days=-100) self.factory.create_query_result(retrieved_at=yesterday)