Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions UPDATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,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
Expand Down
7 changes: 4 additions & 3 deletions docs/admin_docs/configuration/feature-flags.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ 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 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

Store the parsed configuration instance on the writable `self.configuration`
Expand Down
96 changes: 41 additions & 55 deletions superset/semantic_layers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,7 +33,7 @@
from superset_core.semantic_layers.config import SemanticCredentialReadiness
from superset_core.semantic_layers.transport import ProviderRequestError

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,
Expand Down Expand Up @@ -127,28 +128,26 @@ 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
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:
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]:
Expand Down Expand Up @@ -254,7 +253,7 @@ def _parse_partial_config(
return None


class SemanticViewRestApi(_RuntimeGuardedApiMixin, BaseSupersetModelRestApi):
class SemanticViewRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(SemanticView)

resource_name = "semantic_view"
Expand All @@ -275,6 +274,7 @@ class SemanticViewRestApi(_RuntimeGuardedApiMixin, BaseSupersetModelRestApi):

@expose("/<int:pk>/structure", methods=("GET",))
@protect()
@_requires_semantic_layers
@safe
@statsd_metrics
@event_logger.log_this_with_context(
Expand Down Expand Up @@ -307,9 +307,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()
Expand Down Expand Up @@ -374,6 +371,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",
Expand Down Expand Up @@ -421,9 +419,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):
Expand Down Expand Up @@ -484,6 +479,7 @@ def post(self) -> Response:

@expose("/<int:pk>", methods=("PUT",))
@protect()
@_requires_semantic_layers
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put",
Expand Down Expand Up @@ -557,6 +553,7 @@ def put(self, pk: int) -> Response:

@expose("/<int:pk>", methods=("DELETE",))
@protect()
@_requires_semantic_layers
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete",
Expand Down Expand Up @@ -626,9 +623,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")
Expand All @@ -655,6 +649,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(
Expand Down Expand Up @@ -734,9 +729,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()
Expand Down Expand Up @@ -770,7 +762,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"
Expand All @@ -794,6 +786,7 @@ class SemanticLayerRestApi(_RuntimeGuardedApiMixin, BaseSupersetApi):

@expose("/types", methods=("GET",))
@protect()
@_requires_semantic_layers
@safe
@statsd_metrics
def types(self) -> FlaskResponse:
Expand All @@ -815,6 +808,7 @@ def types(self) -> FlaskResponse:

@expose("/schema/configuration", methods=("POST",))
@protect()
@_requires_semantic_layers
@safe
@statsd_metrics
@requires_json
Expand Down Expand Up @@ -901,6 +895,7 @@ def configuration_schema(self) -> FlaskResponse:

@expose("/<uuid>/schema/runtime", methods=("POST",))
@protect()
@_requires_semantic_layers
@safe
@statsd_metrics
def runtime_schema(self, uuid: str) -> FlaskResponse:
Expand Down Expand Up @@ -931,9 +926,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()
Expand Down Expand Up @@ -975,6 +967,7 @@ def runtime_schema(self, uuid: str) -> FlaskResponse:

@expose("/<uuid>/views", methods=("POST",))
@protect()
@_requires_semantic_layers
@safe
@statsd_metrics
def views(self, uuid: str) -> FlaskResponse:
Expand Down Expand Up @@ -1005,9 +998,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()
Expand Down Expand Up @@ -1058,6 +1048,7 @@ def views(self, uuid: str) -> FlaskResponse:

@expose("/", methods=("POST",))
@protect()
@_requires_semantic_layers
@safe
@statsd_metrics
@requires_json
Expand Down Expand Up @@ -1113,6 +1104,7 @@ def post(self) -> FlaskResponse:

@expose("/<uuid>", methods=("PUT",))
@protect()
@_requires_semantic_layers
@safe
@statsd_metrics
@requires_json
Expand Down Expand Up @@ -1179,6 +1171,7 @@ def put(self, uuid: str) -> FlaskResponse:

@expose("/<uuid>/clear_cache", methods=("POST",))
@protect()
@_requires_semantic_layers
@safe
@statsd_metrics
@requires_json
Expand Down Expand Up @@ -1214,8 +1207,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:
Expand All @@ -1230,6 +1221,7 @@ def clear_cache(self, uuid: str) -> FlaskResponse:

@expose("/<uuid>", methods=("DELETE",))
@protect()
@_requires_semantic_layers
@safe
@statsd_metrics
def delete(self, uuid: str) -> FlaskResponse:
Expand Down Expand Up @@ -1322,6 +1314,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)
Expand Down Expand Up @@ -1360,9 +1353,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)
Expand Down Expand Up @@ -1537,6 +1527,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:
Expand All @@ -1550,15 +1541,13 @@ 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)

@expose("/<uuid>", methods=("GET",))
@protect()
@_requires_semantic_layers
@safe
@statsd_metrics
def get(self, uuid: str) -> FlaskResponse:
Expand All @@ -1581,9 +1570,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()
Expand Down
Loading
Loading