feat: Explorable protocol (#36245)

This commit is contained in:
Beto Dealmeida
2025-12-04 13:18:34 -05:00
committed by GitHub
parent c36ac53445
commit 16e6452b8c
14 changed files with 563 additions and 109 deletions

View File

@@ -87,6 +87,7 @@ if TYPE_CHECKING:
RowLevelSecurityFilter,
SqlaTable,
)
from superset.explorables.base import Explorable
from superset.models.core import Database
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
@@ -540,24 +541,43 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
or (catalog_perm and self.can_access("catalog_access", catalog_perm))
)
def can_access_schema(self, datasource: "BaseDatasource") -> bool:
def can_access_schema(self, datasource: "BaseDatasource | Explorable") -> bool:
"""
Return True if the user can access the schema associated with specified
datasource, False otherwise.
For SQL datasources: Checks database → catalog → schema hierarchy
For other explorables: Only checks all_datasources permission
:param datasource: The datasource
:returns: Whether the user can access the datasource's schema
"""
from superset.connectors.sqla.models import BaseDatasource
return (
self.can_access_all_datasources()
or self.can_access_database(datasource.database)
or (
datasource.catalog
# Admin/superuser override
if self.can_access_all_datasources():
return True
# SQL-specific hierarchy checks
if isinstance(datasource, BaseDatasource):
# Database-level access grants all schemas
if self.can_access_database(datasource.database):
return True
# Catalog-level access grants all schemas in catalog
if (
hasattr(datasource, "catalog")
and datasource.catalog
and self.can_access_catalog(datasource.database, datasource.catalog)
)
or self.can_access("schema_access", datasource.schema_perm or "")
)
):
return True
# Schema-level permission (SQL only)
if self.can_access("schema_access", datasource.schema_perm or ""):
return True
# Non-SQL explorables don't have schema hierarchy
return False
def can_access_datasource(self, datasource: "BaseDatasource") -> bool:
"""
@@ -604,7 +624,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
self,
form_data: dict[str, Any],
dashboard: "Dashboard",
datasource: "BaseDatasource",
datasource: "BaseDatasource | Explorable",
) -> bool:
"""
Return True if the form_data is performing a supported drill by operation,
@@ -612,10 +632,10 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
:param form_data: The form_data included in the request.
:param dashboard: The dashboard the user is drilling from.
:returns: Whether the user has drill byaccess.
:param datasource: The datasource being queried
:returns: Whether the user has drill by access.
"""
from superset.connectors.sqla.models import TableColumn
from superset.models.slice import Slice
return bool(
@@ -630,16 +650,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
and slc in dashboard.slices
and slc.datasource == datasource
and (dimensions := form_data.get("groupby"))
and (
drillable_columns := {
row[0]
for row in self.session.query(TableColumn.column_name)
.filter(TableColumn.table_id == datasource.id)
.filter(TableColumn.groupby)
.all()
}
)
and set(dimensions).issubset(drillable_columns)
and datasource.has_drill_by_columns(dimensions)
)
def can_access_dashboard(self, dashboard: "Dashboard") -> bool:
@@ -705,7 +716,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
)
@staticmethod
def get_datasource_access_error_msg(datasource: "BaseDatasource") -> str:
def get_datasource_access_error_msg(
datasource: "BaseDatasource | Explorable",
) -> str:
"""
Return the error message for the denied Superset datasource.
@@ -714,13 +727,13 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"""
return (
f"This endpoint requires the datasource {datasource.id}, "
f"This endpoint requires the datasource {datasource.data['id']}, "
"database or `all_datasource_access` permission"
)
@staticmethod
def get_datasource_access_link( # pylint: disable=unused-argument
datasource: "BaseDatasource",
datasource: "BaseDatasource | Explorable",
) -> Optional[str]:
"""
Return the link for the denied Superset datasource.
@@ -732,7 +745,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
return get_conf().get("PERMISSION_INSTRUCTIONS_LINK")
def get_datasource_access_error_object( # pylint: disable=invalid-name
self, datasource: "BaseDatasource"
self, datasource: "BaseDatasource | Explorable"
) -> SupersetError:
"""
Return the error object for the denied Superset datasource.
@@ -746,8 +759,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
level=ErrorLevel.WARNING,
extra={
"link": self.get_datasource_access_link(datasource),
"datasource": datasource.id,
"datasource_name": datasource.name,
"datasource": datasource.data["id"],
"datasource_name": datasource.data["name"],
},
)
@@ -2280,8 +2293,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
dashboard: Optional["Dashboard"] = None,
chart: Optional["Slice"] = None,
database: Optional["Database"] = None,
datasource: Optional["BaseDatasource"] = None,
query: Optional["Query"] = None,
datasource: Optional["BaseDatasource | Explorable"] = None,
query: Optional["Query | Explorable"] = None,
query_context: Optional["QueryContext"] = None,
table: Optional["Table"] = None,
viz: Optional["BaseViz"] = None,
@@ -2326,7 +2339,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
if database and table or query:
if query:
database = query.database
# Type narrow: only SQL Lab Query objects have .database attribute
if hasattr(query, "database"):
database = query.database
database = cast("Database", database)
default_catalog = database.get_default_catalog()
@@ -2334,7 +2349,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
if self.can_access_database(database):
return
if query:
# Type narrow: this path only applies to SQL Lab Query objects
if query and hasattr(query, "sql") and hasattr(query, "catalog"):
# Getting the default schema for a query is hard. Users can select the
# schema in SQL Lab, but there's no guarantee that the query actually
# will run in that schema. Each DB engine spec needs to implement the
@@ -2342,8 +2358,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
# If the DB engine spec doesn't implement the logic the schema is read
# from the SQLAlchemy URI if possible; if not, we use the SQLAlchemy
# inspector to read it.
from superset.models.sql_lab import Query
default_schema = database.get_default_schema_for_query(
query, template_params
cast(Query, query),
template_params,
)
tables = {
table_.qualify(
@@ -2455,7 +2474,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
and dashboard_.json_metadata
and (json_metadata := json.loads(dashboard_.json_metadata))
and any(
target.get("datasetId") == datasource.id
target.get("datasetId") == datasource.data["id"]
for fltr in json_metadata.get(
"native_filter_configuration",
[],
@@ -2560,7 +2579,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
return super().get_user_roles(user)
def get_guest_rls_filters(
self, dataset: "BaseDatasource"
self, dataset: "BaseDatasource | Explorable"
) -> list[GuestTokenRlsRule]:
"""
Retrieves the row level security filters for the current user and the dataset,
@@ -2573,11 +2592,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
rule
for rule in guest_user.rls
if not rule.get("dataset")
or str(rule.get("dataset")) == str(dataset.id)
or str(rule.get("dataset")) == str(dataset.data["id"])
]
return []
def get_rls_filters(self, table: "BaseDatasource") -> list[SqlaQuery]:
def get_rls_filters(self, table: "BaseDatasource | Explorable") -> list[SqlaQuery]:
"""
Retrieves the appropriate row level security filters for the current user and
the passed table.
@@ -2614,7 +2633,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
.filter(RLSFilterRoles.c.role_id.in_(user_roles))
)
filter_tables = self.session.query(RLSFilterTables.c.rls_filter_id).filter(
RLSFilterTables.c.table_id == table.id
RLSFilterTables.c.table_id == table.data["id"]
)
query = (
self.session.query(
@@ -2640,7 +2659,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
)
return query.all()
def get_rls_sorted(self, table: "BaseDatasource") -> list["RowLevelSecurityFilter"]:
def get_rls_sorted(
self, table: "BaseDatasource | Explorable"
) -> list["RowLevelSecurityFilter"]:
"""
Retrieves a list RLS filters sorted by ID for
the current user and the passed table.
@@ -2652,10 +2673,12 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
filters.sort(key=lambda f: f.id)
return filters
def get_guest_rls_filters_str(self, table: "BaseDatasource") -> list[str]:
def get_guest_rls_filters_str(
self, table: "BaseDatasource | Explorable"
) -> list[str]:
return [f.get("clause", "") for f in self.get_guest_rls_filters(table)]
def get_rls_cache_key(self, datasource: "BaseDatasource") -> list[str]:
def get_rls_cache_key(self, datasource: "Explorable | BaseDatasource") -> list[str]:
rls_clauses_with_group_key = []
if datasource.is_rls_supported:
rls_clauses_with_group_key = [