diff --git a/src/App.tsx b/src/App.tsx index 7f5014b..1f2e0be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> + + } + /> { + const [jit, setJit] = useState({ + team: '', + vertical: '', + environment: '', + resourceType: '', + resourceAction: '', + awsResourceType: '', + awsResourceNames: [''], + justification: '', + grantWindow: 1, + grantAt: 0, + }); + const [verticalList, setVerticalList] = useState>([]); + const [environmentList, setEnvironmentList] = useState>([]); + + const classes = useStyles(); + + let isResourceTypeAWSCustom = false; + let resourceActionList = Array(); + let awsCustomResourceTypeList = Array(); + + 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 = ({ 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 ( + + {label} + option === value || value === ''} + getOptionLabel={(option: string) => (_.isString(option) ? option : '')} + options={list} + autoSelect + className={style} + /> + + ); + }; + + return ( + <> +
+ + + + { + values.grantAt = new Date(values.grantAt).getTime(); + const tempList = values.awsResourceNames.toString(); + values.awsResourceNames = tempList.split(','); + submitJustInTimeAccessRequest(values); + }} + > + {({ resetForm, handleSubmit, isSubmitting, setFieldValue }) => { + return ( +
+
+ + {' '} + {'New Request'}{' '} + + + team.name)} + style={classes.field} + /> + vertical !== 'ALL')} + style={classes.field} + /> + + + {isResourceTypeAWSCustom ? ( + + ) : null} + + {isResourceTypeAWSCustom ? ( + + ) : null} + + + + + +
+ ); + }} +
+
+
+ + + + +
+ + ); +}; + +export default withCookies(JustInTimeAccessPage); diff --git a/src/components/just-in-time-access/JustInTimeAccessValidation.tsx b/src/components/just-in-time-access/JustInTimeAccessValidation.tsx new file mode 100644 index 0000000..a9eb2f0 --- /dev/null +++ b/src/components/just-in-time-access/JustInTimeAccessValidation.tsx @@ -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'), +}); diff --git a/src/components/just-in-time-access/RequestsComponent.tsx b/src/components/just-in-time-access/RequestsComponent.tsx new file mode 100644 index 0000000..c519aba --- /dev/null +++ b/src/components/just-in-time-access/RequestsComponent.tsx @@ -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([]); + const [loading, setLoading] = useState(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) => ( +
+
+ ), + }, + ]; + + const renderHeaderRows = () => + tableContent.map(header => {header.key}); + + const renderBodyRows = () => + requests.map((row: RequestsInfo) => ( + + {tableContent.map(header => ( + {header.value(row)} + ))} + + )); + + const renderTable = () => ( + + + + {renderHeaderRows()} + + {renderBodyRows()} +
+
+ ); + + const showNoResult = () => ( + + + {`No requests found.`} + + + {'No requests found'} + {'Get Started with JIT. Refer https://navihq.atlassian.net/wiki/x/4gIZNg'} + + + ); + + const showRequestsTable = () => { + if (loading) return ; + if (!requests || requests.length === 0) return showNoResult(); + return renderTable(); + }; + + const classes = useStyles(); + return ( + <> + + +
+ + {' '} + {'My Accesses'}{' '} + + {showRequestsTable()} +
+
+
+ + ); +}; diff --git a/src/components/just-in-time-access/ReviewsComponent.tsx b/src/components/just-in-time-access/ReviewsComponent.tsx new file mode 100644 index 0000000..96e0106 --- /dev/null +++ b/src/components/just-in-time-access/ReviewsComponent.tsx @@ -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([]); + const [loading, setLoading] = useState(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) => ( +
+
+ ), + }, + ]; + + const renderHeaderRows = () => + tableContent.map(header => {header.key}); + + const renderBodyRows = () => + requests.map((row: ReviewsInfo) => ( + + {tableContent.map(header => ( + {header.value(row)} + ))} + + )); + + const renderTable = () => ( + + + + {renderHeaderRows()} + + {renderBodyRows()} +
+
+ ); + + const showNoResult = () => ( + + + {`No reviews found`} + + + {"If you're not receiving any reviews, please get TEAM_JITREVIEWER role."} + {'Get Started with JIT. Refer https://navihq.atlassian.net/wiki/x/4gIZNg'} + + + ); + + const showReviewsTable = () => { + if (loading) return ; + if (!requests || requests.length === 0) return showNoResult(); + return renderTable(); + }; + + const classes = useStyles(); + return ( + <> + + +
+ + {' '} + {'Reviews'}{' '} + + {showReviewsTable()} +
+
+
+ + ); +}; diff --git a/src/components/just-in-time-access/constants.tsx b/src/components/just-in-time-access/constants.tsx new file mode 100644 index 0000000..a764ffe --- /dev/null +++ b/src/components/just-in-time-access/constants.tsx @@ -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'], +}; diff --git a/src/components/just-in-time-access/structs.tsx b/src/components/just-in-time-access/structs.tsx new file mode 100644 index 0000000..7aa15c4 --- /dev/null +++ b/src/components/just-in-time-access/structs.tsx @@ -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; + justification: string; + grantWindow: number; + grantAt: number; +}; + +export type RequestsInfo = { + id: number; + team: string; + vertical: string; + environment: string; + resourceType: string; + awsResourceType: string; + awsResourceNames: Array; + 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; + 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; +}; diff --git a/src/components/just-in-time-access/styles.tsx b/src/components/just-in-time-access/styles.tsx new file mode 100644 index 0000000..29f94bb --- /dev/null +++ b/src/components/just-in-time-access/styles.tsx @@ -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', + }, +})); diff --git a/src/components/just-in-time-access/utils.tsx b/src/components/just-in-time-access/utils.tsx new file mode 100644 index 0000000..a81aa89 --- /dev/null +++ b/src/components/just-in-time-access/utils.tsx @@ -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 ( +
+
{message}
+
+ ); +}; + +export const renderResourceTypeAndAction = ( + resourceType: string, + awsResourceType: string, + resourceAction: string, + awsResourceNames: Array, + classes: ClassNameMap, +): React.ReactElement => { + const message = + resourceType === 'AWS-CUSTOM' + ? `${resourceType} - ${awsResourceType} with ${resourceAction} on ${awsResourceNames.join( + ', ', + )}` + : `${resourceType} - ${resourceAction}`; + + return ( +
+
{message}
+
+ ); +}; + +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'); + } + }); +}; diff --git a/src/components/layout/PortalMenu.tsx b/src/components/layout/PortalMenu.tsx index 14fea9c..556cc2c 100644 --- a/src/components/layout/PortalMenu.tsx +++ b/src/components/layout/PortalMenu.tsx @@ -28,6 +28,9 @@ const PortalMenu = props => { App Access + + Just In Time Access + Database Access