Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion superset/commands/chart/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
DashboardsNotFoundValidationError,
)
from superset.commands.chart.utils import (
raise_for_chart_datasource_access,
raise_for_saved_query_ownership,
touch_dashboards,
validate_chart_datasource_type,
validate_query_context_datasource,
Expand Down Expand Up @@ -82,7 +84,8 @@ def validate(self) -> None:
validate_chart_datasource_type(datasource_type)
datasource = get_datasource_by_id(datasource_id, datasource_type)
self._properties["datasource_name"] = datasource.name
security_manager.raise_for_access(datasource=datasource)
raise_for_saved_query_ownership(datasource)
raise_for_chart_datasource_access(datasource)
except SupersetSecurityException as ex:
raise ChartForbiddenError() from ex
except ValidationError as ex:
Expand Down
5 changes: 4 additions & 1 deletion superset/commands/chart/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
DatasourceTypeUpdateRequiredValidationError,
)
from superset.commands.chart.utils import (
raise_for_chart_datasource_access,
raise_for_saved_query_ownership,
touch_dashboards,
validate_chart_datasource_type,
validate_query_context_datasource,
Expand Down Expand Up @@ -259,7 +261,8 @@ def validate(self) -> None: # noqa: C901
effective_id, datasource_type
)
self._properties["datasource_name"] = datasource.name
security_manager.raise_for_access(datasource=datasource)
raise_for_saved_query_ownership(datasource)
raise_for_chart_datasource_access(datasource)
except SupersetSecurityException as ex:
raise ChartForbiddenError() from ex
except ValidationError as ex:
Expand Down
46 changes: 43 additions & 3 deletions superset/commands/chart/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
from flask import g
from marshmallow import ValidationError

from superset import security_manager
from superset.commands.chart.exceptions import (
ChartForbiddenError,
ChartQueryContextDatasourceMismatchValidationError,
)
from superset.commands.exceptions import DatasourceTypeInvalidError
Expand All @@ -36,18 +38,56 @@
def validate_chart_datasource_type(datasource_type: str) -> None:
"""Accept persistent chart sources with names and type-aware access policies.

SQL Lab queries are registered datasource types but are not persistent
chart sources; accepting all DatasourceDAO registrations would include them.
A raw SQL Lab ``query`` is a registered datasource type but not a
persistent chart source, so it's excluded here; accepting all
DatasourceDAO registrations would include it. A ``saved_query`` is
explicitly persisted and named by the user, so it's accepted alongside
``table``/``semantic_view``.
"""
if (
datasource_type == DatasourceType.SEMANTIC_VIEW
and not is_semantic_layers_enabled()
):
raise ValidationError(SemanticLayersDisabledError.message)
if datasource_type not in (DatasourceType.TABLE, DatasourceType.SEMANTIC_VIEW):
if datasource_type not in (
DatasourceType.TABLE,
DatasourceType.SEMANTIC_VIEW,
DatasourceType.SAVEDQUERY,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Charts created with this newly accepted type cannot round-trip through export/import: Slice.table is None, so chart export omits dataset_uuid, but ImportV1ChartSchema requires it. Could the saved-query export/import contract be implemented before allowing these charts to be persisted?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The importer only resolves a chart's datasource via dataset_uuid (anything that doesnt match an imported dataset gets skipped outright), so saved_query round-tripping needs its own importer path, not just a schema tweak. Same scope call as the ExploreMixin ask, bigger than #29697's create/update fix.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That importer limitation means a chart newly accepted here produces an export that cannot be reimported, so the create/update fix introduces a persisted compatibility failure. If saved-query import support is outside this change, could this type keep returning a controlled validation error until that contract is implemented?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get the concern, but I don't think a chart that can't execute yet should also be blocked from being created. Export/import support feels like its own follow-up, not a blocker here, same call as the ExploreMixin thread above. Fair thing to flag either way.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Charts of this type become creatable as soon as this merges, and each one exports without dataset_uuid, so the bundle fails ImportV1ChartSchema on reimport. Is there a tracked issue for the importer path, and could export at least skip or flag these charts so users are not handed a bundle that cannot be imported?

Comment thread
rusackas marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once creation works, a non-editor whose only grant is database_access on the saved query's database won't see these charts: ChartFilter (superset/charts/filters.py) and DashboardAccessFilter join Database only through SqlaTable, so the chart drops out of the list, GET returns 404, and a published dashboard containing only that chart is filtered out too, even though raise_for_access would allow the datasource. Should those filters also cover saved-query-backed charts, or should the type stay rejected until they do?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one's legit, but it's a visibility gap, not an access-escalation one: a database-only grant just means these charts won't surface yet, not that the wrong people can see them. Feels like a reasonable follow-up (mirroring table_backed_slice_join for SavedQuery) rather than a blocker, happy to be told otherwise.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it is a visibility gap rather than an escalation, but the same user would see create succeed and then get a 404 on the chart they just made, which this PR introduces. Could the follow-up issue be linked here, or ChartFilter and DashboardAccessFilter get the SavedQuery join now?

Comment thread
rusackas marked this conversation as resolved.
Resolved
Comment thread
rusackas marked this conversation as resolved.
Resolved
):
Comment thread
rusackas marked this conversation as resolved.
Resolved
raise DatasourceTypeInvalidError()


def raise_for_saved_query_ownership(datasource: Any) -> None:
"""
Refuse a saved query the caller could not read through the saved-query API.

``raise_for_access`` accepts a schema grant without looking at the creator,
so without this check a user could bind a chart to another user's private
saved query. Mirrors ``SavedQueryFilter``: only the creator, or a user with
``all_query_access``, may reference a saved query.
"""
if getattr(datasource, "type", None) != DatasourceType.SAVEDQUERY:
return
if security_manager.can_access_all_queries():
return
if getattr(datasource, "created_by", None) != g.user:
raise ChartForbiddenError()


def raise_for_chart_datasource_access(datasource: Any) -> None:
"""
Authorize binding a chart to ``datasource``.

A saved query is authored through an API that does not validate its
database, so editorship of one (being its creator) must not stand in for a
database, catalog, or schema grant: require the real grant.
"""
if getattr(datasource, "type", None) == DatasourceType.SAVEDQUERY:
security_manager.raise_for_access(datasource=datasource, allow_editorship=False)
return
security_manager.raise_for_access(datasource=datasource)


def validate_query_context_datasource(
raw_query_context: Any,
expected_datasource_id: Any,
Expand Down
9 changes: 8 additions & 1 deletion superset/commands/database/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from flask_appbuilder.models.sqla import Model

from superset import db
from superset import db, security_manager
from superset.commands.base import BaseCommand
from superset.commands.database.exceptions import (
DatabaseConnectionFailedError,
Expand Down Expand Up @@ -272,6 +272,13 @@ def _update_catalog_attribute(
update = {"catalog": new_catalog}
db.session.query(model).filter_by(**predicate).update(update)

# The bulk update above bypasses ``saved_query_before_update``, so
# re-copy the saved queries' perm strings onto their dependent charts.
if database := db.session.query(Database).get(database_id):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing exercises this resync: test_update_with_catalog_change mocks _update_catalog_attribute entirely, and the model tests drive the ORM listeners, so deleting this call leaves the suite green. Could a test persist a saved-query chart, run the real _update_catalog_attribute from catalog A to B, and assert after expiring the session that the chart's catalog_perm and schema_perm both carry B?

security_manager.sync_database_saved_query_chart_perms(
db.session.connection(), database
)

def validate(self) -> None:
if database_name := self._properties.get("database_name"):
if not DatabaseDAO.validate_update_uniqueness(
Expand Down
12 changes: 12 additions & 0 deletions superset/daos/datasource.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,18 @@ def get_datasource(
)
raise DatasourceNotFound()

if isinstance(datasource, SavedQuery) and datasource.db_id is None:
# A saved query with no associated database can't back a chart:
# its perm/schema_perm/data properties all need `self.database`.
# Reject it here with a controlled validation error instead of
# letting callers hit an AttributeError further down the line.
logger.warning(
"SavedQuery %s has no associated database; refusing to use "
"it as a chart datasource",
database_id_or_uuid,
)
raise DatasourceNotFound()

return datasource

@classmethod
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# 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.
"""allow saved_query chart datasource

Revision ID: dfa683969f48
Revises: f72b6e9c104a
Create Date: 2026-10-13 00:00:00.000000

"""

import logging

import sqlalchemy as sa
from alembic import op
from sqlalchemy.orm import declarative_base

from superset import db

# revision identifiers, used by Alembic.
revision = "dfa683969f48"
down_revision = "f72b6e9c104a"

Base = declarative_base()

logger = logging.getLogger("alembic.env")


class Slice(Base): # type: ignore
__tablename__ = "slices"

id = sa.Column(sa.Integer, primary_key=True)
datasource_type = sa.Column(sa.String(200))


def upgrade() -> None:
# Update chart datasource constraint to allow saved_query, matching
# CreateChartCommand/UpdateChartCommand.validate_chart_datasource_type
# (apache/superset#29697), which now accepts a saved_query as a valid,
# persistent chart datasource alongside table and semantic_view.
with op.batch_alter_table("slices") as batch_op:
batch_op.drop_constraint("ck_chart_datasource", type_="check")
batch_op.create_check_constraint(
"ck_chart_datasource",
"datasource_type in ('table', 'semantic_view', 'saved_query')",
)


def downgrade() -> None:
# Any chart persisted with datasource_type == saved_query would violate
# the stricter constraint being restored below, so reassign those rows
# first (mirrors the cleanup in 7e67aecbf3f1_chart_ds_constraint.py).
bind = op.get_bind()
session = db.Session(bind=bind)
for slc in session.query(Slice).filter(Slice.datasource_type == "saved_query"):
logger.warning(
"downgrading slice.id = %s from datasource_type saved_query to table",
slc.id,
)
slc.datasource_type = "table"
session.add(slc)
Comment thread
rusackas marked this conversation as resolved.
Resolved
session.commit()
session.close()

with op.batch_alter_table("slices") as batch_op:
batch_op.drop_constraint("ck_chart_datasource", type_="check")
batch_op.create_check_constraint(
Comment thread
rusackas marked this conversation as resolved.
Resolved
"ck_chart_datasource",
"datasource_type in ('table', 'semantic_view')",
)
10 changes: 8 additions & 2 deletions superset/models/slice.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,9 @@ def __repr__(self) -> str:
def datasource(self) -> SqlaTable | None:
return self.table

def _display_datasource(self) -> SqlaTable | SemanticView | None:
def _display_datasource(
self,
) -> SqlaTable | SemanticView | Datasource | None:
"""Return the datasource used to name and link this chart in listings.

Display-only counterpart of ``datasource``: it also resolves semantic
Expand All @@ -232,6 +234,8 @@ def _display_datasource(self) -> SqlaTable | SemanticView | None:
"""
if self.datasource_type == utils.DatasourceType.SEMANTIC_VIEW:
return self.semantic_view
if self.datasource_type == utils.DatasourceType.SAVEDQUERY:
return self.resolved_datasource
return self.table

@property
Expand Down Expand Up @@ -369,7 +373,9 @@ def datasource_name_text(self) -> str | None:

@property
def datasource_edit_url(self) -> str | None:
return getattr(self._display_datasource(), "url", None)
url = getattr(self._display_datasource(), "url", None)
# ``SavedQuery.url`` is a method, not a property; only surface strings.
return url if isinstance(url, str) else None

@property
def description_markeddown(self) -> str:
Expand Down
Loading
Loading