fix: dataset update with invalid SQL query (#35543)

This commit is contained in:
Daniel Vaz Gaspar
2025-10-08 12:22:38 +01:00
committed by GitHub
parent 441e043bff
commit 50a5854b25
3 changed files with 174 additions and 30 deletions

View File

@@ -41,6 +41,7 @@ import {
VizType,
FeatureFlag,
isFeatureEnabled,
getClientErrorObject,
} from '@superset-ui/core';
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
import { useSelector, useDispatch } from 'react-redux';
@@ -227,39 +228,50 @@ export const SaveDatasetModal = ({
}
setLoading(true);
const [, key] = await Promise.all([
updateDataset(
datasource?.dbId,
datasetToOverwrite?.datasetid,
datasource?.sql,
datasource?.columns?.map(
(d: { column_name: string; type: string; is_dttm: boolean }) => ({
column_name: d.column_name,
type: d.type,
is_dttm: d.is_dttm,
}),
try {
const [, key] = await Promise.all([
updateDataset(
datasource?.dbId,
datasetToOverwrite?.datasetid,
datasource?.sql,
datasource?.columns?.map(
(d: { column_name: string; type: string; is_dttm: boolean }) => ({
column_name: d.column_name,
type: d.type,
is_dttm: d.is_dttm,
}),
),
datasetToOverwrite?.owners?.map((o: DatasetOwner) => o.id),
true,
),
datasetToOverwrite?.owners?.map((o: DatasetOwner) => o.id),
true,
),
postFormData(datasetToOverwrite.datasetid, 'table', {
...formDataWithDefaults,
datasource: `${datasetToOverwrite.datasetid}__table`,
...(defaultVizType === VizType.Table && {
all_columns: datasource?.columns?.map(column => column.column_name),
postFormData(datasetToOverwrite.datasetid, 'table', {
...formDataWithDefaults,
datasource: `${datasetToOverwrite.datasetid}__table`,
...(defaultVizType === VizType.Table && {
all_columns: datasource?.columns?.map(column => column.column_name),
}),
}),
}),
]);
setLoading(false);
]);
setLoading(false);
const url = mountExploreUrl(null, {
[URL_PARAMS.formDataKey.name]: key,
});
createWindow(url);
const url = mountExploreUrl(null, {
[URL_PARAMS.formDataKey.name]: key,
});
createWindow(url);
setShouldOverwriteDataset(false);
setDatasetName(getDefaultDatasetName());
onHide();
setShouldOverwriteDataset(false);
setDatasetName(getDefaultDatasetName());
onHide();
} catch (error) {
setLoading(false);
getClientErrorObject(error).then((e: { error: string }) => {
dispatch(
addDangerToast(
e.error || t('An error occurred while overwriting the dataset'),
),
);
});
}
};
const loadDatasetOverwriteOptions = useCallback(

View File

@@ -33,6 +33,7 @@ from superset.commands.dataset.exceptions import (
DatasetColumnNotFoundValidationError,
DatasetColumnsDuplicateValidationError,
DatasetColumnsExistsValidationError,
DatasetDataAccessIsNotAllowed,
DatasetExistsValidationError,
DatasetForbiddenError,
DatasetInvalidError,
@@ -46,7 +47,7 @@ from superset.commands.dataset.exceptions import (
from superset.connectors.sqla.models import SqlaTable
from superset.daos.dataset import DatasetDAO
from superset.datasets.schemas import FolderSchema
from superset.exceptions import SupersetSecurityException
from superset.exceptions import SupersetParseError, SupersetSecurityException
from superset.models.core import Database
from superset.sql.parse import Table
from superset.utils.decorators import on_error, transaction
@@ -166,6 +167,38 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
):
exceptions.append(DatasetExistsValidationError(table))
self._validate_sql_access(db, catalog, schema, exceptions)
def _validate_sql_access(
self,
db: Database,
catalog: str | None,
schema: str | None,
exceptions: list[ValidationError],
) -> None:
"""Validate SQL query access if SQL is being updated."""
# we know we have a valid model
self._model = cast(SqlaTable, self._model)
sql = self._properties.get("sql")
if sql and sql != self._model.sql:
try:
security_manager.raise_for_access(
database=db,
sql=sql,
catalog=catalog,
schema=schema,
)
except SupersetSecurityException as ex:
exceptions.append(DatasetDataAccessIsNotAllowed(ex.error.message))
except SupersetParseError as ex:
exceptions.append(
ValidationError(
f"Invalid SQL: {ex.error.message}",
field_name="sql",
)
)
def _validate_semantics(self, exceptions: list[ValidationError]) -> None:
# we know we have a valid model
self._model = cast(SqlaTable, self._model)

View File

@@ -70,6 +70,105 @@ def test_update_dataset_forbidden(mocker: MockerFixture) -> None:
UpdateDatasetCommand(1, {"name": "test"}).run()
def test_update_dataset_sql_authorized_schema(mocker: MockerFixture) -> None:
"""
Test that updating a dataset with SQL works when user has schema access.
"""
mock_dataset_dao = mocker.patch("superset.commands.dataset.update.DatasetDAO")
mock_database = mocker.MagicMock()
mock_database.id = 1
mock_database.get_default_catalog.return_value = "catalog"
mock_database.allow_multi_catalog = False
mock_dataset = mocker.MagicMock()
mock_dataset.database = mock_database
mock_dataset.catalog = "catalog"
mock_dataset.schema = "public"
mock_dataset.table_name = "test_table"
mock_dataset.owners = [] # No owners to avoid ownership computation issues
mock_dataset_dao.find_by_id.return_value = mock_dataset
mock_dataset_dao.get_database_by_id.return_value = mock_database
mock_dataset_dao.validate_update_uniqueness.return_value = True
mock_dataset_dao.update.return_value = mock_dataset
# Mock successful ownership check
mocker.patch(
"superset.commands.dataset.update.security_manager.raise_for_ownership",
)
# Mock security manager methods for owner computation
mocker.patch("superset.commands.utils.security_manager.is_admin", return_value=True)
# Mock security manager to allow access to the schema
mocker.patch(
"superset.commands.dataset.update.security_manager.raise_for_access",
)
# Update dataset with SQL - should work when user has access
result = UpdateDatasetCommand(
1, {"sql": "SELECT * FROM public.allowed_table"}
).run()
# Verify the update was called
assert result == mock_dataset
mock_dataset_dao.update.assert_called_once()
def test_update_dataset_sql_unauthorized_schema(mocker: MockerFixture) -> None:
"""
Test that updating a dataset with SQL to an unauthorized schema raises an error.
"""
mock_dataset_dao = mocker.patch("superset.commands.dataset.update.DatasetDAO")
mock_database = mocker.MagicMock()
mock_database.id = 1
mock_database.get_default_catalog.return_value = "catalog"
mock_database.allow_multi_catalog = False
mock_dataset = mocker.MagicMock()
mock_dataset.database = mock_database
mock_dataset.catalog = "catalog"
mock_dataset.schema = "public"
mock_dataset.table_name = "test_table"
mock_dataset.owners = [] # No owners to avoid ownership computation issues
mock_dataset_dao.find_by_id.return_value = mock_dataset
mock_dataset_dao.get_database_by_id.return_value = mock_database
mock_dataset_dao.validate_update_uniqueness.return_value = True
# Mock successful ownership check
mocker.patch(
"superset.commands.dataset.update.security_manager.raise_for_ownership",
)
# Mock security manager methods for owner computation
mocker.patch("superset.commands.utils.security_manager.is_admin", return_value=True)
# Mock security manager to raise error for SQL schema access
mocker.patch(
"superset.commands.dataset.update.security_manager.raise_for_access",
side_effect=SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.MISSING_OWNERSHIP_ERROR,
message="You don't have access to the 'restricted_schema' schema",
level=ErrorLevel.ERROR,
)
),
)
# Try to update dataset with SQL querying an unauthorized schema
with pytest.raises(DatasetInvalidError) as excinfo:
UpdateDatasetCommand(
1, {"sql": "SELECT * FROM restricted_schema.sensitive_table"}
).run()
# Check that the appropriate error message is in the exceptions
assert any(
"You don't have access to the 'restricted_schema' schema" in str(exc)
for exc in excinfo.value._exceptions
)
@pytest.mark.parametrize(
("payload, exception, error_msg"),
[