You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/21 16:10:17 UTC
(superset) 04/05: fix(reports): scope execution-log API reads to editable schedules
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch fix/tag-report-log-dataset-schema-checks
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 816b98f876455b9eb083a5a888ac21b9eac9e26d
Author: Superset Dev <de...@superset.apache.org>
AuthorDate: Fri Aug 21 00:48:26 2026 -0700
fix(reports): scope execution-log API reads to editable schedules
ReportExecutionLogRestApi declared no base_filters, so the only
scoping on its list/item routes was the caller-chosen schedule pk
folded into the rison filters -- any role with generic ReportSchedule
read could iterate every schedule's logs, including alert result
values and database error messages for schedules it doesn't own. Add
ReportExecutionLogFilter, scoping directly on
ReportExecutionLog.report_schedule_id (mirroring
ReportScheduleFilter on the sibling schedule API), and apply it as a
base filter on both routes.
---
superset/reports/filters.py | 22 +++++++++++++++-
superset/reports/logs/api.py | 8 ++++++
tests/unit_tests/reports/filters_test.py | 42 +++++++++++++++++++++++++++++++
tests/unit_tests/reports/logs_api_test.py | 37 +++++++++++++++++++++++++++
4 files changed, 108 insertions(+), 1 deletion(-)
diff --git a/superset/reports/filters.py b/superset/reports/filters.py
index ad08a4a234a..21209312df6 100644
--- a/superset/reports/filters.py
+++ b/superset/reports/filters.py
@@ -22,7 +22,7 @@ from sqlalchemy.orm.query import Query
from superset import db, security_manager
from superset.daos.base import _escape_like
-from superset.reports.models import ReportSchedule
+from superset.reports.models import ReportExecutionLog, ReportSchedule
from superset.subjects.filters import subject_relation_exists_for_current_user
from superset.views.base import BaseFilter
@@ -43,6 +43,26 @@ class ReportScheduleFilter(BaseFilter): # pylint: disable=too-few-public-method
return query.filter(ReportSchedule.id.in_(editor_ids_query))
+class ReportExecutionLogFilter(BaseFilter): # pylint: disable=too-few-public-methods
+ """
+ Scope execution logs to report schedules the user can edit, mirroring
+ ``ReportScheduleFilter`` on the schedule API. Logs carry evaluated alert
+ values and database error messages, so they must not be readable across
+ ownership boundaries via an attacker-chosen schedule id.
+ """
+
+ def apply(self, query: Query, value: Any) -> Query:
+ if security_manager.can_access_all_datasources():
+ return query
+
+ from superset.subjects.models import report_schedule_editors
+
+ editor_ids_query = db.session.query(
+ report_schedule_editors.c.report_schedule_id
+ ).filter(subject_relation_exists_for_current_user(report_schedule_editors))
+ return query.filter(ReportExecutionLog.report_schedule_id.in_(editor_ids_query))
+
+
class ReportScheduleAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods
name = _("All Text")
arg_name = "report_all_text"
diff --git a/superset/reports/logs/api.py b/superset/reports/logs/api.py
index 9a2cea964e3..96673d1ef3d 100644
--- a/superset/reports/logs/api.py
+++ b/superset/reports/logs/api.py
@@ -31,6 +31,7 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface
from superset import is_feature_enabled
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
+from superset.reports.filters import ReportExecutionLogFilter
from superset.reports.logs.schemas import openapi_spec_methods_override
from superset.reports.models import ReportExecutionLog
from superset.views.base_api import BaseSupersetModelRestApi
@@ -53,6 +54,13 @@ class ReportExecutionLogRestApi(BaseSupersetModelRestApi):
class_permission_name = "ReportSchedule"
resource_name = "report"
allow_browser_login = True
+ # Ownership scoping. Without a base filter the only scoping on these
+ # routes is the caller-controlled ``pk`` path parameter (folded into the
+ # rison filters below), so any role with ReportSchedule read could
+ # iterate every schedule's logs deployment-wide. Mirrors the
+ # ReportScheduleFilter applied by the schedule API
+ # (superset/reports/api.py).
+ base_filters = [["id", ReportExecutionLogFilter, lambda: []]]
show_columns = [
"id",
diff --git a/tests/unit_tests/reports/filters_test.py b/tests/unit_tests/reports/filters_test.py
index bdcb2c181fb..defc5fd11ce 100644
--- a/tests/unit_tests/reports/filters_test.py
+++ b/tests/unit_tests/reports/filters_test.py
@@ -106,3 +106,45 @@ def test_report_schedule_all_text_filter_coerces_non_string(
mock_report_schedule.sql,
):
column.ilike.assert_called_once_with(expected, escape="\\")
+
+
+@patch("superset.reports.filters.security_manager", new_callable=MagicMock)
+def test_report_execution_log_filter_admin_sees_all(mock_sm: MagicMock) -> None:
+ """
+ Regression test: ``ReportExecutionLogRestApi`` had no base filter at all,
+ so any role with generic ReportSchedule read could iterate every
+ schedule's logs by pk. An admin (can_access_all_datasources) must still
+ see everything unfiltered.
+ """
+ from superset.reports.filters import ReportExecutionLogFilter
+
+ mock_sm.can_access_all_datasources.return_value = True
+ query = MagicMock()
+ f = ReportExecutionLogFilter("id", MagicMock())
+ result = f.apply(query, None)
+ assert result is query
+ query.filter.assert_not_called()
+
+
+@patch("superset.reports.filters.security_manager", new_callable=MagicMock)
+@patch("superset.reports.filters.db")
+def test_report_execution_log_filter_non_admin_scoped_to_log_fk(
+ mock_db: MagicMock, mock_sm: MagicMock
+) -> None:
+ """
+ A non-admin must be scoped by ``ReportExecutionLog.report_schedule_id``
+ directly (not by an unjoined filter on ``ReportSchedule.id``, which would
+ pass for any log row as long as the caller edits at least one schedule).
+ """
+ from superset.reports.filters import ReportExecutionLogFilter
+ from superset.reports.models import ReportExecutionLog
+
+ mock_sm.can_access_all_datasources.return_value = False
+ query = MagicMock()
+ f = ReportExecutionLogFilter("id", MagicMock())
+ f.apply(query, None)
+
+ query.filter.assert_called_once()
+ (filter_expr,) = query.filter.call_args[0]
+ assert filter_expr.left.table is ReportExecutionLog.__table__
+ assert filter_expr.left.name == "report_schedule_id"
diff --git a/tests/unit_tests/reports/logs_api_test.py b/tests/unit_tests/reports/logs_api_test.py
new file mode 100644
index 00000000000..e081f54e58f
--- /dev/null
+++ b/tests/unit_tests/reports/logs_api_test.py
@@ -0,0 +1,37 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from superset.reports.filters import ReportExecutionLogFilter
+from superset.reports.logs.api import ReportExecutionLogRestApi
+
+
+def test_execution_log_api_has_ownership_base_filter() -> None:
+ """
+ Regression test: the schedule id in the ``/log/`` and ``/log/<id>``
+ routes is caller-controlled (folded into the rison filters), so an
+ editor-scoped base filter must be applied to both the list and item
+ routes -- otherwise any role with generic ReportSchedule read can iterate
+ every schedule's logs.
+ """
+ assert ReportExecutionLogRestApi.base_filters, (
+ "ReportExecutionLogRestApi must apply an ownership base filter; "
+ "without one, logs are readable across schedules regardless of "
+ "ownership"
+ )
+ assert any(
+ filter_class is ReportExecutionLogFilter
+ for _, filter_class, _ in ReportExecutionLogRestApi.base_filters
+ )