chore: get embedded user with roles and permissions (#19813)

* feat: get user roles endpoint

* add tests

* fix test

* get user with permission and roles with full user

* frontend

* type juggling

* the hash slinging slasher

* user reducer and action

* make it happy

* result

* lint

Co-authored-by: Lily Kuang <lily@preset.io>
This commit is contained in:
David Aaron Suddjian 2022-05-03 12:58:06 -07:00 committed by GitHub
parent 7657e42cff
commit 7f8279b4b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 143 additions and 20 deletions

View File

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

View File

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

View File

@ -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';
@ -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
@ -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.
*/
@ -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;
}
});

View File

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

View File

@ -26,7 +26,7 @@ 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 });
@ -34,7 +34,7 @@ if (process.env.WEBPACK_MODE === 'development') {
// eslint-disable-next-line import/no-mutable-exports
export let bootstrapData: {
user?: User | undefined;
user?: BootstrapUser;
common?: any;
config?: any;
embedded?: {

View File

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

View File

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

View File

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

View File

@ -18,6 +18,8 @@ from flask import g, Response
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()
@ -59,3 +61,33 @@ class CurrentUserRestApi(BaseApi):
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)

View File

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

View File

@ -889,6 +889,7 @@ class TestRolePermission(SupersetTestCase):
["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"],

View File

@ -37,6 +37,21 @@ class TestCurrentUserApi(SupersetTestCase):
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)