# 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. """Unit tests for resource-level authorization in superset/views/datasource/views.py. Tests use ``inspect.unwrap`` to call the underlying view logic directly, bypassing the Flask-AppBuilder permission decorator machinery. """ import inspect from unittest.mock import MagicMock, patch import pytest from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.exceptions import SupersetSecurityException from superset.utils import json as superset_json def _security_exception() -> SupersetSecurityException: return SupersetSecurityException( SupersetError( message="Access denied", error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR, level=ErrorLevel.WARNING, ) ) def _get_view_func(name: str): """Return the unwrapped body of a Datasource view method.""" from superset.views.datasource.views import Datasource return inspect.unwrap(getattr(Datasource, name)) def _view_self() -> MagicMock: """Create a minimal stand-in for a Datasource view instance.""" self = MagicMock() self.json_response = MagicMock(return_value="ok") return self # --------------------------------------------------------------------------- # Datasource.get # --------------------------------------------------------------------------- @patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) @patch("superset.views.datasource.views.DatasourceDAO.get_datasource") def test_get_raises_when_access_denied( mock_get_datasource: MagicMock, mock_security_manager: MagicMock, ) -> None: """raise_for_access is called and propagates for unauthorised callers.""" mock_datasource = MagicMock() mock_get_datasource.return_value = mock_datasource mock_security_manager.raise_for_access.side_effect = _security_exception() raw_get = _get_view_func("get") with pytest.raises(SupersetSecurityException): raw_get(_view_self(), "table", 1) mock_security_manager.raise_for_access.assert_called_once_with( datasource=mock_datasource ) @patch("superset.views.datasource.views.sanitize_datasource_data") @patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) @patch("superset.views.datasource.views.DatasourceDAO.get_datasource") def test_get_succeeds_for_authorised_user( mock_get_datasource: MagicMock, mock_security_manager: MagicMock, mock_sanitize: MagicMock, ) -> None: """raise_for_access is called without raising; sanitized data is returned.""" mock_datasource = MagicMock() mock_datasource.data = {"id": 1} mock_get_datasource.return_value = mock_datasource mock_security_manager.raise_for_access.return_value = None mock_sanitize.return_value = {"id": 1} view = _view_self() raw_get = _get_view_func("get") raw_get(view, "table", 1) mock_security_manager.raise_for_access.assert_called_once_with( datasource=mock_datasource ) view.json_response.assert_called_once_with({"id": 1}) # --------------------------------------------------------------------------- # Datasource.external_metadata # --------------------------------------------------------------------------- @patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) @patch("superset.views.datasource.views.DatasourceDAO.get_datasource") def test_external_metadata_raises_when_access_denied( mock_get_datasource: MagicMock, mock_security_manager: MagicMock, ) -> None: mock_datasource = MagicMock() mock_get_datasource.return_value = mock_datasource mock_security_manager.raise_for_access.side_effect = _security_exception() raw_fn = _get_view_func("external_metadata") with pytest.raises(SupersetSecurityException): raw_fn(_view_self(), "table", 1) mock_security_manager.raise_for_access.assert_called_once_with( datasource=mock_datasource ) @patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) @patch("superset.views.datasource.views.DatasourceDAO.get_datasource") def test_external_metadata_succeeds_for_authorised_user( mock_get_datasource: MagicMock, mock_security_manager: MagicMock, ) -> None: mock_datasource = MagicMock() mock_datasource.external_metadata.return_value = [{"name": "col1"}] mock_get_datasource.return_value = mock_datasource mock_security_manager.raise_for_access.return_value = None view = _view_self() raw_fn = _get_view_func("external_metadata") raw_fn(view, "table", 1) mock_security_manager.raise_for_access.assert_called_once_with( datasource=mock_datasource ) view.json_response.assert_called_once_with([{"name": "col1"}]) # --------------------------------------------------------------------------- # Datasource.external_metadata_by_name # --------------------------------------------------------------------------- @patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) @patch("superset.views.datasource.views.SqlaTable.get_datasource_by_name") @patch("superset.views.datasource.views.ExternalMetadataSchema") def test_external_metadata_by_name_known_datasource_raises_when_access_denied( mock_schema_cls: MagicMock, mock_get_by_name: MagicMock, mock_security_manager: MagicMock, ) -> None: """When a datasource exists, raise_for_access(datasource=...) is enforced.""" params = { "database_name": "mydb", "schema_name": "public", "table_name": "private_table", } mock_schema_cls.return_value.load.return_value = params mock_datasource = MagicMock() mock_get_by_name.return_value = mock_datasource mock_security_manager.raise_for_access.side_effect = _security_exception() raw_fn = _get_view_func("external_metadata_by_name") with pytest.raises(SupersetSecurityException): raw_fn(_view_self(), rison=params) mock_security_manager.raise_for_access.assert_called_once_with( datasource=mock_datasource ) @patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) @patch("superset.views.datasource.views.SqlaTable.get_datasource_by_name") @patch("superset.views.datasource.views.ExternalMetadataSchema") @patch("superset.views.datasource.views.db") def test_external_metadata_by_name_no_datasource_raises_when_access_denied( mock_db: MagicMock, mock_schema_cls: MagicMock, mock_get_by_name: MagicMock, mock_security_manager: MagicMock, ) -> None: """When no datasource exists, raise_for_access(database=..., table=...) runs.""" params = { "database_name": "mydb", "schema_name": "public", "table_name": "new_table", } mock_schema_cls.return_value.load.return_value = params mock_get_by_name.return_value = None mock_database = MagicMock() mock_db.session.query.return_value.filter_by.return_value.one.return_value = ( mock_database ) mock_security_manager.raise_for_access.side_effect = _security_exception() raw_fn = _get_view_func("external_metadata_by_name") with pytest.raises(SupersetSecurityException): raw_fn(_view_self(), rison=params) mock_security_manager.raise_for_access.assert_called_once() call_kwargs = mock_security_manager.raise_for_access.call_args.kwargs assert call_kwargs["database"] is mock_database assert call_kwargs["table"].table == "new_table" assert call_kwargs["table"].schema == "public" # --------------------------------------------------------------------------- # Datasource.save — ownership bypass prevention # --------------------------------------------------------------------------- @patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) @patch("superset.views.datasource.views.DatasourceDAO.get_datasource") def test_save_always_checks_ownership_even_without_owners_field( mock_get_datasource: MagicMock, mock_security_manager: MagicMock, ) -> None: """Ownership check runs even when 'owners' is absent from the payload.""" mock_orm = MagicMock() mock_orm.owner_class = MagicMock() # not None — model supports ownership mock_get_datasource.return_value = mock_orm mock_security_manager.raise_for_ownership.side_effect = SupersetSecurityException( SupersetError( message="Not an owner", error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR, level=ErrorLevel.WARNING, ) ) from flask import Flask from superset.commands.dataset.exceptions import DatasetForbiddenError raw_save = _get_view_func("save") app = Flask(__name__) with app.test_request_context( "/datasource/save/", method="POST", data={ "data": superset_json.dumps( { "id": 1, "type": "table", "database": {"id": 1}, "columns": [], # 'owners' intentionally omitted } ) }, ): with pytest.raises(DatasetForbiddenError): raw_save(_view_self()) mock_security_manager.raise_for_ownership.assert_called_once_with(mock_orm) @patch("superset.views.datasource.views.security_manager", new_callable=MagicMock) @patch("superset.views.datasource.views.DatasourceDAO.get_datasource") def test_save_non_owner_with_owners_field_is_rejected( mock_get_datasource: MagicMock, mock_security_manager: MagicMock, ) -> None: """A non-owner cannot use the save endpoint even when supplying an owners list.""" mock_orm = MagicMock() mock_orm.owner_class = MagicMock() mock_get_datasource.return_value = mock_orm mock_security_manager.raise_for_ownership.side_effect = SupersetSecurityException( SupersetError( message="Not an owner", error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR, level=ErrorLevel.WARNING, ) ) from flask import Flask from superset.commands.dataset.exceptions import DatasetForbiddenError raw_save = _get_view_func("save") app = Flask(__name__) with app.test_request_context( "/datasource/save/", method="POST", data={ "data": superset_json.dumps( { "id": 1, "type": "table", "database": {"id": 1}, "columns": [], "owners": [99], # attacker-supplied owners list } ) }, ): with pytest.raises(DatasetForbiddenError): raw_save(_view_self()) mock_security_manager.raise_for_ownership.assert_called_once_with(mock_orm)