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

Add feature flags to control query sharing, KV exposure #9120

Merged
merged 11 commits into from
Feb 19, 2020
6 changes: 6 additions & 0 deletions UPDATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ This file documents any backwards-incompatible changes in Superset and
assists people when migrating to a new version.

## Next
* [9120](https://github.com/apache/incubator-superset/pull/9120): Changes the default behavior of ad-hoc sharing of
queries in SQLLab to one that links to the saved query rather than one that copies the query data into the KVStore
model and links to the record there. This is a security-related change that makes SQLLab query
sharing respect the existing role-based access controls. Should you wish to retain the existing behavior, set two feature flags:
`"KV_STORE": True` will re-enable the `/kv/` and `/kv/store/` endpoints, and `"SHARE_QUERIES_VIA_KV_STORE": True`
will tell the front-end to utilize them for query sharing.
* [9109](https://github.com/apache/incubator-superset/pull/9109): Expire `filter_immune_slices` and
`filter_immune_filter_fields` to favor dashboard scoped filter metadata `filter_scopes`.

Expand Down
147 changes: 110 additions & 37 deletions superset-frontend/spec/javascripts/sqllab/ShareSqlLabQuery_spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { OverlayTrigger } from 'react-bootstrap';
import fetchMock from 'fetch-mock';
import * as featureFlags from 'src/featureFlags';
import { shallow } from 'enzyme';

import * as utils from '../../../src/utils/common';
Expand All @@ -29,8 +30,9 @@ import ShareSqlLabQuery from '../../../src/SqlLab/components/ShareSqlLabQuery';

const mockStore = configureStore([thunk]);
const store = mockStore();
let isFeatureEnabledMock;

describe('ShareSqlLabQuery', () => {
describe('ShareSqlLabQuery via /kv/store', () => {
const storeQueryUrl = 'glob:*/kv/store/';
const storeQueryMockId = '123';

Expand All @@ -50,9 +52,18 @@ describe('ShareSqlLabQuery', () => {
schema: 'query_schema',
autorun: false,
sql: 'SELECT * FROM ...',
remoteId: 999,
},
};

const storedQueryAttributes = {
dbId: 0,
title: 'query title',
schema: 'query_schema',
autorun: false,
sql: 'SELECT * FROM ...',
};

function setup(overrideProps) {
const wrapper = shallow(
<ShareSqlLabQuery {...defaultProps} {...overrideProps} />,
Expand All @@ -64,51 +75,113 @@ describe('ShareSqlLabQuery', () => {
return wrapper;
}

it('renders an OverlayTrigger with Button', () => {
const wrapper = setup();
const trigger = wrapper.find(OverlayTrigger);
const button = trigger.find(Button);
describe('via /kv/store', () => {
beforeAll(() => {
isFeatureEnabledMock = jest
.spyOn(featureFlags, 'isFeatureEnabled')
.mockImplementation(() => true);
});

expect(trigger).toHaveLength(1);
expect(button).toHaveLength(1);
});
afterAll(() => {
isFeatureEnabledMock.restore();
});

it('calls storeQuery() with the query when getCopyUrl() is called and saves the url', () => {
expect.assertions(4);
const storeQuerySpy = jest.spyOn(utils, 'storeQuery');
it('renders an OverlayTrigger with Button', () => {
const wrapper = setup();
const trigger = wrapper.find(OverlayTrigger);
const button = trigger.find(Button);

const wrapper = setup();
const instance = wrapper.instance();
expect(trigger).toHaveLength(1);
expect(button).toHaveLength(1);
});

return instance.getCopyUrl().then(() => {
expect(storeQuerySpy.mock.calls).toHaveLength(1);
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1);
expect(storeQuerySpy.mock.calls[0][0]).toMatchObject(
defaultProps.queryEditor,
);
expect(instance.state.shortUrl).toContain(storeQueryMockId);
it('calls storeQuery() with the query when getCopyUrl() is called and saves the url', () => {
expect.assertions(4);
const storeQuerySpy = jest.spyOn(utils, 'storeQuery');

return Promise.resolve();
});
});
const wrapper = setup();
const instance = wrapper.instance();

it('dispatches an error toast upon fetching failure', () => {
expect.assertions(3);
const error = 'error';
const addDangerToastSpy = jest.fn();
fetchMock.post(storeQueryUrl, { throws: error }, { overwriteRoutes: true });
const wrapper = setup();
wrapper.setProps({ addDangerToast: addDangerToastSpy });

return wrapper
.instance()
.getCopyUrl()
.then(() => {
return instance.getCopyUrl().then(() => {
expect(storeQuerySpy.mock.calls).toHaveLength(1);
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1);
expect(addDangerToastSpy.mock.calls).toHaveLength(1);
expect(addDangerToastSpy.mock.calls[0][0]).toBe(error);
expect(storeQuerySpy.mock.calls[0][0]).toMatchObject(
storedQueryAttributes,
);
expect(instance.state.shortUrl).toContain(storeQueryMockId);

storeQuerySpy.mockRestore();

return Promise.resolve();
});
});

it('dispatches an error toast upon fetching failure', () => {
expect.assertions(3);
const error = 'error';
const addDangerToastSpy = jest.fn();
fetchMock.post(
storeQueryUrl,
{ throws: error },
{ overwriteRoutes: true },
);
const wrapper = setup();
wrapper.setProps({ addDangerToast: addDangerToastSpy });

return wrapper
.instance()
.getCopyUrl()
.then(() => {
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1);
expect(addDangerToastSpy.mock.calls).toHaveLength(1);
expect(addDangerToastSpy.mock.calls[0][0]).toBe(error);

return Promise.resolve();
});
});
});
describe('via saved query', () => {
beforeAll(() => {
isFeatureEnabledMock = jest
.spyOn(featureFlags, 'isFeatureEnabled')
.mockImplementation(() => false);
});

afterAll(() => {
isFeatureEnabledMock.restore();
});

it('renders an OverlayTrigger with Button', () => {
const wrapper = setup();
const trigger = wrapper.find(OverlayTrigger);
const button = trigger.find(Button);

expect(trigger).toHaveLength(1);
expect(button).toHaveLength(1);
});

it('does not call storeQuery() with the query when getCopyUrl() is called', () => {
const storeQuerySpy = jest.spyOn(utils, 'storeQuery');

const wrapper = setup();
const instance = wrapper.instance();

instance.getCopyUrl();

expect(storeQuerySpy.mock.calls).toHaveLength(0);
expect(fetchMock.calls(storeQueryUrl)).toHaveLength(0);
expect(instance.state.shortUrl).toContain(999);

storeQuerySpy.mockRestore();
});

it('shows a request to save the query when the query is not yet saved', () => {
const wrapper = setup({ remoteId: undefined });
const instance = wrapper.instance();

instance.getCopyUrl();

expect(instance.state.shortUrl).toContain('save');
});
});
});
25 changes: 25 additions & 0 deletions superset-frontend/src/SqlLab/components/ShareSqlLabQuery.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Popover, OverlayTrigger } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';

import Button from '../../components/Button';
import CopyToClipboard from '../../components/CopyToClipboard';
Expand All @@ -34,6 +35,7 @@ const propTypes = {
schema: PropTypes.string,
autorun: PropTypes.bool,
sql: PropTypes.string,
remoteId: PropTypes.number,
}).isRequired,
addDangerToast: PropTypes.func.isRequired,
};
Expand All @@ -48,6 +50,14 @@ class ShareSqlLabQuery extends React.Component {
}

getCopyUrl() {
if (isFeatureEnabled(FeatureFlag.SHARE_QUERIES_VIA_KV_STORE)) {
return this.getCopyUrlForKvStore();
}

return this.getCopyUrlForSavedQuery();
}

getCopyUrlForKvStore() {
const { dbId, title, schema, autorun, sql } = this.props.queryEditor;
const sharedQuery = { dbId, title, schema, autorun, sql };

Expand All @@ -63,6 +73,21 @@ class ShareSqlLabQuery extends React.Component {
});
}

getCopyUrlForSavedQuery() {
let savedQueryToastContent;

if (this.props.queryEditor.remoteId) {
savedQueryToastContent =
window.location.origin +
window.location.pathname +
`?savedQueryId=${this.props.queryEditor.remoteId}`;
this.setState({ shortUrl: savedQueryToastContent });
} else {
savedQueryToastContent = t('Please save the query to enable sharing');
this.setState({ shortUrl: savedQueryToastContent });
}
}

renderPopover() {
return (
<Popover id="sqllab-shareurl-popover">
Expand Down
1 change: 1 addition & 0 deletions superset-frontend/src/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export enum FeatureFlag {
SCHEDULED_QUERIES = 'SCHEDULED_QUERIES',
SQL_VALIDATORS_BY_ENGINE = 'SQL_VALIDATORS_BY_ENGINE',
ESTIMATE_QUERY_COST = 'ESTIMATE_QUERY_COST',
SHARE_QUERIES_VIA_KV_STORE = 'SHARE_QUERIES_VIA_KV_STORE',
SQLLAB_BACKEND_PERSISTENCE = 'SQLLAB_BACKEND_PERSISTENCE',
}

Expand Down
5 changes: 4 additions & 1 deletion superset/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,10 @@ def init_views(self) -> None:
appbuilder.add_view_no_menu(Dashboard)
appbuilder.add_view_no_menu(DashboardModelViewAsync)
appbuilder.add_view_no_menu(Datasource)
appbuilder.add_view_no_menu(KV)

if feature_flag_manager.is_feature_enabled("KV_STORE"):
appbuilder.add_view_no_menu(KV)

appbuilder.add_view_no_menu(R)
appbuilder.add_view_no_menu(SavedQueryView)
appbuilder.add_view_no_menu(SavedQueryViewApi)
Expand Down
2 changes: 2 additions & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@ def _try_json_readsha(filepath, length): # pylint: disable=unused-argument
# Experimental feature introducing a client (browser) cache
"CLIENT_CACHE": False,
"ENABLE_EXPLORE_JSON_CSRF_PROTECTION": False,
"KV_STORE": False,
"PRESTO_EXPAND_DATA": False,
"SHARE_QUERIES_VIA_KV_STORE": False,
"TAGGING_SYSTEM": False,
}

Expand Down
14 changes: 12 additions & 2 deletions tests/core_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,20 @@
import string
from typing import Any, Dict
import unittest
from unittest import mock
from unittest import mock, skipUnless

import pandas as pd
import sqlalchemy as sqla

from tests.test_app import app
from superset import dataframe, db, jinja_context, security_manager, sql_lab
from superset import (
dataframe,
db,
jinja_context,
security_manager,
sql_lab,
is_feature_enabled,
)
from superset.common.query_context import QueryContext
from superset.connectors.connector_registry import ConnectorRegistry
from superset.connectors.sqla.models import SqlaTable
Expand Down Expand Up @@ -497,6 +504,9 @@ def test_shortner(self):
resp = self.client.post("/r/shortner/", data=dict(data=data))
assert re.search(r"\/r\/[0-9]+", resp.data.decode("utf-8"))

@skipUnless(
Copy link
Member

Choose a reason for hiding this comment

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

These should always be run in our test environment whether the flag is enabled or disabled. Can you set the feature flag to on before running them?

Copy link
Member Author

Choose a reason for hiding this comment

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

@etr2460 I spent an hour banging my head against it, and was unable to find an approach to modify feature flags after they were initialized without breaking the whole suite. I'm very open to recommendations.

Copy link
Member

Choose a reason for hiding this comment

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

Could enabling this feature on just for the tests be a viable approach? superset_test_config.py

Copy link
Member

Choose a reason for hiding this comment

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

In this case it would be, since the feature flag only removes functionality and doesn't add anything different. that said, I think we should be able to test both sides of the feature flag here, do you know how to do this @dpgaspar ?

Maybe mocking the feature flag checking function?

Copy link
Member Author

Choose a reason for hiding this comment

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

The issue in this case is that the application is booted once for the test suite and the feature flag changes the nature of the instantiation. This is a boot-time flag, not a run-time check. For this PR I'll go ahead and enable it for tests.

(is_feature_enabled("KV_STORE")), "skipping as /kv/ endpoints are not enabled"
)
def test_kv(self):
self.login(username="admin")

Expand Down
2 changes: 1 addition & 1 deletion tests/superset_test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

SQL_SELECT_AS_CTA = True
SQL_MAX_ROW = 666
FEATURE_FLAGS = {"foo": "bar"}
FEATURE_FLAGS = {"foo": "bar", "KV_STORE": True, "SHARE_QUERIES_VIA_KV_STORE": True}


def GET_FEATURE_FLAGS_FUNC(ff):
Expand Down