Skip to content

Commit

Permalink
Add faker for the pass through api to enable regular user testing (#806)
Browse files Browse the repository at this point in the history
* Add faker for the pass through api to enable regular user testing

* Add faker for backend openshift user

* add impersonate function for developers

* address comments and modify the doc

* Update to get the access token by making API call

* update doc

* add error message

* Fix lint issues

* address comments

---------

Co-authored-by: Juntao Wang <juntwang@redhat.com>
  • Loading branch information
lucferbux and DaoDaoNoCode authored Mar 1, 2023
1 parent 0b996c1 commit 74188de
Show file tree
Hide file tree
Showing 14 changed files with 207 additions and 13 deletions.
10 changes: 10 additions & 0 deletions backend/src/devFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { DEV_MODE } from './utils/constants';

let accessToken = '';

export const setImpersonateAccessToken = (token?: string): void => {
accessToken = token || '';
};

export const isImpersonating = (): boolean => accessToken !== '';
export const getImpersonateAccessToken = (): string => (DEV_MODE ? accessToken : '');
81 changes: 81 additions & 0 deletions backend/src/routes/api/dev-impersonate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { FastifyRequest } from 'fastify';
import https from 'https';
import createError from 'http-errors';
import { setImpersonateAccessToken } from '../../../devFlags';
import { KubeFastifyInstance } from '../../../types';
import { DEV_IMPERSONATE_PASSWORD, DEV_IMPERSONATE_USER } from '../../../utils/constants';
import { createCustomError } from '../../../utils/requestUtils';
import { devRoute } from '../../../utils/route-security';

export default async (fastify: KubeFastifyInstance): Promise<void> => {
fastify.post(
'/',
devRoute(async (request: FastifyRequest<{ Body: { impersonate: boolean } }>) => {
return new Promise<{ code: number; response: string }>((resolve, reject) => {
const doImpersonate = request.body.impersonate;
if (doImpersonate) {
const apiPath = fastify.kube.config.getCurrentCluster().server;
const namedHost = apiPath.slice('https://api.'.length).split(':')[0];
const url = `https://oauth-openshift.apps.${namedHost}/oauth/authorize?response_type=token&client_id=openshift-challenging-client`;
const httpsRequest = https
.get(
url,
{
headers: {
Authorization: `Basic ${Buffer.from(
`${DEV_IMPERSONATE_USER}:${DEV_IMPERSONATE_PASSWORD}`,
).toString('base64')}`,
},
},
(res) => {
// 302 Found means the success of this call
if (res.statusCode === 302) {
/**
* we will get the location in the headers like:
* https://oauth-openshift.apps.juntwang.dev.datahub.redhat.com/oauth/token/implicit#access_token={ACCESS_TOKEN_WE_WANT}
* &expires_in=86400&scope=user%3Afull&token_type=Bearer
*/
const searchParams = new URLSearchParams(res.headers.location.split('#')[1]);
const accessToken = searchParams.get('access_token');
if (accessToken) {
setImpersonateAccessToken(accessToken);
resolve({ code: 200, response: accessToken });
} else {
reject({
code: 500,
response: 'Cannot fetch the impersonate token from the server.',
});
}
} else {
reject({
code: 403,
response:
'Authorization error, please check the username and password in your local env file.',
});
}
},
)
.on('error', () => {
reject({
code: 500,
response: 'There are some errors on the server, please try again later.',
});
});
httpsRequest.end();
} else {
setImpersonateAccessToken('');
resolve({ code: 200, response: '' });
}
}).catch((e: createError.HttpError) => {
if (e?.code) {
throw createCustomError(
'Error impersonating user',
e.response?.message || 'Impersonating user error',
e.code,
);
}
throw e;
});
}),
);
};
28 changes: 17 additions & 11 deletions backend/src/routes/api/status/statusUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { KubeFastifyInstance, KubeStatus } from '../../../types';
import { getUserName } from '../../../utils/userUtils';
import { createCustomError } from '../../../utils/requestUtils';
import { isUserAdmin, isUserAllowed } from '../../../utils/adminUtils';
import { isImpersonating } from '../../../devFlags';

export const status = async (
fastify: KubeFastifyInstance,
Expand All @@ -25,18 +26,23 @@ export const status = async (
fastify.log.error(error, 'failed to get status');
throw error;
} else {
const impersonating = isImpersonating();
const data: KubeStatus = {
currentContext,
currentUser,
namespace,
userName,
clusterID,
clusterBranding,
isAdmin,
isAllowed,
serverURL: server,
};
if (impersonating) {
data.isImpersonating = impersonating;
}
return {
kube: {
currentContext,
currentUser,
namespace,
userName,
clusterID,
clusterBranding,
isAdmin,
isAllowed,
serverURL: server,
},
kube: data,
};
}
};
1 change: 1 addition & 0 deletions backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export type KubeStatus = {
isAdmin: boolean;
isAllowed: boolean;
serverURL: string;
isImpersonating?: boolean;
};

export type KubeDecorator = KubeStatus & {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export const IP = process.env.IP || '0.0.0.0';
export const LOG_LEVEL = process.env.FASTIFY_LOG_LEVEL || process.env.LOG_LEVEL || 'info';
export const LOG_DIR = path.join(__dirname, '../../../logs');
export const DEV_MODE = process.env.APP_ENV === 'development';
/** Allows a username to be impersonated in place of the logged in user for testing purposes -- impacts only some API */
export const DEV_IMPERSONATE_USER = DEV_MODE ? process.env.DEV_IMPERSONATE_USER : undefined;
export const DEV_IMPERSONATE_PASSWORD = DEV_MODE ? process.env.DEV_IMPERSONATE_PASSWORD : undefined;
export const APP_ENV = process.env.APP_ENV;

export const USER_ACCESS_TOKEN = 'x-forwarded-access-token';
Expand Down
10 changes: 10 additions & 0 deletions backend/src/utils/directCallUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { RequestOptions } from 'https';
import { DEV_MODE, USER_ACCESS_TOKEN } from './constants';
import { KubeFastifyInstance, OauthFastifyRequest } from '../types';
import { getImpersonateAccessToken, isImpersonating } from '../devFlags';

export const getDirectCallOptions = async (
fastify: KubeFastifyInstance,
Expand All @@ -18,6 +19,15 @@ export const getDirectCallOptions = async (
if (DEV_MODE) {
// In dev mode, we always are logged in fully -- no service accounts
headers = kubeHeaders;
// Fakes the call as another user to test permissions
if (isImpersonating() && !url.includes('thanos-querier-openshift-monitoring')) {
// We are impersonating an endpoint that is not thanos -- use the token from the impersonated user
// Thanos Querier does not grant basic user access on external routes
headers = {
...kubeHeaders,
Authorization: `Bearer ${getImpersonateAccessToken()}`,
};
}
} else {
// When not in dev mode, we want to switch the token from the service account to the user
const accessToken = request.headers[USER_ACCESS_TOKEN];
Expand Down
13 changes: 13 additions & 0 deletions backend/src/utils/route-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FastifyReply, FastifyRequest } from 'fastify';
import { isUserAdmin } from './adminUtils';
import { getNamespaces } from './notebookUtils';
import { logRequestDetails } from './fileUtils';
import { DEV_MODE } from './constants';

const testAdmin = async (
fastify: KubeFastifyInstance,
Expand Down Expand Up @@ -221,3 +222,15 @@ export const secureRoute =
*/
export const secureAdminRoute = (fastify: KubeFastifyInstance): ReturnType<typeof secureRoute> =>
secureRoute(fastify, true);

/**
* Make sure the route can only be called in DEV MODE
*/
export const devRoute =
<T>(requestCall: (request: FastifyRequest, reply: FastifyReply) => Promise<T>) =>
async (request: OauthFastifyRequest, reply: FastifyReply): Promise<T> => {
if (!DEV_MODE) {
throw createCustomError('404 Endpoint Not Found', 'Not Found', 404);
}
return requestCall(request, reply);
};
6 changes: 5 additions & 1 deletion backend/src/utils/userUtils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { FastifyRequest } from 'fastify';
import * as _ from 'lodash';
import { 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);
Expand Down Expand Up @@ -96,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}`);
Expand Down
11 changes: 11 additions & 0 deletions docs/SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ We have set up a pass through API that will effectively take the path built by t

See the [k8s pass through API](../backend/src/routes/api/k8s/pass-through.ts) here.

### Pass Through Impersonate User Dev Mode

In order to check regular user permissions without disabling the rest of the backend functionality in `dev mode`, you can add the `DEV_IMPERSONATE_USER` and `DEV_IMPERSONATE_PASSWORD` environment variables to your local setup with valid k8s username and password in your cluster. This will bypass the regular pass-through flow and will add that specific headers to the calls. The steps to impersonate another user are listed as follows:

1. Create a new env variable in your `.env.local` file with this format `DEV_IMPERSONATE_USER=<username>` and `DEV_IMPERSONATE_PASSWORD=<password>`
2. Run the dev server for ODH dashboard. If you don't know how to run a local dev server, please refer to [CONTRIBUTING](../CONTRIBUTING.md)
3. Click on the username on the top right corner to open the dropdown menu, and choose `Start impersonate`, then the page will refresh and you will be impersonating as the user you set up in step 1
4. To stop impersonating, click on the `Stop impersonate` button in the header toolbar

NOTE: You may not be able to read data from some Prometheus applications when impersonating another user. In the DEV_MODE, we use the external route to fetch Prometheus data, and the route might connect to a target port that's not accessible by a regular user even if the bearer token is set. To validate that, you may need to deploy the image to the cluster.

## Patches

Patches are based on [jsonpatch](https://jsonpatch.com/). For those who are unaware of the details let's do a quick breakdown on how they work. When making a `k8sPatchResource` call, it will ask for `Patches[]`. A `Patch` is just simply a straight forward operation on the existing resource.
Expand Down
41 changes: 40 additions & 1 deletion frontend/src/app/HeaderTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ 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, DOC_LINK, SUPPORT_LINK, DEV_MODE } from '~/utilities/const';
import useNotification from '~/utilities/useNotification';
import { updateImpersonateSettings } from '~/services/impersonateService';
import { AppNotification } from '~/redux/types';
import { useAppSelector } from '~/redux/hooks';
import AppLauncher from './AppLauncher';
Expand All @@ -27,7 +31,9 @@ const HeaderTools: React.FC<HeaderToolsProps> = ({ 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(
() => notifications.filter((notification) => !notification.read).length,
Expand All @@ -49,6 +55,21 @@ const HeaderTools: React.FC<HeaderToolsProps> = ({ onNotificationsClick }) => {
</DropdownItem>,
];

if (DEV_MODE && !isImpersonating) {
userMenuItems.unshift(
<DropdownItem
key="impersonate"
onClick={() => {
updateImpersonateSettings(true)
.then(() => location.reload())
.catch((e) => notification.error('Failed impersonating user', e.message));
}}
>
Start impersonate
</DropdownItem>,
);
}

const handleHelpClick = () => {
setHelpMenuOpen(false);
};
Expand Down Expand Up @@ -133,6 +154,24 @@ const HeaderTools: React.FC<HeaderToolsProps> = ({ onNotificationsClick }) => {
</ToolbarItem>
) : null}
</ToolbarGroup>
{DEV_MODE && isImpersonating && (
<ToolbarItem>
<Tooltip
content={`You are impersonating as ${userName}, click to stop impersonating`}
position="bottom"
>
<Button
onClick={() =>
updateImpersonateSettings(false)
.then(() => location.reload())
.catch((e) => notification.error('Failed stopping impersonating', e.message))
}
>
Stop impersonate
</Button>
</Tooltip>
</ToolbarItem>
)}
<ToolbarItem>
<Dropdown
removeFindDomNode
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/redux/actions/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const getUserFulfilled = (response: {
isAdmin: boolean;
isAllowed: boolean;
namespace: string;
isImpersonating?: boolean;
};
}): GetUserAction => ({
type: Actions.GET_USER_FULFILLED,
Expand All @@ -27,6 +28,7 @@ export const getUserFulfilled = (response: {
isAdmin: response.kube.isAdmin,
isAllowed: response.kube.isAllowed,
dashboardNamespace: response.kube.namespace,
isImpersonating: response.kube.isImpersonating,
},
});

Expand Down
1 change: 1 addition & 0 deletions frontend/src/redux/reducers/appReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/redux/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface GetUserAction {
isAllowed?: boolean;
error?: Error | null;
notification?: AppNotification;
isImpersonating?: boolean;
};
}

Expand All @@ -42,6 +43,7 @@ export type AppState = {
user?: string;
userLoading: boolean;
userError?: Error | null;
isImpersonating?: boolean;

clusterID?: string;
clusterBranding?: string;
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/services/impersonateService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import axios from 'axios';

export const updateImpersonateSettings = (impersonate: boolean): Promise<void> => {
const url = '/api/dev-impersonate';
return axios
.post(url, { impersonate })
.then((response) => response.data)
.catch((e) => {
throw new Error(e.response.data.message);
});
};

0 comments on commit 74188de

Please sign in to comment.