You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by as...@apache.org on 2026/06/03 11:30:31 UTC
(superset) 01/01: feat(security): add config-based extension hooks for permission overrides
This is an automated email from the ASF dual-hosted git repository.
alexandrusoare pushed a commit to branch alexandrusoare/feat/permission-extension-hooks
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 8c818beedbe4dc10eaa9a6622df3069ec402b3a7
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Wed Jun 3 14:30:09 2026 +0300
feat(security): add config-based extension hooks for permission overrides
---
superset/charts/api.py | 10 +++++++++-
superset/charts/filters.py | 28 ++++++++++++++++++++++++----
superset/commands/explore/get.py | 11 +++++++++++
superset/commands/utils.py | 8 ++++++--
superset/config.py | 28 ++++++++++++++++++++++++++++
superset/dashboards/api.py | 9 ++++++++-
superset/dashboards/filters.py | 12 ++++++++++++
superset/security/manager.py | 20 ++++++++++++++++++++
8 files changed, 118 insertions(+), 8 deletions(-)
diff --git a/superset/charts/api.py b/superset/charts/api.py
index 617129d49c6..1b35ddd4c7d 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -21,7 +21,7 @@ from io import BytesIO
from typing import Any, cast, Optional
from zipfile import is_zipfile, ZipFile
-from flask import redirect, request, Response, send_file, url_for
+from flask import current_app, redirect, request, Response, send_file, url_for
from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
from flask_appbuilder.hooks import before_request
from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -86,6 +86,7 @@ from superset.models.slice import Slice
from superset.tasks.thumbnails import cache_chart_thumbnail
from superset.tasks.utils import get_current_user
from superset.utils import json
+from superset.utils.core import get_user_id
from superset.utils.screenshots import (
ChartScreenshot,
DEFAULT_CHART_WINDOW_SIZE,
@@ -308,6 +309,13 @@ class ChartRestApi(BaseSupersetModelRestApi):
try:
dash = ChartDAO.get_by_id_or_uuid(id_or_uuid)
result = self.chart_get_response_schema.dump(dash)
+ can_edit = security_manager.is_owner(dash)
+ if not can_edit:
+ if resolver := current_app.config.get("EXTRA_CAN_EDIT_RESOLVER"):
+ user_id = get_user_id()
+ if user_id:
+ can_edit = resolver(user_id, dash)
+ result["can_edit"] = can_edit
return self.response(200, result=result)
except ChartNotFoundError:
return self.response_404()
diff --git a/superset/charts/filters.py b/superset/charts/filters.py
index f9748dd0ecb..eb656f913b2 100644
--- a/superset/charts/filters.py
+++ b/superset/charts/filters.py
@@ -104,12 +104,32 @@ class ChartFilter(BaseFilter): # pylint: disable=too-few-public-methods
if security_manager.can_access_all_datasources():
return query
+ from flask import current_app
+
table_alias = aliased(SqlaTable)
- query = query.join(table_alias, self.model.datasource_id == table_alias.id)
- query = query.join(
- models.Database, table_alias.database_id == models.Database.id
+ dataset_query = (
+ query.join(table_alias, self.model.datasource_id == table_alias.id)
+ .join(models.Database, table_alias.database_id == models.Database.id)
+ .filter(get_dataset_access_filters(self.model))
+ )
+
+ extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
+ if extra_charts_filter := extra_filters.get("charts"):
+ user_id = get_user_id()
+ if user_id:
+ extra_ids = extra_charts_filter(user_id)
+ return query.filter(
+ or_(
+ self.model.id.in_(
+ dataset_query.with_entities(self.model.id).subquery()
+ ),
+ self.model.id.in_(extra_ids),
+ )
+ )
+
+ return query.filter(
+ self.model.id.in_(dataset_query.with_entities(self.model.id).subquery())
)
- return query.filter(get_dataset_access_filters(self.model))
class ChartHasCreatedByFilter(BaseFilter): # pylint: disable=too-few-public-methods
diff --git a/superset/commands/explore/get.py b/superset/commands/explore/get.py
index d4dd58ca812..7038bfc1e42 100644
--- a/superset/commands/explore/get.py
+++ b/superset/commands/explore/get.py
@@ -160,9 +160,20 @@ class GetExploreCommand(BaseCommand, ABC):
metadata = None
if slc:
+ from flask import current_app
+
+ from superset.utils.core import get_user_id
+
+ user_id = get_user_id()
+ can_edit = any(owner.id == user_id for owner in slc.owners)
+ if not can_edit and user_id:
+ if resolver := current_app.config.get("EXTRA_CAN_EDIT_RESOLVER"):
+ can_edit = resolver(user_id, slc)
+
metadata = {
"created_on_humanized": slc.created_on_humanized,
"changed_on_humanized": slc.changed_on_humanized,
+ "can_edit": can_edit,
"owners": [owner.get_full_name() for owner in slc.owners],
"dashboards": [
{"id": dashboard.id, "dashboard_title": dashboard.dashboard_title}
diff --git a/superset/commands/utils.py b/superset/commands/utils.py
index 7486d657adc..adf393f0990 100644
--- a/superset/commands/utils.py
+++ b/superset/commands/utils.py
@@ -19,7 +19,7 @@ from __future__ import annotations
from collections import Counter
from typing import Any, Optional, TYPE_CHECKING
-from flask import g
+from flask import current_app, g
from flask_appbuilder.security.sqla.models import Role, User
from superset import security_manager
@@ -59,7 +59,11 @@ def populate_owner_list(
return [g.user]
if not (security_manager.is_admin() or get_user_id() in owner_ids):
# make sure non-admins can't remove themselves as owner by mistake
- owners.append(g.user)
+ skip = False
+ if skip_check := current_app.config.get("EXTRA_OWNER_AUTO_ADD_SKIP"):
+ skip = skip_check(get_user_id())
+ if not skip:
+ owners.append(g.user)
for owner_id in owner_ids:
owner = security_manager.get_user_by_id(owner_id)
if not owner:
diff --git a/superset/config.py b/superset/config.py
index e2f48711a77..486abf3103a 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -2501,6 +2501,34 @@ class ExtraDynamicQueryFilters(TypedDict, total=False):
EXTRA_DYNAMIC_QUERY_FILTERS: ExtraDynamicQueryFilters = {}
+# Extra access query filters inject additional OR conditions into
+# ChartFilter and DashboardAccessFilter, enabling external systems
+# (e.g. folder permissions) to grant asset visibility.
+# The callable receives the current user ID and returns a subquery of asset IDs.
+class ExtraAccessQueryFilters(TypedDict, total=False):
+ charts: Callable[[int], Query]
+ dashboards: Callable[[int], Query]
+
+
+EXTRA_ACCESS_QUERY_FILTERS: ExtraAccessQueryFilters = {}
+
+# Bypass hook for raise_for_access(). Return True to skip all permission checks
+# (including datasource). Receives keyword arguments matching raise_for_access().
+EXTRA_RAISE_FOR_ACCESS_BYPASS: Callable[..., bool] | None = None
+
+# Ownership bypass hook for raise_for_ownership(). Return True to allow edit/delete.
+# Receives (user_id, resource).
+EXTRA_OWNERSHIP_CHECKS: Callable[[int, Any], bool] | None = None
+
+# Skip auto-adding current user as owner on save. Return True to skip.
+# Receives user_id.
+EXTRA_OWNER_AUTO_ADD_SKIP: Callable[[int], bool] | None = None
+
+# Resolve additional edit permission beyond ownership.
+# Receives (user_id, resource). Return True if user can edit.
+EXTRA_CAN_EDIT_RESOLVER: Callable[[int, Any], bool] | None = None
+
+
# The migrations that add catalog permissions might take a considerably long time
# to execute as it has to create permissions to all schemas and catalogs from all
# other catalogs accessible by the credentials. This flag allows to skip the
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 3deb304c009..15c7e8d25f5 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -130,7 +130,7 @@ from superset.tasks.thumbnails import (
)
from superset.tasks.utils import get_current_user
from superset.utils import json
-from superset.utils.core import parse_boolean_string
+from superset.utils.core import get_user_id, parse_boolean_string
from superset.utils.file import get_filename
from superset.utils.pdf import build_pdf_from_screenshots
from superset.utils.screenshots import (
@@ -519,6 +519,13 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
schema = self.dashboard_get_response_schema
result = schema.dump(dash)
+ can_edit = security_manager.is_owner(dash)
+ if not can_edit:
+ if resolver := current_app.config.get("EXTRA_CAN_EDIT_RESOLVER"):
+ user_id = get_user_id()
+ if user_id:
+ can_edit = resolver(user_id, dash)
+ result["can_edit"] = can_edit
add_extra_log_payload(
dashboard_id=dash.id, action=f"{self.__class__.__name__}.get"
)
diff --git a/superset/dashboards/filters.py b/superset/dashboards/filters.py
index 9a4c496b20b..0e808e64086 100644
--- a/superset/dashboards/filters.py
+++ b/superset/dashboards/filters.py
@@ -182,11 +182,23 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
feature_flagged_filters.append(condition)
+ from flask import current_app
+
+ extra_access_filters = []
+ extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
+ if extra_dashboards_filter := extra_filters.get("dashboards"):
+ user_id = get_user_id()
+ if user_id:
+ extra_access_filters.append(
+ Dashboard.id.in_(extra_dashboards_filter(user_id))
+ )
+
query = query.filter(
or_(
Dashboard.id.in_(owner_ids_query),
Dashboard.id.in_(datasource_perm_query),
*feature_flagged_filters,
+ *extra_access_filters,
)
)
diff --git a/superset/security/manager.py b/superset/security/manager.py
index 737b172cebd..616f73e9634 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -2911,6 +2911,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
:raises SupersetSecurityException: If the user cannot access the resource
"""
# pylint: disable=import-outside-toplevel
+ from flask import current_app
+
from superset import is_feature_enabled
from superset.connectors.sqla.models import SqlaTable
from superset.models.dashboard import Dashboard
@@ -2918,6 +2920,18 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
from superset.models.sql_lab import Query
from superset.utils.core import shortid
+ # Extension hook: bypass all permission checks if an external system
+ # (e.g. folder permissions) grants access to this resource.
+ if bypass := current_app.config.get("EXTRA_RAISE_FOR_ACCESS_BYPASS"):
+ if bypass(
+ user_id=get_user_id(),
+ dashboard=dashboard,
+ chart=chart,
+ datasource=datasource,
+ query_context=query_context,
+ ):
+ return
+
if sql and database:
query = Query(
database=database,
@@ -3707,6 +3721,12 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
owners = orig_resource.owners if hasattr(orig_resource, "owners") else []
if g.user.is_anonymous or g.user not in owners:
+ # Extension hook: allow edit/delete if an external system grants access
+ if ownership_check := current_app.config.get("EXTRA_OWNERSHIP_CHECKS"):
+ user_id = g.user.id if not g.user.is_anonymous else None
+ if user_id and ownership_check(user_id, resource):
+ return
+
raise SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.MISSING_OWNERSHIP_ERROR,