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/08/27 20:04:27 UTC

(superset) branch alexandrusoare/feat/folders-testing-branch-v2 updated: latest changes

This is an automated email from the ASF dual-hosted git repository.

alexandrusoare pushed a commit to branch alexandrusoare/feat/folders-testing-branch-v2
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/alexandrusoare/feat/folders-testing-branch-v2 by this push:
     new 063e9f93046 latest changes
063e9f93046 is described below

commit 063e9f93046f72e56507081a7abe3e1889235029
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Thu Aug 27 23:04:06 2026 +0300

    latest changes
---
 .../pages/AnalyticsList/FolderPermissionsModal.tsx |  14 +-
 .../src/pages/AnalyticsList/TransferModal.tsx      |  11 +-
 .../src/pages/AnalyticsList/index.tsx              |  80 +++++++----
 superset-frontend/src/pages/AnalyticsList/types.ts |   2 +-
 superset/daos/folder.py                            | 150 ++++++++++++++-------
 superset/folders/api.py                            | 148 +++++++++++++++-----
 superset/folders/hooks.py                          |  87 ++++++++----
 superset/security/manager.py                       |  29 ++++
 8 files changed, 372 insertions(+), 149 deletions(-)

diff --git a/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx b/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
index 851889e2d21..d684dbf953c 100644
--- a/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { SupersetClient } from '@superset-ui/core';
 import { styled, css, useTheme } from '@apache-superset/core/theme';
 import { t } from '@apache-superset/core/translation';
@@ -107,7 +107,7 @@ export default function FolderPermissionsModal({
   const [loading, setLoading] = useState(false);
   const [saving, setSaving] = useState(false);
   const [memberSearch, setMemberSearch] = useState('');
-  const [adminIds, setAdminIds] = useState<Set<number>>(new Set());
+  const adminIdsRef = useRef<Set<number>>(new Set());
 
   const fetchSubjects = useCallback(async () => {
     setLoading(true);
@@ -146,7 +146,7 @@ export default function FolderPermissionsModal({
       const item = selected as { value: number; label: string };
       const userId = Number(item.value);
       if (!userId || Number.isNaN(userId)) return;
-      const isAdmin = adminIds.has(userId);
+      const isAdmin = adminIdsRef.current.has(userId);
       setLocalSubjects(prev => {
         if (prev.some(s => s.user_id === userId)) return prev;
         return [
@@ -164,7 +164,7 @@ export default function FolderPermissionsModal({
         ];
       });
     },
-    [currentUserId, adminIds],
+    [currentUserId],
   );
 
   const handleChangePermission = useCallback(
@@ -275,11 +275,9 @@ export default function FolderPermissionsModal({
         endpoint: `/api/v1/folders/${folderUuid}/available-users?${params}`,
       });
       const results = json?.result || [];
-      const newAdminIds = new Set(adminIds);
       results.forEach((u: { id: number; is_admin?: boolean }) => {
-        if (u.is_admin) newAdminIds.add(u.id);
+        if (u.is_admin) adminIdsRef.current.add(u.id);
       });
-      setAdminIds(newAdminIds);
       return {
         data: results.map(
           (u: { id: number; email: string; is_admin?: boolean }) => ({
@@ -290,7 +288,7 @@ export default function FolderPermissionsModal({
         totalCount: json?.count ?? 0,
       };
     },
-    [folderUuid, adminIds],
+    [folderUuid],
   );
 
   const filtered = useMemo(() => {
diff --git a/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx b/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
index 2b611c9d4d8..e7585f6649d 100644
--- a/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
@@ -60,6 +60,7 @@ interface FolderOption {
   uuid: string;
   name: string;
   parent_uuid: string | null;
+  user_permission?: 'editor' | 'viewer' | 'implicit' | null;
 }
 
 interface TransferModalProps {
@@ -217,13 +218,15 @@ async function fetchPage(
   const response = await SupersetClient.get({
     endpoint: `${base}?${folderParam}q=${q}`,
   });
-  const results =
+  const results = (
     (response.json.result as {
       type: string;
       id: number;
       uuid?: string | null;
       name: string;
-    }[]) || [];
+      user_permission?: string | null;
+    }[]) || []
+  ).filter(r => r.user_permission !== 'implicit');
   return {
     items: results.map(toTransferItem),
     total: (response.json.count as number) || 0,
@@ -290,7 +293,9 @@ export default function TransferModal({
         setLeftPage(0);
         const folders = (
           (foldersRes.json.result as FolderOption[]) || []
-        ).filter(f => f.uuid !== currentFolderUuid);
+        ).filter(
+          f => f.uuid !== currentFolderUuid && f.user_permission !== 'implicit',
+        );
         setAllFolders(folders);
         if (preSelectedKeys?.length) {
           setLeftSelected(new Set(preSelectedKeys));
diff --git a/superset-frontend/src/pages/AnalyticsList/index.tsx b/superset-frontend/src/pages/AnalyticsList/index.tsx
index c0234e08d43..1c29c4b84a0 100644
--- a/superset-frontend/src/pages/AnalyticsList/index.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/index.tsx
@@ -81,7 +81,7 @@ import type { ContentItem } from './types';
 interface Crumb {
   uuid: string | null;
   name: string;
-  user_permission?: 'editor' | 'viewer' | null;
+  user_permission?: 'editor' | 'viewer' | 'implicit' | null;
 }
 
 interface SortColumn {
@@ -391,8 +391,16 @@ function AnalyticsList({
             break;
           case 'changed_on':
             if (operator === 'between' && Array.isArray(scalar)) {
-              filterExps.push({ col: 'changed_on', opr: 'gt', value: scalar[0] });
-              filterExps.push({ col: 'changed_on', opr: 'lt', value: scalar[1] });
+              filterExps.push({
+                col: 'changed_on',
+                opr: 'gt',
+                value: scalar[0],
+              });
+              filterExps.push({
+                col: 'changed_on',
+                opr: 'lt',
+                value: scalar[1],
+              });
             } else if (operator === 'gt') {
               filterExps.push({ col: 'changed_on', opr: 'gt', value: scalar });
             } else if (operator === 'lt') {
@@ -463,7 +471,7 @@ function AnalyticsList({
             uuid: string;
             name: string;
             parent_uuid: string | null;
-            user_permission?: 'editor' | 'viewer' | null;
+            user_permission?: 'editor' | 'viewer' | 'implicit' | null;
           };
           crumbs.unshift({
             uuid: folder.uuid,
@@ -738,7 +746,7 @@ function AnalyticsList({
               });
             }
           }
-          if (canEditCurrentFolder) {
+          if (canEditCurrentFolder && original.user_permission !== 'implicit') {
             contextMenuItems.push({
               key: 'move',
               label: t('Move to…'),
@@ -749,23 +757,31 @@ function AnalyticsList({
           const nameContent = (() => {
             if (original.type === 'folder') {
               return (
-                <NameLink
-                  role="button"
-                  tabIndex={0}
-                  onClick={() => drillInto(original)}
-                  onKeyDown={e => {
-                    if (e.key === 'Enter') drillInto(original);
-                  }}
+                <Tooltip
+                  title={
+                    original.user_permission === 'implicit'
+                      ? t('You have limited access to this folder')
+                      : undefined
+                  }
                 >
-                  <Icons.FolderOutlined
-                    iconSize="m"
-                    css={{
-                      color: theme.colorSuccess,
+                  <NameLink
+                    role="button"
+                    tabIndex={0}
+                    onClick={() => drillInto(original)}
+                    onKeyDown={e => {
+                      if (e.key === 'Enter') drillInto(original);
                     }}
-                  />
-                  {highlightName(original.name)}
-                  {pinIcon}
-                </NameLink>
+                  >
+                    <Icons.FolderOutlined
+                      iconSize="m"
+                      css={{
+                        color: theme.colorSuccess,
+                      }}
+                    />
+                    {highlightName(original.name)}
+                    {pinIcon}
+                  </NameLink>
+                </Tooltip>
               );
             }
             if (original.type === 'dashboard') {
@@ -1024,9 +1040,13 @@ function AnalyticsList({
             );
           }
           // chart / dashboard rows: mirror the CRUD lists' row actions.
+          const isAssetOwner = original.owners?.some(
+            o => o.id === currentUserId,
+          );
+          const canEditAsset = canEditCurrentFolder || isAssetOwner;
           return (
             <Actions className="actions">
-              {canEditCurrentFolder && (
+              {canEditAsset && (
                 <Tooltip title={t('Edit')} placement="bottom">
                   <span
                     role="button"
@@ -1055,7 +1075,7 @@ function AnalyticsList({
                   <Icons.UploadOutlined iconSize="l" />
                 </span>
               </Tooltip>
-              {canEditCurrentFolder && (
+              {canEditAsset && (
                 <ConfirmStatusChange
                   title={t('Please confirm')}
                   description={t(
@@ -1274,12 +1294,16 @@ function AnalyticsList({
                   label: t('Dashboard'),
                   onClick: () => window.location.assign('/dashboard/new'),
                 },
-                {
-                  key: 'folder',
-                  icon: <Icons.FolderOutlined />,
-                  label: t('Folder'),
-                  onClick: () => setShowCreateFolder(true),
-                },
+                ...(canEditCurrentFolder
+                  ? [
+                      {
+                        key: 'folder',
+                        icon: <Icons.FolderOutlined />,
+                        label: t('Folder'),
+                        onClick: () => setShowCreateFolder(true),
+                      },
+                    ]
+                  : []),
               ],
             }}
           >
diff --git a/superset-frontend/src/pages/AnalyticsList/types.ts b/superset-frontend/src/pages/AnalyticsList/types.ts
index 27594b07a92..a804b016327 100644
--- a/superset-frontend/src/pages/AnalyticsList/types.ts
+++ b/superset-frontend/src/pages/AnalyticsList/types.ts
@@ -44,7 +44,7 @@ export interface ContentItem {
   tags?: TagType[];
   asset_count?: number;
   children_count?: number;
-  user_permission?: 'editor' | 'viewer' | null;
+  user_permission?: 'editor' | 'viewer' | 'implicit' | null;
   parent_uuid?: string | null;
   inherits_permissions?: boolean;
   folder_path?: Array<{ uuid: string; name: string }>;
diff --git a/superset/daos/folder.py b/superset/daos/folder.py
index a1af64ddee3..49688037472 100644
--- a/superset/daos/folder.py
+++ b/superset/daos/folder.py
@@ -200,11 +200,21 @@ class FolderDAO(BaseDAO[Folder]):
                 fq = fq.where(Folder.changed_on >= modified_start)
             if modified_end:
                 fq = fq.where(Folder.changed_on <= modified_end)
-            # Access filter: non-admins only see folders they are a member of
+            # Access filter: non-admins see member folders + implicit folders
             if not is_admin and user_id:
                 from superset.folders.utils import user_accessible_folder_ids
 
-                fq = fq.where(Folder.id.in_(user_accessible_folder_ids(user_id)))
+                member_folder_ids = user_accessible_folder_ids(user_id)
+                implicit_ids = cls.folders_with_accessible_assets(user_id, folder_type)
+                if implicit_ids:
+                    fq = fq.where(
+                        or_(
+                            Folder.id.in_(member_folder_ids),
+                            Folder.id.in_(list(implicit_ids)),
+                        )
+                    )
+                else:
+                    fq = fq.where(Folder.id.in_(member_folder_ids))
             if editors:
                 editor_folder_ids = select(folder_editors.c.folder_id).where(
                     folder_editors.c.user_id.in_(editors)
@@ -231,27 +241,24 @@ class FolderDAO(BaseDAO[Folder]):
             model = ASSET_TYPE_CONFIGS[name].model
             fk_col = getattr(FolderObject, ASSET_TYPE_CONFIGS[name].fk_column)
             if name == "chart":
-                aq = (
-                    select(
-                        literal(1).label("kind_order"),
-                        literal(name).label("item_type"),
-                        model.id.label("item_id"),
-                        model.changed_on.label("changed_on"),
-                        title_attrs[name].label("sort_name"),
-                        func.coalesce(
-                            func.concat(
-                                func.coalesce(SqlaTable.schema, ""),
-                                case(
-                                    (SqlaTable.schema.isnot(None), "."),
-                                    else_="",
-                                ),
-                                func.coalesce(SqlaTable.table_name, ""),
+                aq = select(
+                    literal(1).label("kind_order"),
+                    literal(name).label("item_type"),
+                    model.id.label("item_id"),
+                    model.changed_on.label("changed_on"),
+                    title_attrs[name].label("sort_name"),
+                    func.coalesce(
+                        func.concat(
+                            func.coalesce(SqlaTable.schema, ""),
+                            case(
+                                (SqlaTable.schema.isnot(None), "."),
+                                else_="",
                             ),
-                            "",
-                        ).label("sort_datasource"),
-                    )
-                    .outerjoin(SqlaTable, Slice.datasource_id == SqlaTable.id)
-                )
+                            func.coalesce(SqlaTable.table_name, ""),
+                        ),
+                        "",
+                    ).label("sort_datasource"),
+                ).outerjoin(SqlaTable, Slice.datasource_id == SqlaTable.id)
             else:
                 aq = select(
                     literal(1).label("kind_order"),
@@ -311,31 +318,29 @@ class FolderDAO(BaseDAO[Folder]):
                 )
                 aq = aq.where(model.id.in_(tagged))
             if editors:
-                fk_col_filter = getattr(FolderObject, ASSET_TYPE_CONFIGS[name].fk_column)
-                editor_asset_ids = (
-                    select(fk_col_filter)
-                    .where(
-                        FolderObject.folder_id.in_(
-                            select(folder_editors.c.folder_id).where(
-                                folder_editors.c.user_id.in_(editors)
-                            )
-                        ),
-                        fk_col_filter.isnot(None),
-                    )
+                fk_col_filter = getattr(
+                    FolderObject, ASSET_TYPE_CONFIGS[name].fk_column
+                )
+                editor_asset_ids = select(fk_col_filter).where(
+                    FolderObject.folder_id.in_(
+                        select(folder_editors.c.folder_id).where(
+                            folder_editors.c.user_id.in_(editors)
+                        )
+                    ),
+                    fk_col_filter.isnot(None),
                 )
                 aq = aq.where(model.id.in_(editor_asset_ids))
             if viewers:
-                fk_col_filter = getattr(FolderObject, ASSET_TYPE_CONFIGS[name].fk_column)
-                viewer_asset_ids = (
-                    select(fk_col_filter)
-                    .where(
-                        FolderObject.folder_id.in_(
-                            select(folder_viewers.c.folder_id).where(
-                                folder_viewers.c.user_id.in_(viewers)
-                            )
-                        ),
-                        fk_col_filter.isnot(None),
-                    )
+                fk_col_filter = getattr(
+                    FolderObject, ASSET_TYPE_CONFIGS[name].fk_column
+                )
+                viewer_asset_ids = select(fk_col_filter).where(
+                    FolderObject.folder_id.in_(
+                        select(folder_viewers.c.folder_id).where(
+                            folder_viewers.c.user_id.in_(viewers)
+                        )
+                    ),
+                    fk_col_filter.isnot(None),
                 )
                 aq = aq.where(model.id.in_(viewer_asset_ids))
 
@@ -448,7 +453,9 @@ class FolderDAO(BaseDAO[Folder]):
                 .correlate(unioned)
                 .scalar_subquery()
             )
-            pin_order = func.coalesce(pin_position, literal(4) + literal(0)).label("pin_order")
+            pin_order = func.coalesce(pin_position, literal(4) + literal(0)).label(
+                "pin_order"
+            )
 
         page_rows = db.session.execute(
             select(unioned.c.item_type, unioned.c.item_id)
@@ -878,6 +885,59 @@ class FolderDAO(BaseDAO[Folder]):
         )
         cls.add_subject(folder_id, user_id, permission)
 
+    @classmethod
+    def folders_with_accessible_assets(cls, user_id: int, folder_type: str) -> set[int]:
+        """Return folder IDs containing at least one asset the user owns.
+
+        Only checks ownership — not datasource access. This is intentional:
+        the folder gate in raise_for_access blocks non-members from accessing
+        foldered assets even if they have datasource access. So implicit
+        folder visibility should only show folders with assets the user owns,
+        since those are the only assets the gate allows through.
+        """
+        folder_ids: set[int] = set()
+
+        for name in asset_types_for_folder_type(folder_type):
+            config = ASSET_TYPE_CONFIGS[name]
+            model = config.model
+            fk_col = getattr(FolderObject, config.fk_column)
+
+            owned_assets = (
+                select(model.id)
+                .where(model.owners.any(cls._user_model().id == user_id))
+                .subquery()
+            )
+            rows = (
+                db.session.query(FolderObject.folder_id)
+                .join(Folder, Folder.id == FolderObject.folder_id)
+                .filter(
+                    fk_col.isnot(None),
+                    fk_col.in_(select(owned_assets.c.id)),
+                    Folder.folder_type == folder_type,
+                )
+                .distinct()
+                .all()
+            )
+            folder_ids.update(r[0] for r in rows)
+
+        # Include ancestor folders so the tree stays navigable.
+        if folder_ids:
+            parent_map = {
+                f.id: f.parent_id
+                for f in db.session.query(Folder.id, Folder.parent_id)
+                .filter(Folder.folder_type == folder_type)
+                .all()
+            }
+            ancestors: set[int] = set()
+            for fid in folder_ids:
+                parent_id = parent_map.get(fid)
+                while parent_id is not None and parent_id not in ancestors:
+                    ancestors.add(parent_id)
+                    parent_id = parent_map.get(parent_id)
+            folder_ids |= ancestors
+
+        return folder_ids
+
     @classmethod
     def remove_subject(cls, folder_id: int, user_id: int) -> None:
         db.session.execute(
diff --git a/superset/folders/api.py b/superset/folders/api.py
index 1f0662e3874..b310ac4266f 100644
--- a/superset/folders/api.py
+++ b/superset/folders/api.py
@@ -30,7 +30,7 @@ from flask_appbuilder.api import (
     rison as parse_rison,
     safe,
 )
-from flask_appbuilder.security.sqla.models import Role, User
+from flask_appbuilder.security.sqla.models import User
 from marshmallow import ValidationError
 
 from superset import security_manager
@@ -53,7 +53,7 @@ from superset.daos.folder import FolderDAO, ResolvedAsset
 from superset.daos.folder_permissions import FolderPermissionDAO
 from superset.extensions import db, event_logger
 from superset.folders.constants import ASSET_TYPE_CONFIGS, DEFAULT_FOLDER_TYPE
-from superset.folders.models import Folder
+from superset.folders.models import Folder, folder_editors, folder_viewers
 from superset.folders.schemas import (
     FolderAssetSchema,
     FolderAssetsPutSchema,
@@ -70,8 +70,8 @@ from superset.folders.schemas import (
     FolderSubjectPutSchema,
 )
 from superset.folders.utils import can_manage_folders
-from superset.utils.core import get_user_id
 from superset.utils import json as json_utils
+from superset.utils.core import get_user_id
 from superset.utils.decorators import transaction
 from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
 
@@ -88,7 +88,9 @@ def _serialize_user(user: Any) -> dict[str, Any] | None:
     }
 
 
-def _get_user_permission(folder: Folder) -> str | None:
+def _get_user_permission(
+    folder: Folder, implicit_folder_ids: set[int] | None = None
+) -> str | None:
     """Return the current user's permission level for this folder."""
     if security_manager.is_admin():
         return "editor"
@@ -99,6 +101,8 @@ def _get_user_permission(folder: Folder) -> str | None:
         return "editor"
     if FolderPermissionDAO.user_has_folder_access(user_id, folder.id):
         return "viewer"
+    if implicit_folder_ids and folder.id in implicit_folder_ids:
+        return "implicit"
     return None
 
 
@@ -114,6 +118,7 @@ def serialize_folder(
     folder: Folder,
     children_count: int | None = None,
     asset_count: int | None = None,
+    implicit_folder_ids: set[int] | None = None,
 ) -> dict[str, Any]:
     """Serialize a folder's metadata for API responses.
 
@@ -141,11 +146,9 @@ def serialize_folder(
         "changed_on_humanized": folder.changed_on_humanized,
         "created_by": _serialize_user(folder.created_by),
         "changed_by": _serialize_user(folder.changed_by),
-        "user_permission": _get_user_permission(folder),
+        "user_permission": _get_user_permission(folder, implicit_folder_ids),
         "inherits_permissions": _get_inherits_permissions(folder),
-        "owners": [
-            _serialize_user(u) for u in (folder.editors or [])
-        ],
+        "owners": [_serialize_user(u) for u in (folder.editors or [])],
     }
 
 
@@ -153,9 +156,14 @@ def serialize_row(
     kind: str,
     obj: Any,
     folder_path: list[dict[str, str]] | None = None,
+    implicit_folder_ids: set[int] | None = None,
 ) -> dict[str, Any]:
     """Serialize a contents row (folder or asset) into a uniform-ish item."""
-    data = serialize_folder(obj) if kind == "folder" else serialize_asset(kind, obj)
+    data = (
+        serialize_folder(obj, implicit_folder_ids=implicit_folder_ids)
+        if kind == "folder"
+        else serialize_asset(kind, obj)
+    )
     if folder_path is not None:
         data["folder_path"] = folder_path
     return data
@@ -198,10 +206,7 @@ def serialize_rows_with_paths(
 def serialize_asset(asset_type: str, asset: Any) -> dict[str, Any]:
     """Serialize a single asset (dashboard/chart/dataset) with column data."""
     config = ASSET_TYPE_CONFIGS[asset_type]
-    owners = [
-        _serialize_user(o)
-        for o in (getattr(asset, "owners", None) or [])
-    ]
+    owners = [_serialize_user(o) for o in (getattr(asset, "owners", None) or [])]
     return {
         "type": asset_type,
         "id": asset.id,
@@ -216,18 +221,22 @@ def serialize_asset(asset_type: str, asset: Any) -> dict[str, Any]:
         "viz_type": getattr(asset, "viz_type", None),
         "datasource_name": (
             asset.datasource_name_text()
-            if asset_type == "chart" and callable(
-                getattr(asset, "datasource_name_text", None)
-            )
+            if asset_type == "chart"
+            and callable(getattr(asset, "datasource_name_text", None))
             else None
         ),
         "datasource_url": (
             asset.datasource_url()
-            if asset_type == "chart" and callable(getattr(asset, "datasource_url", None))
+            if asset_type == "chart"
+            and callable(getattr(asset, "datasource_url", None))
             else None
         ),
         "tags": [
-            {"id": t.id, "name": t.name, "type": t.type.value if hasattr(t.type, "value") else t.type}
+            {
+                "id": t.id,
+                "name": t.name,
+                "type": t.type.value if hasattr(t.type, "value") else t.type,
+            }
             for t in getattr(asset, "tags", []) or []
         ],
     }
@@ -323,9 +332,7 @@ def _parse_rison_args(rison_args: dict[str, Any]) -> dict[str, Any]:
                 int(v) for v in (val if isinstance(val, list) else [val])
             ]
         elif col == "tags" and opr == "rel_m_m":
-            result["tags"] = [
-                int(v) for v in (val if isinstance(val, list) else [val])
-            ]
+            result["tags"] = [int(v) for v in (val if isinstance(val, list) else [val])]
     return result
 
 
@@ -355,14 +362,22 @@ class FolderRestApi(BaseSupersetApi):
 
     @staticmethod
     def _raise_for_folder_access(folder: Folder) -> None:
-        """Raise FolderForbiddenError if user cannot view the folder."""
+        """Raise FolderForbiddenError if user cannot view the folder.
 
+        Allows explicit members (editors/viewers) and implicit access
+        (users who own at least one asset inside the folder).
+        """
         if security_manager.is_admin():
             return
         user_id = get_user_id()
-        if not user_id or not FolderPermissionDAO.user_has_folder_access(
-            user_id, folder.id
-        ):
+        if not user_id:
+            raise FolderForbiddenError()
+        if FolderPermissionDAO.user_has_folder_access(user_id, folder.id):
+            return
+        implicit_ids = FolderDAO.folders_with_accessible_assets(
+            user_id, folder.folder_type
+        )
+        if folder.id not in implicit_ids:
             raise FolderForbiddenError()
 
     @staticmethod
@@ -412,15 +427,48 @@ class FolderRestApi(BaseSupersetApi):
         folder_type = request.args.get("folder_type")
         folders = FolderDAO.get_folders(folder_type=folder_type)
 
-        # Non-admins only see folders they have access to
+        # Non-admins see explicit member folders + implicit access folders
+        implicit_folder_ids: set[int] = set()
         if not security_manager.is_admin():
             user_id = get_user_id()
             if user_id:
-                folders = [
-                    f
-                    for f in folders
-                    if FolderPermissionDAO.user_has_folder_access(user_id, f.id)
-                ]
+                # Batch query instead of N+1 per-folder checks
+                all_folder_ids = [f.id for f in folders]
+                editor_ids = (
+                    {
+                        r[0]
+                        for r in db.session.query(folder_editors.c.folder_id)
+                        .filter(
+                            folder_editors.c.user_id == user_id,
+                            folder_editors.c.folder_id.in_(all_folder_ids),
+                        )
+                        .all()
+                    }
+                    if all_folder_ids
+                    else set()
+                )
+                viewer_ids = (
+                    {
+                        r[0]
+                        for r in db.session.query(folder_viewers.c.folder_id)
+                        .filter(
+                            folder_viewers.c.user_id == user_id,
+                            folder_viewers.c.folder_id.in_(all_folder_ids),
+                        )
+                        .all()
+                    }
+                    if all_folder_ids
+                    else set()
+                )
+                member_ids = editor_ids | viewer_ids
+                implicit_folder_ids = (
+                    FolderDAO.folders_with_accessible_assets(
+                        user_id, folder_type or "analytics"
+                    )
+                    - member_ids
+                )
+                visible_ids = member_ids | implicit_folder_ids
+                folders = [f for f in folders if f.id in visible_ids]
             else:
                 folders = []
 
@@ -453,6 +501,7 @@ class FolderRestApi(BaseSupersetApi):
                 folder,
                 children_count=children_counts.get(folder.id, 0),
                 asset_count=asset_counts.get(folder.id, 0),
+                implicit_folder_ids=implicit_folder_ids,
             )
             for folder in folders
         ]
@@ -502,10 +551,21 @@ class FolderRestApi(BaseSupersetApi):
         folder_type = request.args.get("folder_type", DEFAULT_FOLDER_TYPE)
         parsed = _parse_rison_args(kwargs.get("rison", {}))
         rows, count = FolderDAO.get_contents(None, folder_type, **parsed)
+        # Compute implicit folder IDs for user_permission serialization
+        implicit_ids: set[int] = set()
+        if not security_manager.is_admin():
+            user_id = get_user_id()
+            if user_id:
+                implicit_ids = FolderDAO.folders_with_accessible_assets(
+                    user_id, folder_type
+                )
         result = (
             serialize_rows_with_paths(rows, None)
             if parsed.get("search")
-            else [serialize_row(kind, obj) for kind, obj in rows]
+            else [
+                serialize_row(kind, obj, implicit_folder_ids=implicit_ids)
+                for kind, obj in rows
+            ]
         )
         return self.response(
             200,
@@ -607,16 +667,32 @@ class FolderRestApi(BaseSupersetApi):
             self._raise_for_folder_access(folder)
         except FolderForbiddenError:
             return self.response_403()
+        implicit_ids: set[int] = set()
+        user_id = get_user_id()
+        if user_id and not security_manager.is_admin():
+            implicit_ids = FolderDAO.folders_with_accessible_assets(
+                user_id, folder.folder_type
+            )
         parsed = _parse_rison_args(kwargs.get("rison", {}))
         rows, count = FolderDAO.get_contents(folder, folder.folder_type, **parsed)
         result = (
             serialize_rows_with_paths(rows, folder.id)
             if parsed.get("search")
-            else [serialize_row(kind, obj) for kind, obj in rows]
+            else [
+                serialize_row(kind, obj, implicit_folder_ids=implicit_ids)
+                for kind, obj in rows
+            ]
         )
+        folder_implicit = set()
+        if (
+            user_id
+            and not security_manager.is_admin()
+            and not FolderPermissionDAO.user_has_folder_access(user_id, folder.id)
+        ):
+            folder_implicit = {folder.id}
         return self.response(
             200,
-            folder=serialize_folder(folder),
+            folder=serialize_folder(folder, implicit_folder_ids=folder_implicit),
             result=result,
             count=count,
             page=parsed["page"],
@@ -1205,7 +1281,9 @@ class FolderRestApi(BaseSupersetApi):
         except ValidationError as ex:
             return self.response(400, message=ex.messages)
         try:
-            permission = "editor" if data["permission"] == "admin" else data["permission"]
+            permission = (
+                "editor" if data["permission"] == "admin" else data["permission"]
+            )
             FolderDAO.add_subject(folder.id, data["user_id"], permission)
         except ValueError as ex:
             return self.response(422, message=str(ex))
diff --git a/superset/folders/hooks.py b/superset/folders/hooks.py
index 3cc132e18e1..e71a34c8b59 100644
--- a/superset/folders/hooks.py
+++ b/superset/folders/hooks.py
@@ -43,47 +43,76 @@ def folder_access_dashboards(user_id: int) -> Any:
     )
 
 
+def _safe_int(value: Any) -> int | None:
+    """Cast to int, returning None on failure."""
+    try:
+        return int(value) if value else None
+    except (TypeError, ValueError):
+        return None
+
+
 def folder_raise_for_access_bypass(**kwargs: Any) -> bool:
-    """Bypass raise_for_access if user has folder access to the asset."""
-    from flask import current_app
+    """Bypass raise_for_access if user has folder access to the asset.
 
+    Collects chart and dashboard IDs from all available sources (kwargs,
+    query_context form_data, request URL) and checks whether any of them
+    are in a folder the user has access to.
+
+    Global datasource bypass is intentionally NOT granted — folder
+    membership should not leak into dataset-level access.
+    """
     from superset.daos.folder_permissions import FolderPermissionDAO
 
     user_id = get_user_id()
     if not user_id:
         return False
 
+    # Collect IDs from all sources
     dashboard = kwargs.get("dashboard")
     chart = kwargs.get("chart")
     query_context = kwargs.get("query_context")
-    viz = kwargs.get("viz")
-    datasource = kwargs.get("datasource")
-
-    if not datasource and query_context:
-        datasource = query_context.datasource
-    if not datasource and viz:
-        datasource = viz.datasource
-
-    if FolderPermissionDAO.user_has_folder_access_for_asset(
-        user_id=user_id,
-        dashboard_id=dashboard.id if dashboard else None,
-        chart_id=chart.id if chart else None,
-        datasource_id=datasource.id if datasource else None,
-    ):
-        return True
-
-    # Transitive access: datasource → chart → dashboard → folder.
-    # Grants access to datasources used by charts on dashboards in the
-    # user's folders, even if the chart is not directly in any folder.
-    if (
-        datasource
-        and current_app.config.get("FOLDER_DASHBOARD_TRANSITIVE_ACCESS")
-        and FolderPermissionDAO.user_has_transitive_dashboard_access(
+    form_data = (
+        query_context.form_data
+        if query_context
+        and hasattr(query_context, "form_data")
+        and query_context.form_data
+        else {}
+    )
+
+    from flask import request as flask_request
+
+    chart_ids = {
+        _safe_int(v)
+        for v in [
+            chart.id if chart else None,
+            form_data.get("slice_id"),
+            flask_request.args.get("slice_id"),
+        ]
+    } - {None}
+
+    dashboard_ids = {
+        _safe_int(v)
+        for v in [
+            dashboard.id if dashboard else None,
+            form_data.get("dashboardId"),
+            flask_request.args.get("dashboard_id"),
+        ]
+    } - {None}
+
+    # Check folder access for any collected ID
+    for chart_id in chart_ids:
+        if FolderPermissionDAO.user_has_folder_access_for_asset(
             user_id=user_id,
-            datasource_id=datasource.id,
-        )
-    ):
-        return True
+            chart_id=chart_id,
+        ):
+            return True
+
+    for dashboard_id in dashboard_ids:
+        if FolderPermissionDAO.user_has_folder_access_for_asset(
+            user_id=user_id,
+            dashboard_id=dashboard_id,
+        ):
+            return True
 
     return False
 
diff --git a/superset/security/manager.py b/superset/security/manager.py
index d065668fb97..fc1ac71980b 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -3874,6 +3874,35 @@ class SupersetSecurityManager(  # pylint: disable=too-many-public-methods
 
             raise SupersetSecurityException(self.get_chart_access_error_object(chart))
 
+        # Folder gate: if an asset is in a folder, non-members are blocked
+        # even if Superset's standard checks granted access above.
+        if is_feature_enabled("FOLDERS") and (dashboard or chart):
+            if not self.is_admin():
+                user_id = get_user_id()
+                asset = dashboard or chart
+                if user_id and not self.is_owner(asset):
+                    from superset.folders.models import FolderObject
+
+                    fo_filter = (
+                        FolderObject.dashboard_id == dashboard.id
+                        if dashboard
+                        else FolderObject.chart_id == chart.id
+                    )
+                    fo = self.session.query(FolderObject).filter(fo_filter).first()
+                    if fo:
+                        from superset.daos.folder_permissions import FolderPermissionDAO
+
+                        if not FolderPermissionDAO.user_has_folder_access(
+                            user_id, fo.folder_id
+                        ):
+                            raise SupersetSecurityException(
+                                SupersetError(
+                                    error_type=SupersetErrorType.DASHBOARD_SECURITY_ACCESS_ERROR,
+                                    message="Access denied — this asset is in a folder you don't have access to.",
+                                    level=ErrorLevel.WARNING,
+                                )
+                            )
+
     def get_user_by_username(self, username: str) -> Optional[User]:
         """
         Retrieves a user by it's username case sensitive. Optional session parameter