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 aware RBAC "Save as" menu item #24806

Merged
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
Empty file added _update-notifier-last-checked
Empty file.
7 changes: 5 additions & 2 deletions superset-frontend/src/dashboard/actions/hydrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ import { getInitialState as getInitialNativeFilterState } from 'src/dashboard/re
import { applyDefaultFormData } from 'src/explore/store';
import { buildActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { findPermission } from 'src/utils/findPermission';
import { canUserEditDashboard } from 'src/dashboard/util/permissionUtils';
import {
canUserEditDashboard,
canUserSaveAsDashboard,
} from 'src/dashboard/util/permissionUtils';
import {
getCrossFiltersConfiguration,
isCrossFiltersEnabled,
Expand Down Expand Up @@ -336,7 +339,7 @@ export const hydrateDashboard =
metadata,
userId: user.userId ? String(user.userId) : null, // legacy, please use state.user instead
dash_edit_perm: canEdit,
dash_save_perm: findPermission('can_write', 'Dashboard', roles),
dash_save_perm: canUserSaveAsDashboard(dashboard, user),
dash_share_perm: findPermission(
'can_share_dashboard',
'Superset',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ test('should show the share actions', async () => {
expect(screen.getByText('Share')).toBeInTheDocument();
});

test('should render the "Save Modal" when user can save', async () => {
test('should render the "Save as" menu item when user can save', async () => {
const mockedProps = createProps();
const canSaveProps = {
...mockedProps,
Expand All @@ -206,7 +206,7 @@ test('should render the "Save Modal" when user can save', async () => {
expect(screen.getByText('Save as')).toBeInTheDocument();
});

test('should NOT render the "Save Modal" menu item when user cannot save', async () => {
test('should NOT render the "Save as" menu item when user cannot save', async () => {
const mockedProps = createProps();
setup(mockedProps);
expect(screen.queryByText('Save as')).not.toBeInTheDocument();
Expand Down
2 changes: 1 addition & 1 deletion superset-frontend/src/dashboard/components/SaveModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class SaveModal extends React.PureComponent<SaveModalProps, SaveModalState> {
super(props);
this.state = {
saveType: props.saveType,
newDashName: props.dashboardTitle + t('[copy]'),
newDashName: `${props.dashboardTitle} ${t('[copy]')}`,
duplicateSlices: false,
};

Expand Down
91 changes: 76 additions & 15 deletions superset-frontend/src/dashboard/util/permissionUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FeatureFlag } from '@superset-ui/core';
import * as featureFlags from 'src/featureFlags';
import {
UndefinedUser,
UserWithPermissionsAndRoles,
Expand All @@ -25,6 +27,7 @@ import Owner from 'src/types/Owner';
import {
canUserAccessSqlLab,
canUserEditDashboard,
canUserSaveAsDashboard,
isUserAdmin,
} from './permissionUtils';

Expand Down Expand Up @@ -73,22 +76,24 @@ const sqlLabUser: UserWithPermissionsAndRoles = {

const undefinedUser: UndefinedUser = {};

describe('canUserEditDashboard', () => {
const dashboard: Dashboard = {
id: 1,
dashboard_title: 'Test Dash',
url: 'https://dashboard.example.com/1',
thumbnail_url: 'https://dashboard.example.com/1/thumbnail.png',
published: true,
css: null,
changed_by_name: 'Test User',
changed_by: owner,
changed_on: '2021-05-12T16:56:22.116839',
charts: [],
owners: [owner],
roles: [],
};
const dashboard: Dashboard = {
id: 1,
dashboard_title: 'Test Dash',
url: 'https://dashboard.example.com/1',
thumbnail_url: 'https://dashboard.example.com/1/thumbnail.png',
published: true,
css: null,
changed_by_name: 'Test User',
changed_by: owner,
changed_on: '2021-05-12T16:56:22.116839',
charts: [],
owners: [owner],
roles: [],
};

let isFeatureEnabledMock: jest.MockInstance<boolean, [feature: FeatureFlag]>;

describe('canUserEditDashboard', () => {
it('allows owners to edit', () => {
expect(canUserEditDashboard(dashboard, ownerUser)).toEqual(true);
});
Expand Down Expand Up @@ -151,3 +156,59 @@ test('canUserAccessSqlLab returns false for non-sqllab role', () => {
test('canUserAccessSqlLab returns true for sqllab role', () => {
expect(canUserAccessSqlLab(sqlLabUser)).toEqual(true);
});

describe('canUserSaveAsDashboard with RBAC feature flag disabled', () => {
beforeAll(() => {
isFeatureEnabledMock = jest
.spyOn(featureFlags, 'isFeatureEnabled')
.mockImplementation(
(featureFlag: FeatureFlag) =>
featureFlag !== FeatureFlag.DASHBOARD_RBAC,
);
});

afterAll(() => {
// @ts-ignore
isFeatureEnabledMock.restore();
});

it('allows owners', () => {
expect(canUserSaveAsDashboard(dashboard, ownerUser)).toEqual(true);
});

it('allows admin users', () => {
expect(canUserSaveAsDashboard(dashboard, adminUser)).toEqual(true);
});

it('allows non-owners', () => {
expect(canUserSaveAsDashboard(dashboard, outsiderUser)).toEqual(true);
});
});

describe('canUserSaveAsDashboard with RBAC feature flag enabled', () => {
beforeAll(() => {
isFeatureEnabledMock = jest
.spyOn(featureFlags, 'isFeatureEnabled')
.mockImplementation(
(featureFlag: FeatureFlag) =>
featureFlag === FeatureFlag.DASHBOARD_RBAC,
);
});

afterAll(() => {
// @ts-ignore
isFeatureEnabledMock.restore();
});

it('allows owners', () => {
expect(canUserSaveAsDashboard(dashboard, ownerUser)).toEqual(true);
});

it('allows admin users', () => {
expect(canUserSaveAsDashboard(dashboard, adminUser)).toEqual(true);
});

it('reject non-owners', () => {
expect(canUserSaveAsDashboard(dashboard, outsiderUser)).toEqual(false);
});
});
12 changes: 12 additions & 0 deletions superset-frontend/src/dashboard/util/permissionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FeatureFlag } from '@superset-ui/core';
import { isFeatureEnabled } from 'src/featureFlags';
import {
isUserWithPermissionsAndRoles,
UndefinedUser,
Expand Down Expand Up @@ -63,3 +65,13 @@ export function canUserAccessSqlLab(
))
);
}

export const canUserSaveAsDashboard = (
dashboard: Dashboard,
user?: UserWithPermissionsAndRoles | UndefinedUser | null,
) =>
isUserWithPermissionsAndRoles(user) &&
findPermission('can_write', 'Dashboard', user?.roles) &&
(!isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC) ||
isUserAdmin(user) ||
isUserDashboardOwner(dashboard, user));
7 changes: 7 additions & 0 deletions superset/daos/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@
from flask_appbuilder.models.sqla.interface import SQLAInterface
from sqlalchemy.exc import SQLAlchemyError

from superset import is_feature_enabled, security_manager
from superset.daos.base import BaseDAO
from superset.daos.exceptions import DAOConfigError, DAOCreateFailedError
from superset.dashboards.commands.exceptions import (
DashboardAccessDeniedError,
DashboardForbiddenError,
DashboardNotFoundError,
)
from superset.dashboards.filter_sets.consts import (
Expand Down Expand Up @@ -321,6 +323,11 @@ def favorited_ids(dashboards: list[Dashboard]) -> list[FavStar]:
def copy_dashboard(
cls, original_dash: Dashboard, data: dict[str, Any]
) -> Dashboard:
if is_feature_enabled("DASHBOARD_RBAC") and not security_manager.is_owner(
original_dash
):
raise DashboardForbiddenError()

dash = Dashboard()
dash.owners = [g.user] if g.user else []
dash.dashboard_title = data["dashboard_title"]
Expand Down
6 changes: 5 additions & 1 deletion superset/dashboards/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1408,7 +1408,11 @@ def copy_dash(self, original_dash: Dashboard) -> Response:
except ValidationError as error:
return self.response_400(message=error.messages)

dash = DashboardDAO.copy_dashboard(original_dash, data)
try:
dash = DashboardDAO.copy_dashboard(original_dash, data)
except DashboardForbiddenError:
return self.response_403()

return self.response(
200,
result={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@
# specific language governing permissions and limitations
# under the License.
"""Unit tests for Superset"""
import json
from unittest import mock
from unittest.mock import patch

import pytest

from superset.utils.core import backend
from superset.daos.dashboard import DashboardDAO
from superset.dashboards.commands.exceptions import DashboardForbiddenError
from superset.utils.core import backend, override_user
from tests.integration_tests.conftest import with_feature_flags
from tests.integration_tests.dashboards.dashboard_test_utils import *
from tests.integration_tests.dashboards.security.base_case import (
BaseTestDashboardSecurity,
Expand All @@ -36,6 +41,10 @@
)
from tests.integration_tests.fixtures.public_role import public_role_like_gamma
from tests.integration_tests.fixtures.query_context import get_query_context
from tests.integration_tests.fixtures.world_bank_dashboard import (
load_world_bank_dashboard_with_slices,
load_world_bank_data,
)

CHART_DATA_URI = "api/v1/chart/data"

Expand Down Expand Up @@ -431,3 +440,82 @@ def test_cannot_get_draft_dashboard_with_roles_by_uuid(self):
# rollback changes
db.session.delete(dashboard)
db.session.commit()

@with_feature_flags(DASHBOARD_RBAC=True)
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
def test_copy_dashboard_via_api(self):
source = db.session.query(Dashboard).filter_by(slug="world_health").first()
source.roles = [self.get_role("Gamma")]

if not (published := source.published):
source.published = True # Required per the DashboardAccessFilter for RBAC.

db.session.commit()

uri = f"api/v1/dashboard/{source.id}/copy/"

data = {
"dashboard_title": "copied dash",
"css": "<css>",
"duplicate_slices": False,
"json_metadata": json.dumps(
{
"positions": source.position,
"color_namespace": "Color Namespace Test",
"color_scheme": "Color Scheme Test",
}
),
}

self.login(username="gamma")
rv = self.client.post(uri, json=data)
self.assertEqual(rv.status_code, 403)
self.logout()

self.login(username="admin")
rv = self.client.post(uri, json=data)
self.assertEqual(rv.status_code, 200)
self.logout()
response = json.loads(rv.data.decode("utf-8"))

target = (
db.session.query(Dashboard)
.filter(Dashboard.id == response["result"]["id"])
.one()
)

db.session.delete(target)
source.roles = []

if not published:
source.published = False

db.session.commit()

@with_feature_flags(DASHBOARD_RBAC=True)
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
def test_copy_dashboard_via_dao(self):
source = db.session.query(Dashboard).filter_by(slug="world_health").first()

data = {
"dashboard_title": "copied dash",
"css": "<css>",
"duplicate_slices": False,
"json_metadata": json.dumps(
{
"positions": source.position,
"color_namespace": "Color Namespace Test",
"color_scheme": "Color Scheme Test",
}
),
}

with override_user(security_manager.find_user("gamma")):
with pytest.raises(DashboardForbiddenError):
DashboardDAO.copy_dashboard(source, data)

with override_user(security_manager.find_user("admin")):
target = DashboardDAO.copy_dashboard(source, data)
db.session.delete(target)

db.session.commit()