Merge pull request #724 from navi-infra/infra-3971-jit

INFRA-3971 | Harinder | Integrating JustInTime access in deployment portal frontend
This commit is contained in:
Harinder Singh
2024-11-22 14:21:07 +00:00
committed by GitHub
10 changed files with 841 additions and 0 deletions

View File

@@ -15,6 +15,7 @@ import ManifestVersionHistory from '@components/ManifestVersionHistory/ManifestV
import ImportManifestJson from '@components/manifest/ImportManifestJson';
import GocdYamlGenerator from '@components/gocd-yaml/PipelineBaseForm';
import TokenGeneratorPage from '@components/token-generator/TokenGeneratorPage';
import JustInTimeAccessPage from '@components/just-in-time-access/JustInTimeAccessPage';
import GenerateForm from '@components/manifest/GenerateForm';
import ReportGeneratorPage from '@components/report-generator/ReportGeneratorPage';
import ShowDeploymentManifest from '@components/manifest/ShowDeploymentManifest';
@@ -107,6 +108,12 @@ function App() {
<ProtectedRoute loginRedirect={loginRedirect} component={TokenGeneratorPage} />
}
/>
<Route
path="/justInTimeAccess/"
element={
<ProtectedRoute loginRedirect={loginRedirect} component={JustInTimeAccessPage} />
}
/>
<Route
path="/databaseAccessToken/"
element={

View File

@@ -0,0 +1,233 @@
import React, { useEffect, useState } from 'react';
import { Field, Form, Formik } from 'formik';
import { withCookies } from 'react-cookie';
import { useSelector } from 'react-redux';
import { RootState } from '@src/store';
import { justInTimeAccessValidationSchema } from './JustInTimeAccessValidation';
import { FormikTextField } from '../common/FormikTextField';
import Header from '../layout/Header';
import { httpClient } from '../../helper/api-client';
import { getIn, useFormikContext } from 'formik';
import { Button, Card, Grid, Typography, InputLabel, FormControl } from '@material-ui/core/';
import { FormikAutocomplete } from '../common/FormikAutocomplete';
import _, { get, set, values } from 'lodash';
import { DropDownListProps, JitRequest } from './structs';
import { useStyles } from './styles';
import { submitJustInTimeAccessRequest } from './utils';
import { ReviewsComponent } from './ReviewsComponent';
import { RequestsComponent } from './RequestsComponent';
import {
RESOURCE_TYPE_LIST,
RESOURCE_ACTION_MAP,
CUSTOM_RESOURCE_ACTION_LIST_MAP,
AWS_CUSTOM_RESOURCE_TYPE,
} from './constants';
const JustInTimeAccessPage = () => {
const [jit, setJit] = useState<JitRequest>({
team: '',
vertical: '',
environment: '',
resourceType: '',
resourceAction: '',
awsResourceType: '',
awsResourceNames: [''],
justification: '',
grantWindow: 1,
grantAt: 0,
});
const [verticalList, setVerticalList] = useState<Array<string>>([]);
const [environmentList, setEnvironmentList] = useState<Array<string>>([]);
const classes = useStyles();
let isResourceTypeAWSCustom = false;
let resourceActionList = Array<string>();
let awsCustomResourceTypeList = Array<string>();
const teamList = useSelector((state: RootState) => state.initial.teamList);
const fetchAllVerticals = () => {
httpClient(`/api/vertical`)
.then(response => response.json())
.then(verticals => {
setVerticalList(verticals);
})
.catch(console.log);
return [];
};
const fetchAllEnvironments = () => {
httpClient(`/api/environment`)
.then(response => response.json())
.then(environments => {
setEnvironmentList(environments);
})
.catch(console.log);
return [];
};
React.useEffect(() => {
fetchAllVerticals();
fetchAllEnvironments();
}, []);
const DropDownList: React.FC<DropDownListProps> = ({ label, fieldName, list, style }) => {
const classes = useStyles();
const { values }: { values: any } = useFormikContext();
const resourceTypeField = 'resourceType';
if (typeof getIn(values, resourceTypeField) !== undefined && values[resourceTypeField] !== '') {
if (values[resourceTypeField] === 'AWS-CUSTOM') {
resourceActionList = CUSTOM_RESOURCE_ACTION_LIST_MAP[values['awsResourceType']];
awsCustomResourceTypeList = AWS_CUSTOM_RESOURCE_TYPE;
isResourceTypeAWSCustom = true;
} else {
isResourceTypeAWSCustom = false;
}
resourceActionList = RESOURCE_ACTION_MAP[values[resourceTypeField]];
// }
}
return (
<FormControl className={classes.field}>
<InputLabel shrink>{label}</InputLabel>
<Field
name={fieldName}
component={FormikAutocomplete}
getOptionSelected={(option, value) => option === value || value === ''}
getOptionLabel={(option: string) => (_.isString(option) ? option : '')}
options={list}
autoSelect
className={style}
/>
</FormControl>
);
};
return (
<>
<Header name="Just In Time Access" />
<Grid item direction="row" className={classes.gridRow} justifyContent="space-evenly">
<Grid item direction="column" justifyContent="flex-start">
<Card className={classes.bigCard}>
<Formik
enableReinitialize
initialValues={jit}
validateOnChange={true}
validationSchema={justInTimeAccessValidationSchema}
onSubmit={values => {
values.grantAt = new Date(values.grantAt).getTime();
const tempList = values.awsResourceNames.toString();
values.awsResourceNames = tempList.split(',');
submitJustInTimeAccessRequest(values);
}}
>
{({ resetForm, handleSubmit, isSubmitting, setFieldValue }) => {
return (
<Form onSubmit={handleSubmit}>
<br />
<Typography variant="h6" className={classes.leftHeading}>
{' '}
{'New Request'}{' '}
</Typography>
<Grid container direction="column" alignItems="center">
<DropDownList
label="Resource Owning Team"
fieldName="team"
list={teamList.map((team: any) => team.name)}
style={classes.field}
/>
<DropDownList
label="Vertical"
fieldName="vertical"
list={verticalList.filter((vertical: string) => vertical !== 'ALL')}
style={classes.field}
/>
<DropDownList
label="Environment"
fieldName="environment"
list={environmentList}
style={classes.field}
/>
<DropDownList
label="Resource Type"
fieldName="resourceType"
list={RESOURCE_TYPE_LIST}
style={classes.field}
/>
{isResourceTypeAWSCustom ? (
<DropDownList
label="AWS Resource Type"
fieldName="awsResourceType"
list={AWS_CUSTOM_RESOURCE_TYPE}
style={classes.field}
/>
) : null}
<DropDownList
label="Resource Action"
fieldName="resourceAction"
list={resourceActionList}
style={classes.field}
/>
{isResourceTypeAWSCustom ? (
<FormikTextField
multiline
label="AWS Resource Names"
className={classes.field}
name="awsResourceNames"
id="awsResourceNames"
/>
) : null}
<FormikTextField
multiline
className={classes.field}
label="Justification for access"
name="justification"
/>
<FormikTextField
name="grantWindow"
id="grantWindow"
label="Grant Window"
type="number"
className={classes.field}
InputLabelProps={{
shrink: true,
}}
/>
<FormikTextField
name="grantAt"
id="grantAt"
label="Start Grant At"
type="datetime-local"
className={classes.field}
InputLabelProps={{
shrink: true,
}}
/>
<Button
variant="contained"
className={classes.submitButton}
type="submit"
disabled={isSubmitting}
>
Submit
</Button>
</Grid>
</Form>
);
}}
</Formik>
</Card>
</Grid>
<Grid item direction="column" justifyContent="flex-end">
<RequestsComponent />
<ReviewsComponent />
</Grid>
</Grid>
</>
);
};
export default withCookies(JustInTimeAccessPage);

View File

@@ -0,0 +1,16 @@
import * as yup from 'yup';
export const justInTimeAccessValidationSchema = yup.object({
team: yup.string().required('is Required'),
vertical: yup.string().required('is Required'),
environment: yup.string().required('is Required'),
resourceType: yup.string().required('is Required'),
resourceAction: yup.string().required('is Required'),
justification: yup.string().required('is Required'),
grantWindow: yup
.number()
.required('is Required')
.positive('should be positive')
.integer('should be integer')
.max(24, 'should be less than or equal to 24'),
});

View File

@@ -0,0 +1,151 @@
import {
Button,
TableCell,
TableContainer,
TableHead,
Container,
Typography,
CardContent,
} from '@material-ui/core';
import { Clear } from '@material-ui/icons';
import { TableRow, Paper, Table, TableBody, Card } from '@material-ui/core/';
import React, { useState, useEffect } from 'react';
import LoadingScreen from '../common/LoadingScreen';
import { RequestsInfo, RequestTableContent } from './structs';
import { useStyles } from './styles';
import { renderResourceTypeAndAction, renderGrantDuration, reviewRequest } from './utils';
export const RequestsComponent = () => {
const [requests, setRequests] = React.useState<RequestsInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
fetch(`/api/jit/list/requests`)
.then(response => response.json())
.then(setRequests)
.then(() => setLoading(false))
.catch(console.error);
}, []);
const tableContent: RequestTableContent[] = [
{
key: '',
value: () => '',
},
{
key: 'ID',
value: ({ id }: RequestsInfo) => id,
},
{
key: 'Vertical',
value: ({ vertical }: RequestsInfo) => vertical,
},
{
key: 'Team',
value: ({ team }: RequestsInfo) => team,
},
{
key: 'Environment',
value: ({ environment }: RequestsInfo) => environment,
},
{
key: 'Requested For',
value: ({ resourceType, awsResourceType, resourceAction, awsResourceNames }: RequestsInfo) =>
renderResourceTypeAndAction(
resourceType,
awsResourceType,
resourceAction,
awsResourceNames,
classes,
),
},
{
key: 'Justification',
value: ({ justification }: RequestsInfo) => justification,
},
{
key: 'Grant Duration',
value: ({ grantAt, grantWindow }: RequestsInfo) =>
renderGrantDuration(grantAt, grantWindow, classes),
},
{
key: 'Status',
value: ({ status }: RequestsInfo) => status,
},
{
key: 'Close',
value: ({ id, status }: RequestsInfo) => (
<div className={classes.buttonContainer}>
<Button
variant="contained"
color="secondary"
className={classes.clearButton}
startIcon={<Clear />}
onClick={() => {
reviewRequest(id, 'close');
}}
disabled={status === 'APPROVED' || status === 'REJECTED' || status === 'CLOSED'}
/>
</div>
),
},
];
const renderHeaderRows = () =>
tableContent.map(header => <TableCell key={header.key}>{header.key}</TableCell>);
const renderBodyRows = () =>
requests.map((row: RequestsInfo) => (
<TableRow key={row.id}>
{tableContent.map(header => (
<TableCell key={header.key}>{header.value(row)}</TableCell>
))}
</TableRow>
));
const renderTable = () => (
<TableContainer component={Paper}>
<Table aria-label="table">
<TableHead>
<TableRow>{renderHeaderRows()}</TableRow>
</TableHead>
<TableBody>{renderBodyRows()}</TableBody>
</Table>
</TableContainer>
);
const showNoResult = () => (
<Container component="main" className={classes.main} maxWidth="sm">
<Typography variant="h2" component="h1" gutterBottom>
{`No requests found.`}
</Typography>
<Typography variant="h5" component="h2" gutterBottom>
{'No requests found'}
{'Get Started with JIT. Refer https://navihq.atlassian.net/wiki/x/4gIZNg'}
</Typography>
</Container>
);
const showRequestsTable = () => {
if (loading) return <LoadingScreen containerClass={classes.main} />;
if (!requests || requests.length === 0) return showNoResult();
return renderTable();
};
const classes = useStyles();
return (
<>
<Card className={classes.halfCardTop}>
<CardContent>
<div>
<Typography variant="h6" className={classes.heading}>
{' '}
{'My Accesses'}{' '}
</Typography>
{showRequestsTable()}
</div>
</CardContent>
</Card>
</>
);
};

View File

@@ -0,0 +1,157 @@
import {
Button,
TableCell,
TableContainer,
TableHead,
Container,
Typography,
CardContent,
} from '@material-ui/core';
import { Check, Clear } from '@material-ui/icons';
import { TableRow, Paper, Table, TableBody, Card } from '@material-ui/core/';
import React, { useState, useEffect } from 'react';
import LoadingScreen from '../common/LoadingScreen';
import { ReviewsInfo, ReviewsTableContent } from './structs';
import { useStyles } from './styles';
import { renderResourceTypeAndAction, renderGrantDuration, reviewRequest } from './utils';
export const ReviewsComponent = () => {
const [requests, setRequests] = React.useState<ReviewsInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
fetch(`/api/jit/list/reviews`)
.then(response => response.json())
.then(setRequests)
.then(() => setLoading(false))
.catch(console.error);
}, []);
const tableContent: ReviewsTableContent[] = [
{
key: '',
value: () => '',
},
{
key: 'ID',
value: ({ jitRequestId }: ReviewsInfo) => jitRequestId,
},
{
key: 'User',
value: ({ requestedFor }: ReviewsInfo) => requestedFor,
},
{
key: 'Vertical',
value: ({ vertical }: ReviewsInfo) => vertical,
},
{
key: 'Review As',
value: ({ team }: ReviewsInfo) => team,
},
{
key: 'Environment',
value: ({ environment }: ReviewsInfo) => environment,
},
{
key: 'Requested For',
value: ({ resourceType, awsResourceType, resourceAction, awsResourceNames }: ReviewsInfo) =>
renderResourceTypeAndAction(
resourceType,
awsResourceType,
resourceAction,
awsResourceNames,
classes,
),
},
{
key: 'Justification',
value: ({ justification }: ReviewsInfo) => justification,
},
{
key: 'Grant Duration',
value: ({ grantAt, grantWindow }: ReviewsInfo) =>
renderGrantDuration(grantAt, grantWindow, classes),
},
{
key: 'Actions',
value: ({ jitApprovalId, status }: ReviewsInfo) => (
<div className={classes.buttonContainer}>
<Button
variant="contained"
color="primary"
className={classes.checkButton}
startIcon={<Check />}
onClick={() => reviewRequest(jitApprovalId, 'approve')}
disabled={status !== 'PENDING'}
/>
<Button
variant="contained"
color="secondary"
className={classes.clearButton}
startIcon={<Clear />}
onClick={() => reviewRequest(jitApprovalId, 'reject')}
disabled={status !== 'PENDING'}
/>
</div>
),
},
];
const renderHeaderRows = () =>
tableContent.map(header => <TableCell key={header.key}>{header.key}</TableCell>);
const renderBodyRows = () =>
requests.map((row: ReviewsInfo) => (
<TableRow key={row.jitApprovalId}>
{tableContent.map(header => (
<TableCell key={header.key}>{header.value(row)}</TableCell>
))}
</TableRow>
));
const renderTable = () => (
<TableContainer component={Paper}>
<Table aria-label="table">
<TableHead>
<TableRow>{renderHeaderRows()}</TableRow>
</TableHead>
<TableBody>{renderBodyRows()}</TableBody>
</Table>
</TableContainer>
);
const showNoResult = () => (
<Container component="main" className={classes.main} maxWidth="sm">
<Typography variant="h2" component="h1" gutterBottom>
{`No reviews found`}
</Typography>
<Typography variant="h5" component="h2" gutterBottom>
{"If you're not receiving any reviews, please get TEAM_JITREVIEWER role."}
{'Get Started with JIT. Refer https://navihq.atlassian.net/wiki/x/4gIZNg'}
</Typography>
</Container>
);
const showReviewsTable = () => {
if (loading) return <LoadingScreen containerClass={classes.main} />;
if (!requests || requests.length === 0) return showNoResult();
return renderTable();
};
const classes = useStyles();
return (
<>
<Card className={classes.halfCardBottom}>
<CardContent>
<div>
<Typography variant="h6" className={classes.heading}>
{' '}
{'Reviews'}{' '}
</Typography>
{showReviewsTable()}
</div>
</CardContent>
</Card>
</>
);
};

View File

@@ -0,0 +1,31 @@
export const RESOURCE_TYPE_LIST = ['DB', 'KUBERNETES', 'AWS', 'AWS-CUSTOM', 'GOCD'];
export const KUBERNETES_ACTION_LIST = ['read', 'write', 'admin'];
export const DB_ACTION_LIST = ['read', 'write', 'master', 'manager'];
export const AWS_ACTION_LIST = ['App-Developers', 'App-Admin', 'App-Owner'];
export const AWS_CUSTOM_RESOURCE_TYPE = ['S3', 'DYNAMODB', 'RDS', 'DOCDB', 'ELASTICACHE'];
export const GOCD_ACTION_LIST = ['deploy-prod', 'prod-resource', 'hotfix'];
export const resourceActionMap = {
KUBERNETES: KUBERNETES_ACTION_LIST,
DB: DB_ACTION_LIST,
AWS: AWS_ACTION_LIST,
'AWS-CUSTOM': ['read'],
GOCD: GOCD_ACTION_LIST,
};
export const RESOURCE_ACTION_MAP = {
KUBERNETES: KUBERNETES_ACTION_LIST,
DB: DB_ACTION_LIST,
AWS: AWS_ACTION_LIST,
'AWS-CUSTOM': ['read'],
GOCD: GOCD_ACTION_LIST,
};
export const CUSTOM_RESOURCE_ACTION_LIST_MAP = {
S3: ['read', 'write'],
DYNAMODB: ['read'],
RDS: ['read', 'write'],
DOCDB: ['read', 'write'],
ELASTICACHE: ['read'],
// CUSTOM: ['read', 'write', 'custom'],
};

View File

@@ -0,0 +1,62 @@
export interface DropDownListProps {
label: string;
fieldName: string;
list: any[];
style: string;
}
export type JitRequest = {
team: string;
vertical: string;
environment: string;
resourceType: string;
resourceAction: string;
awsResourceType: string;
awsResourceNames: Array<string>;
justification: string;
grantWindow: number;
grantAt: number;
};
export type RequestsInfo = {
id: number;
team: string;
vertical: string;
environment: string;
resourceType: string;
awsResourceType: string;
awsResourceNames: Array<string>;
resourceAction: string;
justification: string;
grantWindow: number;
grantAt: number;
status: string;
};
export type ReviewsInfo = {
jitApprovalId: number;
jitRequestId: number;
requestedFor: string;
team: string;
vertical: string;
environment: string;
resourceType: string;
awsResourceType: string;
awsResourceNames: Array<string>;
resourceAction: string;
grantWindow: number;
grantAt: number;
justification: string;
action: string;
status: string;
};
export type RequestTableContent = {
key: string;
value: (row: RequestsInfo) => any;
};
export type ReviewsTableContent = {
key: string;
value: (row: ReviewsInfo) => any;
};

View File

@@ -0,0 +1,104 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
main: {
marginTop: theme.spacing(8),
marginLeft: theme.spacing(2),
marginBottom: theme.spacing(2),
},
formContainer: {
display: 'flex',
justifyContent: 'center',
},
buttonContainer: {
display: 'flex',
justifyContent: 'center',
},
checkButton: {
marginRight: 10,
paddingRight: 0,
},
clearButton: {
paddingRight: 5,
},
box: {
marginLeft: 40,
marginBottom: 20,
marginTop: 60,
},
bigCard: {
width: 400,
height: 800,
border: '5px solid #E8E8E8',
padding: 5,
overflowY: 'auto',
},
halfCardTop: {
width: 1200,
height: 390,
border: '5px solid #E8E8E8',
padding: 5,
overflowY: 'auto',
marginLeft: 10,
marginBottom: 10,
},
halfCardBottom: {
width: 1200,
height: 380,
border: '5px solid #E8E8E8',
padding: 5,
overflowY: 'auto',
marginLeft: 10,
},
field: {
marginTop: 20,
width: 350,
padding: 5,
},
leftField: {
marginTop: 20,
width: 170,
marginRight: 10,
},
rightField: {
marginTop: 20,
width: 170,
},
gridRow: {
width: 1600,
display: 'flex',
marginTop: 20,
marginLeft: 75,
},
heading: {
backgroundColor: 'grey',
color: 'white',
paddingLeft: 20,
paddingTop: 5,
paddingBottom: 5,
},
leftHeading: {
backgroundColor: 'grey',
color: 'white',
paddingLeft: 50,
paddingTop: 5,
paddingBottom: 5,
},
submitButton: {
backgroundColor: '#fd7700',
alignItems: 'flex-end',
color: '#ffffff',
'&:hover': {
backgroundColor: '#ff7700',
color: '#ffffff',
},
},
grantDuration: {
display: 'flex',
alignItems: 'center',
},
resourceTypeAndAction: {
display: 'flex',
alignItems: 'center',
},
}));

View File

@@ -0,0 +1,77 @@
import { toast } from 'react-toastify';
import { ClassNameMap } from '@material-ui/core/styles/withStyles';
import { parseDate } from '../../helper/date';
import { httpClient, post } from '../../helper/api-client';
import React from 'react';
import { JitRequest } from './structs';
import { updateToastError, updateToastSuccess } from '@src/helper/updateToast';
export const renderGrantDuration = (
grantAt: number,
grantWindow: number,
classes: ClassNameMap,
): React.ReactElement => {
const message = parseDate(grantAt * 1000) + ' for ' + grantWindow + ' hours';
return (
<div className={classes.grantDuration}>
<div>{message}</div>
</div>
);
};
export const renderResourceTypeAndAction = (
resourceType: string,
awsResourceType: string,
resourceAction: string,
awsResourceNames: Array<string>,
classes: ClassNameMap,
): React.ReactElement => {
const message =
resourceType === 'AWS-CUSTOM'
? `${resourceType} - ${awsResourceType} with ${resourceAction} on ${awsResourceNames.join(
', ',
)}`
: `${resourceType} - ${resourceAction}`;
return (
<div className={classes.resourceTypeAndAction}>
<div>{message}</div>
</div>
);
};
export const submitJustInTimeAccessRequest = async (values: JitRequest) => {
const toastId = toast.info('In Progress ...', {
autoClose: 10000,
});
await post(values, '/api/jit/portal').then(response => {
if (response.status === 201) {
updateToastSuccess(toastId, 'Request submitted successfully');
} else if (response.status === 400) {
updateToastError(toastId, 'Invalid request or reviewer not found');
} else if (response.status === 409) {
updateToastError(toastId, 'Request already exists. Please wait for approval');
} else {
updateToastError(toastId, 'Internal Server Error');
}
});
};
export const reviewRequest = (id: number, action: string) => {
const toastId = toast.info('Reviewing ...', {
autoClose: 10000,
});
httpClient(`/api/jit/${action}/portal/${id}`, {
method: 'POST',
}).then(response => {
if (response.ok) {
const message =
action === 'reject' ? `${action}ed request ${id}` : `${action}d request ${id}`;
updateToastSuccess(toastId, message);
} else if (response.status === 401) {
updateToastError(toastId, 'You are not authorized to perform this action');
} else {
updateToastError(toastId, 'Internal Server Error');
}
});
};

View File

@@ -28,6 +28,9 @@ const PortalMenu = props => {
<MenuItem component={Link} onClick={props.handleClose} to="/generateToken">
App Access
</MenuItem>
<MenuItem component={Link} onClick={props.handleClose} to="/justInTimeAccess">
Just In Time Access
</MenuItem>
<MenuItem component={Link} onClick={props.handleClose} to="/databaseAccessToken">
Database Access
</MenuItem>