From 7d34920df367ac299c6c7cf27b61b46f057529ce Mon Sep 17 00:00:00 2001 From: Mike Bridge Date: Sun, 1 Nov 2026 13:14:33 -0600 Subject: [PATCH 1/4] fix(semantic-layer): check API permissions before availability --- .../extension-points/semantic-layers.md | 15 + superset/semantic_layers/api.py | 60 +-- .../semantic_layers/api_guard_order_test.py | 344 ++++++++++++++++++ .../semantic_layers/test_runtime_flag.py | 6 +- 4 files changed, 397 insertions(+), 28 deletions(-) create mode 100644 tests/unit_tests/semantic_layers/api_guard_order_test.py diff --git a/docs/developer_docs/extensions/extension-points/semantic-layers.md b/docs/developer_docs/extensions/extension-points/semantic-layers.md index 240635756a53..1e5908247c82 100644 --- a/docs/developer_docs/extensions/extension-points/semantic-layers.md +++ b/docs/developer_docs/extensions/extension-points/semantic-layers.md @@ -90,6 +90,21 @@ class MySemanticLayer(SemanticLayer[MyConfig, MySemanticView]): The decorator registers the class in the semantic layers registry, making it available in the UI for users to create connections. The `configuration_class` should be a Pydantic model that defines the fields needed to connect (credentials, project, database, etc.). Superset uses the model's JSON schema to render the configuration form dynamically. +## API permissions and runtime availability + +The `/api/v1/semantic_layer/` and `/api/v1/semantic_view/` endpoints check +endpoint permissions before evaluating `SEMANTIC_LAYERS`. Callers without +access receive the same authentication or permission refusal whether the feature +is enabled or disabled. Permitted callers receive `404` when it is disabled; +when enabled, the handler still enforces its resource-level access checks. +A Public-role `can_read` grant on `SemanticView` allows anonymous callers to +reach the structure endpoint, subject to the view's datasource permissions. + +Availability is evaluated through the feature flag manager on each permitted +request, so host applications can supply a runtime feature decision. Automatic +`OPTIONS` responses report allowed HTTP methods without evaluating feature +availability or executing a semantic handler. + ## Provider obligations and isolated tests Store the parsed configuration instance on the writable `self.configuration` diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py index 1134e18763b5..d94a7336cfc0 100644 --- a/superset/semantic_layers/api.py +++ b/superset/semantic_layers/api.py @@ -18,11 +18,12 @@ import logging import time -from typing import Any +from collections.abc import Callable +from functools import wraps +from typing import Any, ParamSpec -from flask import Blueprint, current_app, make_response, request, Response -from flask_appbuilder import AppBuilder -from flask_appbuilder.api import BaseApi, expose, protect, rison, safe +from flask import current_app, make_response, request, Response +from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.api.schemas import get_list_schema from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import lazy_gettext as t, ngettext @@ -120,28 +121,21 @@ def _log_bulk_create_timeouts(timed_out: int, total: int) -> None: ) -def _refuse_disabled_semantic_layers() -> FlaskResponse | None: - """Refuse semantic API dispatch before authentication or handler side effects.""" - if not is_semantic_layers_enabled(): - return BaseSupersetApi.response(404, message="Not found") - return None +P = ParamSpec("P") -class _RuntimeGuardedApiMixin(BaseApi): - """Install the same runtime guard on both semantic API blueprints.""" +def _requires_semantic_layers( + function: Callable[P, FlaskResponse], +) -> Callable[P, FlaskResponse]: + """Check runtime availability after the endpoint's permission decorator.""" - def create_blueprint( - self, - appbuilder: AppBuilder, - endpoint: str | None = None, - static_folder: str | None = None, - ) -> Blueprint: - """Register all routes behind a runtime feature-availability guard.""" - blueprint: Blueprint = super().create_blueprint( - appbuilder, endpoint, static_folder - ) - blueprint.before_request(_refuse_disabled_semantic_layers) - return blueprint + @wraps(function) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> FlaskResponse: + if not is_semantic_layers_enabled(): + return BaseSupersetApi.response(404, message="Not found") + return function(*args, **kwargs) + + return wrapped def _mask_configuration(layer: SemanticLayer, config: dict[str, Any]) -> dict[str, Any]: @@ -247,7 +241,7 @@ def _parse_partial_config( return None -class SemanticViewRestApi(_RuntimeGuardedApiMixin, BaseSupersetModelRestApi): +class SemanticViewRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(SemanticView) resource_name = "semantic_view" @@ -268,6 +262,7 @@ class SemanticViewRestApi(_RuntimeGuardedApiMixin, BaseSupersetModelRestApi): @expose("//structure", methods=("GET",)) @protect() + @_requires_semantic_layers @safe @statsd_metrics @event_logger.log_this_with_context( @@ -363,6 +358,7 @@ def structure(self, pk: int) -> Response: @expose("/", methods=("POST",)) @protect() + @_requires_semantic_layers @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post", @@ -473,6 +469,7 @@ def post(self) -> Response: @expose("/", methods=("PUT",)) @protect() + @_requires_semantic_layers @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put", @@ -546,6 +543,7 @@ def put(self, pk: int) -> Response: @expose("/", methods=("DELETE",)) @protect() + @_requires_semantic_layers @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete", @@ -644,6 +642,7 @@ def delete(self, pk: int) -> Response: @expose("/", methods=("DELETE",)) @protect() + @_requires_semantic_layers @statsd_metrics @rison(get_delete_ids_schema) @event_logger.log_this_with_context( @@ -759,7 +758,7 @@ def bulk_delete(self, **kwargs: Any) -> Response: return self.response_422(message=str(ex)) -class SemanticLayerRestApi(_RuntimeGuardedApiMixin, BaseSupersetApi): +class SemanticLayerRestApi(BaseSupersetApi): resource_name = "semantic_layer" allow_browser_login = True class_permission_name = "SemanticLayer" @@ -783,6 +782,7 @@ class SemanticLayerRestApi(_RuntimeGuardedApiMixin, BaseSupersetApi): @expose("/types", methods=("GET",)) @protect() + @_requires_semantic_layers @safe @statsd_metrics def types(self) -> FlaskResponse: @@ -804,6 +804,7 @@ def types(self) -> FlaskResponse: @expose("/schema/configuration", methods=("POST",)) @protect() + @_requires_semantic_layers @safe @statsd_metrics @requires_json @@ -877,6 +878,7 @@ def configuration_schema(self) -> FlaskResponse: @expose("//schema/runtime", methods=("POST",)) @protect() + @_requires_semantic_layers @safe @statsd_metrics def runtime_schema(self, uuid: str) -> FlaskResponse: @@ -945,6 +947,7 @@ def runtime_schema(self, uuid: str) -> FlaskResponse: @expose("//views", methods=("POST",)) @protect() + @_requires_semantic_layers @safe @statsd_metrics def views(self, uuid: str) -> FlaskResponse: @@ -1028,6 +1031,7 @@ def views(self, uuid: str) -> FlaskResponse: @expose("/", methods=("POST",)) @protect() + @_requires_semantic_layers @safe @statsd_metrics @requires_json @@ -1083,6 +1087,7 @@ def post(self) -> FlaskResponse: @expose("/", methods=("PUT",)) @protect() + @_requires_semantic_layers @safe @statsd_metrics @requires_json @@ -1149,6 +1154,7 @@ def put(self, uuid: str) -> FlaskResponse: @expose("//clear_cache", methods=("POST",)) @protect() + @_requires_semantic_layers @safe @statsd_metrics @requires_json @@ -1200,6 +1206,7 @@ def clear_cache(self, uuid: str) -> FlaskResponse: @expose("/", methods=("DELETE",)) @protect() + @_requires_semantic_layers @safe @statsd_metrics def delete(self, uuid: str) -> FlaskResponse: @@ -1292,6 +1299,7 @@ def delete(self, uuid: str) -> FlaskResponse: @expose("/connections/", methods=("GET",)) @protect_read("Database", "SemanticLayer") + @_requires_semantic_layers @safe @statsd_metrics @rison(get_list_schema) @@ -1507,6 +1515,7 @@ def _serialize_semantic_layer(obj: SemanticLayer) -> dict[str, Any]: @expose("/", methods=("GET",)) @protect() + @_requires_semantic_layers @safe @statsd_metrics def get_list(self) -> FlaskResponse: @@ -1529,6 +1538,7 @@ def get_list(self) -> FlaskResponse: @expose("/", methods=("GET",)) @protect() + @_requires_semantic_layers @safe @statsd_metrics def get(self, uuid: str) -> FlaskResponse: diff --git a/tests/unit_tests/semantic_layers/api_guard_order_test.py b/tests/unit_tests/semantic_layers/api_guard_order_test.py new file mode 100644 index 000000000000..0ba474526feb --- /dev/null +++ b/tests/unit_tests/semantic_layers/api_guard_order_test.py @@ -0,0 +1,344 @@ +# 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. + +"""Semantic API authorization precedes the runtime feature decision.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any +from unittest.mock import MagicMock + +import pytest +from flask.testing import FlaskClient +from flask_appbuilder.security.sqla.models import ( + Permission, + PermissionView, + Role, + User, + ViewMenu, +) +from flask_login import AnonymousUserMixin +from pytest_mock import MockerFixture +from werkzeug.routing import Rule +from werkzeug.test import TestResponse + +from superset import feature_flag_manager, security_manager +from superset.app import SupersetApp +from superset.security.guest_token import GuestToken, GuestUser +from superset.semantic_layers.models import SemanticView + +# Each method/path pair is exercised through Flask, including implicit HEAD. +ROUTES: tuple[tuple[str, str], ...] = ( + ("GET", "/api/v1/semantic_view/17/structure"), + ("HEAD", "/api/v1/semantic_view/17/structure"), + ("POST", "/api/v1/semantic_view/"), + ("PUT", "/api/v1/semantic_view/17"), + ("DELETE", "/api/v1/semantic_view/17"), + ("DELETE", "/api/v1/semantic_view/"), + ("GET", "/api/v1/semantic_layer/types"), + ("HEAD", "/api/v1/semantic_layer/types"), + ("POST", "/api/v1/semantic_layer/schema/configuration"), + ("POST", "/api/v1/semantic_layer/test-layer/schema/runtime"), + ("POST", "/api/v1/semantic_layer/test-layer/views"), + ("POST", "/api/v1/semantic_layer/"), + ("PUT", "/api/v1/semantic_layer/test-layer"), + ("POST", "/api/v1/semantic_layer/test-layer/clear_cache"), + ("DELETE", "/api/v1/semantic_layer/test-layer"), + ("GET", "/api/v1/semantic_layer/connections/"), + ("HEAD", "/api/v1/semantic_layer/connections/"), + ("GET", "/api/v1/semantic_layer/"), + ("HEAD", "/api/v1/semantic_layer/"), + ("GET", "/api/v1/semantic_layer/test-layer"), + ("HEAD", "/api/v1/semantic_layer/test-layer"), +) + + +@pytest.fixture +def semantic_decision(mocker: MockerFixture) -> MagicMock: + """Exercise the real feature manager's host callback on every request.""" + decision: MagicMock = MagicMock(return_value=False) + + def resolve(name: str, default: bool) -> bool: + return bool(decision()) if name == "SEMANTIC_LAYERS" else default + + mocker.patch.object(feature_flag_manager, "_is_feature_enabled_func", resolve) + return decision + + +@pytest.fixture +def set_principal( + app: SupersetApp, + mocker: MockerFixture, +) -> Callable[[str, bool], None]: + """Supply identities and grants without replacing FAB's permission checks.""" + + def configure(kind: str, permitted: bool) -> None: + role: Role = Role(name="SemanticReader", permissions=[]) + public: Role = Role(name="Public", permissions=[]) + if kind == "public" and permitted: + public.permissions = [ + PermissionView( + permission=Permission(name="can_read"), + view_menu=ViewMenu(name="SemanticView"), + ) + ] + mocker.patch.object(security_manager, "get_public_role", return_value=public) + mocker.patch.object( + security_manager, + "get_public_permissions", + return_value=public.permissions, + ) + # FAB's real built-in role matcher avoids persisting users in a unit test. + mocker.patch.dict( + security_manager.builtin_roles, + { + role.name: [ + ("^(SemanticView|SemanticLayer|Database)$", "^can_(read|write)$") + ], + }, + ) + roles: list[Role] = [role] if permitted else [] + user: User | AnonymousUserMixin + if kind == "guest": + token: GuestToken = { + "user": {"username": "embedded"}, + "resources": [], + "rls_rules": [], + "iat": 0, + "exp": 1, + } + user = GuestUser(token, roles) + elif kind == "signed_in": + user = User(id=17, username="reader", active=True, roles=roles) + else: + user = AnonymousUserMixin() + mocker.patch.object(app.login_manager, "_request_callback", return_value=user) + + return configure + + +@pytest.mark.parametrize("method,path", ROUTES) +@pytest.mark.parametrize( + "kind,status", [("anonymous", 401), ("signed_in", 403), ("guest", 403)] +) +def test_denied_request_never_reads_feature_state( + client: FlaskClient[Any], + set_principal: Callable[[str, bool], None], + semantic_decision: MagicMock, + method: str, + path: str, + kind: str, + status: int, +) -> None: + """Every exposed route denies before observing semantic availability.""" + set_principal(kind, False) + responses: list[TestResponse] = [] + enabled: bool + for enabled in (True, False): + semantic_decision.return_value = enabled + responses.append(client.open(path, method=method)) + assert [response.status_code for response in responses] == [status, status] + assert responses[0].data == responses[1].data + semantic_decision.assert_not_called() + + +@pytest.mark.parametrize("method,path", ROUTES) +def test_permitted_disabled_request_never_queries_metadata( + client: FlaskClient[Any], + set_principal: Callable[[str, bool], None], + semantic_decision: MagicMock, + mocker: MockerFixture, + method: str, + path: str, +) -> None: + """All registered handlers refuse before parsing or looking up resources.""" + set_principal("signed_in", True) + query: MagicMock = mocker.patch("superset.semantic_layers.api.db.session.query") + response: TestResponse = client.open(path, method=method) + assert response.status_code == 404 + if method != "HEAD": + assert response.json == {"message": "Not found"} + query.assert_not_called() + semantic_decision.assert_called_once_with() + + +def test_route_inventory(app: SupersetApp) -> None: + """The matrix covers every exposed method, including inherited FAB routes.""" + expected: set[tuple[str, str]] = set() + rule: Rule + method: str + for rule in app.url_map.iter_rules(): + if rule.endpoint.split(".")[0] in { + "SemanticViewRestApi", + "SemanticLayerRestApi", + }: + for method in rule.methods or set(): + if method != "OPTIONS": + expected.add( + ( + method, + rule.rule.replace("", "17").replace( + "", "test-layer" + ), + ) + ) + assert set(ROUTES) == expected + + +@pytest.mark.parametrize("path", sorted({path for _, path in ROUTES})) +def test_automatic_options_is_independent_of_feature_state( + client: FlaskClient[Any], + semantic_decision: MagicMock, + path: str, +) -> None: + """Flask's automatic OPTIONS reports methods without dispatching a handler.""" + enabled: bool + responses: list[TestResponse] = [] + for enabled in (True, False): + semantic_decision.return_value = enabled + responses.append(client.options(path)) + assert [response.status_code for response in responses] == [200, 200] + assert responses[0].headers["Allow"] == responses[1].headers["Allow"] + assert responses[0].data == responses[1].data == b"" + semantic_decision.assert_not_called() + + +@pytest.mark.parametrize("kind", ["public", "signed_in", "guest"]) +def test_permitted_structure_tracks_runtime_toggle( + client: FlaskClient[Any], + set_principal: Callable[[str, bool], None], + semantic_decision: MagicMock, + mocker: MockerFixture, + kind: str, +) -> None: + """A Public grant and authenticated grants reach the same guarded handler.""" + set_principal(kind, True) + view: MagicMock = MagicMock(spec=SemanticView) + view.name = "orders" + view.description = None + view.cache_timeout = None + view.implementation.selection_identity_version = 1 + view.implementation.get_dimensions.return_value = [] + view.implementation.get_metrics.return_value = [] + query: MagicMock = mocker.patch("superset.semantic_layers.api.db.session.query") + query.return_value.filter_by.return_value.first.return_value = view + semantic_decision.return_value = True + response: TestResponse = client.get("/api/v1/semantic_view/17/structure") + assert response.status_code == 200 + assert response.json["result"]["name"] == "orders" + view.raise_for_access.assert_called_once_with() + query.reset_mock() + view.reset_mock() + semantic_decision.return_value = False + response = client.get("/api/v1/semantic_view/17/structure") + assert response.status_code == 404 + assert response.json == {"message": "Not found"} + query.assert_not_called() + view.raise_for_access.assert_not_called() + + +@pytest.mark.parametrize("kind,status", [("anonymous", 401), ("signed_in", 403)]) +@pytest.mark.parametrize("ids", ["!(17,18)", "!(17,999)", "[17.0,18]", "invalid"]) +def test_bulk_denial_never_inspects_rows( + client: FlaskClient[Any], + set_principal: Callable[[str, bool], None], + semantic_decision: MagicMock, + mocker: MockerFixture, + kind: str, + status: int, + ids: str, +) -> None: + """Mixed or missing IDs and malformed payloads cannot change a route denial.""" + set_principal(kind, False) + command: MagicMock = mocker.patch( + "superset.semantic_layers.api.BulkDeleteSemanticViewCommand" + ) + query: MagicMock = mocker.patch("superset.semantic_layers.api.db.session.query") + enabled: bool + for enabled in (True, False): + semantic_decision.return_value = enabled + response: TestResponse = client.delete( + "/api/v1/semantic_view/", query_string={"q": ids} + ) + assert response.status_code == status + command.assert_not_called() + query.assert_not_called() + semantic_decision.assert_not_called() + + +@pytest.mark.parametrize("kind", ["public", "signed_in", "guest"]) +def test_route_grant_does_not_bypass_view_access( + client: FlaskClient[Any], + set_principal: Callable[[str, bool], None], + semantic_decision: MagicMock, + mocker: MockerFixture, + kind: str, +) -> None: + """An admitted caller still needs the semantic view's datasource permission.""" + set_principal(kind, True) + semantic_decision.return_value = True + view: SemanticView = SemanticView(id=17, name="restricted") + query: MagicMock = mocker.patch("superset.semantic_layers.api.db.session.query") + query.return_value.filter_by.return_value.first.return_value = view + response: TestResponse = client.get("/api/v1/semantic_view/17/structure") + assert response.status_code == 403 + assert "restricted" not in response.get_data(as_text=True) + + +def test_permitted_bulk_delete_keeps_mixed_row_refusal( + client: FlaskClient[Any], + set_principal: Callable[[str, bool], None], + semantic_decision: MagicMock, + mocker: MockerFixture, +) -> None: + """The real bulk command refuses a mixed editable/restricted batch atomically.""" + set_principal("signed_in", True) + allowed: MagicMock = MagicMock(spec=SemanticView, id=17) + denied: MagicMock = MagicMock(spec=SemanticView, id=18) + find: MagicMock = mocker.patch( + "superset.commands.semantic_layer.delete.SemanticViewDAO.find_by_ids", + return_value=[allowed, denied], + ) + delete: MagicMock = mocker.patch( + "superset.commands.semantic_layer.delete.SemanticViewDAO.delete" + ) + + def can_modify(model: SemanticView) -> bool: + return model.id == allowed.id + + mocker.patch( + "superset.commands.semantic_layer.delete.current_user_can_modify_object", + side_effect=can_modify, + ) + semantic_decision.return_value = True + response: TestResponse = client.delete( + "/api/v1/semantic_view/", query_string={"q": "!(17,18)"} + ) + assert response.status_code == 403 + assert response.json == {"message": "Forbidden"} + find.assert_called_once_with([17, 18], id_column="id") + allowed.raise_for_access.assert_called_once_with() + denied.raise_for_access.assert_called_once_with() + delete.assert_not_called() + find.reset_mock() + semantic_decision.return_value = False + response = client.delete("/api/v1/semantic_view/", query_string={"q": "!(17,18)"}) + assert response.status_code == 404 + assert response.json == {"message": "Not found"} + find.assert_not_called() + delete.assert_not_called() diff --git a/tests/unit_tests/semantic_layers/test_runtime_flag.py b/tests/unit_tests/semantic_layers/test_runtime_flag.py index fa440eb275cc..0dd227b153bf 100644 --- a/tests/unit_tests/semantic_layers/test_runtime_flag.py +++ b/tests/unit_tests/semantic_layers/test_runtime_flag.py @@ -143,10 +143,10 @@ def test_saving_semantic_chart_is_validation_error_when_disabled() -> None: @pytest.mark.parametrize( "path", ["/api/v1/semantic_layer/types", "/api/v1/semantic_view/17/structure"] ) -def test_registered_semantic_api_refuses_before_dispatch( - client: FlaskClient[Any], path: str +def test_registered_semantic_api_refuses_before_handler( + client: FlaskClient[Any], path: str, full_api_access: None ) -> None: - """The off-at-boot application still owns both guarded blueprints.""" + """Permitted callers cannot dispatch disabled semantic handlers.""" assert "SemanticLayerRestApi" in client.application.blueprints assert "SemanticViewRestApi" in client.application.blueprints with patch("superset.feature_flag_manager.is_feature_enabled", return_value=False): From a9c49831f09079ab051cb450df0061c36232d4bb Mon Sep 17 00:00:00 2001 From: Mike Bridge Date: Sun, 1 Nov 2026 21:16:08 -0600 Subject: [PATCH 2/4] fix(semantic-layer): centralize runtime API availability checks --- UPDATING.md | 7 +- .../configuration/feature-flags.mdx | 7 +- .../extension-points/semantic-layers.md | 13 +- superset/semantic_layers/api.py | 38 +---- .../semantic_layers/api_guard_order_test.py | 14 +- tests/unit_tests/semantic_layers/api_test.py | 151 ++---------------- .../simple_cache_clear_test.py | 9 +- .../semantic_layers/test_runtime_flag.py | 4 +- 8 files changed, 48 insertions(+), 195 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index 7cfd118b90b1..d18d2fee683a 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -223,8 +223,11 @@ assists people when migrating to a new version. semantic view. Interactive dashboards retain unavailable semantic charts and continue rendering ordinary datasets. Dual-source MCP tools retain dataset access; unscoped metric listings omit semantic views. Legacy batch warm-up - reports a per-chart error for a disabled semantic chart and continues. Existing - permissions remain required when enabled. Run `superset init` during upgrade + reports a per-chart error for a disabled semantic chart and continues. Endpoint + permissions are checked before the flag: callers without access receive 401 or + 403 in either state, and permitted callers receive 404 while the feature is + disabled. Automatic OPTIONS responses report allowed methods in either state + without executing a semantic handler. Run `superset init` during upgrade to register the existing semantic permissions even when the feature is off. Runtime flag hooks can enable the feature without another restart or permission sync; provider extensions must already be loaded. Do not target runtime diff --git a/docs/admin_docs/configuration/feature-flags.mdx b/docs/admin_docs/configuration/feature-flags.mdx index 2815303bcbd6..ea96aa7fbaa6 100644 --- a/docs/admin_docs/configuration/feature-flags.mdx +++ b/docs/admin_docs/configuration/feature-flags.mdx @@ -56,14 +56,15 @@ FEATURE_FLAGS = { ## Runtime semantic-layer availability `SEMANTIC_LAYERS` defaults to `False`. Both semantic-layer APIs are registered -at startup, and each request evaluates the active flag decision. Hosts can use +at startup, and each permitted API request evaluates the active flag decision. Hosts can use `IS_FEATURE_ENABLED_FUNC` and `GET_FEATURE_FLAGS_FUNC` to supply runtime decisions; changing static configuration still requires a restart. Initialize permissions with the normal `superset init` upgrade step. Enabling the runtime flag does not grant permissions or require another permission sync. -When disabled, semantic API routes return 404. Semantic-view queries and metadata -return “Semantic layers are not enabled.” Saved charts remain listed, but their +Endpoint permissions are checked before `SEMANTIC_LAYERS`: callers without access receive 401 or 403 in either state, and permitted callers receive 404 when `SEMANTIC_LAYERS` is disabled. Permission is decided per endpoint: `/connections/` accepts `can_read` on either `Database` or `SemanticLayer`. A Public-role `can_read` grant on `SemanticView` permits anonymous requests to the structure endpoint, subject to the view's datasource permissions. Enabling the flag never bypasses resource-level access checks. Automatic `OPTIONS` returns 200 with the allowed HTTP methods in either state, without executing a semantic handler or evaluating availability. + +When `SEMANTIC_LAYERS` is disabled, semantic-view queries and metadata return “Semantic layers are not enabled.” Saved charts remain listed, but their data and cached images are unavailable; dashboards omit semantic-view dataset metadata. Chart saves using semantic views are refused. Both fresh and cached dashboard screenshots/thumbnails return 404 if any chart uses a semantic view. Legacy batch warm-up records a per-chart unavailable error diff --git a/docs/developer_docs/extensions/extension-points/semantic-layers.md b/docs/developer_docs/extensions/extension-points/semantic-layers.md index 1e5908247c82..2d9ac8a281dd 100644 --- a/docs/developer_docs/extensions/extension-points/semantic-layers.md +++ b/docs/developer_docs/extensions/extension-points/semantic-layers.md @@ -92,18 +92,7 @@ The decorator registers the class in the semantic layers registry, making it ava ## API permissions and runtime availability -The `/api/v1/semantic_layer/` and `/api/v1/semantic_view/` endpoints check -endpoint permissions before evaluating `SEMANTIC_LAYERS`. Callers without -access receive the same authentication or permission refusal whether the feature -is enabled or disabled. Permitted callers receive `404` when it is disabled; -when enabled, the handler still enforces its resource-level access checks. -A Public-role `can_read` grant on `SemanticView` allows anonymous callers to -reach the structure endpoint, subject to the view's datasource permissions. - -Availability is evaluated through the feature flag manager on each permitted -request, so host applications can supply a runtime feature decision. Automatic -`OPTIONS` responses report allowed HTTP methods without evaluating feature -availability or executing a semantic handler. +The semantic APIs check endpoint permissions before evaluating `SEMANTIC_LAYERS` through the feature flag manager. See [runtime semantic-layer availability](../../../admin_docs/configuration/feature-flags.mdx#runtime-semantic-layer-availability) for permission, Public-role, `OPTIONS`, and `IS_FEATURE_ENABLED_FUNC` behavior. ## Provider obligations and isolated tests diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py index d94a7336cfc0..367e43f60146 100644 --- a/superset/semantic_layers/api.py +++ b/superset/semantic_layers/api.py @@ -31,7 +31,7 @@ from pydantic import ValidationError as PydanticValidationError from sqlalchemy.orm import load_only, Query -from superset import db, event_logger, is_feature_enabled, security_manager +from superset import db, event_logger, security_manager from superset.commands.semantic_layer.create import ( CreateSemanticLayerCommand, CreateSemanticViewCommand, @@ -127,7 +127,12 @@ def _log_bulk_create_timeouts(timed_out: int, total: int) -> None: def _requires_semantic_layers( function: Callable[P, FlaskResponse], ) -> Callable[P, FlaskResponse]: - """Check runtime availability after the endpoint's permission decorator.""" + """Check runtime availability after the endpoint's permission decorator. + + Apply to every exposed handler directly below protect/protect_read and above + safe, metrics, parsing, and event logging. Keep the handler in the registered + route matrix in api_guard_order_test.py so a missing guard fails a test. + """ @wraps(function) def wrapped(*args: P.args, **kwargs: P.kwargs) -> FlaskResponse: @@ -295,9 +300,6 @@ def structure(self, pk: int) -> Response: 422: $ref: '#/components/responses/422' """ - if not is_feature_enabled("SEMANTIC_LAYERS"): - return self.response_404() - view = db.session.query(SemanticView).filter_by(id=pk).first() if not view: return self.response_404() @@ -406,9 +408,6 @@ def post(self) -> Response: 422: $ref: '#/components/responses/422' """ - if not is_feature_enabled("SEMANTIC_LAYERS"): - return self.response_404() - body = request.json or {} views_data = body.get("views", []) if refusal := _bulk_create_refusal(views_data): @@ -613,9 +612,6 @@ def delete(self, pk: int) -> Response: 422: $ref: '#/components/responses/422' """ - if not is_feature_enabled("SEMANTIC_LAYERS"): - return self.response_404() - try: DeleteSemanticViewCommand(pk).run() return self.response(200, message="OK") @@ -722,9 +718,6 @@ def bulk_delete(self, **kwargs: Any) -> Response: 422: $ref: '#/components/responses/422' """ - if not is_feature_enabled("SEMANTIC_LAYERS"): - return self.response_404() - item_ids: list[int] = kwargs["rison"] try: BulkDeleteSemanticViewCommand(item_ids).run() @@ -909,9 +902,6 @@ def runtime_schema(self, uuid: str) -> FlaskResponse: 404: $ref: '#/components/responses/404' """ - if not is_feature_enabled("SEMANTIC_LAYERS"): - return self.response_404() - layer = SemanticLayerDAO.find_by_uuid(uuid) if not layer: return self.response_404() @@ -978,9 +968,6 @@ def views(self, uuid: str) -> FlaskResponse: 404: $ref: '#/components/responses/404' """ - if not is_feature_enabled("SEMANTIC_LAYERS"): - return self.response_404() - layer = SemanticLayerDAO.find_by_uuid(uuid) if not layer: return self.response_404() @@ -1190,8 +1177,6 @@ def clear_cache(self, uuid: str) -> FlaskResponse: 422: $ref: '#/components/responses/422' """ - if not is_feature_enabled("SEMANTIC_LAYERS"): - return self.response_404() if request.get_json(silent=True) != {}: return self.response_400(message="Expected an empty JSON object") try: @@ -1338,9 +1323,6 @@ def connections(self, **kwargs: Any) -> FlaskResponse: source_type, name_filter = self._parse_connection_filters(filters) - if not is_feature_enabled("SEMANTIC_LAYERS"): - return self.response_404() - all_items = self._fetch_connection_items(source_type, name_filter) sort_key = self._get_connection_sort_key(order_column) @@ -1529,9 +1511,6 @@ def get_list(self) -> FlaskResponse: 401: $ref: '#/components/responses/401' """ - if not is_feature_enabled("SEMANTIC_LAYERS"): - return self.response_404() - layers = SemanticLayerDAO.find_all() result = [_serialize_layer(layer) for layer in layers] return self.response(200, result=result) @@ -1561,9 +1540,6 @@ def get(self, uuid: str) -> FlaskResponse: 404: $ref: '#/components/responses/404' """ - if not is_feature_enabled("SEMANTIC_LAYERS"): - return self.response_404() - layer = SemanticLayerDAO.find_by_uuid(uuid) if not layer: return self.response_404() diff --git a/tests/unit_tests/semantic_layers/api_guard_order_test.py b/tests/unit_tests/semantic_layers/api_guard_order_test.py index 0ba474526feb..9124afd7897c 100644 --- a/tests/unit_tests/semantic_layers/api_guard_order_test.py +++ b/tests/unit_tests/semantic_layers/api_guard_order_test.py @@ -32,7 +32,7 @@ User, ViewMenu, ) -from flask_login import AnonymousUserMixin +from flask_login import AnonymousUserMixin, LoginManager from pytest_mock import MockerFixture from werkzeug.routing import Rule from werkzeug.test import TestResponse @@ -76,13 +76,13 @@ def semantic_decision(mocker: MockerFixture) -> MagicMock: def resolve(name: str, default: bool) -> bool: return bool(decision()) if name == "SEMANTIC_LAYERS" else default + # Patch the host callback, preserving the real feature manager resolution. mocker.patch.object(feature_flag_manager, "_is_feature_enabled_func", resolve) return decision @pytest.fixture def set_principal( - app: SupersetApp, mocker: MockerFixture, ) -> Callable[[str, bool], None]: """Supply identities and grants without replacing FAB's permission checks.""" @@ -127,7 +127,10 @@ def configure(kind: str, permitted: bool) -> None: user = User(id=17, username="reader", active=True, roles=roles) else: user = AnonymousUserMixin() - mocker.patch.object(app.login_manager, "_request_callback", return_value=user) + # Replace only identity loading; keep real FAB authorization and restore + # the callback after each test instead of accumulating public loaders. + login_manager: LoginManager = security_manager.lm + mocker.patch.object(login_manager, "_request_callback", return_value=user) return configure @@ -177,7 +180,7 @@ def test_permitted_disabled_request_never_queries_metadata( semantic_decision.assert_called_once_with() -def test_route_inventory(app: SupersetApp) -> None: +def test_every_registered_route_is_in_guard_matrix(app: SupersetApp) -> None: """The matrix covers every exposed method, including inherited FAB routes.""" expected: set[tuple[str, str]] = set() rule: Rule @@ -242,6 +245,8 @@ def test_permitted_structure_tracks_runtime_toggle( assert response.status_code == 200 assert response.json["result"]["name"] == "orders" view.raise_for_access.assert_called_once_with() + semantic_decision.assert_called_once_with() + semantic_decision.reset_mock() query.reset_mock() view.reset_mock() semantic_decision.return_value = False @@ -250,6 +255,7 @@ def test_permitted_structure_tracks_runtime_toggle( assert response.json == {"message": "Not found"} query.assert_not_called() view.raise_for_access.assert_not_called() + semantic_decision.assert_called_once_with() @pytest.mark.parametrize("kind,status", [("anonymous", 401), ("signed_in", 403)]) diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py index 349dbf46bfc9..82d4f5d058d3 100644 --- a/tests/unit_tests/semantic_layers/api_test.py +++ b/tests/unit_tests/semantic_layers/api_test.py @@ -15,11 +15,10 @@ # specific language governing permissions and limitations # under the License. -import inspect import logging import uuid as uuid_lib from typing import Annotated, Any, Literal -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest from flask.testing import FlaskClient @@ -1723,7 +1722,7 @@ def test_connections_list( ) mocker.patch( - "superset.semantic_layers.api.is_feature_enabled", + "superset.semantic_layers.api.is_semantic_layers_enabled", return_value=True, ) @@ -1764,7 +1763,9 @@ def query_for_model(model: type[Database] | type[SemanticLayer]) -> MagicMock: return queries[model] mock_db_session.query.side_effect = query_for_model - mocker.patch("superset.semantic_layers.api.is_feature_enabled", return_value=True) + mocker.patch( + "superset.semantic_layers.api.is_semantic_layers_enabled", return_value=True + ) return db_query @@ -1839,7 +1840,7 @@ def test_connections_database_only( """Test GET /connections/ returns 404 when feature flag is disabled.""" mocker.patch( - "superset.semantic_layers.api.is_feature_enabled", + "superset.semantic_layers.api.is_semantic_layers_enabled", return_value=False, ) @@ -1867,7 +1868,7 @@ def test_connections_name_filter( mock_db_session.query.side_effect = [db_query, sl_query] mocker.patch( - "superset.semantic_layers.api.is_feature_enabled", + "superset.semantic_layers.api.is_semantic_layers_enabled", return_value=True, ) @@ -1936,7 +1937,7 @@ def test_connections_sort_by_name( ) mocker.patch( - "superset.semantic_layers.api.is_feature_enabled", + "superset.semantic_layers.api.is_semantic_layers_enabled", return_value=True, ) @@ -1985,7 +1986,7 @@ def test_connections_source_type_filter( mock_db_session.query.return_value = db_query mocker.patch( - "superset.semantic_layers.api.is_feature_enabled", + "superset.semantic_layers.api.is_semantic_layers_enabled", return_value=True, ) @@ -2040,7 +2041,7 @@ def test_connections_source_type_semantic_layer_only( ) mocker.patch( - "superset.semantic_layers.api.is_feature_enabled", + "superset.semantic_layers.api.is_semantic_layers_enabled", return_value=True, ) @@ -2099,7 +2100,7 @@ def test_connections_semantic_layer_filters_by_perms( ) mocker.patch( - "superset.semantic_layers.api.is_feature_enabled", + "superset.semantic_layers.api.is_semantic_layers_enabled", return_value=True, ) mocker.patch( @@ -3166,86 +3167,6 @@ def test_get_semantic_view_structure_forbidden( assert response.status_code == 403 -def test_semantic_view_structure_flag_off_unwrapped() -> None: - """Cover structure() feature-flag guard without auth decorators.""" - api = SemanticViewRestApi() - api.response_404 = MagicMock(return_value=("404", 404)) - structure_fn = inspect.unwrap(SemanticViewRestApi.structure) - - with patch( - "superset.semantic_layers.api.is_feature_enabled", - return_value=False, - ): - response = structure_fn(api, 1) - - assert response == ("404", 404) - api.response_404.assert_called_once() - - -def test_semantic_view_post_flag_off_unwrapped() -> None: - """Cover post() feature-flag guard without auth decorators.""" - api = SemanticViewRestApi() - api.response_404 = MagicMock(return_value=("404", 404)) - post_fn = inspect.unwrap(SemanticViewRestApi.post) - - with patch( - "superset.semantic_layers.api.is_feature_enabled", - return_value=False, - ): - response = post_fn(api) - - assert response == ("404", 404) - api.response_404.assert_called_once() - - -def test_semantic_view_delete_flag_off_unwrapped() -> None: - """Cover delete() feature-flag guard without auth decorators.""" - api = SemanticViewRestApi() - api.response_404 = MagicMock(return_value=("404", 404)) - delete_fn = inspect.unwrap(SemanticViewRestApi.delete) - - with patch( - "superset.semantic_layers.api.is_feature_enabled", - return_value=False, - ): - response = delete_fn(api, 1) - - assert response == ("404", 404) - api.response_404.assert_called_once() - - -def test_semantic_view_bulk_delete_flag_off_unwrapped() -> None: - """Cover bulk_delete() feature-flag guard without auth decorators.""" - api = SemanticViewRestApi() - api.response_404 = MagicMock(return_value=("404", 404)) - bulk_delete_fn = inspect.unwrap(SemanticViewRestApi.bulk_delete) - - with patch( - "superset.semantic_layers.api.is_feature_enabled", - return_value=False, - ): - response = bulk_delete_fn(api, rison=[1]) - - assert response == ("404", 404) - api.response_404.assert_called_once() - - -def test_semantic_layer_views_flag_off_unwrapped() -> None: - """Cover views() feature-flag guard without auth decorators.""" - api = SemanticLayerRestApi() - api.response_404 = MagicMock(return_value=("404", 404)) - views_fn = inspect.unwrap(SemanticLayerRestApi.views) - - with patch( - "superset.semantic_layers.api.is_feature_enabled", - return_value=False, - ): - response = views_fn(api, str(uuid_lib.uuid4())) - - assert response == ("404", 404) - api.response_404.assert_called_once() - - @SEMANTIC_LAYERS_APP def test_get_semantic_layer_feature_disabled( client: Any, @@ -3254,7 +3175,7 @@ def test_get_semantic_layer_feature_disabled( ) -> None: """Test GET / returns 404 when the feature flag is disabled.""" mocker.patch( - "superset.semantic_layers.api.is_feature_enabled", + "superset.semantic_layers.api.is_semantic_layers_enabled", return_value=False, ) @@ -3263,54 +3184,6 @@ def test_get_semantic_layer_feature_disabled( assert response.status_code == 404 -def test_semantic_layer_get_list_flag_off_unwrapped() -> None: - """Cover get_list() feature-flag guard without auth decorators.""" - api = SemanticLayerRestApi() - api.response_404 = MagicMock(return_value=("404", 404)) - get_list_fn = inspect.unwrap(SemanticLayerRestApi.get_list) - - with patch( - "superset.semantic_layers.api.is_feature_enabled", - return_value=False, - ): - response = get_list_fn(api) - - assert response == ("404", 404) - api.response_404.assert_called_once() - - -def test_semantic_layer_get_flag_off_unwrapped() -> None: - """Cover get() feature-flag guard without auth decorators.""" - api = SemanticLayerRestApi() - api.response_404 = MagicMock(return_value=("404", 404)) - get_fn = inspect.unwrap(SemanticLayerRestApi.get) - - with patch( - "superset.semantic_layers.api.is_feature_enabled", - return_value=False, - ): - response = get_fn(api, str(uuid_lib.uuid4())) - - assert response == ("404", 404) - api.response_404.assert_called_once() - - -def test_semantic_layer_runtime_schema_flag_off_unwrapped() -> None: - """Cover runtime_schema() feature-flag guard without auth decorators.""" - api = SemanticLayerRestApi() - api.response_404 = MagicMock(return_value=("404", 404)) - runtime_schema_fn = inspect.unwrap(SemanticLayerRestApi.runtime_schema) - - with patch( - "superset.semantic_layers.api.is_feature_enabled", - return_value=False, - ): - response = runtime_schema_fn(api, str(uuid_lib.uuid4())) - - assert response == ("404", 404) - api.response_404.assert_called_once() - - @SEMANTIC_LAYERS_APP @pytest.mark.parametrize("operation", ["create", "update"]) @pytest.mark.parametrize( diff --git a/tests/unit_tests/semantic_layers/simple_cache_clear_test.py b/tests/unit_tests/semantic_layers/simple_cache_clear_test.py index 8b71bf732314..70a4e083272b 100644 --- a/tests/unit_tests/semantic_layers/simple_cache_clear_test.py +++ b/tests/unit_tests/semantic_layers/simple_cache_clear_test.py @@ -251,7 +251,9 @@ def test_clear_route_requires_empty_object( """The clear endpoint does not accept arbitrary configuration or versions.""" command: Mock with ( - patch("superset.semantic_layers.api.is_feature_enabled", return_value=True), + patch( + "superset.semantic_layers.api.is_semantic_layers_enabled", return_value=True + ), patch( "superset.semantic_layers.api.ClearSemanticLayerCacheCommand", create=True, @@ -468,7 +470,10 @@ def test_clear_route_disabled_feature_does_no_work( """A disabled feature does not expose the mutation.""" command: Mock with ( - patch("superset.semantic_layers.api.is_feature_enabled", return_value=False), + patch( + "superset.semantic_layers.api.is_semantic_layers_enabled", + return_value=False, + ), patch( "superset.semantic_layers.api.ClearSemanticLayerCacheCommand", ) as command, diff --git a/tests/unit_tests/semantic_layers/test_runtime_flag.py b/tests/unit_tests/semantic_layers/test_runtime_flag.py index 0dd227b153bf..4c5a06049a76 100644 --- a/tests/unit_tests/semantic_layers/test_runtime_flag.py +++ b/tests/unit_tests/semantic_layers/test_runtime_flag.py @@ -143,10 +143,10 @@ def test_saving_semantic_chart_is_validation_error_when_disabled() -> None: @pytest.mark.parametrize( "path", ["/api/v1/semantic_layer/types", "/api/v1/semantic_view/17/structure"] ) -def test_registered_semantic_api_refuses_before_handler( +def test_semantic_blueprints_remain_registered_when_disabled( client: FlaskClient[Any], path: str, full_api_access: None ) -> None: - """Permitted callers cannot dispatch disabled semantic handlers.""" + """Both semantic blueprints stay registered while runtime availability is off.""" assert "SemanticLayerRestApi" in client.application.blueprints assert "SemanticViewRestApi" in client.application.blueprints with patch("superset.feature_flag_manager.is_feature_enabled", return_value=False): From 2c9a50105ca69057269bd2ea199bc7e3a4fc6406 Mon Sep 17 00:00:00 2001 From: Mike Bridge Date: Mon, 2 Nov 2026 12:47:59 -0600 Subject: [PATCH 3/4] docs(semantic-layer): fix runtime availability link --- .../extensions/extension-points/semantic-layers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer_docs/extensions/extension-points/semantic-layers.md b/docs/developer_docs/extensions/extension-points/semantic-layers.md index 2d9ac8a281dd..42aed62e4e6e 100644 --- a/docs/developer_docs/extensions/extension-points/semantic-layers.md +++ b/docs/developer_docs/extensions/extension-points/semantic-layers.md @@ -92,7 +92,7 @@ The decorator registers the class in the semantic layers registry, making it ava ## API permissions and runtime availability -The semantic APIs check endpoint permissions before evaluating `SEMANTIC_LAYERS` through the feature flag manager. See [runtime semantic-layer availability](../../../admin_docs/configuration/feature-flags.mdx#runtime-semantic-layer-availability) for permission, Public-role, `OPTIONS`, and `IS_FEATURE_ENABLED_FUNC` behavior. +The semantic APIs check endpoint permissions before evaluating `SEMANTIC_LAYERS` through the feature flag manager. See [runtime semantic-layer availability](/admin-docs/configuration/feature-flags#runtime-semantic-layer-availability) for permission, Public-role, `OPTIONS`, and `IS_FEATURE_ENABLED_FUNC` behavior. ## Provider obligations and isolated tests From 5fb3354985295ed394a7766bd095e9c3d81f4642 Mon Sep 17 00:00:00 2001 From: Mike Bridge Date: Tue, 3 Nov 2026 09:41:39 -0600 Subject: [PATCH 4/4] test(semantic-layer): check runtime refusal after permissions --- .../semantic_layers/api_flag_test.py | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/tests/integration_tests/semantic_layers/api_flag_test.py b/tests/integration_tests/semantic_layers/api_flag_test.py index 256a46ce9b99..477a8d6d4248 100644 --- a/tests/integration_tests/semantic_layers/api_flag_test.py +++ b/tests/integration_tests/semantic_layers/api_flag_test.py @@ -58,36 +58,43 @@ def test_every_semantic_route_is_guarded_at_runtime( test_client: FlaskClient[Any], login_as_admin: None, - monkeypatch: pytest.MonkeyPatch, method: str, path: str, expected_status: int, ) -> None: - """Boot-off registration plus a hook flip protects dispatch before any work.""" + """Runtime refusal allows permission checks but prevents semantic lookups.""" + from superset import db + from superset.semantic_layers.models import SemanticLayer, SemanticView + assert "SemanticLayerRestApi" in app.blueprints assert "SemanticViewRestApi" in app.blueprints - endpoint: str = app.url_map.bind("localhost").match(path, method=method)[0] - handler: MagicMock = MagicMock(wraps=app.view_functions[endpoint]) - monkeypatch.setitem(app.view_functions, endpoint, handler) decision: dict[str, bool] = {"enabled": False} + query: MagicMock + enabled: bool def resolve(name: str, default: bool) -> bool: """Change only the semantic flag, without rebuilding the application.""" return decision["enabled"] if name == "SEMANTIC_LAYERS" else default - with patch.object(feature_flag_manager, "_is_feature_enabled_func", resolve): - assert test_client.open(path, method=method).status_code == 404 - assert test_client.open(path, method=method).json == {"message": "Not found"} - handler.assert_not_called() - decision["enabled"] = True - enabled_response: TestResponse = test_client.open(path, method=method) - assert enabled_response.status_code == expected_status, ( - enabled_response.get_data(as_text=True) - ) - assert handler.call_count == 1 - decision["enabled"] = False - assert test_client.open(path, method=method).status_code == 404 - assert handler.call_count == 1 + with ( + patch.object(feature_flag_manager, "_is_feature_enabled_func", resolve), + patch.object(db.session, "query", wraps=db.session.query) as query, + ): + for enabled in (False, True, False): + decision["enabled"] = enabled + query.reset_mock() + response: TestResponse = test_client.open(path, method=method) + assert response.status_code == (expected_status if enabled else 404), ( + response.get_data(as_text=True) + ) + if not enabled: + assert response.json == {"message": "Not found"} + # FAB may query users and permissions before the runtime guard. + assert not any( + entity is SemanticView or entity is SemanticLayer + for query_call in query.call_args_list + for entity in query_call.args + ) def test_boot_off_registers_existing_semantic_permissions(app_context: Any) -> None: