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 <aasuddjian@gmail.com>
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 <lily@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 <lily@preset.io>

* SupersetClient guest token test

* Apply suggestions from code review

Co-authored-by: Lily Kuang <lily@preset.io>

Co-authored-by: Lily Kuang <lily@preset.io>
This commit is contained in:
David Aaron Suddjian 2022-01-25 16:41:32 -08:00 committed by GitHub
parent 62009773a6
commit 4ad5ad045a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1466 additions and 325 deletions

View File

@ -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),

View File

@ -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 {

View File

@ -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;
}

View File

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

View File

@ -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);

View File

@ -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>
);
};

View File

@ -31,6 +31,10 @@ export const URL_PARAMS = {
name: 'standalone',
type: 'number',
},
uiConfig: {
name: 'uiConfig',
type: 'number',
},
preselectFilters: {
name: 'preselect_filters',
type: 'object',

View File

@ -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,
],
);

View File

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

View File

@ -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');

View File

@ -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') || '{}')
: {};

View File

@ -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 => {

View File

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

View File

@ -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>
);

View File

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

View File

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

View File

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

View File

@ -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.,
#

View File

@ -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,)

View File

@ -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,
)
)

View File

@ -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)

View File

@ -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", [])

View File

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

View File

@ -22,6 +22,6 @@
{% endblock %}
{% block tail_js %}
{{ js_bundle("spa") }}
{{ js_bundle(entry) }}
{% include "tail_js_custom_extra.html" %}
{% endblock %}

View File

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

View File

@ -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(

View File

@ -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"

View File

@ -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])

View File

@ -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)

View File

@ -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)

View File

@ -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"
)