sadpandajoe commented on code in PR #44992:
URL: https://github.com/apache/superset/pull/44992#discussion_r4202272488


##########
superset/commands/report/alert.py:
##########
@@ -214,37 +216,41 @@ def _execute_query(self) -> pd.DataFrame:
         :raises AlertQueryError: SQL query is not valid
         :raises AlertQueryTimeout: The SQL query received a celery soft timeout
         """
-        sql_template = jinja_context.get_template_processor(
-            database=self._report_schedule.database
-        )
-
         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
-            )
-
-            if app.config["MUTATE_ALERT_QUERY"]:
-                limited_rendered_sql = (
-                    self._report_schedule.database.mutate_sql_based_on_config(
-                        limited_rendered_sql
-                    )
+            user = get_dynamic_executor(self._report_schedule, 
alert_query=True)
+            if user is not None:
+                username = user.username
+            else:
+                executor, username = get_executor(  # pylint: 
disable=unused-variable
+                    executors=app.config["ALERT_REPORTS_EXECUTORS"],
+                    model=self._report_schedule,
                 )
-
-            executor, username = get_executor(  # pylint: 
disable=unused-variable
-                executors=app.config["ALERT_REPORTS_EXECUTORS"],
-                model=self._report_schedule,
-            )
-            user = security_manager.find_user(username)
+                user = security_manager.find_user(username)
             # A deleted/disabled executor user makes find_user return None. 
Raise
             # the dedicated error so the handler below re-surfaces it instead 
of
             # masking it as an opaque AlertQueryError (or letting the missing 
user
             # surface as a NoneType error from the downstream auth flow).
-            if user is None:
+            if user is None or not user.is_active:
                 raise ReportScheduleExecutorNotFoundError(username)
 
             with override_user(user):

Review Comment:
   `override_user` doesn't restore `g.user` when the body raises, so a failing 
alert query leaves the separate query executor as the current user for the rest 
of the run. With `ALERT_REPORTS_EXECUTORS=[MODIFIER]`, content running as Alice 
and the query as Bob, a query error is persisted with `changed_by_fk` set to 
Bob, and the next execution resolves its content executor to Bob. Should the 
override be wrapped in `try/finally` (or the error-state write done after 
restoring the user) so a query-only identity can't become the content executor?



##########
superset/commands/report/update.py:
##########
@@ -65,6 +114,153 @@ def run(self) -> Model:
         self.validate()
         return ReportScheduleDAO.update(self._model, self._properties)
 
+    def _changed_content_fields(self) -> set[str]:
+        """
+        Return the content fields whose payload value differs from the stored
+        one. Must run before other validators replace ids with model objects.
+        """
+        assert self._model is not None
+        model = self._model
+        current: dict[str, Any] = {
+            "chart": model.chart_id,
+            "dashboard": model.dashboard_id,
+            "extra": _normalize_json(model.extra_json),
+            "recipients": _normalize_recipients(model.recipients),
+            "report_format": model.report_format,
+        }
+        normalizers: dict[str, Callable[[Any], Any]] = {
+            "extra": _normalize_json,
+            "recipients": _normalize_recipients,
+        }
+        changed: set[str] = set()
+        for field in CONTENT_FIELDS & set(self._properties):
+            value = self._properties[field]
+            normalizer = normalizers.get(field)
+            if normalizer:
+                value = normalizer(value)
+            if value != current[field]:
+                changed.add(field)
+        return changed
+
+    def _validate_executors(
+        self,
+        report_type: str,
+        changed_content_fields: set[str],
+        exceptions: list[ValidationError],
+    ) -> None:
+        """
+        Resolve the "Run As" fields (SIP-209) on update.
+
+        Fields absent from the payload keep their stored value. Admins may set
+        any active user or explicit executor type. Clearing content restores 
the
+        legacy application-configured executor;
+        clearing the query executor inherits the content identity.
+        Non-admins may only point the fields at themselves and, when the 
schedule
+        executes as another user,
+        may not change its content or recipients: doing so would let an editor
+        read data with someone else's credentials.
+        """
+        assert self._model is not None
+        has_run_as = bool({"run_as", "run_as_type"} & self._properties.keys())
+        has_query_as = bool(
+            {"run_alert_query_as", "run_alert_query_as_type"} & 
self._properties.keys()
+        )
+        run_as_id = self._properties.pop("run_as", None)
+        query_as_id = self._properties.pop("run_alert_query_as", None)
+        if not is_feature_enabled("ALERT_REPORT_DYNAMIC_EXECUTOR"):

Review Comment:
   With the flag off this returns early and `changed_content_fields` is also 
skipped, but the stored `run_as`/`run_as_type` are left in place. A non-admin 
editor can then change the dashboard, recipients or format of a schedule that 
was set to run as an admin while the flag was on. When the flag is turned back 
on, `get_dynamic_executor` resolves to that stored admin again and the editor's 
content is rendered with the admin's RLS and OAuth credentials. Should the 
other-identity content check also apply to a stored executor while the flag is 
off, or should those stored values be cleared when a non-admin edits content?



##########
superset/commands/report/base.py:
##########
@@ -345,45 +393,118 @@ def validate_report_frequency(
         report_type: str,
     ) -> None:
         """
-        Validates if the report scheduled frequency doesn't exceed a limit
-        configured in `config.py`.
+        Validates if the report scheduled frequency doesn't exceed the minimum
+        interval in effect.
 
         :param cron_schedule: The cron schedule configured.
         :param report_type: The report type (Alert/Report).
         """
         config_key = (
-            "ALERT_MINIMUM_INTERVAL"
+            ReportConfigKey.ALERT_MINIMUM_INTERVAL
             if report_type == ReportScheduleType.ALERT
-            else "REPORT_MINIMUM_INTERVAL"
+            else ReportConfigKey.REPORT_MINIMUM_INTERVAL
         )
-        minimum_interval = app.config.get(config_key, 0)
-        if callable(minimum_interval):
-            minimum_interval = minimum_interval()
+        minimum_interval = ReportConfigDAO.get_effective_value(config_key)
 
+        if minimum_interval is None:
+            return
         if not isinstance(minimum_interval, int):
             logger.error(
                 "Invalid value for %s: %s", config_key, minimum_interval, 
exc_info=True
             )
             return
 
-        # Since configuration is in minutes, we only need to validate
-        # in case `minimum_interval` is <= 120 (2min)
-        if minimum_interval < 120:
-            return
-
-        iterations = 60 if minimum_interval <= 3660 else 24
         try:
-            schedule = croniter(cron_schedule)
-            current_exec = next(schedule)
-
-            for _i in range(iterations):
-                next_exec = next(schedule)
-                diff, current_exec = next_exec - current_exec, next_exec
-                if int(diff) < minimum_interval:
-                    raise ReportScheduleFrequencyNotAllowed(
-                        report_type=report_type, 
minimum_interval=minimum_interval
-                    )
+            if not cron_meets_minimum_interval(cron_schedule, 
minimum_interval):
+                raise ReportScheduleFrequencyNotAllowed(
+                    report_type=report_type, minimum_interval=minimum_interval
+                )
         except CroniterBadDateError as ex:
             raise ReportScheduleCrontabNotValidError(
                 cron_schedule=cron_schedule
             ) from ex
+
+    def validate_report_format(
+        self,
+        report_type: str,
+        report_format: str | None,
+        exceptions: list[ValidationError],
+    ) -> None:
+        """
+        Reports always deliver content: the "no attachment" format is only
+        allowed on alerts.
+        """
+        if (
+            report_type == ReportScheduleType.REPORT
+            and report_format == ReportDataFormat.NONE
+        ):
+            exceptions.append(ReportScheduleFormatRequiredError())
+
+    def validate_recipients_policy(self, exceptions: list[ValidationError]) -> 
None:
+        """
+        Validate the e-mail recipients in the payload against the global
+        recipient policy (allowed domains and/or existing users only).
+        """
+        recipients = self._properties.get("recipients")
+        if not recipients:
+            return
+        disallowed = ReportConfigDAO.find_disallowed_addresses(
+            get_email_addresses(recipients)
+        )
+        if disallowed:
+            
exceptions.append(ReportScheduleRecipientNotAllowedError(disallowed))
+
+    def validate_run_as(
+        self,
+        field_name: str,
+        user_id: int | None,
+        exceptions: list[ValidationError],
+        *,
+        default_to_current_user: bool,
+    ) -> User | None:
+        """Validate a specific user or an admin-selected application default.
+
+        A blank query executor inherits the content executor. A blank content
+        executor selected by an admin uses the legacy application 
configuration.
+        """
+        is_admin = security_manager.is_admin()
+        current_user = get_user()
+        type_field = f"{field_name}_type"
+        executor_type = self._properties.pop(type_field, None)
+        if executor_type is not None and executor_type != 
ExecutorType.FIXED_USER:
+            if not is_admin:
+                
exceptions.append(ReportScheduleRunAsForbiddenError(field_name))
+                return None
+            exceptions.append(
+                ValidationError(
+                    _("Choose Application default or a specific user"),
+                    field_name=field_name,
+                )
+            )
+            return None
+
+        if user_id is None and executor_type != ExecutorType.FIXED_USER:
+            if field_name == "run_alert_query_as":

Review Comment:
   Clearing the query executor is allowed for a non-admin without any identity 
check. On an alert whose `run_as` is another user and whose 
`run_alert_query_as` is the editor, `PUT {"run_alert_query_as": null}` clears 
both query fields, and the query then inherits the other user's identity. The 
guard in `_validate_executors` only fires when a content field also changes, 
and the alert's SQL is not one of them, so the unchanged query now runs with 
that user's database access and the editor can read the result from the alert's 
last value or logs. Should clearing `run_alert_query_as` be treated like a 
content change (or rejected for a non-admin) whenever the inherited `run_as` 
isn't the requester?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to