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