Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: get embedded user with roles and permissions #19813

Merged
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const outsiderUser: UserWithPermissionsAndRoles = {

const owner: Owner = {
first_name: 'Test',
id: ownerUser.userId,
id: ownerUser.userId!,
last_name: 'User',
username: ownerUser.username,
};
Expand Down
3 changes: 1 addition & 2 deletions superset-frontend/src/dashboard/util/findPermission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@
*/
import memoizeOne from 'memoize-one';
import {
UserRoles,
isUserWithPermissionsAndRoles,
UndefinedUser,
UserWithPermissionsAndRoles,
} from 'src/types/bootstrapTypes';
import Dashboard from 'src/types/Dashboard';

type UserRoles = Record<string, [string, string][]>;

const findPermission = memoizeOne(
(perm: string, view: string, roles?: UserRoles | null) =>
!!roles &&
Expand Down
43 changes: 38 additions & 5 deletions superset-frontend/src/embedded/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,17 @@
import React, { lazy, Suspense } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { t } from '@superset-ui/core';
import { makeApi, t } from '@superset-ui/core';
import { Switchboard } from '@superset-ui/switchboard';
import { bootstrapData } from 'src/preamble';
import setupClient from 'src/setup/setupClient';
import { RootContextProviders } from 'src/views/RootContextProviders';
import { store } from 'src/views/store';
import { store, USER_LOADED } from 'src/views/store';
import ErrorBoundary from 'src/components/ErrorBoundary';
import Loading from 'src/components/Loading';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';

const debugMode = process.env.WEBPACK_MODE === 'development';

Expand Down Expand Up @@ -69,8 +70,13 @@ const appMountPoint = document.getElementById('app')!;
const MESSAGE_TYPE = '__embedded_comms__';

if (!window.parent || window.parent === window) {
appMountPoint.innerHTML =
'This page is intended to be embedded in an iframe, but it looks like that is not the case.';
showFailureMessage(
'This page is intended to be embedded in an iframe, but it looks like that is not the case.',
);
}

function showFailureMessage(message: string) {
appMountPoint.innerHTML = message;
}

// if the page is embedded in an origin that hasn't
Expand Down Expand Up @@ -109,6 +115,33 @@ function guestUnauthorizedHandler() {
);
}

function start() {
const getMeWithRole = makeApi<void, { result: UserWithPermissionsAndRoles }>({
method: 'GET',
endpoint: '/api/v1/me/roles/',
});
return getMeWithRole().then(
({ result }) => {
// fill in some missing bootstrap data
// (because at pageload, we don't have any auth yet)
// this allows the frontend's permissions checks to work.
bootstrapData.user = result;
store.dispatch({
type: USER_LOADED,
user: result,
});
ReactDOM.render(<EmbeddedApp />, appMountPoint);
},
err => {
// something is most likely wrong with the guest token
console.error(err);
showFailureMessage(
'Something went wrong with embedded authentication. Check the dev console for details.',
);
},
);
}

/**
* Configures SupersetClient with the correct settings for the embedded dashboard page.
*/
Expand Down Expand Up @@ -153,7 +186,7 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
switchboard.defineMethod('guestToken', ({ guestToken }) => {
setupGuestClient(guestToken);
if (!started) {
ReactDOM.render(<EmbeddedApp />, appMountPoint);
start();
started = true;
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const ExploreReport = ({
});
const { userId, email } = useSelector<
ExplorePageState,
{ userId: number; email: string }
{ userId?: number; email?: string }
>(state => pick(state.explore.user, ['userId', 'email']));

const handleReportDelete = useCallback(() => {
Expand Down
4 changes: 2 additions & 2 deletions superset-frontend/src/preamble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ import setupClient from './setup/setupClient';
import setupColors from './setup/setupColors';
import setupFormatters from './setup/setupFormatters';
import setupDashboardComponents from './setup/setupDasboardComponents';
import { User } from './types/bootstrapTypes';
import { BootstrapUser, User } from './types/bootstrapTypes';

if (process.env.WEBPACK_MODE === 'development') {
setHotLoaderConfig({ logLevel: 'debug', trackTailUpdates: false });
}

// eslint-disable-next-line import/no-mutable-exports
export let bootstrapData: {
user?: User | undefined;
user?: BootstrapUser;
common?: any;
config?: any;
embedded?: {
Expand Down
24 changes: 19 additions & 5 deletions superset-frontend/src/types/bootstrapTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,40 @@ import { isPlainObject } from 'lodash';
* under the License.
*/
export type User = {
createdOn: string;
email: string;
createdOn?: string;
email?: string;
firstName: string;
isActive: boolean;
isAnonymous: boolean;
lastName: string;
userId: number;
userId?: number; // optional because guest user doesn't have a user id
username: string;
};

export interface UserWithPermissionsAndRoles extends User {
export type UserRoles = Record<string, [string, string][]>;
export interface PermissionsAndRoles {
permissions: {
database_access?: string[];
datasource_access?: string[];
};
roles: Record<string, [string, string][]>;
roles: UserRoles;
}

export type UserWithPermissionsAndRoles = User & PermissionsAndRoles;

export type UndefinedUser = {};

export type BootstrapUser = UserWithPermissionsAndRoles | undefined;

export type Dashboard = {
dttm: number;
id: number;
url: string;
title: string;
creator?: string;
creator_url?: string;
};

export type DashboardData = {
dashboard_title?: string;
created_on_delta_humanized?: string;
Expand Down
6 changes: 3 additions & 3 deletions superset-frontend/src/views/CRUD/welcome/Welcome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export const LoadingCards = ({ cover }: LoadingProps) => (

function Welcome({ user, addDangerToast }: WelcomeProps) {
const userid = user.userId;
const id = userid.toString();
const id = userid!.toString(); // confident that user is not a guest user
const recent = `/superset/recent_activity/${user.userId}/?limit=6`;
const [activeChild, setActiveChild] = useState('Loading');
const userKey = dangerouslyGetItemDoNotUse(id, null);
Expand Down Expand Up @@ -180,7 +180,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
useEffect(() => {
const activeTab = getItem(LocalStorageKeys.homepage_activity_filter, null);
setActiveState(collapseState.length > 0 ? collapseState : DEFAULT_TAB_ARR);
getRecentAcitivtyObjs(user.userId, recent, addDangerToast)
getRecentAcitivtyObjs(user.userId!, recent, addDangerToast)
.then(res => {
const data: ActivityData | null = {};
data.Examples = res.examples;
Expand Down Expand Up @@ -295,7 +295,7 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
activityData.Created) &&
activeChild !== 'Loading' ? (
<ActivityTable
user={user}
user={{ userId: user.userId! }} // user is definitely not a guest user on this page
activeChild={activeChild}
setActiveChild={setActiveChild}
activityData={activityData}
Expand Down
23 changes: 22 additions & 1 deletion superset-frontend/src/views/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import sliceEntities from 'src/dashboard/reducers/sliceEntities';
import dashboardLayout from 'src/dashboard/reducers/undoableDashboardLayout';
import logger from 'src/middleware/loggerMiddleware';
import shortid from 'shortid';
import {
BootstrapUser,
UserWithPermissionsAndRoles,
} from 'src/types/bootstrapTypes';

// Some reducers don't do anything, and redux is just used to reference the initial "state".
// This may change later, as the client application takes on more responsibilities.
Expand All @@ -57,11 +61,28 @@ const dashboardReducers = {
reports,
};

export const USER_LOADED = 'USER_LOADED';

export type UserLoadedAction = {
type: typeof USER_LOADED;
user: UserWithPermissionsAndRoles;
};

const userReducer = (
user: BootstrapUser = bootstrap.user || {},
action: UserLoadedAction,
): BootstrapUser => {
if (action.type === USER_LOADED) {
return action.user;
}
return user;
};

// exported for tests
export const rootReducer = combineReducers({
messageToasts: messageToastReducer,
common: noopReducer(bootstrap.common || {}),
user: noopReducer(bootstrap.user || {}),
user: userReducer,
impressionId: noopReducer(shortid.generate()),
...dashboardReducers,
});
Expand Down
32 changes: 32 additions & 0 deletions superset/views/users/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from flask_appbuilder.api import BaseApi, expose, safe
from flask_jwt_extended.exceptions import NoAuthorizationError

from superset.views.utils import bootstrap_user_data

from .schemas import UserResponseSchema

user_response_schema = UserResponseSchema()
Expand Down Expand Up @@ -59,3 +61,33 @@ def get_me(self) -> Response:
return self.response_401()

return self.response(200, result=user_response_schema.dump(g.user))

@expose("/roles/", methods=["GET"])
@safe
def get_my_roles(self) -> Response:
"""Get the user roles corresponding to the agent making the request
---
get:
description: >-
Returns the user roles corresponding to the agent making the request,
or returns a 401 error if the user is unauthenticated.
responses:
200:
description: The current user
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/UserResponseSchema'
401:
$ref: '#/components/responses/401'
"""
try:
if g.user is None or g.user.is_anonymous:
return self.response_401()
except NoAuthorizationError:
return self.response_401()
user = bootstrap_user_data(g.user, include_perms=True)
return self.response(200, result=user)
8 changes: 8 additions & 0 deletions superset/views/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ def bootstrap_user_data(user: User, include_perms: bool = False) -> Dict[str, An
if user.is_anonymous:
payload = {}
user.roles = (security_manager.find_role("Public"),)
elif security_manager.is_guest_user(user):
payload = {
"username": user.username,
"firstName": user.first_name,
"lastName": user.last_name,
"isActive": user.is_active,
"isAnonymous": user.is_anonymous,
}
else:
payload = {
"username": user.username,
Expand Down
1 change: 1 addition & 0 deletions tests/integration_tests/security_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,7 @@ def test_views_are_secured(self):
["AuthDBView", "login"],
["AuthDBView", "logout"],
["CurrentUserRestApi", "get_me"],
["CurrentUserRestApi", "get_my_roles"],
# TODO (embedded) remove Dashboard:embedded after uuids have been shipped
["Dashboard", "embedded"],
["EmbeddedView", "embedded"],
Expand Down
15 changes: 15 additions & 0 deletions tests/integration_tests/users/api_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ def test_get_me_logged_in(self):
self.assertEqual(True, response["result"]["is_active"])
self.assertEqual(False, response["result"]["is_anonymous"])

def test_get_me_with_roles(self):
self.login(username="admin")

rv = self.client.get(meUri + "roles/")
self.assertEqual(200, rv.status_code)
response = json.loads(rv.data.decode("utf-8"))
roles = list(response["result"]["roles"].keys())
self.assertEqual("Admin", roles.pop())

@patch("superset.security.manager.g")
def test_get_my_roles_anonymous(self, mock_g):
mock_g.user = security_manager.get_anonymous_user
rv = self.client.get(meUri + "roles/")
self.assertEqual(401, rv.status_code)

def test_get_me_unauthorized(self):
self.logout()
rv = self.client.get(meUri)
Expand Down