You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by su...@apache.org on 2022/01/26 00:42:44 UTC
[superset] branch master updated: feat: embedded dashboard core (#17530)
This is an automated email from the ASF dual-hosted git repository.
suddjian pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 4ad5ad0 feat: embedded dashboard core (#17530)
4ad5ad0 is described below
commit 4ad5ad045a9adb506d14b2c02fdbefc564d25bdb
Author: David Aaron Suddjian <18...@users.noreply.github.com>
AuthorDate: Tue Jan 25 16:41:32 2022 -0800
feat: embedded dashboard core (#17530)
* feat(dashboard): embedded dashboard UI configuration (#17175) (#17450)
* setup embedded provider
* update ui configuration
* fix test
* feat: Guest token (for embedded dashboard auth) (#17517)
* generate an embed token
* improve existing tests
* add some auth setup, and rename token
* fix the stuff for compatibility with external request loaders
* docs, standard jwt claims, tweaks
* black
* lint
* tests, and safer token decoding
* linting
* type annotation
* prettier
* add feature flag
* quiet pylint
* apparently typing is a problem again
* Make guest role name configurable
* fake being a non-anonymous user
* just one log entry
* customizable algo
* lint
* lint again
* 403 works now!
* get guest token from header instead of cookie
* Revert "403 works now!"
This reverts commit df2f49a6d4267b3cccccd66549d54e25bae8e0b6.
* fix tests
* Revert "Revert "403 works now!""
This reverts commit 883dff38f16537e41f0eb5d699845263c96be5cb.
* rename method
* correct import
* feat: entry for embedded dashboard (#17529)
* create entry for embedded dashboard in webpack
* add cookies
* lint
* token message handshake
* guestTokenHeaderName
* use setupClient instead of calling configure
* rename the webpack chunk
* simplified handshake
* embedded entrypoint: render a proper app
* make the embedded page accept anonymous connections
* format
* lint
* fix test
# Conflicts:
# superset-frontend/src/embedded/index.tsx
# superset/views/core.py
* lint
* Update superset-frontend/src/embedded/index.tsx
Co-authored-by: David Aaron Suddjian <18...@users.noreply.github.com>
* comment out origins checks
* move embedded for core to dashboard
* pylint
* isort
Co-authored-by: David Aaron Suddjian <aa...@gmail.com>
Co-authored-by: David Aaron Suddjian <18...@users.noreply.github.com>
* feat: Authorizing guest access to embedded dashboards (#17757)
* helper methods and dashboard access
* guest token dashboard authz
* adjust csrf exempt list
* eums don't work that way
* Remove unnecessary import
* move row level security tests to their own file
* a bit of refactoring
* add guest token security tests
* refactor tests
* clean imports
* variable names can be too long apparently
* missing argument to get_user_roles
* don't redefine builtins
* remove unused imports
* fix test import
* default to global user when getting roles
* missing import
* mock it
* test get_user_roles
* infer g.user for ease of tests
* remove redundant check
* tests for guest user security manager fns
* use algo to get rid of warning messages
* tweaking access checks
* fix guest token security tests
* missing imports
* more tests
* more testing and also some small refactoring
* move validation out of parsing
* fix dashboard access check again
* add more test
Co-authored-by: Lily Kuang <li...@preset.io>
* feat: Row Level Security rules for guest tokens (#17836)
* helper methods and dashboard access
* guest token dashboard authz
* adjust csrf exempt list
* eums don't work that way
* Remove unnecessary import
* move row level security tests to their own file
* a bit of refactoring
* add guest token security tests
* refactor tests
* clean imports
* variable names can be too long apparently
* missing argument to get_user_roles
* don't redefine builtins
* remove unused imports
* fix test import
* default to global user when getting roles
* missing import
* mock it
* test get_user_roles
* infer g.user for ease of tests
* remove redundant check
* tests for guest user security manager fns
* use algo to get rid of warning messages
* tweaking access checks
* fix guest token security tests
* missing imports
* more tests
* more testing and also some small refactoring
* move validation out of parsing
* fix dashboard access check again
* rls rules for guest tokens
* test guest token rls rules
* more flexible rls rules
* lint
* fix tests
* fix test
* defaults
* fix some tests
* fix some tests
* lint
Co-authored-by: Lily Kuang <li...@preset.io>
* SupersetClient guest token test
* Apply suggestions from code review
Co-authored-by: Lily Kuang <li...@preset.io>
Co-authored-by: Lily Kuang <li...@preset.io>
---
.../src/connection/SupersetClient.ts | 2 +-
.../src/connection/SupersetClientClass.ts | 11 +
.../superset-ui-core/src/connection/types.ts | 3 +-
.../superset-ui-core/src/utils/featureFlags.ts | 1 +
.../test/connection/SupersetClientClass.test.ts | 20 ++
.../src/components/UiConfigContext/index.tsx | 57 ++++
superset-frontend/src/constants.ts | 4 +
.../DashboardBuilder/DashboardBuilder.tsx | 10 +-
.../src/dashboard/components/SliceHeader/index.tsx | 59 ++--
superset-frontend/src/embedded/index.tsx | 117 ++++++++
superset-frontend/src/preamble.ts | 4 +-
superset-frontend/src/setup/setupClient.ts | 13 +-
superset-frontend/src/views/App.tsx | 43 +--
.../src/views/RootContextProviders.tsx | 55 ++++
superset-frontend/src/views/components/Menu.tsx | 4 +-
superset-frontend/webpack.config.js | 1 +
superset/common/request_contexed_based.py | 16 +-
superset/config.py | 14 +-
superset/connectors/sqla/models.py | 20 +-
superset/dashboards/filters.py | 24 +-
superset/security/api.py | 94 +++++-
superset/security/guest_token.py | 87 ++++++
superset/security/manager.py | 205 +++++++++++--
superset/templates/superset/spa.html | 2 +-
superset/views/base.py | 11 +-
superset/views/core.py | 5 +-
superset/views/dashboard/views.py | 44 ++-
tests/integration_tests/security/api_tests.py | 49 +++-
.../security/guest_token_security_tests.py | 210 ++++++++++++++
.../security/row_level_security_tests.py | 316 +++++++++++++++++++++
tests/integration_tests/security_tests.py | 290 +++++++------------
31 files changed, 1466 insertions(+), 325 deletions(-)
diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts
index b582025..0f4c123 100644
--- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts
+++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts
@@ -20,6 +20,7 @@
import SupersetClientClass from './SupersetClientClass';
import { SupersetClientInterface } from './types';
+// this is local to this file, don't expose it
let singletonClient: SupersetClientClass | undefined;
function getInstance(): SupersetClientClass {
@@ -39,7 +40,6 @@ const SupersetClient: SupersetClientInterface = {
reset: () => {
singletonClient = undefined;
},
- getInstance,
delete: request => getInstance().delete(request),
get: request => getInstance().get(request),
init: force => getInstance().init(force),
diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts
index ef52134..39d5022 100644
--- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts
+++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts
@@ -40,6 +40,10 @@ export default class SupersetClientClass {
csrfPromise?: CsrfPromise;
+ guestToken?: string;
+
+ guestTokenHeaderName: string;
+
fetchRetryOptions?: FetchRetryOptions;
baseUrl: string;
@@ -64,6 +68,8 @@ export default class SupersetClientClass {
timeout,
credentials = undefined,
csrfToken = undefined,
+ guestToken = undefined,
+ guestTokenHeaderName = 'X-GuestToken',
}: ClientConfig = {}) {
const url = new URL(
host || protocol
@@ -81,6 +87,8 @@ export default class SupersetClientClass {
this.timeout = timeout;
this.credentials = credentials;
this.csrfToken = csrfToken;
+ this.guestToken = guestToken;
+ this.guestTokenHeaderName = guestTokenHeaderName;
this.fetchRetryOptions = {
...DEFAULT_FETCH_RETRY_OPTIONS,
...fetchRetryOptions,
@@ -89,6 +97,9 @@ export default class SupersetClientClass {
this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken };
this.csrfPromise = Promise.resolve(this.csrfToken);
}
+ if (guestToken) {
+ this.headers[guestTokenHeaderName] = guestToken;
+ }
}
async init(force = false): CsrfPromise {
diff --git a/superset-frontend/packages/superset-ui-core/src/connection/types.ts b/superset-frontend/packages/superset-ui-core/src/connection/types.ts
index 3f02f1c..b8df5a9 100644
--- a/superset-frontend/packages/superset-ui-core/src/connection/types.ts
+++ b/superset-frontend/packages/superset-ui-core/src/connection/types.ts
@@ -130,6 +130,8 @@ export interface ClientConfig {
protocol?: Protocol;
credentials?: Credentials;
csrfToken?: CsrfToken;
+ guestToken?: string;
+ guestTokenHeaderName?: string;
fetchRetryOptions?: FetchRetryOptions;
headers?: Headers;
mode?: Mode;
@@ -149,7 +151,6 @@ export interface SupersetClientInterface
| 'reAuthenticate'
> {
configure: (config?: ClientConfig) => SupersetClientClass;
- getInstance: (maybeClient?: SupersetClientClass) => SupersetClientClass;
reset: () => void;
}
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
index a55dcfd..cab4f72 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
@@ -40,6 +40,7 @@ export enum FeatureFlag {
DASHBOARD_CROSS_FILTERS = 'DASHBOARD_CROSS_FILTERS',
DASHBOARD_NATIVE_FILTERS_SET = 'DASHBOARD_NATIVE_FILTERS_SET',
DASHBOARD_FILTERS_EXPERIMENTAL = 'DASHBOARD_FILTERS_EXPERIMENTAL',
+ EMBEDDED_SUPERSET = 'EMBEDDED_SUPERSET',
ENABLE_FILTER_BOX_MIGRATION = 'ENABLE_FILTER_BOX_MIGRATION',
VERSIONED_EXPORT = 'VERSIONED_EXPORT',
GLOBAL_ASYNC_QUERIES = 'GLOBAL_ASYNC_QUERIES',
diff --git a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts
index 4b2307c..4efd4f4 100644
--- a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts
+++ b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts
@@ -328,6 +328,26 @@ describe('SupersetClientClass', () => {
);
});
+ it('uses a guest token when provided', async () => {
+ expect.assertions(1);
+
+ const client = new SupersetClientClass({
+ protocol,
+ host,
+ guestToken: 'abc123',
+ guestTokenHeaderName: 'guestTokenHeader',
+ });
+
+ await client.init();
+ await client.get({ url: mockGetUrl });
+ const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
+ expect(fetchRequest.headers).toEqual(
+ expect.objectContaining({
+ guestTokenHeader: 'abc123',
+ }),
+ );
+ });
+
describe('.get()', () => {
it('makes a request using url or endpoint', async () => {
expect.assertions(2);
diff --git a/superset-frontend/src/components/UiConfigContext/index.tsx b/superset-frontend/src/components/UiConfigContext/index.tsx
new file mode 100644
index 0000000..4b77e42
--- /dev/null
+++ b/superset-frontend/src/components/UiConfigContext/index.tsx
@@ -0,0 +1,57 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React, { createContext, useContext, useState } from 'react';
+import { URL_PARAMS } from 'src/constants';
+import { getUrlParam } from 'src/utils/urlUtils';
+
+interface UiConfigType {
+ hideTitle: boolean;
+ hideTab: boolean;
+ hideNav: boolean;
+ hideChartControls: boolean;
+}
+interface EmbeddedUiConfigProviderProps {
+ children: JSX.Element;
+}
+
+export const UiConfigContext = createContext<UiConfigType>({
+ hideTitle: false,
+ hideTab: false,
+ hideNav: false,
+ hideChartControls: false,
+});
+
+export const useUiConfig = () => useContext(UiConfigContext);
+
+export const EmbeddedUiConfigProvider: React.FC<EmbeddedUiConfigProviderProps> =
+ ({ children }) => {
+ const config = getUrlParam(URL_PARAMS.uiConfig);
+ const [embeddedConfig] = useState({
+ hideTitle: (config & 1) !== 0,
+ hideTab: (config & 2) !== 0,
+ hideNav: (config & 4) !== 0,
+ hideChartControls: (config & 8) !== 0,
+ });
+
+ return (
+ <UiConfigContext.Provider value={embeddedConfig}>
+ {children}
+ </UiConfigContext.Provider>
+ );
+ };
diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts
index a57951a..996cd7d 100644
--- a/superset-frontend/src/constants.ts
+++ b/superset-frontend/src/constants.ts
@@ -31,6 +31,10 @@ export const URL_PARAMS = {
name: 'standalone',
type: 'number',
},
+ uiConfig: {
+ name: 'uiConfig',
+ type: 'number',
+ },
preselectFilters: {
name: 'preselect_filters',
type: 'object',
diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
index 5e7f71b..bd6e663 100644
--- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
+++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx
@@ -49,6 +49,7 @@ import {
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
import Loading from 'src/components/Loading';
import { Global } from '@emotion/react';
+import { useUiConfig } from 'src/components/UiConfigContext';
import { shouldFocusTabs, getRootLevelTabsComponent } from './utils';
import DashboardContainer from './DashboardContainer';
import { useNativeFilters } from './state';
@@ -199,6 +200,8 @@ const StyledDashboardContent = styled.div<{
const DashboardBuilder: FC<DashboardBuilderProps> = () => {
const dispatch = useDispatch();
+ const uiConfig = useUiConfig();
+
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
@@ -243,7 +246,9 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
const standaloneMode = getUrlParam(URL_PARAMS.standalone);
const isReport = standaloneMode === DashboardStandaloneMode.REPORT;
const hideDashboardHeader =
- standaloneMode === DashboardStandaloneMode.HIDE_NAV_AND_TITLE || isReport;
+ uiConfig.hideTitle ||
+ standaloneMode === DashboardStandaloneMode.HIDE_NAV_AND_TITLE ||
+ isReport;
const barTopOffset =
(hideDashboardHeader ? 0 : HEADER_HEIGHT) +
@@ -288,7 +293,7 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
<div>
{!hideDashboardHeader && <DashboardHeader />}
{dropIndicatorProps && <div {...dropIndicatorProps} />}
- {!isReport && topLevelTabs && (
+ {!isReport && topLevelTabs && !uiConfig.hideNav && (
<WithPopoverMenu
shouldFocus={shouldFocusTabs}
menuItems={[
@@ -321,6 +326,7 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
hideDashboardHeader,
isReport,
topLevelTabs,
+ uiConfig.hideNav,
],
);
diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
index 9b42c81..a92fcc4 100644
--- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
@@ -18,6 +18,7 @@
*/
import React, { FC, useMemo } from 'react';
import { styled, t } from '@superset-ui/core';
+import { useUiConfig } from 'src/components/UiConfigContext';
import { Tooltip } from 'src/components/Tooltip';
import { useDispatch, useSelector } from 'react-redux';
import EditableTitle from 'src/components/EditableTitle';
@@ -44,7 +45,6 @@ type SliceHeaderProps = SliceHeaderControlsProps & {
const annotationsLoading = t('Annotation layers are still loading.');
const annotationsError = t('One ore more annotation layers failed loading.');
-
const CrossFilterIcon = styled(Icons.CursorTarget)`
cursor: pointer;
color: ${({ theme }) => theme.colors.primary.base};
@@ -84,6 +84,7 @@ const SliceHeader: FC<SliceHeaderProps> = ({
formData,
}) => {
const dispatch = useDispatch();
+ const uiConfig = useUiConfig();
// TODO: change to indicator field after it will be implemented
const crossFilterValue = useSelector<RootState, any>(
state => state.dataMask[slice?.slice_id]?.filterState?.value,
@@ -157,32 +158,36 @@ const SliceHeader: FC<SliceHeaderProps> = ({
/>
</Tooltip>
)}
- <FiltersBadge chartId={slice.slice_id} />
- <SliceHeaderControls
- slice={slice}
- isCached={isCached}
- isExpanded={isExpanded}
- cachedDttm={cachedDttm}
- updatedDttm={updatedDttm}
- toggleExpandSlice={toggleExpandSlice}
- forceRefresh={forceRefresh}
- logExploreChart={logExploreChart}
- exploreUrl={exploreUrl}
- exportCSV={exportCSV}
- exportFullCSV={exportFullCSV}
- supersetCanExplore={supersetCanExplore}
- supersetCanShare={supersetCanShare}
- supersetCanCSV={supersetCanCSV}
- sliceCanEdit={sliceCanEdit}
- componentId={componentId}
- dashboardId={dashboardId}
- addSuccessToast={addSuccessToast}
- addDangerToast={addDangerToast}
- handleToggleFullSize={handleToggleFullSize}
- isFullSize={isFullSize}
- chartStatus={chartStatus}
- formData={formData}
- />
+ {!uiConfig.hideChartControls && (
+ <FiltersBadge chartId={slice.slice_id} />
+ )}
+ {!uiConfig.hideChartControls && (
+ <SliceHeaderControls
+ slice={slice}
+ isCached={isCached}
+ isExpanded={isExpanded}
+ cachedDttm={cachedDttm}
+ updatedDttm={updatedDttm}
+ toggleExpandSlice={toggleExpandSlice}
+ forceRefresh={forceRefresh}
+ logExploreChart={logExploreChart}
+ exploreUrl={exploreUrl}
+ exportCSV={exportCSV}
+ exportFullCSV={exportFullCSV}
+ supersetCanExplore={supersetCanExplore}
+ supersetCanShare={supersetCanShare}
+ supersetCanCSV={supersetCanCSV}
+ sliceCanEdit={sliceCanEdit}
+ componentId={componentId}
+ dashboardId={dashboardId}
+ addSuccessToast={addSuccessToast}
+ addDangerToast={addDangerToast}
+ handleToggleFullSize={handleToggleFullSize}
+ isFullSize={isFullSize}
+ chartStatus={chartStatus}
+ formData={formData}
+ />
+ )}
</>
)}
</div>
diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx
new file mode 100644
index 0000000..11a686f
--- /dev/null
+++ b/superset-frontend/src/embedded/index.tsx
@@ -0,0 +1,117 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React, { lazy, Suspense } from 'react';
+import ReactDOM from 'react-dom';
+import { BrowserRouter as Router, Route } from 'react-router-dom';
+import { bootstrapData } from 'src/preamble';
+import setupClient from 'src/setup/setupClient';
+import { RootContextProviders } from 'src/views/RootContextProviders';
+import ErrorBoundary from 'src/components/ErrorBoundary';
+import Loading from 'src/components/Loading';
+
+const LazyDashboardPage = lazy(
+ () =>
+ import(
+ /* webpackChunkName: "DashboardPage" */ 'src/dashboard/containers/DashboardPage'
+ ),
+);
+
+const EmbeddedApp = () => (
+ <Router>
+ <Route path="/dashboard/:idOrSlug/embedded">
+ <Suspense fallback={<Loading />}>
+ <RootContextProviders>
+ <ErrorBoundary>
+ <LazyDashboardPage />
+ </ErrorBoundary>
+ </RootContextProviders>
+ </Suspense>
+ </Route>
+ </Router>
+);
+
+const appMountPoint = document.getElementById('app')!;
+
+const MESSAGE_TYPE = '__embedded_comms__';
+
+if (!window.parent) {
+ appMountPoint.innerHTML =
+ 'This page is intended to be embedded in an iframe, but no window.parent was found.';
+}
+
+// if the page is embedded in an origin that hasn't
+// been authorized by the curator, we forbid access entirely.
+// todo: check the referrer on the route serving this page instead
+// const ALLOW_ORIGINS = ['http://127.0.0.1:9001', 'http://localhost:9001'];
+// const parentOrigin = new URL(document.referrer).origin;
+// if (!ALLOW_ORIGINS.includes(parentOrigin)) {
+// throw new Error(
+// `[superset] iframe parent ${parentOrigin} is not in the list of allowed origins`,
+// );
+// }
+
+async function start(guestToken: string) {
+ // the preamble configures a client, but we need to configure a new one
+ // now that we have the guest token
+ setupClient({
+ guestToken,
+ guestTokenHeaderName: bootstrapData.config?.GUEST_TOKEN_HEADER_NAME,
+ });
+ ReactDOM.render(<EmbeddedApp />, appMountPoint);
+}
+
+function validateMessageEvent(event: MessageEvent) {
+ if (
+ event.data?.type === 'webpackClose' ||
+ event.data?.source === '@devtools-page'
+ ) {
+ // sometimes devtools use the messaging api and we want to ignore those
+ throw new Error("Sir, this is a Wendy's");
+ }
+
+ // if (!ALLOW_ORIGINS.includes(event.origin)) {
+ // throw new Error('Message origin is not in the allowed list');
+ // }
+
+ if (typeof event.data !== 'object' || event.data.type !== MESSAGE_TYPE) {
+ throw new Error(`Message type does not match type used for embedded comms`);
+ }
+}
+
+window.addEventListener('message', function (event) {
+ try {
+ validateMessageEvent(event);
+ } catch (err) {
+ console.info('[superset] ignoring message', err, event);
+ return;
+ }
+
+ console.info('[superset] received message', event);
+ const hostAppPort = event.ports?.[0];
+ if (hostAppPort) {
+ hostAppPort.onmessage = function receiveMessage(event) {
+ console.info('[superset] received message event', event.data);
+ if (event.data.guestToken) {
+ start(event.data.guestToken);
+ }
+ };
+ }
+});
+
+console.info('[superset] embed page is ready to receive messages');
diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts
index da2fac3..5380fe2 100644
--- a/superset-frontend/src/preamble.ts
+++ b/superset-frontend/src/preamble.ts
@@ -30,11 +30,11 @@ if (process.env.WEBPACK_MODE === 'development') {
setHotLoaderConfig({ logLevel: 'debug', trackTailUpdates: false });
}
-let bootstrapData: any;
+// eslint-disable-next-line import/no-mutable-exports
+export let bootstrapData: any;
// Configure translation
if (typeof window !== 'undefined') {
const root = document.getElementById('app');
-
bootstrapData = root
? JSON.parse(root.getAttribute('data-bootstrap') || '{}')
: {};
diff --git a/superset-frontend/src/setup/setupClient.ts b/superset-frontend/src/setup/setupClient.ts
index 11f3f6a..8802ae4 100644
--- a/superset-frontend/src/setup/setupClient.ts
+++ b/superset-frontend/src/setup/setupClient.ts
@@ -16,22 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { SupersetClient, logging } from '@superset-ui/core';
+import { SupersetClient, logging, ClientConfig } from '@superset-ui/core';
import parseCookie from 'src/utils/parseCookie';
-export default function setupClient() {
+function getDefaultConfiguration(): ClientConfig {
const csrfNode = document.querySelector<HTMLInputElement>('#csrf_token');
const csrfToken = csrfNode?.value;
// when using flask-jwt-extended csrf is set in cookies
const cookieCSRFToken = parseCookie().csrf_access_token || '';
- SupersetClient.configure({
+ return {
protocol: ['http:', 'https:'].includes(window?.location?.protocol)
? (window?.location?.protocol as 'http:' | 'https:')
: undefined,
host: (window.location && window.location.host) || '',
csrfToken: csrfToken || cookieCSRFToken,
+ };
+}
+
+export default function setupClient(customConfig: Partial<ClientConfig> = {}) {
+ SupersetClient.configure({
+ ...getDefaultConfiguration(),
+ ...customConfig,
})
.init()
.catch(error => {
diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx
index 7bbc40e..4e193bb 100644
--- a/superset-frontend/src/views/App.tsx
+++ b/superset-frontend/src/views/App.tsx
@@ -18,41 +18,31 @@
*/
import React, { Suspense, useEffect } from 'react';
import { hot } from 'react-hot-loader/root';
-import { Provider as ReduxProvider } from 'react-redux';
import {
BrowserRouter as Router,
Switch,
Route,
useLocation,
} from 'react-router-dom';
-import { DndProvider } from 'react-dnd';
-import { HTML5Backend } from 'react-dnd-html5-backend';
-import { QueryParamProvider } from 'use-query-params';
import { initFeatureFlags } from 'src/featureFlags';
-import { ThemeProvider } from '@superset-ui/core';
-import { DynamicPluginProvider } from 'src/components/DynamicPlugins';
import ErrorBoundary from 'src/components/ErrorBoundary';
import Loading from 'src/components/Loading';
import Menu from 'src/views/components/Menu';
-import FlashProvider from 'src/components/FlashProvider';
-import { theme } from 'src/preamble';
+import { bootstrapData } from 'src/preamble';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import setupApp from 'src/setup/setupApp';
import { routes, isFrontendRoute } from 'src/views/routes';
import { Logger } from 'src/logger/LogUtils';
-import { store } from './store';
+import { RootContextProviders } from './RootContextProviders';
setupApp();
-const container = document.getElementById('app');
-const bootstrap = JSON.parse(container?.getAttribute('data-bootstrap') ?? '{}');
-const user = { ...bootstrap.user };
-const menu = { ...bootstrap.common.menu_data };
-const common = { ...bootstrap.common };
+const user = { ...bootstrapData.user };
+const menu = { ...bootstrapData.common.menu_data };
let lastLocationPathname: string;
-initFeatureFlags(bootstrap.common.feature_flags);
+initFeatureFlags(bootstrapData.common.feature_flags);
-const RootContextProviders: React.FC = ({ children }) => {
+const LocationPathnameLogger = () => {
const location = useLocation();
useEffect(() => {
// reset performance logger timer start point to avoid soft navigation
@@ -62,29 +52,12 @@ const RootContextProviders: React.FC = ({ children }) => {
}
lastLocationPathname = location.pathname;
}, [location.pathname]);
-
- return (
- <ThemeProvider theme={theme}>
- <ReduxProvider store={store}>
- <DndProvider backend={HTML5Backend}>
- <FlashProvider messages={common.flash_messages}>
- <DynamicPluginProvider>
- <QueryParamProvider
- ReactRouterRoute={Route}
- stringifyOptions={{ encode: false }}
- >
- {children}
- </QueryParamProvider>
- </DynamicPluginProvider>
- </FlashProvider>
- </DndProvider>
- </ReduxProvider>
- </ThemeProvider>
- );
+ return <></>;
};
const App = () => (
<Router>
+ <LocationPathnameLogger />
<RootContextProviders>
<Menu data={menu} isFrontendRoute={isFrontendRoute} />
<Switch>
diff --git a/superset-frontend/src/views/RootContextProviders.tsx b/superset-frontend/src/views/RootContextProviders.tsx
new file mode 100644
index 0000000..f40f228
--- /dev/null
+++ b/superset-frontend/src/views/RootContextProviders.tsx
@@ -0,0 +1,55 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React from 'react';
+import { Route } from 'react-router-dom';
+import { ThemeProvider } from '@superset-ui/core';
+import { Provider as ReduxProvider } from 'react-redux';
+import { QueryParamProvider } from 'use-query-params';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
+
+import { store } from './store';
+import FlashProvider from '../components/FlashProvider';
+import { bootstrapData, theme } from '../preamble';
+import { EmbeddedUiConfigProvider } from '../components/UiConfigContext';
+import { DynamicPluginProvider } from '../components/DynamicPlugins';
+
+const common = { ...bootstrapData.common };
+
+export const RootContextProviders: React.FC = ({ children }) => (
+ <ThemeProvider theme={theme}>
+ <ReduxProvider store={store}>
+ <DndProvider backend={HTML5Backend}>
+ <FlashProvider messages={common.flash_messages}>
+ <EmbeddedUiConfigProvider>
+ <DynamicPluginProvider>
+ <QueryParamProvider
+ ReactRouterRoute={Route}
+ stringifyOptions={{ encode: false }}
+ >
+ {children}
+ </QueryParamProvider>
+ </DynamicPluginProvider>
+ </EmbeddedUiConfigProvider>
+ </FlashProvider>
+ </DndProvider>
+ </ReduxProvider>
+ </ThemeProvider>
+);
diff --git a/superset-frontend/src/views/components/Menu.tsx b/superset-frontend/src/views/components/Menu.tsx
index fd998b3..a29671c 100644
--- a/superset-frontend/src/views/components/Menu.tsx
+++ b/superset-frontend/src/views/components/Menu.tsx
@@ -31,6 +31,7 @@ import {
import { Tooltip } from 'src/components/Tooltip';
import { Link } from 'react-router-dom';
import Icons from 'src/components/Icons';
+import { useUiConfig } from 'src/components/UiConfigContext';
import { URL_PARAMS } from 'src/constants';
import RightMenu from './MenuRight';
import { Languages } from './LanguagePicker';
@@ -182,6 +183,7 @@ export function Menu({
}: MenuProps) {
const [showMenu, setMenu] = useState<MenuMode>('horizontal');
const screens = useBreakpoint();
+ const uiConig = useUiConfig();
useEffect(() => {
function handleResize() {
@@ -196,7 +198,7 @@ export function Menu({
}, []);
const standalone = getUrlParam(URL_PARAMS.standalone);
- if (standalone) return <></>;
+ if (standalone || uiConig.hideNav) return <></>;
const renderSubMenu = ({
label,
diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js
index 17547da..55f0122 100644
--- a/superset-frontend/webpack.config.js
+++ b/superset-frontend/webpack.config.js
@@ -208,6 +208,7 @@ const config = {
theme: path.join(APP_DIR, '/src/theme.ts'),
menu: addPreamble('src/views/menu.tsx'),
spa: addPreamble('/src/views/index.tsx'),
+ embedded: addPreamble('/src/embedded/index.tsx'),
addSlice: addPreamble('/src/addSlice/index.tsx'),
explore: addPreamble('/src/explore/index.jsx'),
sqllab: addPreamble('/src/SqlLab/index.tsx'),
diff --git a/superset/common/request_contexed_based.py b/superset/common/request_contexed_based.py
index 0b06a0c..5d8405e 100644
--- a/superset/common/request_contexed_based.py
+++ b/superset/common/request_contexed_based.py
@@ -16,24 +16,10 @@
# under the License.
from __future__ import annotations
-from typing import List, TYPE_CHECKING
-
-from flask import g
-
from superset import conf, security_manager
-if TYPE_CHECKING:
- from flask_appbuilder.security.sqla.models import Role
-
-
-def get_user_roles() -> List[Role]:
- if g.user.is_anonymous:
- public_role = conf.get("AUTH_ROLE_PUBLIC")
- return [security_manager.get_public_role()] if public_role else []
- return g.user.roles
-
def is_user_admin() -> bool:
- user_roles = [role.name.lower() for role in get_user_roles()]
+ user_roles = [role.name.lower() for role in security_manager.get_user_roles()]
admin_role = conf.get("AUTH_ROLE_ADMIN").lower()
return admin_role in user_roles
diff --git a/superset/config.py b/superset/config.py
index 16d9d66..228a9f2 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -204,7 +204,11 @@ QUERY_SEARCH_LIMIT = 1000
WTF_CSRF_ENABLED = True
# Add endpoints that need to be exempt from CSRF protection
-WTF_CSRF_EXEMPT_LIST = ["superset.views.core.log", "superset.charts.data.api.data"]
+WTF_CSRF_EXEMPT_LIST = [
+ "superset.views.core.log",
+ "superset.views.core.explore_json",
+ "superset.charts.data.api.data",
+]
# Whether to run the web server in debug mode or not
DEBUG = os.environ.get("FLASK_ENV") == "development"
@@ -406,6 +410,7 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
# a custom security config could potentially give access to setting filters on
# tables that users do not have access to.
"ROW_LEVEL_SECURITY": True,
+ "EMBEDDED_SUPERSET": False,
# Enables Alerts and reports new implementation
"ALERT_REPORTS": False,
# Enable experimental feature to search for other dashboards
@@ -1305,6 +1310,13 @@ GLOBAL_ASYNC_QUERIES_POLLING_DELAY = int(
)
GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = "ws://127.0.0.1:8080/"
+# Embedded config options
+GUEST_ROLE_NAME = "Public"
+GUEST_TOKEN_JWT_SECRET = "test-guest-secret-change-me"
+GUEST_TOKEN_JWT_ALGO = "HS256"
+GUEST_TOKEN_HEADER_NAME = "X-GuestToken"
+GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutes
+
# A SQL dataset health check. Note if enabled it is strongly advised that the callable
# be memoized to aid with performance, i.e.,
#
diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index 2251693..311889b 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -963,14 +963,28 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho
:returns: A list of SQL clauses to be ANDed together.
:rtype: List[str]
"""
- filters_grouped: Dict[Union[int, str], List[str]] = defaultdict(list)
+ all_filters: List[TextClause] = []
+ filter_groups: Dict[Union[int, str], List[TextClause]] = defaultdict(list)
try:
for filter_ in security_manager.get_rls_filters(self):
clause = self.text(
f"({template_processor.process_template(filter_.clause)})"
)
- filters_grouped[filter_.group_key or filter_.id].append(clause)
- return [or_(*clauses) for clauses in filters_grouped.values()]
+ if filter_.group_key:
+ filter_groups[filter_.group_key].append(clause)
+ else:
+ all_filters.append(clause)
+
+ if is_feature_enabled("EMBEDDED_SUPERSET"):
+ for rule in security_manager.get_guest_rls_filters(self):
+ clause = self.text(
+ f"({template_processor.process_template(rule['clause'])})"
+ )
+ all_filters.append(clause)
+
+ grouped_filters = [or_(*clauses) for clauses in filter_groups.values()]
+ all_filters.extend(grouped_filters)
+ return all_filters
except TemplateError as ex:
raise QueryObjectValidationError(
_("Error in jinja expression in RLS filters: %(msg)s", msg=ex.message,)
diff --git a/superset/dashboards/filters.py b/superset/dashboards/filters.py
index 9658478..e398af9 100644
--- a/superset/dashboards/filters.py
+++ b/superset/dashboards/filters.py
@@ -16,6 +16,7 @@
# under the License.
from typing import Any, Optional
+from flask import g
from flask_appbuilder.security.sqla.models import Role
from flask_babel import lazy_gettext as _
from sqlalchemy import and_, or_
@@ -25,7 +26,8 @@ from superset import db, is_feature_enabled, security_manager
from superset.models.core import FavStar
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
-from superset.views.base import BaseFilter, get_user_roles, is_user_admin
+from superset.security.guest_token import GuestTokenResourceType, GuestUser
+from superset.views.base import BaseFilter, is_user_admin
from superset.views.base_api import BaseFavoriteFilter
@@ -112,7 +114,7 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
)
)
- dashboard_rbac_or_filters = []
+ feature_flagged_filters = []
if is_feature_enabled("DASHBOARD_RBAC"):
roles_based_query = (
db.session.query(Dashboard.id)
@@ -121,19 +123,31 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
and_(
Dashboard.published.is_(True),
dashboard_has_roles,
- Role.id.in_([x.id for x in get_user_roles()]),
+ Role.id.in_([x.id for x in security_manager.get_user_roles()]),
),
)
)
- dashboard_rbac_or_filters.append(Dashboard.id.in_(roles_based_query))
+ feature_flagged_filters.append(Dashboard.id.in_(roles_based_query))
+
+ if is_feature_enabled("EMBEDDED_SUPERSET") and security_manager.is_guest_user(
+ g.user
+ ):
+ guest_user: GuestUser = g.user
+ embedded_dashboard_ids = [
+ r["id"]
+ for r in guest_user.resources
+ if r["type"] == GuestTokenResourceType.DASHBOARD.value
+ ]
+ if len(embedded_dashboard_ids) != 0:
+ feature_flagged_filters.append(Dashboard.id.in_(embedded_dashboard_ids))
query = query.filter(
or_(
Dashboard.id.in_(owner_ids_query),
Dashboard.id.in_(datasource_perm_query),
Dashboard.id.in_(users_favorite_dash_query),
- *dashboard_rbac_or_filters,
+ *feature_flagged_filters,
)
)
diff --git a/superset/security/api.py b/superset/security/api.py
index 5aa51f8..b919e29 100644
--- a/superset/security/api.py
+++ b/superset/security/api.py
@@ -15,18 +15,64 @@
# specific language governing permissions and limitations
# under the License.
import logging
+from typing import Any, Dict
-from flask import Response
+from flask import request, Response
from flask_appbuilder import expose
from flask_appbuilder.api import BaseApi, safe
from flask_appbuilder.security.decorators import permission_name, protect
from flask_wtf.csrf import generate_csrf
+from marshmallow import EXCLUDE, fields, post_load, Schema, ValidationError
+from marshmallow_enum import EnumField
from superset.extensions import event_logger
+from superset.security.guest_token import GuestTokenResourceType
logger = logging.getLogger(__name__)
+class PermissiveSchema(Schema):
+ """
+ A marshmallow schema that ignores unexpected fields, instead of throwing an error.
+ """
+
+ class Meta: # pylint: disable=too-few-public-methods
+ unknown = EXCLUDE
+
+
+class UserSchema(PermissiveSchema):
+ username = fields.String()
+ first_name = fields.String()
+ last_name = fields.String()
+
+
+class ResourceSchema(PermissiveSchema):
+ type = EnumField(GuestTokenResourceType, by_value=True, required=True)
+ id = fields.String(required=True)
+
+ @post_load
+ def convert_enum_to_value( # pylint: disable=no-self-use
+ self, data: Dict[str, Any], **kwargs: Any # pylint: disable=unused-argument
+ ) -> Dict[str, Any]:
+ # we don't care about the enum, we want the value inside
+ data["type"] = data["type"].value
+ return data
+
+
+class RlsRuleSchema(PermissiveSchema):
+ dataset = fields.Integer()
+ clause = fields.String(required=True) # todo other options?
+
+
+class GuestTokenCreateSchema(PermissiveSchema):
+ user = fields.Nested(UserSchema)
+ resources = fields.List(fields.Nested(ResourceSchema), required=True)
+ rls = fields.List(fields.Nested(RlsRuleSchema), required=True)
+
+
+guest_token_create_schema = GuestTokenCreateSchema()
+
+
class SecurityRestApi(BaseApi):
resource_name = "security"
allow_browser_login = True
@@ -60,3 +106,49 @@ class SecurityRestApi(BaseApi):
$ref: '#/components/responses/500'
"""
return self.response(200, result=generate_csrf())
+
+ @expose("/guest_token/", methods=["POST"])
+ @event_logger.log_this
+ @protect()
+ @safe
+ @permission_name("grant_guest_token")
+ def guest_token(self) -> Response:
+ """Response
+ Returns a guest token that can be used for auth in embedded Superset
+ ---
+ post:
+ description: >-
+ Fetches a guest token
+ requestBody:
+ description: Parameters for the guest token
+ required: true
+ content:
+ application/json:
+ schema: GuestTokenCreateSchema
+ responses:
+ 200:
+ description: Result contains the guest token
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ token:
+ type: string
+ 401:
+ $ref: '#/components/responses/401'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ try:
+ body = guest_token_create_schema.load(request.json)
+ # todo validate stuff:
+ # make sure the resource ids are valid
+ # make sure username doesn't reference an existing user
+ # check rls rules for validity?
+ token = self.appbuilder.sm.create_guest_access_token(
+ body["user"], body["resources"], body["rls"]
+ )
+ return self.response(200, token=token)
+ except ValidationError as error:
+ return self.response_400(message=error.messages)
diff --git a/superset/security/guest_token.py b/superset/security/guest_token.py
new file mode 100644
index 0000000..44b59c1
--- /dev/null
+++ b/superset/security/guest_token.py
@@ -0,0 +1,87 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from enum import Enum
+from typing import List, Optional, TypedDict, Union
+
+from flask_appbuilder.security.sqla.models import Role
+from flask_login import AnonymousUserMixin
+
+
+class GuestTokenUser(TypedDict, total=False):
+ username: str
+ first_name: str
+ last_name: str
+
+
+class GuestTokenResourceType(Enum):
+ DASHBOARD = "dashboard"
+
+
+class GuestTokenResource(TypedDict):
+ type: GuestTokenResourceType
+ id: Union[str, int]
+
+
+GuestTokenResources = List[GuestTokenResource]
+
+
+class GuestTokenRlsRule(TypedDict):
+ dataset: Optional[str]
+ clause: str
+
+
+class GuestToken(TypedDict):
+ iat: float
+ exp: float
+ user: GuestTokenUser
+ resources: GuestTokenResources
+ rls_rules: List[GuestTokenRlsRule]
+
+
+class GuestUser(AnonymousUserMixin):
+ """
+ Used as the "anonymous" user in case of guest authentication (embedded)
+ """
+
+ is_guest_user = True
+
+ @property
+ def is_authenticated(self) -> bool:
+ """
+ This is set to true because guest users should be considered authenticated,
+ at least in most places. The treatment of this flag is kind of inconsistent.
+ """
+ return True
+
+ @property
+ def is_anonymous(self) -> bool:
+ """
+ This is set to false because lots of code assumes that
+ if user.is_anonymous, then role = Public
+ But guest users need to have their own role independent of Public.
+ """
+ return False
+
+ def __init__(self, token: GuestToken, roles: List[Role]):
+ user = token["user"]
+ self.guest_token = token
+ self.username = user.get("username", "guest_user")
+ self.first_name = user.get("first_name", "Guest")
+ self.last_name = user.get("last_name", "User")
+ self.roles = roles
+ self.resources = token["resources"]
+ self.rls = token.get("rls_rules", [])
diff --git a/superset/security/manager.py b/superset/security/manager.py
index 9e45e88..5ca81b2 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -18,6 +18,7 @@
"""A set of constants and methods to manage permissions and security"""
import logging
import re
+import time
from collections import defaultdict
from typing import (
Any,
@@ -32,7 +33,8 @@ from typing import (
Union,
)
-from flask import current_app, g
+import jwt
+from flask import current_app, Flask, g, Request
from flask_appbuilder import Model
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_appbuilder.security.sqla.manager import SecurityManager
@@ -51,7 +53,7 @@ from flask_appbuilder.security.views import (
ViewMenuModelView,
)
from flask_appbuilder.widgets import ListWidget
-from flask_login import AnonymousUserMixin
+from flask_login import AnonymousUserMixin, LoginManager
from sqlalchemy import and_, or_
from sqlalchemy.engine.base import Connection
from sqlalchemy.orm import Session
@@ -63,6 +65,14 @@ from superset.connectors.connector_registry import ConnectorRegistry
from superset.constants import RouteMethod
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
+from superset.security.guest_token import (
+ GuestToken,
+ GuestTokenResources,
+ GuestTokenResourceType,
+ GuestTokenRlsRule,
+ GuestTokenUser,
+ GuestUser,
+)
from superset.utils.core import DatasourceName, RowLevelSecurityFilterType
if TYPE_CHECKING:
@@ -172,6 +182,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"can_approve",
"can_update_role",
"all_query_access",
+ "can_grant_guest_token",
}
READ_ONLY_PERMISSION = {
@@ -221,6 +232,17 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"all_query_access",
)
+ guest_user_cls = GuestUser
+
+ def create_login_manager(self, app: Flask) -> LoginManager:
+ # pylint: disable=import-outside-toplevel
+ from superset.extensions import feature_flag_manager
+
+ lm = super().create_login_manager(app)
+ if feature_flag_manager.is_feature_enabled("EMBEDDED_SUPERSET"):
+ lm.request_loader(self.get_guest_user_from_request)
+ return lm
+
def get_schema_perm( # pylint: disable=no-self-use
self, database: Union["Database", str], schema: Optional[str] = None
) -> Optional[str]:
@@ -1047,11 +1069,16 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
assert datasource
+ should_check_dashboard_access = (
+ feature_flag_manager.is_feature_enabled("DASHBOARD_RBAC")
+ or self.is_guest_user()
+ )
+
if not (
self.can_access_schema(datasource)
or self.can_access("datasource_access", datasource.perm or "")
or (
- feature_flag_manager.is_feature_enabled("DASHBOARD_RBAC")
+ should_check_dashboard_access
and self.can_access_based_on_dashboard(datasource)
)
):
@@ -1077,6 +1104,33 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
def get_anonymous_user(self) -> User: # pylint: disable=no-self-use
return AnonymousUserMixin()
+ def get_user_roles(self, user: Optional[User] = None) -> List[Role]:
+ if not user:
+ user = g.user
+ if user.is_anonymous:
+ public_role = current_app.config.get("AUTH_ROLE_PUBLIC")
+ return [self.get_public_role()] if public_role else []
+ return user.roles
+
+ def get_guest_rls_filters(
+ self, dataset: "BaseDatasource"
+ ) -> List[GuestTokenRlsRule]:
+ """
+ Retrieves the row level security filters for the current user and the dataset,
+ if the user is authenticated with a guest token.
+ :param dataset: The dataset to check against
+ :return: A list of filters
+ """
+ guest_user = self.get_current_guest_user_if_guest()
+ if guest_user:
+ return [
+ rule
+ for rule in guest_user.rls
+ if not rule.get("dataset")
+ or str(rule.get("dataset")) == str(dataset.id)
+ ]
+ return []
+
def get_rls_filters(self, table: "BaseDatasource") -> List[SqlaQuery]:
"""
Retrieves the appropriate row level security filters for the current user and
@@ -1085,7 +1139,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
:param table: The table to check against
:returns: A list of filters
"""
- if hasattr(g, "user") and hasattr(g.user, "id"):
+ if hasattr(g, "user"):
# pylint: disable=import-outside-toplevel
from superset.connectors.sqla.models import (
RLSFilterRoles,
@@ -1093,11 +1147,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
RowLevelSecurityFilter,
)
- user_roles = (
- self.get_session.query(assoc_user_role.c.role_id)
- .filter(assoc_user_role.c.user_id == g.user.get_id())
- .subquery()
- )
+ user_roles = [role.id for role in self.get_user_roles()]
regular_filter_roles = (
self.get_session.query(RLSFilterRoles.c.rls_filter_id)
.join(RowLevelSecurityFilter)
@@ -1175,10 +1225,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
)
)
- @staticmethod
- def raise_for_dashboard_access(dashboard: "Dashboard") -> None:
+ def raise_for_dashboard_access(self, dashboard: "Dashboard") -> None:
"""
Raise an exception if the user cannot access the dashboard.
+ This does not check for the required role/permission pairs,
+ it only concerns itself with entity relationships.
:param dashboard: Dashboard the user wants access to
:raises DashboardAccessDeniedError: If the user cannot access the resource
@@ -1186,23 +1237,27 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
# pylint: disable=import-outside-toplevel
from superset import is_feature_enabled
from superset.dashboards.commands.exceptions import DashboardAccessDeniedError
- from superset.views.base import get_user_roles, is_user_admin
+ from superset.views.base import is_user_admin
from superset.views.utils import is_owner
- has_rbac_access = True
-
- if is_feature_enabled("DASHBOARD_RBAC"):
- has_rbac_access = any(
- dashboard_role.id in [user_role.id for user_role in get_user_roles()]
+ def has_rbac_access() -> bool:
+ return (not is_feature_enabled("DASHBOARD_RBAC")) or any(
+ dashboard_role.id
+ in [user_role.id for user_role in self.get_user_roles()]
for dashboard_role in dashboard.roles
)
- can_access = (
- is_user_admin()
- or is_owner(dashboard, g.user)
- or (dashboard.published and has_rbac_access)
- or (not dashboard.published and not dashboard.roles)
- )
+ if self.is_guest_user():
+ can_access = self.has_guest_access(
+ GuestTokenResourceType.DASHBOARD, dashboard.id
+ )
+ else:
+ can_access = (
+ is_user_admin()
+ or is_owner(dashboard, g.user)
+ or (dashboard.published and has_rbac_access())
+ or (not dashboard.published and not dashboard.roles)
+ )
if not can_access:
raise DashboardAccessDeniedError()
@@ -1228,3 +1283,107 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
exists = db.session.query(query.exists()).scalar()
return exists
+
+ @staticmethod
+ def _get_current_epoch_time() -> float:
+ """ This is used so the tests can mock time """
+ return time.time()
+
+ def create_guest_access_token(
+ self,
+ user: GuestTokenUser,
+ resources: GuestTokenResources,
+ rls: List[GuestTokenRlsRule],
+ ) -> bytes:
+ secret = current_app.config["GUEST_TOKEN_JWT_SECRET"]
+ algo = current_app.config["GUEST_TOKEN_JWT_ALGO"]
+ exp_seconds = current_app.config["GUEST_TOKEN_JWT_EXP_SECONDS"]
+
+ # calculate expiration time
+ now = self._get_current_epoch_time()
+ exp = now + (exp_seconds * 1000)
+ claims = {
+ "user": user,
+ "resources": resources,
+ "rls_rules": rls,
+ # standard jwt claims:
+ "iat": now, # issued at
+ "exp": exp, # expiration time
+ }
+ token = jwt.encode(claims, secret, algorithm=algo)
+ return token
+
+ def get_guest_user_from_request(self, req: Request) -> Optional[GuestUser]:
+ """
+ If there is a guest token in the request (used for embedded),
+ parses the token and returns the guest user.
+ This is meant to be used as a request loader for the LoginManager.
+ The LoginManager will only call this if an active session cannot be found.
+
+ :return: A guest user object
+ """
+ raw_token = req.headers.get(current_app.config["GUEST_TOKEN_HEADER_NAME"])
+ if raw_token is None:
+ return None
+
+ try:
+ token = self.parse_jwt_guest_token(raw_token)
+ if token.get("user") is None:
+ raise ValueError("Guest token does not contain a user claim")
+ if token.get("resources") is None:
+ raise ValueError("Guest token does not contain a resources claim")
+ if token.get("rls_rules") is None:
+ raise ValueError("Guest token does not contain an rls_rules claim")
+ except Exception: # pylint: disable=broad-except
+ # The login manager will handle sending 401s.
+ # We don't need to send a special error message.
+ logger.warning("Invalid guest token", exc_info=True)
+ return None
+ else:
+ return self.get_guest_user_from_token(cast(GuestToken, token))
+
+ def get_guest_user_from_token(self, token: GuestToken) -> GuestUser:
+ return self.guest_user_cls(
+ token=token, roles=[self.find_role(current_app.config["GUEST_ROLE_NAME"])],
+ )
+
+ @staticmethod
+ def parse_jwt_guest_token(raw_token: str) -> Dict[str, Any]:
+ """
+ Parses a guest token. Raises an error if the jwt fails standard claims checks.
+ :param raw_token: the token gotten from the request
+ :return: the same token that was passed in, tested but unchanged
+ """
+ secret = current_app.config["GUEST_TOKEN_JWT_SECRET"]
+ algo = current_app.config["GUEST_TOKEN_JWT_ALGO"]
+ return jwt.decode(raw_token, secret, algorithms=[algo])
+
+ @staticmethod
+ def is_guest_user(user: Optional[Any] = None) -> bool:
+ # pylint: disable=import-outside-toplevel
+ from superset import is_feature_enabled
+
+ if not is_feature_enabled("EMBEDDED_SUPERSET"):
+ return False
+ if not user:
+ user = g.user
+ return hasattr(user, "is_guest_user") and user.is_guest_user
+
+ def get_current_guest_user_if_guest(self) -> Optional[GuestUser]:
+
+ if self.is_guest_user():
+ return g.user
+ return None
+
+ def has_guest_access(
+ self, resource_type: GuestTokenResourceType, resource_id: Union[str, int]
+ ) -> bool:
+ user = self.get_current_guest_user_if_guest()
+ if not user:
+ return False
+
+ strid = str(resource_id)
+ for resource in user.resources:
+ if resource["type"] == resource_type.value and str(resource["id"]) == strid:
+ return True
+ return False
diff --git a/superset/templates/superset/spa.html b/superset/templates/superset/spa.html
index 1e38cb2..6a0312f 100644
--- a/superset/templates/superset/spa.html
+++ b/superset/templates/superset/spa.html
@@ -22,6 +22,6 @@
{% endblock %}
{% block tail_js %}
- {{ js_bundle("spa") }}
+ {{ js_bundle(entry) }}
{% include "tail_js_custom_extra.html" %}
{% endblock %}
diff --git a/superset/views/base.py b/superset/views/base.py
index d3cb477..4244c66 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -38,7 +38,7 @@ from flask_appbuilder import BaseView, Model, ModelView
from flask_appbuilder.actions import action
from flask_appbuilder.forms import DynamicForm
from flask_appbuilder.models.sqla.filters import BaseFilter
-from flask_appbuilder.security.sqla.models import Role, User
+from flask_appbuilder.security.sqla.models import User
from flask_appbuilder.widgets import ListWidget
from flask_babel import get_locale, gettext as __, lazy_gettext as _
from flask_jwt_extended.exceptions import NoAuthorizationError
@@ -264,15 +264,8 @@ def create_table_permissions(table: models.SqlaTable) -> None:
security_manager.add_permission_view_menu("schema_access", table.schema_perm)
-def get_user_roles() -> List[Role]:
- if g.user.is_anonymous:
- public_role = conf.get("AUTH_ROLE_PUBLIC")
- return [security_manager.find_role(public_role)] if public_role else []
- return g.user.roles
-
-
def is_user_admin() -> bool:
- user_roles = [role.name.lower() for role in list(get_user_roles())]
+ user_roles = [role.name.lower() for role in list(security_manager.get_user_roles())]
return "admin" in user_roles
diff --git a/superset/views/core.py b/superset/views/core.py
index 8e115a9..84ef5eb 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -135,7 +135,6 @@ from superset.views.base import (
data_payload_response,
generate_download_headers,
get_error_msg,
- get_user_roles,
handle_api_exception,
json_error_response,
json_errors_response,
@@ -1888,7 +1887,9 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
f"ERROR: cannot find dashboard {dashboard_id}", status=404
)
- edit_perm = is_owner(dash, g.user) or admin_role in get_user_roles()
+ edit_perm = (
+ is_owner(dash, g.user) or admin_role in security_manager.get_user_roles()
+ )
if not edit_perm:
username = g.user.username if hasattr(g.user, "username") else "user"
return json_error_response(
diff --git a/superset/views/dashboard/views.py b/superset/views/dashboard/views.py
index 5e03d24..99782de 100644
--- a/superset/views/dashboard/views.py
+++ b/superset/views/dashboard/views.py
@@ -16,7 +16,7 @@
# under the License.
import json
import re
-from typing import List, Union
+from typing import Callable, List, Union
from flask import g, redirect, request, Response
from flask_appbuilder import expose
@@ -24,8 +24,9 @@ from flask_appbuilder.actions import action
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_appbuilder.security.decorators import has_access
from flask_babel import gettext as __, lazy_gettext as _
+from flask_login import AnonymousUserMixin, LoginManager
-from superset import db, event_logger, is_feature_enabled
+from superset import db, event_logger, is_feature_enabled, security_manager
from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.models.dashboard import Dashboard as DashboardModel
from superset.typing import FlaskResponse
@@ -33,6 +34,7 @@ from superset.utils import core as utils
from superset.views.base import (
BaseSupersetView,
check_ownership,
+ common_bootstrap_payload,
DeleteMixin,
generate_download_headers,
SupersetModelView,
@@ -133,6 +135,44 @@ class Dashboard(BaseSupersetView):
db.session.commit()
return redirect(f"/superset/dashboard/{new_dashboard.id}/?edit=true")
+ @expose("/<dashboard_id_or_slug>/embedded")
+ @event_logger.log_this_with_extra_payload
+ def embedded(
+ self,
+ dashboard_id_or_slug: str,
+ add_extra_log_payload: Callable[..., None] = lambda **kwargs: None,
+ ) -> FlaskResponse:
+ """
+ Server side rendering for a dashboard
+ :param dashboard_id_or_slug: identifier for dashboard. used in the decorators
+ :param add_extra_log_payload: added by `log_this_with_manual_updates`, set a
+ default value to appease pylint
+ """
+ if not is_feature_enabled("EMBEDDED_SUPERSET"):
+ return Response(status=404)
+
+ # Log in as an anonymous user, just for this view.
+ # This view needs to be visible to all users,
+ # and building the page fails if g.user and/or ctx.user aren't present.
+ login_manager: LoginManager = security_manager.lm
+ login_manager.reload_user(AnonymousUserMixin())
+
+ add_extra_log_payload(
+ dashboard_id=dashboard_id_or_slug, dashboard_version="v2",
+ )
+
+ bootstrap_data = {
+ "common": common_bootstrap_payload(),
+ }
+
+ return self.render_template(
+ "superset/spa.html",
+ entry="embedded",
+ bootstrap_data=json.dumps(
+ bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser
+ ),
+ )
+
class DashboardModelViewAsync(DashboardModelView): # pylint: disable=too-many-ancestors
route_base = "/dashboardasync"
diff --git a/tests/integration_tests/security/api_tests.py b/tests/integration_tests/security/api_tests.py
index 6a81efa..d7b3659 100644
--- a/tests/integration_tests/security/api_tests.py
+++ b/tests/integration_tests/security/api_tests.py
@@ -15,22 +15,24 @@
# specific language governing permissions and limitations
# under the License.
# isort:skip_file
-"""Unit tests for Superset"""
+"""Tests for security api methods"""
import json
+import jwt
+
from tests.integration_tests.base_tests import SupersetTestCase
from flask_wtf.csrf import generate_csrf
-class TestSecurityApi(SupersetTestCase):
+class TestSecurityCsrfApi(SupersetTestCase):
resource_name = "security"
def _assert_get_csrf_token(self):
uri = f"api/v1/{self.resource_name}/csrf_token/"
response = self.client.get(uri)
- assert response.status_code == 200
+ self.assert200(response)
data = json.loads(response.data.decode("utf-8"))
- assert data["result"] == generate_csrf()
+ self.assertEqual(generate_csrf(), data["result"])
def test_get_csrf_token(self):
"""
@@ -53,4 +55,41 @@ class TestSecurityApi(SupersetTestCase):
self.logout()
uri = f"api/v1/{self.resource_name}/csrf_token/"
response = self.client.get(uri)
- self.assertEqual(response.status_code, 401)
+ self.assert401(response)
+
+
+class TestSecurityGuestTokenApi(SupersetTestCase):
+ uri = f"api/v1/security/guest_token/"
+
+ def test_post_guest_token_unauthenticated(self):
+ """
+ Security API: Cannot create a guest token without authentication
+ """
+ self.logout()
+ response = self.client.post(self.uri)
+ self.assert401(response)
+
+ def test_post_guest_token_unauthorized(self):
+ """
+ Security API: Cannot create a guest token without authorization
+ """
+ self.login(username="gamma")
+ response = self.client.post(self.uri)
+ self.assert403(response)
+
+ def test_post_guest_token_authorized(self):
+ self.login(username="admin")
+ user = {"username": "bob", "first_name": "Bob", "last_name": "Also Bob"}
+ resource = {"type": "dashboard", "id": "blah"}
+ rls_rule = {"dataset": 1, "clause": "1=1"}
+ params = {"user": user, "resources": [resource], "rls": [rls_rule]}
+
+ response = self.client.post(
+ self.uri, data=json.dumps(params), content_type="application/json"
+ )
+
+ self.assert200(response)
+ token = json.loads(response.data)["token"]
+ decoded_token = jwt.decode(token, self.app.config["GUEST_TOKEN_JWT_SECRET"])
+ self.assertEqual(user, decoded_token["user"])
+ self.assertEqual(resource, decoded_token["resources"][0])
diff --git a/tests/integration_tests/security/guest_token_security_tests.py b/tests/integration_tests/security/guest_token_security_tests.py
new file mode 100644
index 0000000..9ca3419
--- /dev/null
+++ b/tests/integration_tests/security/guest_token_security_tests.py
@@ -0,0 +1,210 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit tests for Superset"""
+from unittest import mock
+
+import pytest
+from flask import g
+
+from superset import db, security_manager
+from superset.dashboards.commands.exceptions import DashboardAccessDeniedError
+from superset.exceptions import SupersetSecurityException
+from superset.models.dashboard import Dashboard
+from superset.security.guest_token import GuestTokenResourceType
+from superset.sql_parse import Table
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.fixtures.birth_names_dashboard import (
+ load_birth_names_dashboard_with_slices,
+ load_birth_names_data,
+)
+
+
+@mock.patch.dict(
+ "superset.extensions.feature_flag_manager._feature_flags", EMBEDDED_SUPERSET=True,
+)
+class TestGuestUserSecurity(SupersetTestCase):
+ # This test doesn't use a dashboard fixture, the next test does.
+ # That way tests are faster.
+
+ resource_id = 42
+
+ def authorized_guest(self):
+ return security_manager.get_guest_user_from_token(
+ {"user": {}, "resources": [{"type": "dashboard", "id": self.resource_id}]}
+ )
+
+ def test_is_guest_user__regular_user(self):
+ is_guest = security_manager.is_guest_user(security_manager.find_user("admin"))
+ self.assertFalse(is_guest)
+
+ def test_is_guest_user__anonymous(self):
+ is_guest = security_manager.is_guest_user(security_manager.get_anonymous_user())
+ self.assertFalse(is_guest)
+
+ def test_is_guest_user__guest_user(self):
+ is_guest = security_manager.is_guest_user(self.authorized_guest())
+ self.assertTrue(is_guest)
+
+ @mock.patch.dict(
+ "superset.extensions.feature_flag_manager._feature_flags",
+ EMBEDDED_SUPERSET=False,
+ )
+ def test_is_guest_user__flag_off(self):
+ is_guest = security_manager.is_guest_user(self.authorized_guest())
+ self.assertFalse(is_guest)
+
+ def test_get_guest_user__regular_user(self):
+ g.user = security_manager.find_user("admin")
+ guest_user = security_manager.get_current_guest_user_if_guest()
+ self.assertIsNone(guest_user)
+
+ def test_get_guest_user__anonymous_user(self):
+ g.user = security_manager.get_anonymous_user()
+ guest_user = security_manager.get_current_guest_user_if_guest()
+ self.assertIsNone(guest_user)
+
+ def test_get_guest_user__guest_user(self):
+ g.user = self.authorized_guest()
+ guest_user = security_manager.get_current_guest_user_if_guest()
+ self.assertEqual(guest_user, g.user)
+
+ def test_has_guest_access__regular_user(self):
+ g.user = security_manager.find_user("admin")
+ has_guest_access = security_manager.has_guest_access(
+ GuestTokenResourceType.DASHBOARD, self.resource_id
+ )
+ self.assertFalse(has_guest_access)
+
+ def test_has_guest_access__anonymous_user(self):
+ g.user = security_manager.get_anonymous_user()
+ has_guest_access = security_manager.has_guest_access(
+ GuestTokenResourceType.DASHBOARD, self.resource_id
+ )
+ self.assertFalse(has_guest_access)
+
+ def test_has_guest_access__authorized_guest_user(self):
+ g.user = self.authorized_guest()
+ has_guest_access = security_manager.has_guest_access(
+ GuestTokenResourceType.DASHBOARD, self.resource_id
+ )
+ self.assertTrue(has_guest_access)
+
+ def test_has_guest_access__authorized_guest_user__non_zero_resource_index(self):
+ guest = self.authorized_guest()
+ guest.resources = [
+ {"type": "dashboard", "id": self.resource_id - 1}
+ ] + guest.resources
+ g.user = guest
+
+ has_guest_access = security_manager.has_guest_access(
+ GuestTokenResourceType.DASHBOARD, self.resource_id
+ )
+ self.assertTrue(has_guest_access)
+
+ def test_has_guest_access__unauthorized_guest_user__different_resource_id(self):
+ g.user = security_manager.get_guest_user_from_token(
+ {
+ "user": {},
+ "resources": [{"type": "dashboard", "id": self.resource_id - 1}],
+ }
+ )
+ has_guest_access = security_manager.has_guest_access(
+ GuestTokenResourceType.DASHBOARD, self.resource_id
+ )
+ self.assertFalse(has_guest_access)
+
+ def test_has_guest_access__unauthorized_guest_user__different_resource_type(self):
+ g.user = security_manager.get_guest_user_from_token(
+ {"user": {}, "resources": [{"type": "dirt", "id": self.resource_id}]}
+ )
+ has_guest_access = security_manager.has_guest_access(
+ GuestTokenResourceType.DASHBOARD, self.resource_id
+ )
+ self.assertFalse(has_guest_access)
+
+ def test_get_guest_user_roles_explicit(self):
+ guest = self.authorized_guest()
+ roles = security_manager.get_user_roles(guest)
+ self.assertEqual(guest.roles, roles)
+
+ def test_get_guest_user_roles_implicit(self):
+ guest = self.authorized_guest()
+ g.user = guest
+
+ roles = security_manager.get_user_roles()
+ self.assertEqual(guest.roles, roles)
+
+
+@mock.patch.dict(
+ "superset.extensions.feature_flag_manager._feature_flags", EMBEDDED_SUPERSET=True,
+)
+@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+class TestGuestUserDashboardAccess(SupersetTestCase):
+ def setUp(self) -> None:
+ self.dash = db.session.query(Dashboard).filter_by(slug="births").first()
+ self.authorized_guest = security_manager.get_guest_user_from_token(
+ {"user": {}, "resources": [{"type": "dashboard", "id": self.dash.id}]}
+ )
+ self.unauthorized_guest = security_manager.get_guest_user_from_token(
+ {"user": {}, "resources": [{"type": "dashboard", "id": self.dash.id + 1}]}
+ )
+
+ def test_chart_raise_for_access_as_guest(self):
+ chart = self.dash.slices[0]
+ g.user = self.authorized_guest
+
+ security_manager.raise_for_access(viz=chart)
+
+ def test_chart_raise_for_access_as_unauthorized_guest(self):
+ chart = self.dash.slices[0]
+ g.user = self.unauthorized_guest
+
+ with self.assertRaises(SupersetSecurityException):
+ security_manager.raise_for_access(viz=chart)
+
+ def test_dataset_raise_for_access_as_guest(self):
+ dataset = self.dash.slices[0].datasource
+ g.user = self.authorized_guest
+
+ security_manager.raise_for_access(datasource=dataset)
+
+ def test_dataset_raise_for_access_as_unauthorized_guest(self):
+ dataset = self.dash.slices[0].datasource
+ g.user = self.unauthorized_guest
+
+ with self.assertRaises(SupersetSecurityException):
+ security_manager.raise_for_access(datasource=dataset)
+
+ def test_guest_token_does_not_grant_access_to_underlying_table(self):
+ sqla_table = self.dash.slices[0].table
+ table = Table(table=sqla_table.table_name)
+
+ g.user = self.authorized_guest
+
+ with self.assertRaises(Exception):
+ security_manager.raise_for_access(table=table, database=sqla_table.database)
+
+ def test_raise_for_dashboard_access_as_guest(self):
+ g.user = self.authorized_guest
+
+ security_manager.raise_for_dashboard_access(self.dash)
+
+ def test_raise_for_dashboard_access_as_unauthorized_guest(self):
+ g.user = self.unauthorized_guest
+
+ with self.assertRaises(DashboardAccessDeniedError):
+ security_manager.raise_for_dashboard_access(self.dash)
diff --git a/tests/integration_tests/security/row_level_security_tests.py b/tests/integration_tests/security/row_level_security_tests.py
new file mode 100644
index 0000000..06610bb
--- /dev/null
+++ b/tests/integration_tests/security/row_level_security_tests.py
@@ -0,0 +1,316 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# isort:skip_file
+import re
+from typing import Any, Dict, List, Optional
+from unittest import mock
+
+import pytest
+from flask import g
+
+from superset import db, security_manager
+from superset.connectors.sqla.models import RowLevelSecurityFilter, SqlaTable
+from superset.security.guest_token import (
+ GuestTokenRlsRule,
+ GuestTokenResourceType,
+ GuestUser,
+)
+from ..base_tests import SupersetTestCase
+from tests.integration_tests.fixtures.birth_names_dashboard import (
+ load_birth_names_dashboard_with_slices,
+ load_birth_names_data,
+)
+from tests.integration_tests.fixtures.energy_dashboard import (
+ load_energy_table_with_slice,
+ load_energy_table_data,
+)
+from tests.integration_tests.fixtures.unicode_dashboard import (
+ load_unicode_dashboard_with_slice,
+ load_unicode_data,
+)
+
+
+class TestRowLevelSecurity(SupersetTestCase):
+ """
+ Testing Row Level Security
+ """
+
+ rls_entry = None
+ query_obj: Dict[str, Any] = dict(
+ groupby=[],
+ metrics=None,
+ filter=[],
+ is_timeseries=False,
+ columns=["value"],
+ granularity=None,
+ from_dttm=None,
+ to_dttm=None,
+ extras={},
+ )
+ NAME_AB_ROLE = "NameAB"
+ NAME_Q_ROLE = "NameQ"
+ NAMES_A_REGEX = re.compile(r"name like 'A%'")
+ NAMES_B_REGEX = re.compile(r"name like 'B%'")
+ NAMES_Q_REGEX = re.compile(r"name like 'Q%'")
+ BASE_FILTER_REGEX = re.compile(r"gender = 'boy'")
+
+ def setUp(self):
+ session = db.session
+
+ # Create roles
+ self.role_ab = security_manager.add_role(self.NAME_AB_ROLE)
+ self.role_q = security_manager.add_role(self.NAME_Q_ROLE)
+ gamma_user = security_manager.find_user(username="gamma")
+ gamma_user.roles.append(self.role_ab)
+ gamma_user.roles.append(self.role_q)
+ self.create_user_with_roles("NoRlsRoleUser", ["Gamma"])
+ session.commit()
+
+ # Create regular RowLevelSecurityFilter (energy_usage, unicode_test)
+ self.rls_entry1 = RowLevelSecurityFilter()
+ self.rls_entry1.tables.extend(
+ session.query(SqlaTable)
+ .filter(SqlaTable.table_name.in_(["energy_usage", "unicode_test"]))
+ .all()
+ )
+ self.rls_entry1.filter_type = "Regular"
+ self.rls_entry1.clause = "value > {{ cache_key_wrapper(1) }}"
+ self.rls_entry1.group_key = None
+ self.rls_entry1.roles.append(security_manager.find_role("Gamma"))
+ self.rls_entry1.roles.append(security_manager.find_role("Alpha"))
+ db.session.add(self.rls_entry1)
+
+ # Create regular RowLevelSecurityFilter (birth_names name starts with A or B)
+ self.rls_entry2 = RowLevelSecurityFilter()
+ self.rls_entry2.tables.extend(
+ session.query(SqlaTable)
+ .filter(SqlaTable.table_name.in_(["birth_names"]))
+ .all()
+ )
+ self.rls_entry2.filter_type = "Regular"
+ self.rls_entry2.clause = "name like 'A%' or name like 'B%'"
+ self.rls_entry2.group_key = "name"
+ self.rls_entry2.roles.append(security_manager.find_role("NameAB"))
+ db.session.add(self.rls_entry2)
+
+ # Create Regular RowLevelSecurityFilter (birth_names name starts with Q)
+ self.rls_entry3 = RowLevelSecurityFilter()
+ self.rls_entry3.tables.extend(
+ session.query(SqlaTable)
+ .filter(SqlaTable.table_name.in_(["birth_names"]))
+ .all()
+ )
+ self.rls_entry3.filter_type = "Regular"
+ self.rls_entry3.clause = "name like 'Q%'"
+ self.rls_entry3.group_key = "name"
+ self.rls_entry3.roles.append(security_manager.find_role("NameQ"))
+ db.session.add(self.rls_entry3)
+
+ # Create Base RowLevelSecurityFilter (birth_names boys)
+ self.rls_entry4 = RowLevelSecurityFilter()
+ self.rls_entry4.tables.extend(
+ session.query(SqlaTable)
+ .filter(SqlaTable.table_name.in_(["birth_names"]))
+ .all()
+ )
+ self.rls_entry4.filter_type = "Base"
+ self.rls_entry4.clause = "gender = 'boy'"
+ self.rls_entry4.group_key = "gender"
+ self.rls_entry4.roles.append(security_manager.find_role("Admin"))
+ db.session.add(self.rls_entry4)
+
+ db.session.commit()
+
+ def tearDown(self):
+ session = db.session
+ session.delete(self.rls_entry1)
+ session.delete(self.rls_entry2)
+ session.delete(self.rls_entry3)
+ session.delete(self.rls_entry4)
+ session.delete(security_manager.find_role("NameAB"))
+ session.delete(security_manager.find_role("NameQ"))
+ session.delete(self.get_user("NoRlsRoleUser"))
+ session.commit()
+
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def test_rls_filter_alters_energy_query(self):
+ g.user = self.get_user(username="alpha")
+ tbl = self.get_table(name="energy_usage")
+ sql = tbl.get_query_str(self.query_obj)
+ assert tbl.get_extra_cache_keys(self.query_obj) == [1]
+ assert "value > 1" in sql
+
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def test_rls_filter_doesnt_alter_energy_query(self):
+ g.user = self.get_user(
+ username="admin"
+ ) # self.login() doesn't actually set the user
+ tbl = self.get_table(name="energy_usage")
+ sql = tbl.get_query_str(self.query_obj)
+ assert tbl.get_extra_cache_keys(self.query_obj) == []
+ assert "value > 1" not in sql
+
+ @pytest.mark.usefixtures("load_unicode_dashboard_with_slice")
+ def test_multiple_table_filter_alters_another_tables_query(self):
+ g.user = self.get_user(
+ username="alpha"
+ ) # self.login() doesn't actually set the user
+ tbl = self.get_table(name="unicode_test")
+ sql = tbl.get_query_str(self.query_obj)
+ assert tbl.get_extra_cache_keys(self.query_obj) == [1]
+ assert "value > 1" in sql
+
+ @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+ def test_rls_filter_alters_gamma_birth_names_query(self):
+ g.user = self.get_user(username="gamma")
+ tbl = self.get_table(name="birth_names")
+ sql = tbl.get_query_str(self.query_obj)
+
+ # establish that the filters are grouped together correctly with
+ # ANDs, ORs and parens in the correct place
+ assert (
+ "WHERE ((name like 'A%'\n or name like 'B%')\n OR (name like 'Q%'))\n AND (gender = 'boy');"
+ in sql
+ )
+
+ @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+ def test_rls_filter_alters_no_role_user_birth_names_query(self):
+ g.user = self.get_user(username="NoRlsRoleUser")
+ tbl = self.get_table(name="birth_names")
+ sql = tbl.get_query_str(self.query_obj)
+
+ # gamma's filters should not be present query
+ assert not self.NAMES_A_REGEX.search(sql)
+ assert not self.NAMES_B_REGEX.search(sql)
+ assert not self.NAMES_Q_REGEX.search(sql)
+ # base query should be present
+ assert self.BASE_FILTER_REGEX.search(sql)
+
+ @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+ def test_rls_filter_doesnt_alter_admin_birth_names_query(self):
+ g.user = self.get_user(username="admin")
+ tbl = self.get_table(name="birth_names")
+ sql = tbl.get_query_str(self.query_obj)
+
+ # no filters are applied for admin user
+ assert not self.NAMES_A_REGEX.search(sql)
+ assert not self.NAMES_B_REGEX.search(sql)
+ assert not self.NAMES_Q_REGEX.search(sql)
+ assert not self.BASE_FILTER_REGEX.search(sql)
+
+
+RLS_ALICE_REGEX = re.compile(r"name = 'Alice'")
+RLS_GENDER_REGEX = re.compile(r"AND \(gender = 'girl'\)")
+
+
+@mock.patch.dict(
+ "superset.extensions.feature_flag_manager._feature_flags", EMBEDDED_SUPERSET=True,
+)
+class GuestTokenRowLevelSecurityTests(SupersetTestCase):
+ query_obj: Dict[str, Any] = dict(
+ groupby=[],
+ metrics=None,
+ filter=[],
+ is_timeseries=False,
+ columns=["value"],
+ granularity=None,
+ from_dttm=None,
+ to_dttm=None,
+ extras={},
+ )
+
+ def default_rls_rule(self):
+ return {
+ "dataset": self.get_table(name="birth_names").id,
+ "clause": "name = 'Alice'",
+ }
+
+ def guest_user_with_rls(self, rules: Optional[List[Any]] = None) -> GuestUser:
+ if rules is None:
+ rules = [self.default_rls_rule()]
+ return security_manager.get_guest_user_from_token(
+ {
+ "user": {},
+ "resources": [{"type": GuestTokenResourceType.DASHBOARD.value}],
+ "rls_rules": rules,
+ }
+ )
+
+ @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+ def test_rls_filter_alters_query(self):
+ g.user = self.guest_user_with_rls()
+ tbl = self.get_table(name="birth_names")
+ sql = tbl.get_query_str(self.query_obj)
+
+ self.assertRegexpMatches(sql, RLS_ALICE_REGEX)
+
+ @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+ def test_rls_filter_does_not_alter_unrelated_query(self):
+ g.user = self.guest_user_with_rls(
+ rules=[
+ {
+ "dataset": self.get_table(name="birth_names").id + 1,
+ "clause": "name = 'Alice'",
+ }
+ ]
+ )
+ tbl = self.get_table(name="birth_names")
+ sql = tbl.get_query_str(self.query_obj)
+
+ self.assertNotRegexpMatches(sql, RLS_ALICE_REGEX)
+
+ @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+ def test_multiple_rls_filters_are_unionized(self):
+ g.user = self.guest_user_with_rls(
+ rules=[
+ self.default_rls_rule(),
+ {
+ "dataset": self.get_table(name="birth_names").id,
+ "clause": "gender = 'girl'",
+ },
+ ]
+ )
+ tbl = self.get_table(name="birth_names")
+ sql = tbl.get_query_str(self.query_obj)
+
+ self.assertRegexpMatches(sql, RLS_ALICE_REGEX)
+ self.assertRegexpMatches(sql, RLS_GENDER_REGEX)
+
+ @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+ @pytest.mark.usefixtures("load_energy_table_with_slice")
+ def test_rls_filter_for_all_datasets(self):
+ births = self.get_table(name="birth_names")
+ energy = self.get_table(name="energy_usage")
+ guest = self.guest_user_with_rls(rules=[{"clause": "name = 'Alice'"}])
+ guest.resources.append({type: "dashboard", id: energy.id})
+ g.user = guest
+ births_sql = births.get_query_str(self.query_obj)
+ energy_sql = energy.get_query_str(self.query_obj)
+
+ self.assertRegexpMatches(births_sql, RLS_ALICE_REGEX)
+ self.assertRegexpMatches(energy_sql, RLS_ALICE_REGEX)
+
+ @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+ def test_dataset_id_can_be_string(self):
+ dataset = self.get_table(name="birth_names")
+ str_id = str(dataset.id)
+ g.user = self.guest_user_with_rls(
+ rules=[{"dataset": str_id, "clause": "name = 'Alice'"}]
+ )
+ sql = dataset.get_query_str(self.query_obj)
+
+ self.assertRegexpMatches(sql, RLS_ALICE_REGEX)
diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py
index ab4450b..46ca679 100644
--- a/tests/integration_tests/security_tests.py
+++ b/tests/integration_tests/security_tests.py
@@ -16,23 +16,24 @@
# under the License.
# isort:skip_file
import inspect
-import re
+import time
import unittest
from collections import namedtuple
from unittest import mock
from unittest.mock import Mock, patch
-from typing import Any, Dict
+from typing import Any
+import jwt
import prison
import pytest
-from flask import current_app, g
+from flask import current_app
from superset.models.dashboard import Dashboard
from superset import app, appbuilder, db, security_manager, viz, ConnectorRegistry
from superset.connectors.druid.models import DruidCluster, DruidDatasource
-from superset.connectors.sqla.models import RowLevelSecurityFilter, SqlaTable
+from superset.connectors.sqla.models import SqlaTable
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.models.core import Database
@@ -46,22 +47,10 @@ from superset.utils.database import get_example_database
from superset.views.access_requests import AccessRequestsModelView
from .base_tests import SupersetTestCase
-from tests.integration_tests.fixtures.birth_names_dashboard import (
- load_birth_names_dashboard_with_slices,
- load_birth_names_data,
-)
-from tests.integration_tests.fixtures.energy_dashboard import (
- load_energy_table_with_slice,
- load_energy_table_data,
-)
from tests.integration_tests.fixtures.public_role import (
public_role_like_gamma,
public_role_like_test_role,
)
-from tests.integration_tests.fixtures.unicode_dashboard import (
- load_unicode_dashboard_with_slice,
- load_unicode_data,
-)
from tests.integration_tests.fixtures.world_bank_dashboard import (
load_world_bank_dashboard_with_slices,
load_world_bank_data,
@@ -917,6 +906,7 @@ class TestRolePermission(SupersetTestCase):
["LocaleView", "index"],
["AuthDBView", "login"],
["AuthDBView", "logout"],
+ ["Dashboard", "embedded"],
["R", "index"],
["Superset", "log"],
["Superset", "theme"],
@@ -975,9 +965,7 @@ class TestSecurityManager(SupersetTestCase):
mock_raise_for_access.side_effect = SupersetSecurityException(
SupersetError(
- "dummy",
- SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR,
- ErrorLevel.ERROR,
+ "dummy", SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR, ErrorLevel.ERROR
)
)
@@ -1054,174 +1042,18 @@ class TestSecurityManager(SupersetTestCase):
with self.assertRaises(SupersetSecurityException):
security_manager.raise_for_access(viz=test_viz)
+ @patch("superset.security.manager.g")
+ def test_get_user_roles(self, mock_g):
+ admin = security_manager.find_user("admin")
+ mock_g.user = admin
+ roles = security_manager.get_user_roles()
+ self.assertEqual(admin.roles, roles)
-class TestRowLevelSecurity(SupersetTestCase):
- """
- Testing Row Level Security
- """
-
- rls_entry = None
- query_obj: Dict[str, Any] = dict(
- groupby=[],
- metrics=None,
- filter=[],
- is_timeseries=False,
- columns=["value"],
- granularity=None,
- from_dttm=None,
- to_dttm=None,
- extras={},
- )
- NAME_AB_ROLE = "NameAB"
- NAME_Q_ROLE = "NameQ"
- NAMES_A_REGEX = re.compile(r"name like 'A%'")
- NAMES_B_REGEX = re.compile(r"name like 'B%'")
- NAMES_Q_REGEX = re.compile(r"name like 'Q%'")
- BASE_FILTER_REGEX = re.compile(r"gender = 'boy'")
-
- def setUp(self):
- session = db.session
-
- # Create roles
- security_manager.add_role(self.NAME_AB_ROLE)
- security_manager.add_role(self.NAME_Q_ROLE)
- gamma_user = security_manager.find_user(username="gamma")
- gamma_user.roles.append(security_manager.find_role(self.NAME_AB_ROLE))
- gamma_user.roles.append(security_manager.find_role(self.NAME_Q_ROLE))
- self.create_user_with_roles("NoRlsRoleUser", ["Gamma"])
- session.commit()
-
- # Create regular RowLevelSecurityFilter (energy_usage, unicode_test)
- self.rls_entry1 = RowLevelSecurityFilter()
- self.rls_entry1.tables.extend(
- session.query(SqlaTable)
- .filter(SqlaTable.table_name.in_(["energy_usage", "unicode_test"]))
- .all()
- )
- self.rls_entry1.filter_type = "Regular"
- self.rls_entry1.clause = "value > {{ cache_key_wrapper(1) }}"
- self.rls_entry1.group_key = None
- self.rls_entry1.roles.append(security_manager.find_role("Gamma"))
- self.rls_entry1.roles.append(security_manager.find_role("Alpha"))
- db.session.add(self.rls_entry1)
-
- # Create regular RowLevelSecurityFilter (birth_names name starts with A or B)
- self.rls_entry2 = RowLevelSecurityFilter()
- self.rls_entry2.tables.extend(
- session.query(SqlaTable)
- .filter(SqlaTable.table_name.in_(["birth_names"]))
- .all()
- )
- self.rls_entry2.filter_type = "Regular"
- self.rls_entry2.clause = "name like 'A%' or name like 'B%'"
- self.rls_entry2.group_key = "name"
- self.rls_entry2.roles.append(security_manager.find_role("NameAB"))
- db.session.add(self.rls_entry2)
-
- # Create Regular RowLevelSecurityFilter (birth_names name starts with Q)
- self.rls_entry3 = RowLevelSecurityFilter()
- self.rls_entry3.tables.extend(
- session.query(SqlaTable)
- .filter(SqlaTable.table_name.in_(["birth_names"]))
- .all()
- )
- self.rls_entry3.filter_type = "Regular"
- self.rls_entry3.clause = "name like 'Q%'"
- self.rls_entry3.group_key = "name"
- self.rls_entry3.roles.append(security_manager.find_role("NameQ"))
- db.session.add(self.rls_entry3)
-
- # Create Base RowLevelSecurityFilter (birth_names boys)
- self.rls_entry4 = RowLevelSecurityFilter()
- self.rls_entry4.tables.extend(
- session.query(SqlaTable)
- .filter(SqlaTable.table_name.in_(["birth_names"]))
- .all()
- )
- self.rls_entry4.filter_type = "Base"
- self.rls_entry4.clause = "gender = 'boy'"
- self.rls_entry4.group_key = "gender"
- self.rls_entry4.roles.append(security_manager.find_role("Admin"))
- db.session.add(self.rls_entry4)
-
- db.session.commit()
-
- def tearDown(self):
- session = db.session
- session.delete(self.rls_entry1)
- session.delete(self.rls_entry2)
- session.delete(self.rls_entry3)
- session.delete(self.rls_entry4)
- session.delete(security_manager.find_role("NameAB"))
- session.delete(security_manager.find_role("NameQ"))
- session.delete(self.get_user("NoRlsRoleUser"))
- session.commit()
-
- @pytest.mark.usefixtures("load_energy_table_with_slice")
- def test_rls_filter_alters_energy_query(self):
- g.user = self.get_user(username="alpha")
- tbl = self.get_table(name="energy_usage")
- sql = tbl.get_query_str(self.query_obj)
- assert tbl.get_extra_cache_keys(self.query_obj) == [1]
- assert "value > 1" in sql
-
- @pytest.mark.usefixtures("load_energy_table_with_slice")
- def test_rls_filter_doesnt_alter_energy_query(self):
- g.user = self.get_user(
- username="admin"
- ) # self.login() doesn't actually set the user
- tbl = self.get_table(name="energy_usage")
- sql = tbl.get_query_str(self.query_obj)
- assert tbl.get_extra_cache_keys(self.query_obj) == []
- assert "value > 1" not in sql
-
- @pytest.mark.usefixtures("load_unicode_dashboard_with_slice")
- def test_multiple_table_filter_alters_another_tables_query(self):
- g.user = self.get_user(
- username="alpha"
- ) # self.login() doesn't actually set the user
- tbl = self.get_table(name="unicode_test")
- sql = tbl.get_query_str(self.query_obj)
- assert tbl.get_extra_cache_keys(self.query_obj) == [1]
- assert "value > 1" in sql
-
- @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
- def test_rls_filter_alters_gamma_birth_names_query(self):
- g.user = self.get_user(username="gamma")
- tbl = self.get_table(name="birth_names")
- sql = tbl.get_query_str(self.query_obj)
-
- # establish that the filters are grouped together correctly with
- # ANDs, ORs and parens in the correct place
- assert (
- "WHERE ((name like 'A%'\n or name like 'B%')\n OR (name like 'Q%'))\n AND (gender = 'boy');"
- in sql
- )
-
- @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
- def test_rls_filter_alters_no_role_user_birth_names_query(self):
- g.user = self.get_user(username="NoRlsRoleUser")
- tbl = self.get_table(name="birth_names")
- sql = tbl.get_query_str(self.query_obj)
-
- # gamma's filters should not be present query
- assert not self.NAMES_A_REGEX.search(sql)
- assert not self.NAMES_B_REGEX.search(sql)
- assert not self.NAMES_Q_REGEX.search(sql)
- # base query should be present
- assert self.BASE_FILTER_REGEX.search(sql)
-
- @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
- def test_rls_filter_doesnt_alter_admin_birth_names_query(self):
- g.user = self.get_user(username="admin")
- tbl = self.get_table(name="birth_names")
- sql = tbl.get_query_str(self.query_obj)
-
- # no filters are applied for admin user
- assert not self.NAMES_A_REGEX.search(sql)
- assert not self.NAMES_B_REGEX.search(sql)
- assert not self.NAMES_Q_REGEX.search(sql)
- assert not self.BASE_FILTER_REGEX.search(sql)
+ @patch("superset.security.manager.g")
+ def test_get_anonymous_roles(self, mock_g):
+ mock_g.user = security_manager.get_anonymous_user()
+ roles = security_manager.get_user_roles()
+ self.assertEqual([security_manager.get_public_role()], roles)
class TestAccessRequestEndpoints(SupersetTestCase):
@@ -1323,3 +1155,89 @@ class TestDatasources(SupersetTestCase):
Datasource("database1", "schema1", "table1"),
Datasource("database1", "schema1", "table2"),
]
+
+
+class FakeRequest:
+ headers: Any = {}
+
+
+class TestGuestTokens(SupersetTestCase):
+ def create_guest_token(self):
+ user = {"username": "test_guest"}
+ resources = [{"some": "resource"}]
+ rls = [{"dataset": 1, "clause": "access = 1"}]
+ return security_manager.create_guest_access_token(user, resources, rls)
+
+ @patch("superset.security.SupersetSecurityManager._get_current_epoch_time")
+ def test_create_guest_access_token(self, get_time_mock):
+ now = time.time()
+ get_time_mock.return_value = now # so we know what it should =
+
+ user = {"username": "test_guest"}
+ resources = [{"some": "resource"}]
+ rls = [{"dataset": 1, "clause": "access = 1"}]
+ token = security_manager.create_guest_access_token(user, resources, rls)
+
+ # unfortunately we cannot mock time in the jwt lib
+ decoded_token = jwt.decode(
+ token,
+ self.app.config["GUEST_TOKEN_JWT_SECRET"],
+ algorithms=[self.app.config["GUEST_TOKEN_JWT_ALGO"]],
+ )
+
+ self.assertEqual(user, decoded_token["user"])
+ self.assertEqual(resources, decoded_token["resources"])
+ self.assertEqual(now, decoded_token["iat"])
+ self.assertEqual(
+ now + (self.app.config["GUEST_TOKEN_JWT_EXP_SECONDS"] * 1000),
+ decoded_token["exp"],
+ )
+
+ def test_get_guest_user(self):
+ token = self.create_guest_token()
+ fake_request = FakeRequest()
+ fake_request.headers[current_app.config["GUEST_TOKEN_HEADER_NAME"]] = token
+
+ guest_user = security_manager.get_guest_user_from_request(fake_request)
+
+ self.assertIsNotNone(guest_user)
+ self.assertEqual("test_guest", guest_user.username)
+
+ @patch("superset.security.SupersetSecurityManager._get_current_epoch_time")
+ def test_get_guest_user_expired_token(self, get_time_mock):
+ # make a just-expired token
+ get_time_mock.return_value = (
+ time.time() - (self.app.config["GUEST_TOKEN_JWT_EXP_SECONDS"] * 1000) - 1
+ )
+ token = self.create_guest_token()
+ fake_request = FakeRequest()
+ fake_request.headers[current_app.config["GUEST_TOKEN_HEADER_NAME"]] = token
+
+ guest_user = security_manager.get_guest_user_from_request(fake_request)
+
+ self.assertIsNone(guest_user)
+
+ def test_get_guest_user_no_user(self):
+ user = None
+ resources = [{"type": "dashboard", "id": 1}]
+ rls = {}
+ token = security_manager.create_guest_access_token(user, resources, rls)
+ fake_request = FakeRequest()
+ fake_request.headers[current_app.config["GUEST_TOKEN_HEADER_NAME"]] = token
+ guest_user = security_manager.get_guest_user_from_request(fake_request)
+
+ self.assertIsNone(guest_user)
+ self.assertRaisesRegex(ValueError, "Guest token does not contain a user claim")
+
+ def test_get_guest_user_no_resource(self):
+ user = {"username": "test_guest"}
+ resources = []
+ rls = {}
+ token = security_manager.create_guest_access_token(user, resources, rls)
+ fake_request = FakeRequest()
+ fake_request.headers[current_app.config["GUEST_TOKEN_HEADER_NAME"]] = token
+ security_manager.get_guest_user_from_request(fake_request)
+
+ self.assertRaisesRegex(
+ ValueError, "Guest token does not contain a resources claim"
+ )