diff --git a/plugins/rbac/README.md b/plugins/rbac/README.md index 5af529d906..1102683002 100644 --- a/plugins/rbac/README.md +++ b/plugins/rbac/README.md @@ -10,6 +10,20 @@ The RBAC UI plugin offers a streamlined user interface for effectively managing Follow the RBAC backend plugin [README](https://github.com/janus-idp/backstage-plugins/tree/main/plugins/rbac-backend) to integrate rbac in your Backstage instance +--- + +**NOTE** + +To enable create role button on Administration -> RBAC roles list page, the role associacted with your user should have the following permission policies associated with it. Add the following in your permission policies configuration file: + +```CSV +p, role:default/team_a, catalog-entity, read, allow +p, role:default/team_a, policy-entity, create, allow +g, user:default/user, role:default/team_a +``` + +--- + #### Procedure 1. Install the RBAC UI plugin using the following command: diff --git a/plugins/rbac/dev/index.tsx b/plugins/rbac/dev/index.tsx index 5633cd1831..b3acbc9300 100644 --- a/plugins/rbac/dev/index.tsx +++ b/plugins/rbac/dev/index.tsx @@ -104,6 +104,10 @@ class MockRBACApi implements RBACAPI { async createRole(_role: Role): Promise { return { status: 200 } as Response; } + + async createPolicy(_data: any): Promise { + return { status: 200 } as Response; + } } const mockPermissionApi = new MockPermissionApi({ result: 'ALLOW' }); diff --git a/plugins/rbac/src/api/RBACBackendClient.ts b/plugins/rbac/src/api/RBACBackendClient.ts index 91c6ccd396..384e170fc9 100644 --- a/plugins/rbac/src/api/RBACBackendClient.ts +++ b/plugins/rbac/src/api/RBACBackendClient.ts @@ -33,6 +33,7 @@ export type RBACAPI = { oldPolicy: RoleBasedPolicy, newPolicy: RoleBasedPolicy, ) => Promise; + createPolicy: (data: any) => Promise; }; export type Options = { @@ -268,4 +269,22 @@ export class RBACBackendClient implements RBACAPI { ); return jsonResponse; } + + async createPolicy(data: RoleBasedPolicy[]) { + const { token: idToken } = await this.identityApi.getCredentials(); + const backendUrl = this.configApi.getString('backend.baseUrl'); + const jsonResponse = await fetch(`${backendUrl}/api/permission/policies`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...(idToken && { Authorization: `Bearer ${idToken}` }), + }, + body: JSON.stringify(data), + }); + if (jsonResponse.status !== 200 && jsonResponse.status !== 201) { + return jsonResponse.json(); + } + return jsonResponse; + } } diff --git a/plugins/rbac/src/components/CreateRole/AddMembersForm.tsx b/plugins/rbac/src/components/CreateRole/AddMembersForm.tsx index 582866752c..a6a37f82cb 100644 --- a/plugins/rbac/src/components/CreateRole/AddMembersForm.tsx +++ b/plugins/rbac/src/components/CreateRole/AddMembersForm.tsx @@ -34,6 +34,7 @@ export const AddMembersForm = ({ membersData, }: AddMembersFormProps) => { const [search, setSearch] = React.useState(''); + const [selectedMember, setSelectedMember] = React.useState(); const getDescription = (member: MemberEntity) => { const memberCount = getMembersCount(member); @@ -74,10 +75,16 @@ export const AddMembersForm = ({ } loading={membersData.loading} loadingText={} - onChange={(_e, value: SelectedMember | null) => - setFieldValue('selectedMembers', [...selectedMembers, value]) - } disableClearable + value={selectedMember} + onChange={(_e, value: SelectedMember) => { + setSelectedMember(value); + if (value) { + setFieldValue('selectedMembers', [...selectedMembers, value]); + } + }} + inputValue={search} + onInputChange={(_e, newSearch: string) => setSearch(newSearch)} getOptionDisabled={(option: SelectedMember) => !!selectedMembers.find( (sm: SelectedMember) => sm.etag === option.etag, @@ -86,6 +93,7 @@ export const AddMembersForm = ({ renderOption={(option: SelectedMember, state) => ( )} + clearOnEscape renderInput={params => ( setSearch(e.target.value)} required /> )} diff --git a/plugins/rbac/src/components/CreateRole/AddedMembersTableColumn.tsx b/plugins/rbac/src/components/CreateRole/AddedMembersTableColumn.tsx index 16525c4822..9744f23e81 100644 --- a/plugins/rbac/src/components/CreateRole/AddedMembersTableColumn.tsx +++ b/plugins/rbac/src/components/CreateRole/AddedMembersTableColumn.tsx @@ -1,13 +1,39 @@ import React from 'react'; -import { TableColumn } from '@backstage/core-components'; +import { Link, TableColumn } from '@backstage/core-components'; import { IconButton } from '@material-ui/core'; import Delete from '@mui/icons-material/Delete'; import { FormikErrors } from 'formik'; +import { getKindNamespaceName } from '../../utils/rbac-utils'; import { RoleFormValues, SelectedMember } from './types'; +export const basicSelectedMembersColumns = + (): TableColumn[] => [ + { + title: 'Type', + field: 'type', + type: 'string', + }, + { + title: 'Members', + field: 'members', + type: 'numeric', + align: 'left', + emptyValue: '-', + }, + ]; + +export const reviewStepMemebersTableColumns = () => [ + { + title: 'Name', + field: 'label', + type: 'string', + }, + ...basicSelectedMembersColumns(), +]; + export const selectedMembersColumns = ( selectedMembers: SelectedMember[], setFieldValue: ( @@ -28,19 +54,16 @@ export const selectedMembersColumns = ( title: 'Name', field: 'label', type: 'string', + render: props => { + const { kind, namespace, name } = getKindNamespaceName(props.ref); + return ( + + {props.label} + + ); + }, }, - { - title: 'Type', - field: 'type', - type: 'string', - }, - { - title: 'Members', - field: 'members', - type: 'numeric', - align: 'left', - emptyValue: '-', - }, + ...basicSelectedMembersColumns(), { title: 'Actions', sorting: false, diff --git a/plugins/rbac/src/components/CreateRole/CreateRolePage.tsx b/plugins/rbac/src/components/CreateRole/CreateRolePage.tsx index 1093e71f18..70a9bedb30 100644 --- a/plugins/rbac/src/components/CreateRole/CreateRolePage.tsx +++ b/plugins/rbac/src/components/CreateRole/CreateRolePage.tsx @@ -8,6 +8,7 @@ import { RequirePermission } from '@backstage/plugin-permission-react'; import { rbacApiRef } from '../../api/RBACBackendClient'; import { MemberEntity } from '../../types'; +import { initialPermissionPolicyRowValue } from './const'; import { RoleForm } from './RoleForm'; import { RoleFormValues } from './types'; @@ -27,6 +28,7 @@ export const CreateRolePage = () => { kind: 'role', description: '', selectedMembers: [], + permissionPoliciesRows: [initialPermissionPolicyRowValue], }; return ( @@ -35,7 +37,7 @@ export const CreateRolePage = () => { resourceRef={catalogEntityReadPermission.resourceType} > -
+
{ kind: roleKind || 'role', description: '', selectedMembers, + permissionPoliciesRows: [ + { + plugin: '', + permission: '', + policies: [ + { label: 'Create', checked: false }, + { label: 'Read', checked: false }, + { label: 'Update', checked: false }, + { label: 'Delete', checked: false }, + ], + }, + ], }; const renderPage = () => { if (loading) { diff --git a/plugins/rbac/src/components/CreateRole/MembersDropdownOption.tsx b/plugins/rbac/src/components/CreateRole/MembersDropdownOption.tsx index 73666fd578..4e2b8bd624 100644 --- a/plugins/rbac/src/components/CreateRole/MembersDropdownOption.tsx +++ b/plugins/rbac/src/components/CreateRole/MembersDropdownOption.tsx @@ -32,7 +32,7 @@ export const MembersDropdownOption = ({ const parts = parse(label, matches); return ( - + {parts.map(part => ( ({ + permissionPoliciesForm: { + padding: '20px', + border: `2px solid ${theme.palette.border}`, + borderRadius: '5px', + }, + addButton: { + color: theme.palette.primary.light, + }, +})); + +type PermissionPoliciesFormProps = { + permissionPoliciesRows: PermissionPolicyRow[]; + permissionPoliciesRowsError: FormikErrors[]; + setFieldValue: ( + field: string, + value: any, + shouldValidate?: boolean, + ) => Promise> | Promise; + setFieldError: (field: string, value: string | undefined) => void; + handleBlur: React.FocusEventHandler; +}; + +export const PermissionPoliciesForm = ({ + permissionPoliciesRows, + permissionPoliciesRowsError, + setFieldValue, + setFieldError, + handleBlur, +}: PermissionPoliciesFormProps) => { + const classes = useStyles(); + const rbacApi = useApi(rbacApiRef); + + const { value: permissionPolicies, loading: permissionPoliciesLoading } = + useAsync(async () => { + return await rbacApi.listPermissions(); + }); + + const permissionPoliciesData = + !permissionPoliciesLoading && permissionPolicies + ? getPluginsPermissionPoliciesData(permissionPolicies) + : undefined; + + const onChangePlugin = (plugin: string, index: number) => { + setFieldValue(`permissionPoliciesRows[${index}].plugin`, plugin, true); + setFieldValue(`permissionPoliciesRows[${index}].permission`, '', false); + setFieldValue( + `permissionPoliciesRows[${index}].policies`, + initialPermissionPolicyRowValue.policies, + false, + ); + }; + + const onChangePermission = ( + permission: string, + index: number, + policies?: string[], + ) => { + setFieldValue( + `permissionPoliciesRows[${index}].permission`, + permission, + true, + ); + setFieldValue( + `permissionPoliciesRows[${index}].policies`, + policies + ? policies.map(p => ({ label: p, checked: true })) + : initialPermissionPolicyRowValue.policies, + false, + ); + }; + + const onChangePolicy = ( + isChecked: boolean, + policyIndex: number, + index: number, + ) => { + setFieldValue( + `permissionPoliciesRows[${index}].policies[${policyIndex}].checked`, + isChecked, + true, + ); + }; + + const onRowRemove = (index: number) => { + const finalPps = permissionPoliciesRows.filter( + (_pp, ppIndex) => index !== ppIndex, + ); + setFieldError(`permissionPoliciesRows[${index}]`, undefined); + setFieldValue('permissionPoliciesRows', finalPps, false); + }; + + const onRowAdd = () => + setFieldValue( + 'permissionPoliciesRows', + [...permissionPoliciesRows, initialPermissionPolicyRowValue], + false, + ); + + return ( +
+ + Permission policies can be selected for each plugin. You can add + multiple permission policies using +Add option. + +
+
+ {permissionPoliciesRows.map((pp, index) => ( + onChangePlugin(plugin, index)} + onChangePermission={(permission: string, policies?: string[]) => + onChangePermission(permission, index, policies) + } + onChangePolicy={(isChecked: boolean, policyIndex: number) => + onChangePolicy(isChecked, policyIndex, index) + } + onRemove={() => onRowRemove(index)} + handleBlur={handleBlur} + /> + ))} + +
+
+ ); +}; diff --git a/plugins/rbac/src/components/CreateRole/PermissionPoliciesFormRow.tsx b/plugins/rbac/src/components/CreateRole/PermissionPoliciesFormRow.tsx new file mode 100644 index 0000000000..6912ac780a --- /dev/null +++ b/plugins/rbac/src/components/CreateRole/PermissionPoliciesFormRow.tsx @@ -0,0 +1,125 @@ +import React from 'react'; + +import { makeStyles } from '@material-ui/core'; +import RemoveIcon from '@mui/icons-material/Remove'; +import Autocomplete from '@mui/material/Autocomplete'; +import IconButton from '@mui/material/IconButton'; +import TextField from '@mui/material/TextField'; +import { FormikErrors } from 'formik'; + +import { PoliciesCheckboxGroup } from './PoliciesCheckboxGroup'; +import { PermissionPolicyRow, PluginsPermissionPoliciesData } from './types'; + +const useStyles = makeStyles(theme => ({ + removeButton: { + color: theme.palette.grey[500], + }, +})); + +type PermissionPoliciesFormRowProps = { + permissionPoliciesRowData: PermissionPolicyRow; + permissionPoliciesData?: PluginsPermissionPoliciesData; + permissionPoliciesRowError: FormikErrors; + rowCount: number; + rowName: string; + onRemove: () => void; + onChangePlugin: (plugin: string) => void; + onChangePermission: (permission: string, policies?: string[]) => void; + onChangePolicy: (isChecked: boolean, policyIndex: number) => void; + handleBlur: React.FocusEventHandler; +}; + +export const PermissionPoliciesFormRow = ({ + permissionPoliciesRowData, + permissionPoliciesData, + permissionPoliciesRowError, + rowCount, + rowName, + onRemove, + onChangePermission, + onChangePolicy, + onChangePlugin, + handleBlur, +}: PermissionPoliciesFormRowProps) => { + const classes = useStyles(); + const [pluginSearch, setPluginSearch] = React.useState(''); + const [permissionSearch, setPermissionSearch] = React.useState(''); + const { plugin: pluginError, permission: permissionError } = + permissionPoliciesRowError; + + return ( +
+ { + onChangePlugin(value || ''); + }} + inputValue={pluginSearch} + onInputChange={(_e, newSearch) => setPluginSearch(newSearch)} + renderInput={params => ( + + )} + /> + + onChangePermission( + value || '', + value + ? permissionPoliciesData?.pluginsPermissions?.[ + permissionPoliciesRowData.plugin + ]?.policies?.[value] + : undefined, + ) + } + inputValue={permissionSearch} + onInputChange={(_e, newSearch) => setPermissionSearch(newSearch)} + renderInput={params => ( + + )} + /> + + onRemove()} + disabled={rowCount === 1} + > + + +
+ ); +}; diff --git a/plugins/rbac/src/components/CreateRole/PoliciesCheckboxGroup.tsx b/plugins/rbac/src/components/CreateRole/PoliciesCheckboxGroup.tsx new file mode 100644 index 0000000000..084d5f7a95 --- /dev/null +++ b/plugins/rbac/src/components/CreateRole/PoliciesCheckboxGroup.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import Checkbox from '@mui/material/Checkbox'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import FormLabel from '@mui/material/FormLabel'; + +import { PermissionPolicyRow, RowPolicy } from './types'; + +export const PoliciesCheckboxGroup = ({ + permissionPoliciesRowData, + rowName, + onChangePolicy, +}: { + permissionPoliciesRowData: PermissionPolicyRow; + rowName: string; + onChangePolicy: (isChecked: boolean, policyIndex: number) => void; +}) => { + return ( + + + Policy + + + {permissionPoliciesRowData.policies.map( + (p: RowPolicy, index: number, self) => { + const labelCheckedArray = self.filter(val => !!val.checked); + const labelCheckedCount = labelCheckedArray.length; + return ( + onChangePolicy(e.target.checked, index)} + /> + } + /> + ); + }, + )} + + + ); +}; diff --git a/plugins/rbac/src/components/CreateRole/ReviewStep.tsx b/plugins/rbac/src/components/CreateRole/ReviewStep.tsx new file mode 100644 index 0000000000..fc0a9e35d6 --- /dev/null +++ b/plugins/rbac/src/components/CreateRole/ReviewStep.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { StructuredMetadataTable } from '@backstage/core-components'; + +import Typography from '@mui/material/Typography'; + +import { getPermissionsNumber } from '../../utils/create-role-utils'; +import { getMembers } from '../../utils/rbac-utils'; +import { reviewStepMemebersTableColumns } from './AddedMembersTableColumn'; +import { ReviewStepTable } from './ReviewStepTable'; +import { selectedPermissionPoliciesColumn } from './SelectedPermissionPoliciesColumn'; +import { RoleFormValues } from './types'; + +const tableMetadata = (values: RoleFormValues) => { + const membersKey = + values.selectedMembers.length > 0 + ? `Users and groups - ${getMembers(values.selectedMembers)}` + : 'Users and groups'; + const permissionPoliciesKey = `Permission policies ${getPermissionsNumber( + values, + )}`; + return { + 'Name and description of role': ( + <> +

{values.name}

+
+

{values.description}

+ + ), + [membersKey]: ( + + ), + [permissionPoliciesKey]: ( + + ), + }; +}; + +export const ReviewStep = ({ values }: { values: RoleFormValues }) => { + return ( +
+ Review and create + +
+ ); +}; diff --git a/plugins/rbac/src/components/CreateRole/ReviewStepTable.tsx b/plugins/rbac/src/components/CreateRole/ReviewStepTable.tsx new file mode 100644 index 0000000000..3d181f752a --- /dev/null +++ b/plugins/rbac/src/components/CreateRole/ReviewStepTable.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +export const ReviewStepTable = ({ + columns, + rows, +}: { + columns: any[]; + rows: any[]; +}) => { + return ( +
+ + + + {columns.map(col => ( + + ))} + + +
+ + {rows.map(row => ( + <> + + {columns.map(rowCol => ( + + ))} + + + + ))} + +
+ {col.title} +
+ {rowCol.render + ? rowCol.render(row[rowCol.field]) + : row[rowCol.field] || (rowCol.emptyValue ?? '')} +
+
+ ); +}; diff --git a/plugins/rbac/src/components/CreateRole/RoleForm.test.tsx b/plugins/rbac/src/components/CreateRole/RoleForm.test.tsx index 7738adb523..79e339bac7 100644 --- a/plugins/rbac/src/components/CreateRole/RoleForm.test.tsx +++ b/plugins/rbac/src/components/CreateRole/RoleForm.test.tsx @@ -1,11 +1,19 @@ import React from 'react'; +import { LinkProps } from '@backstage/core-components'; + import { render, screen } from '@testing-library/react'; import { useFormik } from 'formik'; import { RoleForm } from './RoleForm'; jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + Link: (props: LinkProps) => ( + + {props.children} + + ), useNavigate: jest.fn(), })); @@ -39,6 +47,18 @@ describe('Create RoleForm', () => { kind: 'role', description: '', selectedMembers: [], + permissionPoliciesRows: [ + { + plugin: '', + permission: '', + policies: [ + { label: 'Create', checked: false }, + { label: 'Read', checked: false }, + { label: 'Update', checked: false }, + { label: 'Delete', checked: false }, + ], + }, + ], }} titles={{ formTitle: 'Create Role', @@ -74,6 +94,18 @@ describe('Create RoleForm', () => { kind: 'role', description: '', selectedMembers: [], + permissionPoliciesRows: [ + { + plugin: '', + permission: '', + policies: [ + { label: 'Create', checked: false }, + { label: 'Read', checked: false }, + { label: 'Update', checked: false }, + { label: 'Delete', checked: false }, + ], + }, + ], }} titles={{ formTitle: 'Create Role', @@ -116,6 +148,18 @@ describe('Edit RoleForm', () => { namespace: 'default', }, ], + permissionPoliciesRows: [ + { + plugin: '', + permission: '', + policies: [ + { label: 'Create', checked: false }, + { label: 'Read', checked: false }, + { label: 'Update', checked: false }, + { label: 'Delete', checked: false }, + ], + }, + ], }} titles={{ formTitle: 'Edit Role', @@ -211,6 +255,18 @@ describe('Edit RoleForm', () => { namespace: 'default', }, ], + permissionPoliciesRows: [ + { + plugin: '', + permission: '', + policies: [ + { label: 'Create', checked: false }, + { label: 'Read', checked: false }, + { label: 'Update', checked: false }, + { label: 'Delete', checked: false }, + ], + }, + ], }} titles={{ formTitle: 'Edit Role', @@ -252,6 +308,18 @@ describe('Edit RoleForm', () => { kind: 'role', description: '', selectedMembers: [], + permissionPoliciesRows: [ + { + plugin: '', + permission: '', + policies: [ + { label: 'Create', checked: false }, + { label: 'Read', checked: false }, + { label: 'Update', checked: false }, + { label: 'Delete', checked: false }, + ], + }, + ], }} titles={{ formTitle: 'Edit Role', diff --git a/plugins/rbac/src/components/CreateRole/RoleForm.tsx b/plugins/rbac/src/components/CreateRole/RoleForm.tsx index 2b0dc42244..2440f21c35 100644 --- a/plugins/rbac/src/components/CreateRole/RoleForm.tsx +++ b/plugins/rbac/src/components/CreateRole/RoleForm.tsx @@ -14,16 +14,22 @@ import { Paper, } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import { FormikHelpers, useFormik } from 'formik'; +import { FormikErrors, FormikHelpers, useFormik } from 'formik'; import { rbacApiRef } from '../../api/RBACBackendClient'; import { CreateRoleError, MemberEntity } from '../../types'; -import { getRoleData, validationSchema } from '../../utils/create-role-utils'; +import { + getPermissionPoliciesData, + getRoleData, + validationSchema, +} from '../../utils/create-role-utils'; import { useToast } from '../ToastContext'; import { AddedMembersTable } from './AddedMembersTable'; import { AddMembersForm } from './AddMembersForm'; +import { PermissionPoliciesForm } from './PermissionPoliciesForm'; +import { ReviewStep } from './ReviewStep'; import { RoleDetailsForm } from './RoleDetailsForm'; -import { RoleFormValues } from './types'; +import { PermissionPolicyRow, RoleFormValues } from './types'; type RoleFormProps = { membersData: { members: MemberEntity[]; loading: boolean; error: Error }; @@ -77,13 +83,23 @@ export const RoleForm = ({ roleName ? 'Unable to edit the role. ' : 'Unable to create role. ' }${(res as CreateRoleError).error.message}`, ); + } else if (roleName) { + setToastMessage(`Role ${roleName} updated successfully`); + navigate('/rbac'); } else { - if (roleName) { - setToastMessage(`Role ${roleName} updated successfully`); + const permissionsData = getPermissionPoliciesData(values); + const permissionsRes: Response | CreateRoleError = + await rbacApi.createPolicy(permissionsData); + if ((permissionsRes as unknown as CreateRoleError).error) { + throw new Error( + `Role was created successfully but unable to add permissions to the role. ${ + (permissionsRes as unknown as CreateRoleError).error.message + }`, + ); } else { setToastMessage(`Role ${newData.name} created successfully`); + navigate('/rbac'); } - navigate('/rbac'); } } catch (e) { formikHelpers.setStatus({ submitError: e }); @@ -101,6 +117,13 @@ export const RoleForm = ({ formik.validateField(fieldName); return formik.errors.selectedMembers; } + case 'permissionPoliciesRows': { + formik.values.permissionPoliciesRows.forEach((_pp, index) => { + formik.validateField(`permissionPoliciesRows[${index}].plugin`); + formik.validateField(`permissionPoliciesRows[${index}].permission`); + }); + return formik.errors.permissionPoliciesRows; + } default: return undefined; } @@ -115,6 +138,18 @@ export const RoleForm = ({ } }; + const canNextPermissionPoliciesStep = () => { + return ( + formik.values.permissionPoliciesRows.filter(pp => !!pp.plugin).length === + formik.values.permissionPoliciesRows.length && + (!formik.errors.permissionPoliciesRows || + ( + formik.errors + .permissionPoliciesRows as unknown as FormikErrors[] + )?.filter(err => !!err)?.length === 0) + ); + }; + const handleBack = () => setActiveStep(Math.max(activeStep - 1, 0)); const handleReset = (e: React.MouseEvent) => { @@ -174,8 +209,33 @@ export const RoleForm = ({ />
+ canNextPermissionPoliciesStep(), + onNext: () => handleNext('permissionPoliciesRows'), + showBack: true, + backText: 'Back', + onBack: handleBack, + }} + > + [] + } + setFieldValue={formik.setFieldValue} + setFieldError={formik.setFieldError} + handleBlur={formik.handleBlur} + /> + - + + +