From 8e3b442decf276bf689222e870c9ede9f1e76aa4 Mon Sep 17 00:00:00 2001 From: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com> Date: Tue, 25 Jan 2022 16:41:32 -0800 Subject: [PATCH] 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 <1858430+suddjian@users.noreply.github.com> * comment out origins checks * move embedded for core to dashboard * pylint * isort Co-authored-by: David Aaron Suddjian Co-authored-by: David Aaron Suddjian <1858430+suddjian@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 * 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 * SupersetClient guest token test * Apply suggestions from code review Co-authored-by: Lily Kuang Co-authored-by: Lily Kuang --- .../src/connection/SupersetClient.ts | 2 +- .../src/connection/SupersetClientClass.ts | 11 + .../superset-ui-core/src/connection/types.ts | 3 +- .../src/utils/featureFlags.ts | 1 + .../connection/SupersetClientClass.test.ts | 20 ++ .../src/components/UiConfigContext/index.tsx | 57 ++++ superset-frontend/src/constants.ts | 4 + .../DashboardBuilder/DashboardBuilder.tsx | 10 +- .../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 +++ .../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(-) create mode 100644 superset-frontend/src/components/UiConfigContext/index.tsx create mode 100644 superset-frontend/src/embedded/index.tsx create mode 100644 superset-frontend/src/views/RootContextProviders.tsx create mode 100644 superset/security/guest_token.py create mode 100644 tests/integration_tests/security/guest_token_security_tests.py create mode 100644 tests/integration_tests/security/row_level_security_tests.py 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 b5820255a33b8..0f4c123ca974a 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 ef52134f31376..39d5022be8a0b 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 3f02f1c61d0c2..b8df5a95be136 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 a55dcfdf19b87..cab4f72741167 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 4b2307c9d4f26..4efd4f43ca685 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 0000000000000..4b77e42015ebc --- /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({ + hideTitle: false, + hideTab: false, + hideNav: false, + hideChartControls: false, +}); + +export const useUiConfig = () => useContext(UiConfigContext); + +export const EmbeddedUiConfigProvider: React.FC = + ({ 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 ( + + {children} + + ); + }; diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts index a57951af12c32..996cd7d98ce41 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 5e7f71b0a181e..bd6e663db68fd 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 = () => { const dispatch = useDispatch(); + const uiConfig = useUiConfig(); + const dashboardLayout = useSelector( state => state.dashboardLayout.present, ); @@ -243,7 +246,9 @@ const DashboardBuilder: FC = () => { 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 = () => {
{!hideDashboardHeader && } {dropIndicatorProps &&
} - {!isReport && topLevelTabs && ( + {!isReport && topLevelTabs && !uiConfig.hideNav && ( = () => { 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 9b42c81224cf4..a92fcc47a6683 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 = ({ formData, }) => { const dispatch = useDispatch(); + const uiConfig = useUiConfig(); // TODO: change to indicator field after it will be implemented const crossFilterValue = useSelector( state => state.dataMask[slice?.slice_id]?.filterState?.value, @@ -157,32 +158,36 @@ const SliceHeader: FC = ({ /> )} - - + {!uiConfig.hideChartControls && ( + + )} + {!uiConfig.hideChartControls && ( + + )} )}
diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx new file mode 100644 index 0000000000000..11a686ff2584b --- /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 = () => ( + + + }> + + + + + + + + +); + +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(, 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 da2fac3f75fec..5380fe269861d 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 11f3f6a1a2864..8802ae47227d1 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('#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 = {}) { + SupersetClient.configure({ + ...getDefaultConfiguration(), + ...customConfig, }) .init() .catch(error => { diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 7bbc40e531aee..4e193bbf776aa 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 ( - - - - - - - {children} - - - - - - - ); + return <>; }; const App = () => ( + diff --git a/superset-frontend/src/views/RootContextProviders.tsx b/superset-frontend/src/views/RootContextProviders.tsx new file mode 100644 index 0000000000000..f40f228bb8c13 --- /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 }) => ( + + + + + + + + {children} + + + + + + + +); diff --git a/superset-frontend/src/views/components/Menu.tsx b/superset-frontend/src/views/components/Menu.tsx index fd998b3b2b437..a29671c902dd9 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('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 17547da5cd2f1..55f0122ad20a1 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 0b06a0ccbe1d5..5d8405e36cf05 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 16d9d6602a2ce..228a9f2e4c983 100644 --- a/superset/config.py +++ b/superset/config.py @@ -204,7 +204,11 @@ def _try_json_readsha(filepath: str, length: int) -> Optional[str]: 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 @@ def _try_json_readsha(filepath: str, length: int) -> Optional[str]: # 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 @@ def SQL_QUERY_MUTATOR( # pylint: disable=invalid-name,unused-argument ) 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 22516934db139..311889b090596 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -963,14 +963,28 @@ def _get_sqla_row_level_filters( :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 96584784f449b..e398af97b744a 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.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 @@ def apply(self, query: Query, value: Any) -> Query: ) ) - 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 @@ def apply(self, query: Query, value: Any) -> Query: 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 5aa51f85f0bc4..b919e29f78ddd 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 @@ def csrf_token(self) -> Response: $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 0000000000000..44b59c1dbbb11 --- /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 9e45e88afcda6..5ca81b2a9546e 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 @@ 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 @@ 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.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 @@ def raise_for_access( 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 @@ def get_user_by_username( 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 @@ def get_rls_filters(self, table: "BaseDatasource") -> List[SqlaQuery]: :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 @@ def get_rls_filters(self, table: "BaseDatasource") -> List[SqlaQuery]: 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 @@ def raise_for_user_activity_access(user_id: int) -> None: ) ) - @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 @@ def raise_for_dashboard_access(dashboard: "Dashboard") -> None: # 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 @@ def can_access_based_on_dashboard(datasource: "BaseDatasource") -> bool: 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 1e38cb2e75be7..6a0312f4f0955 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 d3cb477b9fd9a..4244c66131f28 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -38,7 +38,7 @@ 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 8e115a97ce5de..84ef5eba8a446 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -135,7 +135,6 @@ 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 @@ def publish( # pylint: disable=no-self-use 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 5e03d24d13fd4..99782def38a68 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.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.views.base import ( BaseSupersetView, check_ownership, + common_bootstrap_payload, DeleteMixin, generate_download_headers, SupersetModelView, @@ -133,6 +135,44 @@ def new(self) -> FlaskResponse: # pylint: disable=no-self-use db.session.commit() return redirect(f"/superset/dashboard/{new_dashboard.id}/?edit=true") + @expose("//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 6a81efaa677ca..d7b365985d9b2 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 @@ def test_get_csrf_unauthorized(self): 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 0000000000000..9ca34198dbdf2 --- /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 0000000000000..06610bbd06de8 --- /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 ab4450b04cd6c..46ca679deebc8 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.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 @@ def test_views_are_secured(self): ["LocaleView", "index"], ["AuthDBView", "login"], ["AuthDBView", "logout"], + ["Dashboard", "embedded"], ["R", "index"], ["Superset", "log"], ["Superset", "theme"], @@ -975,9 +965,7 @@ def test_can_access_table(self, mock_raise_for_access): 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 @@ def test_raise_for_access_viz(self, mock_can_access_schema, mock_can_access): 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 @@ def test_get_user_datasources_gamma_with_schema( 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" + )