mirror of
https://github.com/apache/superset.git
synced 2026-08-27 10:31:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0ccab62d | ||
|
|
7f1b41444c | ||
|
|
8a32324184 | ||
|
|
4623d9219a | ||
|
|
dcac129b51 | ||
|
|
7196ce8a0c | ||
|
|
e7756aeef9 | ||
|
|
af859717a7 | ||
|
|
8fa48d73f6 | ||
|
|
90a3d93002 | ||
|
|
5ffb72151e | ||
|
|
c3ed8b312d | ||
|
|
9f505eb0cb |
@@ -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:
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
Generated
+23
-26
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user