# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. """ Tests for double RLS application in virtual datasets (Issue #37359). This module tests that guest user RLS filters are applied only once when querying virtual datasets, not both in the underlying table SQL and the outer query. """ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from flask import Flask from sqlalchemy.sql.elements import TextClause from superset.connectors.sqla.models import BaseDatasource @pytest.fixture def mock_datasource() -> MagicMock: """Create a mock datasource for testing.""" datasource = MagicMock(spec=BaseDatasource) datasource.get_template_processor.return_value = MagicMock() datasource.get_template_processor.return_value.process_template = lambda x: x datasource.text = lambda x: TextClause(x) return datasource def test_rls_filters_include_guest_when_enabled( mock_datasource: MagicMock, app: Flask, ) -> None: """ Test that RLS filters include guest filters when enabled. When include_global_guest_rls=True and EMBEDDED_SUPERSET is enabled, both regular and guest RLS filters should be returned. """ regular_filter = MagicMock() regular_filter.clause = "col1 = 'value1'" regular_filter.group_key = None guest_rule = {"clause": "col2 = 'value2'"} with ( patch( "superset.connectors.sqla.models.security_manager.get_rls_filters", return_value=[regular_filter], ), patch( "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", return_value=[guest_rule], ), patch( "superset.connectors.sqla.models.is_feature_enabled", return_value=True, ), ): # Call with include_global_guest_rls=True filters = BaseDatasource.get_sqla_row_level_filters( mock_datasource, include_global_guest_rls=True ) # Should include both regular and guest RLS assert len(filters) == 2 filter_strs = [str(f) for f in filters] assert any("col1" in s for s in filter_strs) assert any("col2" in s for s in filter_strs) def test_rls_filters_exclude_guest_when_requested( mock_datasource: MagicMock, app: Flask, ) -> None: """ Test that RLS filters exclude guest filters when requested. Issue #37359: When analyzing underlying tables in virtual datasets, guest RLS should be excluded to prevent double application. """ regular_filter = MagicMock() regular_filter.clause = "col1 = 'value1'" regular_filter.group_key = None guest_rule = {"clause": "col2 = 'value2'"} with ( patch( "superset.connectors.sqla.models.security_manager.get_rls_filters", return_value=[regular_filter], ), patch( "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", return_value=[guest_rule], ), patch( "superset.connectors.sqla.models.is_feature_enabled", return_value=True, ), ): # Call internal API with include_global_guest_rls=False filters = BaseDatasource.get_sqla_row_level_filters( mock_datasource, include_global_guest_rls=False ) # Should include only regular RLS, not guest RLS assert len(filters) == 1 filter_strs = [str(f) for f in filters] assert any("col1" in s for s in filter_strs) assert not any("col2" in s for s in filter_strs) def test_rls_filters_include_guest_by_default( mock_datasource: MagicMock, app: Flask, ) -> None: """ Test that RLS filters include guest filters by default. The default behavior (include_global_guest_rls=True) ensures backwards compatibility with existing code. """ regular_filter = MagicMock() regular_filter.clause = "col1 = 'value1'" regular_filter.group_key = None guest_rule = {"clause": "col2 = 'value2'"} with ( patch( "superset.connectors.sqla.models.security_manager.get_rls_filters", return_value=[regular_filter], ), patch( "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", return_value=[guest_rule], ), patch( "superset.connectors.sqla.models.is_feature_enabled", return_value=True, ), ): # Call internal API with default include_global_guest_rls=True filters = BaseDatasource.get_sqla_row_level_filters(mock_datasource) # Should include both regular and guest RLS assert len(filters) == 2 def test_regular_rls_always_included( mock_datasource: MagicMock, app: Flask, ) -> None: """ Test that regular (non-guest) RLS is always included. Even when include_global_guest_rls=False, regular RLS filters must still be applied to underlying tables in virtual datasets. """ regular_filter = MagicMock() regular_filter.clause = "tenant_id = 123" regular_filter.group_key = None with ( patch( "superset.connectors.sqla.models.security_manager.get_rls_filters", return_value=[regular_filter], ), patch( "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", return_value=[], ), patch( "superset.connectors.sqla.models.is_feature_enabled", return_value=True, ), ): # Call internal API with include_global_guest_rls=False filters = BaseDatasource.get_sqla_row_level_filters( mock_datasource, include_global_guest_rls=False ) # Regular RLS should still be included assert len(filters) == 1 assert "tenant_id" in str(filters[0]) def test_guest_rls_skipped_when_feature_disabled( mock_datasource: MagicMock, app: Flask, ) -> None: """ Test that guest RLS is skipped when EMBEDDED_SUPERSET is disabled. This verifies that the feature flag is respected regardless of the include_global_guest_rls parameter. """ regular_filter = MagicMock() regular_filter.clause = "col1 = 'value1'" regular_filter.group_key = None guest_rule = {"clause": "col2 = 'value2'"} with ( patch( "superset.connectors.sqla.models.security_manager.get_rls_filters", return_value=[regular_filter], ), patch( "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", return_value=[guest_rule], ), patch( "superset.connectors.sqla.models.is_feature_enabled", return_value=False, # Feature disabled ), ): # Even with include_global_guest_rls=True, feature flag takes precedence filters = BaseDatasource.get_sqla_row_level_filters( mock_datasource, include_global_guest_rls=True ) # Should include only regular RLS assert len(filters) == 1 assert not any("col2" in str(f) for f in filters) def test_filter_grouping_preserved( mock_datasource: MagicMock, app: Flask, ) -> None: """ Test that filter grouping logic is preserved in internal method. Filters with the same group_key should be ORed together, while different groups are ANDed. """ filter1 = MagicMock() filter1.clause = "region = 'US'" filter1.group_key = "region_group" filter2 = MagicMock() filter2.clause = "region = 'EU'" filter2.group_key = "region_group" filter3 = MagicMock() filter3.clause = "active = true" filter3.group_key = None with ( patch( "superset.connectors.sqla.models.security_manager.get_rls_filters", return_value=[filter1, filter2, filter3], ), patch( "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", return_value=[], ), patch( "superset.connectors.sqla.models.is_feature_enabled", return_value=False, ), ): filters = BaseDatasource.get_sqla_row_level_filters( mock_datasource, include_global_guest_rls=False ) # Should have 2 filters: one ungrouped, one grouped (ORed) assert len(filters) == 2