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:
parent
62009773a6
commit
4ad5ad045a
|
|
@ -20,6 +20,7 @@
|
||||||
import SupersetClientClass from './SupersetClientClass';
|
import SupersetClientClass from './SupersetClientClass';
|
||||||
import { SupersetClientInterface } from './types';
|
import { SupersetClientInterface } from './types';
|
||||||
|
|
||||||
|
// this is local to this file, don't expose it
|
||||||
let singletonClient: SupersetClientClass | undefined;
|
let singletonClient: SupersetClientClass | undefined;
|
||||||
|
|
||||||
function getInstance(): SupersetClientClass {
|
function getInstance(): SupersetClientClass {
|
||||||
|
|
@ -39,7 +40,6 @@ const SupersetClient: SupersetClientInterface = {
|
||||||
reset: () => {
|
reset: () => {
|
||||||
singletonClient = undefined;
|
singletonClient = undefined;
|
||||||
},
|
},
|
||||||
getInstance,
|
|
||||||
delete: request => getInstance().delete(request),
|
delete: request => getInstance().delete(request),
|
||||||
get: request => getInstance().get(request),
|
get: request => getInstance().get(request),
|
||||||
init: force => getInstance().init(force),
|
init: force => getInstance().init(force),
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ export default class SupersetClientClass {
|
||||||
|
|
||||||
csrfPromise?: CsrfPromise;
|
csrfPromise?: CsrfPromise;
|
||||||
|
|
||||||
|
guestToken?: string;
|
||||||
|
|
||||||
|
guestTokenHeaderName: string;
|
||||||
|
|
||||||
fetchRetryOptions?: FetchRetryOptions;
|
fetchRetryOptions?: FetchRetryOptions;
|
||||||
|
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
@ -64,6 +68,8 @@ export default class SupersetClientClass {
|
||||||
timeout,
|
timeout,
|
||||||
credentials = undefined,
|
credentials = undefined,
|
||||||
csrfToken = undefined,
|
csrfToken = undefined,
|
||||||
|
guestToken = undefined,
|
||||||
|
guestTokenHeaderName = 'X-GuestToken',
|
||||||
}: ClientConfig = {}) {
|
}: ClientConfig = {}) {
|
||||||
const url = new URL(
|
const url = new URL(
|
||||||
host || protocol
|
host || protocol
|
||||||
|
|
@ -81,6 +87,8 @@ export default class SupersetClientClass {
|
||||||
this.timeout = timeout;
|
this.timeout = timeout;
|
||||||
this.credentials = credentials;
|
this.credentials = credentials;
|
||||||
this.csrfToken = csrfToken;
|
this.csrfToken = csrfToken;
|
||||||
|
this.guestToken = guestToken;
|
||||||
|
this.guestTokenHeaderName = guestTokenHeaderName;
|
||||||
this.fetchRetryOptions = {
|
this.fetchRetryOptions = {
|
||||||
...DEFAULT_FETCH_RETRY_OPTIONS,
|
...DEFAULT_FETCH_RETRY_OPTIONS,
|
||||||
...fetchRetryOptions,
|
...fetchRetryOptions,
|
||||||
|
|
@ -89,6 +97,9 @@ export default class SupersetClientClass {
|
||||||
this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken };
|
this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken };
|
||||||
this.csrfPromise = Promise.resolve(this.csrfToken);
|
this.csrfPromise = Promise.resolve(this.csrfToken);
|
||||||
}
|
}
|
||||||
|
if (guestToken) {
|
||||||
|
this.headers[guestTokenHeaderName] = guestToken;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(force = false): CsrfPromise {
|
async init(force = false): CsrfPromise {
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,8 @@ export interface ClientConfig {
|
||||||
protocol?: Protocol;
|
protocol?: Protocol;
|
||||||
credentials?: Credentials;
|
credentials?: Credentials;
|
||||||
csrfToken?: CsrfToken;
|
csrfToken?: CsrfToken;
|
||||||
|
guestToken?: string;
|
||||||
|
guestTokenHeaderName?: string;
|
||||||
fetchRetryOptions?: FetchRetryOptions;
|
fetchRetryOptions?: FetchRetryOptions;
|
||||||
headers?: Headers;
|
headers?: Headers;
|
||||||
mode?: Mode;
|
mode?: Mode;
|
||||||
|
|
@ -149,7 +151,6 @@ export interface SupersetClientInterface
|
||||||
| 'reAuthenticate'
|
| 'reAuthenticate'
|
||||||
> {
|
> {
|
||||||
configure: (config?: ClientConfig) => SupersetClientClass;
|
configure: (config?: ClientConfig) => SupersetClientClass;
|
||||||
getInstance: (maybeClient?: SupersetClientClass) => SupersetClientClass;
|
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export enum FeatureFlag {
|
||||||
DASHBOARD_CROSS_FILTERS = 'DASHBOARD_CROSS_FILTERS',
|
DASHBOARD_CROSS_FILTERS = 'DASHBOARD_CROSS_FILTERS',
|
||||||
DASHBOARD_NATIVE_FILTERS_SET = 'DASHBOARD_NATIVE_FILTERS_SET',
|
DASHBOARD_NATIVE_FILTERS_SET = 'DASHBOARD_NATIVE_FILTERS_SET',
|
||||||
DASHBOARD_FILTERS_EXPERIMENTAL = 'DASHBOARD_FILTERS_EXPERIMENTAL',
|
DASHBOARD_FILTERS_EXPERIMENTAL = 'DASHBOARD_FILTERS_EXPERIMENTAL',
|
||||||
|
EMBEDDED_SUPERSET = 'EMBEDDED_SUPERSET',
|
||||||
ENABLE_FILTER_BOX_MIGRATION = 'ENABLE_FILTER_BOX_MIGRATION',
|
ENABLE_FILTER_BOX_MIGRATION = 'ENABLE_FILTER_BOX_MIGRATION',
|
||||||
VERSIONED_EXPORT = 'VERSIONED_EXPORT',
|
VERSIONED_EXPORT = 'VERSIONED_EXPORT',
|
||||||
GLOBAL_ASYNC_QUERIES = 'GLOBAL_ASYNC_QUERIES',
|
GLOBAL_ASYNC_QUERIES = 'GLOBAL_ASYNC_QUERIES',
|
||||||
|
|
|
||||||
|
|
@ -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()', () => {
|
describe('.get()', () => {
|
||||||
it('makes a request using url or endpoint', async () => {
|
it('makes a request using url or endpoint', async () => {
|
||||||
expect.assertions(2);
|
expect.assertions(2);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -31,6 +31,10 @@ export const URL_PARAMS = {
|
||||||
name: 'standalone',
|
name: 'standalone',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
},
|
},
|
||||||
|
uiConfig: {
|
||||||
|
name: 'uiConfig',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
preselectFilters: {
|
preselectFilters: {
|
||||||
name: 'preselect_filters',
|
name: 'preselect_filters',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import {
|
||||||
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
|
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
|
||||||
import Loading from 'src/components/Loading';
|
import Loading from 'src/components/Loading';
|
||||||
import { Global } from '@emotion/react';
|
import { Global } from '@emotion/react';
|
||||||
|
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||||
import { shouldFocusTabs, getRootLevelTabsComponent } from './utils';
|
import { shouldFocusTabs, getRootLevelTabsComponent } from './utils';
|
||||||
import DashboardContainer from './DashboardContainer';
|
import DashboardContainer from './DashboardContainer';
|
||||||
import { useNativeFilters } from './state';
|
import { useNativeFilters } from './state';
|
||||||
|
|
@ -199,6 +200,8 @@ const StyledDashboardContent = styled.div<{
|
||||||
|
|
||||||
const DashboardBuilder: FC<DashboardBuilderProps> = () => {
|
const DashboardBuilder: FC<DashboardBuilderProps> = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const uiConfig = useUiConfig();
|
||||||
|
|
||||||
const dashboardLayout = useSelector<RootState, DashboardLayout>(
|
const dashboardLayout = useSelector<RootState, DashboardLayout>(
|
||||||
state => state.dashboardLayout.present,
|
state => state.dashboardLayout.present,
|
||||||
);
|
);
|
||||||
|
|
@ -243,7 +246,9 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
|
||||||
const standaloneMode = getUrlParam(URL_PARAMS.standalone);
|
const standaloneMode = getUrlParam(URL_PARAMS.standalone);
|
||||||
const isReport = standaloneMode === DashboardStandaloneMode.REPORT;
|
const isReport = standaloneMode === DashboardStandaloneMode.REPORT;
|
||||||
const hideDashboardHeader =
|
const hideDashboardHeader =
|
||||||
standaloneMode === DashboardStandaloneMode.HIDE_NAV_AND_TITLE || isReport;
|
uiConfig.hideTitle ||
|
||||||
|
standaloneMode === DashboardStandaloneMode.HIDE_NAV_AND_TITLE ||
|
||||||
|
isReport;
|
||||||
|
|
||||||
const barTopOffset =
|
const barTopOffset =
|
||||||
(hideDashboardHeader ? 0 : HEADER_HEIGHT) +
|
(hideDashboardHeader ? 0 : HEADER_HEIGHT) +
|
||||||
|
|
@ -288,7 +293,7 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
|
||||||
<div>
|
<div>
|
||||||
{!hideDashboardHeader && <DashboardHeader />}
|
{!hideDashboardHeader && <DashboardHeader />}
|
||||||
{dropIndicatorProps && <div {...dropIndicatorProps} />}
|
{dropIndicatorProps && <div {...dropIndicatorProps} />}
|
||||||
{!isReport && topLevelTabs && (
|
{!isReport && topLevelTabs && !uiConfig.hideNav && (
|
||||||
<WithPopoverMenu
|
<WithPopoverMenu
|
||||||
shouldFocus={shouldFocusTabs}
|
shouldFocus={shouldFocusTabs}
|
||||||
menuItems={[
|
menuItems={[
|
||||||
|
|
@ -321,6 +326,7 @@ const DashboardBuilder: FC<DashboardBuilderProps> = () => {
|
||||||
hideDashboardHeader,
|
hideDashboardHeader,
|
||||||
isReport,
|
isReport,
|
||||||
topLevelTabs,
|
topLevelTabs,
|
||||||
|
uiConfig.hideNav,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
*/
|
*/
|
||||||
import React, { FC, useMemo } from 'react';
|
import React, { FC, useMemo } from 'react';
|
||||||
import { styled, t } from '@superset-ui/core';
|
import { styled, t } from '@superset-ui/core';
|
||||||
|
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||||
import { Tooltip } from 'src/components/Tooltip';
|
import { Tooltip } from 'src/components/Tooltip';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import EditableTitle from 'src/components/EditableTitle';
|
import EditableTitle from 'src/components/EditableTitle';
|
||||||
|
|
@ -44,7 +45,6 @@ type SliceHeaderProps = SliceHeaderControlsProps & {
|
||||||
|
|
||||||
const annotationsLoading = t('Annotation layers are still loading.');
|
const annotationsLoading = t('Annotation layers are still loading.');
|
||||||
const annotationsError = t('One ore more annotation layers failed loading.');
|
const annotationsError = t('One ore more annotation layers failed loading.');
|
||||||
|
|
||||||
const CrossFilterIcon = styled(Icons.CursorTarget)`
|
const CrossFilterIcon = styled(Icons.CursorTarget)`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: ${({ theme }) => theme.colors.primary.base};
|
color: ${({ theme }) => theme.colors.primary.base};
|
||||||
|
|
@ -84,6 +84,7 @@ const SliceHeader: FC<SliceHeaderProps> = ({
|
||||||
formData,
|
formData,
|
||||||
}) => {
|
}) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const uiConfig = useUiConfig();
|
||||||
// TODO: change to indicator field after it will be implemented
|
// TODO: change to indicator field after it will be implemented
|
||||||
const crossFilterValue = useSelector<RootState, any>(
|
const crossFilterValue = useSelector<RootState, any>(
|
||||||
state => state.dataMask[slice?.slice_id]?.filterState?.value,
|
state => state.dataMask[slice?.slice_id]?.filterState?.value,
|
||||||
|
|
@ -157,32 +158,36 @@ const SliceHeader: FC<SliceHeaderProps> = ({
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<FiltersBadge chartId={slice.slice_id} />
|
{!uiConfig.hideChartControls && (
|
||||||
<SliceHeaderControls
|
<FiltersBadge chartId={slice.slice_id} />
|
||||||
slice={slice}
|
)}
|
||||||
isCached={isCached}
|
{!uiConfig.hideChartControls && (
|
||||||
isExpanded={isExpanded}
|
<SliceHeaderControls
|
||||||
cachedDttm={cachedDttm}
|
slice={slice}
|
||||||
updatedDttm={updatedDttm}
|
isCached={isCached}
|
||||||
toggleExpandSlice={toggleExpandSlice}
|
isExpanded={isExpanded}
|
||||||
forceRefresh={forceRefresh}
|
cachedDttm={cachedDttm}
|
||||||
logExploreChart={logExploreChart}
|
updatedDttm={updatedDttm}
|
||||||
exploreUrl={exploreUrl}
|
toggleExpandSlice={toggleExpandSlice}
|
||||||
exportCSV={exportCSV}
|
forceRefresh={forceRefresh}
|
||||||
exportFullCSV={exportFullCSV}
|
logExploreChart={logExploreChart}
|
||||||
supersetCanExplore={supersetCanExplore}
|
exploreUrl={exploreUrl}
|
||||||
supersetCanShare={supersetCanShare}
|
exportCSV={exportCSV}
|
||||||
supersetCanCSV={supersetCanCSV}
|
exportFullCSV={exportFullCSV}
|
||||||
sliceCanEdit={sliceCanEdit}
|
supersetCanExplore={supersetCanExplore}
|
||||||
componentId={componentId}
|
supersetCanShare={supersetCanShare}
|
||||||
dashboardId={dashboardId}
|
supersetCanCSV={supersetCanCSV}
|
||||||
addSuccessToast={addSuccessToast}
|
sliceCanEdit={sliceCanEdit}
|
||||||
addDangerToast={addDangerToast}
|
componentId={componentId}
|
||||||
handleToggleFullSize={handleToggleFullSize}
|
dashboardId={dashboardId}
|
||||||
isFullSize={isFullSize}
|
addSuccessToast={addSuccessToast}
|
||||||
chartStatus={chartStatus}
|
addDangerToast={addDangerToast}
|
||||||
formData={formData}
|
handleToggleFullSize={handleToggleFullSize}
|
||||||
/>
|
isFullSize={isFullSize}
|
||||||
|
chartStatus={chartStatus}
|
||||||
|
formData={formData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -30,11 +30,11 @@ if (process.env.WEBPACK_MODE === 'development') {
|
||||||
setHotLoaderConfig({ logLevel: 'debug', trackTailUpdates: false });
|
setHotLoaderConfig({ logLevel: 'debug', trackTailUpdates: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
let bootstrapData: any;
|
// eslint-disable-next-line import/no-mutable-exports
|
||||||
|
export let bootstrapData: any;
|
||||||
// Configure translation
|
// Configure translation
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const root = document.getElementById('app');
|
const root = document.getElementById('app');
|
||||||
|
|
||||||
bootstrapData = root
|
bootstrapData = root
|
||||||
? JSON.parse(root.getAttribute('data-bootstrap') || '{}')
|
? JSON.parse(root.getAttribute('data-bootstrap') || '{}')
|
||||||
: {};
|
: {};
|
||||||
|
|
|
||||||
|
|
@ -16,22 +16,29 @@
|
||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { SupersetClient, logging } from '@superset-ui/core';
|
import { SupersetClient, logging, ClientConfig } from '@superset-ui/core';
|
||||||
import parseCookie from 'src/utils/parseCookie';
|
import parseCookie from 'src/utils/parseCookie';
|
||||||
|
|
||||||
export default function setupClient() {
|
function getDefaultConfiguration(): ClientConfig {
|
||||||
const csrfNode = document.querySelector<HTMLInputElement>('#csrf_token');
|
const csrfNode = document.querySelector<HTMLInputElement>('#csrf_token');
|
||||||
const csrfToken = csrfNode?.value;
|
const csrfToken = csrfNode?.value;
|
||||||
|
|
||||||
// when using flask-jwt-extended csrf is set in cookies
|
// when using flask-jwt-extended csrf is set in cookies
|
||||||
const cookieCSRFToken = parseCookie().csrf_access_token || '';
|
const cookieCSRFToken = parseCookie().csrf_access_token || '';
|
||||||
|
|
||||||
SupersetClient.configure({
|
return {
|
||||||
protocol: ['http:', 'https:'].includes(window?.location?.protocol)
|
protocol: ['http:', 'https:'].includes(window?.location?.protocol)
|
||||||
? (window?.location?.protocol as 'http:' | 'https:')
|
? (window?.location?.protocol as 'http:' | 'https:')
|
||||||
: undefined,
|
: undefined,
|
||||||
host: (window.location && window.location.host) || '',
|
host: (window.location && window.location.host) || '',
|
||||||
csrfToken: csrfToken || cookieCSRFToken,
|
csrfToken: csrfToken || cookieCSRFToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function setupClient(customConfig: Partial<ClientConfig> = {}) {
|
||||||
|
SupersetClient.configure({
|
||||||
|
...getDefaultConfiguration(),
|
||||||
|
...customConfig,
|
||||||
})
|
})
|
||||||
.init()
|
.init()
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|
|
||||||
|
|
@ -18,41 +18,31 @@
|
||||||
*/
|
*/
|
||||||
import React, { Suspense, useEffect } from 'react';
|
import React, { Suspense, useEffect } from 'react';
|
||||||
import { hot } from 'react-hot-loader/root';
|
import { hot } from 'react-hot-loader/root';
|
||||||
import { Provider as ReduxProvider } from 'react-redux';
|
|
||||||
import {
|
import {
|
||||||
BrowserRouter as Router,
|
BrowserRouter as Router,
|
||||||
Switch,
|
Switch,
|
||||||
Route,
|
Route,
|
||||||
useLocation,
|
useLocation,
|
||||||
} from 'react-router-dom';
|
} 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 { initFeatureFlags } from 'src/featureFlags';
|
||||||
import { ThemeProvider } from '@superset-ui/core';
|
|
||||||
import { DynamicPluginProvider } from 'src/components/DynamicPlugins';
|
|
||||||
import ErrorBoundary from 'src/components/ErrorBoundary';
|
import ErrorBoundary from 'src/components/ErrorBoundary';
|
||||||
import Loading from 'src/components/Loading';
|
import Loading from 'src/components/Loading';
|
||||||
import Menu from 'src/views/components/Menu';
|
import Menu from 'src/views/components/Menu';
|
||||||
import FlashProvider from 'src/components/FlashProvider';
|
import { bootstrapData } from 'src/preamble';
|
||||||
import { theme } from 'src/preamble';
|
|
||||||
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
|
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
|
||||||
import setupApp from 'src/setup/setupApp';
|
import setupApp from 'src/setup/setupApp';
|
||||||
import { routes, isFrontendRoute } from 'src/views/routes';
|
import { routes, isFrontendRoute } from 'src/views/routes';
|
||||||
import { Logger } from 'src/logger/LogUtils';
|
import { Logger } from 'src/logger/LogUtils';
|
||||||
import { store } from './store';
|
import { RootContextProviders } from './RootContextProviders';
|
||||||
|
|
||||||
setupApp();
|
setupApp();
|
||||||
|
|
||||||
const container = document.getElementById('app');
|
const user = { ...bootstrapData.user };
|
||||||
const bootstrap = JSON.parse(container?.getAttribute('data-bootstrap') ?? '{}');
|
const menu = { ...bootstrapData.common.menu_data };
|
||||||
const user = { ...bootstrap.user };
|
|
||||||
const menu = { ...bootstrap.common.menu_data };
|
|
||||||
const common = { ...bootstrap.common };
|
|
||||||
let lastLocationPathname: string;
|
let lastLocationPathname: string;
|
||||||
initFeatureFlags(bootstrap.common.feature_flags);
|
initFeatureFlags(bootstrapData.common.feature_flags);
|
||||||
|
|
||||||
const RootContextProviders: React.FC = ({ children }) => {
|
const LocationPathnameLogger = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// reset performance logger timer start point to avoid soft navigation
|
// reset performance logger timer start point to avoid soft navigation
|
||||||
|
|
@ -62,29 +52,12 @@ const RootContextProviders: React.FC = ({ children }) => {
|
||||||
}
|
}
|
||||||
lastLocationPathname = location.pathname;
|
lastLocationPathname = location.pathname;
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
return <></>;
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<Router>
|
<Router>
|
||||||
|
<LocationPathnameLogger />
|
||||||
<RootContextProviders>
|
<RootContextProviders>
|
||||||
<Menu data={menu} isFrontendRoute={isFrontendRoute} />
|
<Menu data={menu} isFrontendRoute={isFrontendRoute} />
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
import { Tooltip } from 'src/components/Tooltip';
|
import { Tooltip } from 'src/components/Tooltip';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Icons from 'src/components/Icons';
|
import Icons from 'src/components/Icons';
|
||||||
|
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||||
import { URL_PARAMS } from 'src/constants';
|
import { URL_PARAMS } from 'src/constants';
|
||||||
import RightMenu from './MenuRight';
|
import RightMenu from './MenuRight';
|
||||||
import { Languages } from './LanguagePicker';
|
import { Languages } from './LanguagePicker';
|
||||||
|
|
@ -182,6 +183,7 @@ export function Menu({
|
||||||
}: MenuProps) {
|
}: MenuProps) {
|
||||||
const [showMenu, setMenu] = useState<MenuMode>('horizontal');
|
const [showMenu, setMenu] = useState<MenuMode>('horizontal');
|
||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
|
const uiConig = useUiConfig();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
|
|
@ -196,7 +198,7 @@ export function Menu({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const standalone = getUrlParam(URL_PARAMS.standalone);
|
const standalone = getUrlParam(URL_PARAMS.standalone);
|
||||||
if (standalone) return <></>;
|
if (standalone || uiConig.hideNav) return <></>;
|
||||||
|
|
||||||
const renderSubMenu = ({
|
const renderSubMenu = ({
|
||||||
label,
|
label,
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,7 @@ const config = {
|
||||||
theme: path.join(APP_DIR, '/src/theme.ts'),
|
theme: path.join(APP_DIR, '/src/theme.ts'),
|
||||||
menu: addPreamble('src/views/menu.tsx'),
|
menu: addPreamble('src/views/menu.tsx'),
|
||||||
spa: addPreamble('/src/views/index.tsx'),
|
spa: addPreamble('/src/views/index.tsx'),
|
||||||
|
embedded: addPreamble('/src/embedded/index.tsx'),
|
||||||
addSlice: addPreamble('/src/addSlice/index.tsx'),
|
addSlice: addPreamble('/src/addSlice/index.tsx'),
|
||||||
explore: addPreamble('/src/explore/index.jsx'),
|
explore: addPreamble('/src/explore/index.jsx'),
|
||||||
sqllab: addPreamble('/src/SqlLab/index.tsx'),
|
sqllab: addPreamble('/src/SqlLab/index.tsx'),
|
||||||
|
|
|
||||||
|
|
@ -16,24 +16,10 @@
|
||||||
# under the License.
|
# under the License.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List, TYPE_CHECKING
|
|
||||||
|
|
||||||
from flask import g
|
|
||||||
|
|
||||||
from superset import conf, security_manager
|
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:
|
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()
|
admin_role = conf.get("AUTH_ROLE_ADMIN").lower()
|
||||||
return admin_role in user_roles
|
return admin_role in user_roles
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,11 @@ QUERY_SEARCH_LIMIT = 1000
|
||||||
WTF_CSRF_ENABLED = True
|
WTF_CSRF_ENABLED = True
|
||||||
|
|
||||||
# Add endpoints that need to be exempt from CSRF protection
|
# 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
|
# Whether to run the web server in debug mode or not
|
||||||
DEBUG = os.environ.get("FLASK_ENV") == "development"
|
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
|
# a custom security config could potentially give access to setting filters on
|
||||||
# tables that users do not have access to.
|
# tables that users do not have access to.
|
||||||
"ROW_LEVEL_SECURITY": True,
|
"ROW_LEVEL_SECURITY": True,
|
||||||
|
"EMBEDDED_SUPERSET": False,
|
||||||
# Enables Alerts and reports new implementation
|
# Enables Alerts and reports new implementation
|
||||||
"ALERT_REPORTS": False,
|
"ALERT_REPORTS": False,
|
||||||
# Enable experimental feature to search for other dashboards
|
# 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/"
|
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
|
# A SQL dataset health check. Note if enabled it is strongly advised that the callable
|
||||||
# be memoized to aid with performance, i.e.,
|
# be memoized to aid with performance, i.e.,
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -963,14 +963,28 @@ class SqlaTable(Model, BaseDatasource): # pylint: disable=too-many-public-metho
|
||||||
:returns: A list of SQL clauses to be ANDed together.
|
:returns: A list of SQL clauses to be ANDed together.
|
||||||
:rtype: List[str]
|
: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:
|
try:
|
||||||
for filter_ in security_manager.get_rls_filters(self):
|
for filter_ in security_manager.get_rls_filters(self):
|
||||||
clause = self.text(
|
clause = self.text(
|
||||||
f"({template_processor.process_template(filter_.clause)})"
|
f"({template_processor.process_template(filter_.clause)})"
|
||||||
)
|
)
|
||||||
filters_grouped[filter_.group_key or filter_.id].append(clause)
|
if filter_.group_key:
|
||||||
return [or_(*clauses) for clauses in filters_grouped.values()]
|
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:
|
except TemplateError as ex:
|
||||||
raise QueryObjectValidationError(
|
raise QueryObjectValidationError(
|
||||||
_("Error in jinja expression in RLS filters: %(msg)s", msg=ex.message,)
|
_("Error in jinja expression in RLS filters: %(msg)s", msg=ex.message,)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
# under the License.
|
# under the License.
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from flask import g
|
||||||
from flask_appbuilder.security.sqla.models import Role
|
from flask_appbuilder.security.sqla.models import Role
|
||||||
from flask_babel import lazy_gettext as _
|
from flask_babel import lazy_gettext as _
|
||||||
from sqlalchemy import and_, or_
|
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.core import FavStar
|
||||||
from superset.models.dashboard import Dashboard
|
from superset.models.dashboard import Dashboard
|
||||||
from superset.models.slice import Slice
|
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
|
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"):
|
if is_feature_enabled("DASHBOARD_RBAC"):
|
||||||
roles_based_query = (
|
roles_based_query = (
|
||||||
db.session.query(Dashboard.id)
|
db.session.query(Dashboard.id)
|
||||||
|
|
@ -121,19 +123,31 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
|
||||||
and_(
|
and_(
|
||||||
Dashboard.published.is_(True),
|
Dashboard.published.is_(True),
|
||||||
dashboard_has_roles,
|
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(
|
query = query.filter(
|
||||||
or_(
|
or_(
|
||||||
Dashboard.id.in_(owner_ids_query),
|
Dashboard.id.in_(owner_ids_query),
|
||||||
Dashboard.id.in_(datasource_perm_query),
|
Dashboard.id.in_(datasource_perm_query),
|
||||||
Dashboard.id.in_(users_favorite_dash_query),
|
Dashboard.id.in_(users_favorite_dash_query),
|
||||||
*dashboard_rbac_or_filters,
|
*feature_flagged_filters,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,64 @@
|
||||||
# specific language governing permissions and limitations
|
# specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
from flask import Response
|
from flask import request, Response
|
||||||
from flask_appbuilder import expose
|
from flask_appbuilder import expose
|
||||||
from flask_appbuilder.api import BaseApi, safe
|
from flask_appbuilder.api import BaseApi, safe
|
||||||
from flask_appbuilder.security.decorators import permission_name, protect
|
from flask_appbuilder.security.decorators import permission_name, protect
|
||||||
from flask_wtf.csrf import generate_csrf
|
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.extensions import event_logger
|
||||||
|
from superset.security.guest_token import GuestTokenResourceType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class SecurityRestApi(BaseApi):
|
||||||
resource_name = "security"
|
resource_name = "security"
|
||||||
allow_browser_login = True
|
allow_browser_login = True
|
||||||
|
|
@ -60,3 +106,49 @@ class SecurityRestApi(BaseApi):
|
||||||
$ref: '#/components/responses/500'
|
$ref: '#/components/responses/500'
|
||||||
"""
|
"""
|
||||||
return self.response(200, result=generate_csrf())
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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", [])
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"""A set of constants and methods to manage permissions and security"""
|
"""A set of constants and methods to manage permissions and security"""
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
|
|
@ -32,7 +33,8 @@ from typing import (
|
||||||
Union,
|
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 import Model
|
||||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||||
from flask_appbuilder.security.sqla.manager import SecurityManager
|
from flask_appbuilder.security.sqla.manager import SecurityManager
|
||||||
|
|
@ -51,7 +53,7 @@ from flask_appbuilder.security.views import (
|
||||||
ViewMenuModelView,
|
ViewMenuModelView,
|
||||||
)
|
)
|
||||||
from flask_appbuilder.widgets import ListWidget
|
from flask_appbuilder.widgets import ListWidget
|
||||||
from flask_login import AnonymousUserMixin
|
from flask_login import AnonymousUserMixin, LoginManager
|
||||||
from sqlalchemy import and_, or_
|
from sqlalchemy import and_, or_
|
||||||
from sqlalchemy.engine.base import Connection
|
from sqlalchemy.engine.base import Connection
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -63,6 +65,14 @@ from superset.connectors.connector_registry import ConnectorRegistry
|
||||||
from superset.constants import RouteMethod
|
from superset.constants import RouteMethod
|
||||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||||
from superset.exceptions import SupersetSecurityException
|
from superset.exceptions import SupersetSecurityException
|
||||||
|
from superset.security.guest_token import (
|
||||||
|
GuestToken,
|
||||||
|
GuestTokenResources,
|
||||||
|
GuestTokenResourceType,
|
||||||
|
GuestTokenRlsRule,
|
||||||
|
GuestTokenUser,
|
||||||
|
GuestUser,
|
||||||
|
)
|
||||||
from superset.utils.core import DatasourceName, RowLevelSecurityFilterType
|
from superset.utils.core import DatasourceName, RowLevelSecurityFilterType
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -172,6 +182,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||||
"can_approve",
|
"can_approve",
|
||||||
"can_update_role",
|
"can_update_role",
|
||||||
"all_query_access",
|
"all_query_access",
|
||||||
|
"can_grant_guest_token",
|
||||||
}
|
}
|
||||||
|
|
||||||
READ_ONLY_PERMISSION = {
|
READ_ONLY_PERMISSION = {
|
||||||
|
|
@ -221,6 +232,17 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||||
"all_query_access",
|
"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
|
def get_schema_perm( # pylint: disable=no-self-use
|
||||||
self, database: Union["Database", str], schema: Optional[str] = None
|
self, database: Union["Database", str], schema: Optional[str] = None
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
|
@ -1047,11 +1069,16 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||||
|
|
||||||
assert datasource
|
assert datasource
|
||||||
|
|
||||||
|
should_check_dashboard_access = (
|
||||||
|
feature_flag_manager.is_feature_enabled("DASHBOARD_RBAC")
|
||||||
|
or self.is_guest_user()
|
||||||
|
)
|
||||||
|
|
||||||
if not (
|
if not (
|
||||||
self.can_access_schema(datasource)
|
self.can_access_schema(datasource)
|
||||||
or self.can_access("datasource_access", datasource.perm or "")
|
or self.can_access("datasource_access", datasource.perm or "")
|
||||||
or (
|
or (
|
||||||
feature_flag_manager.is_feature_enabled("DASHBOARD_RBAC")
|
should_check_dashboard_access
|
||||||
and self.can_access_based_on_dashboard(datasource)
|
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
|
def get_anonymous_user(self) -> User: # pylint: disable=no-self-use
|
||||||
return AnonymousUserMixin()
|
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]:
|
def get_rls_filters(self, table: "BaseDatasource") -> List[SqlaQuery]:
|
||||||
"""
|
"""
|
||||||
Retrieves the appropriate row level security filters for the current user and
|
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
|
:param table: The table to check against
|
||||||
:returns: A list of filters
|
:returns: A list of filters
|
||||||
"""
|
"""
|
||||||
if hasattr(g, "user") and hasattr(g.user, "id"):
|
if hasattr(g, "user"):
|
||||||
# pylint: disable=import-outside-toplevel
|
# pylint: disable=import-outside-toplevel
|
||||||
from superset.connectors.sqla.models import (
|
from superset.connectors.sqla.models import (
|
||||||
RLSFilterRoles,
|
RLSFilterRoles,
|
||||||
|
|
@ -1093,11 +1147,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||||
RowLevelSecurityFilter,
|
RowLevelSecurityFilter,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_roles = (
|
user_roles = [role.id for role in self.get_user_roles()]
|
||||||
self.get_session.query(assoc_user_role.c.role_id)
|
|
||||||
.filter(assoc_user_role.c.user_id == g.user.get_id())
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
regular_filter_roles = (
|
regular_filter_roles = (
|
||||||
self.get_session.query(RLSFilterRoles.c.rls_filter_id)
|
self.get_session.query(RLSFilterRoles.c.rls_filter_id)
|
||||||
.join(RowLevelSecurityFilter)
|
.join(RowLevelSecurityFilter)
|
||||||
|
|
@ -1175,10 +1225,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
def raise_for_dashboard_access(self, dashboard: "Dashboard") -> None:
|
||||||
def raise_for_dashboard_access(dashboard: "Dashboard") -> None:
|
|
||||||
"""
|
"""
|
||||||
Raise an exception if the user cannot access the dashboard.
|
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
|
:param dashboard: Dashboard the user wants access to
|
||||||
:raises DashboardAccessDeniedError: If the user cannot access the resource
|
: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
|
# pylint: disable=import-outside-toplevel
|
||||||
from superset import is_feature_enabled
|
from superset import is_feature_enabled
|
||||||
from superset.dashboards.commands.exceptions import DashboardAccessDeniedError
|
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
|
from superset.views.utils import is_owner
|
||||||
|
|
||||||
has_rbac_access = True
|
def has_rbac_access() -> bool:
|
||||||
|
return (not is_feature_enabled("DASHBOARD_RBAC")) or any(
|
||||||
if is_feature_enabled("DASHBOARD_RBAC"):
|
dashboard_role.id
|
||||||
has_rbac_access = any(
|
in [user_role.id for user_role in self.get_user_roles()]
|
||||||
dashboard_role.id in [user_role.id for user_role in get_user_roles()]
|
|
||||||
for dashboard_role in dashboard.roles
|
for dashboard_role in dashboard.roles
|
||||||
)
|
)
|
||||||
|
|
||||||
can_access = (
|
if self.is_guest_user():
|
||||||
is_user_admin()
|
can_access = self.has_guest_access(
|
||||||
or is_owner(dashboard, g.user)
|
GuestTokenResourceType.DASHBOARD, dashboard.id
|
||||||
or (dashboard.published and has_rbac_access)
|
)
|
||||||
or (not dashboard.published and not dashboard.roles)
|
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:
|
if not can_access:
|
||||||
raise DashboardAccessDeniedError()
|
raise DashboardAccessDeniedError()
|
||||||
|
|
@ -1228,3 +1283,107 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||||
|
|
||||||
exists = db.session.query(query.exists()).scalar()
|
exists = db.session.query(query.exists()).scalar()
|
||||||
return exists
|
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
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,6 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block tail_js %}
|
{% block tail_js %}
|
||||||
{{ js_bundle("spa") }}
|
{{ js_bundle(entry) }}
|
||||||
{% include "tail_js_custom_extra.html" %}
|
{% include "tail_js_custom_extra.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ from flask_appbuilder import BaseView, Model, ModelView
|
||||||
from flask_appbuilder.actions import action
|
from flask_appbuilder.actions import action
|
||||||
from flask_appbuilder.forms import DynamicForm
|
from flask_appbuilder.forms import DynamicForm
|
||||||
from flask_appbuilder.models.sqla.filters import BaseFilter
|
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_appbuilder.widgets import ListWidget
|
||||||
from flask_babel import get_locale, gettext as __, lazy_gettext as _
|
from flask_babel import get_locale, gettext as __, lazy_gettext as _
|
||||||
from flask_jwt_extended.exceptions import NoAuthorizationError
|
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)
|
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:
|
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
|
return "admin" in user_roles
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,6 @@ from superset.views.base import (
|
||||||
data_payload_response,
|
data_payload_response,
|
||||||
generate_download_headers,
|
generate_download_headers,
|
||||||
get_error_msg,
|
get_error_msg,
|
||||||
get_user_roles,
|
|
||||||
handle_api_exception,
|
handle_api_exception,
|
||||||
json_error_response,
|
json_error_response,
|
||||||
json_errors_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
|
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:
|
if not edit_perm:
|
||||||
username = g.user.username if hasattr(g.user, "username") else "user"
|
username = g.user.username if hasattr(g.user, "username") else "user"
|
||||||
return json_error_response(
|
return json_error_response(
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
# under the License.
|
# under the License.
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from typing import List, Union
|
from typing import Callable, List, Union
|
||||||
|
|
||||||
from flask import g, redirect, request, Response
|
from flask import g, redirect, request, Response
|
||||||
from flask_appbuilder import expose
|
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.models.sqla.interface import SQLAInterface
|
||||||
from flask_appbuilder.security.decorators import has_access
|
from flask_appbuilder.security.decorators import has_access
|
||||||
from flask_babel import gettext as __, lazy_gettext as _
|
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.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||||
from superset.models.dashboard import Dashboard as DashboardModel
|
from superset.models.dashboard import Dashboard as DashboardModel
|
||||||
from superset.typing import FlaskResponse
|
from superset.typing import FlaskResponse
|
||||||
|
|
@ -33,6 +34,7 @@ from superset.utils import core as utils
|
||||||
from superset.views.base import (
|
from superset.views.base import (
|
||||||
BaseSupersetView,
|
BaseSupersetView,
|
||||||
check_ownership,
|
check_ownership,
|
||||||
|
common_bootstrap_payload,
|
||||||
DeleteMixin,
|
DeleteMixin,
|
||||||
generate_download_headers,
|
generate_download_headers,
|
||||||
SupersetModelView,
|
SupersetModelView,
|
||||||
|
|
@ -133,6 +135,44 @@ class Dashboard(BaseSupersetView):
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(f"/superset/dashboard/{new_dashboard.id}/?edit=true")
|
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
|
class DashboardModelViewAsync(DashboardModelView): # pylint: disable=too-many-ancestors
|
||||||
route_base = "/dashboardasync"
|
route_base = "/dashboardasync"
|
||||||
|
|
|
||||||
|
|
@ -15,22 +15,24 @@
|
||||||
# specific language governing permissions and limitations
|
# specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
# isort:skip_file
|
# isort:skip_file
|
||||||
"""Unit tests for Superset"""
|
"""Tests for security api methods"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
from tests.integration_tests.base_tests import SupersetTestCase
|
from tests.integration_tests.base_tests import SupersetTestCase
|
||||||
from flask_wtf.csrf import generate_csrf
|
from flask_wtf.csrf import generate_csrf
|
||||||
|
|
||||||
|
|
||||||
class TestSecurityApi(SupersetTestCase):
|
class TestSecurityCsrfApi(SupersetTestCase):
|
||||||
resource_name = "security"
|
resource_name = "security"
|
||||||
|
|
||||||
def _assert_get_csrf_token(self):
|
def _assert_get_csrf_token(self):
|
||||||
uri = f"api/v1/{self.resource_name}/csrf_token/"
|
uri = f"api/v1/{self.resource_name}/csrf_token/"
|
||||||
response = self.client.get(uri)
|
response = self.client.get(uri)
|
||||||
assert response.status_code == 200
|
self.assert200(response)
|
||||||
data = json.loads(response.data.decode("utf-8"))
|
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):
|
def test_get_csrf_token(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -53,4 +55,41 @@ class TestSecurityApi(SupersetTestCase):
|
||||||
self.logout()
|
self.logout()
|
||||||
uri = f"api/v1/{self.resource_name}/csrf_token/"
|
uri = f"api/v1/{self.resource_name}/csrf_token/"
|
||||||
response = self.client.get(uri)
|
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])
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -16,23 +16,24 @@
|
||||||
# under the License.
|
# under the License.
|
||||||
# isort:skip_file
|
# isort:skip_file
|
||||||
import inspect
|
import inspect
|
||||||
import re
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
import prison
|
import prison
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from flask import current_app, g
|
from flask import current_app
|
||||||
|
|
||||||
from superset.models.dashboard import Dashboard
|
from superset.models.dashboard import Dashboard
|
||||||
|
|
||||||
from superset import app, appbuilder, db, security_manager, viz, ConnectorRegistry
|
from superset import app, appbuilder, db, security_manager, viz, ConnectorRegistry
|
||||||
from superset.connectors.druid.models import DruidCluster, DruidDatasource
|
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.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||||
from superset.exceptions import SupersetSecurityException
|
from superset.exceptions import SupersetSecurityException
|
||||||
from superset.models.core import Database
|
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 superset.views.access_requests import AccessRequestsModelView
|
||||||
|
|
||||||
from .base_tests import SupersetTestCase
|
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 (
|
from tests.integration_tests.fixtures.public_role import (
|
||||||
public_role_like_gamma,
|
public_role_like_gamma,
|
||||||
public_role_like_test_role,
|
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 (
|
from tests.integration_tests.fixtures.world_bank_dashboard import (
|
||||||
load_world_bank_dashboard_with_slices,
|
load_world_bank_dashboard_with_slices,
|
||||||
load_world_bank_data,
|
load_world_bank_data,
|
||||||
|
|
@ -917,6 +906,7 @@ class TestRolePermission(SupersetTestCase):
|
||||||
["LocaleView", "index"],
|
["LocaleView", "index"],
|
||||||
["AuthDBView", "login"],
|
["AuthDBView", "login"],
|
||||||
["AuthDBView", "logout"],
|
["AuthDBView", "logout"],
|
||||||
|
["Dashboard", "embedded"],
|
||||||
["R", "index"],
|
["R", "index"],
|
||||||
["Superset", "log"],
|
["Superset", "log"],
|
||||||
["Superset", "theme"],
|
["Superset", "theme"],
|
||||||
|
|
@ -975,9 +965,7 @@ class TestSecurityManager(SupersetTestCase):
|
||||||
|
|
||||||
mock_raise_for_access.side_effect = SupersetSecurityException(
|
mock_raise_for_access.side_effect = SupersetSecurityException(
|
||||||
SupersetError(
|
SupersetError(
|
||||||
"dummy",
|
"dummy", SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR, ErrorLevel.ERROR
|
||||||
SupersetErrorType.TABLE_SECURITY_ACCESS_ERROR,
|
|
||||||
ErrorLevel.ERROR,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1054,174 +1042,18 @@ class TestSecurityManager(SupersetTestCase):
|
||||||
with self.assertRaises(SupersetSecurityException):
|
with self.assertRaises(SupersetSecurityException):
|
||||||
security_manager.raise_for_access(viz=test_viz)
|
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):
|
@patch("superset.security.manager.g")
|
||||||
"""
|
def test_get_anonymous_roles(self, mock_g):
|
||||||
Testing Row Level Security
|
mock_g.user = security_manager.get_anonymous_user()
|
||||||
"""
|
roles = security_manager.get_user_roles()
|
||||||
|
self.assertEqual([security_manager.get_public_role()], roles)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAccessRequestEndpoints(SupersetTestCase):
|
class TestAccessRequestEndpoints(SupersetTestCase):
|
||||||
|
|
@ -1323,3 +1155,89 @@ class TestDatasources(SupersetTestCase):
|
||||||
Datasource("database1", "schema1", "table1"),
|
Datasource("database1", "schema1", "table1"),
|
||||||
Datasource("database1", "schema1", "table2"),
|
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"
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue