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,