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/09/21 17:17:43 UTC

(superset) branch alexandrusoare/feat/folders-testing-phase2-v2 updated: trying to sync the latest changes

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

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


The following commit(s) were added to refs/heads/alexandrusoare/feat/folders-testing-phase2-v2 by this push:
     new be87d968cd6 trying to sync the latest changes
be87d968cd6 is described below

commit be87d968cd636a93d4c4a4e9cc8ec2b7fccedd95
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Mon Sep 21 20:17:27 2026 +0300

    trying to sync the latest changes
---
 .../src/pages/AnalyticsList/CreateFolderModal.tsx  |  26 ++--
 .../pages/AnalyticsList/FolderPermissionsModal.tsx |   2 +
 .../src/pages/AnalyticsList/TransferModal.tsx      |  33 +++-
 .../src/pages/AnalyticsList/index.tsx              | 173 ++++++++++++---------
 superset/commands/folder/assets.py                 |  24 ++-
 superset/commands/folder/create.py                 |  31 ++--
 superset/commands/folder/delete.py                 |  29 +++-
 superset/commands/folder/exceptions.py             |   9 ++
 superset/commands/folder/{delete.py => restore.py} |  36 ++---
 superset/commands/folder/update.py                 |  88 +++++++----
 superset/daos/folder.py                            | 141 ++++++++++++++++-
 superset/folders/api.py                            | 123 ++++++++++++++-
 superset/folders/hooks.py                          |  55 +++++--
 superset/initialization/__init__.py                |  76 +++++----
 superset/security/manager.py                       |  82 +++++++---
 15 files changed, 679 insertions(+), 249 deletions(-)

diff --git a/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx b/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
index 90725fe4046..12cba446519 100644
--- a/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/CreateFolderModal.tsx
@@ -17,7 +17,12 @@
  * under the License.
  */
 import { useCallback, useEffect, useState } from 'react';
-import { SupersetClient, getClientErrorObject } from '@superset-ui/core';
+import {
+  SupersetClient,
+  getClientErrorObject,
+  isFeatureEnabled,
+  FeatureFlag,
+} from '@superset-ui/core';
 import { t } from '@apache-superset/core/translation';
 import { Checkbox, Input } from '@superset-ui/core/components';
 import { StandardModal } from 'src/components/Modal';
@@ -54,6 +59,7 @@ export default function CreateFolderModal({
       setName('');
       setDescription('');
       setNameError('');
+      setIsPrivate(false);
     }
   }, [show]);
 
@@ -88,6 +94,7 @@ export default function CreateFolderModal({
   }, [
     name,
     description,
+    isPrivate,
     parentFolderUuid,
     addSuccessToast,
     addDangerToast,
@@ -127,14 +134,15 @@ export default function CreateFolderModal({
             rows={3}
           />
         </ModalFormField>
-        {!parentFolderUuid && (
-          <Checkbox
-            checked={isPrivate}
-            onChange={e => setIsPrivate(e.target.checked)}
-          >
-            {t('Private folder')}
-          </Checkbox>
-        )}
+        {!parentFolderUuid &&
+          isFeatureEnabled('FOLDER_PERMISSIONS' as FeatureFlag) && (
+            <Checkbox
+              checked={isPrivate}
+              onChange={e => setIsPrivate(e.target.checked)}
+            >
+              {t('Private folder')}
+            </Checkbox>
+          )}
       </ModalContent>
     </StandardModal>
   );
diff --git a/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx b/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
index 1779ba91cdb..d279d2f27f9 100644
--- a/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
@@ -134,6 +134,8 @@ export default function FolderPermissionsModal({
         endpoint: `/api/v1/folders/${folderUuid}/subjects`,
       });
       const subjects = (json.result as Subject[]) || [];
+      // Filter out admins — they have implicit full access and are not
+      // governed by the folder's permission list.
       const nonAdminSubjects = subjects.filter(s => !s.is_admin);
       const enriched: LocalSubject[] = nonAdminSubjects.map(s => ({
         key: String(s.subject_id),
diff --git a/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx b/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
index ed5601520f1..7de9464c15c 100644
--- a/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/TransferModal.tsx
@@ -25,7 +25,12 @@ import {
   useRef,
   useState,
 } from 'react';
-import { SupersetClient, getNumberFormatter } from '@superset-ui/core';
+import {
+  SupersetClient,
+  getNumberFormatter,
+  isFeatureEnabled,
+  FeatureFlag,
+} from '@superset-ui/core';
 import { styled, css, useTheme } from '@apache-superset/core/theme';
 import { t } from '@apache-superset/core/translation';
 import rison from 'rison';
@@ -296,9 +301,7 @@ export default function TransferModal({
         setLeftPage(0);
         const folders = (
           (foldersRes.json.result as FolderOption[]) || []
-        ).filter(
-          f => f.uuid !== currentFolderUuid && f.user_permission !== 'implicit',
-        );
+        ).filter(f => f.uuid !== currentFolderUuid);
         setAllFolders(folders);
         if (preSelectedKeys?.length) {
           setLeftSelected(new Set(preSelectedKeys));
@@ -605,6 +608,12 @@ export default function TransferModal({
       return;
     }
 
+    // Skip all permission-related confirmations when permissions are OFF
+    if (!isFeatureEnabled('FOLDER_PERMISSIONS' as FeatureFlag)) {
+      handleDone();
+      return;
+    }
+
     // Check if crossing the Only Me boundary
     const targetFolder = allFolders.find(f => f.uuid === targetUuid);
     const targetIsPrivate = targetFolder?.is_private ?? false;
@@ -629,6 +638,18 @@ export default function TransferModal({
       return;
     }
 
+    // Moving INTO a private folder from a non-private source
+    if (!currentFolderIsPrivate && targetIsPrivate) {
+      showConfirm({
+        title: t('Move to private folder?'),
+        body: t('Only you will be able to see %s.', movedItemNames),
+        confirmText: t('Move'),
+        onConfirm: handleDone,
+        icon: null,
+      });
+      return;
+    }
+
     // Standard permission diff check for non-private folders
     try {
       const [sourcePerms, targetPerms] = await Promise.all([
@@ -761,7 +782,9 @@ export default function TransferModal({
       }
     }
 
-    const eligible = allFolders.filter(f => !excluded.has(f.uuid));
+    const eligible = allFolders.filter(
+      f => !excluded.has(f.uuid) && f.user_permission === 'editor',
+    );
 
     // Build tree from flat list
     type TreeNode = {
diff --git a/superset-frontend/src/pages/AnalyticsList/index.tsx b/superset-frontend/src/pages/AnalyticsList/index.tsx
index d330cdeaa3f..28a985f5f55 100644
--- a/superset-frontend/src/pages/AnalyticsList/index.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/index.tsx
@@ -109,6 +109,7 @@ interface Crumb {
   name: string;
   user_permission?: 'editor' | 'viewer' | 'implicit' | null;
   is_only_me?: boolean;
+  is_private?: boolean;
 }
 
 interface SortColumn {
@@ -287,7 +288,7 @@ function AnalyticsList({
         if (currentFolder) {
           await SupersetClient.delete({
             endpoint: `/api/v1/folders/${currentFolder}/assets?q=${encodeURIComponent(
-              JSON.stringify([{ type: move.assetType, id: move.assetId }]),
+              rison.encode([{ type: move.assetType, id: move.assetId }]),
             )}`,
           });
         }
@@ -565,12 +566,14 @@ function AnalyticsList({
             parent_uuid: string | null;
             user_permission?: 'editor' | 'viewer' | 'implicit' | null;
             is_only_me?: boolean;
+            is_private?: boolean;
           };
           crumbs.unshift({
             uuid: folder.uuid,
             name: folder.name,
             user_permission: folder.user_permission,
             is_only_me: folder.is_only_me,
+            is_private: folder.is_private,
           });
           uuid = folder.parent_uuid;
         }
@@ -810,7 +813,9 @@ function AnalyticsList({
             return null;
           }
           const pinIcon =
-            isAtRoot && (isPinned(original) || original.is_only_me) ? (
+            isAtRoot &&
+            (isPinned(original) ||
+              (folderPermsEnabled && original.is_only_me)) ? (
               <PushpinOutlined
                 style={{ fontSize: 12, color: theme.colorPrimary }}
               />
@@ -886,6 +891,9 @@ function AnalyticsList({
                       />
                     )}
                     {highlightName(original.name)}
+                    {original.description && (
+                      <InfoTooltip tooltip={original.description} />
+                    )}
                     {original.is_private && (
                       <span
                         css={{
@@ -1058,24 +1066,28 @@ function AnalyticsList({
           );
         },
       },
-      {
-        accessor: 'editors',
-        Header: t('Editors'),
-        id: 'editors',
-        disableSortBy: true,
-        Cell: ({ row: { original } }: CellProps<ContentItem>) => (
-          <SubjectPile subjects={original.editors || []} />
-        ),
-      },
-      {
-        accessor: 'viewers',
-        Header: t('Viewers'),
-        id: 'viewers',
-        disableSortBy: true,
-        Cell: ({ row: { original } }: CellProps<ContentItem>) => (
-          <SubjectPile subjects={original.viewers || []} />
-        ),
-      },
+      ...(folderPermsEnabled
+        ? [
+            {
+              accessor: 'editors',
+              Header: t('Editors'),
+              id: 'editors',
+              disableSortBy: true,
+              Cell: ({ row: { original } }: CellProps<ContentItem>) => (
+                <SubjectPile subjects={original.editors || []} />
+              ),
+            },
+            {
+              accessor: 'viewers',
+              Header: t('Viewers'),
+              id: 'viewers',
+              disableSortBy: true,
+              Cell: ({ row: { original } }: CellProps<ContentItem>) => (
+                <SubjectPile subjects={original.viewers || []} />
+              ),
+            },
+          ]
+        : []),
       {
         accessor: 'changed_on',
         Header: t('Last modified'),
@@ -1170,8 +1182,9 @@ function AnalyticsList({
             );
           }
           // chart / dashboard rows: mirror the CRUD lists' row actions.
+          // Subject.id is the subject row, not the user, so match on user_id.
           const isAssetEditor = original.editors?.some(
-            o => o.user_id === currentUserId,
+            e => e.user_id === currentUserId,
           );
           const canEditAsset = canEditCurrentFolder || isAssetEditor;
           return (
@@ -1273,42 +1286,46 @@ function AnalyticsList({
           { label: t('Dashboard'), value: 'dashboard' },
         ],
       },
-      {
-        Header: t('Editor'),
-        key: 'editors',
-        id: 'editors',
-        input: 'select',
-        operator: FilterOperator.RelationManyMany,
-        unfilteredLabel: t('All'),
-        fetchSelects: createFetchRelated(
-          'chart',
-          'editors',
-          createErrorHandler(errMsg =>
-            addDangerToast(
-              t('An error occurred while fetching editors: %s', errMsg),
-            ),
-          ),
-        ),
-        paginate: true,
-      },
-      {
-        Header: t('Viewer'),
-        key: 'viewers',
-        id: 'viewers',
-        input: 'select',
-        operator: FilterOperator.RelationManyMany,
-        unfilteredLabel: t('All'),
-        fetchSelects: createFetchRelated(
-          'chart',
-          'viewers',
-          createErrorHandler(errMsg =>
-            addDangerToast(
-              t('An error occurred while fetching viewers: %s', errMsg),
-            ),
-          ),
-        ),
-        paginate: true,
-      },
+      ...(folderPermsEnabled
+        ? [
+            {
+              Header: t('Editor'),
+              key: 'editors',
+              id: 'editors',
+              input: 'select' as const,
+              operator: FilterOperator.RelationManyMany,
+              unfilteredLabel: t('All'),
+              fetchSelects: createFetchRelated(
+                'chart',
+                'editors',
+                createErrorHandler(errMsg =>
+                  addDangerToast(
+                    t('An error occurred while fetching editors: %s', errMsg),
+                  ),
+                ),
+              ),
+              paginate: true,
+            },
+            {
+              Header: t('Viewer'),
+              key: 'viewers',
+              id: 'viewers',
+              input: 'select' as const,
+              operator: FilterOperator.RelationManyMany,
+              unfilteredLabel: t('All'),
+              fetchSelects: createFetchRelated(
+                'chart',
+                'viewers',
+                createErrorHandler(errMsg =>
+                  addDangerToast(
+                    t('An error occurred while fetching viewers: %s', errMsg),
+                  ),
+                ),
+              ),
+              paginate: true,
+            },
+          ]
+        : []),
       {
         Header: t('Last modified'),
         key: 'changed_on',
@@ -1532,7 +1549,7 @@ function AnalyticsList({
           addSuccessToast={addSuccessToast}
         />
       )}
-      {folderToManagePerms && (
+      {folderPermsEnabled && folderToManagePerms && (
         <FolderPermissionsModal
           folderUuid={folderToManagePerms.uuid ?? ''}
           folderName={folderToManagePerms.name}
@@ -1551,7 +1568,9 @@ function AnalyticsList({
         <TransferModal
           currentFolderUuid={currentFolder.uuid}
           currentFolderName={currentFolder.name}
-          currentFolderIsPrivate={breadcrumb.some(c => c.is_only_me)}
+          currentFolderIsPrivate={breadcrumb.some(
+            c => c.is_only_me || c.is_private,
+          )}
           preSelectedKeys={
             moveTarget ? [`${moveTarget.type}-${moveTarget.id}`] : []
           }
@@ -1726,25 +1745,27 @@ function AnalyticsList({
                     <BreadcrumbWrap>
                       <FolderBreadcrumb items={breadcrumbItems} />
                     </BreadcrumbWrap>
-                    {currentFolder.is_only_me && (
-                      <Alert
-                        type="success"
-                        showIcon
-                        message={
-                          <>
-                            <span style={{ fontWeight: 800 }}>
-                              {t("Only you can see what's in this folder.")}
-                            </span>{' '}
-                            {t(
-                              'New charts and dashboards land here by default. Move them out to share. Permissions then inherit from the destination folder.',
-                            )}
-                          </>
-                        }
-                        css={{
+                    {folderPermsEnabled && currentFolder.is_only_me && (
+                      <div
+                        style={{
                           marginBottom: theme.sizeUnit * 2,
-                          padding: `${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px`,
                         }}
-                      />
+                      >
+                        <Alert
+                          type="success"
+                          showIcon
+                          message={
+                            <>
+                              <span style={{ fontWeight: 800 }}>
+                                {t("Only you can see what's in this folder.")}
+                              </span>{' '}
+                              {t(
+                                'New charts and dashboards land here by default. Move them out to share. Permissions then inherit from the destination folder.',
+                              )}
+                            </>
+                          }
+                        />
+                      </div>
                     )}
                   </>
                 )
diff --git a/superset/commands/folder/assets.py b/superset/commands/folder/assets.py
index 1eb1236c174..48c9bfc71ff 100644
--- a/superset/commands/folder/assets.py
+++ b/superset/commands/folder/assets.py
@@ -47,12 +47,24 @@ def _validate_folder_assets(
     if not model:
         raise FolderNotFoundError()
 
-    if not security_manager.is_admin():
-        user_id = get_user_id()
-        if not user_id or not FolderPermissionDAO.user_is_folder_editor(
-            user_id, model.id
-        ):
-            raise FolderForbiddenError()
+    if security_manager.is_admin():
+        pass
+    else:
+        from superset.folders.utils import folder_permissions_enabled
+
+        if folder_permissions_enabled():
+            user_id = get_user_id()
+            if not user_id or not FolderPermissionDAO.user_is_folder_editor(
+                user_id, model.id
+            ):
+                raise FolderForbiddenError()
+        else:
+            from flask import g
+
+            from superset.folders.utils import can_manage_folders
+
+            if not can_manage_folders(g.user):
+                raise FolderForbiddenError()
 
     exceptions: list[ValidationError] = []
     allowed = set(asset_types_for_folder_type(model.folder_type))
diff --git a/superset/commands/folder/create.py b/superset/commands/folder/create.py
index 3ebfc0a8308..739971ba9c9 100644
--- a/superset/commands/folder/create.py
+++ b/superset/commands/folder/create.py
@@ -55,7 +55,8 @@ class CreateFolderCommand(BaseCommand):
                 "description": self._properties.get("description"),
                 "parent_id": self._parent.id if self._parent else None,
                 "folder_type": self._properties["folder_type"],
-                "is_private": self._properties.get("is_private", False),
+                "is_private": self._properties.get("is_private", False)
+                or (self._parent.is_private if self._parent else False),
             }
         )
         db.session.flush()
@@ -72,10 +73,10 @@ class CreateFolderCommand(BaseCommand):
                 user_subject = get_or_create_user_subject(g.user.id)
                 if user_subject:
                     FolderDAO.add_subject(folder.id, user_subject.id, "editor")
-        if self._parent:
-            FolderPermissionDAO.copy_permissions_to_subfolder(
-                self._parent.id, folder.id
-            )
+            if self._parent:
+                FolderPermissionDAO.copy_permissions_to_subfolder(
+                    self._parent.id, folder.id
+                )
         return folder
 
     def validate(self) -> None:
@@ -93,13 +94,21 @@ class CreateFolderCommand(BaseCommand):
             elif self._parent.folder_type != folder_type:
                 exceptions.append(FolderParentTypeMismatchValidationError())
             elif not security_manager.is_admin():
-                from superset.utils.core import get_user_id
+                from superset.folders.utils import folder_permissions_enabled
 
-                user_id = get_user_id()
-                if not FolderPermissionDAO.user_is_folder_editor(
-                    user_id, self._parent.id
-                ):
-                    raise FolderForbiddenError()
+                if folder_permissions_enabled():
+                    from superset.utils.core import get_user_id
+
+                    user_id = get_user_id()
+                    if not FolderPermissionDAO.user_is_folder_editor(
+                        user_id, self._parent.id
+                    ):
+                        raise FolderForbiddenError()
+                else:
+                    from superset.folders.utils import can_manage_folders
+
+                    if not can_manage_folders(g.user):
+                        raise FolderForbiddenError()
 
         parent_id = self._parent.id if self._parent else None
         if not FolderDAO.validate_name_uniqueness(name, parent_id, folder_type):
diff --git a/superset/commands/folder/delete.py b/superset/commands/folder/delete.py
index 70e32e52fad..923a8310ad0 100644
--- a/superset/commands/folder/delete.py
+++ b/superset/commands/folder/delete.py
@@ -52,14 +52,27 @@ class DeleteFolderCommand(BaseCommand):
         if not self._model:
             raise FolderNotFoundError()
 
+        from superset.folders.utils import folder_permissions_enabled
+
+        _perms_on = folder_permissions_enabled()
+
         # "Only Me" folder cannot be deleted
-        extra = json_utils.loads(self._model.extra) if self._model.extra else {}
-        if extra.get("only_me"):
+        if _perms_on and self._model.is_only_me:
             raise FolderForbiddenError()
 
-        if not security_manager.is_admin():
-            user_id = get_user_id()
-            if not user_id or not FolderPermissionDAO.user_is_folder_editor(
-                user_id, self._model.id
-            ):
-                raise FolderForbiddenError()
+        if security_manager.is_admin() and not self._model.is_private:
+            return
+        if not security_manager.is_admin() or self._model.is_private:
+            if _perms_on:
+                user_id = get_user_id()
+                if not user_id or not FolderPermissionDAO.user_is_folder_editor(
+                    user_id, self._model.id
+                ):
+                    raise FolderForbiddenError()
+            else:
+                from flask import g
+
+                from superset.folders.utils import can_manage_folders
+
+                if not can_manage_folders(g.user):
+                    raise FolderForbiddenError()
diff --git a/superset/commands/folder/exceptions.py b/superset/commands/folder/exceptions.py
index a2f3593370e..a3f5e01d1e4 100644
--- a/superset/commands/folder/exceptions.py
+++ b/superset/commands/folder/exceptions.py
@@ -51,6 +51,15 @@ class FolderDeleteFailedError(DeleteFailedError):
     message = _("Folder could not be deleted.")
 
 
+class FolderRestoreFailedError(CommandException):
+    message = _("Folder could not be restored.")
+
+
+class FolderNotDeletedError(CommandException):
+    status = 409
+    message = _("Folder is not deleted.")
+
+
 class FolderNameUniquenessValidationError(ValidationError):
     """A sibling folder of the same type already uses this name."""
 
diff --git a/superset/commands/folder/delete.py b/superset/commands/folder/restore.py
similarity index 61%
copy from superset/commands/folder/delete.py
copy to superset/commands/folder/restore.py
index 70e32e52fad..1e9eee6551d 100644
--- a/superset/commands/folder/delete.py
+++ b/superset/commands/folder/restore.py
@@ -20,46 +20,42 @@ from functools import partial
 
 from superset import security_manager
 from superset.commands.base import BaseCommand
-from superset.utils.core import get_user_id
+from superset.extensions import db
+from superset.models.helpers import skip_visibility_filter
 from superset.utils.decorators import on_error, transaction
 
 from superset.commands.folder.exceptions import (
-    FolderDeleteFailedError,
     FolderForbiddenError,
+    FolderNotDeletedError,
     FolderNotFoundError,
+    FolderRestoreFailedError,
 )
 from superset.daos.folder import FolderDAO
 from superset.folders.models import Folder
-from superset.daos.folder_permissions import FolderPermissionDAO
 
 
-class DeleteFolderCommand(BaseCommand):
-    def __init__(self, folder_id_or_uuid: str, archive_items: bool = False):
+class RestoreFolderCommand(BaseCommand):
+    def __init__(self, folder_id_or_uuid: str):
         self._id = folder_id_or_uuid
-        self._archive_items = archive_items
         self._model: Folder | None = None
 
-    @transaction(on_error=partial(on_error, reraise=FolderDeleteFailedError))
-    def run(self) -> None:
+    @transaction(on_error=partial(on_error, reraise=FolderRestoreFailedError))
+    def run(self) -> Folder:
         self.validate()
         assert self._model
-        FolderDAO.delete_folder(self._model, self._archive_items)
+        with skip_visibility_filter(db.session, Folder):
+            FolderDAO.restore_folder(self._model)
+        return self._model
 
     def validate(self) -> None:
-        from superset.utils import json as json_utils
+        with skip_visibility_filter(db.session, Folder):
+            self._model = FolderDAO.find_by_id_or_uuid(self._id)
 
-        self._model = FolderDAO.find_by_id_or_uuid(self._id)
         if not self._model:
             raise FolderNotFoundError()
 
-        # "Only Me" folder cannot be deleted
-        extra = json_utils.loads(self._model.extra) if self._model.extra else {}
-        if extra.get("only_me"):
-            raise FolderForbiddenError()
+        if self._model.deleted_at is None:
+            raise FolderNotDeletedError()
 
         if not security_manager.is_admin():
-            user_id = get_user_id()
-            if not user_id or not FolderPermissionDAO.user_is_folder_editor(
-                user_id, self._model.id
-            ):
-                raise FolderForbiddenError()
+            raise FolderForbiddenError()
diff --git a/superset/commands/folder/update.py b/superset/commands/folder/update.py
index 19f67c39430..496230bd0f8 100644
--- a/superset/commands/folder/update.py
+++ b/superset/commands/folder/update.py
@@ -65,24 +65,27 @@ class UpdateFolderCommand(BaseCommand):
 
         folder = FolderDAO.update(self._model, attributes)
 
-        # When moving to a new parent: copy permissions and remove root pins
-        if self._parent_changed and self._new_parent_id is not None:
-            FolderPermissionDAO.copy_permissions_to_subfolder(
-                self._new_parent_id, folder.id
-            )
-            FolderPermissionDAO.push_down_permissions(folder.id)
-            from superset.folders.models import FolderPin
-
-            db.session.query(FolderPin).filter(
-                FolderPin.folder_id == folder.id,
-            ).delete()
-
-        # Sync permissions from parent when requested
-        if self._properties.get("sync_permissions") and folder.parent_id:
-            FolderPermissionDAO.copy_permissions_to_subfolder(
-                folder.parent_id, folder.id
-            )
-            FolderPermissionDAO.push_down_permissions(folder.id)
+        from superset import is_feature_enabled
+
+        if is_feature_enabled("FOLDER_PERMISSIONS"):
+            # When moving to a new parent: copy permissions and remove root pins
+            if self._parent_changed and self._new_parent_id is not None:
+                FolderPermissionDAO.copy_permissions_to_subfolder(
+                    self._new_parent_id, folder.id
+                )
+                FolderPermissionDAO.push_down_permissions(folder.id)
+                from superset.folders.models import FolderPin
+
+                db.session.query(FolderPin).filter(
+                    FolderPin.folder_id == folder.id,
+                ).delete()
+
+            # Sync permissions from parent when requested
+            if self._properties.get("sync_permissions") and folder.parent_id:
+                FolderPermissionDAO.copy_permissions_to_subfolder(
+                    folder.parent_id, folder.id
+                )
+                FolderPermissionDAO.push_down_permissions(folder.id)
 
         return folder
 
@@ -102,31 +105,52 @@ class UpdateFolderCommand(BaseCommand):
                 exceptions.append(FolderParentTypeMismatchValidationError())
             elif FolderDAO.is_descendant(parent, self._model):  # type: ignore[arg-type]
                 exceptions.append(FolderCycleValidationError())
-            elif not security_manager.is_admin():
-                user_id = get_user_id()
-                if not user_id or not FolderPermissionDAO.user_is_folder_editor(user_id, parent.id):
-                    raise FolderForbiddenError()
+            elif security_manager.is_admin() and not parent.is_private:
+                pass
+            else:
+                from superset.folders.utils import folder_permissions_enabled
+
+                if folder_permissions_enabled():
+                    user_id = get_user_id()
+                    if not user_id or not FolderPermissionDAO.user_is_folder_editor(user_id, parent.id):
+                        raise FolderForbiddenError()
+                else:
+                    from flask import g
+
+                    from superset.folders.utils import can_manage_folders
+
+                    if not can_manage_folders(g.user):
+                        raise FolderForbiddenError()
         new_parent_id = parent.id if parent else None
         self._new_parent_id = new_parent_id
         return new_parent_id
 
     def validate(self) -> None:
-        from superset.utils import json as json_utils
-
         self._model = FolderDAO.find_by_id_or_uuid(self._id)
         if not self._model:
             raise FolderNotFoundError()
 
-        if not security_manager.is_admin():
-            user_id = get_user_id()
-            if not user_id or not FolderPermissionDAO.user_is_folder_editor(
-                user_id, self._model.id
-            ):
-                raise FolderForbiddenError()
+        if security_manager.is_admin() and not self._model.is_private:
+            pass
+        else:
+            from superset.folders.utils import folder_permissions_enabled
+
+            if folder_permissions_enabled():
+                user_id = get_user_id()
+                if not user_id or not FolderPermissionDAO.user_is_folder_editor(
+                    user_id, self._model.id
+                ):
+                    raise FolderForbiddenError()
+            else:
+                from flask import g
+
+                from superset.folders.utils import can_manage_folders
+
+                if not can_manage_folders(g.user):
+                    raise FolderForbiddenError()
 
         # "Only Me" folder: reject name/parent changes (sync_permissions is allowed)
-        extra = json_utils.loads(self._model.extra) if self._model.extra else {}
-        if extra.get("only_me"):
+        if self._model.is_only_me:
             if "name" in self._properties or "parent_uuid" in self._properties:
                 raise FolderForbiddenError()
 
diff --git a/superset/daos/folder.py b/superset/daos/folder.py
index 730a15e5210..7a4fffc6923 100644
--- a/superset/daos/folder.py
+++ b/superset/daos/folder.py
@@ -617,19 +617,34 @@ class FolderDAO(BaseDAO[Folder]):
 
         When ``archive_items`` is False (default), assets linked to the folder
         become unfoldered. When True, the assets themselves are also deleted.
+
+        When the ``SOFT_DELETE`` feature flag is enabled the folder (and its
+        children when ``archive_items`` is True) are soft-deleted instead of
+        permanently removed.  Permissions and asset links are preserved so
+        that a future restore can bring everything back.
         """
         from superset.folders.utils import folder_permissions_enabled
 
         if folder_permissions_enabled() and folder.is_only_me:
             raise ValueError("Cannot delete an Only Me folder")
 
+        from superset import is_feature_enabled
+
+        soft = is_feature_enabled("SOFT_DELETE")
+
+        if soft:
+            cls._soft_delete_folder(folder, archive_items)
+        else:
+            cls._hard_delete_folder(folder, archive_items)
+
+    @classmethod
+    def _hard_delete_folder(cls, folder: Folder, archive_items: bool = False) -> None:
+        grandparent = folder.parent
         for child in list(folder.children):
-            child.parent_id = folder.parent_id
+            child.parent = grandparent
             child.name = cls.resolve_name_conflict(
                 child.name, folder.parent_id, folder.folder_type, exclude_id=child.id
             )
-        # Clear the in-memory collection so db.session.delete(folder) below
-        # doesn't re-null the children's parent_id.
         folder.children.clear()
         db.session.flush()
 
@@ -642,14 +657,90 @@ class FolderDAO(BaseDAO[Folder]):
                         if asset:
                             db.session.delete(asset)
                         break
+
+        db.session.delete(folder)
+
+    @classmethod
+    def _soft_delete_folder(cls, folder: Folder, archive_items: bool = False) -> None:
+        from superset.models.helpers import SoftDeleteMixin
+
+        if archive_items:
+            folders_to_delete: list[Folder] = []
+            stack = [folder]
+            while stack:
+                current = stack.pop()
+                folders_to_delete.append(current)
+                stack.extend(current.children)
+
+            for f in folders_to_delete:
+                for link in list(f.objects):
+                    for _name, config in ASSET_TYPE_CONFIGS.items():
+                        asset_id = getattr(link, config.fk_column)
+                        if asset_id is not None:
+                            asset = db.session.get(config.model, asset_id)
+                            if asset and isinstance(asset, SoftDeleteMixin):
+                                asset.soft_delete()
+                            elif asset:
+                                db.session.delete(asset)
+                            break
+
+            for f in folders_to_delete:
+                f.soft_delete()
         else:
-            # Detach assets so they become unfoldered (visible at root).
+            grandparent = folder.parent
+            for child in list(folder.children):
+                child.parent = grandparent
+                child.name = cls.resolve_name_conflict(
+                    child.name,
+                    folder.parent_id,
+                    folder.folder_type,
+                    exclude_id=child.id,
+                )
+            folder.children.clear()
             db.session.query(FolderObject).filter(
                 FolderObject.folder_id == folder.id
             ).delete(synchronize_session=False)
             db.session.flush()
+            folder.soft_delete()
 
-        db.session.delete(folder)
+    @classmethod
+    def restore_folder(cls, folder: Folder) -> None:
+        """Restore a soft-deleted folder and all its soft-deleted descendants.
+
+        Asset links and permissions were preserved during soft-delete, so
+        restoring the folder tree makes everything visible again.
+        Soft-deleted assets linked to the folders are also restored.
+        """
+        from superset.models.helpers import skip_visibility_filter, SoftDeleteMixin
+
+        folders_to_restore: list[Folder] = []
+        stack = [folder]
+        while stack:
+            current = stack.pop()
+            if current.deleted_at is not None:
+                folders_to_restore.append(current)
+            stack.extend(current.children)
+
+        for f in folders_to_restore:
+            for link in list(f.objects):
+                for _name, config in ASSET_TYPE_CONFIGS.items():
+                    asset_id = getattr(link, config.fk_column)
+                    if asset_id is not None:
+                        with skip_visibility_filter(db.session, config.model):
+                            asset = db.session.get(config.model, asset_id)
+                        if (
+                            asset
+                            and isinstance(asset, SoftDeleteMixin)
+                            and asset.deleted_at is not None
+                        ):
+                            asset.restore()
+                        break
+
+        for f in folders_to_restore:
+            f.name = cls.resolve_name_conflict(
+                f.name, f.parent_id, f.folder_type, exclude_id=f.id
+            )
+            f.restore()
 
     @classmethod
     def assign_assets(cls, folder: Folder, assets: list[dict[str, Any]]) -> None:
@@ -826,6 +917,35 @@ class FolderDAO(BaseDAO[Folder]):
         db.session.flush()
         return new_folder
 
+    @classmethod
+    def handle_user_deactivation(cls, user_id: int, email: str) -> None:
+        """Handle folder cleanup when a user is removed from the workspace.
+
+        Makes the user's Only Me folder visible to admins by:
+        - Renaming it to "Only Me (<email>)"
+        - Setting is_private = False so admins can see and manage it
+        """
+        from superset.folders.utils import folder_permissions_enabled
+
+        if not folder_permissions_enabled():
+            return
+        subject = get_user_subject(user_id)
+        subject_id = subject.id if subject else None
+
+        folder = (
+            db.session.query(Folder)
+            .join(folder_editors, folder_editors.c.folder_id == Folder.id)
+            .filter(
+                folder_editors.c.subject_id == subject_id,
+                Folder.is_only_me.is_(True),
+            )
+            .first()
+        )
+        if folder:
+            folder.name = f"Only Me ({email})"
+            folder.is_private = False
+            db.session.flush()
+
     # ------------------------------------------------------------------ #
     # Pins
     # ------------------------------------------------------------------ #
@@ -1068,9 +1188,14 @@ class FolderDAO(BaseDAO[Folder]):
 
             from superset.subjects.models import Subject
 
-            owned_assets = (
+            accessible_assets = (
                 select(model.id)
-                .where(model.editors.any(Subject.user_id == user_id))
+                .where(
+                    or_(
+                        model.editors.any(Subject.user_id == user_id),
+                        model.viewers.any(Subject.user_id == user_id),
+                    )
+                )
                 .subquery()
             )
             rows = (
@@ -1078,7 +1203,7 @@ class FolderDAO(BaseDAO[Folder]):
                 .join(Folder, Folder.id == FolderObject.folder_id)
                 .filter(
                     fk_col.isnot(None),
-                    fk_col.in_(select(owned_assets.c.id)),
+                    fk_col.in_(select(accessible_assets.c.id)),
                     Folder.folder_type == folder_type,
                 )
                 .distinct()
diff --git a/superset/folders/api.py b/superset/folders/api.py
index 1b51863b9b7..99807ec748e 100644
--- a/superset/folders/api.py
+++ b/superset/folders/api.py
@@ -45,12 +45,16 @@ from superset.commands.folder.exceptions import (
     FolderDeleteFailedError,
     FolderForbiddenError,
     FolderInvalidError,
+    FolderNotDeletedError,
     FolderNotFoundError,
+    FolderRestoreFailedError,
     FolderUpdateFailedError,
 )
+from superset.commands.folder.restore import RestoreFolderCommand
 from superset.commands.folder.update import UpdateFolderCommand
 from superset.daos.folder import FolderDAO, ResolvedAsset
 from superset.daos.folder_permissions import FolderPermissionDAO
+from superset.exceptions import SupersetSecurityException
 from superset.extensions import db, event_logger
 from superset.folders.constants import ASSET_TYPE_CONFIGS, DEFAULT_FOLDER_TYPE
 from superset.folders.models import Folder, folder_editors, folder_viewers
@@ -242,6 +246,7 @@ def serialize_asset(asset_type: str, asset: Any) -> dict[str, Any]:
         "id": asset.id,
         "uuid": str(asset.uuid) if getattr(asset, "uuid", None) else None,
         "name": getattr(asset, config.title_attr, None),
+        "description": getattr(asset, "description", None),
         "url": getattr(asset, "url", None),
         "changed_on": getattr(asset, "changed_on", None),
         "changed_on_humanized": getattr(asset, "changed_on_humanized", None),
@@ -398,7 +403,7 @@ class FolderRestApi(BaseSupersetApi):
 
         if not folder_permissions_enabled():
             return
-        if security_manager.is_admin():
+        if security_manager.is_admin() and not folder.is_private:
             return
         user_id = get_user_id()
         if not user_id:
@@ -423,7 +428,7 @@ class FolderRestApi(BaseSupersetApi):
                 raise FolderForbiddenError()
             return
 
-        if security_manager.is_admin():
+        if security_manager.is_admin() and not folder.is_private:
             return
         user_id = get_user_id()
         if not user_id or not FolderPermissionDAO.user_is_folder_editor(
@@ -727,7 +732,18 @@ class FolderRestApi(BaseSupersetApi):
             self._raise_for_folder_access(folder)
         except FolderForbiddenError:
             return self.response_403()
-        return self.response(200, result=serialize_folder(folder))
+        # Compute implicit status for accurate user_permission serialization
+        implicit_ids: set[int] = set()
+        user_id = get_user_id()
+        if (
+            user_id
+            and not security_manager.is_admin()
+            and not FolderPermissionDAO.user_has_folder_access(user_id, folder.id)
+        ):
+            implicit_ids = {folder.id}
+        return self.response(
+            200, result=serialize_folder(folder, implicit_folder_ids=implicit_ids)
+        )
 
     @expose("/<string:folder_uuid>/assets", methods=("GET",))
     @protect()
@@ -986,6 +1002,44 @@ class FolderRestApi(BaseSupersetApi):
         except FolderDeleteFailedError as ex:
             return self.response_422(message=str(ex))
 
+    @expose("/<string:folder_uuid>/restore", methods=("POST",))
+    @protect()
+    @safe
+    @permission_name("write")
+    @statsd_metrics
+    def restore(self, folder_uuid: str) -> Response:
+        """Restore a soft-deleted folder and its descendants.
+        ---
+        post:
+          summary: Restore a soft-deleted folder
+          parameters:
+          - in: path
+            name: folder_uuid
+            required: true
+            schema:
+              type: string
+          responses:
+            200:
+              description: The folder was restored
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+            409:
+              description: Folder is not deleted
+        """
+        try:
+            folder = RestoreFolderCommand(folder_uuid).run()
+            return self.response(200, message="OK", uuid=str(folder.uuid))
+        except FolderNotFoundError:
+            return self.response_404()
+        except FolderForbiddenError:
+            return self.response_403()
+        except FolderNotDeletedError:
+            return self.response(409, message="Folder is not deleted")
+        except FolderRestoreFailedError as ex:
+            return self.response_422(message=str(ex))
+
     @expose("/<string:folder_uuid>/assets", methods=("PUT",))
     @protect()
     @safe
@@ -1208,6 +1262,15 @@ class FolderRestApi(BaseSupersetApi):
             DashboardNotFoundError,
         )
         from superset.daos.dashboard import DashboardDAO
+        from superset.models.dashboard import Dashboard
+
+        dashboard = db.session.get(Dashboard, dashboard_id)
+        if not dashboard:
+            return self.response_404()
+        try:
+            security_manager.raise_for_access(dashboard=dashboard)
+        except SupersetSecurityException:
+            return self.response_403()
 
         try:
             slices = DashboardDAO.get_charts_for_dashboard(str(dashboard_id))
@@ -1322,6 +1385,45 @@ class FolderRestApi(BaseSupersetApi):
             return self.response(200, message="OK")
         return self.response_404()
 
+    # ------------------------------------------------------------------ #
+    # Only Me folder
+    # ------------------------------------------------------------------ #
+    @expose("/only-me", methods=("POST",))
+    @protect()
+    @safe
+    @permission_name("write")
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.only_me",
+        log_to_statsd=False,
+    )
+    @transaction()
+    def only_me(self) -> Response:
+        """Create or return the current user's Only Me folder.
+        ---
+        post:
+          summary: Ensure the Only Me folder exists
+          responses:
+            200:
+              description: The Only Me folder
+              content:
+                application/json:
+                  schema:
+                    $ref: '#/components/schemas/FolderResponseSchema'
+            403:
+              $ref: '#/components/responses/403'
+        """
+        if not folder_permissions_enabled():
+            return self.response_404()
+
+        user_id = get_user_id()
+        if not user_id or not can_manage_folders(g.user):
+            return self.response_403()
+        folder = FolderDAO.get_or_create_only_me_folder(user_id)
+        if not folder:
+            return self.response_404()
+        return self.response(200, result=serialize_folder(folder))
+
     # ------------------------------------------------------------------ #
     # Subjects (permissions)
     # ------------------------------------------------------------------ #
@@ -1356,7 +1458,13 @@ class FolderRestApi(BaseSupersetApi):
         if not folder:
             return self.response_404()
         # Explicit members only — implicit access users cannot see permissions
-        if not security_manager.is_admin():
+        if security_manager.is_admin() and folder.is_private:
+            user_id = get_user_id()
+            if not user_id or not FolderPermissionDAO.user_has_folder_access(
+                user_id, folder.id
+            ):
+                return self.response_403()
+        elif not security_manager.is_admin():
             user_id = get_user_id()
             if not user_id or not FolderPermissionDAO.user_has_folder_access(
                 user_id, folder.id
@@ -1697,6 +1805,13 @@ class FolderRestApi(BaseSupersetApi):
         if not folder:
             return self.response(200, result=None)
 
+        if folder.is_private:
+            user_id = get_user_id()
+            if not user_id or not FolderPermissionDAO.user_has_folder_access(
+                user_id, folder.id
+            ):
+                return self.response(200, result=None)
+
         return self.response(
             200,
             result={
diff --git a/superset/folders/hooks.py b/superset/folders/hooks.py
index 03773497dd7..1849e63d471 100644
--- a/superset/folders/hooks.py
+++ b/superset/folders/hooks.py
@@ -107,11 +107,41 @@ def folder_raise_for_access_bypass(**kwargs: Any) -> bool:
             ):
                 chart_ids.add(slice_id)
         if dash_id := _safe_int(form_data.get("dashboardId")):
-            dashboard_ids.add(dash_id)
+            # Verify the dashboard contains a chart using this datasource.
+            from superset.models.dashboard import dashboard_slices
+            from superset.models.slice import Slice as SliceModel2
+
+            has_matching_chart = (
+                (
+                    db.session.query(dashboard_slices.c.slice_id)
+                    .join(SliceModel2, SliceModel2.id == dashboard_slices.c.slice_id)
+                    .filter(
+                        dashboard_slices.c.dashboard_id == dash_id,
+                        SliceModel2.datasource_id == query_context.datasource.id,
+                    )
+                    .first()
+                )
+                if (hasattr(query_context, "datasource") and query_context.datasource)
+                else None
+            )
+            if has_matching_chart:
+                dashboard_ids.add(dash_id)
 
     chart_ids.discard(None)
     dashboard_ids.discard(None)
 
+    # Check private folder gate for ALL collected IDs (including form_data).
+    for cid in chart_ids:
+        if is_asset_in_private_folder(chart_id=cid):
+            return FolderPermissionDAO.user_has_folder_access_for_asset(
+                user_id=user_id, chart_id=cid
+            )
+    for did in dashboard_ids:
+        if is_asset_in_private_folder(dashboard_id=did):
+            return FolderPermissionDAO.user_has_folder_access_for_asset(
+                user_id=user_id, dashboard_id=did
+            )
+
     # Check folder access for any collected ID
     for chart_id in chart_ids:
         if FolderPermissionDAO.user_has_folder_access_for_asset(
@@ -156,34 +186,28 @@ def folder_extra_owners(resource: Any) -> list[int]:
 
 def after_asset_create(asset: Any, asset_type: str) -> None:
     """Auto-assign newly created charts/dashboards to the user's 'Only Me' folder."""
-    import logging
+    from superset.folders.utils import folder_permissions_enabled
 
-    logger = logging.getLogger(__name__)
-    logger.info("[after_asset_create] called with asset_type=%s, asset_id=%s", asset_type, asset.id)
+    if not folder_permissions_enabled():
+        return
 
     from superset.daos.folder import FolderDAO
     from superset.folders.utils import can_manage_folders
 
     user_id = get_user_id()
-    logger.info("[after_asset_create] user_id=%s", user_id)
     if not user_id:
-        logger.info("[after_asset_create] no user_id, returning")
         return
 
     from flask import g
 
     if not hasattr(g, "user"):
-        logger.info("[after_asset_create] no g.user, returning")
         return
     if not can_manage_folders(g.user):
-        logger.info("[after_asset_create] user cannot manage folders, roles=%s", [r.name for r in g.user.roles])
         return
 
-    logger.info("[after_asset_create] creating/getting Only Me folder")
     folder = FolderDAO.get_or_create_only_me_folder(user_id)
-    logger.info("[after_asset_create] assigning asset to folder %s", folder.id)
-    FolderDAO.assign_assets(folder, [{"type": asset_type, "id": asset.id}])
-    logger.info("[after_asset_create] done")
+    if folder:
+        FolderDAO.assign_assets(folder, [{"type": asset_type, "id": asset.id}])
 
 
 def folder_export_fields(model: Any, asset_type: str) -> dict[str, Any]:
@@ -218,10 +242,13 @@ def folder_export_fields(model: Any, asset_type: str) -> dict[str, Any]:
     # Walk the ancestry rather than checking the leaf alone: a folder nested
     # under a private one is only reachable by that folder's members. The same
     # pass collects each node's description so it survives the round trip.
+    from superset.folders.utils import folder_permissions_enabled
+
+    perms_on = folder_permissions_enabled()
     nodes: list[dict[str, Any]] = []
     for entry in path:
         ancestor = FolderDAO.get_by_uuid(entry["uuid"])
-        if ancestor is not None and ancestor.is_private:
+        if perms_on and ancestor is not None and ancestor.is_private:
             return {}
         nodes.append(
             {
@@ -263,6 +290,8 @@ def folder_import_handler(
             Folder.name == name,
             Folder.folder_type == DEFAULT_FOLDER_TYPE,
             Folder.parent_id == (parent.id if parent else None),
+            Folder.is_private.is_(False),
+            Folder.is_only_me.is_(False),
         )
         folder = query.first()
         if folder is None:
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 453527ce1cd..da5e2eb9b4e 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -349,39 +349,51 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
             category="Data",
             category_label=_("Data"),
         )
-        if feature_flag_manager.is_feature_enabled("FOLDERS"):
-            from superset.folders.api import FolderRestApi
-            from superset.views.folders import FolderView
-
-            appbuilder.add_api(FolderRestApi)
-            appbuilder.add_view_no_menu(FolderView)
-
-            appbuilder.add_link(
-                "Analytics",
-                label=_("Analytics"),
-                href="/analytics/",
-                icon="fa-folder-open",
-                category="",
-                category_icon="",
-            )
+        from superset.folders.api import FolderRestApi
+        from superset.views.folders import FolderView
 
-        if not feature_flag_manager.is_feature_enabled("FOLDERS"):
-            appbuilder.add_view(
-                DashboardModelView,
-                "Dashboards",
-                label=_("Dashboards"),
-                icon="fa-dashboard",
-                category="",
-                category_icon="",
-            )
-            appbuilder.add_view(
-                SliceModelView,
-                "Charts",
-                label=_("Charts"),
-                icon="fa-bar-chart",
-                category="",
-                category_icon="",
-            )
+        appbuilder.add_api(FolderRestApi)
+        appbuilder.add_view_no_menu(FolderView)
+
+        appbuilder.add_link(
+            "Analytics",
+            label=_("Analytics"),
+            href="/analytics/",
+            icon="fa-folder-open",
+            category="",
+            category_icon="",
+            cond=lambda: feature_flag_manager.is_feature_enabled("FOLDERS"),
+        )
+
+        appbuilder.add_view(
+            DashboardModelView,
+            "Dashboards",
+            label=_("Dashboards"),
+            icon="fa-dashboard",
+            category="",
+            category_icon="",
+            menu_cond=lambda: not feature_flag_manager.is_feature_enabled("FOLDERS"),
+        )
+        appbuilder.add_view(
+            SliceModelView,
+            "Charts",
+            label=_("Charts"),
+            icon="fa-bar-chart",
+            category="",
+            category_icon="",
+            menu_cond=lambda: not feature_flag_manager.is_feature_enabled("FOLDERS"),
+        )
+
+        @self.superset_app.before_request
+        def _gate_folder_list_routes():  # type: ignore[no-untyped-def]
+            from flask import abort, request as flask_request
+
+            path = flask_request.path.rstrip("/")
+            folders_on = feature_flag_manager.is_feature_enabled("FOLDERS")
+            if folders_on and path in ("/chart/list", "/dashboard/list"):
+                abort(404)
+            if not folders_on and path.startswith("/analytics"):
+                abort(404)
 
         appbuilder.add_link(
             "Datasets",
diff --git a/superset/security/manager.py b/superset/security/manager.py
index ba0e322e131..88e34c26564 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -5017,32 +5017,64 @@ class SupersetSecurityManager(  # pylint: disable=too-many-public-methods
 
         # 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():
+        if (
+            is_feature_enabled("FOLDERS")
+            and is_feature_enabled("FOLDER_PERMISSIONS")
+            and (dashboard or chart)
+        ):
+            # Embedded charts arrive as query_context — resolve the chart from
+            # form_data so guest users can't bypass the folder gate.
+            if not chart and self.is_guest_user():
+                qc = kwargs.get("query_context")
+                if qc and getattr(qc, "form_data", None):
+                    slice_id = qc.form_data.get("slice_id")
+                    if slice_id:
+                        from superset.models.slice import Slice
+
+                        chart = self.session.get(Slice, int(slice_id))
+
+            if not dashboard and not chart:
+                pass  # nothing to gate
+            elif self.is_admin():
+                pass  # admin bypass (private folder enforcement is at the API layer)
+            else:
                 user_id = get_user_id()
-                asset = dashboard or chart
-                if user_id and not self.is_editor(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,
-                                )
-                            )
+                if user_id:
+                    asset = dashboard or chart
+                    if self.is_editor(asset) or self.is_viewer(asset):
+                        pass  # asset editors/viewers always have access
+                    else:
+                        from superset.folders.models import Folder, FolderObject
+
+                        fk_col = FolderObject.dashboard_id if dashboard else FolderObject.chart_id
+                        fo = self.session.query(FolderObject).filter(fk_col == asset.id).first()
+                        if fo:
+                            folder = self.session.get(Folder, fo.folder_id)
+                            if folder:
+                                from superset.daos.folder_permissions import FolderPermissionDAO
+
+                                if not FolderPermissionDAO.user_has_folder_access(
+                                    user_id, folder.id
+                                ):
+                                    error_type = (
+                                        SupersetErrorType.DASHBOARD_SECURITY_ACCESS_ERROR
+                                        if dashboard
+                                        else SupersetErrorType.CHART_SECURITY_ACCESS_ERROR
+                                    )
+                                    message = (
+                                        "Access denied — this asset is in a private folder "
+                                        "and can only be accessed by its owner."
+                                        if folder.is_private
+                                        else "Access denied — you don't have access to the "
+                                        "folder containing this asset."
+                                    )
+                                    raise SupersetSecurityException(
+                                        SupersetError(
+                                            error_type=error_type,
+                                            message=message,
+                                            level=ErrorLevel.WARNING,
+                                        )
+                                    )
 
     def get_user_by_username(self, username: str) -> Optional[User]:
         """