From ca2fcd8d1f40404d94daf978f262a2fc5a558dcd Mon Sep 17 00:00:00 2001 From: Juntao Wang Date: Tue, 17 Jan 2023 14:19:42 -0500 Subject: [PATCH] add impersonate function for developers --- backend/src/devFlags.ts | 7 +++ .../src/routes/api/dev-impersonate/index.ts | 9 ++++ backend/src/routes/api/status/statusUtils.ts | 4 ++ backend/src/types.ts | 1 + backend/src/utils/constants.ts | 2 +- backend/src/utils/directCallUtils.ts | 13 +++-- backend/src/utils/userUtils.ts | 24 +++++----- frontend/src/app/HeaderTools.tsx | 48 ++++++++++++++++++- frontend/src/redux/actions/actions.ts | 2 + frontend/src/redux/reducers/appReducer.ts | 1 + frontend/src/redux/types.ts | 2 + frontend/src/services/impersonateService.ts | 13 +++++ frontend/src/utilities/const.ts | 2 + 13 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 backend/src/devFlags.ts create mode 100644 backend/src/routes/api/dev-impersonate/index.ts create mode 100644 frontend/src/services/impersonateService.ts diff --git a/backend/src/devFlags.ts b/backend/src/devFlags.ts new file mode 100644 index 0000000000..39be50c6fb --- /dev/null +++ b/backend/src/devFlags.ts @@ -0,0 +1,7 @@ +let impersonating = false; + +export const setImpersonate = (impersonate: boolean): void => { + impersonating = impersonate || false; +}; + +export const isImpersonating = (): boolean => impersonating; diff --git a/backend/src/routes/api/dev-impersonate/index.ts b/backend/src/routes/api/dev-impersonate/index.ts new file mode 100644 index 0000000000..ca11cedd28 --- /dev/null +++ b/backend/src/routes/api/dev-impersonate/index.ts @@ -0,0 +1,9 @@ +import { FastifyInstance, FastifyRequest } from 'fastify'; +import { setImpersonate } from '../../../devFlags'; + +export default async (fastify: FastifyInstance): Promise => { + fastify.post('/', async (request: FastifyRequest<{ Body: { impersonate: boolean } }>) => { + setImpersonate(request.body.impersonate); + return null; + }); +}; diff --git a/backend/src/routes/api/status/statusUtils.ts b/backend/src/routes/api/status/statusUtils.ts index 8db60677a4..65860cb863 100644 --- a/backend/src/routes/api/status/statusUtils.ts +++ b/backend/src/routes/api/status/statusUtils.ts @@ -3,6 +3,8 @@ import { KubeFastifyInstance, KubeStatus } from '../../../types'; import { getUserName } from '../../../utils/userUtils'; import { createCustomError } from '../../../utils/requestUtils'; import { isUserAdmin, isUserAllowed } from '../../../utils/adminUtils'; +import { DEV_MODE } from '../../../utils/constants'; +import { isImpersonating } from '../../../devFlags'; export const status = async ( fastify: KubeFastifyInstance, @@ -16,6 +18,7 @@ export const status = async ( const userName = await getUserName(fastify, request); const isAdmin = await isUserAdmin(fastify, userName, namespace); const isAllowed = isAdmin ? true : await isUserAllowed(fastify, userName); + const impersonating = DEV_MODE ? isImpersonating() : undefined; if (!kubeContext && !kubeContext.trim()) { const error = createCustomError( @@ -36,6 +39,7 @@ export const status = async ( isAdmin, isAllowed, serverURL: server, + isImpersonating: impersonating, }, }; } diff --git a/backend/src/types.ts b/backend/src/types.ts index 8b95ce5534..afeb8b143b 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -217,6 +217,7 @@ export type KubeStatus = { isAdmin: boolean; isAllowed: boolean; serverURL: string; + isImpersonating?: boolean; }; export type KubeDecorator = KubeStatus & { diff --git a/backend/src/utils/constants.ts b/backend/src/utils/constants.ts index fa28aa1313..d92925e6ec 100644 --- a/backend/src/utils/constants.ts +++ b/backend/src/utils/constants.ts @@ -8,7 +8,7 @@ export const LOG_LEVEL = process.env.FASTIFY_LOG_LEVEL || process.env.LOG_LEVEL export const LOG_DIR = path.join(__dirname, '../../../logs'); export const DEV_MODE = process.env.APP_ENV === 'development'; /** Allows a user token to be used in place of the actual token for testing purposes */ -export const DEV_TOKEN_AUTH = DEV_MODE ? process.env.DEV_TOKEN_AUTH : undefined; +export const DEV_IMPERSONATE_USER = DEV_MODE ? process.env.DEV_IMPERSONATE_USER : undefined; export const APP_ENV = process.env.APP_ENV; export const USER_ACCESS_TOKEN = 'x-forwarded-access-token'; diff --git a/backend/src/utils/directCallUtils.ts b/backend/src/utils/directCallUtils.ts index b0d2201460..0e8df25ded 100644 --- a/backend/src/utils/directCallUtils.ts +++ b/backend/src/utils/directCallUtils.ts @@ -1,6 +1,7 @@ import { RequestOptions } from 'https'; -import { DEV_MODE, DEV_TOKEN_AUTH, USER_ACCESS_TOKEN } from './constants'; +import { DEV_IMPERSONATE_USER, DEV_MODE, USER_ACCESS_TOKEN } from './constants'; import { KubeFastifyInstance, OauthFastifyRequest } from '../types'; +import { isImpersonating } from '../devFlags'; export const getDirectCallOptions = async ( fastify: KubeFastifyInstance, @@ -19,10 +20,16 @@ export const getDirectCallOptions = async ( // In dev mode, we always are logged in fully -- no service accounts headers = kubeHeaders; // Fakes the call as another user to test permissions - if (DEV_TOKEN_AUTH) { + // if (DEV_TOKEN_AUTH) { + // headers = { + // ...headers, + // Authorization: `Bearer ${DEV_TOKEN_AUTH}`, + // }; + // } + if (isImpersonating()) { headers = { ...headers, - Authorization: `Bearer ${DEV_TOKEN_AUTH}`, + 'Impersonate-User': DEV_IMPERSONATE_USER, }; } } else { diff --git a/backend/src/utils/userUtils.ts b/backend/src/utils/userUtils.ts index 1b81ac83c4..f612e572d7 100644 --- a/backend/src/utils/userUtils.ts +++ b/backend/src/utils/userUtils.ts @@ -1,9 +1,10 @@ import { FastifyRequest } from 'fastify'; import * as _ from 'lodash'; -import { DEV_TOKEN_AUTH, USER_ACCESS_TOKEN } from './constants'; +import { DEV_IMPERSONATE_USER, USER_ACCESS_TOKEN } from './constants'; import { KubeFastifyInstance } from '../types'; import { DEV_MODE } from './constants'; import { createCustomError } from './requestUtils'; +import { isImpersonating } from '../devFlags'; export const usernameTranslate = (username: string): string => { const encodedUsername = encodeURIComponent(username); @@ -55,18 +56,14 @@ export const getUser = async ( fastify: KubeFastifyInstance, request: FastifyRequest, ): Promise => { - let accessToken = request.headers[USER_ACCESS_TOKEN] as string; + const accessToken = request.headers[USER_ACCESS_TOKEN] as string; if (!accessToken) { - if(DEV_TOKEN_AUTH){ - accessToken = DEV_TOKEN_AUTH; - } else { - const error = createCustomError( - 'Unauthorized', - `Error, missing x-forwarded-access-token header`, - 401, - ); - throw error; - } + const error = createCustomError( + 'Unauthorized', + `Error, missing x-forwarded-access-token header`, + 401, + ); + throw error; } try { const customObjectApiNoAuth = _.cloneDeep(fastify.kube.customObjectsApi); @@ -100,6 +97,9 @@ export const getUserName = async ( return userOauth.metadata.name; } catch (e) { if (DEV_MODE) { + if (isImpersonating()) { + return DEV_IMPERSONATE_USER; + } return (currentUser.username || currentUser.name)?.split('/')[0]; } fastify.log.error(`Failed to retrieve username: ${e.response?.body?.message || e.message}`); diff --git a/frontend/src/app/HeaderTools.tsx b/frontend/src/app/HeaderTools.tsx index 1f688cf929..7c24bca972 100644 --- a/frontend/src/app/HeaderTools.tsx +++ b/frontend/src/app/HeaderTools.tsx @@ -9,14 +9,24 @@ import { ToolbarContent, ToolbarGroup, ToolbarItem, + Button, + Tooltip, } from '@patternfly/react-core'; import { ExternalLinkAltIcon, QuestionCircleIcon } from '@patternfly/react-icons'; -import { COMMUNITY_LINK, DOC_LINK, SUPPORT_LINK } from '../utilities/const'; +import { + COMMUNITY_LINK, + DEV_IMPERSONATE_USER, + DEV_MODE, + DOC_LINK, + SUPPORT_LINK, +} from '../utilities/const'; import { AppNotification } from '../redux/types'; import AppLauncher from './AppLauncher'; import { useAppContext } from './AppContext'; import { logout } from './appUtils'; import { useAppSelector } from '../redux/hooks'; +import { updateImpersonateSettings } from '../services/impersonateService'; +import useNotification from '../utilities/useNotification'; interface HeaderToolsProps { onNotificationsClick: () => void; @@ -27,7 +37,9 @@ const HeaderTools: React.FC = ({ onNotificationsClick }) => { const [helpMenuOpen, setHelpMenuOpen] = React.useState(false); const notifications: AppNotification[] = useAppSelector((state) => state.notifications); const userName: string = useAppSelector((state) => state.user || ''); + const isImpersonating: boolean = useAppSelector((state) => state.isImpersonating || false); const { dashboardConfig } = useAppContext(); + const notification = useNotification(); const newNotifications = React.useMemo(() => { return notifications.filter((notification) => !notification.read).length; @@ -47,6 +59,26 @@ const HeaderTools: React.FC = ({ onNotificationsClick }) => { , ]; + if (DEV_MODE && !isImpersonating) { + userMenuItems.unshift( + { + if (!DEV_IMPERSONATE_USER) { + notification.error( + 'Cannot impersonate user', + 'Please check your .env or .env.local file to make sure you set DEV_IMPERSONATE_USER to the username you want to impersonate.', + ); + } else { + updateImpersonateSettings(true).then(() => location.reload()); + } + }} + > + Start impersonate + , + ); + } + const handleHelpClick = () => { setHelpMenuOpen(false); }; @@ -131,6 +163,20 @@ const HeaderTools: React.FC = ({ onNotificationsClick }) => { ) : null} + {DEV_MODE && isImpersonating && ( + + + + + + )} ({ type: Actions.GET_USER_FULFILLED, @@ -27,6 +28,7 @@ export const getUserFulfilled = (response: { isAdmin: response.kube.isAdmin, isAllowed: response.kube.isAllowed, dashboardNamespace: response.kube.namespace, + isImpersonating: response.kube.isImpersonating, }, }); diff --git a/frontend/src/redux/reducers/appReducer.ts b/frontend/src/redux/reducers/appReducer.ts index 1120d8ccfa..c42772a2da 100755 --- a/frontend/src/redux/reducers/appReducer.ts +++ b/frontend/src/redux/reducers/appReducer.ts @@ -30,6 +30,7 @@ const appReducer = (state: AppState = initialState, action: GetUserAction): AppS isAdmin: action.payload.isAdmin, isAllowed: action.payload.isAllowed, dashboardNamespace: action.payload.dashboardNamespace, + isImpersonating: action.payload.isImpersonating, }; case Actions.GET_USER_REJECTED: return { diff --git a/frontend/src/redux/types.ts b/frontend/src/redux/types.ts index 2b82db564b..f8e3c8febb 100644 --- a/frontend/src/redux/types.ts +++ b/frontend/src/redux/types.ts @@ -33,6 +33,7 @@ export interface GetUserAction { isAllowed?: boolean; error?: Error | null; notification?: AppNotification; + isImpersonating?: boolean; }; } @@ -42,6 +43,7 @@ export type AppState = { user?: string; userLoading: boolean; userError?: Error | null; + isImpersonating?: boolean; clusterID?: string; clusterBranding?: string; diff --git a/frontend/src/services/impersonateService.ts b/frontend/src/services/impersonateService.ts new file mode 100644 index 0000000000..2d1ce27531 --- /dev/null +++ b/frontend/src/services/impersonateService.ts @@ -0,0 +1,13 @@ +import axios from 'axios'; + +export const updateImpersonateSettings = (impersonate: boolean): Promise => { + const url = '/api/dev-impersonate'; + return axios + .post(url, { impersonate }) + .then((response) => { + return response.data; + }) + .catch((e) => { + throw new Error(e.response.data.message); + }); +}; diff --git a/frontend/src/utilities/const.ts b/frontend/src/utilities/const.ts index 466d01c32b..4667d3622b 100644 --- a/frontend/src/utilities/const.ts +++ b/frontend/src/utilities/const.ts @@ -12,6 +12,7 @@ const SUPPORT_LINK = process.env.SUPPORT_LINK; const ODH_LOGO = process.env.ODH_LOGO || 'odh-logo.svg'; const ODH_PRODUCT_NAME = process.env.ODH_PRODUCT_NAME; const ODH_NOTEBOOK_REPO = process.env.ODH_NOTEBOOK_REPO; +const DEV_IMPERSONATE_USER = DEV_MODE ? process.env.DEV_IMPERSONATE_USER : undefined; export { DEV_MODE, @@ -24,6 +25,7 @@ export { ODH_LOGO, ODH_PRODUCT_NAME, ODH_NOTEBOOK_REPO, + DEV_IMPERSONATE_USER, }; export const DOC_TYPE_TOOLTIPS = {