mirror of
https://github.com/apache/superset.git
synced 2026-08-12 19:20:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92c485464b |
@@ -17,7 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { createElement } from 'react';
|
||||
import { PickingInfo } from '@deck.gl/core';
|
||||
import { JsonObject, QueryFormData } from '@superset-ui/core';
|
||||
import {
|
||||
@@ -132,32 +131,6 @@ describe('commonLayerProps', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('clears a custom tooltip on hover-out instead of trailing the cursor', () => {
|
||||
// Regression test for a custom (Handlebars) deck.gl tooltip that stayed
|
||||
// visible and followed the mouse after leaving a feature.
|
||||
const setTooltip = jest.fn();
|
||||
const customContent = createElement('div', {
|
||||
'data-tooltip-type': 'custom',
|
||||
});
|
||||
const props = commonLayerProps({
|
||||
formData: { ...partialformData } as QueryFormData,
|
||||
setTooltip: setTooltip as any,
|
||||
setTooltipContent: (() => customContent) as any,
|
||||
});
|
||||
|
||||
// Hovering a feature shows the custom tooltip.
|
||||
props.onHover?.({ picked: true, x: 10, y: 20 } as any);
|
||||
expect(setTooltip).toHaveBeenLastCalledWith({
|
||||
content: customContent,
|
||||
x: 10,
|
||||
y: 20,
|
||||
});
|
||||
|
||||
// Moving off the feature must dismiss it, not keep repositioning it.
|
||||
props.onHover?.({ picked: false, x: 30, y: 40 } as any);
|
||||
expect(setTooltip).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
test('calls onSelect when table_filter is enabled', () => {
|
||||
const formData = {
|
||||
...partialformData,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, isValidElement } from 'react';
|
||||
import {
|
||||
ascending as d3ascending,
|
||||
quantile as d3quantile,
|
||||
@@ -70,16 +70,19 @@ export function commonLayerProps({
|
||||
if (setTooltipContent) {
|
||||
let currentTooltipContent: ReactNode = null;
|
||||
|
||||
const isCustomTooltip = (content: ReactNode): boolean =>
|
||||
isValidElement(content) &&
|
||||
content.props?.['data-tooltip-type'] === 'custom';
|
||||
|
||||
onHover = (o: JsonObject) => {
|
||||
if (o.picked) {
|
||||
currentTooltipContent = setTooltipContent(o);
|
||||
}
|
||||
|
||||
// Only show the tooltip while a feature is actually hovered. Custom
|
||||
// (Handlebars) tooltips used to stay visible and follow the cursor
|
||||
// after hover-out because their content was kept on screen even when
|
||||
// nothing was picked.
|
||||
if (o.picked && currentTooltipContent) {
|
||||
if (
|
||||
currentTooltipContent &&
|
||||
(o.picked || isCustomTooltip(currentTooltipContent))
|
||||
) {
|
||||
setTooltip({
|
||||
content: currentTooltipContent,
|
||||
x: o.x,
|
||||
|
||||
@@ -646,6 +646,8 @@ class ChartDataRestApi(ChartRestApi):
|
||||
return self.response_422(message=sanitize_error_message(exc.message))
|
||||
except ChartDataQueryFailedError as exc:
|
||||
return self.response_400(message=sanitize_error_message(exc.message))
|
||||
except QueryObjectValidationError as exc:
|
||||
return self.response_400(message=sanitize_error_message(exc.message))
|
||||
|
||||
# Log is_cached if extra payload callback is provided
|
||||
materialized_result = result.materialize()
|
||||
|
||||
@@ -73,7 +73,6 @@ class SyncPermissionsCommand(BaseCommand):
|
||||
self.username = username
|
||||
self._old_db_connection_name: str | None = old_db_connection_name
|
||||
self._db_connection: Database | None = db_connection
|
||||
self._user_id: int | None = None
|
||||
|
||||
self.async_mode: bool = app.config["SYNC_DB_PERMISSIONS_IN_ASYNC_MODE"]
|
||||
|
||||
@@ -100,15 +99,11 @@ class SyncPermissionsCommand(BaseCommand):
|
||||
if not self._db_connection:
|
||||
raise DatabaseNotFoundError()
|
||||
|
||||
# Need user info to impersonate for OAuth2 connections. The id is
|
||||
# captured here, at validation/enqueue time, so that an async run of
|
||||
# this command binds to whoever held the username right now, rather
|
||||
# than re-resolving the (mutable) username at execution time.
|
||||
if not self.username or not (
|
||||
user := security_manager.get_user_by_username(self.username)
|
||||
# Need user info to impersonate for OAuth2 connections
|
||||
if not self.username or not security_manager.get_user_by_username(
|
||||
self.username
|
||||
):
|
||||
raise UserNotFoundInSessionError()
|
||||
self._user_id = user.id
|
||||
|
||||
with self.db_connection.get_sqla_engine() as engine:
|
||||
try:
|
||||
@@ -131,7 +126,7 @@ class SyncPermissionsCommand(BaseCommand):
|
||||
self.validate()
|
||||
if self.async_mode:
|
||||
sync_database_permissions_task.delay(
|
||||
self.db_connection_id, self._user_id, self.old_db_connection_name
|
||||
self.db_connection_id, self.username, self.old_db_connection_name
|
||||
)
|
||||
return
|
||||
|
||||
@@ -318,14 +313,14 @@ class SyncPermissionsCommand(BaseCommand):
|
||||
|
||||
@celery_app.task(name="sync_database_permissions", soft_time_limit=600)
|
||||
def sync_database_permissions_task(
|
||||
database_id: int, user_id: int, old_db_connection_name: str
|
||||
database_id: int, username: str, old_db_connection_name: str
|
||||
) -> None:
|
||||
"""
|
||||
Celery task that triggers the SyncPermissionsCommand in async mode.
|
||||
"""
|
||||
with app.test_request_context():
|
||||
try:
|
||||
user = security_manager.get_user_by_id(user_id)
|
||||
user = security_manager.get_user_by_username(username)
|
||||
if not user:
|
||||
raise UserNotFoundInSessionError()
|
||||
g.user = user
|
||||
@@ -341,7 +336,7 @@ def sync_database_permissions_task(
|
||||
|
||||
SyncPermissionsCommand(
|
||||
database_id,
|
||||
user.username,
|
||||
username,
|
||||
old_db_connection_name=old_db_connection_name,
|
||||
db_connection=db_connection,
|
||||
).sync_database_permissions()
|
||||
|
||||
@@ -21,7 +21,6 @@ from typing import Any, Optional
|
||||
from flask import current_app as app
|
||||
from flask_babel import gettext as __
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.database.exceptions import (
|
||||
DatabaseNotFoundError,
|
||||
@@ -70,17 +69,6 @@ class ValidateSQLCommand(BaseCommand):
|
||||
schema = self._properties.get("schema")
|
||||
template_params = self._properties.get("template_params") or {}
|
||||
|
||||
# Check access before rendering the Jinja template (mirrors the SQL
|
||||
# Lab execute path).
|
||||
security_manager.raise_for_access(
|
||||
database=self._model,
|
||||
sql=sql,
|
||||
catalog=catalog,
|
||||
schema=schema,
|
||||
template_params=template_params,
|
||||
force_dataset_match=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Render Jinja templates to handle template syntax before
|
||||
# validation. Note: The ENABLE_TEMPLATE_PROCESSING feature flag is
|
||||
|
||||
@@ -123,66 +123,13 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
|
||||
# we know we have a valid model
|
||||
self._model = cast(SqlaTable, self._model)
|
||||
database_id = self._properties.pop("database_id", None)
|
||||
new_db_connection = self._get_new_database_connection(database_id, exceptions)
|
||||
db = new_db_connection or self._model.database
|
||||
database_changed = new_db_connection is not None
|
||||
|
||||
# Detect a caller-supplied change to the source binding, inspected
|
||||
# before the catalog normalization below injects derived values.
|
||||
source_changed = database_changed or any(
|
||||
field in self._properties
|
||||
and self._properties[field] != getattr(self._model, field)
|
||||
for field in ("catalog", "schema", "table_name")
|
||||
)
|
||||
|
||||
catalog, schema, table = self._resolve_catalog_schema_table(db, exceptions)
|
||||
|
||||
# Repointing to a different database connection requires access to
|
||||
# that connection, independent of the caller's editorship of this
|
||||
# dataset -- only persist the change once that's confirmed.
|
||||
if new_db_connection:
|
||||
self._apply_database_repoint(new_db_connection, table, exceptions)
|
||||
|
||||
# Validate uniqueness
|
||||
if not DatasetDAO.validate_update_uniqueness(
|
||||
db,
|
||||
table,
|
||||
self._model_id,
|
||||
):
|
||||
exceptions.append(DatasetExistsValidationError(table))
|
||||
|
||||
# Repointing a physical dataset (or converting a virtual dataset to a
|
||||
# physical one) runs the same data-access check as the create path.
|
||||
# Skip it when the database connection itself changed: that case is
|
||||
# already covered by the repoint check above, against the same
|
||||
# (db, table) pair.
|
||||
sql = self._properties.get("sql", self._model.sql)
|
||||
if (
|
||||
not new_db_connection
|
||||
and not sql
|
||||
and (source_changed or ("sql" in self._properties and self._model.sql))
|
||||
):
|
||||
self._validate_table_access(db, table, exceptions)
|
||||
|
||||
self._validate_sql_access(db, catalog, schema, exceptions)
|
||||
|
||||
def _get_new_database_connection(
|
||||
self, database_id: int | None, exceptions: list[ValidationError]
|
||||
) -> Database | None:
|
||||
# we know we have a valid model
|
||||
self._model = cast(SqlaTable, self._model)
|
||||
if database_id and database_id != self._model.database.id:
|
||||
if new_db_connection := DatasetDAO.get_database_by_id(database_id):
|
||||
return new_db_connection
|
||||
exceptions.append(DatabaseNotFoundValidationError())
|
||||
return None
|
||||
|
||||
def _resolve_catalog_schema_table(
|
||||
self, db: Database, exceptions: list[ValidationError]
|
||||
) -> tuple[str | None, str | None, Table]:
|
||||
# we know we have a valid model
|
||||
self._model = cast(SqlaTable, self._model)
|
||||
catalog = self._properties.get("catalog")
|
||||
new_db_connection: Database | None = None
|
||||
|
||||
if database_id and database_id != self._model.database.id:
|
||||
if not (new_db_connection := DatasetDAO.get_database_by_id(database_id)):
|
||||
exceptions.append(DatabaseNotFoundValidationError())
|
||||
db = new_db_connection or self._model.database
|
||||
default_catalog = db.get_default_catalog()
|
||||
|
||||
# If multi-catalog is disabled, and catalog provided is not
|
||||
@@ -214,28 +161,29 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
|
||||
schema,
|
||||
catalog,
|
||||
)
|
||||
return catalog, schema, table
|
||||
|
||||
def _apply_database_repoint(
|
||||
self,
|
||||
new_db_connection: Database,
|
||||
table: Table,
|
||||
exceptions: list[ValidationError],
|
||||
) -> None:
|
||||
try:
|
||||
security_manager.raise_for_access(database=new_db_connection, table=table)
|
||||
except SupersetSecurityException as ex:
|
||||
exceptions.append(DatasetDataAccessIsNotAllowed(ex.error.message))
|
||||
else:
|
||||
self._properties["database"] = new_db_connection
|
||||
# Repointing to a different database connection requires access to
|
||||
# that connection, independent of the caller's editorship of this
|
||||
# dataset -- only persist the change once that's confirmed.
|
||||
if new_db_connection:
|
||||
try:
|
||||
security_manager.raise_for_access(
|
||||
database=new_db_connection, table=table
|
||||
)
|
||||
except SupersetSecurityException as ex:
|
||||
exceptions.append(DatasetDataAccessIsNotAllowed(ex.error.message))
|
||||
else:
|
||||
self._properties["database"] = new_db_connection
|
||||
|
||||
def _validate_table_access(
|
||||
self, db: Database, table: Table, exceptions: list[ValidationError]
|
||||
) -> None:
|
||||
try:
|
||||
security_manager.raise_for_access(database=db, table=table)
|
||||
except SupersetSecurityException as ex:
|
||||
exceptions.append(DatasetDataAccessIsNotAllowed(ex.error.message))
|
||||
# Validate uniqueness
|
||||
if not DatasetDAO.validate_update_uniqueness(
|
||||
db,
|
||||
table,
|
||||
self._model_id,
|
||||
):
|
||||
exceptions.append(DatasetExistsValidationError(table))
|
||||
|
||||
self._validate_sql_access(db, catalog, schema, exceptions)
|
||||
|
||||
def _validate_sql_access(
|
||||
self,
|
||||
@@ -278,9 +226,6 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
|
||||
self._validate_metrics(metrics, exceptions)
|
||||
self._validate_expressions(metrics, "metrics", exceptions)
|
||||
|
||||
if predicate := self._properties.get("fetch_values_predicate"):
|
||||
self._validate_fetch_values_predicate(predicate, exceptions)
|
||||
|
||||
if folders := self._properties.get("folders"):
|
||||
valid_uuids: set[UUID] = set()
|
||||
if metrics:
|
||||
@@ -389,34 +334,6 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand):
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_fetch_values_predicate(
|
||||
self,
|
||||
predicate: str,
|
||||
exceptions: list[ValidationError],
|
||||
) -> None:
|
||||
"""
|
||||
Validate ``fetch_values_predicate`` with the same parser-based
|
||||
validator used for stored column and metric expressions.
|
||||
"""
|
||||
self._model = cast(SqlaTable, self._model)
|
||||
database = self._properties.get("database") or self._model.database
|
||||
catalog = self._properties.get("catalog", self._model.catalog)
|
||||
schema = self._properties.get("schema", self._model.schema)
|
||||
try:
|
||||
validate_stored_expression(database, catalog, schema, predicate)
|
||||
except (SupersetSecurityException, QueryClauseValidationException) as ex:
|
||||
message = (
|
||||
ex.error.message
|
||||
if isinstance(ex, SupersetSecurityException)
|
||||
else ex.message
|
||||
)
|
||||
exceptions.append(
|
||||
ValidationError(
|
||||
message,
|
||||
field_name="fetch_values_predicate",
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_duplicates(data: list[dict[str, Any]], key: str) -> list[str]:
|
||||
duplicates = [
|
||||
|
||||
@@ -39,9 +39,7 @@ from superset.commands.report.exceptions import (
|
||||
AlertValidatorConfigError,
|
||||
ReportScheduleExecutorNotFoundError,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.reports.models import ReportSchedule, ReportScheduleValidatorType
|
||||
from superset.sql.parse import SQLScript
|
||||
from superset.tasks.utils import get_executor
|
||||
from superset.utils import json
|
||||
from superset.utils.core import override_user
|
||||
@@ -183,18 +181,6 @@ class AlertCommand(BaseCommand):
|
||||
"execution_id": self._execution_id,
|
||||
}
|
||||
|
||||
def _validate_rendered_sql(self, rendered_sql: str) -> None:
|
||||
"""
|
||||
Enforce SQL-level constraints on the rendered alert query: a single
|
||||
statement, and no mutations unless the database allows DML.
|
||||
"""
|
||||
database = self._report_schedule.database
|
||||
script = SQLScript(rendered_sql, engine=database.backend)
|
||||
if len(script.statements) != 1:
|
||||
raise AlertQueryError(message=_("Alert query must be a single statement"))
|
||||
if script.has_mutation() and not database.allow_dml:
|
||||
raise AlertQueryError(message=_("Alert query must be read-only"))
|
||||
|
||||
@logs_context(context_func=_get_alert_metadata_from_object)
|
||||
def _execute_query(self) -> pd.DataFrame:
|
||||
"""
|
||||
@@ -210,7 +196,6 @@ class AlertCommand(BaseCommand):
|
||||
|
||||
try:
|
||||
rendered_sql = sql_template.process_template(self._report_schedule.sql)
|
||||
self._validate_rendered_sql(rendered_sql)
|
||||
limited_rendered_sql = self._report_schedule.database.apply_limit_to_sql(
|
||||
rendered_sql, ALERT_SQL_LIMIT
|
||||
)
|
||||
@@ -235,18 +220,6 @@ class AlertCommand(BaseCommand):
|
||||
raise ReportScheduleExecutorNotFoundError(username)
|
||||
|
||||
with override_user(user):
|
||||
# Run table-level authorization as the executing user against
|
||||
# the rendered SQL.
|
||||
try:
|
||||
security_manager.raise_for_access(
|
||||
database=self._report_schedule.database,
|
||||
sql=rendered_sql,
|
||||
force_dataset_match=True,
|
||||
)
|
||||
except SupersetSecurityException as ex:
|
||||
raise AlertQueryError(
|
||||
message=_("Alert query failed the authorization check")
|
||||
) from ex
|
||||
start = default_timer()
|
||||
df = self._report_schedule.database.get_df(sql=limited_rendered_sql)
|
||||
stop = default_timer()
|
||||
@@ -263,10 +236,6 @@ class AlertCommand(BaseCommand):
|
||||
# A missing executor user is a configuration problem, not a transient
|
||||
# query error; surface the typed error rather than masking it.
|
||||
raise
|
||||
except AlertQueryError:
|
||||
# Re-raise the typed validation/authorization errors as-is instead
|
||||
# of masking them behind the generic error below.
|
||||
raise
|
||||
except Exception as ex:
|
||||
logger.warning("An error occurred when running alert query")
|
||||
# The exception message here can reveal to much information to malicious
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from croniter import croniter, CroniterBadDateError
|
||||
@@ -26,9 +25,6 @@ from marshmallow import ValidationError
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.report.exceptions import (
|
||||
AlertQueryDataAccessValidationError,
|
||||
AlertQueryDMLNotAllowedValidationError,
|
||||
AlertQueryMultipleStatementsValidationError,
|
||||
ChartNotFoundValidationError,
|
||||
ChartNotSavedValidationError,
|
||||
DashboardNotFoundValidationError,
|
||||
@@ -42,22 +38,16 @@ from superset.commands.report.exceptions import (
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.exceptions import SupersetParseError, SupersetSecurityException
|
||||
from superset.models.core import Database
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.reports.models import (
|
||||
ReportCreationMethod,
|
||||
ReportScheduleType,
|
||||
)
|
||||
from superset.reports.types import ReportScheduleExtra
|
||||
from superset.sql.parse import SQLScript
|
||||
from superset.utils import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches balanced Jinja blocks so templated alert SQL can be recognized and
|
||||
# its static validation deferred to execution time.
|
||||
_JINJA_BLOCK_RE = re.compile(r"\{\{.*?\}\}|\{%.*?%\}|\{#.*?#\}", re.DOTALL)
|
||||
|
||||
|
||||
class BaseReportScheduleCommand(BaseCommand):
|
||||
_properties: dict[str, Any]
|
||||
@@ -68,52 +58,6 @@ class BaseReportScheduleCommand(BaseCommand):
|
||||
def validate(self) -> None:
|
||||
pass
|
||||
|
||||
def validate_alert_query(
|
||||
self,
|
||||
database: Database,
|
||||
sql: str,
|
||||
exceptions: list[ValidationError],
|
||||
) -> None:
|
||||
"""
|
||||
Validate alert SQL at save time: it must parse as a single statement,
|
||||
must not mutate state unless the database allows DML, and the saving
|
||||
user must be authorized for the tables it reads. Templated SQL that
|
||||
only parses after rendering is validated at execution time on the
|
||||
rendered query.
|
||||
"""
|
||||
contains_jinja = bool(_JINJA_BLOCK_RE.search(sql))
|
||||
try:
|
||||
script = SQLScript(sql, engine=database.backend)
|
||||
except SupersetParseError as ex:
|
||||
if not contains_jinja:
|
||||
exceptions.append(
|
||||
ValidationError(
|
||||
_("Invalid SQL: %(error)s", error=ex.error.message),
|
||||
field_name="sql",
|
||||
)
|
||||
)
|
||||
return
|
||||
if len(script.statements) != 1:
|
||||
exceptions.append(AlertQueryMultipleStatementsValidationError())
|
||||
return
|
||||
if script.has_mutation() and not database.allow_dml:
|
||||
exceptions.append(AlertQueryDMLNotAllowedValidationError())
|
||||
return
|
||||
try:
|
||||
security_manager.raise_for_access(
|
||||
database=database, sql=sql, force_dataset_match=True
|
||||
)
|
||||
except SupersetSecurityException as ex:
|
||||
exceptions.append(AlertQueryDataAccessValidationError(ex.error.message))
|
||||
except SupersetParseError as ex:
|
||||
if not contains_jinja:
|
||||
exceptions.append(
|
||||
ValidationError(
|
||||
_("Invalid SQL: %(error)s", error=ex.error.message),
|
||||
field_name="sql",
|
||||
)
|
||||
)
|
||||
|
||||
def _check_object_access(
|
||||
self,
|
||||
object_id: int,
|
||||
|
||||
@@ -129,8 +129,6 @@ class CreateReportScheduleCommand(CreateMixin, BaseReportScheduleCommand):
|
||||
database_id = self._properties["database"]
|
||||
if database := DatabaseDAO.find_by_id(database_id):
|
||||
self._properties["database"] = database
|
||||
if sql := self._properties.get("sql"):
|
||||
self.validate_alert_query(database, sql, exceptions)
|
||||
else:
|
||||
exceptions.append(DatabaseNotFoundValidationError())
|
||||
except KeyError:
|
||||
|
||||
@@ -40,38 +40,6 @@ class DatabaseNotFoundValidationError(ValidationError):
|
||||
super().__init__(_("Database does not exist"), field_name="database")
|
||||
|
||||
|
||||
class AlertQueryMultipleStatementsValidationError(ValidationError):
|
||||
"""
|
||||
Marshmallow validation error for alert SQL containing multiple statements
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
_("Alert query must be a single statement"),
|
||||
field_name="sql",
|
||||
)
|
||||
|
||||
|
||||
class AlertQueryDMLNotAllowedValidationError(ValidationError):
|
||||
"""
|
||||
Marshmallow validation error for alert SQL that mutates state on a
|
||||
database that does not allow DML
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_("Alert query must be read-only"), field_name="sql")
|
||||
|
||||
|
||||
class AlertQueryDataAccessValidationError(ValidationError):
|
||||
"""
|
||||
Marshmallow validation error for alert SQL referencing tables the user
|
||||
is not authorized to query
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, field_name="sql")
|
||||
|
||||
|
||||
class ReportScheduleDatabaseNotAllowedValidationError(ValidationError):
|
||||
"""
|
||||
Marshmallow validation error for database reference on a Report type schedule
|
||||
|
||||
@@ -149,19 +149,6 @@ class UpdateReportScheduleCommand(UpdateMixin, BaseReportScheduleCommand):
|
||||
exceptions.append(DatabaseNotFoundValidationError())
|
||||
self._properties["database"] = database
|
||||
|
||||
# Re-validate the alert SQL whenever the SQL or the target database
|
||||
# changes, using the stored value for whichever half is absent from
|
||||
# the payload.
|
||||
if report_type == ReportScheduleType.ALERT and (
|
||||
"sql" in self._properties or "database" in self._properties
|
||||
):
|
||||
effective_database = (
|
||||
self._properties.get("database") or self._model.database
|
||||
)
|
||||
effective_sql = self._properties.get("sql", self._model.sql)
|
||||
if effective_database and effective_sql:
|
||||
self.validate_alert_query(effective_database, effective_sql, exceptions)
|
||||
|
||||
# validate report frequency
|
||||
try:
|
||||
self.validate_report_frequency(
|
||||
|
||||
@@ -23,9 +23,8 @@ from flask import current_app as app
|
||||
from flask_babel import gettext as __
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import is_feature_enabled, security_manager
|
||||
from superset import db, is_feature_enabled, security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.daos.database import DatabaseDAO
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
SupersetDisallowedSQLFunctionException,
|
||||
@@ -67,10 +66,8 @@ class QueryEstimationCommand(BaseCommand):
|
||||
self._catalog = params.get("catalog")
|
||||
|
||||
def validate(self) -> None:
|
||||
# Load the database through the DAO so ``DatabaseFilter`` scopes
|
||||
# visibility the same way it does on the SQL Lab execution path.
|
||||
database = DatabaseDAO.find_by_id(self._database_id)
|
||||
if not database:
|
||||
self._database = db.session.query(Database).get(self._database_id)
|
||||
if not self._database:
|
||||
raise SupersetErrorException(
|
||||
SupersetError(
|
||||
message=__("The database could not be found"),
|
||||
@@ -79,17 +76,7 @@ class QueryEstimationCommand(BaseCommand):
|
||||
),
|
||||
status=404,
|
||||
)
|
||||
self._database = database
|
||||
# Pass the SQL so table-level authorization runs, mirroring the SQL
|
||||
# Lab execution path. Runs before Jinja templating in ``run()``.
|
||||
security_manager.raise_for_access(
|
||||
database=self._database,
|
||||
sql=self._sql,
|
||||
catalog=self._catalog,
|
||||
schema=self._schema or None,
|
||||
template_params=self._template_params,
|
||||
force_dataset_match=True,
|
||||
)
|
||||
security_manager.raise_for_access(database=self._database)
|
||||
|
||||
def _apply_sql_security(self, sql: str) -> str:
|
||||
"""Run the disallowed-function/table, DML and RLS controls against the
|
||||
@@ -163,7 +150,6 @@ class QueryEstimationCommand(BaseCommand):
|
||||
|
||||
sql = self._sql
|
||||
if self._template_params:
|
||||
# Access is already checked in validate() before any rendering.
|
||||
template_processor = get_template_processor(self._database)
|
||||
try:
|
||||
sql = template_processor.process_template(sql, **self._template_params)
|
||||
|
||||
@@ -81,7 +81,6 @@ from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
ColumnNotFoundException,
|
||||
DatasetInvalidPermissionEvaluationException,
|
||||
QueryClauseValidationException,
|
||||
QueryObjectValidationError,
|
||||
SupersetParseError,
|
||||
SupersetSecurityException,
|
||||
@@ -104,7 +103,6 @@ from superset.models.helpers import (
|
||||
SoftDeleteMixin,
|
||||
SQLA_QUERY_KEYS,
|
||||
validate_adhoc_subquery,
|
||||
validate_rendered_expression,
|
||||
validate_stored_expression_at_query_time,
|
||||
)
|
||||
from superset.models.slice import Slice
|
||||
@@ -1217,14 +1215,6 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
|
||||
msg=msg,
|
||||
)
|
||||
) from ex
|
||||
if expression != self.expression:
|
||||
# Re-check the rendered expression before embedding it.
|
||||
expression = validate_rendered_expression(
|
||||
expression,
|
||||
self.database,
|
||||
self.table.catalog if self.table else None,
|
||||
self.table.schema if self.table else None,
|
||||
)
|
||||
expression = self._validate_stored_expression(expression)
|
||||
col = literal_column(expression, type_=type_)
|
||||
else:
|
||||
@@ -1273,14 +1263,6 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
|
||||
msg=msg,
|
||||
)
|
||||
) from ex
|
||||
if expression != self.expression:
|
||||
# Re-check the rendered expression before embedding it.
|
||||
expression = validate_rendered_expression(
|
||||
expression,
|
||||
self.database,
|
||||
self.table.catalog if self.table else None,
|
||||
self.table.schema if self.table else None,
|
||||
)
|
||||
expression = self._validate_stored_expression(expression)
|
||||
col = literal_column(expression, type_=type_)
|
||||
else:
|
||||
@@ -1386,14 +1368,6 @@ class SqlMetric(AuditMixinNullable, ImportExportMixin, CertificationMixin, Model
|
||||
msg=msg,
|
||||
)
|
||||
) from ex
|
||||
if expression != self.expression:
|
||||
# Re-check the rendered expression before embedding it.
|
||||
expression = validate_rendered_expression(
|
||||
expression,
|
||||
self.table.database,
|
||||
self.table.catalog,
|
||||
self.table.schema,
|
||||
)
|
||||
|
||||
if expression:
|
||||
expression = self._validate_stored_expression(expression)
|
||||
@@ -1677,18 +1651,7 @@ class SqlaTable(
|
||||
def dttm_cols(self) -> list[str]:
|
||||
l = [c.column_name for c in self.columns if c.is_dttm] # noqa: E741
|
||||
if self.main_dttm_col and self.main_dttm_col not in l:
|
||||
# Only treat ``main_dttm_col`` as a datetime column when the column it
|
||||
# points to is actually temporal. A column whose "Is Temporal" flag was
|
||||
# removed must not keep being reported as a datetime column just because
|
||||
# it is still referenced by ``main_dttm_col`` (#30510). When the column
|
||||
# is not present on the dataset, fall back to the legacy behavior of
|
||||
# trusting ``main_dttm_col``.
|
||||
main_dttm_column: TableColumn | None = next(
|
||||
(c for c in self.columns if c.column_name == self.main_dttm_col),
|
||||
None,
|
||||
)
|
||||
if main_dttm_column is None or main_dttm_column.is_dttm:
|
||||
l.append(self.main_dttm_col)
|
||||
l.append(self.main_dttm_col)
|
||||
return l
|
||||
|
||||
@property
|
||||
@@ -1806,24 +1769,7 @@ class SqlaTable(
|
||||
fetch_values_predicate
|
||||
)
|
||||
try:
|
||||
# Re-validate the rendered predicate with the same parser policy
|
||||
# as stored column and metric expressions before embedding it.
|
||||
validate_stored_expression(
|
||||
self.database, self.catalog, self.schema, fetch_values_predicate
|
||||
)
|
||||
return self.text(fetch_values_predicate)
|
||||
except (SupersetSecurityException, QueryClauseValidationException) as ex:
|
||||
message = (
|
||||
ex.error.message
|
||||
if isinstance(ex, SupersetSecurityException)
|
||||
else ex.message
|
||||
)
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"Fetch values predicate failed SQL validation: %(msg)s",
|
||||
msg=message,
|
||||
)
|
||||
) from ex
|
||||
except (TemplateError, SupersetSyntaxErrorException) as ex:
|
||||
msg = getattr(ex, "message", str(ex))
|
||||
raise QueryObjectValidationError(
|
||||
|
||||
@@ -641,11 +641,12 @@ class PostgresEngineSpec(BasicParametersMixin, PostgresBaseEngineSpec):
|
||||
"""
|
||||
Return the default schema for a given query.
|
||||
|
||||
This method simply uses the parent method after checking that the query
|
||||
cannot rebind the schema used to resolve unqualified table names.
|
||||
This method simply uses the parent method after checking that there are no
|
||||
malicious path setting in the query.
|
||||
"""
|
||||
script = process_jinja_sql(query.sql, database, template_params).script
|
||||
if script.changes_default_schema():
|
||||
settings = script.get_settings()
|
||||
if "search_path" in settings:
|
||||
raise SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.QUERY_SECURITY_ACCESS_ERROR,
|
||||
|
||||
+14
-19
@@ -915,18 +915,6 @@ class BaseTemplateProcessor:
|
||||
"""
|
||||
return self._context.copy()
|
||||
|
||||
def get_template_context(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Build the validated context used to render a template.
|
||||
|
||||
Split out from ``process_template`` so that validation paths which
|
||||
render a pre-parsed template (``superset.sql.parse.process_jinja_sql``)
|
||||
use exactly the same context as execution, keeping the validated SQL
|
||||
identical to the executed SQL.
|
||||
"""
|
||||
kwargs.update(self._context)
|
||||
return validate_template_context(self.engine, kwargs)
|
||||
|
||||
def process_template(self, sql: str, **kwargs: Any) -> str:
|
||||
"""Processes a sql template
|
||||
|
||||
@@ -996,7 +984,8 @@ class BaseTemplateProcessor:
|
||||
|
||||
raise SupersetTemplateException(message) from ex
|
||||
|
||||
context = self.get_template_context(**kwargs)
|
||||
kwargs.update(self._context)
|
||||
context = validate_template_context(self.engine, kwargs)
|
||||
|
||||
try:
|
||||
return template.render(context)
|
||||
@@ -1144,21 +1133,27 @@ class HiveTemplateProcessor(PrestoTemplateProcessor):
|
||||
class SparkTemplateProcessor(HiveTemplateProcessor):
|
||||
engine = "spark"
|
||||
|
||||
def get_template_context(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_template_context(**kwargs)
|
||||
def process_template(self, sql: str, **kwargs: Any) -> str:
|
||||
template = self.env.from_string(sql)
|
||||
kwargs.update(self._context)
|
||||
|
||||
# Backwards compatibility if migrating from Hive.
|
||||
context = validate_template_context(self.engine, kwargs)
|
||||
context["hive"] = context["spark"]
|
||||
return context
|
||||
return template.render(context)
|
||||
|
||||
|
||||
class TrinoTemplateProcessor(PrestoTemplateProcessor):
|
||||
engine = "trino"
|
||||
|
||||
def get_template_context(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_template_context(**kwargs)
|
||||
def process_template(self, sql: str, **kwargs: Any) -> str:
|
||||
template = self.env.from_string(sql)
|
||||
kwargs.update(self._context)
|
||||
|
||||
# Backwards compatibility if migrating from Presto.
|
||||
context = validate_template_context(self.engine, kwargs)
|
||||
context["presto"] = context["trino"]
|
||||
return context
|
||||
return template.render(context)
|
||||
|
||||
|
||||
DEFAULT_PROCESSORS = {
|
||||
|
||||
@@ -153,7 +153,7 @@ from superset.utils.date_parser import (
|
||||
TimeDeltaAmbiguousError,
|
||||
)
|
||||
from superset.utils.dates import datetime_to_epoch
|
||||
from superset.utils.rls import apply_rls, get_predicates_for_table
|
||||
from superset.utils.rls import apply_rls
|
||||
|
||||
|
||||
class ValidationResultDict(TypedDict):
|
||||
@@ -329,62 +329,6 @@ def validate_stored_expression_at_query_time(
|
||||
return expression
|
||||
|
||||
|
||||
def validate_rendered_expression(
|
||||
expression: str,
|
||||
database: Database,
|
||||
catalog: str | None,
|
||||
schema: str | None,
|
||||
) -> str:
|
||||
"""
|
||||
Apply the stored-expression validation policy to a rendered expression.
|
||||
|
||||
Query-time counterpart to ``validate_stored_expression``: it runs on the
|
||||
already-rendered expression that is embedded via ``literal_column`` and
|
||||
applies the same policy, failing closed on unparseable results.
|
||||
|
||||
:param expression: the rendered expression
|
||||
:returns: the expression to embed, possibly rewritten with RLS predicates
|
||||
:raises QueryObjectValidationError: on multi-statement, set-operation,
|
||||
disallowed sub-query, or sanitization failures -- matching the
|
||||
``QueryObjectValidationError`` contract callers already expect from
|
||||
``validate_stored_expression_at_query_time``, rather than letting a
|
||||
raw ``SupersetSecurityException`` escape uncaught.
|
||||
"""
|
||||
engine = database.backend
|
||||
wrapped = f"SELECT {expression}"
|
||||
|
||||
try:
|
||||
parsed = SQLStatement(wrapped, engine)
|
||||
except SupersetParseError as ex:
|
||||
raise QueryObjectValidationError(
|
||||
_("Custom SQL fields cannot be parsed as a single SQL statement.")
|
||||
) from ex
|
||||
|
||||
if parsed.is_set_operation():
|
||||
raise QueryObjectValidationError(
|
||||
_("Custom SQL fields cannot contain set operations.")
|
||||
)
|
||||
|
||||
try:
|
||||
wrapped = validate_adhoc_subquery(
|
||||
wrapped, database, catalog, schema or "", engine
|
||||
)
|
||||
except SupersetSecurityException as ex:
|
||||
raise QueryObjectValidationError(ex.message) from ex
|
||||
try:
|
||||
wrapped = sanitize_clause(wrapped, engine)
|
||||
except QueryClauseValidationException as ex:
|
||||
raise QueryObjectValidationError(ex.message) from ex
|
||||
|
||||
prefix, expression = re.split(
|
||||
r"SELECT\s+",
|
||||
wrapped,
|
||||
maxsplit=1,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return expression.strip()
|
||||
|
||||
|
||||
def json_to_dict(json_str: str) -> dict[Any, Any]:
|
||||
if json_str:
|
||||
val = re.sub(",[ \t\r\n]+}", "}", json_str)
|
||||
@@ -3139,40 +3083,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
if rls_applied:
|
||||
from_sql = parsed_script.format()
|
||||
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
# RLS injection failures fail closed: only continue when it is
|
||||
# positively confirmed that no RLS predicates apply to the
|
||||
# referenced tables; any other outcome aborts the query.
|
||||
try:
|
||||
rls_required = any(
|
||||
get_predicates_for_table(
|
||||
table.qualify(
|
||||
catalog=self.catalog,
|
||||
schema=self.schema or default_schema or "",
|
||||
),
|
||||
self.database,
|
||||
self.database.get_default_catalog(),
|
||||
exclude_dataset_id=self_id,
|
||||
)
|
||||
for statement in parsed_script.statements
|
||||
for table in statement.tables
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
rls_required = True
|
||||
if rls_required:
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"Row-level security could not be applied to the "
|
||||
"virtual dataset query, so it cannot be run "
|
||||
"securely: %(msg)s",
|
||||
msg=str(ex),
|
||||
)
|
||||
) from ex
|
||||
logger.warning(
|
||||
"RLS application to virtual dataset SQL failed, but no "
|
||||
"predicates apply to its tables; continuing: %s",
|
||||
ex,
|
||||
)
|
||||
except Exception as ex:
|
||||
# Log the error but don't fail - RLS application is best-effort
|
||||
logger.warning("Failed to apply RLS to virtual dataset SQL: %s", ex)
|
||||
|
||||
cte = self.db_engine_spec.get_cte_query(from_sql)
|
||||
from_clause = (
|
||||
@@ -3869,11 +3782,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
if expression := tbl_column.expression:
|
||||
if template_processor:
|
||||
expression = template_processor.process_template(expression)
|
||||
if expression != tbl_column.expression:
|
||||
# Re-check the rendered expression before embedding it.
|
||||
expression = validate_rendered_expression(
|
||||
expression, self.database, self.catalog, self.schema
|
||||
)
|
||||
expression = self._validate_stored_expression(expression)
|
||||
col = literal_column(expression, type_=type_)
|
||||
else:
|
||||
|
||||
@@ -3971,21 +3971,6 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
if query in self.session:
|
||||
self.session.expunge(query)
|
||||
|
||||
# When only ``database`` is provided, enforce database-level access
|
||||
# here so the call is not a no-op.
|
||||
if database and not (table or query):
|
||||
if not self.can_access_database(database):
|
||||
raise SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR,
|
||||
message=_(
|
||||
"You need access to the following database: %(name)s",
|
||||
name=database.database_name,
|
||||
),
|
||||
level=ErrorLevel.WARNING,
|
||||
)
|
||||
)
|
||||
|
||||
if database and table or query:
|
||||
if query:
|
||||
# Type narrow: only SQL Lab Query objects have .database attribute
|
||||
@@ -4068,24 +4053,6 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
# Statements that rebind how unqualified table names resolve
|
||||
# (``USE``, ``SET SCHEMA``, or a ``search_path`` change) make
|
||||
# the qualification below diverge from what the engine uses at
|
||||
# execution time, so reject them regardless of engine.
|
||||
if force_dataset_match and parse_result.script.changes_default_schema():
|
||||
raise SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.QUERY_SECURITY_ACCESS_ERROR,
|
||||
message=_(
|
||||
"SQL Lab cannot authorise a script that "
|
||||
"changes the schema used to resolve "
|
||||
"unqualified table names (e.g. USE or "
|
||||
"search_path changes). Qualify tables "
|
||||
"explicitly instead."
|
||||
),
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
tables = {
|
||||
table_.qualify(
|
||||
catalog=query.catalog or default_catalog,
|
||||
|
||||
+56
-276
@@ -616,18 +616,6 @@ class BaseSQLStatement(Generic[InternalRepresentation]):
|
||||
"""
|
||||
return False
|
||||
|
||||
def changes_default_schema(self) -> bool:
|
||||
"""
|
||||
Check if the statement changes the schema used to resolve unqualified
|
||||
table names.
|
||||
|
||||
Defaults to ``False``; engines whose statements can rebind unqualified
|
||||
schema resolution override this.
|
||||
|
||||
:return: True if the statement rebinds default schema resolution
|
||||
"""
|
||||
return False
|
||||
|
||||
def get_disallowed_tables(
|
||||
self,
|
||||
tables: set[str],
|
||||
@@ -763,24 +751,26 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
}
|
||||
)
|
||||
|
||||
# Constructs that sqlglot represents as an opaque ``exp.Command`` (no
|
||||
# structured AST). Each can mutate server state or wrap a DML body that
|
||||
# would otherwise be detected by node-type matching. The head keywords
|
||||
# are not engine-specific (MySQL ``CALL`` / ``LOAD DATA INFILE`` and
|
||||
# MSSQL ``EXEC`` reach the same ``exp.Command`` fallback as their
|
||||
# PostgreSQL counterparts), so ``is_mutating()`` applies this list for
|
||||
# every dialect: an opaque command with one of these heads is treated as
|
||||
# mutating.
|
||||
_MUTATING_COMMAND_NAMES: frozenset[str] = frozenset(
|
||||
# PostgreSQL constructs that sqlglot represents as an opaque ``exp.Command``
|
||||
# (no structured AST). Each can mutate server state or wrap a DML body that
|
||||
# would otherwise be detected by node-type matching. Used by
|
||||
# ``is_mutating()``.
|
||||
_POSTGRES_MUTATING_COMMAND_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"DO", # PL/pgSQL anonymous block
|
||||
"PREPARE", # PREPARE u AS UPDATE ... ; EXECUTE u
|
||||
"EXECUTE", # body is the prepared DML
|
||||
"EXEC", # MSSQL spelling of EXECUTE; the procedure body may mutate
|
||||
"CALL", # procedure body may mutate
|
||||
"COPY", # server-side file ingest into a table
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
# Only the command-fallback forms (e.g. SET ROLE / SET SESSION
|
||||
# AUTHORIZATION, which change the effective user) reach here as an
|
||||
# exp.Command. Structured `SET search_path = ...` /
|
||||
# `SET statement_timeout = ...` parse as exp.Set and are NOT matched
|
||||
# by this command-name path.
|
||||
"SET",
|
||||
"RESET", # RESET ROLE / RESET ALL reverts SET; same class as SET
|
||||
"REFRESH", # REFRESH MATERIALIZED VIEW
|
||||
"REINDEX",
|
||||
"VACUUM",
|
||||
@@ -793,9 +783,7 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
"CREATE",
|
||||
"ALTER",
|
||||
"DROP",
|
||||
# MySQL LOAD DATA INFILE ingests server files into a table;
|
||||
# PostgreSQL LOAD '/path/lib.so' dlopens a shared library.
|
||||
"LOAD",
|
||||
"LOAD", # LOAD '/path/lib.so' dlopens a shared library on the PG host
|
||||
# NOTE: `SHOW` is intentionally NOT included. It is a read (mutates
|
||||
# nothing), so classifying it as mutating would be wrong for every
|
||||
# is_mutating()/has_mutation() consumer (the commit decision, the
|
||||
@@ -806,20 +794,6 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
}
|
||||
)
|
||||
|
||||
# PostgreSQL-only command-fallback heads. Only the command-fallback
|
||||
# forms (e.g. SET ROLE / SET SESSION AUTHORIZATION, which change the
|
||||
# effective user) reach here as an exp.Command; structured
|
||||
# `SET search_path = ...` / `SET statement_timeout = ...` parse as
|
||||
# exp.Set and are NOT matched by this path. On other dialects the `SET`
|
||||
# fallback covers session variables (e.g. Hive `SET hivevar:x=1`),
|
||||
# which do not mutate data, so these heads stay dialect-gated.
|
||||
_POSTGRES_MUTATING_COMMAND_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"SET",
|
||||
"RESET", # RESET ROLE / RESET ALL reverts SET; same class as SET
|
||||
}
|
||||
)
|
||||
|
||||
# Dialects where `SELECT ... INTO target` is CTAS (creates a table, and so
|
||||
# mutates schema). Elsewhere the same syntax assigns into a variable and is
|
||||
# a read: Oracle PL/SQL `SELECT ... INTO v` and MySQL `SELECT ... INTO @v`
|
||||
@@ -952,7 +926,7 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
"""
|
||||
return isinstance(self._parsed, exp.Select)
|
||||
|
||||
def is_mutating(self) -> bool: # noqa: C901
|
||||
def is_mutating(self) -> bool:
|
||||
"""
|
||||
Check if the statement mutates data (DDL/DML).
|
||||
|
||||
@@ -975,14 +949,6 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
exp.Revoke,
|
||||
# COMMENT ON TABLE/COLUMN/etc. writes to system catalog pg_description.
|
||||
exp.Comment,
|
||||
# A bare COMMIT persists earlier writes on the same connection, so
|
||||
# treat it as mutating.
|
||||
exp.Commit,
|
||||
# EXEC/EXECUTE invokes a stored procedure whose body is opaque;
|
||||
# some dialects (e.g. MSSQL) parse it as this structured node
|
||||
# rather than an opaque exp.Command, so treat it as mutating here
|
||||
# too.
|
||||
exp.Execute,
|
||||
)
|
||||
|
||||
if self._parsed.find(*mutating_nodes):
|
||||
@@ -1020,76 +986,37 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
):
|
||||
return True
|
||||
|
||||
# Statements that sqlglot cannot model parse as an opaque
|
||||
# `exp.Command`. The `.name` attribute on `exp.Command` preserves
|
||||
# the source-case of the head keyword (so `create extension ...`
|
||||
# would yield `'create'`), which means the lookups must be
|
||||
# case-insensitive. This also covers the dialects (Oracle, MS SQL)
|
||||
# where `ALTER` itself is parsed as a command, not an expression.
|
||||
if isinstance(self._parsed, exp.Command):
|
||||
command_name = self._parsed.name.upper()
|
||||
# depending on the dialect (Oracle, MS SQL) the `ALTER` is parsed as a
|
||||
# command, not an expression - check at root level
|
||||
if isinstance(self._parsed, exp.Command) and self._parsed.name == "ALTER":
|
||||
return True # pragma: no cover
|
||||
|
||||
if command_name in self._MUTATING_COMMAND_NAMES:
|
||||
return True
|
||||
# PostgreSQL constructs that sqlglot represents as an opaque
|
||||
# `exp.Command` rather than a structured AST. Each of these can mutate
|
||||
# state or wrap a DML body that would otherwise be detected. The
|
||||
# `.name` attribute on `exp.Command` preserves the source-case of the
|
||||
# head keyword (so `create extension ...` would yield `'create'`),
|
||||
# which means the set lookup must be case-insensitive.
|
||||
if (
|
||||
self._dialect == Dialects.POSTGRES
|
||||
and isinstance(self._parsed, exp.Command)
|
||||
and self._parsed.name.upper() in self._POSTGRES_MUTATING_COMMAND_NAMES
|
||||
):
|
||||
return True
|
||||
|
||||
if (
|
||||
self._dialect == Dialects.POSTGRES
|
||||
and command_name in self._POSTGRES_MUTATING_COMMAND_NAMES
|
||||
):
|
||||
return True
|
||||
|
||||
# `EXPLAIN ANALYZE <statement>` executes the statement for real
|
||||
# (PostgreSQL and MySQL both run the body), see
|
||||
# https://www.postgresql.org/docs/current/sql-explain.html
|
||||
# The flag may be spelled `ANALYSE`, be separated by any
|
||||
# whitespace, or appear in a parenthesized option list such as
|
||||
# `EXPLAIN (ANALYZE, BUFFERS) ...`, so the raw tail is
|
||||
# normalized before the inner statement is classified. Anything
|
||||
# that carries the flag but cannot be classified is treated as
|
||||
# mutating.
|
||||
if command_name == "EXPLAIN":
|
||||
tail = (
|
||||
self._parsed.expression.name.strip()
|
||||
if self._parsed.expression
|
||||
else ""
|
||||
)
|
||||
|
||||
# sqlglot preserves the raw tail text, comments included;
|
||||
# strip leading comments so an option list hidden behind
|
||||
# `/* ... */` or `-- ...` is still recognized.
|
||||
while True:
|
||||
if tail.startswith("/*") and "*/" in tail:
|
||||
tail = tail.split("*/", 1)[1].lstrip()
|
||||
elif tail.startswith("--"):
|
||||
parts = tail.split("\n", 1)
|
||||
tail = parts[1].lstrip() if len(parts) > 1 else ""
|
||||
else:
|
||||
break
|
||||
|
||||
has_analyze = False
|
||||
if tail.startswith("("):
|
||||
options, _, tail = tail[1:].partition(")")
|
||||
has_analyze = bool(
|
||||
re.search(r"\b(ANALYZE|ANALYSE)\b", options, re.IGNORECASE)
|
||||
)
|
||||
else:
|
||||
while match := re.match(
|
||||
r"(ANALYZE|ANALYSE|VERBOSE)\s+", tail, re.IGNORECASE
|
||||
):
|
||||
if match.group(1).upper() != "VERBOSE":
|
||||
has_analyze = True
|
||||
tail = tail[match.end() :]
|
||||
|
||||
if has_analyze:
|
||||
if not (inner_sql := tail.strip()):
|
||||
return True
|
||||
try:
|
||||
return SQLStatement(
|
||||
statement=inner_sql,
|
||||
engine=self.engine,
|
||||
).is_mutating()
|
||||
except SupersetParseError:
|
||||
return True
|
||||
# Postgres runs DMLs prefixed by `EXPLAIN ANALYZE`, see
|
||||
# https://www.postgresql.org/docs/current/sql-explain.html
|
||||
if (
|
||||
self._dialect == Dialects.POSTGRES
|
||||
and isinstance(self._parsed, exp.Command)
|
||||
and self._parsed.name == "EXPLAIN"
|
||||
and self._parsed.expression.name.upper().startswith("ANALYZE ")
|
||||
):
|
||||
analyzed_sql = self._parsed.expression.name[len("ANALYZE ") :]
|
||||
return SQLStatement(
|
||||
statement=analyzed_sql,
|
||||
engine=self.engine,
|
||||
).is_mutating()
|
||||
|
||||
return False
|
||||
|
||||
@@ -1261,58 +1188,6 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
return bool(tokens) and tokens[0].strip('"').lower() == "search_path"
|
||||
return False
|
||||
|
||||
def changes_default_schema(self) -> bool:
|
||||
"""
|
||||
Return True if the statement rebinds default schema resolution.
|
||||
|
||||
Covers ``USE`` statements (MySQL-, Doris- and Snowflake-family
|
||||
engines) and ``SET [CURRENT] SCHEMA`` / ``SET CATALOG`` variants, in
|
||||
addition to anything that changes the Postgres ``search_path``.
|
||||
Unqualified table names in later statements on the same cursor then
|
||||
resolve against a different schema.
|
||||
"""
|
||||
for use in self._parsed.find_all(exp.Use):
|
||||
kind = use.args.get("kind")
|
||||
# `USE WAREHOUSE ...` selects compute, not a namespace, and does
|
||||
# not affect how table names resolve.
|
||||
if kind and kind.name.upper() == "WAREHOUSE":
|
||||
continue
|
||||
return True
|
||||
# `SET SCHEMA 'x'` / `SET CATALOG 'x'` rebind resolution through a
|
||||
# structured setting rather than a search path.
|
||||
rebinding_settings = {
|
||||
"schema",
|
||||
"current_schema",
|
||||
"current schema",
|
||||
"catalog",
|
||||
}
|
||||
if any(
|
||||
key.strip('"').lower() in rebinding_settings for key in self.get_settings()
|
||||
):
|
||||
return True
|
||||
# A `set_config()` with a non-literal setting name may set
|
||||
# `search_path` at runtime, so treat it as a schema change; literal
|
||||
# names are handled by `changes_search_path`.
|
||||
for func in self._parsed.find_all(exp.Anonymous):
|
||||
if func.name.lower() == "set_config" and not (
|
||||
func.expressions and isinstance(func.expressions[0], exp.Literal)
|
||||
):
|
||||
return True
|
||||
# `SET SCHEMA` / `SET CATALOG` forms that fall back to an opaque
|
||||
# exp.Command: match the leading setting name, mirroring
|
||||
# `changes_search_path`.
|
||||
parsed = self._parsed
|
||||
if isinstance(parsed, exp.Command) and parsed.name.upper() == "SET":
|
||||
tokens = str(parsed.expression).replace("=", " ").split()
|
||||
while tokens and tokens[0].upper() in {"SESSION", "LOCAL", "CURRENT"}:
|
||||
tokens.pop(0)
|
||||
if tokens and tokens[0].strip('"').strip("'").lower() in {
|
||||
"schema",
|
||||
"catalog",
|
||||
}:
|
||||
return True
|
||||
return self.changes_search_path()
|
||||
|
||||
def get_disallowed_tables(
|
||||
self,
|
||||
tables: set[str],
|
||||
@@ -1944,16 +1819,12 @@ class SQLScript:
|
||||
def has_unparseable_statement(self) -> bool:
|
||||
"""
|
||||
True if any statement in the script cannot be fully modeled as an
|
||||
AST whose table references Superset can enumerate. This covers the
|
||||
following cases, which must all fail closed under strict scoping:
|
||||
AST whose table references Superset can enumerate. This covers two
|
||||
cases that must both fail closed under strict scoping:
|
||||
|
||||
* SQLGlot ``exp.Command`` nodes: statements sqlglot recognises but
|
||||
cannot fully parse (e.g. dynamic SQL inside a stored-procedure
|
||||
call); ``extract_tables_from_statement`` cannot see the tables.
|
||||
* ``exp.Show`` statements with no extractable target (e.g.
|
||||
``SHOW TABLES FROM some_schema``): the statement reads database
|
||||
metadata, but there is no table reference for the per-table check
|
||||
to enforce against.
|
||||
* Non-sqlglot engines (e.g. Kusto KQL): the statement class does
|
||||
not produce a sqlglot AST at all and its
|
||||
``_extract_tables_from_statement`` returns an empty set, so the
|
||||
@@ -1964,11 +1835,6 @@ class SQLScript:
|
||||
return True
|
||||
if isinstance(statement._parsed, exp.Command): # noqa: SLF001
|
||||
return True
|
||||
if (
|
||||
isinstance(statement._parsed, exp.Show) # noqa: SLF001
|
||||
and not statement.tables
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_settings(self) -> dict[str, str | bool]:
|
||||
@@ -2002,16 +1868,6 @@ class SQLScript:
|
||||
"""
|
||||
return any(statement.is_destructive() for statement in self.statements)
|
||||
|
||||
def changes_default_schema(self) -> bool:
|
||||
"""
|
||||
Check if any statement rebinds default schema resolution.
|
||||
|
||||
:return: True if any statement changes the schema (``USE``,
|
||||
``SET SCHEMA``) or the Postgres ``search_path`` used to resolve
|
||||
unqualified table names
|
||||
"""
|
||||
return any(statement.changes_default_schema() for statement in self.statements)
|
||||
|
||||
def optimize(self) -> SQLScript:
|
||||
"""
|
||||
Return optimized script.
|
||||
@@ -2130,31 +1986,6 @@ def extract_tables_from_statement(
|
||||
except (ParseError, SupersetParseError):
|
||||
return set()
|
||||
sources = pseudo_query.find_all(exp.Table)
|
||||
elif isinstance(statement, exp.Show):
|
||||
# Structured metadata statements (`SHOW CREATE TABLE foo.bar`,
|
||||
# `SHOW COLUMNS FROM foo`, ...) reference their target via dedicated
|
||||
# args rather than query sources, so build the table references
|
||||
# explicitly. Statements with no extractable target (e.g.
|
||||
# `SHOW TABLES FROM some_schema`) yield an empty set and are treated
|
||||
# as unparseable for authorization purposes (see
|
||||
# `SQLScript.has_unparseable_statement`).
|
||||
show_tables = {
|
||||
Table(
|
||||
source.name,
|
||||
source.db if source.db != "" else None,
|
||||
source.catalog if source.catalog != "" else None,
|
||||
)
|
||||
for source in statement.find_all(exp.Table)
|
||||
}
|
||||
if target := statement.args.get("target"):
|
||||
db = statement.args.get("db")
|
||||
show_tables.add(
|
||||
Table(
|
||||
target.name if isinstance(target, exp.Expression) else str(target),
|
||||
db.name if isinstance(db, exp.Expression) else db,
|
||||
)
|
||||
)
|
||||
return show_tables
|
||||
else:
|
||||
sources = [
|
||||
source
|
||||
@@ -2240,17 +2071,6 @@ def remove_quotes(val: T) -> T:
|
||||
return val
|
||||
|
||||
|
||||
# Jinja macros that execute statements against the analytical database when
|
||||
# rendered; their table references are extracted before rendering, and the
|
||||
# macros are stubbed out during a validation-time render.
|
||||
PARTITION_MACRO_NAMES = (
|
||||
"first_latest_partition",
|
||||
"latest_partition",
|
||||
"latest_partitions",
|
||||
"latest_sub_partition",
|
||||
)
|
||||
|
||||
|
||||
def process_jinja_sql(
|
||||
sql: str, database: Database, template_params: Optional[dict[str, Any]] = None
|
||||
) -> JinjaSQLResult:
|
||||
@@ -2271,13 +2091,10 @@ def process_jinja_sql(
|
||||
:returns: JinjaSQLResult containing the processed script and table references
|
||||
:raises SupersetSecurityException: If SQLGlot is unable to parse the SQL statement
|
||||
:raises jinja2.exceptions.TemplateError: If the Jinjafied SQL could not be rendered
|
||||
:raises SupersetParseError: If a partition macro references a table that
|
||||
cannot be determined statically
|
||||
"""
|
||||
|
||||
from superset.jinja_context import ( # pylint: disable=import-outside-toplevel
|
||||
get_template_processor,
|
||||
NoOpTemplateProcessor,
|
||||
)
|
||||
|
||||
processor = get_template_processor(database)
|
||||
@@ -2285,74 +2102,37 @@ def process_jinja_sql(
|
||||
|
||||
tables = set()
|
||||
|
||||
def raise_for_unresolvable_macro() -> Any:
|
||||
raise SupersetParseError(
|
||||
sql,
|
||||
database.db_engine_spec.engine,
|
||||
message=(
|
||||
"Unable to determine the table referenced by a partition "
|
||||
"macro; use a single constant table reference"
|
||||
),
|
||||
)
|
||||
|
||||
for node in ast.find_all(nodes.Call):
|
||||
if (
|
||||
isinstance(node.node, nodes.Getattr)
|
||||
and node.node.attr in PARTITION_MACRO_NAMES
|
||||
if isinstance(node.node, nodes.Getattr) and node.node.attr in (
|
||||
"latest_partition",
|
||||
"latest_sub_partition",
|
||||
):
|
||||
# Extract the table referenced in the macro. The reference must
|
||||
# be statically evaluable; otherwise raise rather than render.
|
||||
# Try to extract the table referenced in the macro.
|
||||
try:
|
||||
if len(node.args) != 1:
|
||||
raise nodes.Impossible()
|
||||
tables.add(
|
||||
Table(
|
||||
*[
|
||||
remove_quotes(part.strip())
|
||||
for part in node.args[0].as_const().split(".")[::-1]
|
||||
if len(node.args) == 1
|
||||
]
|
||||
)
|
||||
)
|
||||
except nodes.Impossible:
|
||||
raise_for_unresolvable_macro()
|
||||
pass
|
||||
|
||||
# Replace the potentially problematic Jinja macro with some benign SQL.
|
||||
node.__class__ = nodes.TemplateData
|
||||
node.fields = nodes.TemplateData.fields
|
||||
node.data = "NULL"
|
||||
|
||||
# Render the neutralized template once, using the same context
|
||||
# ``process_template`` builds at execution time, so the validated SQL
|
||||
# matches the executed SQL. A no-op processor runs the raw SQL at
|
||||
# execution time, so validate that raw SQL directly.
|
||||
if isinstance(processor, NoOpTemplateProcessor):
|
||||
rendered_sql = processor.process_template(sql)
|
||||
else:
|
||||
code = processor.env.compile(ast)
|
||||
template = Template.from_code(
|
||||
processor.env,
|
||||
code,
|
||||
globals=processor.env.globals,
|
||||
)
|
||||
# Replace live partition macros with stubs so a call that survives
|
||||
# neutralization (e.g. via a dynamic attribute lookup) does not
|
||||
# execute during this render.
|
||||
context = processor.get_template_context(**(template_params or {}))
|
||||
if (engine := getattr(processor, "engine", None)) and isinstance(
|
||||
context.get(engine), dict
|
||||
):
|
||||
context[engine] = {
|
||||
key: (
|
||||
(lambda *args, **kwargs: raise_for_unresolvable_macro())
|
||||
if key in PARTITION_MACRO_NAMES
|
||||
else value
|
||||
)
|
||||
for key, value in context[engine].items()
|
||||
}
|
||||
rendered_sql = template.render(context)
|
||||
# re-render template back into a string
|
||||
code = processor.env.compile(ast)
|
||||
template = Template.from_code(processor.env, code, globals=processor.env.globals)
|
||||
rendered_sql = template.render(processor.get_context(), **(template_params or {}))
|
||||
|
||||
parsed_script = SQLScript(
|
||||
rendered_sql,
|
||||
processor.process_template(rendered_sql),
|
||||
engine=database.db_engine_spec.engine,
|
||||
)
|
||||
for parsed_statement in parsed_script.statements:
|
||||
|
||||
@@ -258,14 +258,6 @@ class SqlLabRestApi(BaseSupersetApi):
|
||||
else template_params
|
||||
)
|
||||
if template_params:
|
||||
# Check access before rendering the Jinja
|
||||
# template (mirrors the SQL Lab execute path).
|
||||
security_manager.raise_for_access(
|
||||
database=database,
|
||||
sql=sql,
|
||||
template_params=template_params,
|
||||
force_dataset_match=True,
|
||||
)
|
||||
template_processor = get_template_processor(
|
||||
database=database
|
||||
)
|
||||
|
||||
@@ -22,12 +22,9 @@ from dataclasses import dataclass
|
||||
from typing import Any, cast, TYPE_CHECKING
|
||||
|
||||
from flask import g
|
||||
from flask_babel import gettext as __
|
||||
from sqlalchemy.orm.exc import DetachedInstanceError
|
||||
|
||||
from superset import is_feature_enabled
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetErrorException
|
||||
from superset.models.sql_lab import Query
|
||||
from superset.sql.parse import CTASMethod
|
||||
from superset.utils import core as utils, json
|
||||
@@ -131,45 +128,9 @@ class SqlJsonExecutionContext: # pylint: disable=too-many-instance-attributes
|
||||
if self.catalog is None:
|
||||
self.catalog = database.get_default_catalog()
|
||||
if self.select_as_cta:
|
||||
self._validate_ctas_is_allowed(database)
|
||||
schema_name = self._get_ctas_target_schema_name(database)
|
||||
self.create_table_as_select.target_schema_name = schema_name # type: ignore
|
||||
|
||||
def _validate_ctas_is_allowed(self, database: Database) -> None:
|
||||
"""
|
||||
Enforce the per-database CTAS/CVAS grants server-side.
|
||||
|
||||
The database's ``allow_ctas``/``allow_cvas`` flags are checked at
|
||||
submission, mirroring the ``allow_dml`` gate on the execution path.
|
||||
"""
|
||||
ctas = cast(CreateTableAsSelect, self.create_table_as_select)
|
||||
if ctas.ctas_method == CTASMethod.TABLE and not database.allow_ctas:
|
||||
raise SupersetErrorException(
|
||||
SupersetError(
|
||||
message=__(
|
||||
"This database does not allow creating tables from "
|
||||
"queries (CTAS). Please contact your administrator "
|
||||
"for more assistance."
|
||||
),
|
||||
error_type=SupersetErrorType.QUERY_SECURITY_ACCESS_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
),
|
||||
status=403,
|
||||
)
|
||||
if ctas.ctas_method == CTASMethod.VIEW and not database.allow_cvas:
|
||||
raise SupersetErrorException(
|
||||
SupersetError(
|
||||
message=__(
|
||||
"This database does not allow creating views from "
|
||||
"queries (CVAS). Please contact your administrator "
|
||||
"for more assistance."
|
||||
),
|
||||
error_type=SupersetErrorType.QUERY_SECURITY_ACCESS_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
),
|
||||
status=403,
|
||||
)
|
||||
|
||||
def _get_ctas_target_schema_name(self, database: Database) -> str | None:
|
||||
if database.force_ctas_schema:
|
||||
return database.force_ctas_schema
|
||||
|
||||
+4
-33
@@ -17,41 +17,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset import db
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils import json
|
||||
from superset.utils.core import get_user_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.core import Database
|
||||
from superset.sql.parse import BaseSQLStatement
|
||||
|
||||
|
||||
def _get_cache_identity() -> str:
|
||||
"""
|
||||
Build a stable per-session identity to key the parse-failure sentinel on.
|
||||
|
||||
Logged-in users have a stable numeric id from ``get_user_id()``. Guest
|
||||
users (embedded) don't -- ``get_user_id()`` always returns ``None`` for
|
||||
them -- so different guest tokens with different RLS scopes would
|
||||
otherwise all collapse onto the same "user-None" sentinel and share cache
|
||||
entries. Key those on a hash of the guest token's own RLS rules instead,
|
||||
so distinct guest scopes stay isolated from one another.
|
||||
"""
|
||||
if guest_user := security_manager.get_current_guest_user_if_guest():
|
||||
rls_rules = guest_user.guest_token.get("rls_rules", [])
|
||||
digest = hashlib.sha256(
|
||||
json.dumps(rls_rules, sort_keys=True).encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"guest-{digest}"
|
||||
return str(get_user_id())
|
||||
|
||||
|
||||
def apply_rls(
|
||||
database: Database,
|
||||
catalog: str | None,
|
||||
@@ -227,12 +204,6 @@ def collect_rls_predicates_for_sql(
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
# If we can't parse the SQL, we can't tell which (if any) RLS
|
||||
# predicates would apply, so we can't contribute a meaningful cache
|
||||
# key component. Returning an empty list here would make every
|
||||
# user's failure collapse onto the same (missing) contribution,
|
||||
# which is unsafe when different users have different RLS scopes on
|
||||
# the underlying tables. Fall back to a per-user marker instead, so
|
||||
# the cache key still varies by user even though we don't know the
|
||||
# actual predicates.
|
||||
return [f"rls-predicate-parse-failed-for-user-{_get_cache_identity()}"]
|
||||
# If we can't parse the SQL, return empty list
|
||||
# This ensures RLS application failure doesn't break caching
|
||||
return []
|
||||
|
||||
@@ -70,26 +70,10 @@ def get_query_by_id(id: int):
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def setup_sqllab():
|
||||
# These tests exercise CTAS/CVAS, which the example database must be
|
||||
# granted to allow. Enable the grants for the duration of the module and
|
||||
# restore the originals afterwards.
|
||||
with app.app_context():
|
||||
example_db = get_example_database()
|
||||
original_allow_ctas = example_db.allow_ctas
|
||||
original_allow_cvas = example_db.allow_cvas
|
||||
example_db.allow_ctas = True
|
||||
example_db.allow_cvas = True
|
||||
db.session.commit()
|
||||
|
||||
yield
|
||||
|
||||
# clean up after all tests are done
|
||||
# use a new app context
|
||||
with app.app_context():
|
||||
example_db = get_example_database()
|
||||
example_db.allow_ctas = original_allow_ctas
|
||||
example_db.allow_cvas = original_allow_cvas
|
||||
db.session.commit()
|
||||
db.session.query(Query).delete()
|
||||
db.session.commit()
|
||||
for tbl in TMP_TABLES:
|
||||
|
||||
@@ -4608,9 +4608,8 @@ class TestDatabaseApi(SupersetTestCase):
|
||||
assert rv.status_code == 202
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
assert response == {"message": "Async task created to sync permissions"}
|
||||
admin_user = security_manager.find_user(username=ADMIN_USERNAME)
|
||||
mock_task.assert_called_once_with(
|
||||
test_database.id, admin_user.id, test_database.database_name
|
||||
test_database.id, ADMIN_USERNAME, test_database.database_name
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
|
||||
@@ -104,9 +104,6 @@ def test_execute_query_as_report_executor(
|
||||
)
|
||||
command = AlertCommand(report_schedule=report_schedule, execution_id=uuid.uuid4())
|
||||
override_user_mock = mocker.patch("superset.commands.report.alert.override_user")
|
||||
# override_user is mocked, so no real executor context is set; the alert
|
||||
# authorization check is covered elsewhere, so keep it a no-op here.
|
||||
mocker.patch("superset.commands.report.alert.security_manager.raise_for_access")
|
||||
cm = (
|
||||
pytest.raises(type(expected_result))
|
||||
if isinstance(expected_result, Exception)
|
||||
@@ -131,7 +128,6 @@ def test_execute_query_mutate_query_enabled(
|
||||
|
||||
app.config["MUTATE_ALERT_QUERY"] = True
|
||||
mocker.patch("superset.commands.report.alert.override_user")
|
||||
mocker.patch("superset.commands.report.alert.security_manager.raise_for_access")
|
||||
mock_df = mocker.MagicMock(spec=pd.DataFrame)
|
||||
mock_df.empty = True
|
||||
mock_database = get_example_database()
|
||||
@@ -175,7 +171,6 @@ def test_execute_query_mutate_query_disabled(
|
||||
|
||||
app.config["MUTATE_ALERT_QUERY"] = False
|
||||
mocker.patch("superset.commands.report.alert.override_user")
|
||||
mocker.patch("superset.commands.report.alert.security_manager.raise_for_access")
|
||||
mock_database = mocker.MagicMock()
|
||||
|
||||
admin_user = get_user("admin")
|
||||
|
||||
@@ -262,11 +262,8 @@ class TestSqlLabApi(SupersetTestCase):
|
||||
return_value=formatter_response
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch("superset.commands.sql_lab.estimate.DatabaseDAO") as mock_dao,
|
||||
mock.patch("superset.security_manager.raise_for_access"),
|
||||
):
|
||||
mock_dao.find_by_id.return_value = db_mock
|
||||
with mock.patch("superset.commands.sql_lab.estimate.db") as mock_superset_db:
|
||||
mock_superset_db.session.query().get.return_value = db_mock
|
||||
|
||||
data = {"database_id": 1, "sql": "SELECT 1"}
|
||||
rv = self.client.post(
|
||||
|
||||
@@ -49,8 +49,8 @@ class TestQueryEstimationCommand(SupersetTestCase):
|
||||
data: EstimateQueryCostSchema = schema.dump(params)
|
||||
command = estimate.QueryEstimationCommand(data)
|
||||
|
||||
with mock.patch("superset.commands.sql_lab.estimate.DatabaseDAO") as mock_dao:
|
||||
mock_dao.find_by_id.return_value = None
|
||||
with mock.patch("superset.commands.sql_lab.estimate.db") as mock_superset_db:
|
||||
mock_superset_db.session.query().get.return_value = None
|
||||
with pytest.raises(SupersetErrorException) as ex_info:
|
||||
command.validate()
|
||||
assert (
|
||||
@@ -81,11 +81,8 @@ class TestQueryEstimationCommand(SupersetTestCase):
|
||||
db_mock.db_engine_spec.query_cost_formatter = mock.Mock(return_value=None)
|
||||
is_feature_enabled.return_value = False
|
||||
|
||||
with (
|
||||
mock.patch("superset.commands.sql_lab.estimate.DatabaseDAO") as mock_dao,
|
||||
mock.patch("superset.security_manager.raise_for_access"),
|
||||
):
|
||||
mock_dao.find_by_id.return_value = db_mock
|
||||
with mock.patch("superset.commands.sql_lab.estimate.db") as mock_superset_db:
|
||||
mock_superset_db.session.query().get.return_value = db_mock
|
||||
with pytest.raises(SupersetErrorException) as ex_info:
|
||||
command.run()
|
||||
assert (
|
||||
@@ -110,11 +107,8 @@ class TestQueryEstimationCommand(SupersetTestCase):
|
||||
db_mock.db_engine_spec.estimate_query_cost = mock.Mock(return_value=100)
|
||||
db_mock.db_engine_spec.query_cost_formatter = mock.Mock(return_value=payload)
|
||||
|
||||
with (
|
||||
mock.patch("superset.commands.sql_lab.estimate.DatabaseDAO") as mock_dao,
|
||||
mock.patch("superset.security_manager.raise_for_access"),
|
||||
):
|
||||
mock_dao.find_by_id.return_value = db_mock
|
||||
with mock.patch("superset.commands.sql_lab.estimate.db") as mock_superset_db:
|
||||
mock_superset_db.session.query().get.return_value = db_mock
|
||||
result = command.run()
|
||||
assert result == payload
|
||||
|
||||
|
||||
@@ -835,17 +835,7 @@ def test_none_operand_in_filter(login_as_admin, physical_dataset):
|
||||
'{{ user_email }}' as email,
|
||||
'{{ current_user_roles()|tojson }}' as roles
|
||||
""",
|
||||
# The leading `{% set %}` block isn't valid SQL, so parsing this
|
||||
# virtual dataset's SQL for RLS predicates fails and the cache key
|
||||
# picks up the per-user parse-failure sentinel (no user is logged
|
||||
# in for this test, hence "user-None").
|
||||
{
|
||||
1,
|
||||
"abc",
|
||||
"abc@test.com",
|
||||
'["role1", "role2"]',
|
||||
"rls-predicate-parse-failed-for-user-None",
|
||||
},
|
||||
{1, "abc", "abc@test.com", '["role1", "role2"]'},
|
||||
True,
|
||||
),
|
||||
(
|
||||
@@ -855,9 +845,7 @@ def test_none_operand_in_filter(login_as_admin, physical_dataset):
|
||||
SELECT
|
||||
'{{ user_conditional_id }}' as conditional
|
||||
""",
|
||||
# Same parse-failure sentinel as above: the leading `{% set %}`
|
||||
# block breaks SQL parsing for RLS predicate collection.
|
||||
{1, "abc@test.com", "rls-predicate-parse-failed-for-user-None"},
|
||||
{1, "abc@test.com"},
|
||||
True,
|
||||
),
|
||||
(
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# 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.
|
||||
"""
|
||||
Traces how ``sync_database_permissions_task`` binds an acting identity.
|
||||
|
||||
The Celery task receives the immutable ``id`` of the user who enqueued it
|
||||
(captured at enqueue time by ``SyncPermissionsCommand.validate``), not a
|
||||
mutable username string. At execution time it resolves that id to a user
|
||||
record via ``security_manager.get_user_by_id`` and binds the result to
|
||||
``flask.g.user`` for the duration of the sync. Because resolution is by id,
|
||||
a username change between enqueue and execution has no effect on which user
|
||||
record the task acts as.
|
||||
|
||||
That identity is not just used for logging: ``Database._get_sqla_engine``
|
||||
reads ``g.user.id`` to look up a per-user OAuth2 access token, and, for
|
||||
databases with ``impersonate_user`` enabled, ``Database.get_effective_user``
|
||||
reads ``g.user.username`` (via ``get_username()``) to pick the identity the
|
||||
outgoing connection impersonates at the external database. These tests pin
|
||||
down both halves of that chain: the id-based resolution in the task, and the
|
||||
fact that the resolved user is what a privileged, identity-sensitive
|
||||
codepath consumes downstream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from flask import g
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.database.sync_permissions import (
|
||||
sync_database_permissions_task,
|
||||
)
|
||||
from superset.models.core import Database
|
||||
|
||||
|
||||
def test_task_binds_g_user_to_whoever_held_the_id_at_enqueue_time(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
The task resolves its acting identity from the user id captured at
|
||||
enqueue time, via ``security_manager.get_user_by_id``. A username change
|
||||
that happens between enqueue and execution has no effect on which user
|
||||
record the task binds ``g.user`` to, because the id -- not the mutable
|
||||
username -- is what crosses the enqueue/execute boundary.
|
||||
"""
|
||||
# Whoever enqueued the task saw this identity at enqueue time. Its id is
|
||||
# what's passed to the task.
|
||||
enqueuing_user = MagicMock()
|
||||
enqueuing_user.id = 101
|
||||
enqueuing_user.username = "alice"
|
||||
|
||||
get_user_mock = mocker.patch(
|
||||
"superset.commands.database.sync_permissions.security_manager.get_user_by_id",
|
||||
return_value=enqueuing_user,
|
||||
)
|
||||
|
||||
mock_db_connection = MagicMock()
|
||||
mocker.patch(
|
||||
"superset.commands.database.sync_permissions.DatabaseDAO.find_by_id",
|
||||
return_value=mock_db_connection,
|
||||
)
|
||||
|
||||
observed_g_user: list[MagicMock] = []
|
||||
|
||||
def capture_g_user(self: object) -> None:
|
||||
# Read g.user at the moment the sync logic actually runs, the same
|
||||
# way privileged downstream code (e.g. _get_sqla_engine) would.
|
||||
observed_g_user.append(g.user)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.database.sync_permissions.SyncPermissionsCommand"
|
||||
".sync_database_permissions",
|
||||
autospec=True,
|
||||
side_effect=capture_g_user,
|
||||
)
|
||||
|
||||
# By the time the task executes, "alice" has been renamed (and the
|
||||
# username could even have been reassigned to someone else) -- but the
|
||||
# task was enqueued with id 101, so the rename doesn't affect resolution.
|
||||
enqueuing_user.username = "alice_renamed"
|
||||
|
||||
sync_database_permissions_task(1, 101, "old_db_name")
|
||||
|
||||
# Resolution happened purely off the immutable id...
|
||||
get_user_mock.assert_called_once_with(101)
|
||||
# ...and the sync ran under the same user captured at enqueue time,
|
||||
# regardless of the username change in between.
|
||||
assert observed_g_user == [enqueuing_user]
|
||||
assert observed_g_user[0].id == 101
|
||||
|
||||
|
||||
def test_g_user_bound_by_the_task_drives_external_db_impersonation_identity(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
``Database.get_effective_user`` -- consulted by ``_get_sqla_engine`` to
|
||||
decide which identity an outgoing, ``impersonate_user``-enabled
|
||||
connection impersonates at the external database -- reads
|
||||
``g.user.username``. Whatever user object the task bound to ``g.user``
|
||||
(per the previous test, the user resolved from the id captured at
|
||||
enqueue time) is therefore the identity used to connect to the external
|
||||
database.
|
||||
"""
|
||||
database = MagicMock(spec=Database)
|
||||
database.impersonate_user = True
|
||||
|
||||
object_url = MagicMock()
|
||||
object_url.username = "url-embedded-user"
|
||||
|
||||
# ``get_effective_user`` calls ``get_username()``, which reads
|
||||
# ``g.user.username`` using the ``g`` imported into
|
||||
# ``superset.utils.core`` (where ``get_username`` is defined) -- patch
|
||||
# that module's ``g``, matching what the running task actually touches.
|
||||
user_a = MagicMock()
|
||||
user_a.username = "user_a"
|
||||
mocker.patch("superset.utils.core.g", MagicMock(user=user_a))
|
||||
assert Database.get_effective_user(database, object_url) == "user_a"
|
||||
|
||||
# A different user bound to g.user (as would happen if a different id
|
||||
# had been captured at enqueue time) changes the impersonated identity
|
||||
# for the exact same database configuration and target URL.
|
||||
user_b = MagicMock()
|
||||
user_b.username = "user_b"
|
||||
mocker.patch("superset.utils.core.g", MagicMock(user=user_b))
|
||||
assert Database.get_effective_user(database, object_url) == "user_b"
|
||||
@@ -100,7 +100,7 @@ def test_sync_permissions_command_async_mode(
|
||||
"superset.commands.database.sync_permissions.DatabaseDAO"
|
||||
)
|
||||
mock_database_dao.find_by_id.return_value = database_with_catalog
|
||||
mock_user = mocker.patch(
|
||||
mocker.patch(
|
||||
"superset.commands.database.sync_permissions.security_manager.get_user_by_username"
|
||||
)
|
||||
async_task_mock = mocker.patch(
|
||||
@@ -110,7 +110,7 @@ def test_sync_permissions_command_async_mode(
|
||||
|
||||
cmmd = SyncPermissionsCommand(1, "admin")
|
||||
cmmd.run()
|
||||
async_task_mock.delay.assert_called_once_with(1, mock_user.return_value.id, "my_db")
|
||||
async_task_mock.delay.assert_called_once_with(1, "admin", "my_db")
|
||||
|
||||
|
||||
@with_config({"SYNC_DB_PERMISSIONS_IN_ASYNC_MODE": False})
|
||||
@@ -219,7 +219,7 @@ def test_sync_permissions_command_async_mode_new_db_name(
|
||||
Test ``SyncPermissionsCommand`` in async mode when the
|
||||
database name changed.
|
||||
"""
|
||||
mock_user = mocker.patch(
|
||||
mocker.patch(
|
||||
"superset.commands.database.sync_permissions.security_manager.get_user_by_username"
|
||||
)
|
||||
async_task_mock = mocker.patch(
|
||||
@@ -233,9 +233,7 @@ def test_sync_permissions_command_async_mode_new_db_name(
|
||||
)
|
||||
cmmd.run()
|
||||
|
||||
async_task_mock.delay.assert_called_once_with(
|
||||
1, mock_user.return_value.id, "Old Name"
|
||||
)
|
||||
async_task_mock.delay.assert_called_once_with(1, "admin", "Old Name")
|
||||
|
||||
|
||||
def test_sync_permissions_command_get_catalogs(database_with_catalog: MagicMock):
|
||||
|
||||
@@ -122,11 +122,11 @@ def test_update_sync_perms_in_async_mode(
|
||||
"superset.commands.database.sync_permissions.sync_database_permissions_task.delay"
|
||||
)
|
||||
mocker.patch("superset.commands.database.update.get_username", return_value="admin")
|
||||
mock_user = mocker.patch("superset.security_manager.get_user_by_username")
|
||||
mocker.patch("superset.security_manager.get_user_by_username")
|
||||
|
||||
UpdateDatabaseCommand(1, {}).run()
|
||||
|
||||
sync_task.assert_called_once_with(1, mock_user.return_value.id, "my_db")
|
||||
sync_task.assert_called_once_with(1, "admin", "my_db")
|
||||
|
||||
|
||||
def test_update_without_catalog(
|
||||
|
||||
@@ -44,11 +44,6 @@ def mock_database(mocker: MockerFixture) -> MagicMock:
|
||||
"superset.commands.database.validate_sql.DatabaseDAO"
|
||||
)
|
||||
DatabaseDAO.find_by_id.return_value = database
|
||||
# Access validation runs before template processing; it has its own
|
||||
# coverage, so keep it a no-op here.
|
||||
mocker.patch(
|
||||
"superset.commands.database.validate_sql.security_manager.raise_for_access"
|
||||
)
|
||||
return database
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ from superset.commands.dataset.update import (
|
||||
from superset.datasets.schemas import FolderSchema
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.sql.parse import Table
|
||||
from superset.subjects.exceptions import SubjectsNotFoundValidationError
|
||||
from tests.unit_tests.conftest import with_feature_flags
|
||||
|
||||
@@ -279,60 +278,6 @@ def test_update_dataset_database_id_change_allowed_with_access(
|
||||
assert update_kwargs["attributes"]["database"] is mock_new_database
|
||||
|
||||
|
||||
def test_update_dataset_physical_repoint_requires_table_access(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Repointing a physical dataset at a different table must pass the same
|
||||
``raise_for_access(database=..., table=...)`` gate the create path
|
||||
enforces; editorship alone must not grant access to the new table.
|
||||
"""
|
||||
mock_dataset_dao = mocker.patch("superset.commands.dataset.update.DatasetDAO")
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.update.security_manager.raise_for_editorship",
|
||||
)
|
||||
mocker.patch("superset.commands.utils.security_manager.is_admin", return_value=True)
|
||||
|
||||
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 = "allowed_table"
|
||||
mock_dataset.sql = None # physical dataset
|
||||
mock_dataset.editors = []
|
||||
|
||||
mock_dataset_dao.find_by_id.return_value = mock_dataset
|
||||
mock_dataset_dao.validate_update_uniqueness.return_value = True
|
||||
|
||||
raise_for_access = mocker.patch(
|
||||
"superset.commands.dataset.update.security_manager.raise_for_access",
|
||||
side_effect=SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
message="You don't have access to the table 'restricted_table'",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(DatasetInvalidError) as excinfo:
|
||||
UpdateDatasetCommand(1, {"table_name": "restricted_table"}).run()
|
||||
|
||||
raise_for_access.assert_called_once_with(
|
||||
database=mock_database,
|
||||
table=Table("restricted_table", "public", "catalog"),
|
||||
)
|
||||
assert any(
|
||||
"You don't have access to the table" in str(exc)
|
||||
for exc in excinfo.value._exceptions
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload, exception, error_msg"),
|
||||
[
|
||||
@@ -1274,44 +1219,3 @@ def test_validate_folders_metrics_vs_columns_behavior(mocker: MockerFixture) ->
|
||||
command2._validate_semantics([])
|
||||
except Exception as e:
|
||||
pytest.fail(f"Should work with new metric UUIDs when new metrics provided: {e}")
|
||||
|
||||
|
||||
def test_update_dataset_rejects_malicious_fetch_values_predicate(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
``fetch_values_predicate`` is wrapped verbatim into a raw WHERE clause at
|
||||
query time, so the command routes it through the stored-expression
|
||||
validator; a UNION-based predicate is rejected at save time.
|
||||
"""
|
||||
mock_dataset_dao = mocker.patch("superset.commands.dataset.update.DatasetDAO")
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.update.security_manager.raise_for_editorship",
|
||||
)
|
||||
mocker.patch("superset.commands.utils.security_manager.is_admin", return_value=True)
|
||||
mocker.patch(
|
||||
"superset.commands.utils.security_manager.get_user_by_id", return_value=None
|
||||
)
|
||||
mock_database = mocker.MagicMock()
|
||||
mock_database.id = 1
|
||||
mock_database.backend = "sqlite"
|
||||
mock_database.allow_multi_catalog = False
|
||||
mock_database.get_default_catalog.return_value = "catalog"
|
||||
mock_dataset = mocker.MagicMock()
|
||||
mock_dataset.database = mock_database
|
||||
mock_dataset.catalog = "catalog"
|
||||
mock_dataset.schema = None
|
||||
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
|
||||
|
||||
payload = {
|
||||
"fetch_values_predicate": "1=0 UNION SELECT card_number FROM billing.cards"
|
||||
}
|
||||
with pytest.raises(DatasetInvalidError) as excinfo:
|
||||
UpdateDatasetCommand(1, payload).run()
|
||||
assert any(
|
||||
isinstance(exc, ValidationError)
|
||||
and "fetch_values_predicate" in (exc.field_name or "")
|
||||
for exc in excinfo.value._exceptions
|
||||
)
|
||||
|
||||
@@ -511,11 +511,8 @@ def test_execute_query_raises_when_executor_user_missing(
|
||||
username, rather than swallowing it into an opaque ``AlertQueryError`` (or
|
||||
surfacing a NoneType/AttributeError from the downstream auth flow).
|
||||
"""
|
||||
template_processor_mock = mocker.Mock()
|
||||
template_processor_mock.process_template.return_value = "SELECT value FROM metrics"
|
||||
mocker.patch(
|
||||
"superset.commands.report.alert.jinja_context.get_template_processor",
|
||||
return_value=template_processor_mock,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.report.alert.get_executor",
|
||||
@@ -529,8 +526,6 @@ def test_execute_query_raises_when_executor_user_missing(
|
||||
report_schedule_mock = mocker.Mock()
|
||||
report_schedule_mock.id = 1
|
||||
report_schedule_mock.sql = "SELECT value FROM metrics"
|
||||
report_schedule_mock.database.backend = "sqlite"
|
||||
report_schedule_mock.database.allow_dml = False
|
||||
|
||||
command = AlertCommand(
|
||||
report_schedule=report_schedule_mock,
|
||||
|
||||
@@ -24,7 +24,6 @@ from typing import Any, Callable
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.report.base import BaseReportScheduleCommand
|
||||
from superset.commands.report.exceptions import (
|
||||
@@ -319,94 +318,3 @@ def test_validate_report_frequency_using_callable() -> None:
|
||||
"1,6 * * * *",
|
||||
ReportScheduleType.REPORT,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_alert_query_rejects_multi_statement_sql() -> None:
|
||||
"""
|
||||
Alert SQL is validated at save time; multi-statement SQL cannot be
|
||||
persisted for later raw execution by the alert runner.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.commands.report.base import BaseReportScheduleCommand
|
||||
from superset.commands.report.exceptions import (
|
||||
AlertQueryMultipleStatementsValidationError,
|
||||
)
|
||||
|
||||
database = MagicMock()
|
||||
database.backend = "sqlite"
|
||||
database.allow_dml = False
|
||||
|
||||
exceptions: list[ValidationError] = []
|
||||
BaseReportScheduleCommand().validate_alert_query(
|
||||
database, "SELECT 1; DROP TABLE ab_user", exceptions
|
||||
)
|
||||
|
||||
assert len(exceptions) == 1
|
||||
assert isinstance(exceptions[0], AlertQueryMultipleStatementsValidationError)
|
||||
|
||||
|
||||
def test_validate_alert_query_rejects_dml_when_not_allowed() -> None:
|
||||
"""A mutating alert query is rejected unless the database allows DML."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.commands.report.base import BaseReportScheduleCommand
|
||||
from superset.commands.report.exceptions import (
|
||||
AlertQueryDMLNotAllowedValidationError,
|
||||
)
|
||||
|
||||
database = MagicMock()
|
||||
database.backend = "sqlite"
|
||||
database.allow_dml = False
|
||||
|
||||
exceptions: list[ValidationError] = []
|
||||
BaseReportScheduleCommand().validate_alert_query(
|
||||
database, "UPDATE ab_user SET active = 1", exceptions
|
||||
)
|
||||
|
||||
assert len(exceptions) == 1
|
||||
assert isinstance(exceptions[0], AlertQueryDMLNotAllowedValidationError)
|
||||
|
||||
|
||||
def test_validate_alert_query_rejects_unauthorized_tables(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A single read-only statement referencing tables the user cannot access
|
||||
is rejected via the table-level authorization check."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.commands.report.base import BaseReportScheduleCommand
|
||||
from superset.commands.report.exceptions import (
|
||||
AlertQueryDataAccessValidationError,
|
||||
)
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.report.base.security_manager.raise_for_access",
|
||||
side_effect=SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR,
|
||||
message="You need access to the following tables: `secret`",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
database = MagicMock()
|
||||
database.backend = "sqlite"
|
||||
database.allow_dml = False
|
||||
|
||||
exceptions: list[ValidationError] = []
|
||||
BaseReportScheduleCommand().validate_alert_query(
|
||||
database, "SELECT * FROM secret", exceptions
|
||||
)
|
||||
|
||||
assert len(exceptions) == 1
|
||||
assert isinstance(exceptions[0], AlertQueryDataAccessValidationError)
|
||||
|
||||
@@ -81,12 +81,6 @@ def _setup_mocks(mocker: MockerFixture, model: Mock) -> None:
|
||||
UpdateReportScheduleCommand,
|
||||
"validate_report_frequency",
|
||||
)
|
||||
# Alert-query validation has dedicated coverage in base_test.py; these
|
||||
# tests focus on database-presence handling, so stub it out here.
|
||||
mocker.patch.object(
|
||||
UpdateReportScheduleCommand,
|
||||
"validate_alert_query",
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.report.update.compute_subjects",
|
||||
)
|
||||
|
||||
@@ -57,13 +57,13 @@ def _security_exception() -> SupersetSecurityException:
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.security_manager", new_callable=MagicMock)
|
||||
@patch("superset.commands.sql_lab.estimate.DatabaseDAO")
|
||||
@patch("superset.commands.sql_lab.estimate.db")
|
||||
def test_validate_raises_when_database_not_found(
|
||||
mock_dao: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_security_manager: MagicMock,
|
||||
) -> None:
|
||||
"""404 is raised before the access check when the database does not exist."""
|
||||
mock_dao.find_by_id.return_value = None
|
||||
mock_db.session.query.return_value.get.return_value = None
|
||||
|
||||
command = QueryEstimationCommand(_make_params())
|
||||
with pytest.raises(SupersetErrorException) as exc_info:
|
||||
@@ -79,21 +79,23 @@ def test_validate_raises_when_database_not_found(
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.security_manager", new_callable=MagicMock)
|
||||
@patch("superset.commands.sql_lab.estimate.DatabaseDAO")
|
||||
@patch("superset.commands.sql_lab.estimate.db")
|
||||
def test_validate_raises_when_database_access_denied(
|
||||
mock_dao: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_security_manager: MagicMock,
|
||||
) -> None:
|
||||
"""SupersetSecurityException propagates when raise_for_access denies access."""
|
||||
mock_database = MagicMock()
|
||||
mock_dao.find_by_id.return_value = mock_database
|
||||
mock_db.session.query.return_value.get.return_value = mock_database
|
||||
mock_security_manager.raise_for_access.side_effect = _security_exception()
|
||||
|
||||
command = QueryEstimationCommand(_make_params())
|
||||
with pytest.raises(SupersetSecurityException):
|
||||
command.validate()
|
||||
|
||||
mock_security_manager.raise_for_access.assert_called_once()
|
||||
mock_security_manager.raise_for_access.assert_called_once_with(
|
||||
database=mock_database
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -102,21 +104,22 @@ def test_validate_raises_when_database_access_denied(
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.security_manager", new_callable=MagicMock)
|
||||
@patch("superset.commands.sql_lab.estimate.DatabaseDAO")
|
||||
@patch("superset.commands.sql_lab.estimate.db")
|
||||
def test_validate_succeeds_for_authorised_user(
|
||||
mock_dao: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_security_manager: MagicMock,
|
||||
) -> None:
|
||||
"""validate() completes without error when access is granted."""
|
||||
mock_database = MagicMock()
|
||||
mock_dao.find_by_id.return_value = mock_database
|
||||
mock_db.session.query.return_value.get.return_value = mock_database
|
||||
mock_security_manager.raise_for_access.return_value = None
|
||||
|
||||
command = QueryEstimationCommand(_make_params())
|
||||
command.validate() # must not raise
|
||||
|
||||
call_kwargs = mock_security_manager.raise_for_access.call_args.kwargs
|
||||
assert call_kwargs["database"] is mock_database
|
||||
mock_security_manager.raise_for_access.assert_called_once_with(
|
||||
database=mock_database
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -125,15 +128,15 @@ def test_validate_succeeds_for_authorised_user(
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.security_manager", new_callable=MagicMock)
|
||||
@patch("superset.commands.sql_lab.estimate.DatabaseDAO")
|
||||
@patch("superset.commands.sql_lab.estimate.db")
|
||||
def test_raise_for_access_called_with_correct_database(
|
||||
mock_dao: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_security_manager: MagicMock,
|
||||
) -> None:
|
||||
"""The database object fetched from the session is passed to raise_for_access."""
|
||||
mock_database = MagicMock()
|
||||
mock_database.id = 42
|
||||
mock_dao.find_by_id.return_value = mock_database
|
||||
mock_db.session.query.return_value.get.return_value = mock_database
|
||||
mock_security_manager.raise_for_access.return_value = None
|
||||
|
||||
command = QueryEstimationCommand(_make_params(database_id=42))
|
||||
@@ -143,39 +146,6 @@ def test_raise_for_access_called_with_correct_database(
|
||||
assert call_kwargs["database"] is mock_database
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: the SQL to be estimated must be authorized, not just the handle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.security_manager", new_callable=MagicMock)
|
||||
@patch("superset.commands.sql_lab.estimate.DatabaseDAO")
|
||||
def test_validate_authorizes_the_sql_to_be_estimated(
|
||||
mock_dao: MagicMock,
|
||||
mock_security_manager: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
``raise_for_access`` must receive the SQL so table-level authorization
|
||||
runs; a bare ``database=`` argument matches no branch and checks nothing.
|
||||
"""
|
||||
mock_database = MagicMock()
|
||||
mock_dao.find_by_id.return_value = mock_database
|
||||
|
||||
command = QueryEstimationCommand(
|
||||
_make_params(sql="SELECT * FROM secret_table", schema="main")
|
||||
)
|
||||
command.validate()
|
||||
|
||||
mock_security_manager.raise_for_access.assert_called_once_with(
|
||||
database=mock_database,
|
||||
sql="SELECT * FROM secret_table",
|
||||
catalog=None,
|
||||
schema="main",
|
||||
template_params={},
|
||||
force_dataset_match=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL security controls applied on the estimate path (parity with executor)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -416,9 +386,9 @@ def test_apply_sql_security_propagates_engine_schema_gate(
|
||||
|
||||
@patch("superset.commands.sql_lab.estimate.get_template_processor")
|
||||
@patch("superset.commands.sql_lab.estimate.security_manager", new_callable=MagicMock)
|
||||
@patch("superset.commands.sql_lab.estimate.DatabaseDAO")
|
||||
@patch("superset.commands.sql_lab.estimate.db")
|
||||
def test_run_wraps_raw_jinja_undefined_error(
|
||||
mock_dao: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
mock_security_manager: MagicMock,
|
||||
mock_get_template_processor: MagicMock,
|
||||
) -> None:
|
||||
@@ -431,7 +401,7 @@ def test_run_wraps_raw_jinja_undefined_error(
|
||||
from jinja2.exceptions import UndefinedError
|
||||
|
||||
mock_database = MagicMock()
|
||||
mock_dao.find_by_id.return_value = mock_database
|
||||
mock_db.session.query.return_value.get.return_value = mock_database
|
||||
mock_security_manager.raise_for_access.return_value = None
|
||||
mock_get_template_processor.return_value.process_template.side_effect = (
|
||||
UndefinedError("'foo' is undefined")
|
||||
|
||||
@@ -39,11 +39,7 @@ from superset.exceptions import (
|
||||
SupersetSecurityException,
|
||||
)
|
||||
from superset.models.core import Database
|
||||
from superset.models.helpers import (
|
||||
ExploreMixin,
|
||||
validate_adhoc_subquery,
|
||||
validate_rendered_expression,
|
||||
)
|
||||
from superset.models.helpers import ExploreMixin, validate_adhoc_subquery
|
||||
from superset.sql.parse import Table
|
||||
from superset.superset_typing import QueryObjectDict
|
||||
from superset.utils import json
|
||||
@@ -1649,79 +1645,6 @@ def test_get_sqla_col_catches_subquery_beside_unparseable_syntax(
|
||||
tc.get_sqla_col()
|
||||
|
||||
|
||||
def test_validate_rendered_expression_rejects_multi_statement(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
database = _database_for_expression(mocker)
|
||||
with pytest.raises(QueryObjectValidationError):
|
||||
validate_rendered_expression("1; DROP TABLE users", database, None, "public")
|
||||
|
||||
|
||||
def test_validate_rendered_expression_rejects_set_operation(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
database = _database_for_expression(mocker)
|
||||
with pytest.raises(QueryObjectValidationError):
|
||||
validate_rendered_expression(
|
||||
"1 UNION SELECT password FROM ab_user", database, None, "public"
|
||||
)
|
||||
|
||||
|
||||
def test_validate_rendered_expression_rejects_subquery(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
With ``ALLOW_ADHOC_SUBQUERY=False`` (the default), a rendered expression
|
||||
containing a sub-query is rejected by the same ``validate_adhoc_subquery``
|
||||
gate used for stored and adhoc expressions.
|
||||
"""
|
||||
database = _database_for_expression(mocker)
|
||||
mocker.patch("superset.models.helpers.is_feature_enabled", return_value=False)
|
||||
with pytest.raises(QueryObjectValidationError):
|
||||
validate_rendered_expression(
|
||||
"(SELECT password FROM ab_user LIMIT 1)", database, None, "public"
|
||||
)
|
||||
|
||||
|
||||
def test_validate_rendered_expression_accepts_valid_expression(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A benign rendered expression is returned unchanged (no RLS applied)."""
|
||||
database = _database_for_expression(mocker)
|
||||
mocker.patch("superset.models.helpers.is_feature_enabled", return_value=False)
|
||||
result = validate_rendered_expression("SUM(amount)", database, None, "public")
|
||||
assert result == "SUM(amount)"
|
||||
|
||||
|
||||
def test_get_sqla_col_revalidates_rendered_jinja_expression(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
A Jinja block that renders into a sub-query must be rejected at query
|
||||
time: save-time validation only sees the block as a placeholder, so the
|
||||
rendered expression is re-validated before it is embedded via
|
||||
``literal_column``. The failure surfaces as a chart-level
|
||||
``QueryObjectValidationError``, matching the stored-expression path,
|
||||
rather than a raw ``SupersetSecurityException``.
|
||||
"""
|
||||
# A real Database (not a MagicMock) so the ORM relationship assignment on
|
||||
# SqlaTable has a valid instance state; sqlite gives a concrete backend.
|
||||
database = Database(database_name="t", sqlalchemy_uri="sqlite://")
|
||||
mocker.patch("superset.models.helpers.is_feature_enabled", return_value=False)
|
||||
table = SqlaTable(table_name="t", database=database)
|
||||
tbl_column = TableColumn(
|
||||
column_name="c",
|
||||
expression='{{ "(SELECT password FROM ab_user LIMIT 1)" }}',
|
||||
table=table,
|
||||
)
|
||||
template_processor = mocker.MagicMock()
|
||||
template_processor.process_template.return_value = (
|
||||
"(SELECT password FROM ab_user LIMIT 1)"
|
||||
)
|
||||
with pytest.raises(QueryObjectValidationError):
|
||||
tbl_column.get_sqla_col(template_processor=template_processor)
|
||||
|
||||
|
||||
def test_has_extra_cache_key_calls_scans_guest_token_rls(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
@@ -1757,46 +1680,3 @@ def test_has_extra_cache_key_calls_scans_guest_token_rls(
|
||||
|
||||
get_guest_rls.return_value = [{"clause": "tenant = 'acme'"}]
|
||||
assert table.has_extra_cache_key_calls(query_obj) is False
|
||||
|
||||
|
||||
def test_dttm_cols_excludes_column_after_temporal_flag_removed(
|
||||
session: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Regression for #30510: when a column is mistakenly marked temporal, set as the
|
||||
dataset's default datetime (``main_dttm_col``) and saved, then later has its
|
||||
``is_dttm`` flag removed, the dataset must stop treating that column as temporal.
|
||||
|
||||
Otherwise ``dttm_cols`` (which feeds time-column selection and the default time
|
||||
filter for every chart built on the dataset) keeps returning a non-temporal
|
||||
column, corrupting the dataset with a time filter that cannot be removed.
|
||||
"""
|
||||
Database.metadata.create_all(session.bind)
|
||||
database = Database(database_name="my_db", sqlalchemy_uri="sqlite://")
|
||||
|
||||
# A column the user mistakenly marks as temporal ("Is Temporal") and then picks
|
||||
# as the dataset "Default Datetime" (``main_dttm_col``).
|
||||
column = TableColumn(column_name="not_really_a_date", type="VARCHAR", is_dttm=True)
|
||||
dataset = SqlaTable(
|
||||
database=database,
|
||||
table_name="my_table",
|
||||
columns=[column],
|
||||
main_dttm_col="not_really_a_date",
|
||||
)
|
||||
session.add(dataset)
|
||||
session.commit()
|
||||
|
||||
# While flagged temporal, the column is (expectedly) exposed as a datetime column.
|
||||
assert dataset.dttm_cols == ["not_really_a_date"]
|
||||
|
||||
# The user realizes the mistake and unchecks "Is Temporal", then saves. Persisting
|
||||
# the update clears ``is_dttm`` on the column.
|
||||
column.is_dttm = False
|
||||
session.commit()
|
||||
|
||||
# The column is no longer temporal...
|
||||
assert column.is_temporal is False
|
||||
# ...so it must no longer be reported as a datetime column. On master
|
||||
# ``main_dttm_col`` is never cleared, so ``dttm_cols`` still contains the stale,
|
||||
# non-temporal column and this assertion fails (bug reproduced).
|
||||
assert "not_really_a_date" not in dataset.dttm_cols
|
||||
|
||||
@@ -182,27 +182,6 @@ SELECT * FROM some_table;
|
||||
)
|
||||
|
||||
|
||||
def test_get_default_schema_for_query_set_config(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
A ``set_config('search_path', ...)`` call rebinds unqualified-name
|
||||
resolution on the shared cursor just like ``SET search_path``, so it
|
||||
must be rejected too.
|
||||
"""
|
||||
database = mocker.MagicMock()
|
||||
query = mocker.MagicMock()
|
||||
query.schema = "foo"
|
||||
query.sql = (
|
||||
"SELECT set_config('search_path', 'tenant_b', false); SELECT * FROM orders"
|
||||
)
|
||||
|
||||
with pytest.raises(SupersetSecurityException) as excinfo:
|
||||
spec.get_default_schema_for_query(database, query)
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
== "Users are not allowed to set a search path for security reasons."
|
||||
)
|
||||
|
||||
|
||||
def test_adjust_engine_params() -> None:
|
||||
"""
|
||||
Test `adjust_engine_params`.
|
||||
|
||||
@@ -38,7 +38,6 @@ import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.sql.elements import TextClause
|
||||
|
||||
from superset.exceptions import QueryObjectValidationError
|
||||
from superset.models.helpers import ExploreMixin
|
||||
from superset.sql.parse import RLSMethod, SQLStatement, Table
|
||||
|
||||
@@ -381,36 +380,3 @@ class TestRLSSubqueryAlias:
|
||||
|
||||
assert "is_green" in result
|
||||
assert "WHERE" in result # RLS predicate applied
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. RLS injection failures must fail closed when predicates apply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVirtualDatasetRLSFailClosed:
|
||||
"""
|
||||
When RLS predicates exist for the underlying tables but cannot be
|
||||
injected into the virtual dataset SQL, the query must be aborted
|
||||
instead of running against the unfiltered inner SQL.
|
||||
"""
|
||||
|
||||
@patch(
|
||||
"superset.models.helpers.get_predicates_for_table",
|
||||
return_value=["user_id = 42"],
|
||||
)
|
||||
@patch(
|
||||
"superset.models.helpers.apply_rls",
|
||||
side_effect=NotImplementedError("engine cannot apply RLS"),
|
||||
)
|
||||
def test_raises_when_rls_predicates_cannot_be_applied(
|
||||
self,
|
||||
mock_apply_rls: MagicMock,
|
||||
mock_get_predicates: MagicMock,
|
||||
virtual_datasource: MagicMock,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
_set_virtual_sql(virtual_datasource, "SELECT pen_id FROM public.pens")
|
||||
|
||||
with pytest.raises(QueryObjectValidationError):
|
||||
virtual_datasource.get_from_clause(template_processor=None)
|
||||
|
||||
@@ -408,29 +408,10 @@ def test_extract_tables_illdefined() -> None:
|
||||
def test_extract_tables_show_tables_from() -> None:
|
||||
"""
|
||||
Test `SHOW TABLES FROM`.
|
||||
|
||||
No individual table target is extractable, so the statement must be
|
||||
flagged as unparseable for authorization purposes instead of passing
|
||||
strict scoping with an empty table set.
|
||||
"""
|
||||
assert (
|
||||
extract_tables_from_sql("SHOW TABLES FROM s1 like '%order%'", "mysql") == set()
|
||||
)
|
||||
assert SQLScript(
|
||||
"SHOW TABLES FROM s1 like '%order%'", "mysql"
|
||||
).has_unparseable_statement
|
||||
|
||||
|
||||
def test_extract_tables_show_create_table() -> None:
|
||||
"""
|
||||
Test `SHOW CREATE TABLE`.
|
||||
|
||||
The target table must enter table-level authorization.
|
||||
"""
|
||||
assert extract_tables_from_sql("SHOW CREATE TABLE s1.t1", "mysql") == {
|
||||
Table("t1", "s1")
|
||||
}
|
||||
assert not SQLScript("SHOW CREATE TABLE s1.t1", "mysql").has_unparseable_statement
|
||||
|
||||
|
||||
def test_format_show_tables() -> None:
|
||||
@@ -1608,44 +1589,6 @@ def test_is_mutating(sql: str, engine: str, expected: bool) -> None:
|
||||
assert SQLStatement(sql, engine).is_mutating() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, engine",
|
||||
[
|
||||
# Opaque `exp.Command` fallbacks must fail closed on every dialect,
|
||||
# not only PostgreSQL.
|
||||
("CALL evil_proc()", "mysql"),
|
||||
("LOAD '/tmp/x.so'", "postgres"),
|
||||
("EXEC dbo.evil_proc", "mssql"),
|
||||
# The EXPLAIN ANALYZE unwrap must handle the parenthesized
|
||||
# option-list, whitespace, alternate-spelling, and leading-comment
|
||||
# forms: PostgreSQL executes the inner DML for all of them.
|
||||
("EXPLAIN (ANALYZE) UPDATE t SET x = 1", "postgresql"),
|
||||
("EXPLAIN (ANALYZE, BUFFERS) DELETE FROM t", "postgresql"),
|
||||
("EXPLAIN ANALYZE\nUPDATE t SET x = 1", "postgresql"),
|
||||
("EXPLAIN ANALYSE UPDATE t SET x = 1", "postgresql"),
|
||||
("EXPLAIN /* c */ (ANALYZE) UPDATE t SET x = 1", "postgresql"),
|
||||
# A bare COMMIT persists every prior write on the connection even
|
||||
# when the execution layer skips its own commit call.
|
||||
("COMMIT", "postgresql"),
|
||||
("COMMIT", "mysql"),
|
||||
# Further EXPLAIN ANALYZE edge forms: a leading line comment before
|
||||
# the option, a VERBOSE qualifier, an empty option list, and an
|
||||
# inner statement that cannot be parsed all fail closed as mutating.
|
||||
("EXPLAIN --c\nANALYZE UPDATE t SET x = 1", "postgresql"),
|
||||
("EXPLAIN ANALYZE VERBOSE UPDATE t SET x = 1", "postgresql"),
|
||||
("EXPLAIN (ANALYZE)", "postgresql"),
|
||||
("EXPLAIN ANALYZE )))", "postgresql"),
|
||||
],
|
||||
)
|
||||
def test_is_mutating_fails_closed_on_gate_blind_spots(sql: str, engine: str) -> None:
|
||||
"""
|
||||
`is_mutating` must fail closed on statements that slip past node-type
|
||||
matching: non-PostgreSQL command fallbacks, normalized `EXPLAIN ANALYZE`
|
||||
variants, and structured `COMMIT`.
|
||||
"""
|
||||
assert SQLStatement(sql, engine).is_mutating()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, expected",
|
||||
[
|
||||
@@ -3538,7 +3481,6 @@ def test_sqlstatement_format_preserves_multi_arg_distinct(engine: str) -> None:
|
||||
assert "CASE WHEN" not in formatted
|
||||
|
||||
|
||||
@with_feature_flags(ENABLE_TEMPLATE_PROCESSING=True)
|
||||
@pytest.mark.parametrize(
|
||||
"engine",
|
||||
[
|
||||
@@ -3567,12 +3509,12 @@ def test_sqlstatement_format_preserves_multi_arg_distinct(engine: str) -> None:
|
||||
{Table(table="bar", schema="foo")},
|
||||
),
|
||||
(
|
||||
"latest_partitions('foo.bar')",
|
||||
{Table(table="bar", schema="foo")},
|
||||
"latest_partition('foo.%s'|format(str('bar')))",
|
||||
set(),
|
||||
),
|
||||
(
|
||||
"first_latest_partition('foo.bar')",
|
||||
{Table(table="bar", schema="foo")},
|
||||
"latest_partition('foo.{}'.format('bar'))",
|
||||
set(),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -3591,42 +3533,6 @@ def test_extract_tables_from_jinja_sql(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"engine",
|
||||
[
|
||||
"hive",
|
||||
"presto",
|
||||
"trino",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"macro",
|
||||
[
|
||||
"latest_partition('foo.%s'|format(str('bar')))",
|
||||
"latest_partition('foo.{}'.format('bar'))",
|
||||
"latest_partitions('foo.{}'.format('bar'))",
|
||||
# A partition macro with the wrong number of arguments cannot be
|
||||
# resolved to a single table, so it must also fail closed.
|
||||
"latest_partition('foo.bar', 'extra')",
|
||||
],
|
||||
)
|
||||
def test_extract_tables_from_jinja_sql_fails_closed(
|
||||
mocker: MockerFixture,
|
||||
engine: str,
|
||||
macro: str,
|
||||
) -> None:
|
||||
"""
|
||||
A partition macro whose table reference cannot be evaluated statically
|
||||
must fail closed, as the macro would otherwise execute against a table
|
||||
that never entered the authorization check.
|
||||
"""
|
||||
with pytest.raises(SupersetParseError):
|
||||
process_jinja_sql(
|
||||
sql=f"'{{{{ {engine}.{macro} }}}}'",
|
||||
database=mocker.MagicMock(backend=engine),
|
||||
)
|
||||
|
||||
|
||||
@with_feature_flags(ENABLE_TEMPLATE_PROCESSING=False)
|
||||
def test_extract_tables_from_jinja_sql_disabled(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
@@ -3716,31 +3622,6 @@ def test_process_jinja_sql_template_params_parameter(mocker: MockerFixture) -> N
|
||||
assert result.tables == {Table("table_name")}
|
||||
|
||||
|
||||
@with_feature_flags(ENABLE_TEMPLATE_PROCESSING=True)
|
||||
def test_process_jinja_sql_renders_exactly_once(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
The authorization path must validate exactly the SQL that executes.
|
||||
|
||||
A template whose first render emits Jinja comment markers inside SQL
|
||||
comments used to be rendered a second time, which stripped the markers
|
||||
and everything between them from the validated SQL while the executed
|
||||
SQL (rendered once) kept the extra statement text.
|
||||
"""
|
||||
database = mocker.MagicMock(backend="postgresql")
|
||||
database.db_engine_spec.engine = "postgresql"
|
||||
|
||||
result = process_jinja_sql(
|
||||
sql=(
|
||||
'SELECT * FROM granted /*{{ "{#" }}*/ '
|
||||
'UNION SELECT * FROM restricted /*{{ "#}" }}*/'
|
||||
),
|
||||
database=database,
|
||||
)
|
||||
|
||||
assert Table("restricted") in result.tables
|
||||
assert Table("granted") in result.tables
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, engine, expected",
|
||||
[
|
||||
@@ -4283,60 +4164,6 @@ def test_changes_search_path(sql: str, expected: bool) -> None:
|
||||
assert SQLStatement(sql, "postgresql").changes_search_path() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, engine, expected",
|
||||
[
|
||||
# `USE` rebinds the schema for every later statement on the cursor.
|
||||
("USE tenant_b; SELECT * FROM orders", "mysql", True),
|
||||
("use `tenant_b`", "mysql", True),
|
||||
("USE SCHEMA tenant_b", "snowflake", True),
|
||||
# Warehouse selection changes compute, not name resolution.
|
||||
("USE WAREHOUSE compute_wh", "snowflake", False),
|
||||
# Search-path changes are schema rebinds too.
|
||||
("SET search_path = tenant_b", "postgresql", True),
|
||||
(
|
||||
"SELECT set_config('search_path', 'tenant_b', false)",
|
||||
"postgresql",
|
||||
True,
|
||||
),
|
||||
# A `set_config()` with a computed setting name fails closed.
|
||||
(
|
||||
"SELECT set_config('search' || '_path', 'tenant_b', false)",
|
||||
"postgresql",
|
||||
True,
|
||||
),
|
||||
# `SET SCHEMA` is an alias for a search-path rebind on Postgres and
|
||||
# a schema rebind on DB2-family engines.
|
||||
("SET SCHEMA 'tenant_b'", "postgresql", True),
|
||||
("SELECT * FROM orders", "mysql", False),
|
||||
("SET statement_timeout = 10", "postgresql", False),
|
||||
# A structured `SET current_schema = ...` rebinds resolution through
|
||||
# a setting rather than a search path.
|
||||
("SET current_schema = foo", "postgresql", True),
|
||||
# `SET CATALOG`/`SET SCHEMA` that fall back to an opaque command are
|
||||
# schema rebinds, including the `CURRENT` spelling; an unrelated `SET`
|
||||
# command (e.g. `SET ROLE`) is not.
|
||||
("SET CATALOG tenant_b", "postgresql", True),
|
||||
("SET CURRENT SCHEMA foo", "postgresql", True),
|
||||
("SET ROLE admin", "postgresql", False),
|
||||
# A `set_config()` whose setting name is a column reference rather than
|
||||
# a literal is treated conservatively as a schema change.
|
||||
("SELECT set_config(schema_col, 'tenant_b', false)", "postgresql", True),
|
||||
# Engines without a sqlglot AST (e.g. Kusto KQL) do not rebind schema
|
||||
# resolution through these forms.
|
||||
("print x = 1", "kustokql", False),
|
||||
],
|
||||
)
|
||||
def test_changes_default_schema(sql: str, engine: str, expected: bool) -> None:
|
||||
"""
|
||||
`changes_default_schema` detects statements that rebind unqualified-name
|
||||
resolution (`USE`, `SET SCHEMA`, search-path changes) so the SQL Lab
|
||||
authorization path can reject the script before qualifying tables with
|
||||
the schema the user selected.
|
||||
"""
|
||||
assert SQLScript(sql, engine).changes_default_schema() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, denylist, expected",
|
||||
[
|
||||
|
||||
@@ -16,12 +16,8 @@
|
||||
# under the License.
|
||||
# pylint: disable=import-outside-toplevel, invalid-name, unused-argument, too-many-locals
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.errors import SupersetErrorType
|
||||
from superset.exceptions import SupersetErrorException
|
||||
from superset.sql.parse import CTASMethod
|
||||
from superset.sqllab.sqllab_execution_context import (
|
||||
CreateTableAsSelect,
|
||||
@@ -105,45 +101,3 @@ def test_create_table_as_select():
|
||||
assert ctas.ctas_method == CTASMethod.TABLE
|
||||
assert ctas.target_schema_name == "public"
|
||||
assert ctas.target_table_name == "temp_table"
|
||||
|
||||
|
||||
def test_set_database_rejects_ctas_when_database_disallows_it(query_params):
|
||||
"""
|
||||
``allow_ctas`` must be enforced server-side at submission: the
|
||||
``select_as_cta``/``ctas_method`` payload fields are client-supplied.
|
||||
"""
|
||||
query_params["select_as_cta"] = True
|
||||
query_params["ctas_method"] = "TABLE"
|
||||
query_params["tmp_table_name"] = "tmp_target"
|
||||
context = SqlJsonExecutionContext(query_params)
|
||||
|
||||
database = MagicMock()
|
||||
database.allow_ctas = False
|
||||
|
||||
with pytest.raises(SupersetErrorException) as exc_info:
|
||||
context.set_database(database)
|
||||
|
||||
assert (
|
||||
exc_info.value.error.error_type == SupersetErrorType.QUERY_SECURITY_ACCESS_ERROR
|
||||
)
|
||||
|
||||
|
||||
def test_set_database_rejects_cvas_when_database_disallows_it(query_params):
|
||||
"""
|
||||
``allow_cvas`` must be enforced server-side at submission, mirroring the
|
||||
``allow_ctas``/VIEW branch of ``_validate_ctas_is_allowed``.
|
||||
"""
|
||||
query_params["select_as_cta"] = True
|
||||
query_params["ctas_method"] = "VIEW"
|
||||
query_params["tmp_table_name"] = "tmp_target"
|
||||
context = SqlJsonExecutionContext(query_params)
|
||||
|
||||
database = MagicMock()
|
||||
database.allow_cvas = False
|
||||
|
||||
with pytest.raises(SupersetErrorException) as exc_info:
|
||||
context.set_database(database)
|
||||
|
||||
assert (
|
||||
exc_info.value.error.error_type == SupersetErrorType.QUERY_SECURITY_ACCESS_ERROR
|
||||
)
|
||||
|
||||
@@ -17,14 +17,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from flask import Flask
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
|
||||
def _disposition_filename(form_filename: str | None) -> str:
|
||||
@@ -67,44 +62,3 @@ def test_streaming_csv_falls_back_when_filename_empty() -> None:
|
||||
|
||||
assert filename.startswith("sqllab_abc123_")
|
||||
assert filename.endswith(".csv")
|
||||
|
||||
|
||||
def test_format_sql_checks_access_before_rendering(
|
||||
mocker: MockerFixture,
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
) -> None:
|
||||
"""
|
||||
Access must be checked before Jinja rendering, as some Jinja macros
|
||||
execute statements against the database upon rendering.
|
||||
"""
|
||||
database = mocker.MagicMock()
|
||||
database.db_engine_spec.engine = "presto"
|
||||
mocker.patch(
|
||||
"superset.sqllab.api.DatabaseDAO.find_by_id",
|
||||
return_value=database,
|
||||
)
|
||||
get_template_processor = mocker.patch("superset.sqllab.api.get_template_processor")
|
||||
raise_for_access = mocker.patch(
|
||||
"superset.sqllab.api.security_manager.raise_for_access",
|
||||
side_effect=SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR,
|
||||
message="You need access to the following tables: `s.t`",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/sqllab/format_sql/",
|
||||
json={
|
||||
"sql": "SELECT '{{ presto.latest_partition('s.t') }}'",
|
||||
"database_id": 1,
|
||||
"template_params": '{"foo": "bar"}',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
raise_for_access.assert_called_once()
|
||||
get_template_processor.assert_not_called()
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
# 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.
|
||||
"""
|
||||
Traces the exact scope of the fallback in
|
||||
``superset.utils.rls.collect_rls_predicates_for_sql``: a SQL-parse failure
|
||||
there makes that function return a per-user marker instead of the real
|
||||
predicates, but this module is wired in as a *cache-key* input only
|
||||
(``SqlaTable.get_extra_cache_keys``), not as part of the code path that
|
||||
actually attaches RLS predicates to a query's WHERE clause
|
||||
(``BaseDatasource.get_sqla_row_level_filters``, consumed directly by
|
||||
``get_sqla_query``). These tests pin down that separation: a parse failure
|
||||
in the cache-key helper only ever affects the cache key contribution (kept
|
||||
distinct per user via the marker), and never "RLS predicates stop being
|
||||
applied to the query".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.sql.elements import TextClause
|
||||
|
||||
from superset.connectors.sqla.models import BaseDatasource
|
||||
from superset.utils.rls import collect_rls_predicates_for_sql
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_database() -> MagicMock:
|
||||
database = MagicMock()
|
||||
database.db_engine_spec.engine = "sqlite"
|
||||
database.get_default_catalog.return_value = None
|
||||
return database
|
||||
|
||||
|
||||
def test_collect_rls_predicates_for_sql_returns_per_user_sentinel_on_parse_failure(
|
||||
mock_database: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
A SQL-parse exception inside ``collect_rls_predicates_for_sql`` is
|
||||
swallowed, and the function returns a marker derived from the current
|
||||
user's id instead of propagating the exception or silently returning an
|
||||
empty list.
|
||||
"""
|
||||
with (
|
||||
patch(
|
||||
"superset.sql.parse.SQLScript",
|
||||
side_effect=ValueError("cannot parse"),
|
||||
),
|
||||
patch("superset.utils.rls.get_user_id", return_value=42),
|
||||
):
|
||||
result = collect_rls_predicates_for_sql(
|
||||
"SELECT * FROM some_table",
|
||||
mock_database,
|
||||
catalog=None,
|
||||
schema="public",
|
||||
)
|
||||
|
||||
assert result == ["rls-predicate-parse-failed-for-user-42"]
|
||||
|
||||
|
||||
def test_parse_failure_produces_different_cache_contributions_for_different_users(
|
||||
mock_database: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
Two virtual datasets whose underlying RLS predicates differ (one has a
|
||||
predicate, the other has none) would normally contribute different
|
||||
strings to the cache key. If SQL parsing fails before predicates are
|
||||
even collected, the actual predicate difference never gets a chance to
|
||||
be collected -- but each user still contributes a marker scoped to their
|
||||
own id, so the two calls don't collapse onto the same cache key
|
||||
contribution.
|
||||
"""
|
||||
with (
|
||||
patch(
|
||||
"superset.sql.parse.SQLScript",
|
||||
side_effect=ValueError("cannot parse"),
|
||||
),
|
||||
patch(
|
||||
"superset.utils.rls.get_predicates_for_table",
|
||||
side_effect=[["tenant_id = 1"], []],
|
||||
) as mock_get_predicates,
|
||||
patch(
|
||||
"superset.utils.rls.get_user_id",
|
||||
side_effect=[1, 2],
|
||||
),
|
||||
):
|
||||
result_user_one = collect_rls_predicates_for_sql(
|
||||
"SELECT * FROM some_table",
|
||||
mock_database,
|
||||
catalog=None,
|
||||
schema="public",
|
||||
)
|
||||
result_user_two = collect_rls_predicates_for_sql(
|
||||
"SELECT * FROM some_table",
|
||||
mock_database,
|
||||
catalog=None,
|
||||
schema="public",
|
||||
)
|
||||
|
||||
# get_predicates_for_table was never reached: the parse exception fires
|
||||
# first, so the per-user predicate difference never had a chance to be
|
||||
# collected in the first place.
|
||||
mock_get_predicates.assert_not_called()
|
||||
assert result_user_one != result_user_two
|
||||
assert result_user_one == ["rls-predicate-parse-failed-for-user-1"]
|
||||
assert result_user_two == ["rls-predicate-parse-failed-for-user-2"]
|
||||
|
||||
|
||||
def test_parse_failure_sentinel_distinguishes_guest_tokens_by_rls_scope(
|
||||
mock_database: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
``get_user_id()`` always returns ``None`` for guest users, so keying the
|
||||
parse-failure sentinel on it alone would collapse every guest token onto
|
||||
the same cache contribution regardless of the RLS rules baked into each
|
||||
token. Guest sessions must instead be distinguished by (a hash of) their
|
||||
own token's ``rls_rules``, so two guests with different row-level scopes
|
||||
never share a cache entry, while two guests with the *same* scope do.
|
||||
"""
|
||||
|
||||
def _guest_user(rls_rules: list[dict[str, str]]) -> MagicMock:
|
||||
guest_user = MagicMock()
|
||||
guest_user.guest_token = {"rls_rules": rls_rules}
|
||||
return guest_user
|
||||
|
||||
scope_a = [{"dataset": "1", "clause": "tenant_id = 1"}]
|
||||
scope_b = [{"dataset": "1", "clause": "tenant_id = 2"}]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"superset.sql.parse.SQLScript",
|
||||
side_effect=ValueError("cannot parse"),
|
||||
),
|
||||
patch(
|
||||
"superset.utils.rls.security_manager.get_current_guest_user_if_guest",
|
||||
side_effect=[
|
||||
_guest_user(scope_a),
|
||||
_guest_user(scope_b),
|
||||
_guest_user(scope_a),
|
||||
],
|
||||
),
|
||||
):
|
||||
result_guest_scope_a = collect_rls_predicates_for_sql(
|
||||
"SELECT * FROM some_table",
|
||||
mock_database,
|
||||
catalog=None,
|
||||
schema="public",
|
||||
)
|
||||
result_guest_scope_b = collect_rls_predicates_for_sql(
|
||||
"SELECT * FROM some_table",
|
||||
mock_database,
|
||||
catalog=None,
|
||||
schema="public",
|
||||
)
|
||||
result_guest_scope_a_again = collect_rls_predicates_for_sql(
|
||||
"SELECT * FROM some_table",
|
||||
mock_database,
|
||||
catalog=None,
|
||||
schema="public",
|
||||
)
|
||||
|
||||
assert result_guest_scope_a[0].startswith(
|
||||
"rls-predicate-parse-failed-for-user-guest-"
|
||||
)
|
||||
assert result_guest_scope_a != result_guest_scope_b
|
||||
assert result_guest_scope_a == result_guest_scope_a_again
|
||||
|
||||
|
||||
def test_real_rls_enforcement_does_not_go_through_the_cache_key_helper(
|
||||
app: Flask,
|
||||
) -> None:
|
||||
"""
|
||||
``get_sqla_row_level_filters`` -- the method ``get_sqla_query`` actually
|
||||
calls to build a query's WHERE clause -- reaches the RLS rules directly
|
||||
via ``security_manager.get_rls_filters`` and never touches
|
||||
``collect_rls_predicates_for_sql``. So even in a request where SQL
|
||||
parsing inside the cache-key helper fails, the predicate is still
|
||||
attached to the real, executed query: the failure mode is confined to
|
||||
the cache key, not the query itself.
|
||||
"""
|
||||
datasource = MagicMock(spec=BaseDatasource)
|
||||
datasource.get_template_processor.return_value = MagicMock()
|
||||
datasource.get_template_processor.return_value.process_template = lambda x: x
|
||||
datasource.text = lambda x: TextClause(x)
|
||||
|
||||
configured_filter = MagicMock()
|
||||
configured_filter.clause = "tenant_id = 1"
|
||||
configured_filter.group_key = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"superset.connectors.sqla.models.security_manager.get_rls_filters",
|
||||
return_value=[configured_filter],
|
||||
),
|
||||
patch(
|
||||
"superset.connectors.sqla.models.is_feature_enabled",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"superset.utils.rls.collect_rls_predicates_for_sql",
|
||||
side_effect=AssertionError(
|
||||
"get_sqla_row_level_filters must not call the cache-key helper"
|
||||
),
|
||||
),
|
||||
):
|
||||
filters = BaseDatasource.get_sqla_row_level_filters(datasource)
|
||||
|
||||
assert len(filters) == 1
|
||||
assert "tenant_id" in str(filters[0])
|
||||
Reference in New Issue
Block a user