gabotorresruiz commented on code in PR #45622:
URL: https://github.com/apache/superset/pull/45622#discussion_r4355197339


##########
superset/commands/chart/update.py:
##########
@@ -155,6 +155,31 @@ def _validate_new_dashboard_access(
                 if dash.is_managed_externally or not 
security_manager.is_editor(dash):
                     raise DashboardsForbiddenError()
 
+            # A chart's datasource can be served through a dashboard it belongs
+            # to (drill access, and promiscuous-viewer access), so linking the
+            # chart to a new dashboard must require access to that datasource.
+            # Use the datasource this request will leave on the chart: the
+            # incoming one when the same request repoints it, otherwise the
+            # stored one. (Admin and unresolvable-datasource exemptions live in
+            # ``raise_for_datasources_access``.)
+            datasource_type: str = self._properties.get("datasource_type", "")

Review Comment:
   This block worries me a bit. `get_datasource_by_id` raises 
`DatasourceNotFoundValidationError`, a `ValidationError`, and nothing here 
catches it. The datasource block up at line 283 already resolved the same 
datasource and appended that error to `exceptions`, so we arrive here and raise 
it a second time with no handler: `on_error` only translates `SQLAlchemyError` 
and `ChartRestApi.put` has no `except ValidationError`, so `@safe` answers `500 
Fatal error`.
   
   Concretely, a `PUT /api/v1/chart/<id>` carrying `datasource_type`, a 
`datasource_id` that does not exist, and a new entry in `dashboards` returns 
500 here and 422 `{"datasource_id": ["Datasource does not exist"]}` on master. 
Drop just the `dashboards` key from that same request and this branch gives the 
422 too, so it is the second lookup.
   
   Smallest fix I could find, since `validate()` raises `ChartInvalidError` 
anyway once `exceptions` is non-empty:
   
   ```suggestion
               if exceptions:
                   return
               datasource_type: str = self._properties.get("datasource_type", 
"")
   ```
   
   With that in place the 422 is back, every other cell of the matrix is 
unchanged (repoint to a dataset the caller cannot read still 403s, link-only 
still 200s), and all 74 new unit tests still pass. A case in 
`test_update_dashboard_access.py` that combines a missing `datasource_id` with 
a new `dashboards` entry would pin it.
   
   Separately, this branch re-resolves and re-checks what line 283 already 
resolved and already passed through `raise_for_access`, so reusing that object 
instead would remove the duplicate lookup as well as the 500. That one needs 
the repoint unit test retargeted at `validate()`, so the two-line guard above 
may be the better trade for now.



##########
superset/daos/dashboard.py:
##########
@@ -609,6 +619,58 @@ def validate_update_slug_uniqueness(dashboard_id: int, 
slug: str | None) -> bool
             return not db.session.query(dashboard_query.exists()).scalar()
         return True
 
+    @classmethod
+    def raise_for_added_member_datasource_access(
+        cls,
+        data: dict[str, Any],
+        existing_dashboard: Dashboard | None = None,
+        old_to_new_slice_ids: dict[int, int] | None = None,
+    ) -> None:
+        """Require the acting user's access to each chart newly added to a 
dashboard.
+
+        A chart's datasource can be served through a dashboard it belongs to
+        (see ``can_drill_dataset_via_dashboard_access`` / 
``has_drill_access``),
+        so adding one must not grant data the user could not otherwise read.
+        This runs from the command ``validate()`` layer (not the DAO write
+        path) so a refusal is translated to ``DashboardForbiddenError`` -> 403
+        before any side effects, and the rule lives in one place.
+
+        Datasources are resolved across types and deduplicated; the admin,
+        no-request-user and unresolvable-datasource exemptions live in
+        ``security_manager.raise_for_datasources_access``. The clone path (a
+        truthy ``old_to_new_slice_ids``) is exempt: it duplicates charts from a
+        source dashboard the caller already edits, so it grants nothing new;
+        the copy-with-layout mode passes no map and is checked like
+        create/update. Raises ``SupersetSecurityException`` when refused.
+        """
+        if old_to_new_slice_ids:
+            return
+        positions = data.get("positions")
+        if not positions:
+            return
+        slice_ids = _slice_ids_from_positions(positions)
+        if not slice_ids:
+            return
+        with skip_visibility_filter(db.session, Slice):
+            # Read the existing membership with the visibility filter lifted, 
the
+            # same as ``set_dash_metadata``'s rebuild, so a soft-deleted member
+            # still referenced by the layout is recognised as already present
+            # and not mistaken for a new addition.
+            prior_slice_ids = (
+                {slc.id for slc in existing_dashboard.slices}
+                if existing_dashboard
+                else set()
+            )

Review Comment:
   This is the one I would most want changed. The comment says the lifted 
filter makes a soft-deleted member count as already present, but for a 
dashboard that came out of a query it does not. The soft-delete listener 
attaches `with_loader_criteria(Slice, ...)` to the `Dashboard` SELECT, and that 
option defaults to `propagate_to_loaders=True`, so the criteria is already 
baked into `existing_dashboard.slices` before `skip_visibility_filter` ever 
opens. On the `PUT` path `self._model` comes from `DashboardDAO.find_by_id`, so 
that is the production shape; a dashboard built in Python carries no criteria, 
which is why it looks fine from a unit test.
   
   Measured on this branch with a dashboard holding one live and one archived 
member: inside the `with` block, `{s.id for s in dashboard.slices}` returns 
only the live id while `dashboard_slices` holds both. The archived member 
therefore lands in `added_slices`, so an editor who cannot read its dataset 
gets a 403 on every save of that dashboard for as long as the layout still 
carries its slot. Dropping that slot from the layout saves fine, which is how I 
isolated it.
   
   On master that save already fails (a 422, which is what #45543 is for), so 
nobody loses a working flow today. The part that concerns me is forward: I 
merged #45543 into this branch and the save is still a 403, and disabling only 
this check on the merged tree turns it into a 200. So this would sit on top of 
that fix and keep the same dashboard unsaveable. #45538 already carries the 
right primitive, `_member_chart_ids(dashboard_id)`, which reads 
`dashboard_slices` instead of `Dashboard.slices` for exactly this reason.
   
   Worth saying why the new tests do not catch it: 
`test_added_member_datasource_access_skips_soft_deleted_member` builds its 
dashboard in Python, and it still passes with the `skip_visibility_filter` call 
deleted outright. A version that loads the dashboard through 
`db.session.query(Dashboard)` before calling the helper would fail on this 
baseline.



##########
superset/commands/dashboard/update.py:
##########
@@ -171,6 +171,18 @@ def validate(self) -> None:
         except SupersetSecurityException as ex:
             raise DashboardForbiddenError() from ex
 
+        # A chart added to the dashboard must be one whose datasource the 
editor
+        # can access (its datasource can be served through the dashboard). 
Check
+        # here, before ``run`` mutates anything, so a refusal is a 403 and no
+        # side effects (e.g. report deactivation) have fired yet.
+        if json_metadata := self._properties.get("json_metadata"):

Review Comment:
   Question about the gate rather than the rule. This fires only when the 
request carries `json_metadata`, but `UpdateDashboardCommand` also accepts 
`slices`, and that key rebuilds membership straight through 
`DashboardDAO.update` without ever reaching `set_dash_metadata`. `slices` is 
not in `DashboardPutSchema`, so the REST API cannot send it, but 
`superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py:345` 
does exactly that (`{"position_json": ..., "slices": existing + [new_chart]}`, 
no `json_metadata`) and only checks `raise_for_editorship` on the dashboard. 
`generate_dashboard.py:392` assigns `dashboard.slices` directly from 
`request.chart_ids` with no command at all.
   
   I ran both shapes as the same `Gamma` editor with no grant on the chart's 
dataset: the `json_metadata` shape is refused, the `slices` shape links the 
chart. Is scoping to the request shape deliberate for now? Reading 
`self._properties.get("slices")` here too, or checking where membership is 
actually written, would close it. Happy to be told the MCP tools are out of 
scope for this one.



##########
superset/commands/dashboard/copy.py:
##########
@@ -62,3 +64,17 @@ def validate(self) -> None:
             raise DashboardInvalidError()
         if not security_manager.is_editor(self._original_dash):
             raise DashboardForbiddenError()
+
+        # A copy that rebuilds membership from a caller-supplied layout must 
not
+        # pull in a chart whose datasource the caller cannot access. The
+        # ``duplicate_slices`` (clone) mode is exempt: it duplicates charts 
from
+        # the source dashboard the caller already edits, so it grants nothing 
new.
+        try:
+            DashboardDAO.raise_for_added_member_datasource_access(
+                json.loads(self._properties["json_metadata"]),
+                old_to_new_slice_ids={0: 0}
+                if self._properties.get("duplicate_slices")
+                else None,
+            )

Review Comment:
   Not a blocker, two small things. Deleting this whole block leaves every new 
test passing: the DAO-level copy test calls the helper directly, so the 
endpoint itself is never exercised, which matches what codecov flags on this 
file. And `old_to_new_slice_ids={0: 0}` as a truthy flag is hard to read here; 
a named parameter on the helper would say what it means. The behaviour itself 
is right, for what it is worth: a `POST /api/v1/dashboard/<id>/copy/` whose 
layout references an unreadable chart returns 403 and copies nothing.



-- 
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