Compare commits

..
Author SHA1 Message Date
Elizabeth ThompsonandClaude Opus 4.8 92c485464b fix(chart/data): handle QueryObjectValidationError in _get_data_response
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 00:26:23 +00:00
43 changed files with 176 additions and 2009 deletions
@@ -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,
+2
View File
@@ -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()
+7 -12
View File
@@ -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
+27 -110
View File
@@ -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 = [
-31
View File
@@ -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
+1 -57
View File
@@ -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,
-2
View File
@@ -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:
-32
View File
@@ -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
-13
View File
@@ -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(
+4 -18
View File
@@ -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)
+1 -55
View File
@@ -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(
+4 -3
View File
@@ -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
View File
@@ -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 = {
+4 -96
View File
@@ -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:
-33
View File
@@ -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
View File
@@ -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:
-8
View File
@@ -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
View File
@@ -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 []
-16
View File
@@ -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")
+2 -5
View File
@@ -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
+2 -14
View File
@@ -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")
+1 -121
View File
@@ -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)
+4 -177
View File
@@ -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
)
-46
View File
@@ -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()
-225
View File
@@ -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])