| # 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 superset.views.datasource.utils module.""" |
| |
| from unittest.mock import MagicMock, patch |
| |
| import pytest |
| |
| from superset.errors import ErrorLevel, SupersetError, SupersetErrorType |
| from superset.exceptions import SupersetSecurityException |
| |
| |
| @patch("superset.views.datasource.utils.get_limit_clause") |
| def test_get_samples_raises_security_exception_when_access_denied( |
| mock_get_limit_clause: MagicMock, |
| ): |
| """ |
| Test that get_samples() enforces access control by calling raise_for_access(). |
| This verifies the fix for issue #31944 where users with "can samples on Datasource" |
| permission could read samples from datasets they don't have access to. |
| """ |
| mock_get_limit_clause.return_value = {"row_offset": 0, "row_limit": 100} |
| |
| mock_datasource = MagicMock() |
| mock_datasource.type = "table" |
| mock_datasource.id = 1 |
| mock_datasource.columns = [] |
| |
| mock_samples_context = MagicMock() |
| mock_count_context = MagicMock() |
| |
| # Simulate security exception when raise_for_access is called |
| mock_samples_context.raise_for_access.side_effect = SupersetSecurityException( |
| SupersetError( |
| message="Access denied", |
| error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR, |
| level=ErrorLevel.WARNING, |
| ) |
| ) |
| |
| with ( |
| patch( |
| "superset.views.datasource.utils.DatasourceDAO.get_datasource", |
| return_value=mock_datasource, |
| ), |
| patch( |
| "superset.views.datasource.utils.QueryContextFactory" |
| ) as mock_factory_class, |
| ): |
| mock_factory = MagicMock() |
| mock_factory_class.return_value = mock_factory |
| |
| # Return different mock contexts for samples vs count queries |
| mock_factory.create.side_effect = [mock_samples_context, mock_count_context] |
| |
| from superset.views.datasource.utils import get_samples |
| |
| with pytest.raises(SupersetSecurityException) as exc_info: |
| get_samples( |
| datasource_type="table", |
| datasource_id=1, |
| force=False, |
| page=1, |
| per_page=100, |
| ) |
| |
| assert exc_info.value.error.error_type == ( |
| SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR |
| ) |
| # Verify raise_for_access was called on the samples context |
| mock_samples_context.raise_for_access.assert_called_once() |
| |
| |
| @patch("superset.views.datasource.utils.get_limit_clause") |
| def test_get_samples_calls_raise_for_access_on_both_contexts( |
| mock_get_limit_clause: MagicMock, |
| ): |
| """ |
| Test that get_samples() calls raise_for_access() on both the samples |
| and count_star query contexts before fetching data. |
| """ |
| mock_get_limit_clause.return_value = {"row_offset": 0, "row_limit": 100} |
| |
| mock_datasource = MagicMock() |
| mock_datasource.type = "table" |
| mock_datasource.id = 1 |
| mock_datasource.columns = [] |
| |
| mock_samples_context = MagicMock() |
| mock_count_context = MagicMock() |
| |
| # Set up successful access check |
| mock_samples_context.raise_for_access.return_value = None |
| mock_count_context.raise_for_access.return_value = None |
| |
| # Set up successful payload responses |
| mock_count_context.get_payload.return_value = { |
| "queries": [{"data": [{"COUNT(*)": 100}], "status": "success"}] |
| } |
| mock_samples_context.get_payload.return_value = { |
| "queries": [ |
| { |
| "data": [{"col1": "val1"}], |
| "status": "success", |
| "cache_key": "test_key", |
| } |
| ] |
| } |
| |
| with ( |
| patch( |
| "superset.views.datasource.utils.DatasourceDAO.get_datasource", |
| return_value=mock_datasource, |
| ), |
| patch( |
| "superset.views.datasource.utils.QueryContextFactory" |
| ) as mock_factory_class, |
| ): |
| mock_factory = MagicMock() |
| mock_factory_class.return_value = mock_factory |
| |
| # Return different mock contexts for samples vs count queries |
| mock_factory.create.side_effect = [mock_samples_context, mock_count_context] |
| |
| from superset.views.datasource.utils import get_samples |
| |
| result = get_samples( |
| datasource_type="table", |
| datasource_id=1, |
| force=False, |
| page=1, |
| per_page=100, |
| ) |
| |
| # Verify both contexts had raise_for_access called |
| mock_samples_context.raise_for_access.assert_called_once() |
| mock_count_context.raise_for_access.assert_called_once() |
| |
| # Verify the result contains expected data |
| assert result["data"] == [{"col1": "val1"}] |
| assert result["total_count"] == 100 |
| |
| |
| @patch("superset.views.datasource.utils.get_limit_clause") |
| def test_get_samples_count_star_access_denied(mock_get_limit_clause: MagicMock): |
| """ |
| Test that get_samples() raises security exception when access to count_star |
| query context is denied. |
| """ |
| mock_get_limit_clause.return_value = {"row_offset": 0, "row_limit": 100} |
| |
| mock_datasource = MagicMock() |
| mock_datasource.type = "table" |
| mock_datasource.id = 1 |
| mock_datasource.columns = [] |
| |
| mock_samples_context = MagicMock() |
| mock_count_context = MagicMock() |
| |
| # Samples context allows access |
| mock_samples_context.raise_for_access.return_value = None |
| |
| # Count context denies access |
| mock_count_context.raise_for_access.side_effect = SupersetSecurityException( |
| SupersetError( |
| message="Access denied to count query", |
| error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR, |
| level=ErrorLevel.WARNING, |
| ) |
| ) |
| |
| with ( |
| patch( |
| "superset.views.datasource.utils.DatasourceDAO.get_datasource", |
| return_value=mock_datasource, |
| ), |
| patch( |
| "superset.views.datasource.utils.QueryContextFactory" |
| ) as mock_factory_class, |
| ): |
| mock_factory = MagicMock() |
| mock_factory_class.return_value = mock_factory |
| |
| mock_factory.create.side_effect = [mock_samples_context, mock_count_context] |
| |
| from superset.views.datasource.utils import get_samples |
| |
| with pytest.raises(SupersetSecurityException) as exc_info: |
| get_samples( |
| datasource_type="table", |
| datasource_id=1, |
| force=False, |
| page=1, |
| per_page=100, |
| ) |
| |
| assert exc_info.value.error.error_type == ( |
| SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR |
| ) |
| # Verify samples context was checked first |
| mock_samples_context.raise_for_access.assert_called_once() |
| # Verify count context was also checked |
| mock_count_context.raise_for_access.assert_called_once() |