You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by as...@apache.org on 2026/06/19 11:56:05 UTC

(superset) 04/10: major improvements

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

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

commit b1bd7d8be26133f15121a561ac6458c26cb88557
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Wed Jun 10 14:49:25 2026 +0300

    major improvements
---
 .../src/components/Modal/StandardModal.tsx         |   2 +-
 .../src/pages/AnalyticsList/DeleteFolderModal.tsx  | 123 ++++++++++
 .../src/pages/AnalyticsList/FolderAssetsModal.tsx  |  90 +++----
 .../pages/AnalyticsList/FolderPermissionsModal.tsx | 109 ++++-----
 .../src/pages/AnalyticsList/MoveToModal.tsx        |  46 ++--
 .../src/pages/AnalyticsList/index.tsx              |  42 ++--
 superset/charts/filters.py                         |  41 +---
 superset/commands/chart/create.py                  |   7 +-
 superset/commands/dashboard/create.py              |   3 +
 superset/commands/folder/assets.py                 |  60 +++++
 superset/commands/folder/delete.py                 |  16 +-
 superset/commands/folder/exceptions.py             |   5 +
 superset/commands/folder/update.py                 |  70 ++++--
 superset/commands/utils.py                         |  12 +-
 superset/config.py                                 |  25 ++
 superset/daos/folder.py                            | 135 ++++++++++-
 superset/daos/folder_permissions.py                | 118 +++------
 superset/dashboards/filters.py                     |  30 +--
 superset/folders/api.py                            | 268 ++++++++++++---------
 superset/folders/models.py                         |   6 +
 superset/security/manager.py                       |  50 ++--
 21 files changed, 805 insertions(+), 453 deletions(-)

diff --git a/superset-frontend/src/components/Modal/StandardModal.tsx b/superset-frontend/src/components/Modal/StandardModal.tsx
index 963afdbca53..6bb644698a2 100644
--- a/superset-frontend/src/components/Modal/StandardModal.tsx
+++ b/superset-frontend/src/components/Modal/StandardModal.tsx
@@ -61,7 +61,7 @@ const StyledModal = styled(Modal)`
       ${({ theme }) => theme.sizeUnit * 4}px
       ${({ theme }) => theme.sizeUnit * 3}px;
     margin-bottom: 0;
-    border-bottom: 1px solid ${({ theme }) => theme.colorBorder};
+    border-bottom: none;
   }
 
   .ant-modal-footer {
diff --git a/superset-frontend/src/pages/AnalyticsList/DeleteFolderModal.tsx b/superset-frontend/src/pages/AnalyticsList/DeleteFolderModal.tsx
new file mode 100644
index 00000000000..cb5f213dac3
--- /dev/null
+++ b/superset-frontend/src/pages/AnalyticsList/DeleteFolderModal.tsx
@@ -0,0 +1,123 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useCallback, useState } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { SupersetClient } from '@superset-ui/core';
+import { styled, css, useTheme } from '@apache-superset/core/theme';
+import { Checkbox, Input } from '@superset-ui/core/components';
+import { StandardModal } from 'src/components/Modal';
+
+interface DeleteFolderModalProps {
+  folder: {
+    uuid: string;
+    name: string;
+    asset_count: number;
+    children_count: number;
+  };
+  show: boolean;
+  onHide: () => void;
+  onSuccess: () => void;
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+}
+
+const ModalContent = styled.div`
+  ${({ theme }) => css`
+    padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px;
+    display: flex;
+    flex-direction: column;
+    gap: ${theme.sizeUnit * 3}px;
+  `}
+`;
+
+export default function DeleteFolderModal({
+  folder,
+  show,
+  onHide,
+  onSuccess,
+  addDangerToast,
+  addSuccessToast,
+}: DeleteFolderModalProps) {
+  const theme = useTheme();
+  const [confirmName, setConfirmName] = useState('');
+  const [deleteItems, setDeleteItems] = useState(false);
+  const [saving, setSaving] = useState(false);
+
+  const isEmpty = folder.asset_count === 0 && folder.children_count === 0;
+  const nameMatches = confirmName === folder.name;
+
+  const handleDelete = useCallback(async () => {
+    setSaving(true);
+    try {
+      await SupersetClient.delete({
+        endpoint: `/api/v1/folders/${folder.uuid}${deleteItems ? '?archive_items=true' : ''}`,
+      });
+      addSuccessToast(t('Folder "%s" deleted', folder.name));
+      onSuccess();
+      onHide();
+    } catch {
+      addDangerToast(t('Error deleting folder'));
+    } finally {
+      setSaving(false);
+    }
+  }, [folder, deleteItems, addSuccessToast, addDangerToast, onSuccess, onHide]);
+
+  const handleHide = useCallback(() => {
+    setConfirmName('');
+    setDeleteItems(false);
+    onHide();
+  }, [onHide]);
+
+  return (
+    <StandardModal
+      title={t('Delete folder')}
+      show={show}
+      onHide={handleHide}
+      onSave={handleDelete}
+      saveText={t('Delete')}
+      saveLoading={saving}
+      saveDisabled={!nameMatches}
+    >
+      <ModalContent>
+        <p css={{ color: theme.colorText, margin: 0 }}>
+          {isEmpty
+            ? t("Delete this folder? It doesn't contain any items.")
+            : t(
+                'This folder contains items. Items will be moved to the parent folder or Analytics page (depending on the level) unless you choose to delete everything together.',
+              )}
+        </p>
+        <div>
+          <Input
+            placeholder={t('Type folder name to confirm')}
+            value={confirmName}
+            onChange={e => setConfirmName(e.target.value)}
+          />
+        </div>
+        {!isEmpty && (
+          <Checkbox
+            checked={deleteItems}
+            onChange={e => setDeleteItems(e.target.checked)}
+          >
+            {t('Delete all items in this folder')}
+          </Checkbox>
+        )}
+      </ModalContent>
+    </StandardModal>
+  );
+}
diff --git a/superset-frontend/src/pages/AnalyticsList/FolderAssetsModal.tsx b/superset-frontend/src/pages/AnalyticsList/FolderAssetsModal.tsx
index e4c2420300e..de406a56c03 100644
--- a/superset-frontend/src/pages/AnalyticsList/FolderAssetsModal.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/FolderAssetsModal.tsx
@@ -176,39 +176,33 @@ export default function FolderAssetsModal({
   const [selected, setSelected] = useState<Set<string>>(new Set());
   const [saving, setSaving] = useState(false);
 
-  const fetchCharts = useCallback(
-    async (page: number, append = false) => {
-      const { json } = await SupersetClient.get({
-        endpoint: `/api/v1/chart/?q=(page_size:${PAGE_SIZE},page:${page})`,
-      });
-      const mapped = ((json.result as ChartResult[]) || []).map(c => ({
-        id: c.id,
-        name: c.slice_name,
-        type: 'chart' as AssetType,
-      }));
-      setCharts(prev => (append ? [...prev, ...mapped] : mapped));
-      setChartsTotal(json.count || 0);
-      setChartsPage(page);
-    },
-    [],
-  );
+  const fetchCharts = useCallback(async (page: number, append = false) => {
+    const { json } = await SupersetClient.get({
+      endpoint: `/api/v1/chart/?q=(page_size:${PAGE_SIZE},page:${page})`,
+    });
+    const mapped = ((json.result as ChartResult[]) || []).map(c => ({
+      id: c.id,
+      name: c.slice_name,
+      type: 'chart' as AssetType,
+    }));
+    setCharts(prev => (append ? [...prev, ...mapped] : mapped));
+    setChartsTotal(json.count || 0);
+    setChartsPage(page);
+  }, []);
 
-  const fetchDashboards = useCallback(
-    async (page: number, append = false) => {
-      const { json } = await SupersetClient.get({
-        endpoint: `/api/v1/dashboard/?q=(page_size:${PAGE_SIZE},page:${page})`,
-      });
-      const mapped = ((json.result as DashboardResult[]) || []).map(d => ({
-        id: d.id,
-        name: d.dashboard_title,
-        type: 'dashboard' as AssetType,
-      }));
-      setDashboards(prev => (append ? [...prev, ...mapped] : mapped));
-      setDashboardsTotal(json.count || 0);
-      setDashboardsPage(page);
-    },
-    [],
-  );
+  const fetchDashboards = useCallback(async (page: number, append = false) => {
+    const { json } = await SupersetClient.get({
+      endpoint: `/api/v1/dashboard/?q=(page_size:${PAGE_SIZE},page:${page})`,
+    });
+    const mapped = ((json.result as DashboardResult[]) || []).map(d => ({
+      id: d.id,
+      name: d.dashboard_title,
+      type: 'dashboard' as AssetType,
+    }));
+    setDashboards(prev => (append ? [...prev, ...mapped] : mapped));
+    setDashboardsTotal(json.count || 0);
+    setDashboardsPage(page);
+  }, []);
 
   const fetchFolders = useCallback(
     async (page: number, append = false) => {
@@ -235,8 +229,7 @@ export default function FolderAssetsModal({
     const { json } = await SupersetClient.get({
       endpoint: `/api/v1/folders/${folderUuid}/assets?types=chart,dashboard&page_size=1000`,
     });
-    const members =
-      (json.result as { type: string; id: number }[]) || [];
+    const members = (json.result as { type: string; id: number }[]) || [];
     setSelected(
       new Set(
         members
@@ -258,7 +251,14 @@ export default function FolderAssetsModal({
     ])
       .catch(() => addDangerToast(t('Error loading assets')))
       .finally(() => setLoading(false));
-  }, [show, fetchCharts, fetchDashboards, fetchFolders, fetchMembers, addDangerToast]);
+  }, [
+    show,
+    fetchCharts,
+    fetchDashboards,
+    fetchFolders,
+    fetchMembers,
+    addDangerToast,
+  ]);
 
   const toggleSelect = useCallback((key: string) => {
     setSelected(prev => {
@@ -286,7 +286,7 @@ export default function FolderAssetsModal({
 
       const calls: Promise<unknown>[] = [];
 
-      // Update assets via bulk PUT
+      // Update assets via bulk PUT (set_assets — replaces full membership)
       calls.push(
         SupersetClient.put({
           endpoint: `/api/v1/folders/${folderUuid}/assets`,
@@ -294,13 +294,18 @@ export default function FolderAssetsModal({
         }),
       );
 
-      // Move each selected folder into this folder via single-asset PUT
+      // Move each selected folder by updating its parent_uuid
+      const folderById = new Map(folders.map(f => [f.id, f]));
       for (const f of folderPayload) {
-        calls.push(
-          SupersetClient.put({
-            endpoint: `/api/v1/folders/${folderUuid}/assets/folder/${f.id}`,
-          }),
-        );
+        const folderItem = folderById.get(f.id);
+        if (folderItem?.uuid) {
+          calls.push(
+            SupersetClient.put({
+              endpoint: `/api/v1/folders/${folderItem.uuid}`,
+              jsonPayload: { parent_uuid: folderUuid },
+            }),
+          );
+        }
       }
 
       await Promise.all(calls);
@@ -318,6 +323,7 @@ export default function FolderAssetsModal({
     selected,
     addSuccessToast,
     addDangerToast,
+    folders,
     onSuccess,
     onHide,
   ]);
diff --git a/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx b/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
index bc0a53d2f99..128289d9b11 100644
--- a/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/FolderPermissionsModal.tsx
@@ -24,8 +24,8 @@ import { styled, css, useTheme } from '@apache-superset/core/theme';
 import {
   AsyncSelect,
   Select,
-  Tag,
   type SelectOptionsTypePage,
+  type SelectValue,
 } from '@superset-ui/core/components';
 import { StandardModal } from 'src/components/Modal';
 import { Icons } from '@superset-ui/core/components/Icons';
@@ -210,13 +210,20 @@ export default function FolderPermissionsModal({
   }, [show, fetchSubjects]);
 
   const handleAddUser = useCallback(
-    (value: number, option: { label: string }) => {
-      if (!value) return;
+    (selected: SelectValue) => {
+      if (!selected || typeof selected !== 'object') return;
+      const item = selected as { value: number; label: string };
+      const userId = Number(item.value);
+      if (!userId || Number.isNaN(userId)) return;
       setLocalSubjects(prev => {
-        if (prev.some(s => s.user_id === value)) return prev;
+        if (prev.some(s => s.user_id === userId)) return prev;
         return [
           ...prev,
-          { user_id: value, permission: addPermission, label: option.label },
+          {
+            user_id: userId,
+            permission: addPermission,
+            label: String(item.label),
+          },
         ];
       });
     },
@@ -286,8 +293,15 @@ export default function FolderPermissionsModal({
         }
       }
 
-      await Promise.all(calls);
-      addSuccessToast(t('Permissions updated'));
+      const results = await Promise.allSettled(calls);
+      const failures = results.filter(r => r.status === 'rejected');
+      if (failures.length > 0) {
+        addDangerToast(
+          t('%s permission update(s) failed', failures.length),
+        );
+      } else {
+        addSuccessToast(t('Permissions updated'));
+      }
       onHide();
     } catch {
       addDangerToast(t('Error updating permissions'));
@@ -347,10 +361,8 @@ export default function FolderPermissionsModal({
                 ariaLabel={t('Search users')}
                 placeholder={t('Search for a user…')}
                 options={filteredUserOptions}
-                onSelect={(value: number, option: { label: string }) =>
-                  handleAddUser(value, option)
-                }
-                value={null}
+                onChange={handleAddUser}
+                value={undefined}
                 getPopupContainer={trigger =>
                   trigger.closest('.ant-modal-content') || document.body
                 }
@@ -418,50 +430,39 @@ export default function FolderPermissionsModal({
                         </span>
                       )}
                     </span>
-                    {isCurrentUser ? (
-                      <Tag
-                        color={
-                          subject.permission === 'editor' ? 'blue' : 'green'
-                        }
-                      >
-                        {subject.permission === 'editor'
-                          ? t('Editor')
-                          : t('Viewer')}
-                      </Tag>
-                    ) : (
-                      <>
-                        <Select
-                          ariaLabel={t('Permission')}
-                          options={PERMISSION_OPTIONS}
-                          value={subject.permission}
-                          onChange={(val: Permission) =>
-                            handleChangePermission(subject.user_id, val)
-                          }
-                          css={{ width: 110 }}
-                          getPopupContainer={trigger =>
-                            trigger.closest('.ant-modal-content') ||
-                            document.body
+                    <Select
+                      ariaLabel={t('Permission')}
+                      options={PERMISSION_OPTIONS}
+                      value={subject.permission}
+                      disabled={isCurrentUser}
+                      onChange={(val: Permission) =>
+                        handleChangePermission(subject.user_id, val)
+                      }
+                      css={{ width: 110 }}
+                      getPopupContainer={trigger =>
+                        trigger.closest('.ant-modal-content') ||
+                        document.body
+                      }
+                    />
+                    {!isCurrentUser && (
+                      <Icons.DeleteOutlined
+                        iconSize="m"
+                        role="button"
+                        tabIndex={0}
+                        onClick={() => handleRemoveSubject(subject.user_id)}
+                        onKeyDown={(e: React.KeyboardEvent) => {
+                          if (e.key === 'Enter')
+                            handleRemoveSubject(subject.user_id);
+                        }}
+                        css={css`
+                          cursor: pointer;
+                          color: ${theme.colorIcon};
+                          flex-shrink: 0;
+                          &:hover {
+                            color: ${theme.colorError};
                           }
-                        />
-                        <Icons.DeleteOutlined
-                          iconSize="m"
-                          role="button"
-                          tabIndex={0}
-                          onClick={() => handleRemoveSubject(subject.user_id)}
-                          onKeyDown={(e: React.KeyboardEvent) => {
-                            if (e.key === 'Enter')
-                              handleRemoveSubject(subject.user_id);
-                          }}
-                          css={css`
-                            cursor: pointer;
-                            color: ${theme.colorIcon};
-                            flex-shrink: 0;
-                            &:hover {
-                              color: ${theme.colorError};
-                            }
-                          `}
-                        />
-                      </>
+                        `}
+                      />
                     )}
                   </SubjectRow>
                 );
diff --git a/superset-frontend/src/pages/AnalyticsList/MoveToModal.tsx b/superset-frontend/src/pages/AnalyticsList/MoveToModal.tsx
index fad57175e32..244a229b050 100644
--- a/superset-frontend/src/pages/AnalyticsList/MoveToModal.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/MoveToModal.tsx
@@ -192,24 +192,21 @@ export default function MoveToModal({
   const handleMove = useCallback(async () => {
     setSaving(true);
     try {
-      if (selectedUuid === 'root') {
-        // Move to root: if folder, set parent_id to null; if asset, remove from folder
-        if (item.type === 'folder') {
-          await SupersetClient.put({
-            endpoint: `/api/v1/folders/${item.uuid}`,
-            jsonPayload: { parent_uuid: null },
-          });
-        } else {
-          // Remove asset from its current folder by moving to root
-          // This requires a separate endpoint or convention
-          // For now, use the folder update to set parent to null
-          await SupersetClient.delete({
-            endpoint: `/api/v1/folders/${currentFolderUuid}/assets/${item.type}/${item.id}`,
-          });
-        }
-      } else if (selectedUuid) {
+      if (item.type === 'folder') {
+        // Folder move: update parent_uuid (null = root)
         await SupersetClient.put({
-          endpoint: `/api/v1/folders/${selectedUuid}/assets/${item.type}/${item.id}`,
+          endpoint: `/api/v1/folders/${item.uuid}`,
+          jsonPayload: {
+            parent_uuid: selectedUuid === 'root' ? null : selectedUuid,
+          },
+        });
+      } else if (selectedUuid && selectedUuid !== 'root') {
+        // Asset move: add to target folder (upsert)
+        await SupersetClient.post({
+          endpoint: `/api/v1/folders/${selectedUuid}/assets`,
+          jsonPayload: {
+            assets: [{ type: item.type, id: item.id }],
+          },
         });
       }
       addSuccessToast(t('Moved "%s" successfully', item.name));
@@ -234,12 +231,14 @@ export default function MoveToModal({
     return nodes.map(node => {
       const isExpanded = expandedUuids.has(node.uuid);
       const hasChildren = node.children.length > 0;
+      const isCurrent = node.uuid === currentFolderUuid;
       return (
         <div key={node.uuid}>
           <FolderRow
             selected={selectedUuid === node.uuid}
             depth={depth}
-            onClick={() => setSelectedUuid(node.uuid)}
+            onClick={isCurrent ? undefined : () => setSelectedUuid(node.uuid)}
+            css={isCurrent ? { opacity: 0.4, cursor: 'default' } : undefined}
           >
             {hasChildren ? (
               <button
@@ -285,7 +284,16 @@ export default function MoveToModal({
           <FolderTree>
             <FolderRow
               selected={selectedUuid === 'root'}
-              onClick={() => setSelectedUuid('root')}
+              onClick={
+                currentFolderUuid === null
+                  ? undefined
+                  : () => setSelectedUuid('root')
+              }
+              css={
+                currentFolderUuid === null
+                  ? { opacity: 0.4, cursor: 'default' }
+                  : undefined
+              }
             >
               <Icons.FolderOpenOutlined iconSize="m" />
               <span className="folder-name">{t('Root (Analytics)')}</span>
diff --git a/superset-frontend/src/pages/AnalyticsList/index.tsx b/superset-frontend/src/pages/AnalyticsList/index.tsx
index 01fabc2c0b3..10006d4db7c 100644
--- a/superset-frontend/src/pages/AnalyticsList/index.tsx
+++ b/superset-frontend/src/pages/AnalyticsList/index.tsx
@@ -57,6 +57,7 @@ import { type Slice } from 'src/types/Chart';
 import CreateFolderModal from './CreateFolderModal';
 import FolderAssetsModal from './FolderAssetsModal';
 import FolderPermissionsModal from './FolderPermissionsModal';
+import DeleteFolderModal from './DeleteFolderModal';
 import MoveToModal from './MoveToModal';
 import DashboardCharts from './DashboardCharts';
 
@@ -80,6 +81,8 @@ interface ContentItem {
   owners?: Owner[];
   changed_on?: string;
   changed_by?: Owner | null;
+  asset_count?: number;
+  children_count?: number;
 }
 
 /** A location in the drill path; the root has a null uuid. */
@@ -184,6 +187,9 @@ function AnalyticsList({
   } | null>(null);
   const [bulkSelectEnabled, setBulkSelectEnabled] = useState(false);
   const [moveTarget, setMoveTarget] = useState<ContentItem | null>(null);
+  const [folderToArchive, setFolderToArchive] = useState<ContentItem | null>(
+    null,
+  );
 
   // --- Pinning state ---
   const [pins, setPins] = useState<
@@ -346,21 +352,6 @@ function AnalyticsList({
     );
   }, []);
 
-  const deleteFolder = useCallback(
-    async (item: ContentItem) => {
-      try {
-        await SupersetClient.delete({
-          endpoint: `/api/v1/folders/${item.uuid}`,
-        });
-        addSuccessToast(t('Deleted: %s', item.name));
-        refreshData();
-      } catch {
-        addDangerToast(t('Error deleting %s', item.name));
-      }
-    },
-    [addSuccessToast, addDangerToast, refreshData],
-  );
-
   // Mirror ChartList: PropertiesModal fetches the chart itself by `slice_id`,
   // so it only needs a minimal slice built from the row (no pre-fetch).
   const openChartEdit = useCallback(
@@ -660,7 +651,7 @@ function AnalyticsList({
                     role="button"
                     tabIndex={0}
                     className="action-button"
-                    onClick={() => deleteFolder(original)}
+                    onClick={() => setFolderToArchive(original)}
                   >
                     <Icons.DeleteOutlined iconSize="l" />
                   </span>
@@ -726,7 +717,6 @@ function AnalyticsList({
     ],
     [
       drillInto,
-      deleteFolder,
       expandedKeys,
       toggleExpand,
       openChartEdit,
@@ -850,16 +840,19 @@ function AnalyticsList({
               items: [
                 {
                   key: 'chart',
+                  icon: <Icons.AreaChartOutlined />,
                   label: t('Chart'),
                   onClick: () => history.push('/chart/add'),
                 },
                 {
                   key: 'dashboard',
+                  icon: <Icons.AppstoreOutlined />,
                   label: t('Dashboard'),
                   onClick: () => window.location.assign('/dashboard/new'),
                 },
                 {
                   key: 'folder',
+                  icon: <Icons.FolderOutlined />,
                   label: t('Folder'),
                   onClick: () => setShowCreateFolder(true),
                 },
@@ -933,6 +926,21 @@ function AnalyticsList({
           }}
         />
       )}
+      {folderToArchive && (
+        <DeleteFolderModal
+          folder={{
+            uuid: folderToArchive.uuid ?? '',
+            name: folderToArchive.name,
+            asset_count: folderToArchive.asset_count ?? 0,
+            children_count: folderToArchive.children_count ?? 0,
+          }}
+          show
+          onHide={() => setFolderToArchive(null)}
+          onSuccess={refreshData}
+          addDangerToast={addDangerToast}
+          addSuccessToast={addSuccessToast}
+        />
+      )}
       {editDashboard && (
         <DashboardPropertiesModal
           dashboardId={editDashboard.id}
diff --git a/superset/charts/filters.py b/superset/charts/filters.py
index 8f4e8c36105..6c82f63d4da 100644
--- a/superset/charts/filters.py
+++ b/superset/charts/filters.py
@@ -16,6 +16,7 @@
 # under the License.
 from typing import Any
 
+from flask import current_app
 from flask_babel import lazy_gettext as _
 from sqlalchemy import and_, or_
 from sqlalchemy.orm import aliased
@@ -24,7 +25,6 @@ from sqlalchemy.orm.query import Query
 from superset import db, security_manager
 from superset.connectors.sqla import models
 from superset.connectors.sqla.models import SqlaTable
-from superset.folders.models import FolderObject, folder_editors, folder_viewers
 from superset.models.core import FavStar
 from superset.models.slice import Slice
 from superset.tags.filters import BaseTagIdFilter, BaseTagNameFilter
@@ -112,38 +112,15 @@ class ChartFilter(BaseFilter):  # pylint: disable=too-few-public-methods
             .filter(get_dataset_access_filters(self.model))
         )
 
-        # Folder access: charts accessible via folder viewer/editor membership
-        user_id = get_user_id()
-        folder_access_subquery = (
-            db.session.query(FolderObject.chart_id)
-            .join(
-                folder_viewers,
-                folder_viewers.c.folder_id == FolderObject.folder_id,
-                isouter=True,
-            )
-            .join(
-                folder_editors,
-                folder_editors.c.folder_id == FolderObject.folder_id,
-                isouter=True,
-            )
-            .filter(
-                FolderObject.chart_id.isnot(None),
-                or_(
-                    folder_viewers.c.user_id == user_id,
-                    folder_editors.c.user_id == user_id,
-                ),
-            )
-            .subquery()
-        )
+        conditions = [
+            self.model.id.in_(dataset_query.with_entities(self.model.id).subquery()),
+        ]
 
-        return query.filter(
-            or_(
-                self.model.id.in_(
-                    dataset_query.with_entities(self.model.id).subquery()
-                ),
-                self.model.id.in_(folder_access_subquery),
-            )
-        )
+        extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
+        if chart_filter := extra_filters.get("chart"):
+            conditions.append(chart_filter(query, self.model))
+
+        return query.filter(or_(*conditions))
 
 
 class ChartHasCreatedByFilter(BaseFilter):  # pylint: disable=too-few-public-methods
diff --git a/superset/commands/chart/create.py b/superset/commands/chart/create.py
index 5742fe8d386..86ddce3e41f 100644
--- a/superset/commands/chart/create.py
+++ b/superset/commands/chart/create.py
@@ -19,7 +19,7 @@ from datetime import datetime
 from functools import partial
 from typing import Any, Optional
 
-from flask import g
+from flask import current_app, g
 from flask_appbuilder.models.sqla import Model
 from marshmallow import ValidationError
 
@@ -58,7 +58,10 @@ class CreateChartCommand(CreateMixin, BaseCommand):
         self.validate()
         self._properties["last_saved_at"] = datetime.now()
         self._properties["last_saved_by"] = g.user
-        return ChartDAO.create(attributes=self._properties)
+        chart = ChartDAO.create(attributes=self._properties)
+        if after_create := current_app.config.get("AFTER_ASSET_CREATE"):
+            after_create(chart, "chart")
+        return chart
 
     def validate(self) -> None:
         exceptions = []
diff --git a/superset/commands/dashboard/create.py b/superset/commands/dashboard/create.py
index 3896594d56f..c2e0a4c4cc7 100644
--- a/superset/commands/dashboard/create.py
+++ b/superset/commands/dashboard/create.py
@@ -18,6 +18,7 @@ import logging
 from functools import partial
 from typing import Any, Optional
 
+from flask import current_app
 from flask_appbuilder.models.sqla import Model
 from marshmallow import ValidationError
 
@@ -52,6 +53,8 @@ class CreateDashboardCommand(CreateMixin, BaseCommand):
                 dashboard,
                 data=json.loads(json_metadata),
             )
+        if after_create := current_app.config.get("AFTER_ASSET_CREATE"):
+            after_create(dashboard, "dashboard")
         return dashboard
 
     def validate(self) -> None:
diff --git a/superset/commands/folder/assets.py b/superset/commands/folder/assets.py
index 86a020778ab..67231cd6b9a 100644
--- a/superset/commands/folder/assets.py
+++ b/superset/commands/folder/assets.py
@@ -20,17 +20,21 @@ from typing import Any, Optional
 
 from marshmallow import ValidationError
 
+from superset import security_manager
 from superset.commands.base import BaseCommand
 from superset.commands.folder.exceptions import (
     FolderAssetNotFoundValidationError,
     FolderAssetTypeValidationError,
+    FolderForbiddenError,
     FolderInvalidError,
     FolderNotFoundError,
     FolderUpdateFailedError,
 )
 from superset.daos.folder import FolderDAO
+from superset.daos.folder_permissions import FolderPermissionDAO
 from superset.folders.constants import asset_types_for_folder_type
 from superset.folders.models import Folder
+from superset.utils.core import get_user_id
 from superset.utils.decorators import on_error, transaction
 
 logger = logging.getLogger(__name__)
@@ -61,6 +65,62 @@ class UpdateFolderAssetsCommand(BaseCommand):
         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()
+
+        exceptions: list[ValidationError] = []
+        allowed = set(asset_types_for_folder_type(self._model.folder_type))
+        for asset in self._assets:
+            asset_type = asset["type"]
+            if asset_type not in allowed:
+                exceptions.append(
+                    FolderAssetTypeValidationError(asset_type, self._model.folder_type)
+                )
+            elif not FolderDAO.asset_exists(asset_type, asset["id"]):
+                exceptions.append(
+                    FolderAssetNotFoundValidationError(asset_type, asset["id"])
+                )
+
+        if exceptions:
+            raise FolderInvalidError(exceptions=exceptions)
+
+
+class AddFolderAssetsCommand(BaseCommand):
+    """Add assets to a folder (upsert).
+
+    Assets already in another folder are moved. Assets already in this folder
+    are left as-is. Unlike ``UpdateFolderAssetsCommand``, existing assets in
+    the folder that are not listed are **not** removed.
+    """
+
+    def __init__(self, folder_id_or_uuid: str, assets: list[dict[str, Any]]):
+        self._id = folder_id_or_uuid
+        self._assets = assets
+        self._model: Optional[Folder] = None
+
+    @transaction(on_error=partial(on_error, reraise=FolderUpdateFailedError))
+    def run(self) -> Folder:
+        self.validate()
+        assert self._model
+        FolderDAO.assign_assets(self._model, self._assets)
+        return self._model
+
+    def validate(self) -> None:
+        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()
+
         exceptions: list[ValidationError] = []
         allowed = set(asset_types_for_folder_type(self._model.folder_type))
         for asset in self._assets:
diff --git a/superset/commands/folder/delete.py b/superset/commands/folder/delete.py
index 985ddcd2705..52a23330c99 100644
--- a/superset/commands/folder/delete.py
+++ b/superset/commands/folder/delete.py
@@ -18,30 +18,42 @@ import logging
 from functools import partial
 from typing import Optional
 
+from superset import security_manager
 from superset.commands.base import BaseCommand
 from superset.commands.folder.exceptions import (
     FolderDeleteFailedError,
+    FolderForbiddenError,
     FolderNotFoundError,
 )
 from superset.daos.folder import FolderDAO
+from superset.daos.folder_permissions import FolderPermissionDAO
 from superset.folders.models import Folder
+from superset.utils.core import get_user_id
 from superset.utils.decorators import on_error, transaction
 
 logger = logging.getLogger(__name__)
 
 
 class DeleteFolderCommand(BaseCommand):
-    def __init__(self, folder_id_or_uuid: str):
+    def __init__(self, folder_id_or_uuid: str, archive_items: bool = False):
         self._id = folder_id_or_uuid
+        self._archive_items = archive_items
         self._model: Optional[Folder] = None
 
     @transaction(on_error=partial(on_error, reraise=FolderDeleteFailedError))
     def run(self) -> None:
         self.validate()
         assert self._model
-        FolderDAO.delete_folder(self._model)
+        FolderDAO.delete_folder(self._model, self._archive_items)
 
     def validate(self) -> None:
         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()
diff --git a/superset/commands/folder/exceptions.py b/superset/commands/folder/exceptions.py
index cbe3921234e..d71aa15bbc2 100644
--- a/superset/commands/folder/exceptions.py
+++ b/superset/commands/folder/exceptions.py
@@ -21,11 +21,16 @@ from superset.commands.exceptions import (
     CommandInvalidError,
     CreateFailedError,
     DeleteFailedError,
+    ForbiddenError,
     UpdateFailedError,
     ValidationError,
 )
 
 
+class FolderForbiddenError(ForbiddenError):
+    message = _("You don't have permission to access this folder.")
+
+
 class FolderInvalidError(CommandInvalidError):
     message = _("Folder parameters are invalid.")
 
diff --git a/superset/commands/folder/update.py b/superset/commands/folder/update.py
index 41fcb8283d0..b098261c200 100644
--- a/superset/commands/folder/update.py
+++ b/superset/commands/folder/update.py
@@ -20,9 +20,11 @@ from typing import Any, Optional
 
 from marshmallow import ValidationError
 
+from superset import db, security_manager
 from superset.commands.base import BaseCommand
 from superset.commands.folder.exceptions import (
     FolderCycleValidationError,
+    FolderForbiddenError,
     FolderInvalidError,
     FolderNameUniquenessValidationError,
     FolderNotFoundError,
@@ -31,7 +33,9 @@ from superset.commands.folder.exceptions import (
     FolderUpdateFailedError,
 )
 from superset.daos.folder import FolderDAO
+from superset.daos.folder_permissions import FolderPermissionDAO
 from superset.folders.models import Folder
+from superset.utils.core import get_user_id
 from superset.utils.decorators import on_error, transaction
 
 logger = logging.getLogger(__name__)
@@ -60,30 +64,62 @@ class UpdateFolderCommand(BaseCommand):
         if self._parent_changed:
             attributes["parent_id"] = self._new_parent_id
 
-        return FolderDAO.update(self._model, attributes)
+        folder = FolderDAO.update(self._model, attributes)
+
+        # Copy permissions from new parent when moving a folder
+        if self._parent_changed and self._new_parent_id is not None:
+            FolderPermissionDAO.copy_permissions_to_subfolder(
+                self._new_parent_id, folder.id
+            )
+
+        # Remove pins when a folder moves away from root
+        if self._parent_changed and self._new_parent_id is not None:
+            from superset.folders.models import FolderPin
+
+            db.session.query(FolderPin).filter(
+                FolderPin.object_id == folder.id,
+                FolderPin.object_type == "folder",
+            ).delete()
+
+        return folder
+
+    def _validate_parent(
+        self, exceptions: list[ValidationError]
+    ) -> int | None:
+        """Validate and resolve parent_uuid, returning the new parent_id."""
+        new_parent_id = self._model.parent_id  # type: ignore[union-attr]
+        if "parent_uuid" not in self._properties:
+            return new_parent_id
+
+        self._parent_changed = True
+        parent_uuid = self._properties["parent_uuid"]
+        parent: Optional[Folder] = None
+        if parent_uuid:
+            parent = FolderDAO.find_by_id_or_uuid(parent_uuid)
+            if parent is None:
+                exceptions.append(FolderParentNotFoundValidationError())
+            elif parent.folder_type != self._model.folder_type:  # type: ignore[union-attr]
+                exceptions.append(FolderParentTypeMismatchValidationError())
+            elif FolderDAO.is_descendant(parent, self._model):  # type: ignore[arg-type]
+                exceptions.append(FolderCycleValidationError())
+        new_parent_id = parent.id if parent else None
+        self._new_parent_id = new_parent_id
+        return new_parent_id
 
     def validate(self) -> None:
         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()
+
         exceptions: list[ValidationError] = []
-        new_parent_id = self._model.parent_id
-
-        if "parent_uuid" in self._properties:
-            self._parent_changed = True
-            parent_uuid = self._properties["parent_uuid"]
-            parent: Optional[Folder] = None
-            if parent_uuid:
-                parent = FolderDAO.find_by_id_or_uuid(parent_uuid)
-                if parent is None:
-                    exceptions.append(FolderParentNotFoundValidationError())
-                elif parent.folder_type != self._model.folder_type:
-                    exceptions.append(FolderParentTypeMismatchValidationError())
-                elif FolderDAO.is_descendant(parent, self._model):
-                    exceptions.append(FolderCycleValidationError())
-            new_parent_id = parent.id if parent else None
-            self._new_parent_id = new_parent_id
+        new_parent_id = self._validate_parent(exceptions)
 
         new_name = self._properties.get("name", self._model.name)
         if new_name != self._model.name or new_parent_id != self._model.parent_id:
diff --git a/superset/commands/utils.py b/superset/commands/utils.py
index 4441d9add37..3758570c569 100644
--- a/superset/commands/utils.py
+++ b/superset/commands/utils.py
@@ -19,7 +19,7 @@ from __future__ import annotations
 from collections import Counter
 from typing import Any, Optional, TYPE_CHECKING
 
-from flask import g
+from flask import current_app, g
 from flask_appbuilder.security.sqla.models import Role, User
 
 from superset import security_manager
@@ -58,12 +58,10 @@ def populate_owner_list(
     if not owner_ids and default_to_user:
         return [g.user]
     if not (security_manager.is_admin() or get_user_id() in owner_ids):
-        # make sure non-admins can't remove themselves as owner by mistake
-        # but don't auto-add folder editors who aren't actual owners
-        from superset.daos.folder_permissions import FolderPermissionDAO
-
-        user_id = get_user_id()
-        if not (user_id and FolderPermissionDAO.user_has_any_folder_editor_access(user_id)):
+        # Make sure non-admins can't remove themselves as owner by mistake.
+        # Skip auto-add when an EXTRA_OWNERS_RESOLVER is configured — the
+        # resolver handles access independently of the owners list.
+        if not current_app.config.get("EXTRA_OWNERS_RESOLVER"):
             owners.append(g.user)
     for owner_id in owner_ids:
         owner = security_manager.get_user_by_id(owner_id)
diff --git a/superset/config.py b/superset/config.py
index 6a040098792..7f7d5c47171 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -2544,6 +2544,31 @@ class ExtraDynamicQueryFilters(TypedDict, total=False):
 
 EXTRA_DYNAMIC_QUERY_FILTERS: ExtraDynamicQueryFilters = {}
 
+# ---------------------------------------------------------------------------
+# Extension hooks
+# ---------------------------------------------------------------------------
+# Additional filters applied to chart/dashboard list queries. Each callable
+# receives (query, model) and returns a modified query.
+# Example: {"chart": my_chart_filter, "dashboard": my_dashboard_filter}
+EXTRA_ACCESS_QUERY_FILTERS: dict[str, Callable[..., Any]] = {}
+
+# Callable that can bypass ``raise_for_access`` for specific assets. Receives
+# the keyword arguments passed to ``raise_for_access`` and returns ``True`` to
+# skip the default access check.
+EXTRA_RAISE_FOR_ACCESS_BYPASS: Callable[..., bool] | None = None
+
+# Resolve additional owners for an asset beyond the ``owners`` relationship.
+# Receives a model instance and returns a list of user objects. Used both for
+# API serialization (extra_owners) and ownership checks (is_owner). When
+# configured, the auto-add of the current user to ``owners`` on create is
+# skipped — the resolver is expected to handle access independently.
+EXTRA_OWNERS_RESOLVER: Callable[..., list[Any]] | None = None
+
+# Called after a chart or dashboard is created. Receives the model instance
+# and the asset type string ("chart" or "dashboard"). Runs inside the same
+# transaction — if it raises, the create is rolled back.
+AFTER_ASSET_CREATE: Callable[[Any, str], None] | None = None
+
 
 # The migrations that add catalog permissions might take a considerably long time
 # to execute as it has to create permissions to all schemas and catalogs from all
diff --git a/superset/daos/folder.py b/superset/daos/folder.py
index 28c937c14b7..fbc0d456f5a 100644
--- a/superset/daos/folder.py
+++ b/superset/daos/folder.py
@@ -27,12 +27,19 @@ from collections import defaultdict
 from datetime import datetime
 from typing import Any
 
-from sqlalchemy import func, literal, select, union_all
+from sqlalchemy import func, literal, or_, select, union_all
+from sqlalchemy.orm import joinedload
 
 from superset.daos.base import BaseDAO
 from superset.extensions import db
 from superset.folders.constants import ASSET_TYPE_CONFIGS, asset_types_for_folder_type
-from superset.folders.models import Folder, FolderObject, FolderPin
+from superset.folders.models import (
+    Folder,
+    folder_editors,
+    folder_viewers,
+    FolderObject,
+    FolderPin,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -129,13 +136,17 @@ class FolderDAO(BaseDAO[Folder]):
         ``UNION ALL`` over the included sources ordered by (folders first, then most
         recently changed), so callers get exactly one page plus the total count.
         """
-        from superset.connectors.sqla.models import SqlaTable  # noqa: F401
+        from superset import security_manager
+        from superset.connectors.sqla.models import SqlaTable
         from superset.models.dashboard import Dashboard
         from superset.models.slice import Slice
+        from superset.utils.core import get_user_id
 
         folder_id = folder.id if folder else None
         requested = set(types) if types else None
         asset_only_filter = bool(viz_types or datasets or owners)
+        is_admin = security_manager.is_admin()
+        user_id = get_user_id()
 
         def included(kind: str) -> bool:
             return requested is None or kind in requested
@@ -161,6 +172,18 @@ 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
+            if not is_admin and user_id:
+                accessible_folder_ids = (
+                    select(folder_viewers.c.folder_id)
+                    .where(folder_viewers.c.user_id == user_id)
+                    .union(
+                        select(folder_editors.c.folder_id).where(
+                            folder_editors.c.user_id == user_id
+                        )
+                    )
+                )
+                fq = fq.where(Folder.id.in_(accessible_folder_ids))
             selects.append(fq)
 
         # --- Assets (charts / dashboards for this folder type) ---
@@ -203,6 +226,82 @@ class FolderDAO(BaseDAO[Folder]):
                     aq = aq.where(Slice.viz_type.in_(viz_types))
                 if datasets:
                     aq = aq.where(Slice.datasource_id.in_(datasets))
+
+            # Access filter: non-admins only see assets they can access
+            if not is_admin and user_id:
+                from superset.connectors.sqla.models import (
+                    Database as ConnDatabase,
+                )
+                from superset.utils.filters import get_dataset_access_filters
+
+                access_conditions = []
+
+                # 1. User owns the asset
+                access_conditions.append(
+                    model.owners.any(cls._user_model().id == user_id)
+                )
+
+                # 2. User has folder membership for the asset
+                user_folder_ids = (
+                    select(folder_viewers.c.folder_id)
+                    .where(folder_viewers.c.user_id == user_id)
+                    .union(
+                        select(folder_editors.c.folder_id).where(
+                            folder_editors.c.user_id == user_id
+                        )
+                    )
+                )
+                folder_accessible = (
+                    select(fk_col)
+                    .select_from(FolderObject)
+                    .where(
+                        fk_col.isnot(None),
+                        FolderObject.folder_id.in_(user_folder_ids),
+                    )
+                )
+                access_conditions.append(model.id.in_(folder_accessible))
+
+                # 3. Chart: user has datasource access
+                can_access_all = security_manager.can_access_all_datasources()
+                if name == "chart" and not can_access_all:
+                    ds_accessible = (
+                        select(Slice.id)
+                        .join(
+                            SqlaTable,
+                            Slice.datasource_id == SqlaTable.id,
+                        )
+                        .join(
+                            ConnDatabase,
+                            SqlaTable.database_id == ConnDatabase.id,
+                        )
+                        .where(get_dataset_access_filters(Slice))
+                    )
+                    access_conditions.append(model.id.in_(ds_accessible))
+
+                # 4. Dashboard: published + user has datasource access
+                if name == "dashboard":
+                    if can_access_all:
+                        access_conditions.append(Dashboard.published.is_(True))
+                    else:
+                        published_accessible = (
+                            select(Dashboard.id)
+                            .join(Dashboard.slices, isouter=True)
+                            .join(
+                                SqlaTable,
+                                Slice.datasource_id == SqlaTable.id,
+                            )
+                            .join(
+                                ConnDatabase,
+                                SqlaTable.database_id == ConnDatabase.id,
+                            )
+                            .where(
+                                Dashboard.published.is_(True),
+                                get_dataset_access_filters(Slice, can_access_all),
+                            )
+                        )
+                        access_conditions.append(model.id.in_(published_accessible))
+
+                aq = aq.where(or_(*access_conditions))
             selects.append(aq)
 
         if not selects:
@@ -227,6 +326,11 @@ class FolderDAO(BaseDAO[Folder]):
         if ids_by_type.get("folder"):
             for obj in (
                 db.session.query(Folder)
+                .options(
+                    joinedload(Folder.parent),
+                    joinedload(Folder.created_by),
+                    joinedload(Folder.changed_by),
+                )
                 .filter(Folder.id.in_(ids_by_type["folder"]))
                 .all()
             ):
@@ -268,16 +372,26 @@ class FolderDAO(BaseDAO[Folder]):
     # Persistence
     # ------------------------------------------------------------------ #
     @classmethod
-    def delete_folder(cls, folder: Folder) -> None:
+    def delete_folder(cls, folder: Folder, archive_items: bool = False) -> None:
         """Delete a folder, re-parenting children to its parent.
 
-        Assets linked to the folder become unfoldered: the ``folder_objects``
-        rows are removed (ORM delete-orphan / DB ``ON DELETE CASCADE``) while the
-        assets themselves are untouched.
+        When ``archive_items`` is False (default), assets linked to the folder
+        become unfoldered. When True, the assets themselves are also deleted.
         """
         for child in list(folder.children):
             child.parent_id = folder.parent_id
         db.session.flush()
+
+        if archive_items:
+            for link in list(folder.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:
+                            db.session.delete(asset)
+                        break
+
         db.session.delete(folder)
 
     @classmethod
@@ -302,6 +416,12 @@ class FolderDAO(BaseDAO[Folder]):
                 setattr(link, config.fk_column, asset["id"])
                 db.session.add(link)
 
+            # Remove any pins for this asset since it's no longer at root
+            db.session.query(FolderPin).filter(
+                FolderPin.object_id == asset["id"],
+                FolderPin.object_type == asset["type"],
+            ).delete()
+
     @classmethod
     def set_assets(cls, folder: Folder, assets: list[dict[str, Any]]) -> None:
         """Set ``folder``'s membership to exactly ``assets``.
@@ -431,7 +551,6 @@ class FolderDAO(BaseDAO[Folder]):
 
     @classmethod
     def add_subject(cls, folder_id: int, user_id: int, permission: str) -> None:
-
         from superset.folders.models import folder_editors, folder_viewers
 
         if permission == "editor":
diff --git a/superset/daos/folder_permissions.py b/superset/daos/folder_permissions.py
index 6aa3877444b..2015cc8c004 100644
--- a/superset/daos/folder_permissions.py
+++ b/superset/daos/folder_permissions.py
@@ -15,19 +15,21 @@
 # specific language governing permissions and limitations
 # under the License.
 """Data access for folder permission operations."""
+
 from __future__ import annotations
 
 from typing import Any
 
-from sqlalchemy import and_, or_
+from sqlalchemy import and_
 
 from superset import db
 from superset.folders.models import (
     Folder,
-    FolderObject,
     folder_editors,
     folder_viewers,
+    FolderObject,
 )
+from superset.utils import json as json_utils
 
 
 class FolderPermissionDAO:
@@ -102,7 +104,9 @@ class FolderPermissionDAO:
     ) -> None:
         """Move a user between editors and viewers."""
         if permission not in ("editor", "viewer"):
-            raise ValueError(f"Invalid permission: {permission!r}. Must be 'editor' or 'viewer'.")
+            raise ValueError(
+                f"Invalid permission: {permission!r}. Must be 'editor' or 'viewer'."
+            )
         if permission == "editor":
             FolderPermissionDAO.remove_viewer(folder_id, user_id)
             FolderPermissionDAO.add_editor(folder_id, user_id)
@@ -120,13 +124,14 @@ class FolderPermissionDAO:
     # Permission inheritance
     # ------------------------------------------------------------------ #
     @staticmethod
-    def copy_permissions_to_subfolder(parent_folder_id: int, child_folder_id: int) -> None:
+    def copy_permissions_to_subfolder(
+        parent_folder_id: int, child_folder_id: int
+    ) -> None:
         """Copy all editors/viewers from parent to child folder.
 
         Called when a subfolder is created. Sets extra.inherits_permissions = true
         on the child.
         """
-        import json as json_lib
 
         parent_editors = db.session.execute(
             folder_editors.select().where(
@@ -153,10 +158,10 @@ class FolderPermissionDAO:
             )
 
         # Set inherits flag on child
-        if (child := db.session.query(Folder).get(child_folder_id)):
-            extra = json_lib.loads(child.extra) if child.extra else {}
+        if child := db.session.query(Folder).get(child_folder_id):
+            extra = json_utils.loads(child.extra) if child.extra else {}
             extra["inherits_permissions"] = True
-            child.extra = json_lib.dumps(extra)
+            child.extra = json_utils.dumps(extra)
 
         db.session.flush()
 
@@ -167,7 +172,6 @@ class FolderPermissionDAO:
         Called when a folder's permissions change. Only affects descendants
         where extra.inherits_permissions = true.
         """
-        import json as json_lib
 
         folder = db.session.query(Folder).get(folder_id)
         if not folder:
@@ -179,13 +183,11 @@ class FolderPermissionDAO:
         # Find all descendants that still inherit
         def _get_inheriting_descendants(parent_id: int) -> list[int]:
             children = (
-                db.session.query(Folder)
-                .filter(Folder.parent_id == parent_id)
-                .all()
+                db.session.query(Folder).filter(Folder.parent_id == parent_id).all()
             )
             result = []
             for child in children:
-                extra = json_lib.loads(child.extra) if child.extra else {}
+                extra = json_utils.loads(child.extra) if child.extra else {}
                 if extra.get("inherits_permissions", True):
                     result.append(child.id)
                     result.extend(_get_inheriting_descendants(child.id))
@@ -196,14 +198,10 @@ class FolderPermissionDAO:
         for desc_id in descendant_ids:
             # Clear existing permissions
             db.session.execute(
-                folder_editors.delete().where(
-                    folder_editors.c.folder_id == desc_id
-                )
+                folder_editors.delete().where(folder_editors.c.folder_id == desc_id)
             )
             db.session.execute(
-                folder_viewers.delete().where(
-                    folder_viewers.c.folder_id == desc_id
-                )
+                folder_viewers.delete().where(folder_viewers.c.folder_id == desc_id)
             )
             # Copy from parent
             for subject in current_subjects:
@@ -225,12 +223,11 @@ class FolderPermissionDAO:
     @staticmethod
     def mark_permissions_explicit(folder_id: int) -> None:
         """Mark a folder as having explicitly set permissions (no longer inheriting)."""
-        import json as json_lib
 
-        if (folder := db.session.query(Folder).get(folder_id)):
-            extra = json_lib.loads(folder.extra) if folder.extra else {}
+        if folder := db.session.query(Folder).get(folder_id):
+            extra = json_utils.loads(folder.extra) if folder.extra else {}
             extra["inherits_permissions"] = False
-            folder.extra = json_lib.dumps(extra)
+            folder.extra = json_utils.dumps(extra)
             db.session.flush()
 
     # ------------------------------------------------------------------ #
@@ -298,69 +295,38 @@ class FolderPermissionDAO:
             .first()
         ) is not None
 
+    @staticmethod
+    def _check_folder_object_access(user_id: int, filter_condition: Any) -> bool:
+        """Check if a matching FolderObject is in an accessible folder."""
+        fo = db.session.query(FolderObject).filter(filter_condition).first()
+        return bool(
+            fo and FolderPermissionDAO.user_has_folder_access(user_id, fo.folder_id)
+        )
+
     @staticmethod
     def user_has_folder_access_for_asset(
         user_id: int,
         dashboard_id: int | None = None,
         chart_id: int | None = None,
         datasource_id: int | None = None,
-        form_data: dict | None = None,
     ) -> bool:
         """Check if user has folder access that covers this asset.
 
         Checks multiple paths:
         1. Dashboard/chart is directly in a folder the user has access to
-        2. form_data contains dashboardId or slice_id pointing to a foldered asset
-        3. Datasource is used by a chart in a folder the user has access to
+        2. Datasource is used by a chart in a folder the user has access to
         """
-        from superset.models.slice import Slice as SliceModel
+        check = FolderPermissionDAO._check_folder_object_access
 
-        if dashboard_id:
-            fo = (
-                db.session.query(FolderObject)
-                .filter(FolderObject.dashboard_id == dashboard_id)
-                .first()
-            )
-            if fo and FolderPermissionDAO.user_has_folder_access(
-                user_id, fo.folder_id
-            ):
-                return True
-
-        if chart_id:
-            fo = (
-                db.session.query(FolderObject)
-                .filter(FolderObject.chart_id == chart_id)
-                .first()
-            )
-            if fo and FolderPermissionDAO.user_has_folder_access(
-                user_id, fo.folder_id
-            ):
-                return True
-
-        if form_data:
-            if sid := form_data.get("slice_id"):
-                fo = (
-                    db.session.query(FolderObject)
-                    .filter(FolderObject.chart_id == sid)
-                    .first()
-                )
-                if fo and FolderPermissionDAO.user_has_folder_access(
-                    user_id, fo.folder_id
-                ):
-                    return True
+        if dashboard_id and check(user_id, FolderObject.dashboard_id == dashboard_id):
+            return True
 
-            if did := form_data.get("dashboardId"):
-                fo = (
-                    db.session.query(FolderObject)
-                    .filter(FolderObject.dashboard_id == did)
-                    .first()
-                )
-                if fo and FolderPermissionDAO.user_has_folder_access(
-                    user_id, fo.folder_id
-                ):
-                    return True
+        if chart_id and check(user_id, FolderObject.chart_id == chart_id):
+            return True
 
         if datasource_id:
+            from superset.models.slice import Slice as SliceModel
+
             folder_objects = (
                 db.session.query(FolderObject)
                 .join(SliceModel, SliceModel.id == FolderObject.chart_id)
@@ -368,9 +334,7 @@ class FolderPermissionDAO:
                 .all()
             )
             for fo in folder_objects:
-                if FolderPermissionDAO.user_has_folder_access(
-                    user_id, fo.folder_id
-                ):
+                if FolderPermissionDAO.user_has_folder_access(user_id, fo.folder_id):
                     return True
 
         return False
@@ -389,9 +353,7 @@ class FolderPermissionDAO:
                 .first()
             )
             if fo:
-                return FolderPermissionDAO.user_is_folder_editor(
-                    user_id, fo.folder_id
-                )
+                return FolderPermissionDAO.user_is_folder_editor(user_id, fo.folder_id)
 
         if chart_id:
             fo = (
@@ -400,9 +362,7 @@ class FolderPermissionDAO:
                 .first()
             )
             if fo:
-                return FolderPermissionDAO.user_is_folder_editor(
-                    user_id, fo.folder_id
-                )
+                return FolderPermissionDAO.user_is_folder_editor(user_id, fo.folder_id)
 
         return False
 
diff --git a/superset/dashboards/filters.py b/superset/dashboards/filters.py
index f36e933f48d..f7c9310662c 100644
--- a/superset/dashboards/filters.py
+++ b/superset/dashboards/filters.py
@@ -16,7 +16,7 @@
 # under the License.
 from typing import Any, Optional
 
-from flask import g
+from flask import current_app, g
 from flask_appbuilder.security.sqla.models import Role
 from flask_babel import lazy_gettext as _
 from sqlalchemy import and_, or_
@@ -24,7 +24,6 @@ from sqlalchemy.orm.query import Query
 
 from superset import db, is_feature_enabled, security_manager
 from superset.connectors.sqla.models import SqlaTable
-from superset.folders.models import FolderObject, folder_editors, folder_viewers
 from superset.models.core import Database
 from superset.models.dashboard import Dashboard, is_uuid
 from superset.models.embedded_dashboard import EmbeddedDashboard
@@ -183,34 +182,17 @@ class DashboardAccessFilter(BaseFilter):  # pylint: disable=too-few-public-metho
 
             feature_flagged_filters.append(condition)
 
-        # Folder access: dashboards accessible via folder viewer/editor membership
-        folder_access_subquery = (
-            db.session.query(FolderObject.dashboard_id)
-            .join(
-                folder_viewers,
-                folder_viewers.c.folder_id == FolderObject.folder_id,
-                isouter=True,
-            )
-            .join(
-                folder_editors,
-                folder_editors.c.folder_id == FolderObject.folder_id,
-                isouter=True,
-            )
-            .filter(
-                FolderObject.dashboard_id.isnot(None),
-                or_(
-                    folder_viewers.c.user_id == get_user_id(),
-                    folder_editors.c.user_id == get_user_id(),
-                ),
-            )
-        )
+        extra_conditions = []
+        extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
+        if dashboard_filter := extra_filters.get("dashboard"):
+            extra_conditions.append(dashboard_filter(query, Dashboard))
 
         query = query.filter(
             or_(
                 Dashboard.id.in_(owner_ids_query),
                 Dashboard.id.in_(datasource_perm_query),
-                Dashboard.id.in_(folder_access_subquery),
                 *feature_flagged_filters,
+                *extra_conditions,
             )
         )
 
diff --git a/superset/folders/api.py b/superset/folders/api.py
index daaf839a276..f7fe8dfcbcb 100644
--- a/superset/folders/api.py
+++ b/superset/folders/api.py
@@ -26,19 +26,24 @@ from flask import request, Response
 from flask_appbuilder.api import expose, permission_name, protect, safe
 from marshmallow import ValidationError
 
-from superset.commands.folder.assets import UpdateFolderAssetsCommand
+from superset.commands.folder.assets import (
+    AddFolderAssetsCommand,
+    UpdateFolderAssetsCommand,
+)
 from superset.commands.folder.create import CreateFolderCommand
 from superset.commands.folder.delete import DeleteFolderCommand
 from superset.commands.folder.exceptions import (
     FolderCreateFailedError,
     FolderDeleteFailedError,
+    FolderForbiddenError,
     FolderInvalidError,
     FolderNotFoundError,
     FolderUpdateFailedError,
 )
 from superset.commands.folder.update import UpdateFolderCommand
 from superset.daos.folder import FolderDAO, ResolvedAsset
-from superset.extensions import db, event_logger
+from superset.daos.folder_permissions import FolderPermissionDAO
+from superset.extensions import event_logger
 from superset.folders.constants import ASSET_TYPE_CONFIGS, DEFAULT_FOLDER_TYPE
 from superset.folders.models import Folder
 from superset.folders.schemas import (
@@ -53,6 +58,7 @@ from superset.folders.schemas import (
     FolderRootResponseSchema,
     FolderSchema,
 )
+from superset.utils.decorators import transaction
 from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
 
 logger = logging.getLogger(__name__)
@@ -68,10 +74,18 @@ def _serialize_user(user: Any) -> dict[str, Any] | None:
     }
 
 
-def serialize_folder(folder: Folder) -> dict[str, Any]:
-    """Serialize a folder's metadata for API responses."""
+def serialize_folder(
+    folder: Folder,
+    children_count: int | None = None,
+    asset_count: int | None = None,
+) -> dict[str, Any]:
+    """Serialize a folder's metadata for API responses.
+
+    Accepts optional pre-computed counts to avoid N+1 queries. When not
+    provided, falls back to relationship access (acceptable for single-folder
+    responses but expensive in list views).
+    """
     return {
-        # Discriminates folder rows from asset rows in the unified contents list.
         "type": "folder",
         "id": folder.id,
         "uuid": str(folder.uuid),
@@ -80,13 +94,16 @@ def serialize_folder(folder: Folder) -> dict[str, Any]:
         "parent_uuid": str(folder.parent.uuid) if folder.parent else None,
         "folder_type": folder.folder_type,
         "is_private": folder.is_private,
-        "children_count": len(folder.children),
-        "asset_count": len(folder.objects),
+        "children_count": (
+            children_count if children_count is not None else len(folder.children)
+        ),
+        "asset_count": (
+            asset_count if asset_count is not None else len(folder.objects)
+        ),
         "created_on": folder.created_on,
         "changed_on": folder.changed_on,
         "created_by": _serialize_user(folder.created_by),
         "changed_by": _serialize_user(folder.changed_by),
-        # Folders have no owners; present for a uniform contents row shape.
         "owners": [],
     }
 
@@ -158,10 +175,10 @@ def _parse_contents_filters() -> dict[str, Any]:
         except ValueError:
             return None
 
-    def positive_int(key: str, default: int) -> int:
+    def positive_int(key: str, default: int, maximum: int = 100) -> int:
         raw = args.get(key)
         if raw is not None and raw.isdigit():
-            return int(raw)
+            return min(int(raw), maximum)
         return default
 
     return {
@@ -201,6 +218,36 @@ class FolderRestApi(BaseSupersetApi):
     edit_model_schema = FolderPutSchema()
     assets_model_schema = FolderAssetsPutSchema()
 
+    @staticmethod
+    def _raise_for_folder_access(folder: Folder) -> None:
+        """Raise FolderForbiddenError if user cannot view the folder."""
+        from superset import security_manager
+        from superset.daos.folder_permissions import FolderPermissionDAO
+        from superset.utils.core import get_user_id
+
+        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
+        ):
+            raise FolderForbiddenError()
+
+    @staticmethod
+    def _raise_for_folder_edit(folder: Folder) -> None:
+        """Raise FolderForbiddenError if user cannot edit the folder."""
+        from superset import security_manager
+        from superset.daos.folder_permissions import FolderPermissionDAO
+        from superset.utils.core import get_user_id
+
+        if security_manager.is_admin():
+            return
+        user_id = get_user_id()
+        if not user_id or not FolderPermissionDAO.user_is_folder_editor(
+            user_id, folder.id
+        ):
+            raise FolderForbiddenError()
+
     @expose("/", methods=("GET",))
     @protect()
     @safe
@@ -319,6 +366,10 @@ class FolderRestApi(BaseSupersetApi):
         folder = FolderDAO.get_by_uuid(folder_uuid)
         if not folder:
             return self.response_404()
+        try:
+            self._raise_for_folder_access(folder)
+        except FolderForbiddenError:
+            return self.response_403()
         return self.response(200, result=serialize_folder(folder))
 
     @expose("/<string:folder_uuid>/assets", methods=("GET",))
@@ -358,6 +409,10 @@ class FolderRestApi(BaseSupersetApi):
         folder = FolderDAO.get_by_uuid(folder_uuid)
         if not folder:
             return self.response_404()
+        try:
+            self._raise_for_folder_access(folder)
+        except FolderForbiddenError:
+            return self.response_403()
         filters = _parse_contents_filters()
         rows, count = FolderDAO.get_contents(folder, folder.folder_type, **filters)
         return self.response(
@@ -477,6 +532,8 @@ class FolderRestApi(BaseSupersetApi):
             return self.response(200, result=serialize_folder(folder))
         except FolderNotFoundError:
             return self.response_404()
+        except FolderForbiddenError:
+            return self.response_403()
         except FolderInvalidError as ex:
             return self.response(422, message=ex.normalized_messages())
         except FolderUpdateFailedError as ex:
@@ -522,11 +579,14 @@ class FolderRestApi(BaseSupersetApi):
             500:
               $ref: '#/components/responses/500'
         """
+        archive_items = request.args.get("archive_items", "").lower() == "true"
         try:
-            DeleteFolderCommand(folder_uuid).run()
+            DeleteFolderCommand(folder_uuid, archive_items).run()
             return self.response(200, message="OK")
         except FolderNotFoundError:
             return self.response_404()
+        except FolderForbiddenError:
+            return self.response_403()
         except FolderDeleteFailedError as ex:
             return self.response_422(message=str(ex))
 
@@ -590,104 +650,71 @@ class FolderRestApi(BaseSupersetApi):
             )
         except FolderNotFoundError:
             return self.response_404()
+        except FolderForbiddenError:
+            return self.response_403()
         except FolderInvalidError as ex:
             return self.response(422, message=ex.normalized_messages())
         except FolderUpdateFailedError as ex:
             return self.response_422(message=str(ex))
 
-    @expose(
-        "/<string:folder_uuid>/assets/<string:asset_type>/<int:asset_id>",
-        methods=("PUT",),
-    )
+    @expose("/<string:folder_uuid>/assets", methods=("POST",))
     @protect()
     @safe
     @permission_name("write")
     @statsd_metrics
-    def put_single_asset(
-        self, folder_uuid: str, asset_type: str, asset_id: int
-    ) -> Response:
-        """Move a single asset into a folder (upsert).
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post_assets",
+        log_to_statsd=False,
+    )
+    @requires_json
+    def post_assets(self, folder_uuid: str) -> Response:
+        """Add assets to a folder (upsert).
+
+        Assets already in another folder are moved. Existing assets in this
+        folder that are not listed are left untouched (unlike PUT which
+        replaces the full membership).
         ---
-        put:
-          summary: Move one asset into a folder
+        post:
+          summary: Add assets to a folder
           parameters:
           - in: path
             name: folder_uuid
             required: true
             schema:
               type: string
-          - in: path
-            name: asset_type
-            required: true
-            schema:
-              type: string
-              enum: [chart, dashboard, dataset]
-          - in: path
-            name: asset_id
+          requestBody:
             required: true
-            schema:
-              type: integer
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/FolderAssetsPutSchema'
           responses:
             200:
-              description: Asset moved
+              description: Assets added
+            400:
+              $ref: '#/components/responses/400'
+            403:
+              $ref: '#/components/responses/403'
             404:
               $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
         """
-        folder = FolderDAO.get_by_uuid(folder_uuid)
-        if not folder:
-            return self.response_404()
-        # Moving a folder into another folder = update parent_id
-        if asset_type == "folder":
-            from superset.folders.models import Folder
-
-            child_folder = db.session.query(Folder).filter(Folder.id == asset_id).first()
-            if not child_folder:
-                return self.response_404()
-            if child_folder.id == folder.id:
-                return self.response(400, message="Cannot move a folder into itself")
-            child_folder.parent_id = folder.id
-            # Copy parent's permissions to the moved folder
-            from superset.folders.models import folder_editors, folder_viewers
-
-            # Clear existing permissions on the child
-            db.session.execute(
-                folder_editors.delete().where(
-                    folder_editors.c.folder_id == child_folder.id
-                )
-            )
-            db.session.execute(
-                folder_viewers.delete().where(
-                    folder_viewers.c.folder_id == child_folder.id
-                )
-            )
-            # Copy from new parent
-            for row in db.session.execute(
-                folder_editors.select().where(
-                    folder_editors.c.folder_id == folder.id
-                )
-            ).fetchall():
-                db.session.execute(
-                    folder_editors.insert().values(
-                        folder_id=child_folder.id, user_id=row.user_id
-                    )
-                )
-            for row in db.session.execute(
-                folder_viewers.select().where(
-                    folder_viewers.c.folder_id == folder.id
-                )
-            ).fetchall():
-                db.session.execute(
-                    folder_viewers.insert().values(
-                        folder_id=child_folder.id, user_id=row.user_id
-                    )
-                )
-            db.session.commit()
+        try:
+            data = self.assets_model_schema.load(request.json)
+        except ValidationError as ex:
+            return self.response(400, message=ex.messages)
+        try:
+            AddFolderAssetsCommand(folder_uuid, data["assets"]).run()
             return self.response(200, message="OK")
-        if not FolderDAO.asset_exists(asset_type, asset_id):
+        except FolderNotFoundError:
             return self.response_404()
-        FolderDAO.assign_assets(folder, [{"type": asset_type, "id": asset_id}])
-        db.session.commit()
-        return self.response(200, message="OK")
+        except FolderForbiddenError:
+            return self.response_403()
+        except FolderInvalidError as ex:
+            return self.response(422, message=ex.normalized_messages())
+        except FolderUpdateFailedError as ex:
+            return self.response_422(message=str(ex))
 
     # ------------------------------------------------------------------ #
     # Pins
@@ -727,6 +754,7 @@ class FolderRestApi(BaseSupersetApi):
     @permission_name("write")
     @statsd_metrics
     @requires_json
+    @transaction()
     def post_pin(self) -> Response:
         """Pin an item.
         ---
@@ -752,32 +780,28 @@ class FolderRestApi(BaseSupersetApi):
             data = FolderPinPostSchema().load(request.json)
         except ValidationError as ex:
             return self.response(400, message=ex.messages)
-        try:
-            pin = FolderDAO.create_pin(
-                user_id=g.user.id,
-                object_id=data["object_id"],
-                object_type=data["object_type"],
-                position=data["position"],
-            )
-            db.session.commit()
-            return self.response(
-                201,
-                result={
-                    "id": pin.id,
-                    "object_id": pin.object_id,
-                    "object_type": pin.object_type,
-                    "position": pin.position,
-                },
-            )
-        except Exception as ex:
-            db.session.rollback()
-            return self.response(400, message=str(ex))
+        pin = FolderDAO.create_pin(
+            user_id=g.user.id,
+            object_id=data["object_id"],
+            object_type=data["object_type"],
+            position=data["position"],
+        )
+        return self.response(
+            201,
+            result={
+                "id": pin.id,
+                "object_id": pin.object_id,
+                "object_type": pin.object_type,
+                "position": pin.position,
+            },
+        )
 
     @expose("/pins/<int:pin_id>", methods=("DELETE",))
     @protect()
     @safe
     @permission_name("write")
     @statsd_metrics
+    @transaction()
     def delete_pin(self, pin_id: int) -> Response:
         """Unpin an item.
         ---
@@ -798,7 +822,6 @@ class FolderRestApi(BaseSupersetApi):
         from flask import g
 
         if FolderDAO.delete_pin(pin_id, g.user.id):
-            db.session.commit()
             return self.response(200, message="OK")
         return self.response_404()
 
@@ -830,6 +853,10 @@ class FolderRestApi(BaseSupersetApi):
         folder = FolderDAO.get_by_uuid(folder_uuid)
         if not folder:
             return self.response_404()
+        try:
+            self._raise_for_folder_access(folder)
+        except FolderForbiddenError:
+            return self.response_403()
         subjects = FolderDAO.get_subjects(folder.id)
         return self.response(200, result=subjects)
 
@@ -839,6 +866,7 @@ class FolderRestApi(BaseSupersetApi):
     @permission_name("write")
     @statsd_metrics
     @requires_json
+    @transaction()
     def post_subject(self, folder_uuid: str) -> Response:
         """Add a subject to a folder.
         ---
@@ -869,17 +897,17 @@ class FolderRestApi(BaseSupersetApi):
         folder = FolderDAO.get_by_uuid(folder_uuid)
         if not folder:
             return self.response_404()
+        try:
+            self._raise_for_folder_edit(folder)
+        except FolderForbiddenError:
+            return self.response_403()
         try:
             data = FolderSubjectPostSchema().load(request.json)
         except ValidationError as ex:
             return self.response(400, message=ex.messages)
-        try:
-            FolderDAO.add_subject(folder.id, data["user_id"], data["permission"])
-            db.session.commit()
-            return self.response(201, message="OK")
-        except Exception as ex:
-            db.session.rollback()
-            return self.response(400, message=str(ex))
+        FolderDAO.add_subject(folder.id, data["user_id"], data["permission"])
+        FolderPermissionDAO.push_down_permissions(folder.id)
+        return self.response(201, message="OK")
 
     @expose("/<folder_uuid>/subjects/<int:user_id>", methods=("PUT",))
     @protect()
@@ -887,6 +915,7 @@ class FolderRestApi(BaseSupersetApi):
     @permission_name("write")
     @statsd_metrics
     @requires_json
+    @transaction()
     def put_subject(self, folder_uuid: str, user_id: int) -> Response:
         """Update a subject's permission level.
         ---
@@ -922,12 +951,16 @@ class FolderRestApi(BaseSupersetApi):
         folder = FolderDAO.get_by_uuid(folder_uuid)
         if not folder:
             return self.response_404()
+        try:
+            self._raise_for_folder_edit(folder)
+        except FolderForbiddenError:
+            return self.response_403()
         try:
             data = FolderSubjectPutSchema().load(request.json)
         except ValidationError as ex:
             return self.response(400, message=ex.messages)
         FolderDAO.update_subject(folder.id, user_id, data["permission"])
-        db.session.commit()
+        FolderPermissionDAO.push_down_permissions(folder.id)
         return self.response(200, message="OK")
 
     @expose("/<folder_uuid>/subjects/<int:user_id>", methods=("DELETE",))
@@ -935,6 +968,7 @@ class FolderRestApi(BaseSupersetApi):
     @safe
     @permission_name("write")
     @statsd_metrics
+    @transaction()
     def delete_subject(self, folder_uuid: str, user_id: int) -> Response:
         """Remove a subject from a folder.
         ---
@@ -960,6 +994,10 @@ class FolderRestApi(BaseSupersetApi):
         folder = FolderDAO.get_by_uuid(folder_uuid)
         if not folder:
             return self.response_404()
+        try:
+            self._raise_for_folder_edit(folder)
+        except FolderForbiddenError:
+            return self.response_403()
         FolderDAO.remove_subject(folder.id, user_id)
-        db.session.commit()
+        FolderPermissionDAO.push_down_permissions(folder.id)
         return self.response(200, message="OK")
diff --git a/superset/folders/models.py b/superset/folders/models.py
index 01cf366f2e8..e87117a6dfc 100644
--- a/superset/folders/models.py
+++ b/superset/folders/models.py
@@ -23,6 +23,7 @@ from sqlalchemy import (
     Column,
     DateTime,
     ForeignKey,
+    Index,
     Integer,
     String,
     Table,
@@ -54,6 +55,7 @@ folder_editors = Table(
         nullable=False,
     ),
     UniqueConstraint("folder_id", "user_id"),
+    Index("ix_folder_editors_user_id", "user_id"),
 )
 
 # Junction table: folder viewers (users who can see folder and its assets)
@@ -74,6 +76,7 @@ folder_viewers = Table(
         nullable=False,
     ),
     UniqueConstraint("folder_id", "user_id"),
+    Index("ix_folder_viewers_user_id", "user_id"),
 )
 
 
@@ -133,6 +136,9 @@ class FolderObject(Model):
             " + CASE WHEN dataset_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
             name="ck_folder_objects_exactly_one_fk",
         ),
+        Index("ix_folder_objects_folder_id", "folder_id"),
+        Index("ix_folder_objects_chart_id", "chart_id"),
+        Index("ix_folder_objects_dashboard_id", "dashboard_id"),
     )
 
     id = Column(Integer, primary_key=True)
diff --git a/superset/security/manager.py b/superset/security/manager.py
index 6d050687f7c..e5e127f56fe 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -2924,19 +2924,22 @@ class SupersetSecurityManager(  # pylint: disable=too-many-public-methods
         # pylint: disable=import-outside-toplevel
         from superset import is_feature_enabled
         from superset.connectors.sqla.models import SqlaTable
-        from superset.daos.folder_permissions import FolderPermissionDAO
         from superset.models.dashboard import Dashboard
         from superset.models.slice import Slice
         from superset.models.sql_lab import Query
         from superset.utils.core import shortid
 
-        # Folder access bypass: if the user has folder access to the asset,
-        # skip all permission checks (including datasource).
-        user_id = get_user_id()
-        if user_id and 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,
+        # Extension hook: allow deployments to bypass access checks for
+        # specific assets (e.g. based on external permission systems).
+        bypass_fn = current_app.config.get("EXTRA_RAISE_FOR_ACCESS_BYPASS")
+        if bypass_fn and bypass_fn(
+            dashboard=dashboard,
+            chart=chart,
+            database=database,
+            datasource=datasource,
+            query=query,
+            query_context=query_context,
+            viz=viz,
         ):
             return
 
@@ -3136,14 +3139,6 @@ class SupersetSecurityManager(  # pylint: disable=too-many-public-methods
 
             assert datasource
 
-            # Folder access bypass for datasource checks
-            if user_id and FolderPermissionDAO.user_has_folder_access_for_asset(
-                user_id=user_id,
-                datasource_id=datasource.id if datasource else None,
-                form_data=form_data,
-            ):
-                return
-
             if not (
                 self.can_access_schema(datasource)
                 or self.can_access("datasource_access", datasource.perm or "")
@@ -3820,24 +3815,11 @@ class SupersetSecurityManager(  # pylint: disable=too-many-public-methods
         owners = orig_resource.owners if hasattr(orig_resource, "owners") else []
 
         if g.user.is_anonymous or g.user not in owners:
-            # Folder editor bypass
-            from superset.daos.folder_permissions import FolderPermissionDAO
-
-            user_id = g.user.id if not g.user.is_anonymous else None
-            if user_id:
-                dashboard_id = (
-                    resource.id if resource.__tablename__ == "dashboards" else None
-                )
-                chart_id = (
-                    resource.id if resource.__tablename__ == "slices" else None
-                )
-                if (dashboard_id or chart_id) and (
-                    FolderPermissionDAO.user_is_folder_editor_for_asset(
-                        user_id,
-                        dashboard_id=dashboard_id,
-                        chart_id=chart_id,
-                    )
-                ):
+            # Extension hook: check if the user is an extra owner
+            resolver = current_app.config.get("EXTRA_OWNERS_RESOLVER")
+            if resolver and not g.user.is_anonymous:
+                extra_owners = resolver(orig_resource)
+                if g.user in extra_owners:
                     return
 
             raise SupersetSecurityException(