-
Notifications
You must be signed in to change notification settings - Fork 14.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: Address dashboard permission regression in #23586 #24350
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -348,30 +348,28 @@ def can_access_all_queries(self) -> bool: | |
|
||
def can_access_all_datasources(self) -> bool: | ||
""" | ||
Return True if the user can fully access all the Superset datasources, False | ||
otherwise. | ||
Return True if the user can access all the datasources, False otherwise. | ||
|
||
:returns: Whether the user can fully access all Superset datasources | ||
:returns: Whether the user can access all the datasources | ||
""" | ||
|
||
return self.can_access("all_datasource_access", "all_datasource_access") | ||
|
||
def can_access_all_databases(self) -> bool: | ||
""" | ||
Return True if the user can fully access all the Superset databases, False | ||
otherwise. | ||
Return True if the user can access all the databases, False otherwise. | ||
|
||
:returns: Whether the user can fully access all Superset databases | ||
:returns: Whether the user can access all the databases | ||
""" | ||
|
||
return self.can_access("all_database_access", "all_database_access") | ||
|
||
def can_access_database(self, database: "Database") -> bool: | ||
""" | ||
Return True if the user can fully access the Superset database, False otherwise. | ||
Return True if the user can access the specified database, False otherwise. | ||
|
||
:param database: The Superset database | ||
:returns: Whether the user can fully access the Superset database | ||
:param database: The database | ||
:returns: Whether the user can access the database | ||
""" | ||
|
||
return ( | ||
|
@@ -382,11 +380,11 @@ def can_access_database(self, database: "Database") -> bool: | |
|
||
def can_access_schema(self, datasource: "BaseDatasource") -> bool: | ||
""" | ||
Return True if the user can fully access the schema associated with the Superset | ||
Return True if the user can access the schema associated with specified | ||
datasource, False otherwise. | ||
|
||
:param datasource: The Superset datasource | ||
:returns: Whether the user can fully access the datasource's schema | ||
:param datasource: The datasource | ||
:returns: Whether the user can access the datasource's schema | ||
""" | ||
|
||
return ( | ||
|
@@ -397,11 +395,10 @@ def can_access_schema(self, datasource: "BaseDatasource") -> bool: | |
|
||
def can_access_datasource(self, datasource: "BaseDatasource") -> bool: | ||
""" | ||
Return True if the user can fully access of the Superset datasource, False | ||
otherwise. | ||
Return True if the user can access the specified datasource, False otherwise. | ||
|
||
:param datasource: The Superset datasource | ||
:returns: Whether the user can fully access the Superset datasource | ||
:param datasource: The datasource | ||
:returns: Whether the user can access the datasource | ||
""" | ||
|
||
try: | ||
|
@@ -411,6 +408,24 @@ def can_access_datasource(self, datasource: "BaseDatasource") -> bool: | |
|
||
return True | ||
|
||
def can_access_dashboard(self, dashboard: "Dashboard") -> bool: | ||
""" | ||
Return True if the user can access the specified dashboard, False otherwise. | ||
|
||
:param dashboard: The dashboard | ||
:returns: Whether the user can access the dashboard | ||
""" | ||
|
||
# pylint: disable=import-outside-toplevel | ||
from superset.dashboards.commands.exceptions import DashboardAccessDeniedError | ||
|
||
try: | ||
self.raise_for_dashboard_access(dashboard) | ||
except DashboardAccessDeniedError: | ||
return False | ||
|
||
return True | ||
|
||
@staticmethod | ||
def get_datasource_access_error_msg(datasource: "BaseDatasource") -> str: | ||
""" | ||
|
@@ -1995,38 +2010,42 @@ def raise_for_user_activity_access(user_id: int) -> None: | |
def raise_for_dashboard_access(self, dashboard: "Dashboard") -> None: | ||
""" | ||
Raise an exception if the user cannot access the dashboard. | ||
This does not check for the required role/permission pairs, | ||
it only concerns itself with entity relationships. | ||
|
||
This does not check for the required role/permission pairs, it only concerns | ||
itself with entity relationships. | ||
|
||
:param dashboard: Dashboard the user wants access to | ||
:raises DashboardAccessDeniedError: If the user cannot access the resource | ||
""" | ||
|
||
# pylint: disable=import-outside-toplevel | ||
from superset import is_feature_enabled | ||
from superset.dashboards.commands.exceptions import DashboardAccessDeniedError | ||
|
||
def has_rbac_access() -> bool: | ||
if not is_feature_enabled("DASHBOARD_RBAC") or not dashboard.roles: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was a tad hard to digest, but it essence is checking if RBAC is disabled. A more digestible form (used below) to check if RBAC is enabled is, if is_feature_enabled("DASHBOARD_RBAC") and dashboard.roles:
... |
||
return True | ||
|
||
return any( | ||
dashboard_role.id | ||
in [user_role.id for user_role in self.get_user_roles()] | ||
for dashboard_role in dashboard.roles | ||
) | ||
|
||
if self.is_guest_user() and dashboard.embedded: | ||
can_access = self.has_guest_access(dashboard) | ||
if self.has_guest_access(dashboard): | ||
return | ||
else: | ||
can_access = ( | ||
self.is_admin() | ||
or self.is_owner(dashboard) | ||
or (dashboard.published and has_rbac_access()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm struggling to grok how/why the published state alters things. Note the challenge is logic outlined here isn't the entire corpus of rules from an access standpoint given that the view also had additional (potentially conflicting) logic based on whether the user had access to the underlying datasets. Personally I feel the access rubric is already quite complex and rather difficult to grok as it pertains to embedded dashboards and the like. Additionally I'm not sure I agree with the logic with regards to RBAC when no roles are defined. Personally this feels like one shouldn't strictly have access as opposed to falling back to the legacy logic. Do dashboard owners et al. grok how this works? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The reason why it's like this is because otherwise it's impossible to support both regular RBAC and "dashboard RBAC" on the same running instance. I know it may be slightly confusing, but it's clearly documented, both in the modal and the docs, so changing this would be a pretty substantial breaking change. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @villebro is the desired end state to support only one or do you perceive both coexisting for some time? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @john-bodley I kind of have a SIP brewing for a more universal object level RBAC model. I'll circulate it with you once I have the main design ready, as I'm also curious to hear your thoughts on it. |
||
or (not dashboard.published and not dashboard.roles) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Aren't dashboards roles only a thing if RBAC is enabled? |
||
) | ||
if self.is_admin() or self.is_owner(dashboard): | ||
return | ||
|
||
# RBAC and legacy (datasource inferred) access controls. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The TL;DR is this check now includes logic for the legacy access controls which were based (for right or wrong) around the notion that a user has access to the dashboard if they can access at least one of the underlying datasets which backs the dashboard. Said logic previously resided in the dashboard view. |
||
if is_feature_enabled("DASHBOARD_RBAC") and dashboard.roles: | ||
if dashboard.published and {role.id for role in dashboard.roles} & { | ||
role.id for role in self.get_user_roles() | ||
}: | ||
return | ||
elif ( | ||
not dashboard.published | ||
or not dashboard.datasources | ||
or any( | ||
self.can_access_datasource(datasource) | ||
for datasource in dashboard.datasources | ||
) | ||
): | ||
return | ||
|
||
if not can_access: | ||
raise DashboardAccessDeniedError() | ||
raise DashboardAccessDeniedError() | ||
|
||
@staticmethod | ||
def can_access_based_on_dashboard(datasource: "BaseDatasource") -> bool: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,13 +19,10 @@ | |
import time | ||
from collections.abc import Iterator | ||
from contextlib import contextmanager | ||
from functools import wraps | ||
from typing import Any, Callable, TYPE_CHECKING | ||
|
||
from flask import current_app, Response | ||
|
||
from superset import is_feature_enabled | ||
from superset.dashboards.commands.exceptions import DashboardAccessDeniedError | ||
from superset.utils import core as utils | ||
from superset.utils.dates import now_as_float | ||
|
||
|
@@ -114,27 +111,3 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: | |
|
||
def on_security_exception(self: Any, ex: Exception) -> Response: | ||
return self.response(403, **{"message": utils.error_msg_from_exception(ex)}) | ||
|
||
|
||
# noinspection PyPackageRequirements | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This decorator seemed superfluous as said logic could be handled elsewhere. |
||
def check_dashboard_access(on_error: Callable[[str], Any]) -> Callable[..., Any]: | ||
def decorator(f: Callable[..., Any]) -> Callable[..., Any]: | ||
@wraps(f) | ||
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: | ||
# pylint: disable=import-outside-toplevel | ||
from superset.models.dashboard import Dashboard | ||
|
||
dashboard = Dashboard.get(str(kwargs["dashboard_id_or_slug"])) | ||
if is_feature_enabled("DASHBOARD_RBAC"): | ||
try: | ||
current_app.appbuilder.sm.raise_for_dashboard_access(dashboard) | ||
except DashboardAccessDeniedError as ex: | ||
return on_error(str(ex)) | ||
except Exception as exception: | ||
raise exception | ||
|
||
return f(self, *args, dashboard=dashboard, **kwargs) | ||
|
||
return wrapper | ||
|
||
return decorator |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -135,7 +135,6 @@ | |
ReservedUrlParameters, | ||
) | ||
from superset.utils.dates import now_as_float | ||
from superset.utils.decorators import check_dashboard_access | ||
from superset.views.base import ( | ||
api, | ||
BaseSupersetView, | ||
|
@@ -190,7 +189,6 @@ | |
"disable_data_preview", | ||
] | ||
|
||
DASHBOARD_LIST_URL = "/dashboard/list/" | ||
DATASOURCE_MISSING_ERR = __("The data source seems to have been deleted") | ||
USER_MISSING_ERR = __("The user seems to have been deleted") | ||
PARAMETER_MISSING_ERR = __( | ||
|
@@ -1641,76 +1639,57 @@ def favstar( # pylint: disable=no-self-use | |
@has_access | ||
@expose("/dashboard/<dashboard_id_or_slug>/") | ||
@event_logger.log_this_with_extra_payload | ||
@check_dashboard_access( | ||
on_error=lambda msg: redirect_with_flash(DASHBOARD_LIST_URL, msg, "danger") | ||
) | ||
def dashboard( | ||
self, | ||
dashboard_id_or_slug: str, # pylint: disable=unused-argument | ||
dashboard_id_or_slug: str, | ||
add_extra_log_payload: Callable[..., None] = lambda **kwargs: None, | ||
dashboard: Dashboard | None = None, | ||
) -> FlaskResponse: | ||
""" | ||
Server side rendering for a dashboard | ||
:param dashboard_id_or_slug: identifier for dashboard. used in the decorators | ||
Server side rendering for a dashboard. | ||
|
||
:param dashboard_id_or_slug: identifier for dashboard | ||
:param add_extra_log_payload: added by `log_this_with_manual_updates`, set a | ||
default value to appease pylint | ||
:param dashboard: added by `check_dashboard_access` | ||
""" | ||
|
||
dashboard = Dashboard.get(dashboard_id_or_slug) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is concerning that this is different than the DashboardDAO.get_by_id_or_slug and that the DAO logic differs (from a filter perspective) whether you pass in a UUID versus a slug/ID. Personally the UUID logic is just adding more complexity to an already partially intractable problem in terms of understanding (and thus enforcing) the security model. Adhering to the KISS principle in terms of the security model will actually make the service more secure as it'll be easier to grok and enforce. |
||
|
||
if not dashboard: | ||
abort(404) | ||
|
||
assert dashboard is not None | ||
|
||
has_access_ = False | ||
for datasource in dashboard.datasources: | ||
datasource = DatasourceDAO.get_datasource( | ||
datasource_type=DatasourceType(datasource.type), | ||
datasource_id=datasource.id, | ||
session=db.session(), | ||
try: | ||
security_manager.raise_for_dashboard_access(dashboard) | ||
except DashboardAccessDeniedError as ex: | ||
return redirect_with_flash( | ||
url="/dashboard/list/", | ||
message=utils.error_msg_from_exception(ex), | ||
category="danger", | ||
) | ||
if datasource and security_manager.can_access_datasource( | ||
datasource=datasource, | ||
): | ||
has_access_ = True | ||
|
||
if has_access_: | ||
break | ||
|
||
if dashboard.datasources and not has_access_: | ||
flash(DashboardAccessDeniedError.message, "danger") | ||
return redirect(DASHBOARD_LIST_URL) | ||
|
||
dash_edit_perm = security_manager.is_owner( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is inlined later for improved readability. |
||
dashboard | ||
) and security_manager.can_access("can_save_dash", "Superset") | ||
edit_mode = ( | ||
request.args.get(utils.ReservedUrlParameters.EDIT_MODE.value) == "true" | ||
) | ||
|
||
standalone_mode = ReservedUrlParameters.is_standalone_mode() | ||
|
||
add_extra_log_payload( | ||
dashboard_id=dashboard.id, | ||
dashboard_version="v2", | ||
dash_edit_perm=dash_edit_perm, | ||
edit_mode=edit_mode, | ||
dash_edit_perm=( | ||
security_manager.is_owner(dashboard) | ||
and security_manager.can_access("can_save_dash", "Superset") | ||
), | ||
edit_mode=( | ||
request.args.get(ReservedUrlParameters.EDIT_MODE.value) == "true" | ||
), | ||
) | ||
|
||
bootstrap_data = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is inlined later. |
||
"user": bootstrap_user_data(g.user, include_perms=True), | ||
"common": common_bootstrap_payload(g.user), | ||
} | ||
|
||
return self.render_template( | ||
"superset/spa.html", | ||
entry="spa", | ||
# dashboard title is always visible | ||
title=dashboard.dashboard_title, | ||
title=dashboard.dashboard_title, # dashboard title is always visible | ||
bootstrap_data=json.dumps( | ||
bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser | ||
{ | ||
"user": bootstrap_user_data(g.user, include_perms=True), | ||
"common": common_bootstrap_payload(g.user), | ||
}, | ||
default=utils.pessimistic_json_iso_dttm_ser, | ||
), | ||
standalone_mode=standalone_mode, | ||
standalone_mode=ReservedUrlParameters.is_standalone_mode(), | ||
) | ||
|
||
@has_access | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic has been inlined below.