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