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(explore): CSV Export Permission is incorrect on Explore page #13713

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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -17,14 +17,19 @@
* under the License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { shallow, mount } from 'enzyme';
import { mockStore } from 'spec/fixtures/mockStore';
import ExploreActionButtons from 'src/explore/components/ExploreActionButtons';
import * as exploreUtils from 'src/explore/exploreUtils';

import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import { Provider } from 'react-redux';
import sinon from 'sinon';

describe('ExploreActionButtons', () => {
const defaultProps = {
actions: {},
canDownload: 'True',
canDownloadCSV: 'True',
latestQueryFormData: {},
queryEndpoint: 'localhost',
chartHeight: '30px',
Expand All @@ -42,4 +47,39 @@ describe('ExploreActionButtons', () => {
);
expect(wrapper.dive().children()).toHaveLength(6);
});

describe('ExploreActionButtons and no permission to download CSV', () => {
let wrapper;
const defaultProps = {
actions: {},
canDownloadCSV: false,
latestQueryFormData: {},
queryEndpoint: 'localhost',
chartHeight: '30px',
};

beforeEach(() => {
wrapper = mount(
<ThemeProvider theme={supersetTheme}>
<ExploreActionButtons {...defaultProps} />
</ThemeProvider>,
{
wrappingComponent: Provider,
wrappingComponentProps: {
store: mockStore,
},
},
);
});

it('should render csv buttons but is disabled and not clickable', () => {
const spyExportChart = sinon.spy(exploreUtils, 'exportChart');

const csvButton = wrapper.find('div.disabled');
expect(wrapper).toHaveLength(1);
csvButton.simulate('click');
expect(spyExportChart.callCount).toBe(0);
spyExportChart.restore();
});
});
});
22 changes: 12 additions & 10 deletions superset-frontend/src/explore/components/ExploreActionButtons.tsx
Expand Up @@ -40,7 +40,7 @@ type ActionButtonProps = {

type ExploreActionButtonsProps = {
actions: { redirectSQLLab: Function; openPropertiesModal: Function };
canDownload: boolean;
canDownloadCSV: boolean;
chartStatus: string;
latestQueryFormData: {};
queriesResponse: {};
Expand Down Expand Up @@ -83,7 +83,7 @@ const ActionButton = (props: ActionButtonProps) => {
const ExploreActionButtons = (props: ExploreActionButtonsProps) => {
const {
actions,
canDownload,
canDownloadCSV,
chartStatus,
latestQueryFormData,
queriesResponse,
Expand Down Expand Up @@ -118,20 +118,22 @@ const ExploreActionButtons = (props: ExploreActionButtonsProps) => {
}
};

const doExportCSV = exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'csv',
});
const doExportCSV = canDownloadCSV
? exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'csv',
})
: null;

const doExportChart = exportChart.bind(this, {
const doExportJson = exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'json',
});

const exportToCSVClasses = cx('btn btn-default btn-sm', {
disabled: !canDownload,
disabled: !canDownloadCSV,
});

return (
Expand Down Expand Up @@ -175,7 +177,7 @@ const ExploreActionButtons = (props: ExploreActionButtonsProps) => {
icon={<i className="fa fa-file-code-o" />}
text=".JSON"
tooltip={t('Export to .JSON format')}
onClick={doExportChart}
onClick={doExportJson}
/>
<ActionButton
icon={<i className="fa fa-file-text-o" />}
Expand Down
Expand Up @@ -209,7 +209,7 @@ export class ExploreChartHeader extends React.PureComponent {
openPropertiesModal: this.openPropertiesModal,
}}
slice={this.props.slice}
canDownload={this.props.can_download}
canDownloadCSV={this.props.can_download}
chartStatus={chartStatus}
latestQueryFormData={latestQueryFormData}
queryResponse={queryResponse}
Expand Down
6 changes: 5 additions & 1 deletion superset/charts/api.py
Expand Up @@ -66,7 +66,7 @@
from superset.commands.importers.v1.utils import get_contents_from_bundle
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.exceptions import QueryObjectValidationError
from superset.extensions import event_logger
from superset.extensions import event_logger, security_manager
from superset.models.slice import Slice
from superset.tasks.thumbnails import cache_chart_thumbnail
from superset.utils.async_query_manager import AsyncQueryTokenException
Expand Down Expand Up @@ -491,6 +491,10 @@ def get_data_response(

result_format = result["query_context"].result_format
if result_format == ChartDataResultFormat.CSV:
# Verify user has permission to export CSV file
if not security_manager.can_access("can_csv", "Superset"):
return self.response_403()

# return the first result
data = result["queries"][0]["data"]
return CsvResponse(data, headers=generate_download_headers("csv"))
Expand Down
7 changes: 7 additions & 0 deletions superset/cli.py
Expand Up @@ -732,16 +732,23 @@ def load_test_users_run() -> None:
gamma_sqllab_role = sm.add_role("gamma_sqllab")
sm.add_permission_role(gamma_sqllab_role, examples_pv)

gamma_no_csv_role = sm.add_role("gamma_no_csv")
sm.add_permission_role(gamma_no_csv_role, examples_pv)

for role in ["Gamma", "sql_lab"]:
for perm in sm.find_role(role).permissions:
sm.add_permission_role(gamma_sqllab_role, perm)

if str(perm) != "can csv on Superset":
sm.add_permission_role(gamma_no_csv_role, perm)

users = (
("admin", "Admin"),
("gamma", "Gamma"),
("gamma2", "Gamma"),
("gamma_sqllab", "gamma_sqllab"),
("alpha", "Alpha"),
("gamma_no_csv", "gamma_no_csv"),
)
for username, role in users:
user = sm.find_user(username)
Expand Down
11 changes: 10 additions & 1 deletion superset/views/core.py
Expand Up @@ -578,6 +578,15 @@ def explore_json(
response_type = response_option
break

# Verify user has permission to export CSV file
if (
response_type == utils.ChartDataResultFormat.CSV
and not security_manager.can_access("can_csv", "Superset")
):
return json_error_response(
_("You don't have the rights to ") + _("download as csv"), status=403,
)

form_data = get_form_data()[0]

try:
Expand Down Expand Up @@ -739,7 +748,7 @@ def explore( # pylint: disable=too-many-locals,too-many-return-statements,too-m
# slc perms
slice_add_perm = security_manager.can_access("can_write", "Chart")
slice_overwrite_perm = is_owner(slc, g.user) if slc else False
slice_download_perm = security_manager.can_access("can_read", "Chart")
slice_download_perm = security_manager.can_access("can_csv", "Superset")

form_data["datasource"] = str(datasource_id) + "__" + cast(str, datasource_type)

Expand Down
4 changes: 3 additions & 1 deletion tests/base_api_tests.py
Expand Up @@ -211,13 +211,15 @@ def test_get_filter_related_owners(self):
rv = self.client.get(uri)
assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8"))
assert 3 == response["count"]
assert 4 == response["count"]
sorted_results = sorted(response["result"], key=lambda value: value["text"])
expected_results = [
{"text": "gamma user", "value": 2},
{"text": "gamma2 user", "value": 3},
{"text": "gamma_no_csv user", "value": 6},
{"text": "gamma_sqllab user", "value": 4},
]
# TODO Check me
assert expected_results == sorted_results

def test_get_ids_related_owners(self):
Expand Down
13 changes: 13 additions & 0 deletions tests/charts/api_tests.py
Expand Up @@ -1183,6 +1183,19 @@ def test_chart_data_csv_result_format(self):
rv = self.post_assert_metric(CHART_DATA_URI, request_payload, "data")
self.assertEqual(rv.status_code, 200)

# Test chart csv without permission
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_chart_data_csv_result_format_permission_denined(self):
"""
Chart data API: Test chart data with CSV result format
"""
self.login(username="gamma_no_csv")
request_payload = get_query_context("birth_names")
request_payload["result_format"] = "csv"

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

@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_chart_data_mixed_case_filter_op(self):
"""
Expand Down