Compare commits

...
Author SHA1 Message Date
Claude Code 1f0ccab62d fix(daos): correct broken import of current_user_can_modify_object
superset/daos/tag.py imported current_user_can_modify_object from
superset.commands.tag.utils, which has never defined or re-exported it -
the function only exists in superset.commands.utils. This broke app boot
entirely, since superset.commands.utils itself imports TagDAO, so any
request through ChartRestApi -> commands/chart/create.py ->
commands/utils.py -> daos/tag.py hit the bad import at module load time.

Importing it directly from commands.utils at module level would create a
circular import (commands.utils -> daos.tag -> commands.utils), so the
import is deferred to call time in create_tag_relationship, the one
place daos/tag.py needs it - matching the existing deferred-import
pattern used elsewhere in this codebase for the same reason.

Introduced by #43390.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 09:11:59 -07:00
7f1b41444c fix: add missing access checks to semantic-layer and theme endpoints (#43389)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 08:52:45 -07:00
8a32324184 fix: add missing ownership checks to tag, report-log, and dataset-schema endpoints (#43390)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 08:44:22 -07:00
Joe Li 4623d9219a fix: update in the wild link on community page (#43403) 2026-08-24 22:28:19 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dcac129b51 chore(deps): bump js-yaml from 5.2.3 to 5.3.0 in /docs (#43442)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:17:39 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7196ce8a0c chore(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#43445)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:16:57 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e7756aeef9 chore(deps): bump immer from 11.1.16 to 11.1.17 in /superset-frontend (#43448)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:16:27 +07:00
DanielSwift1992 af859717a7 chore: remove two stale codecov ignore paths (#43438) 2026-08-24 22:15:57 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8fa48d73f6 chore(deps-dev): bump concurrently from 10.0.4 to 10.0.5 in /superset-frontend (#43449)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:04:38 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 90a3d93002 chore(deps): bump dayjs from 1.11.21 to 1.11.22 in /superset-frontend (#43450)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:04:09 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5ffb72151e chore(deps): bump react-error-boundary from 6.1.2 to 6.1.3 in /superset-frontend (#43451)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 22:03:04 +07:00
Enzo Martellucci c3ed8b312d fix(security): surface extra_editors in dashboard/chart lists (#43392) 2026-08-24 11:49:29 +02:00
Elizabeth Thompson 9f505eb0cb fix(examples): replace deprecated timeseries_limit_metric with series_limit_metric (#43422) 2026-08-22 15:02:20 -07:00
66 changed files with 2578 additions and 147 deletions
-2
View File
@@ -3,9 +3,7 @@ codecov:
after_n_builds: 4
ignore:
- "superset/migrations/versions/*.py"
- "superset-frontend/packages/superset-ui-demo/**/*"
- "**/*.stories.tsx"
- "**/*.stories.jsx"
coverage:
status:
project:
+1 -1
View File
@@ -93,7 +93,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Copy image to GHCR
env:
+1 -1
View File
@@ -66,7 +66,7 @@
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
"js-yaml": "^5.2.3",
"js-yaml": "^5.3.0",
"json-bigint": "^1.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
+1 -1
View File
@@ -67,7 +67,7 @@ const communityLinks = [
'Join our monthly virtual meetups and register for any upcoming events on Meetup',
},
{
url: 'https://github.com/apache/superset/blob/master/RESOURCES/INTHEWILD.md',
url: 'https://superset.apache.org/inTheWild/',
title: 'Organizations',
description:
'A list of some of the organizations using Superset in production.',
+4 -4
View File
@@ -10291,10 +10291,10 @@ js-yaml@4.1.0, js-yaml@=4.3.1, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, j
dependencies:
argparse "^2.0.1"
js-yaml@^5.2.3:
version "5.2.3"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.2.3.tgz#0942ae8f507e22eb0e54624871789cd477106e54"
integrity sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==
js-yaml@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.3.0.tgz#526430a6da31065127528ae695ce168cfc5f91f0"
integrity sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==
dependencies:
argparse "^2.0.1"
+23 -26
View File
@@ -86,7 +86,7 @@
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.21",
"dayjs": "^1.11.22",
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
@@ -100,7 +100,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.2",
"immer": "^11.1.16",
"immer": "^11.1.17",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
"js-levenshtein": "^1.1.6",
@@ -220,7 +220,7 @@
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
@@ -17190,9 +17190,9 @@
"license": "MIT"
},
"node_modules/concurrently": {
"version": "10.0.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz",
"integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==",
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
"integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -18541,9 +18541,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"version": "1.11.22",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.22.tgz",
"integrity": "sha512-1YRnxzt/AabP3GHxnaB9/b+ZScCKu5TeF+co+BWG+lnWVIwEcTFc1FVE0WLNmNO3sA6GGXL40i5qkHfbLzpwrg==",
"license": "MIT"
},
"node_modules/debounce": {
@@ -23907,9 +23907,9 @@
"license": "MIT"
},
"node_modules/immer": {
"version": "11.1.16",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz",
"integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==",
"version": "11.1.17",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.17.tgz",
"integrity": "sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -34873,12 +34873,18 @@
}
},
"node_modules/react-error-boundary": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.2.tgz",
"integrity": "sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==",
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.3.tgz",
"integrity": "sha512-GnSKpCohFi2nQmJCWwP8O8wub7zexlePvpsejvQr35vS5RTouS1+utTNOmyc540yw5vyOXnSL1rBWsCQDmkyUA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-google-recaptcha": {
@@ -42962,7 +42968,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dayjs": "^1.11.22",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
@@ -42974,7 +42980,7 @@
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.7.1",
"react-error-boundary": "^6.1.2",
"react-error-boundary": "^6.1.3",
"react-js-cron": "^6.0.2",
"react-markdown": "^10.1.0",
"react-resize-detector": "^7.1.2",
@@ -43073,15 +43079,6 @@
"node": ">=12"
}
},
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"packages/superset-ui-core/node_modules/react-ace": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
+3 -3
View File
@@ -163,7 +163,7 @@
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.21",
"dayjs": "^1.11.22",
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
@@ -177,7 +177,7 @@
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.2",
"immer": "^11.1.16",
"immer": "^11.1.17",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
"js-levenshtein": "^1.1.6",
@@ -297,7 +297,7 @@
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
@@ -67,7 +67,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dayjs": "^1.11.22",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
@@ -79,7 +79,7 @@
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.7.1",
"react-error-boundary": "^6.1.2",
"react-error-boundary": "^6.1.3",
"react-js-cron": "^6.0.2",
"react-markdown": "^10.1.0",
"react-resize-detector": "^7.1.2",
@@ -259,6 +259,21 @@ describe('isUserEditorOrAdmin', () => {
test('returns false when editors is omitted', () => {
expect(isUserEditorOrAdmin(outsiderUser)).toEqual(false);
});
test('returns true when the user is granted editorship only through extra_editors', () => {
expect(isUserEditorOrAdmin(editorUser, [], [10])).toEqual(true);
});
test('unions editors and extra_editors rather than preferring one', () => {
const nonMatchingSubject: Subject = { id: 999, label: 'Other', type: 1 };
expect(isUserEditorOrAdmin(editorUser, [nonMatchingSubject], [10])).toEqual(
true,
);
});
test('returns false when extra_editors names other subjects', () => {
expect(isUserEditorOrAdmin(editorUser, [], [999])).toEqual(false);
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -55,9 +55,6 @@ export const isUserInSubjects = (
);
};
const isUserInEditors = (editors: Subject[] = []): boolean =>
isUserInSubjects(editors);
export const isUserAdmin = (
user?: UserWithPermissionsAndRoles | UndefinedUser,
) =>
@@ -66,10 +63,12 @@ export const isUserAdmin = (
role => role.toLowerCase() === ADMIN_ROLE_NAME.toLowerCase(),
);
/** `extraEditors` is editorship granted via a deployment's EXTRA_EDITORS_RESOLVER. */
export const isUserEditorOrAdmin = (
user?: UserWithPermissionsAndRoles | UndefinedUser,
editors: Subject[] = [],
): boolean => isUserInEditors(editors) || isUserAdmin(user);
extraEditors?: SubjectRef[] | null,
): boolean => isUserInSubjects(editors, extraEditors) || isUserAdmin(user);
/**
* Editorship of *dashboard*, matching the server's `is_editor`: the explicit
@@ -96,7 +96,11 @@ export default function ChartCard({
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
const canExport = hasPerm('can_export');
const allowEdit = isUserEditorOrAdmin(user, chart.editors);
const allowEdit = isUserEditorOrAdmin(
user,
chart.editors,
chart.extra_editors,
);
const menuItems: MenuItem[] = [];
if (canEdit) {
@@ -83,7 +83,11 @@ function DashboardCard({
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
const canExport = hasPerm('can_export');
const allowEdit = isUserEditorOrAdmin(user, dashboard.editors);
const allowEdit = isUserEditorOrAdmin(
user,
dashboard.editors,
dashboard.extra_editors,
);
const digest = dashboard.changed_on_utc || dashboard.changed_on;
const thumbnailUrl =
isFeatureEnabled(FeatureFlag.Thumbnails) && dashboard.id && digest
@@ -650,7 +650,11 @@ function ChartList(props: ChartListProps) {
},
{
Cell: ({ row: { original } }: CellProps<Chart>) => {
const allowEdit = isUserEditorOrAdmin(user, original.editors);
const allowEdit = isUserEditorOrAdmin(
user,
original.editors,
original.extra_editors,
);
const openEditModal = () => openChartEditModal(original);
const handleExport = () => handleBulkChartExport([original]);
if (!canEdit && !canDelete && !canExport) {
@@ -122,6 +122,8 @@ export interface Dashboard {
description?: string;
thumbnail_url?: string | null;
editors?: Subject[];
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
extra_editors?: number[];
viewers?: Subject[];
tags: TagType[];
created_by: object;
@@ -505,7 +507,11 @@ function DashboardList(props: DashboardListProps) {
},
{
Cell: ({ row: { original } }: CellProps<Dashboard>) => {
const allowEdit = isUserEditorOrAdmin(user, original.editors);
const allowEdit = isUserEditorOrAdmin(
user,
original.editors,
original.extra_editors,
);
const handleDelete = () =>
handleDashboardDelete(
original,
+2
View File
@@ -45,6 +45,8 @@ export interface Chart {
cache_timeout: number | null;
thumbnail_url?: string;
editors?: Subject[];
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
extra_editors?: number[];
viewers?: Subject[];
tags?: TagType[];
last_saved_at?: string;
@@ -67,6 +67,8 @@ export interface Dashboard {
url: string;
thumbnail_url?: string | null;
editors?: Subject[];
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
extra_editors?: number[];
viewers?: Subject[];
loading?: boolean;
}
+13 -1
View File
@@ -92,7 +92,10 @@ from superset.exceptions import (
)
from superset.extensions import event_logger, security_manager
from superset.models.slice import Slice
from superset.security.manager import get_extra_editor_subject_ids
from superset.security.manager import (
get_extra_editor_subject_ids,
get_extra_editors_by_pk,
)
from superset.subjects.filters import (
FilterRelatedSubjects,
subject_type_filter,
@@ -410,6 +413,15 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
except ChartNotFoundError:
return self.response_404()
def pre_get_list(self, data: dict[str, Any]) -> None:
"""Attach ``extra_editors`` to each row, matching the single-object GET."""
super().pre_get_list(data)
ids = data.get("ids", [])
extra_editors_by_id = get_extra_editors_by_pk(Slice, ids)
for row, row_id in zip(data.get("result", []), ids, strict=False):
if row_id in extra_editors_by_id:
row["extra_editors"] = extra_editors_by_id[row_id]
@expose("/<pk>/deck_layers/", methods=("GET",))
@protect()
@safe
+7 -1
View File
@@ -29,7 +29,9 @@ from superset.commands.semantic_layer.exceptions import (
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticViewCreateFailedError,
SemanticViewForbiddenError,
)
from superset.commands.utils import current_user_can_modify_object
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
from superset.semantic_layers.registry import registry
from superset.utils import json
@@ -92,9 +94,13 @@ class CreateSemanticViewCommand(BaseCommand):
def validate(self) -> None:
layer_uuid: str = self._properties.get("semantic_layer_uuid", "")
if not SemanticLayerDAO.find_by_uuid(layer_uuid):
layer = SemanticLayerDAO.find_by_uuid(layer_uuid)
if not layer:
raise SemanticLayerNotFoundError()
if not current_user_can_modify_object(layer):
raise SemanticViewForbiddenError()
name: str = self._properties.get("name", "")
configuration: dict[str, Any] = self._properties.get("configuration") or {}
if not SemanticViewDAO.validate_uniqueness(name, layer_uuid, configuration):
+9 -10
View File
@@ -21,17 +21,17 @@ from functools import partial
from sqlalchemy.exc import SQLAlchemyError
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerDeleteFailedError,
SemanticLayerForbiddenError,
SemanticLayerNotFoundError,
SemanticViewDeleteFailedError,
SemanticViewForbiddenError,
SemanticViewNotFoundError,
)
from superset.commands.utils import current_user_can_modify_object
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
from superset.exceptions import SupersetSecurityException
from superset.semantic_layers.models import SemanticLayer, SemanticView
from superset.utils.decorators import on_error, transaction
@@ -60,6 +60,9 @@ class DeleteSemanticLayerCommand(BaseCommand):
if not self._model:
raise SemanticLayerNotFoundError()
if not current_user_can_modify_object(self._model):
raise SemanticLayerForbiddenError()
class DeleteSemanticViewCommand(BaseCommand):
def __init__(self, pk: int):
@@ -82,10 +85,8 @@ class DeleteSemanticViewCommand(BaseCommand):
self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
if not self._model:
raise SemanticViewNotFoundError()
try:
security_manager.raise_for_editorship(self._model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex
if not current_user_can_modify_object(self._model):
raise SemanticViewForbiddenError()
class BulkDeleteSemanticViewCommand(BaseCommand):
@@ -109,7 +110,5 @@ class BulkDeleteSemanticViewCommand(BaseCommand):
if len(self._models) != len(self._model_ids):
raise SemanticViewNotFoundError()
for model in self._models:
try:
security_manager.raise_for_editorship(model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex
if not current_user_can_modify_object(model):
raise SemanticViewForbiddenError()
+7 -6
View File
@@ -23,9 +23,9 @@ from typing import Any
from flask_appbuilder.models.sqla import Model
from sqlalchemy.exc import SQLAlchemyError
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerForbiddenError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticLayerUpdateFailedError,
@@ -33,8 +33,8 @@ from superset.commands.semantic_layer.exceptions import (
SemanticViewNotFoundError,
SemanticViewUpdateFailedError,
)
from superset.commands.utils import current_user_can_modify_object
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
from superset.exceptions import SupersetSecurityException
from superset.semantic_layers.models import SemanticLayer, SemanticView
from superset.semantic_layers.registry import registry
from superset.utils import json
@@ -66,10 +66,8 @@ class UpdateSemanticViewCommand(BaseCommand):
if not self._model:
raise SemanticViewNotFoundError()
try:
security_manager.raise_for_editorship(self._model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex
if not current_user_can_modify_object(self._model):
raise SemanticViewForbiddenError()
name = self._properties.get("name", self._model.name)
layer_uuid = str(self._model.semantic_layer_uuid)
@@ -116,6 +114,9 @@ class UpdateSemanticLayerCommand(BaseCommand):
if not self._model:
raise SemanticLayerNotFoundError()
if not current_user_can_modify_object(self._model):
raise SemanticLayerForbiddenError()
name = self._properties.get("name")
if name and not SemanticLayerDAO.validate_update_uniqueness(self._uuid, name):
raise SemanticLayerInvalidError(f"Name already exists: {name}")
+2 -5
View File
@@ -21,11 +21,8 @@ from typing import Any
from superset import security_manager
from superset.commands.base import BaseCommand, CreateMixin
from superset.commands.tag.exceptions import TagCreateFailedError, TagInvalidError
from superset.commands.tag.utils import (
current_user_can_modify_object,
to_object_model,
to_object_type,
)
from superset.commands.tag.utils import to_object_model, to_object_type
from superset.commands.utils import current_user_can_modify_object
from superset.daos.tag import TagDAO
from superset.exceptions import SupersetSecurityException
from superset.tags.models import ObjectType, TagType
+42 -7
View File
@@ -18,19 +18,22 @@ import logging
from functools import partial
from typing import Any
from marshmallow import ValidationError
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.commands.exceptions import TagNotFoundValidationError
from superset.commands.tag.exceptions import (
TagDeleteFailedError,
TagDeleteForbiddenValidationError,
TaggedObjectDeleteFailedError,
TaggedObjectNotFoundError,
TagInvalidError,
TagNotFoundError,
)
from superset.commands.tag.utils import to_object_model, to_object_type
from superset.daos.tag import TagDAO
from superset.exceptions import SupersetSecurityException
from superset.tags.models import ObjectType
from superset.tags.models import ObjectType, TagType
from superset.utils.decorators import on_error, transaction
from superset.views.base import DeleteMixin
@@ -134,10 +137,42 @@ class DeleteTagsCommand(DeleteMixin, BaseCommand):
TagDAO.delete_tags(self._tags)
def validate(self) -> None:
exceptions = []
# Validate tag exists
for tag in self._tags:
if not TagDAO.find_by_name(tag):
exceptions.append(TagNotFoundError(tag))
# Every item appended here must be a ValidationError (or subclass),
# since TagInvalidError.normalized_messages() calls
# .normalized_messages() on each one to build the aggregated 422
# response.
exceptions: list[ValidationError] = []
for tag_name in self._tags:
tag_name = tag_name.strip()
tag = TagDAO.find_by_name(tag_name)
# Validate tag exists
if not tag:
exceptions.append(
TagNotFoundValidationError(f"Tag with name {tag_name} not found.")
)
continue
# System-generated tags (type:*, editor:*, favorited_by:*) are
# maintained by Superset itself and must not be deletable through
# the bulk route.
if tag.type is not None and tag.type != TagType.custom:
exceptions.append(
TagDeleteForbiddenValidationError(
f"Tag {tag_name} is a system tag and cannot be deleted"
)
)
continue
# Deleting a tag cascades removal of all of its associations
# org-wide, so existence is not enough: require the user to be an
# admin or the tag's creator (the single-association route
# enforces per-object access in DeleteTaggedObjectCommand).
if not (
security_manager.is_admin()
or (tag.created_by and tag.created_by == security_manager.current_user)
):
exceptions.append(
TagDeleteForbiddenValidationError(
f"Access denied to tag {tag_name}"
)
)
if exceptions:
raise TagInvalidError(exceptions=exceptions)
+13
View File
@@ -17,6 +17,7 @@
from typing import Optional
from flask_babel import lazy_gettext as _
from marshmallow import ValidationError
from superset.commands.exceptions import (
CommandException,
@@ -44,6 +45,18 @@ class TagDeleteFailedError(DeleteFailedError):
message = _("Tag could not be deleted.")
class TagDeleteForbiddenValidationError(ValidationError):
"""A tag exists but may not be deleted (a system-generated tag, or the
caller lacks ownership/admin rights). Unlike ``TagDeleteFailedError``,
this is a ``ValidationError`` so it can be composited into a
``TagInvalidError`` alongside other validation failures and still
support ``CommandInvalidError.normalized_messages()``.
"""
def __init__(self, message: str) -> None:
super().__init__(message, field_name="tags")
class TaggedObjectDeleteFailedError(DeleteFailedError):
message = _("Tagged Object could not be deleted.")
+2 -5
View File
@@ -22,11 +22,8 @@ from flask_appbuilder.models.sqla import Model
from superset import db
from superset.commands.base import BaseCommand, UpdateMixin
from superset.commands.tag.exceptions import TagInvalidError, TagNotFoundError
from superset.commands.tag.utils import (
current_user_can_modify_object,
to_object_model,
to_object_type,
)
from superset.commands.tag.utils import to_object_model, to_object_type
from superset.commands.utils import current_user_can_modify_object
from superset.daos.tag import TagDAO
from superset.tags.models import Tag
from superset.utils.decorators import transaction
-21
View File
@@ -17,11 +17,9 @@
from typing import Any, Optional, Union
from superset import security_manager
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.daos.query import SavedQueryDAO
from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.models.sql_lab import SavedQuery
@@ -52,22 +50,3 @@ def to_object_model(
return DatasetDAO.find_by_id(object_id, skip_base_filter=skip_base_filter)
return None
def current_user_can_modify_object(model: Any) -> bool:
"""Whether the current user may create/modify tag relationships on ``model``.
Mirrors the editorship check the bulk-create path already applies, or the
object's creator, so the tag-update path enforces the same boundary.
Look the model up with
``skip_base_filter=True`` before calling this, so an object the user cannot
access reaches the check instead of resolving to ``None`` and being written
without any check.
"""
try:
security_manager.raise_for_editorship(model)
return True
except SupersetSecurityException:
return bool(
model.created_by and model.created_by == security_manager.current_user
)
+12
View File
@@ -44,6 +44,18 @@ def import_theme(config: dict[str, Any], overwrite: bool = False) -> "Theme | No
if existing:
if not overwrite or not can_write:
return existing
if existing.is_system:
raise ThemeImportError("Cannot overwrite a system theme via import")
# The active system-default/dark theme slot may be overwritten by
# admins only; a non-admin overwriting it would change the theme
# rendered for every user, including the login page and other
# admins.
if (
existing.is_system_default or existing.is_system_dark
) and not security_manager.is_admin():
raise ThemeImportError(
"Cannot overwrite the active system-default/dark theme via import"
)
config["id"] = existing.id
elif not can_write:
raise ThemeImportError(
+10
View File
@@ -18,8 +18,10 @@ import logging
from functools import partial
from typing import Any, Optional
from superset import security_manager
from superset.commands.base import UpdateMixin
from superset.commands.theme.exceptions import (
SystemThemeInUseError,
SystemThemeProtectedError,
ThemeNotFoundError,
)
@@ -52,3 +54,11 @@ class UpdateThemeCommand(UpdateMixin):
# Check if it's a system theme
if self._model.is_system:
raise SystemThemeProtectedError()
# The active system-default/dark theme slot may be edited by admins
# only; a non-admin editing it would change the theme rendered for
# every user, including the login page and other admins.
if (
self._model.is_system_default or self._model.is_system_dark
) and not security_manager.is_admin():
raise SystemThemeInUseError()
+25
View File
@@ -32,6 +32,7 @@ from superset.commands.exceptions import (
from superset.daos.datasource import DatasourceDAO
from superset.daos.exceptions import DatasourceNotFound
from superset.daos.tag import TagDAO
from superset.exceptions import SupersetSecurityException
from superset.subjects.exceptions import SubjectsNotFoundValidationError
from superset.subjects.models import Subject
from superset.subjects.utils import (
@@ -52,6 +53,30 @@ def _has_extra_editors_resolver() -> bool:
return bool(has_app_context() and current_app.config.get("EXTRA_EDITORS_RESOLVER"))
def current_user_can_modify_object(model: Any) -> bool:
"""Whether the current user is authorized to create/modify ``model``.
Delegates to ``security_manager.raise_for_editorship``, which grants
access to admins and any subject in ``model.editors`` (when that
relationship exists). For models that don't carry an ``editors``
relationship, or when the current subject isn't one of them, this falls
back to allowing the object's creator.
Callers that need to distinguish "not found" from "no access" should
look the model up bypassing DAO base filters (e.g.
``skip_base_filter=True``) before calling this, so an object the user
cannot access reaches the check instead of resolving to ``None`` and
being written without any check.
"""
try:
security_manager.raise_for_editorship(model)
return True
except SupersetSecurityException:
return bool(
model.created_by and model.created_by == security_manager.current_user
)
def populate_subject_list(
subject_ids: list[int] | None,
default_to_user: bool,
+3 -1
View File
@@ -213,7 +213,9 @@ def orderby_from_form_data(
# The drag-and-drop "sort by" control persists a list; the frontend unwraps it
# with ``ensureIsArray(...)[0]`` (``plugin-chart-table/src/buildQuery.ts:67``).
# Read raw, a list would nest inside ``orderby`` and fail the query.
raw_sort_metric = form_data.get("timeseries_limit_metric")
raw_sort_metric = form_data.get("series_limit_metric") or form_data.get(
"timeseries_limit_metric"
)
sort_metric = (
next(iter(as_list(raw_sort_metric)), None) if raw_sort_metric else None
) or (metrics[0] if form_data.get("sort_by_metric") else None)
+19 -1
View File
@@ -21,7 +21,7 @@ from flask import g
from sqlalchemy.exc import NoResultFound
from superset.commands.tag.exceptions import TagNotFoundError
from superset.commands.tag.utils import to_object_type
from superset.commands.tag.utils import to_object_model, to_object_type
from superset.daos.base import BaseDAO
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
@@ -345,6 +345,12 @@ class TagDAO(BaseDAO[Tag]):
Returns:
None.
"""
# Deferred: superset.commands.utils imports TagDAO, so a module-level
# import here would be circular.
from superset.commands.utils import ( # pylint: disable=import-outside-toplevel
current_user_can_modify_object,
)
tagged_objects = []
if not tag:
raise TagNotFoundError()
@@ -372,6 +378,18 @@ class TagDAO(BaseDAO[Tag]):
if not bulk_create:
# delete relationships that aren't retained from single tag create
for object_type, object_id in tagged_objects_to_delete:
# Only remove associations from objects the current user may
# modify, mirroring the per-object check applied to additions.
# Look the object up bypassing the access base filter so an
# inaccessible object reaches the check instead of resolving
# to None and having its association deleted unchecked.
model = to_object_model(
object_type, # type: ignore
object_id,
skip_base_filter=True,
)
if model and not current_user_can_modify_object(model):
continue
# delete objects that were removed
TagDAO.delete_tagged_object(
object_type, # type: ignore
+13 -1
View File
@@ -142,7 +142,10 @@ from superset.extensions import event_logger, security_manager
from superset.models.dashboard import Dashboard
from superset.models.embedded_dashboard import EmbeddedDashboard
from superset.security.guest_token import GuestUser
from superset.security.manager import get_extra_editor_subject_ids
from superset.security.manager import (
get_extra_editor_subject_ids,
get_extra_editors_by_pk,
)
from superset.subjects.filters import (
FilterRelatedSubjects,
subject_type_filter,
@@ -433,6 +436,15 @@ class DashboardRestApi(
"""
return super().get_list(**kwargs)
def pre_get_list(self, data: dict[str, Any]) -> None:
"""Attach ``extra_editors`` to each row, matching the single-object GET."""
super().pre_get_list(data)
ids = data.get("ids", [])
extra_editors_by_id = get_extra_editors_by_pk(Dashboard, ids)
for row, row_id in zip(data.get("result", []), ids, strict=False):
if row_id in extra_editors_by_id:
row["extra_editors"] = extra_editors_by_id[row_id]
list_select_columns = list_columns + ["changed_on", "created_on", "changed_by_fk"]
order_columns = [
"changed_by.first_name",
+18 -3
View File
@@ -34,7 +34,6 @@ from superset import security_manager
from superset.connectors.sqla.models import SqlaTable
from superset.exceptions import SupersetMarshmallowValidationError
from superset.models.sql_types import parse_currency_string
from superset.subjects.schemas import SubjectResponseSchema
from superset.utils import json
get_delete_ids_schema = {
@@ -480,16 +479,32 @@ class DatasetColumnDrillInfoSchema(Schema):
class UserSchema(Schema):
# Deliberately excludes ``email``: drill_info is reachable by any user
# with read access to the dataset (and, via the dashboard fallback, by
# embedded guests), so exposing maintainer emails here would leak user
# PII across an access boundary. Mirrors the dashboard/RLS user schemas,
# which expose names only.
first_name = fields.String()
last_name = fields.String()
email = fields.String()
class DrillInfoEditorSchema(Schema):
# Deliberately excludes ``secondary_label``: for a user-backed Subject,
# user-subject synchronization (superset.subjects.sync.sync_user_subject)
# stores that user's email in this field, so including it here would
# leak the same maintainer PII that ``UserSchema`` above excludes
# ``email`` to avoid, just through a different field name.
id = fields.Int()
label = fields.String()
img = fields.String()
type = fields.Integer()
class DatasetDrillInfoSchema(Schema):
id = fields.Integer()
columns = fields.List(fields.Nested(DatasetColumnDrillInfoSchema))
table_name = fields.String()
editors = fields.List(fields.Nested(SubjectResponseSchema))
editors = fields.List(fields.Nested(DrillInfoEditorSchema))
created_by = fields.Nested(UserSchema)
created_on_humanized = fields.String()
changed_by = fields.Nested(UserSchema)
+4 -4
View File
@@ -290,7 +290,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
groupby=["name"],
adhoc_filters=[gen_filter("gender", "girl")],
row_limit=50,
timeseries_limit_metric=metric,
series_limit_metric=metric,
metrics=[metric],
),
editors=[],
@@ -321,7 +321,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
groupby=["name"],
adhoc_filters=[gen_filter("gender", "boy")],
row_limit=50,
timeseries_limit_metric=metric,
series_limit_metric=metric,
metrics=[metric],
),
editors=[],
@@ -498,7 +498,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
viz_type="echarts_timeseries_line",
granularity_sqla="ds",
groupby=["name"],
timeseries_limit_metric={
series_limit_metric={
"expressionType": "SIMPLE",
"column": {
"column_name": "num_california",
@@ -522,7 +522,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
metrics=metrics,
groupby=["name"],
row_limit=50,
timeseries_limit_metric={
series_limit_metric={
"expressionType": "SIMPLE",
"column": {
"column_name": "num_california",
@@ -36,8 +36,8 @@ params:
metrics:
- sum__num
row_limit: 50
series_limit_metric: sum__num
time_range: '100 years ago : now'
timeseries_limit_metric: sum__num
viz_type: table
query_context: null
slice_name: Boys
@@ -36,8 +36,8 @@ params:
metrics:
- sum__num
row_limit: 50
series_limit_metric: sum__num
time_range: '100 years ago : now'
timeseries_limit_metric: sum__num
viz_type: table
query_context: null
slice_name: Girls
+29 -1
View File
@@ -22,7 +22,7 @@ from sqlalchemy.orm.query import Query
from superset import db, security_manager
from superset.daos.base import _escape_like
from superset.reports.models import ReportSchedule
from superset.reports.models import ReportExecutionLog, ReportSchedule
from superset.subjects.filters import subject_relation_exists_for_current_user
from superset.views.base import BaseFilter
@@ -43,6 +43,34 @@ class ReportScheduleFilter(BaseFilter): # pylint: disable=too-few-public-method
return query.filter(ReportSchedule.id.in_(editor_ids_query))
class ReportExecutionLogFilter(BaseFilter): # pylint: disable=too-few-public-methods
"""
Scope execution logs to report schedules the user can edit, mirroring
``ReportScheduleFilter`` on the schedule API. Logs carry evaluated alert
values and database error messages, so they must not be readable across
ownership boundaries via an attacker-chosen schedule id.
The unrestricted bypass is gated by ``can_access_all_queries`` rather
than ``can_access_all_datasources``: the latter is also granted to
stock Alpha, which would let a non-editor Alpha user read every other
schedule's evaluated values and database errors. ``can_access_all_queries``
is the admin-only permission this codebase already uses to gate the
equivalent per-execution data on SQL Lab query history
(see ``superset.queries.filters.QueryFilter``).
"""
def apply(self, query: Query, value: Any) -> Query:
if security_manager.can_access_all_queries():
return query
from superset.subjects.models import report_schedule_editors
editor_ids_query = db.session.query(
report_schedule_editors.c.report_schedule_id
).filter(subject_relation_exists_for_current_user(report_schedule_editors))
return query.filter(ReportExecutionLog.report_schedule_id.in_(editor_ids_query))
class ReportScheduleAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods
name = _("All Text")
arg_name = "report_all_text"
+8
View File
@@ -31,6 +31,7 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface
from superset import is_feature_enabled
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.reports.filters import ReportExecutionLogFilter
from superset.reports.logs.schemas import openapi_spec_methods_override
from superset.reports.models import ReportExecutionLog
from superset.views.base_api import BaseSupersetModelRestApi
@@ -53,6 +54,13 @@ class ReportExecutionLogRestApi(BaseSupersetModelRestApi):
class_permission_name = "ReportSchedule"
resource_name = "report"
allow_browser_login = True
# Ownership scoping. Without a base filter the only scoping on these
# routes is the caller-controlled ``pk`` path parameter (folded into the
# rison filters below), so any role with ReportSchedule read could
# iterate every schedule's logs deployment-wide. Mirrors the
# ReportScheduleFilter applied by the schedule API
# (superset/reports/api.py).
base_filters = [["id", ReportExecutionLogFilter, lambda: []]]
show_columns = [
"id",
+15
View File
@@ -40,6 +40,7 @@ from superset.commands.dashboard.embedded.exceptions import (
EmbeddedDashboardNotFoundError,
)
from superset.commands.exceptions import ForbiddenError
from superset.constants import RouteMethod
from superset.exceptions import SupersetGenericErrorException
from superset.extensions import db, event_logger
from superset.security.guest_token import (
@@ -423,6 +424,20 @@ class UserRegistrationsRestAPI(BaseSupersetModelRestApi):
resource_name = "security/user_registrations"
datamodel = SQLAInterface(RegisterUser)
allow_browser_login = True
# POST/PUT are intentionally excluded: restricting the exposed routes
# keeps the FAB default create/update handlers from ever being
# registered, so a mis-granted role cannot silently alter a pending
# registration. DELETE is kept: the User Registrations admin page
# deletes pending registrations through this route, and the class is
# gated Admin-only via ADMIN_ONLY_VIEW_MENUS (keyed on the view-menu
# name, i.e. every permission on this class, not just specific ones),
# so exposing it does not grant non-Admin roles anything.
include_route_methods = {
RouteMethod.GET,
RouteMethod.GET_LIST,
RouteMethod.INFO,
RouteMethod.DELETE,
}
# NOTE: registration_hash is intentionally excluded from both list_columns
# and search_columns. It is a bearer token for the
# /register/activation/<hash> flow; exposing it in API responses (and thus
+35
View File
@@ -170,6 +170,36 @@ def get_extra_editor_subject_ids(resource: Model) -> list[int]:
return subject_ids
def get_extra_editors_by_pk(
model_cls: type[Model], primary_keys: list[Any]
) -> dict[Any, list[int]]:
"""
Resolve extra editor subject IDs for a batch of resources, keyed by
primary key. List responses only have serialized rows, not model
instances, so this re-queries the page's rows in one batched query.
"""
if not primary_keys or not (
has_app_context() and current_app.config.get("EXTRA_EDITORS_RESOLVER")
):
return {}
# pylint: disable=import-outside-toplevel
from superset import db
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
pk_col = inspect(model_cls).primary_key[0]
resources = (
db.session.query(model_cls)
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {model_cls}})
.filter(pk_col.in_(primary_keys))
.all()
)
return {
getattr(resource, pk_col.name): get_extra_editor_subject_ids(resource)
for resource in resources
}
def _render_permission_instructions_link(
*,
datasource_id: str = "",
@@ -1698,6 +1728,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"CssTemplate",
"Dataset",
"Datasource",
"Theme",
} | READ_ONLY_MODEL_VIEWS
GAMMA_EXCLUDED_PVMS = {
@@ -1722,6 +1753,10 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"Security",
"SQL Lab",
"User Registrations",
# REST counterpart of the FAB "User Registrations" views. FAB derives
# the view-menu name from the class name; without this entry
# _is_gamma_pvm grants its permissions to stock Gamma and Alpha.
"UserRegistrationsRestAPI",
"User's Statistics",
# Guarding all AB_ADD_SECURITY_API = True REST APIs
"RoleRestAPI",
+37
View File
@@ -41,6 +41,7 @@ from superset.commands.semantic_layer.delete import (
from superset.commands.semantic_layer.exceptions import (
SemanticLayerCreateFailedError,
SemanticLayerDeleteFailedError,
SemanticLayerForbiddenError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticLayerUpdateFailedError,
@@ -210,6 +211,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
description: Semantic view structure
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
@@ -338,6 +341,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
errors.append(
{"name": view_data.get("name"), "error": "Semantic layer not found"}
)
except SemanticViewForbiddenError as ex:
errors.append({"name": view_data.get("name"), "error": str(ex)})
except SemanticViewCreateFailedError as ex:
logger.error(
"Error creating semantic view: %s",
@@ -447,6 +452,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
description: Semantic view deleted
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
@@ -665,6 +672,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
description: Runtime JSON Schema
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
"""
@@ -672,6 +681,11 @@ class SemanticLayerRestApi(BaseSupersetApi):
if not layer:
return self.response_404()
try:
layer.raise_for_access()
except SupersetSecurityException as ex:
return self.response(403, message=ex.message)
body = request.get_json(silent=True) or {}
runtime_data = body.get("runtime_data")
@@ -716,6 +730,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
description: Available views
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
"""
@@ -726,6 +742,11 @@ class SemanticLayerRestApi(BaseSupersetApi):
if not layer:
return self.response_404()
try:
layer.raise_for_access()
except SupersetSecurityException as ex:
return self.response(403, message=ex.message)
body = request.get_json(silent=True) or {}
runtime_data = body.get("runtime_data", {})
@@ -855,6 +876,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
@@ -870,6 +893,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
return self.response(200, result={"uuid": str(changed_model.uuid)})
except SemanticLayerNotFoundError:
return self.response_404()
except SemanticLayerForbiddenError as ex:
return self.response(403, message=str(ex))
except SemanticLayerInvalidError as ex:
return self.response_422(message=str(ex))
except SemanticLayerUpdateFailedError as ex:
@@ -899,6 +924,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
description: Semantic layer deleted
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
@@ -909,6 +936,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
return self.response(200, message="OK")
except SemanticLayerNotFoundError:
return self.response_404()
except SemanticLayerForbiddenError as ex:
return self.response(403, message=str(ex))
except SemanticLayerDeleteFailedError as ex:
logger.error(
"Error deleting semantic layer: %s",
@@ -1152,10 +1181,18 @@ class SemanticLayerRestApi(BaseSupersetApi):
description: A semantic layer
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
"""
layer = SemanticLayerDAO.find_by_uuid(uuid)
if not layer:
return self.response_404()
try:
layer.raise_for_access()
except SupersetSecurityException as ex:
return self.response(403, message=ex.message)
return self.response(200, result=_serialize_layer(layer))
+20
View File
@@ -148,6 +148,26 @@ class SemanticLayer(AuditMixinNullable, Model):
"""Compute the permission string for this semantic layer."""
return f"[{self.name}](id:{self.uuid.hex})"
def raise_for_access(self) -> None:
"""Check that the user has access to this semantic layer."""
from superset import security_manager
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
if security_manager.can_access_all_datasources():
return
if self.perm and security_manager.can_access("datasource_access", self.perm):
return
raise SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
message=str(_("You don't have access to this semantic layer.")),
level=ErrorLevel.ERROR,
)
)
@staticmethod
def after_insert(
mapper: Mapper,
+58
View File
@@ -486,6 +486,64 @@ class TagRestApi(BaseSupersetModelRestApi):
)
return self.response_422(message=str(ex))
@expose("/<pk>", methods=("DELETE",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete",
log_to_statsd=False,
)
def delete(self, pk: int) -> Response:
"""Deletes a Tag
---
delete:
description: >-
Delete a Tag by id. This will remove all tagged objects with
this tag.
parameters:
- in: path
schema:
type: integer
name: pk
responses:
200:
description: Tag deleted
content:
application/json:
schema:
type: object
properties:
message:
type: string
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
# Overrides the FAB-generated single-object delete route (which would
# otherwise call self.datamodel.delete directly, bypassing
# DeleteTagsCommand's ownership and system-tag checks) so both the
# single-object and bulk-delete routes share the same validation.
tag = TagDAO.find_by_id(pk)
if not tag:
return self.response_404()
try:
DeleteTagsCommand([tag.name]).run()
return self.response(200, message="OK")
except TagNotFoundError:
return self.response_404()
except TagInvalidError as ex:
return self.response_422(message=ex.normalized_messages())
except TagDeleteFailedError as ex:
return self.response_422(message=str(ex))
@expose("/", methods=("DELETE",))
@protect()
@safe
+2
View File
@@ -350,6 +350,8 @@ class ThemeRestApi(BaseSupersetModelRestApi):
return self.response_404()
except SystemThemeProtectedError:
return self.response_403()
except SystemThemeInUseError:
return self.response_403()
except Exception as ex:
logger.exception("Unexpected error in PUT /theme/%s", pk)
return self.response_422(message=str(ex))
+13 -1
View File
@@ -626,9 +626,21 @@ def sanitize_svg_content(svg_content: str) -> str:
return ""
# Minimal protection: remove obvious malicious content, preserve all SVG features
# The closing tag pattern tolerates attributes/whitespace after "script"
# (e.g. "</script foo>"), which browsers still parse as a valid closer.
content = re.sub(
r"<script[^>]*>.*?</script>", "", svg_content, flags=re.IGNORECASE | re.DOTALL
r"<script\b[^>]*>.*?</script\b[^>]*>",
"",
svg_content,
flags=re.IGNORECASE | re.DOTALL,
)
# Second pass: an unterminated <script ...> opener has no matching
# closer, so browsers treat everything after it as script content
# through end-of-file. Drop the opener and the remainder of the
# content with it, rather than leaving the payload text behind.
content = re.sub(r"<script\b[^>]*>.*", "", content, flags=re.IGNORECASE | re.DOTALL)
# Drop any orphaned closing </script ...> fragment too.
content = re.sub(r"</script\b[^>]*>?", "", content, flags=re.IGNORECASE)
content = re.sub(r"javascript:", "", content, flags=re.IGNORECASE)
content = re.sub(r"data:[^;]*;[^,]*,.*javascript", "", content, flags=re.IGNORECASE)
@@ -922,6 +922,55 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
db.session.delete(dashboard)
db.session.commit()
def test_get_dashboards_list_omits_extra_editors_by_default(self):
"""No EXTRA_EDITORS_RESOLVER configured: list rows omit extra_editors."""
admin = self.get_user("admin")
dashboard = self.insert_dashboard(
"no_extra_editors_list_dashboard",
"no-extra-editors-list-dashboard",
[admin.id],
)
try:
self.login(ADMIN_USERNAME)
rv = self.client.get("api/v1/dashboard/")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
row = next(
d
for d in data["result"]
if d["dashboard_title"] == dashboard.dashboard_title
)
assert "extra_editors" not in row
finally:
db.session.delete(dashboard)
db.session.commit()
@with_config({"EXTRA_EDITORS_RESOLVER": lambda resource: [123]})
def test_get_dashboards_list_includes_extra_editors_when_resolver_configured(
self,
):
"""List rows get extra_editors too, mirroring the single-object GET."""
admin = self.get_user("admin")
dashboard = self.insert_dashboard(
"extra_editors_list_dashboard",
"extra-editors-list-dashboard",
[admin.id],
)
try:
self.login(ADMIN_USERNAME)
rv = self.client.get("api/v1/dashboard/")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
row = next(
d
for d in data["result"]
if d["dashboard_title"] == dashboard.dashboard_title
)
assert row["extra_editors"] == [123]
finally:
db.session.delete(dashboard)
db.session.commit()
def test_get_charts_admin_sees_existing_charts(self):
"""Regression for #25890: GET /api/v1/chart/ as an Admin user should
return existing charts, not an empty list."""
@@ -944,6 +993,41 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
db.session.delete(chart)
db.session.commit()
def test_get_charts_list_omits_extra_editors_by_default(self):
"""No EXTRA_EDITORS_RESOLVER configured: list rows omit extra_editors."""
admin = self.get_user("admin")
chart = self.insert_chart(
"no_extra_editors_list_chart", [admin.id], 1, params="{}"
)
try:
self.login(ADMIN_USERNAME)
rv = self.client.get("api/v1/chart/")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
row = next(c for c in data["result"] if c["slice_name"] == chart.slice_name)
assert "extra_editors" not in row
finally:
db.session.delete(chart)
db.session.commit()
@with_config({"EXTRA_EDITORS_RESOLVER": lambda resource: [123]})
def test_get_charts_list_includes_extra_editors_when_resolver_configured(self):
"""List rows get extra_editors too, mirroring the single-object GET."""
admin = self.get_user("admin")
chart = self.insert_chart(
"extra_editors_list_chart", [admin.id], 1, params="{}"
)
try:
self.login(ADMIN_USERNAME)
rv = self.client.get("api/v1/chart/")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
row = next(c for c in data["result"] if c["slice_name"] == chart.slice_name)
assert row["extra_editors"] == [123]
finally:
db.session.delete(chart)
db.session.commit()
def test_get_dashboards_filter(self):
"""
Dashboard API: Test get dashboards filter
@@ -3421,6 +3421,73 @@ class TestDatasetApi(SupersetTestCase):
self.items_to_delete = [dataset]
def test_get_drill_info_does_not_expose_user_emails(self):
"""
Dataset API: drill_info must not leak creator/modifier email addresses.
The nested user schema exposes first/last name only; email is PII and
is not part of the endpoint's select_columns contract.
"""
self.login(ADMIN_USERNAME)
dataset = self.insert_dataset(
table_name="test_drill_dataset_no_email",
editor_user_ids=[],
columns=[
TableColumn(
column_name="category",
type="VARCHAR(255)",
groupby=True,
),
],
fetch_metadata=False,
)
uri = f"api/v1/dataset/{dataset.id}/drill_info/"
rv = self.get_assert_metric(uri, "get_drill_info")
assert rv.status_code == 200
result = json.loads(rv.data.decode("utf-8"))["result"]
for user_field in ("created_by", "changed_by"):
assert "email" not in (result.get(user_field) or {})
self.items_to_delete = [dataset]
def test_get_drill_info_does_not_expose_editor_emails(self):
"""
Dataset API: drill_info must not leak an editor's email address
through the ``editors`` list.
User-subject synchronization stores a user's email in the Subject's
``secondary_label`` field, and ``editors`` nests Subjects directly,
so email must be excluded the same way it is for created_by/changed_by.
"""
self.login(ADMIN_USERNAME)
gamma_user = self.get_user(GAMMA_USERNAME)
dataset = self.insert_dataset(
table_name="test_drill_dataset_no_editor_email",
editor_user_ids=[gamma_user.id],
columns=[
TableColumn(
column_name="category",
type="VARCHAR(255)",
groupby=True,
),
],
fetch_metadata=False,
)
uri = f"api/v1/dataset/{dataset.id}/drill_info/"
rv = self.get_assert_metric(uri, "get_drill_info")
assert rv.status_code == 200
result = json.loads(rv.data.decode("utf-8"))["result"]
editors = result.get("editors") or []
assert len(editors) == 1
assert "secondary_label" not in editors[0]
assert gamma_user.email not in json.dumps(editors)
self.items_to_delete = [dataset]
def test_get_drill_info_admin_user_dataset_not_found(self):
"""
Dataset API: Test drill_info endpoint returns 404 for non-existent dataset.
+43
View File
@@ -570,6 +570,49 @@ class TestTagApi(InsertChartMixin, SupersetTestCase):
tags = db.session.query(Tag).filter(Tag.name.in_(example_tag_names))
assert tags.count() == 0
@pytest.mark.usefixtures("create_tags")
def test_delete_tag_by_pk(self):
"""
Tag API: the single-object ``DELETE /api/v1/tag/<pk>`` route must
share the same ownership/system-tag validation as bulk_delete
(DeleteTagsCommand), not the FAB-generated model delete.
"""
tag = db.session.query(Tag).filter(Tag.name == "example_tag_1").one()
system_tag = Tag(name="system:pk_delete_example", type=TagType.type)
db.session.add(system_tag)
db.session.commit()
try:
# a non-admin, non-creator user may not delete via the pk route
self.login(GAMMA_USERNAME)
rv = self.client.delete(f"api/v1/tag/{tag.id}", follow_redirects=True)
assert rv.status_code == 422
assert db.session.query(Tag).filter(Tag.id == tag.id).count() == 1
# system-generated tags are refused outright, even for an admin
self.logout()
self.login(ADMIN_USERNAME)
rv = self.client.delete(
f"api/v1/tag/{system_tag.id}", follow_redirects=True
)
assert rv.status_code == 422
assert db.session.query(Tag).filter(Tag.id == system_tag.id).count() == 1
# an admin may delete a custom tag via the pk route
rv = self.client.delete(f"api/v1/tag/{tag.id}", follow_redirects=True)
assert rv.status_code == 200
assert db.session.query(Tag).filter(Tag.id == tag.id).count() == 0
finally:
db.session.query(Tag).filter(Tag.id == system_tag.id).delete()
db.session.commit()
def test_delete_tag_by_pk_not_found(self):
self.login(ADMIN_USERNAME)
existing_ids = [tag_id for (tag_id,) in db.session.query(Tag.id).all()]
non_existent_id = max(existing_ids, default=0) + 1
rv = self.client.delete(f"api/v1/tag/{non_existent_id}", follow_redirects=True)
assert rv.status_code == 404
@pytest.mark.usefixtures("create_tags")
def test_delete_favorite_tag(self):
self.login(ADMIN_USERNAME)
+49 -1
View File
@@ -33,13 +33,14 @@ from superset.commands.exceptions import CommandInvalidError # noqa: F401
from superset.commands.importers.exceptions import IncorrectVersionError # noqa: F401
from superset.commands.tag.create import CreateCustomTagCommand
from superset.commands.tag.delete import DeleteTaggedObjectCommand, DeleteTagsCommand
from superset.commands.tag.exceptions import TagInvalidError
from superset.connectors.sqla.models import SqlaTable # noqa: F401
from superset.models.core import Database # noqa: F401
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice # noqa: F401
from superset.tags.models import ObjectType, Tag, TaggedObject, TagType
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.constants import ADMIN_USERNAME
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
from tests.integration_tests.fixtures.importexport import (
chart_config, # noqa: F401
dashboard_config, # noqa: F401
@@ -127,6 +128,53 @@ class TestDeleteTagsCommand(SupersetTestCase):
tags = db.session.query(Tag).filter(Tag.name.in_(example_tags))
assert tags.count() == 0
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@pytest.mark.usefixtures("with_tagging_system_feature")
def test_delete_tags_command_requires_authorization(self):
"""
Regression test: DeleteTagsCommand used to check only that each named
tag existed, letting any Gamma user bulk-delete tags (and every
association they carry) they neither created nor own. It must now
require admin-or-creator, and must refuse system-generated tags
outright for everyone.
"""
example_dashboard = (
db.session.query(Dashboard)
.filter_by(dashboard_title="World Bank's Data")
.one()
)
self.login(ADMIN_USERNAME)
example_tags = {"delete tag authz example"}
CreateCustomTagCommand(
ObjectType.dashboard.value, example_dashboard.id, example_tags
).run()
system_tag = Tag(name="type:delete_tag_authz", type=TagType.type)
db.session.add(system_tag)
db.session.commit()
try:
# a non-admin who is not the tag's creator may not delete it
self.logout()
self.login(GAMMA_USERNAME)
with pytest.raises(TagInvalidError):
DeleteTagsCommand(example_tags).run()
assert db.session.query(Tag).filter(Tag.name.in_(example_tags)).count() == 1
# system-generated tags are refused outright, even for an admin
self.logout()
self.login(ADMIN_USERNAME)
with pytest.raises(TagInvalidError):
DeleteTagsCommand([system_tag.name]).run()
assert db.session.query(Tag).filter_by(name=system_tag.name).count() == 1
finally:
# cleanup
self.logout()
self.login(ADMIN_USERNAME)
DeleteTagsCommand(example_tags).run()
db.session.query(Tag).filter_by(name=system_tag.name).delete()
db.session.commit()
# test delete tagged objects command
class TestDeleteTaggedObjectCommand(SupersetTestCase):
@@ -23,7 +23,11 @@ from superset.models.core import Theme
from superset.utils import json
from tests.conftest import with_config
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
from tests.integration_tests.constants import (
ADMIN_USERNAME,
ALPHA_USERNAME,
GAMMA_USERNAME,
)
class TestThemeAPIPermissions(SupersetTestCase):
@@ -92,9 +96,11 @@ class TestThemeAPIPermissions(SupersetTestCase):
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": True})
def test_non_admin_cannot_set_system_default(self):
"""Test that non-admin users cannot set system themes"""
# Login as gamma user
self.login(GAMMA_USERNAME)
"""Test that a non-admin user with theme write access (Alpha) still
cannot set system themes, since that is an admin-only action."""
# Login as alpha user, who has generic write access to themes but
# is not an admin
self.login(ALPHA_USERNAME)
# Try to set theme as system default
response = self.client.put(
@@ -110,6 +116,26 @@ class TestThemeAPIPermissions(SupersetTestCase):
theme = db.session.query(Theme).filter_by(id=self.regular_theme.id).first()
assert theme.is_system_default is False
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": True})
def test_gamma_cannot_write_themes(self):
"""Test that gamma users, who only have read access to themes, are
rejected before reaching the admin-only check."""
# Login as gamma user
self.login(GAMMA_USERNAME)
# Try to set theme as system default
response = self.client.put(
f"/api/v1/theme/{self.regular_theme.id}/set_system_default"
)
# Should be forbidden at the permission layer, since gamma has no
# write access to themes at all
assert response.status_code == 403
# Verify theme is not system default
theme = db.session.query(Theme).filter_by(id=self.regular_theme.id).first()
assert theme.is_system_default is False
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": False})
def test_system_theme_requires_config_enabled(self):
"""Test that system theme APIs require configuration to be enabled"""
@@ -25,6 +25,7 @@ from superset.commands.semantic_layer.exceptions import (
SemanticLayerCreateFailedError,
SemanticLayerInvalidError,
)
from superset.exceptions import SupersetSecurityException
def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
@@ -56,6 +57,36 @@ def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
mock_cls.from_configuration.assert_called_once_with({"account": "test"})
def test_create_semantic_layer_configuration_already_serialized(
mocker: MockerFixture,
) -> None:
"""When ``configuration`` is already a JSON string, it is passed through
to the DAO unchanged instead of being re-serialized."""
new_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao.validate_uniqueness.return_value = True
dao.create.return_value = new_model
mock_cls = MagicMock()
mocker.patch.dict(
"superset.commands.semantic_layer.create.registry",
{"snowflake": mock_cls},
)
data = {
"name": "My Layer",
"type": "snowflake",
"configuration": '{"account": "test"}',
}
result = CreateSemanticLayerCommand(data).run()
assert result == new_model
dao.create.assert_called_once_with(attributes=data)
def test_create_semantic_layer_unknown_type(mocker: MockerFixture) -> None:
"""Test that SemanticLayerInvalidError is raised for unknown type."""
mocker.patch(
@@ -166,6 +197,11 @@ def test_create_semantic_view_success(mocker: MockerFixture) -> None:
mock_model.name = "orders"
dao_view.create.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.create.current_user_can_modify_object",
return_value=True,
)
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
result = CreateSemanticViewCommand(
@@ -182,6 +218,42 @@ def test_create_semantic_view_success(mocker: MockerFixture) -> None:
)
def test_create_semantic_view_configuration_already_serialized(
mocker: MockerFixture,
) -> None:
"""When ``configuration`` is already a JSON string, it is passed through
to the DAO unchanged instead of being re-serialized."""
mock_layer = MagicMock()
dao_layer = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao_layer.find_by_uuid.return_value = mock_layer
dao_view = mocker.patch(
"superset.commands.semantic_layer.create.SemanticViewDAO",
)
dao_view.validate_uniqueness.return_value = True
mock_model = MagicMock()
dao_view.create.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.create.current_user_can_modify_object",
return_value=True,
)
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
data = {
"name": "orders",
"semantic_layer_uuid": "layer-uuid",
"configuration": '{"db": "prod"}',
}
result = CreateSemanticViewCommand(data).run()
assert result == mock_model
dao_view.create.assert_called_once_with(attributes=data)
def test_create_semantic_view_layer_not_found(mocker: MockerFixture) -> None:
"""Test CreateSemanticViewCommand raises when layer not found."""
dao_layer = mocker.patch(
@@ -215,6 +287,11 @@ def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
)
dao_view.validate_uniqueness.return_value = False
mocker.patch(
"superset.commands.semantic_layer.create.current_user_can_modify_object",
return_value=True,
)
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
from superset.commands.semantic_layer.exceptions import (
SemanticViewCreateFailedError,
@@ -228,3 +305,110 @@ def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
"configuration": {"db": "prod"},
}
).run()
def test_create_semantic_view_forbidden(mocker: MockerFixture) -> None:
"""Test CreateSemanticViewCommand raises when the caller may not modify
the parent layer."""
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
mock_layer = MagicMock()
dao_layer = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao_layer.find_by_uuid.return_value = mock_layer
dao_view = mocker.patch(
"superset.commands.semantic_layer.create.SemanticViewDAO",
)
mocker.patch(
"superset.commands.semantic_layer.create.current_user_can_modify_object",
return_value=False,
)
with pytest.raises(SemanticViewForbiddenError):
CreateSemanticViewCommand(
{
"name": "orders",
"semantic_layer_uuid": "layer-uuid",
"configuration": {"db": "prod"},
}
).run()
dao_view.create.assert_not_called()
def test_create_semantic_view_creator_of_layer_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created the parent layer, but holds no explicit
editorship on it, can still add a semantic view to it."""
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
mock_layer = MagicMock()
dao_layer = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao_layer.find_by_uuid.return_value = mock_layer
dao_view = mocker.patch(
"superset.commands.semantic_layer.create.SemanticViewDAO",
)
dao_view.validate_uniqueness.return_value = True
mock_model = MagicMock()
dao_view.create.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_layer.created_by = sm.current_user
result = CreateSemanticViewCommand(
{
"name": "orders",
"semantic_layer_uuid": "layer-uuid",
"configuration": {"db": "prod"},
}
).run()
assert result == mock_model
dao_view.create.assert_called_once()
def test_create_semantic_view_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who neither created the parent layer nor is an editor of
it is rejected."""
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
mock_layer = MagicMock()
dao_layer = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao_layer.find_by_uuid.return_value = mock_layer
dao_view = mocker.patch(
"superset.commands.semantic_layer.create.SemanticViewDAO",
)
sm = mocker.patch(
"superset.commands.utils.security_manager",
)
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_layer.created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticViewForbiddenError):
CreateSemanticViewCommand(
{
"name": "orders",
"semantic_layer_uuid": "layer-uuid",
"configuration": {"db": "prod"},
}
).run()
dao_view.create.assert_not_called()
@@ -21,7 +21,11 @@ import pytest
from pytest_mock import MockerFixture
from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
from superset.commands.semantic_layer.exceptions import (
SemanticLayerForbiddenError,
SemanticLayerNotFoundError,
)
from superset.exceptions import SupersetSecurityException
def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
@@ -33,6 +37,11 @@ def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
)
dao.find_by_uuid.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
return_value=True,
)
DeleteSemanticLayerCommand("some-uuid").run()
dao.find_by_uuid.assert_called_once_with("some-uuid")
@@ -50,6 +59,71 @@ def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
DeleteSemanticLayerCommand("missing-uuid").run()
def test_delete_semantic_layer_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticLayerForbiddenError is raised for non-editors."""
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
return_value=False,
)
with pytest.raises(SemanticLayerForbiddenError):
DeleteSemanticLayerCommand("some-uuid").run()
dao.delete.assert_not_called()
def test_delete_semantic_layer_creator_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created the layer, but holds no explicit editorship
on it, can still delete it."""
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = sm.current_user
DeleteSemanticLayerCommand("some-uuid").run()
dao.delete.assert_called_once_with([mock_model])
def test_delete_semantic_layer_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who neither created the layer nor is an editor of it is
rejected."""
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticLayerForbiddenError):
DeleteSemanticLayerCommand("some-uuid").run()
dao.delete.assert_not_called()
def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
"""Test successful deletion of a semantic view."""
mock_model = MagicMock()
@@ -59,10 +133,11 @@ def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
)
dao.find_by_id.return_value = mock_model
# Admin is owner of everything — no exception raised
# Admin (or an editor) can modify anything — no exception raised.
mocker.patch(
"superset.commands.semantic_layer.delete.security_manager"
).raise_for_editorship.return_value = None
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
return_value=True,
)
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
@@ -76,12 +151,13 @@ def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticViewForbiddenError is raised for non-owners."""
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
from superset.exceptions import SupersetSecurityException
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_id.return_value = MagicMock()
model = MagicMock()
model.created_by = None
dao.find_by_id.return_value = model
mocker.patch(
"superset.security_manager.raise_for_editorship",
@@ -92,6 +168,56 @@ def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
DeleteSemanticViewCommand(42).run()
def test_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created the view, but holds no explicit editorship on
it, can still delete it."""
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = sm.current_user
DeleteSemanticViewCommand(42).run()
dao.delete.assert_called_once_with([mock_model])
def test_delete_semantic_view_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who neither created the view nor is an editor of it is
rejected."""
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticViewForbiddenError):
DeleteSemanticViewCommand(42).run()
dao.delete.assert_not_called()
def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
"""Test that SemanticViewNotFoundError is raised when view is missing."""
dao = mocker.patch(
@@ -118,8 +244,9 @@ def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
dao.find_by_ids.return_value = mock_models
mocker.patch(
"superset.commands.semantic_layer.delete.security_manager"
).raise_for_editorship.return_value = None
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
return_value=True,
)
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
@@ -133,7 +260,6 @@ def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticViewForbiddenError is raised for non-owners."""
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
from superset.exceptions import SupersetSecurityException
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
@@ -141,14 +267,67 @@ def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
dao.find_by_ids.return_value = [MagicMock(), MagicMock()]
mocker.patch(
"superset.security_manager.raise_for_editorship",
side_effect=SupersetSecurityException(MagicMock()),
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
return_value=False,
)
with pytest.raises(SemanticViewForbiddenError):
BulkDeleteSemanticViewCommand([1, 2]).run()
def test_bulk_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created every view in the batch, but holds no
explicit editorship on them, can still bulk-delete them."""
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
mock_models = [MagicMock(), MagicMock()]
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_ids.return_value = mock_models
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
for model in mock_models:
model.created_by = sm.current_user
BulkDeleteSemanticViewCommand([1, 2]).run()
dao.delete.assert_called_once_with(mock_models)
def test_bulk_delete_semantic_view_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who is neither the creator of, nor an editor for, one of
the views in the batch is rejected."""
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
mock_models = [MagicMock(), MagicMock()]
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_ids.return_value = mock_models
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
# The first view belongs to the current user, the second doesn't.
mock_models[0].created_by = sm.current_user
mock_models[1].created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticViewForbiddenError):
BulkDeleteSemanticViewCommand([1, 2]).run()
dao.delete.assert_not_called()
def test_bulk_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
"""Test that SemanticViewNotFoundError is raised when any id is missing."""
dao = mocker.patch(
@@ -21,6 +21,7 @@ import pytest
from pytest_mock import MockerFixture
from superset.commands.semantic_layer.exceptions import (
SemanticLayerForbiddenError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticViewForbiddenError,
@@ -46,7 +47,7 @@ def test_update_semantic_view_success(mocker: MockerFixture) -> None:
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
data = {"description": "Updated", "cache_timeout": 300}
@@ -77,18 +78,65 @@ def test_update_semantic_view_forbidden(mocker: MockerFixture) -> None:
)
dao.find_by_id.return_value = mock_model
sm = mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
)
# Use a regular MagicMock for raise_for_editorship to avoid AsyncMock issues
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
return_value=False,
)
with pytest.raises(SemanticViewForbiddenError):
UpdateSemanticViewCommand(1, {"description": "test"}).run()
def test_update_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created the view, but holds no explicit editorship on
it, can still update it."""
mock_model = MagicMock()
mock_model.id = 1
mock_model.configuration = "{}"
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
dao.update.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = sm.current_user
data = {"description": "Updated"}
result = UpdateSemanticViewCommand(1, data).run()
assert result == mock_model
dao.update.assert_called_once_with(mock_model, attributes=data)
def test_update_semantic_view_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who neither created the view nor is an editor of it is
rejected."""
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticViewForbiddenError):
UpdateSemanticViewCommand(1, {"description": "test"}).run()
dao.update.assert_not_called()
def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
"""Test that the command copies input data and does not mutate it."""
mock_model = MagicMock()
@@ -101,7 +149,7 @@ def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
original_data = {"description": "Original"}
@@ -127,6 +175,10 @@ def test_update_semantic_layer_success(mocker: MockerFixture) -> None:
dao.find_by_uuid.return_value = mock_model
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
data = {"name": "Updated", "description": "New desc"}
result = UpdateSemanticLayerCommand("some-uuid", data).run()
@@ -146,6 +198,77 @@ def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None:
UpdateSemanticLayerCommand("missing-uuid", {"name": "test"}).run()
def test_update_semantic_layer_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticLayerForbiddenError is raised on ownership failure."""
mock_model = MagicMock()
mock_model.type = "snowflake"
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
return_value=False,
)
with pytest.raises(SemanticLayerForbiddenError):
UpdateSemanticLayerCommand("some-uuid", {"name": "test"}).run()
dao.update.assert_not_called()
def test_update_semantic_layer_creator_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created the layer, but holds no explicit editorship
on it, can still update it."""
mock_model = MagicMock()
mock_model.type = "snowflake"
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
dao.update.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = sm.current_user
data = {"description": "Updated"}
result = UpdateSemanticLayerCommand("some-uuid", data).run()
assert result == mock_model
dao.update.assert_called_once_with(mock_model, attributes=data)
def test_update_semantic_layer_non_creator_non_editor_forbidden(
mocker: MockerFixture,
) -> None:
"""A non-admin who neither created the layer nor is an editor of it is
rejected."""
mock_model = MagicMock()
mock_model.type = "snowflake"
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticLayerDAO",
)
dao.find_by_uuid.return_value = mock_model
sm = mocker.patch("superset.commands.utils.security_manager")
sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
mock_model.created_by = MagicMock(name="someone_else")
with pytest.raises(SemanticLayerForbiddenError):
UpdateSemanticLayerCommand("some-uuid", {"name": "test"}).run()
dao.update.assert_not_called()
def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
"""Test that SemanticLayerInvalidError is raised for duplicate names."""
mock_model = MagicMock()
@@ -157,6 +280,10 @@ def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
dao.find_by_uuid.return_value = mock_model
dao.validate_update_uniqueness.return_value = False
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
with pytest.raises(SemanticLayerInvalidError):
UpdateSemanticLayerCommand("some-uuid", {"name": "Duplicate"}).run()
@@ -174,6 +301,10 @@ def test_update_semantic_layer_validates_configuration(
dao.find_by_uuid.return_value = mock_model
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
mock_cls = MagicMock()
mocker.patch.dict(
"superset.commands.semantic_layer.update.registry",
@@ -199,6 +330,10 @@ def test_update_semantic_layer_skips_name_check_when_no_name(
dao.find_by_uuid.return_value = mock_model
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
UpdateSemanticLayerCommand("some-uuid", {"description": "Updated"}).run()
dao.validate_update_uniqueness.assert_not_called()
@@ -215,6 +350,10 @@ def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None:
dao.find_by_uuid.return_value = mock_model
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
original_data = {"description": "Original"}
UpdateSemanticLayerCommand("some-uuid", original_data).run()
@@ -249,7 +388,7 @@ def test_update_uniqueness_different_config_same_name(
dao.validate_update_uniqueness.return_value = True
mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
# Update to a config that differs from an existing view
@@ -279,7 +418,7 @@ def test_update_uniqueness_same_config_different_name(
dao.validate_update_uniqueness.return_value = True
mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
data = {"name": "renamed_view", "configuration": {"schema": "prod"}}
@@ -307,7 +446,7 @@ def test_update_uniqueness_same_config_same_name_fails(
dao.validate_update_uniqueness.return_value = False
mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
"superset.commands.semantic_layer.update.current_user_can_modify_object",
)
from superset.commands.semantic_layer.exceptions import (
+59
View File
@@ -22,11 +22,13 @@ import pytest
from superset.commands.exceptions import TagForbiddenError, TagNotFoundValidationError
from superset.commands.utils import (
current_user_can_modify_object,
Tag,
TagType,
update_tags,
validate_tags,
)
from superset.exceptions import SupersetSecurityException
from superset.tags.models import ObjectType
OBJECT_TYPES = {ObjectType.chart, ObjectType.chart}
@@ -343,3 +345,60 @@ def test_update_tags_no_tags(mock_tag_dao, object_type):
mock_tag_dao.create_custom_tagged_objects.assert_called_once_with(
object_type, 1, new_tag_names
)
@patch("superset.commands.utils.security_manager")
def test_current_user_can_modify_object_editor(mock_sm):
"""
An editor of the resource (or an admin, since ``raise_for_editorship``
treats admins as editors of everything) is allowed to modify it.
"""
mock_sm.raise_for_editorship.return_value = None
model = MagicMock()
assert current_user_can_modify_object(model) is True
@patch("superset.commands.utils.security_manager")
def test_current_user_can_modify_object_creator_fallback(mock_sm):
"""
A resource without an ``editors`` relationship (or a user who isn't in
it) still allows the object's creator through.
"""
mock_sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock())
)
model = MagicMock()
model.created_by = mock_sm.current_user
assert current_user_can_modify_object(model) is True
@patch("superset.commands.utils.security_manager")
def test_current_user_can_modify_object_denies_non_creator(mock_sm):
"""
A user who is neither an editor nor the creator is denied.
"""
mock_sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock())
)
mock_sm.current_user = MagicMock(name="current_user")
model = MagicMock()
model.created_by = MagicMock(name="someone_else")
assert current_user_can_modify_object(model) is False
@patch("superset.commands.utils.security_manager")
def test_current_user_can_modify_object_no_creator(mock_sm):
"""
A resource with no ``created_by`` set (e.g. created programmatically)
is denied to non-editors.
"""
mock_sm.raise_for_editorship = MagicMock(
side_effect=SupersetSecurityException(MagicMock())
)
model = MagicMock()
model.created_by = None
assert current_user_can_modify_object(model) is False
@@ -0,0 +1,193 @@
# 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.
from unittest.mock import MagicMock, Mock, patch
import pytest
from superset.commands.theme.exceptions import ThemeImportError
from superset.commands.theme.import_themes import import_theme
from superset.models.core import Theme
def _mock_existing(
is_system: bool = False,
is_system_default: bool = False,
is_system_dark: bool = False,
) -> MagicMock:
theme = MagicMock(spec=Theme)
theme.id = 1
theme.is_system = is_system
theme.is_system_default = is_system_default
theme.is_system_dark = is_system_dark
return theme
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_refuses_system_theme_overwrite(mock_db, mock_security_manager):
"""overwrite=True must not be able to replace a seeded system theme."""
mock_security_manager.can_access.return_value = True
existing = _mock_existing(is_system=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
with pytest.raises(ThemeImportError):
import_theme(config, overwrite=True)
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_refuses_system_default_overwrite(mock_db, mock_security_manager):
"""A non-admin overwrite=True must not replace the active default theme."""
mock_security_manager.can_access.return_value = True
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=False)
existing = _mock_existing(is_system_default=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
with pytest.raises(ThemeImportError):
import_theme(config, overwrite=True)
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_refuses_system_dark_overwrite(mock_db, mock_security_manager):
"""A non-admin overwrite=True must not replace the active dark theme."""
mock_security_manager.can_access.return_value = True
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=False)
existing = _mock_existing(is_system_dark=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
with pytest.raises(ThemeImportError):
import_theme(config, overwrite=True)
@patch("superset.utils.core.get_user")
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_admin_allows_system_default_overwrite(
mock_db, mock_security_manager, mock_get_user
):
"""An admin overwrite=True may still replace the active default theme,
mirroring UpdateThemeCommand's admin carve-out."""
mock_security_manager.can_access.return_value = True
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=True)
mock_get_user.return_value = None
existing = _mock_existing(is_system_default=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
mock_theme = MagicMock(spec=Theme)
mock_theme.id = 1
mock_import_from_dict.return_value = mock_theme
result = import_theme(config, overwrite=True)
assert result is mock_theme
assert config["id"] == existing.id
@patch("superset.utils.core.get_user")
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_admin_allows_system_dark_overwrite(
mock_db, mock_security_manager, mock_get_user
):
"""An admin overwrite=True may still replace the active dark theme,
mirroring UpdateThemeCommand's admin carve-out."""
mock_security_manager.can_access.return_value = True
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=True)
mock_get_user.return_value = None
existing = _mock_existing(is_system_dark=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
mock_theme = MagicMock(spec=Theme)
mock_theme.id = 1
mock_import_from_dict.return_value = mock_theme
result = import_theme(config, overwrite=True)
assert result is mock_theme
assert config["id"] == existing.id
@patch("superset.utils.core.get_user")
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_allows_regular_theme_overwrite(
mock_db, mock_security_manager, mock_get_user
):
"""A regular (non-system) theme can still be overwritten as before."""
mock_security_manager.can_access.return_value = True
mock_get_user.return_value = None
existing = _mock_existing()
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
mock_theme = MagicMock(spec=Theme)
mock_theme.id = 1
mock_import_from_dict.return_value = mock_theme
result = import_theme(config, overwrite=True)
assert result is mock_theme
assert config["id"] == existing.id
@patch("superset.security_manager")
@patch("superset.db")
def test_import_theme_no_overwrite_returns_existing(mock_db, mock_security_manager):
"""Without overwrite=True, the existing theme is returned untouched."""
mock_security_manager.can_access.return_value = True
existing = _mock_existing(is_system_default=True)
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
result = import_theme(config, overwrite=False)
assert result is existing
@@ -218,6 +218,31 @@ def test_orderby_uses_timeseries_limit_metric_and_order_desc() -> None:
assert query["orderby"] == [["revenue", True]]
def test_orderby_uses_series_limit_metric_and_order_desc() -> None:
# series_limit_metric is the current field name; timeseries_limit_metric is
# the deprecated alias kept above for back-compat with old saved charts.
form_data = {
"metrics": ["count"],
"groupby": ["c"],
"series_limit_metric": "revenue",
"order_desc": False,
}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["orderby"] == [["revenue", True]]
def test_orderby_prefers_series_limit_metric_over_deprecated_alias() -> None:
form_data = {
"metrics": ["count"],
"groupby": ["c"],
"series_limit_metric": "revenue",
"timeseries_limit_metric": "profit",
"order_desc": False,
}
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
assert query["orderby"] == [["revenue", True]]
def test_orderby_pie_sort_by_metric() -> None:
form_data = {"metric": "count", "groupby": ["c"], "sort_by_metric": True}
query = build_query_context_from_form_data(form_data, DATASOURCE, viz_type="pie")[
+45
View File
@@ -48,6 +48,51 @@ def test_validate_python_date_format_raises(payload) -> None:
validate_python_date_format(payload)
def test_drill_info_user_schema_does_not_expose_email() -> None:
"""
Regression test: the drill_info-local ``UserSchema`` used to declare an
``email`` field. ``DatasetDrillInfoSchema`` nests it (unfiltered by
``select_columns``) for both ``created_by`` and ``changed_by``, so any
user with dataset-read access -- or, via the dashboard fallback,
embedded guests -- received maintainer email addresses. Dataset-read
access does not imply entitlement to other users' PII.
"""
from superset.datasets.schemas import UserSchema
class _FakeUser:
first_name = "Jane"
last_name = "Doe"
email = "jane.doe@example.com"
dumped = UserSchema().dump(_FakeUser())
assert "email" not in dumped
assert dumped == {"first_name": "Jane", "last_name": "Doe"}
def test_drill_info_editor_schema_does_not_expose_secondary_label() -> None:
"""
Regression test: ``DatasetDrillInfoSchema.editors`` used to nest the
shared ``SubjectResponseSchema``, which includes ``secondary_label``.
For a user-backed Subject, user-subject synchronization
(``superset.subjects.sync.sync_user_subject``) stores that user's email
in ``secondary_label``, so nesting it unfiltered leaked the same
maintainer PII that dropping ``email`` from ``UserSchema`` was meant to
close, just under a different field name.
"""
from superset.datasets.schemas import DrillInfoEditorSchema
class _FakeEditorSubject:
id = 1
label = "Jane Doe"
secondary_label = "jane.doe@example.com"
img = "avatar.png"
type = 1
dumped = DrillInfoEditorSchema().dump(_FakeEditorSubject())
assert "secondary_label" not in dumped
assert dumped == {"id": 1, "label": "Jane Doe", "img": "avatar.png", "type": 1}
def test_dataset_post_schema_has_all_put_scalar_fields() -> None:
"""
Every scalar model field accepted by DatasetPutSchema should also be accepted
+66
View File
@@ -106,3 +106,69 @@ def test_report_schedule_all_text_filter_coerces_non_string(
mock_report_schedule.sql,
):
column.ilike.assert_called_once_with(expected, escape="\\")
@patch("superset.reports.filters.security_manager", new_callable=MagicMock)
def test_report_execution_log_filter_admin_sees_all(mock_sm: MagicMock) -> None:
"""
Regression test: ``ReportExecutionLogRestApi`` had no base filter at all,
so any role with generic ReportSchedule read could iterate every
schedule's logs by pk. An admin (can_access_all_queries) must still
see everything unfiltered.
"""
from superset.reports.filters import ReportExecutionLogFilter
mock_sm.can_access_all_queries.return_value = True
query = MagicMock()
f = ReportExecutionLogFilter("id", MagicMock())
result = f.apply(query, None)
assert result is query
query.filter.assert_not_called()
@patch("superset.reports.filters.security_manager", new_callable=MagicMock)
def test_report_execution_log_filter_stock_alpha_is_scoped(
mock_sm: MagicMock,
) -> None:
"""
Regression test: the unrestricted bypass used to key off
``can_access_all_datasources``, which is also granted to stock Alpha
(see ``SupersetSecurityManager.ALPHA_ONLY_PERMISSIONS``), letting a
non-editor Alpha user read every other schedule's evaluated alert
values and database errors. The bypass must require
``can_access_all_queries`` (admin-only) instead, matching the
equivalent per-execution SQL Lab query history filter
(``superset.queries.filters.QueryFilter``).
"""
from superset.reports.filters import ReportExecutionLogFilter
mock_sm.can_access_all_datasources.return_value = True
mock_sm.can_access_all_queries.return_value = False
query = MagicMock()
f = ReportExecutionLogFilter("id", MagicMock())
f.apply(query, None)
query.filter.assert_called_once()
@patch("superset.reports.filters.security_manager", new_callable=MagicMock)
@patch("superset.reports.filters.db")
def test_report_execution_log_filter_non_admin_scoped_to_log_fk(
mock_db: MagicMock, mock_sm: MagicMock
) -> None:
"""
A non-admin must be scoped by ``ReportExecutionLog.report_schedule_id``
directly (not by an unjoined filter on ``ReportSchedule.id``, which would
pass for any log row as long as the caller edits at least one schedule).
"""
from superset.reports.filters import ReportExecutionLogFilter
from superset.reports.models import ReportExecutionLog
mock_sm.can_access_all_queries.return_value = False
query = MagicMock()
f = ReportExecutionLogFilter("id", MagicMock())
f.apply(query, None)
query.filter.assert_called_once()
(filter_expr,) = query.filter.call_args[0]
assert filter_expr.left.table is ReportExecutionLog.__table__
assert filter_expr.left.name == "report_schedule_id"
+37
View File
@@ -0,0 +1,37 @@
# 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.
from superset.reports.filters import ReportExecutionLogFilter
from superset.reports.logs.api import ReportExecutionLogRestApi
def test_execution_log_api_has_ownership_base_filter() -> None:
"""
Regression test: the schedule id in the ``/log/`` and ``/log/<id>``
routes is caller-controlled (folded into the rison filters), so an
editor-scoped base filter must be applied to both the list and item
routes -- otherwise any role with generic ReportSchedule read can iterate
every schedule's logs.
"""
assert ReportExecutionLogRestApi.base_filters, (
"ReportExecutionLogRestApi must apply an ownership base filter; "
"without one, logs are readable across schedules regardless of "
"ownership"
)
assert any(
filter_class is ReportExecutionLogFilter
for _, filter_class, _ in ReportExecutionLogRestApi.base_filters
)
+32 -1
View File
@@ -19,8 +19,10 @@ from typing import Any
import pytest
from marshmallow import ValidationError
from superset.constants import RouteMethod
from superset.extensions import csrf
from superset.security.api import RlsRuleSchema
from superset.security.api import RlsRuleSchema, UserRegistrationsRestAPI
from superset.security.manager import SupersetSecurityManager
@pytest.mark.parametrize(
@@ -172,3 +174,32 @@ def test_rls_rule_schema_rejects_falsy_dataset(dataset: Any) -> None:
with pytest.raises(ValidationError) as exc_info:
RlsRuleSchema().load({"dataset": dataset, "clause": "tenant_id = 1"})
assert "dataset" in exc_info.value.messages
def test_user_registrations_rest_api_is_admin_only() -> None:
"""
The API is documented Admin-only, but the admin gate is membership in
ADMIN_ONLY_VIEW_MENUS keyed by the FAB-derived view-menu name (the class
name). If the entry is missing, ``superset init`` grants the API's
permissions to stock Gamma and Alpha via ``_is_gamma_pvm``, exposing
pending registrants' PII and registration deletion.
"""
assert "UserRegistrationsRestAPI" in SupersetSecurityManager.ADMIN_ONLY_VIEW_MENUS
def test_user_registrations_rest_api_excludes_create_and_update() -> None:
"""
The FAB default POST/PUT handlers must not exist on this API, so a
mis-granted role cannot create or silently alter a pending registration.
DELETE stays registered: the User Registrations admin page deletes
pending registrations through it, and the route is still fully gated
Admin-only (see test_user_registrations_rest_api_is_admin_only).
"""
assert UserRegistrationsRestAPI.include_route_methods == {
RouteMethod.GET,
RouteMethod.GET_LIST,
RouteMethod.INFO,
RouteMethod.DELETE,
}
assert "post" not in UserRegistrationsRestAPI.include_route_methods
assert "put" not in UserRegistrationsRestAPI.include_route_methods
@@ -36,6 +36,7 @@ from superset.commands.semantic_layer.exceptions import (
SemanticViewNotFoundError,
SemanticViewUpdateFailedError,
)
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.semantic_layers.api import SemanticLayerRestApi, SemanticViewRestApi
@@ -529,6 +530,34 @@ def test_runtime_schema_not_found(
assert response.status_code == 404
@SEMANTIC_LAYERS_APP
def test_runtime_schema_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST /<uuid>/schema/runtime returns 403 when access is denied."""
test_uuid = str(uuid_lib.uuid4())
mock_layer = MagicMock()
mock_layer.raise_for_access.side_effect = SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
message="You don't have access to this semantic layer.",
level=ErrorLevel.ERROR,
)
)
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = mock_layer
response = client.post(
f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
)
assert response.status_code == 403
mock_layer.raise_for_access.assert_called_once()
@SEMANTIC_LAYERS_APP
def test_runtime_schema_unknown_type(
client: Any,
@@ -729,6 +758,30 @@ def test_put_semantic_layer_not_found(
assert response.status_code == 404
@SEMANTIC_LAYERS_APP
def test_put_semantic_layer_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test PUT /<uuid> returns 403 when the caller is not an editor."""
from superset.commands.semantic_layer.exceptions import (
SemanticLayerForbiddenError,
)
mock_command = mocker.patch(
"superset.semantic_layers.api.UpdateSemanticLayerCommand",
)
mock_command.return_value.run.side_effect = SemanticLayerForbiddenError()
response = client.put(
f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
json={"name": "New"},
)
assert response.status_code == 403
@SEMANTIC_LAYERS_APP
def test_put_semantic_layer_invalid(
client: Any,
@@ -821,6 +874,27 @@ def test_delete_semantic_layer_not_found(
assert response.status_code == 404
@SEMANTIC_LAYERS_APP
def test_delete_semantic_layer_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test DELETE /<uuid> returns 403 when the caller is not an editor."""
from superset.commands.semantic_layer.exceptions import (
SemanticLayerForbiddenError,
)
mock_command = mocker.patch(
"superset.semantic_layers.api.DeleteSemanticLayerCommand",
)
mock_command.return_value.run.side_effect = SemanticLayerForbiddenError()
response = client.delete(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
assert response.status_code == 403
@SEMANTIC_LAYERS_APP
def test_delete_semantic_layer_failed(
client: Any,
@@ -939,6 +1013,33 @@ def test_get_semantic_layer_not_found(
assert response.status_code == 404
@SEMANTIC_LAYERS_APP
def test_get_semantic_layer_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test GET /<uuid> returns 403 when user lacks access to the layer."""
test_uuid = uuid_lib.uuid4()
layer = MagicMock()
layer.uuid = test_uuid
layer.raise_for_access.side_effect = SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
message="You don't have access to this semantic layer.",
level=ErrorLevel.ERROR,
)
)
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = layer
response = client.get(f"/api/v1/semantic_layer/{test_uuid}")
assert response.status_code == 403
layer.raise_for_access.assert_called_once()
@SEMANTIC_LAYERS_APP
def test_serialize_layer_string_config(
client: Any,
@@ -1645,6 +1746,35 @@ def test_post_semantic_view_layer_not_found(
assert result["errors"][0]["error"] == "Semantic layer not found"
@SEMANTIC_LAYERS_APP
def test_post_semantic_view_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST / collects forbidden errors instead of aborting the batch."""
mock_command = mocker.patch(
"superset.semantic_layers.api.CreateSemanticViewCommand",
)
mock_command.return_value.run.side_effect = SemanticViewForbiddenError()
payload = {
"views": [
{
"name": "View 1",
"semantic_layer_uuid": str(uuid_lib.uuid4()),
"configuration": {},
},
],
}
response = client.post("/api/v1/semantic_view/", json=payload)
assert response.status_code == 422
result = response.json["result"]
assert len(result["errors"]) == 1
assert not result["created"]
@SEMANTIC_LAYERS_APP
def test_post_semantic_view_create_failed(
client: Any,
@@ -1956,6 +2086,37 @@ def test_get_views(
assert result[1]["name"] == "View B"
@SEMANTIC_LAYERS_APP
def test_get_views_forbidden(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST /<uuid>/views returns 403 when access is denied."""
test_uuid = str(uuid_lib.uuid4())
mock_layer = MagicMock()
mock_layer.uuid = uuid_lib.uuid4()
mock_layer.raise_for_access.side_effect = SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
message="You don't have access to this semantic layer.",
level=ErrorLevel.ERROR,
)
)
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = mock_layer
response = client.post(
f"/api/v1/semantic_layer/{test_uuid}/views",
json={"runtime_data": {"database": "mydb"}},
)
assert response.status_code == 403
mock_layer.raise_for_access.assert_called_once()
mock_layer.implementation.get_semantic_views.assert_not_called()
@SEMANTIC_LAYERS_APP
def test_get_views_with_existing(
client: Any,
@@ -1100,6 +1100,90 @@ def test_semantic_layer_get_perm_special_characters() -> None:
)
# =============================================================================
# SemanticLayer.raise_for_access tests
# =============================================================================
def test_semantic_layer_raise_for_access_all_datasources(app: Any) -> None:
"""Test raise_for_access passes when user has all_datasource_access."""
from superset import security_manager
layer = SemanticLayer()
layer.name = "Layer"
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
layer.perm = layer.get_perm()
with patch.object(
security_manager, "can_access_all_datasources", return_value=True
):
layer.raise_for_access()
def test_semantic_layer_raise_for_access_perm(app: Any) -> None:
"""Test raise_for_access passes when user has datasource_access to the
layer's perm."""
from superset import security_manager
layer = SemanticLayer()
layer.name = "Layer"
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
layer.perm = layer.get_perm()
with (
patch.object(
security_manager, "can_access_all_datasources", return_value=False
),
patch.object(
security_manager, "can_access", return_value=True
) as mock_can_access,
):
layer.raise_for_access()
mock_can_access.assert_called_once_with("datasource_access", layer.perm)
def test_semantic_layer_raise_for_access_denied(app: Any) -> None:
"""Test raise_for_access raises SupersetSecurityException when denied."""
from superset import security_manager
from superset.exceptions import SupersetSecurityException
layer = SemanticLayer()
layer.name = "Layer"
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
layer.perm = layer.get_perm()
with (
patch.object(
security_manager, "can_access_all_datasources", return_value=False
),
patch.object(security_manager, "can_access", return_value=False),
):
with pytest.raises(SupersetSecurityException):
layer.raise_for_access()
def test_semantic_layer_raise_for_access_no_perm_denied(app: Any) -> None:
"""Test raise_for_access raises SupersetSecurityException when the layer
has no perm set, without even attempting a datasource_access check."""
from superset import security_manager
from superset.exceptions import SupersetSecurityException
layer = SemanticLayer()
layer.name = "Layer"
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
layer.perm = None
with (
patch.object(
security_manager, "can_access_all_datasources", return_value=False
),
patch.object(security_manager, "can_access") as mock_can_access,
):
with pytest.raises(SupersetSecurityException):
layer.raise_for_access()
mock_can_access.assert_not_called()
# =============================================================================
# SemanticView.raise_for_access tests
# =============================================================================
+164
View File
@@ -0,0 +1,164 @@
# 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.
from typing import Any
from unittest.mock import MagicMock
from pytest_mock import MockerFixture
def test_delete_tag_by_pk_routes_through_delete_tags_command(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""
Regression test: ``DELETE /api/v1/tag/<pk>`` used to fall through to the
FAB-generated single-object delete route, which deletes the row directly
via the datamodel and never runs ``DeleteTagsCommand.validate`` (the
admin-or-creator check, and the system-tag refusal). The pk route must
be overridden to share that same validation instead of duplicating it.
"""
mock_tag = MagicMock(id=1)
mock_tag.name = "example_tag"
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=mock_tag)
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
mock_command.return_value.run.return_value = None
response = client.delete("/api/v1/tag/1")
assert response.status_code == 200
mock_command.assert_called_once_with(["example_tag"])
def test_delete_tag_by_pk_not_found(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""DELETE /api/v1/tag/<pk> returns 404 when the tag does not exist."""
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=None)
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
response = client.delete("/api/v1/tag/999")
assert response.status_code == 404
mock_command.assert_not_called()
def test_delete_tag_by_pk_denied_surfaces_as_422(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""
A non-admin, non-creator caller (or an attempt to delete a system tag)
is rejected by DeleteTagsCommand.validate with TagInvalidError; the pk
route must surface that as 422, not silently succeed.
"""
from superset.commands.tag.exceptions import TagInvalidError
mock_tag = MagicMock(id=1)
mock_tag.name = "someone_elses_tag"
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=mock_tag)
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
mock_command.return_value.run.side_effect = TagInvalidError()
response = client.delete("/api/v1/tag/1")
assert response.status_code == 422
def test_delete_tag_by_pk_denied_with_populated_exceptions_surfaces_as_422(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""
Regression test for an AttributeError that only surfaced once
DeleteTagsCommand.validate's TagInvalidError actually carried the
exceptions it composites at runtime (a plain TagDeleteFailedError,
not a ValidationError). The pk route calls
``ex.normalized_messages()`` on the TagInvalidError it catches, which
previously crashed with a 500 instead of returning 422 because
TagDeleteFailedError has no ``normalized_messages()`` method. A
TagInvalidError() with no exceptions (as in the test above) does not
exercise that aggregation loop, so this test populates it the way the
real command does.
"""
from superset.commands.tag.exceptions import (
TagDeleteForbiddenValidationError,
TagInvalidError,
)
mock_tag = MagicMock(id=1)
mock_tag.name = "system:some_type"
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=mock_tag)
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
mock_command.return_value.run.side_effect = TagInvalidError(
exceptions=[
TagDeleteForbiddenValidationError(
"Tag system:some_type is a system tag and cannot be deleted"
)
]
)
response = client.delete("/api/v1/tag/1")
assert response.status_code == 422
assert "tags" in response.json["message"]
def test_delete_tag_by_pk_race_with_bulk_delete_surfaces_as_404(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""
If the tag is deleted concurrently between the ``find_by_id`` lookup and
``DeleteTagsCommand.validate``'s own lookup, the command raises
TagNotFoundError; the pk route must surface that as 404.
"""
from superset.commands.tag.exceptions import TagNotFoundError
mock_tag = MagicMock(id=1)
mock_tag.name = "example_tag"
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=mock_tag)
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
mock_command.return_value.run.side_effect = TagNotFoundError("example_tag")
response = client.delete("/api/v1/tag/1")
assert response.status_code == 404
def test_delete_tag_by_pk_delete_failed_surfaces_as_422(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""DeleteTagsCommand raising TagDeleteFailedError surfaces as 422."""
from superset.commands.tag.exceptions import TagDeleteFailedError
mock_tag = MagicMock(id=1)
mock_tag.name = "example_tag"
mocker.patch("superset.tags.api.TagDAO.find_by_id", return_value=mock_tag)
mock_command = mocker.patch("superset.tags.api.DeleteTagsCommand")
mock_command.return_value.run.side_effect = TagDeleteFailedError()
response = client.delete("/api/v1/tag/1")
assert response.status_code == 422
@@ -0,0 +1,198 @@
# 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.
from unittest.mock import PropertyMock
import pytest
from pytest_mock import MockerFixture
from sqlalchemy.orm.session import Session
from superset import db
@pytest.fixture
def session_with_tags(session: Session):
from flask_appbuilder.security.sqla.models import User
from superset.tags.models import Tag, TagType
engine = session.get_bind()
Tag.metadata.create_all(engine) # pylint: disable=no-member
User.metadata.create_all(engine) # pylint: disable=no-member
owner = User(
first_name="owner", last_name="owner", username="owner", email="owner@x.com"
)
other = User(
first_name="other", last_name="other", username="other", email="other@x.com"
)
session.add(owner)
session.add(other)
session.flush()
owned_tag = Tag(
name="owned_tag",
type=TagType.custom,
created_by_fk=owner.id,
created_by=owner,
)
system_tag = Tag(name="type:some_type", type=TagType.type)
session.add(owned_tag)
session.add(system_tag)
session.commit()
return session
def test_delete_tags_command_admin_can_delete_any_custom_tag(
session_with_tags: Session, mocker: MockerFixture
):
from superset.commands.tag.delete import DeleteTagsCommand
from superset.tags.models import Tag
mocker.patch(
"superset.security.SupersetSecurityManager.is_admin", return_value=True
)
DeleteTagsCommand(["owned_tag"]).run()
assert db.session.query(Tag).filter_by(name="owned_tag").one_or_none() is None
def test_delete_tags_command_creator_can_delete_own_tag(
session_with_tags: Session, mocker: MockerFixture
):
from flask_appbuilder.security.sqla.models import User
from superset.commands.tag.delete import DeleteTagsCommand
from superset.tags.models import Tag
owner = db.session.query(User).filter_by(username="owner").one()
mocker.patch(
"superset.security.SupersetSecurityManager.is_admin", return_value=False
)
mocker.patch(
"superset.security.SupersetSecurityManager.current_user",
new_callable=PropertyMock,
return_value=owner,
)
DeleteTagsCommand(["owned_tag"]).run()
assert db.session.query(Tag).filter_by(name="owned_tag").one_or_none() is None
def test_delete_tags_command_non_creator_non_admin_denied(
session_with_tags: Session, mocker: MockerFixture
):
"""Regression test: DeleteTagsCommand.validate previously checked only
that each named tag existed, letting any user with can_delete on Tag
(default Gamma) bulk-delete tags -- and every association they carry --
they neither created nor own.
"""
from flask_appbuilder.security.sqla.models import User
from superset.commands.tag.delete import DeleteTagsCommand
from superset.commands.tag.exceptions import TagInvalidError
from superset.tags.models import Tag
other = db.session.query(User).filter_by(username="other").one()
mocker.patch(
"superset.security.SupersetSecurityManager.is_admin", return_value=False
)
mocker.patch(
"superset.security.SupersetSecurityManager.current_user",
new_callable=PropertyMock,
return_value=other,
)
with pytest.raises(TagInvalidError):
DeleteTagsCommand(["owned_tag"]).run()
# the tag must survive the denied deletion
assert db.session.query(Tag).filter_by(name="owned_tag").one_or_none() is not None
def test_delete_tags_command_refuses_system_tag_even_for_admin(
session_with_tags: Session, mocker: MockerFixture
):
"""System-generated tags (type:*, editor:*, favorited_by:*) are
maintained by Superset itself and must not be deletable through the
bulk route, regardless of the caller's role.
"""
from superset.commands.tag.delete import DeleteTagsCommand
from superset.commands.tag.exceptions import TagInvalidError
from superset.tags.models import Tag
mocker.patch(
"superset.security.SupersetSecurityManager.is_admin", return_value=True
)
with pytest.raises(TagInvalidError):
DeleteTagsCommand(["type:some_type"]).run()
assert (
db.session.query(Tag).filter_by(name="type:some_type").one_or_none() is not None
)
def test_delete_tags_command_refused_tag_reports_normalized_messages(
session_with_tags: Session, mocker: MockerFixture
):
"""Regression test: DeleteTagsCommand.validate previously appended a
plain CommandException (TagDeleteFailedError) into the TagInvalidError
it raises, which crashed with AttributeError as soon as anything called
.normalized_messages() on that TagInvalidError (as the single-object
DELETE /api/v1/tag/<pk> route does). Every exception composited into
TagInvalidError must be a ValidationError so normalized_messages() can
aggregate it.
"""
from superset.commands.tag.delete import DeleteTagsCommand
from superset.commands.tag.exceptions import TagInvalidError
mocker.patch(
"superset.security.SupersetSecurityManager.is_admin", return_value=True
)
with pytest.raises(TagInvalidError) as excinfo:
DeleteTagsCommand(["type:some_type"]).run()
messages = excinfo.value.normalized_messages()
assert "tags" in messages
assert "system tag" in messages["tags"][0]
def test_delete_tags_command_not_found_reports_normalized_messages(
session_with_tags: Session, mocker: MockerFixture
):
"""A nonexistent tag name is also composited into TagInvalidError; it
must likewise support normalized_messages() without raising.
"""
from superset.commands.tag.delete import DeleteTagsCommand
from superset.commands.tag.exceptions import TagInvalidError
mocker.patch(
"superset.security.SupersetSecurityManager.is_admin", return_value=True
)
with pytest.raises(TagInvalidError) as excinfo:
DeleteTagsCommand(["does_not_exist"]).run()
messages = excinfo.value.normalized_messages()
assert "tags" in messages
assert "not found" in messages["tags"][0]
@@ -264,3 +264,155 @@ def test_update_command_remove_all_tagged_objects(
assert (
len(db.session.query(TaggedObject).filter_by(tag_id=updated_tag.id).all()) == 0
)
def test_update_command_skips_removal_of_inaccessible_objects(
session_with_data: Session, mocker: MockerFixture
):
"""Associations on objects the user cannot modify must survive an update.
Regression test: the deletion branch of ``create_tag_relationship``
removed every association absent from the submitted set with no
per-object check, so a low-privilege user could strip a tag from
objects they are not allowed to modify (or from every object, by
sending an empty ``objects_to_tag``).
"""
from superset.commands.tag.create import CreateCustomTagWithRelationshipsCommand
from superset.commands.tag.update import UpdateTagCommand
from superset.daos.tag import TagDAO
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.tags.models import ObjectType, TaggedObject
dashboard = db.session.query(Dashboard).first()
chart = db.session.query(Slice).first()
mocker.patch(
"superset.security.SupersetSecurityManager.is_admin", return_value=True
)
mocker.patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart)
mocker.patch(
"superset.daos.dashboard.DashboardDAO.find_by_id", return_value=dashboard
)
# An admin tags both a dashboard and a chart
CreateCustomTagWithRelationshipsCommand(
data={
"name": "test_tag",
"objects_to_tag": [
(ObjectType.dashboard, dashboard.id),
(ObjectType.chart, chart.id),
],
}
).run()
tag = TagDAO.find_by_name("test_tag")
assert len(tag.objects) == 2
# A non-admin who may modify the chart but not the dashboard submits
# only the chart: the dashboard association must not be deleted.
mocker.patch(
"superset.security.SupersetSecurityManager.is_admin", return_value=False
)
def can_modify(model):
return isinstance(model, Slice)
mocker.patch(
"superset.commands.tag.update.current_user_can_modify_object",
side_effect=can_modify,
)
mocker.patch(
"superset.commands.utils.current_user_can_modify_object",
side_effect=can_modify,
)
UpdateTagCommand(
tag.id,
{
"name": "test_tag",
"description": "test_description",
"objects_to_tag": [(ObjectType.chart, chart.id)],
},
).run()
remaining = {
(obj.object_type, obj.object_id)
for obj in db.session.query(TaggedObject).filter_by(tag_id=tag.id).all()
}
assert (ObjectType.dashboard, dashboard.id) in remaining
assert (ObjectType.chart, chart.id) in remaining
def test_update_command_empty_objects_to_tag_only_removes_accessible(
session_with_data: Session, mocker: MockerFixture
):
"""An empty/omitted objects_to_tag must not mass-delete every association.
A PUT with no objects_to_tag used to be treated as "delete every current
association" with zero per-object check. Now each deletion is
access-checked, so objects the caller cannot modify keep their
association.
"""
from superset.commands.tag.create import CreateCustomTagWithRelationshipsCommand
from superset.commands.tag.update import UpdateTagCommand
from superset.daos.tag import TagDAO
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.tags.models import ObjectType, TaggedObject
dashboard = db.session.query(Dashboard).first()
chart = db.session.query(Slice).first()
mocker.patch(
"superset.security.SupersetSecurityManager.is_admin", return_value=True
)
mocker.patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart)
mocker.patch(
"superset.daos.dashboard.DashboardDAO.find_by_id", return_value=dashboard
)
CreateCustomTagWithRelationshipsCommand(
data={
"name": "test_tag",
"objects_to_tag": [
(ObjectType.dashboard, dashboard.id),
(ObjectType.chart, chart.id),
],
}
).run()
tag = TagDAO.find_by_name("test_tag")
assert len(tag.objects) == 2
mocker.patch(
"superset.security.SupersetSecurityManager.is_admin", return_value=False
)
def can_modify(model):
return isinstance(model, Slice)
mocker.patch(
"superset.commands.tag.update.current_user_can_modify_object",
side_effect=can_modify,
)
mocker.patch(
"superset.commands.utils.current_user_can_modify_object",
side_effect=can_modify,
)
UpdateTagCommand(
tag.id,
{
"name": "test_tag",
"description": "test_description",
"objects_to_tag": [],
},
).run()
remaining = {
(obj.object_type, obj.object_id)
for obj in db.session.query(TaggedObject).filter_by(tag_id=tag.id).all()
}
assert (ObjectType.dashboard, dashboard.id) in remaining
assert (ObjectType.chart, chart.id) not in remaining
+51
View File
@@ -20,6 +20,7 @@ from unittest.mock import Mock, patch
import pytest
from superset.commands.theme.exceptions import (
SystemThemeInUseError,
SystemThemeProtectedError,
ThemeNotFoundError,
)
@@ -62,6 +63,8 @@ class TestUpdateThemeCommand:
# Arrange
mock_theme = Mock(spec=Theme)
mock_theme.is_system = False
mock_theme.is_system_default = False
mock_theme.is_system_dark = False
mock_theme_dao.find_by_id.return_value = mock_theme
command = UpdateThemeCommand(123, {"theme_name": "test"})
@@ -77,6 +80,8 @@ class TestUpdateThemeCommand:
# Arrange
mock_theme = Mock(spec=Theme)
mock_theme.is_system = False
mock_theme.is_system_default = False
mock_theme.is_system_dark = False
mock_updated_theme = Mock(spec=Theme)
mock_theme_dao.find_by_id.return_value = mock_theme
mock_theme_dao.update.return_value = mock_updated_theme
@@ -92,6 +97,52 @@ class TestUpdateThemeCommand:
mock_theme, {"theme_name": "updated_name"}
)
@patch("superset.commands.theme.update.security_manager")
@patch("superset.commands.theme.update.ThemeDAO")
def test_validate_system_default_theme_blocks_non_admin(
self, mock_theme_dao, mock_security_manager
):
"""Non-admins cannot update the active system-default/dark theme slot."""
mock_theme = Mock(spec=Theme)
mock_theme.is_system = False
mock_theme.is_system_default = True
mock_theme.is_system_dark = False
mock_theme_dao.find_by_id.return_value = mock_theme
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=False)
command = UpdateThemeCommand(123, {"json_data": "{}"})
with pytest.raises(SystemThemeInUseError):
command.validate()
@patch("superset.commands.theme.update.security_manager")
@patch("superset.commands.theme.update.ThemeDAO")
def test_validate_system_default_theme_allows_admin(
self, mock_theme_dao, mock_security_manager
):
"""Admins can still update the active system-default/dark theme slot."""
mock_theme = Mock(spec=Theme)
mock_theme.is_system = False
mock_theme.is_system_default = True
mock_theme.is_system_dark = False
mock_theme_dao.find_by_id.return_value = mock_theme
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
mock_security_manager.is_admin = Mock(return_value=True)
command = UpdateThemeCommand(123, {"json_data": "{}"})
command.validate() # Should not raise any exception
assert command._model == mock_theme
def test_theme_is_gamma_read_only():
"""Theme writes must require at least Alpha; Gamma only gets read access."""
from superset import security_manager
assert "Theme" in security_manager.GAMMA_READ_ONLY_MODEL_VIEWS
class TestSeedSystemThemesCommand:
"""Unit tests for SeedSystemThemesCommand"""
+17 -1
View File
@@ -1942,13 +1942,29 @@ def test_sanitize_svg_content_safe():
def test_sanitize_svg_content_removes_scripts():
"""Test that nh3 removes dangerous script content."""
"""Test that dangerous script content is removed."""
malicious_svg = '<svg><script>alert("xss")</script><rect/></svg>'
result = sanitize_svg_content(malicious_svg)
assert "script" not in result.lower()
assert "alert" not in result
def test_sanitize_svg_content_removes_script_with_attributes_on_closer():
"""A closing </script foo> tag is still a valid closer to browsers."""
malicious_svg = "<svg><script>fetch('/api/v1/me/')</script foo></svg>"
result = sanitize_svg_content(malicious_svg)
assert "script" not in result.lower()
assert "fetch" not in result
def test_sanitize_svg_content_removes_unterminated_script():
"""An unterminated <script> opener with no closing tag is still stripped."""
malicious_svg = "<svg><script>alert('xss')"
result = sanitize_svg_content(malicious_svg)
assert "script" not in result.lower()
assert "alert" not in result
def test_sanitize_url_relative():
"""Test that relative URLs are allowed."""
assert sanitize_url("/static/spinner.gif") == "/static/spinner.gif"