From 176257e42a1d2b5f2decfaa16802da59cdc2b126 Mon Sep 17 00:00:00 2001 From: Superset Dev Date: Fri, 21 Aug 2026 00:38:48 -0700 Subject: [PATCH] fix(semantic_layers): check access before returning a layer's configuration Add SemanticLayer.raise_for_access(), mirroring the existing SemanticView.raise_for_access(), and call it from the layer GET, runtime schema, and views endpoints before returning or exercising a layer's decrypted configuration. Previously these endpoints resolved the layer by UUID with no per-object permission check. Co-Authored-By: Claude Sonnet 5 --- superset/semantic_layers/api.py | 16 ++++ superset/semantic_layers/models.py | 20 +++++ tests/unit_tests/semantic_layers/api_test.py | 87 ++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py index 19773ce73c1..ff83a3a1f81 100644 --- a/superset/semantic_layers/api.py +++ b/superset/semantic_layers/api.py @@ -672,6 +672,11 @@ class SemanticLayerRestApi(BaseSupersetApi): if not layer: return self.response_404() + try: + layer.raise_for_access() + except SupersetSecurityException as ex: + return self.response(403, message=ex.message) + body = request.get_json(silent=True) or {} runtime_data = body.get("runtime_data") @@ -726,6 +731,11 @@ class SemanticLayerRestApi(BaseSupersetApi): if not layer: return self.response_404() + try: + layer.raise_for_access() + except SupersetSecurityException as ex: + return self.response(403, message=ex.message) + body = request.get_json(silent=True) or {} runtime_data = body.get("runtime_data", {}) @@ -1158,4 +1168,10 @@ class SemanticLayerRestApi(BaseSupersetApi): layer = SemanticLayerDAO.find_by_uuid(uuid) if not layer: return self.response_404() + + try: + layer.raise_for_access() + except SupersetSecurityException as ex: + return self.response(403, message=ex.message) + return self.response(200, result=_serialize_layer(layer)) diff --git a/superset/semantic_layers/models.py b/superset/semantic_layers/models.py index 08677f2c731..e639d9b23d6 100644 --- a/superset/semantic_layers/models.py +++ b/superset/semantic_layers/models.py @@ -148,6 +148,26 @@ class SemanticLayer(AuditMixinNullable, Model): """Compute the permission string for this semantic layer.""" return f"[{self.name}](id:{self.uuid.hex})" + def raise_for_access(self) -> None: + """Check that the user has access to this semantic layer.""" + from superset import security_manager + from superset.errors import ErrorLevel, SupersetError, SupersetErrorType + from superset.exceptions import SupersetSecurityException + + if security_manager.can_access_all_datasources(): + return + + if self.perm and security_manager.can_access("datasource_access", self.perm): + return + + raise SupersetSecurityException( + SupersetError( + error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR, + message=str(_("You don't have access to this semantic layer.")), + level=ErrorLevel.ERROR, + ) + ) + @staticmethod def after_insert( mapper: Mapper, diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py index 5dd2a480a64..bab98cb6ae6 100644 --- a/tests/unit_tests/semantic_layers/api_test.py +++ b/tests/unit_tests/semantic_layers/api_test.py @@ -36,6 +36,7 @@ from superset.commands.semantic_layer.exceptions import ( SemanticViewNotFoundError, SemanticViewUpdateFailedError, ) +from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.exceptions import SupersetSecurityException from superset.semantic_layers.api import SemanticLayerRestApi, SemanticViewRestApi @@ -529,6 +530,34 @@ def test_runtime_schema_not_found( assert response.status_code == 404 +@SEMANTIC_LAYERS_APP +def test_runtime_schema_forbidden( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST //schema/runtime returns 403 when access is denied.""" + test_uuid = str(uuid_lib.uuid4()) + mock_layer = MagicMock() + mock_layer.raise_for_access.side_effect = SupersetSecurityException( + SupersetError( + error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR, + message="You don't have access to this semantic layer.", + level=ErrorLevel.ERROR, + ) + ) + + mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO") + mock_dao.find_by_uuid.return_value = mock_layer + + response = client.post( + f"/api/v1/semantic_layer/{test_uuid}/schema/runtime", + ) + + assert response.status_code == 403 + mock_layer.raise_for_access.assert_called_once() + + @SEMANTIC_LAYERS_APP def test_runtime_schema_unknown_type( client: Any, @@ -939,6 +968,33 @@ def test_get_semantic_layer_not_found( assert response.status_code == 404 +@SEMANTIC_LAYERS_APP +def test_get_semantic_layer_forbidden( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test GET / returns 403 when user lacks access to the layer.""" + test_uuid = uuid_lib.uuid4() + layer = MagicMock() + layer.uuid = test_uuid + layer.raise_for_access.side_effect = SupersetSecurityException( + SupersetError( + error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR, + message="You don't have access to this semantic layer.", + level=ErrorLevel.ERROR, + ) + ) + + mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO") + mock_dao.find_by_uuid.return_value = layer + + response = client.get(f"/api/v1/semantic_layer/{test_uuid}") + + assert response.status_code == 403 + layer.raise_for_access.assert_called_once() + + @SEMANTIC_LAYERS_APP def test_serialize_layer_string_config( client: Any, @@ -1956,6 +2012,37 @@ def test_get_views( assert result[1]["name"] == "View B" +@SEMANTIC_LAYERS_APP +def test_get_views_forbidden( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST //views returns 403 when access is denied.""" + test_uuid = str(uuid_lib.uuid4()) + mock_layer = MagicMock() + mock_layer.uuid = uuid_lib.uuid4() + mock_layer.raise_for_access.side_effect = SupersetSecurityException( + SupersetError( + error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR, + message="You don't have access to this semantic layer.", + level=ErrorLevel.ERROR, + ) + ) + + mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO") + mock_dao.find_by_uuid.return_value = mock_layer + + response = client.post( + f"/api/v1/semantic_layer/{test_uuid}/views", + json={"runtime_data": {"database": "mydb"}}, + ) + + assert response.status_code == 403 + mock_layer.raise_for_access.assert_called_once() + mock_layer.implementation.get_semantic_views.assert_not_called() + + @SEMANTIC_LAYERS_APP def test_get_views_with_existing( client: Any,