Skip to content
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(dashboard-rbac): use normal rbac when no roles chosen #23586

Merged
merged 11 commits into from
Apr 7, 2023
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,8 @@ Non-owner users access can be managed two different ways:

1. Dataset permissions - if you add to the relevant role permissions to datasets it automatically grants implicit access to all dashboards that uses those permitted datasets
2. Dashboard roles - if you enable **DASHBOARD_RBAC** [feature flag](https://superset.apache.org/docs/installation/configuring-superset#feature-flags) then you be able to manage which roles can access the dashboard
- Having dashboard access implicitly grants read access to the associated datasets, therefore
all charts will load their data even if feature flag is turned on and no roles assigned
to roles the access will fallback to **Dataset permissions**
- Granting a role access to a dashboard will bypass dataset level checks. Having dashboard access implicitly grants read access to all the featured charts in the dashboard, and thereby also all the associated datasets.
- If no roles are specified for a dashboard, regular **Dataset permissions** will apply.

<img src={useBaseUrl("/img/tutorial/tutorial_dashboard_access.png" )} />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@ const PropertiesModal = ({
</StyledFormItem>
<p className="help-block">
{t(
'Roles is a list which defines access to the dashboard. Granting a role access to a dashboard will bypass dataset level checks. If no roles are defined, then the dashboard is available to all roles.',
'Roles is a list which defines access to the dashboard. Granting a role access to a dashboard will bypass dataset level checks. If no roles are defined, regular access permissions apply.',
)}
</p>
</Col>
Expand Down
18 changes: 12 additions & 6 deletions superset/security/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2046,7 +2046,10 @@ def raise_for_dashboard_access(self, dashboard: "Dashboard") -> None:
from superset.dashboards.commands.exceptions import DashboardAccessDeniedError

def has_rbac_access() -> bool:
return (not is_feature_enabled("DASHBOARD_RBAC")) or any(
if not is_feature_enabled("DASHBOARD_RBAC") or not 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
Expand All @@ -2073,15 +2076,18 @@ def can_access_based_on_dashboard(datasource: "BaseDatasource") -> bool:
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice

dashboard_ids = db.session.query(Dashboard.id)
dashboard_ids = DashboardAccessFilter(
"id", SQLAInterface(Dashboard, db.session)
).apply(dashboard_ids, None)

datasource_class = type(datasource)
query = (
db.session.query(datasource_class)
db.session.query(Dashboard.id)
.join(Slice, Dashboard.slices)
.join(Slice.table)
.filter(datasource_class.id == datasource.id)
)

query = DashboardAccessFilter("id", SQLAInterface(Dashboard, db.session)).apply(
query, None
.filter(Dashboard.id.in_(dashboard_ids))
)

exists = db.session.query(query.exists()).scalar()
Expand Down
6 changes: 2 additions & 4 deletions superset/utils/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,7 @@ def on_security_exception(self: Any, ex: Exception) -> Response:


# noinspection PyPackageRequirements
def check_dashboard_access(
on_error: Callable[..., Any] = on_security_exception
) -> Callable[..., Any]:
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:
Expand All @@ -130,7 +128,7 @@ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
try:
current_app.appbuilder.sm.raise_for_dashboard_access(dashboard)
except DashboardAccessDeniedError as ex:
return on_error(self, ex)
return on_error(str(ex))
except Exception as exception:
raise exception

Expand Down
51 changes: 30 additions & 21 deletions superset/views/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
TableColumn,
)
from superset.constants import QUERY_EARLY_CANCEL_KEY
from superset.dashboards.commands.exceptions import DashboardAccessDeniedError
from superset.dashboards.commands.importers.v0 import ImportDashboardsCommand
from superset.dashboards.dao import DashboardDAO
from superset.dashboards.permalink.commands.get import GetDashboardPermalinkCommand
Expand Down Expand Up @@ -167,6 +168,7 @@
get_form_data,
get_viz,
loads_request_json,
redirect_with_flash,
sanitize_datasource_data,
)
from superset.viz import BaseViz
Expand All @@ -192,6 +194,7 @@
"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 = __(
Expand Down Expand Up @@ -1826,9 +1829,7 @@ def favstar( # pylint: disable=no-self-use
@expose("/dashboard/<dashboard_id_or_slug>/")
@event_logger.log_this_with_extra_payload
@check_dashboard_access(
on_error=lambda self, ex: Response(
utils.error_msg_from_exception(ex), status=403
)
on_error=lambda msg: redirect_with_flash(DASHBOARD_LIST_URL, msg, "danger")
)
def dashboard(
self,
Expand All @@ -1846,25 +1847,33 @@ def dashboard(
if not dashboard:
abort(404)

if config["ENABLE_ACCESS_REQUEST"]:
for datasource in dashboard.datasources:
datasource = DatasourceDAO.get_datasource(
datasource_type=DatasourceType(datasource.type),
datasource_id=datasource.id,
session=db.session(),
has_access_ = False
Copy link
Member

@john-bodley john-bodley May 16, 2023

Choose a reason for hiding this comment

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

@villebro can all this logic reside in the security manager? The issue is having security logic outside of the security manager adds complexity if you have custom logic in your security manager which grants everyone access to view the dashboard but block at the chart level if you don't have access to the underlying dataset.

Copy link
Member Author

Choose a reason for hiding this comment

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

I agree, it would probably make sense to have this in the security manager. However, in the scope of this PR I decided not to change the existing logic.

for datasource in dashboard.datasources:
datasource = DatasourceDAO.get_datasource(
Copy link
Member

Choose a reason for hiding this comment

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

Why are the datasets reloaded?

datasource_type=DatasourceType(datasource.type),
datasource_id=datasource.id,
session=db.session(),
)
if datasource and security_manager.can_access_datasource(
datasource=datasource,
):
has_access_ = True

if has_access_ is False and config["ENABLE_ACCESS_REQUEST"]:
flash(
__(security_manager.get_datasource_access_error_msg(datasource)),
"danger",
)
if datasource and not security_manager.can_access_datasource(
datasource=datasource
):
flash(
__(
security_manager.get_datasource_access_error_msg(datasource)
),
"danger",
)
return redirect(
f"/superset/request_access/?dashboard_id={dashboard.id}"
)
return redirect(
f"/superset/request_access/?dashboard_id={dashboard.id}"
)

if has_access_:
Copy link
Member

Choose a reason for hiding this comment

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

Why do we break after confirming that they have access to at least one as opposed to all datasets?

Copy link
Member Author

Choose a reason for hiding this comment

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

Answering both this and the previous question here:

  • The logic for reloading the datasets is to check if the user has access to at least one dataset on the dashboard. AFAIK, this is how regular dashboard RBAC (not the feature flag!) works - if you have access to at least one dataset on the dashboard, you are permitted to view the dashboard. Therefore, we break after confirming that the user is able to access at least one dataset. If we want to change this logic we need to also change the base filter that chooses which dashboards to display on the list view.
  • The datasets are reloaded in the block above to check individually if the user has access to them or not. This check was previously only done if ENABLE_ACCESS_REQUEST was enabled - if not, the user would get an obscure React error (when linking from the dashboard page) or a while page with some unformatted text indicating that the user doesn't have access to the dashboard (direct link). Now the user is redirected to the list view with a toast, like we do for other similar access denied errors.

break

if dashboard.datasources and not has_access_:
flash(DashboardAccessDeniedError.message, "danger")
return redirect(DASHBOARD_LIST_URL)

dash_edit_perm = security_manager.is_owner(
dashboard
Expand Down
2 changes: 1 addition & 1 deletion superset/views/dashboard/mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class DashboardMixin: # pylint: disable=too-few-public-methods
"roles": _(
"Roles is a list which defines access to the dashboard. "
"Granting a role access to a dashboard will bypass dataset level checks."
"If no roles are defined then the dashboard is available to all roles."
"If no roles are defined, regular access permissions apply."
),
"published": _(
"Determines whether or not this dashboard is "
Expand Down
8 changes: 7 additions & 1 deletion superset/views/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@
import msgpack
import pyarrow as pa
import simplejson as json
from flask import g, has_request_context, request
from flask import flash, g, has_request_context, redirect, request
from flask_appbuilder.security.sqla import models as ab_models
from flask_appbuilder.security.sqla.models import User
from flask_babel import _
from sqlalchemy.orm.exc import NoResultFound
from werkzeug.wrappers.response import Response

import superset.models.core as models
from superset import app, dataframe, db, result_set, viz
Expand Down Expand Up @@ -592,3 +593,8 @@ def get_cta_schema_name(
if not func:
return None
return func(database, user, schema, sql)


def redirect_with_flash(url: str, message: str, category: str) -> Response:
flash(message=message, category=category)
return redirect(url)
59 changes: 52 additions & 7 deletions tests/integration_tests/dashboards/security/security_rbac_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,11 @@ def test_get_dashboard_view__user_can_not_access_without_permission(self):

# act
response = self.get_dashboard_view_response(dashboard_to_access)
assert response.status_code == 302

request_payload = get_query_context("birth_names")
rv = self.post_assert_metric(CHART_DATA_URI, request_payload, "data")
self.assertEqual(rv.status_code, 403)

# assert
self.assert403(response)
assert rv.status_code == 403

def test_get_dashboard_view__user_with_dashboard_permission_can_not_access_draft(
self,
Expand All @@ -114,11 +112,57 @@ def test_get_dashboard_view__user_with_dashboard_permission_can_not_access_draft
response = self.get_dashboard_view_response(dashboard_to_access)

# assert
self.assert403(response)
assert response.status_code == 302

# post
revoke_access_to_dashboard(dashboard_to_access, new_role)

@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_get_dashboard_view__user_no_access_regular_rbac(self):
if backend() == "hive":
return

slice = (
db.session.query(Slice)
.filter_by(slice_name="Girl Name Cloud")
.one_or_none()
)
dashboard = create_dashboard_to_db(published=True, slices=[slice])
self.login("gamma")

# assert redirect on regular rbac access denied
response = self.get_dashboard_view_response(dashboard)
assert response.status_code == 302

request_payload = get_query_context("birth_names")
rv = self.post_assert_metric(CHART_DATA_URI, request_payload, "data")
assert rv.status_code == 403
db.session.delete(dashboard)
db.session.commit()

@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_get_dashboard_view__user_access_regular_rbac(self):
if backend() == "hive":
return

slice = (
db.session.query(Slice)
.filter_by(slice_name="Girl Name Cloud")
.one_or_none()
)
dashboard = create_dashboard_to_db(published=True, slices=[slice])
self.login("gamma_sqllab")

response = self.get_dashboard_view_response(dashboard)

assert response.status_code == 200

request_payload = get_query_context("birth_names")
rv = self.post_assert_metric(CHART_DATA_URI, request_payload, "data")
assert rv.status_code == 200
db.session.delete(dashboard)
db.session.commit()

@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_get_dashboard_view__user_access_with_dashboard_permission(self):
if backend() == "hive":
Expand Down Expand Up @@ -155,13 +199,14 @@ def test_get_dashboard_view__user_access_with_dashboard_permission(self):
@pytest.mark.usefixtures("public_role_like_gamma")
def test_get_dashboard_view__public_user_can_not_access_without_permission(self):
dashboard_to_access = create_dashboard_to_db(published=True)
grant_access_to_dashboard(dashboard_to_access, "Alpha")
self.logout()

# act
response = self.get_dashboard_view_response(dashboard_to_access)

# assert
self.assert403(response)
assert response.status_code == 302

@pytest.mark.usefixtures("public_role_like_gamma")
def test_get_dashboard_view__public_user_with_dashboard_permission_can_not_access_draft(
Expand All @@ -175,7 +220,7 @@ def test_get_dashboard_view__public_user_with_dashboard_permission_can_not_acces
response = self.get_dashboard_view_response(dashboard_to_access)

# assert
self.assert403(response)
assert response.status_code == 302

# post
revoke_access_to_dashboard(dashboard_to_access, "Public")
Expand Down