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(