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

* Add feature flags to control query sharing, KV exposure

* Add tests, fix bug

* Skip test for kv endpoints when they are disabled

* ESLint fixes

* Remove unnecessary binds

* Fix eslint errors

* Add note to UPDATING.md RE: new feature flag options

* Use expanded version of RBAC

* Enable KV_STORE and SHARE_QUERIES_VIA_KV_STORE feature flags in the test environment

* Fix black
This commit is contained in:
Will Barrett 2020-02-19 09:51:50 -08:00 committed by GitHub
parent 84b42d28b9
commit 38f3fd0c9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 164 additions and 44 deletions

View File

@ -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`.

View File

@ -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';
@ -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';
@ -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} />,
@ -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);
expect(trigger).toHaveLength(1);
expect(button).toHaveLength(1);
});
it('calls storeQuery() with the query when getCopyUrl() is called and saves the url', () => {
expect.assertions(4);
const storeQuerySpy = jest.spyOn(utils, 'storeQuery');
const wrapper = setup();
const instance = wrapper.instance();
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);
return Promise.resolve();
describe('via /kv/store', () => {
beforeAll(() => {
isFeatureEnabledMock = jest
.spyOn(featureFlags, 'isFeatureEnabled')
.mockImplementation(() => true);
});
});
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 });
afterAll(() => {
isFeatureEnabledMock.restore();
});
return wrapper
.instance()
.getCopyUrl()
.then(() => {
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('calls storeQuery() with the query when getCopyUrl() is called and saves the url', () => {
expect.assertions(4);
const storeQuerySpy = jest.spyOn(utils, 'storeQuery');
const wrapper = setup();
const instance = wrapper.instance();
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');
});
});
});

View File

@ -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';
@ -34,6 +35,7 @@ const propTypes = {
schema: PropTypes.string,
autorun: PropTypes.bool,
sql: PropTypes.string,
remoteId: PropTypes.number,
}).isRequired,
addDangerToast: PropTypes.func.isRequired,
};
@ -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 };
@ -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">

View File

@ -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',
}

View File

@ -265,7 +265,10 @@ class SupersetAppInitializer:
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)

View File

@ -277,7 +277,9 @@ DEFAULT_FEATURE_FLAGS = {
# 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,
}

View File

@ -30,13 +30,20 @@ import re
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
@ -497,6 +504,9 @@ class CoreTests(SupersetTestCase):
resp = self.client.post("/r/shortner/", data=dict(data=data))
assert re.search(r"\/r\/[0-9]+", resp.data.decode("utf-8"))
@skipUnless(
(is_feature_enabled("KV_STORE")), "skipping as /kv/ endpoints are not enabled"
)
def test_kv(self):
self.login(username="admin")

View File

@ -30,7 +30,7 @@ if "SUPERSET__SQLALCHEMY_DATABASE_URI" in os.environ:
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):