From 8722056088a3214f6267c621ecc10e3658484a07 Mon Sep 17 00:00:00 2001 From: Debsmita Santra Date: Tue, 28 Nov 2023 18:38:46 +0530 Subject: [PATCH] feat(rbac): list roles (#937) --- packages/backend/package.json | 2 +- plugins/rbac/dev/index.tsx | 120 +++++++++++- plugins/rbac/package.json | 10 +- plugins/rbac/src/api/RBACBackendClient.ts | 38 +++- .../src/components/DeleteDialogContext.tsx | 31 +++ plugins/rbac/src/components/DeleteRole.tsx | 43 +++++ .../rbac/src/components/DeleteRoleDialog.tsx | 181 ++++++++++++++++++ plugins/rbac/src/components/RbacPage.test.tsx | 29 +-- plugins/rbac/src/components/RbacPage.tsx | 28 +-- .../rbac/src/components/RolesList.test.tsx | 122 ++++++++++++ plugins/rbac/src/components/RolesList.tsx | 101 ++++++++++ .../rbac/src/components/RolesListColumns.tsx | 58 ++++++ plugins/rbac/src/components/ToastContext.tsx | 25 +++ plugins/rbac/src/hooks/useRoles.ts | 63 ++++++ plugins/rbac/src/types.ts | 9 + plugins/rbac/src/utils/rbac-utils.test.ts | 72 +++++++ plugins/rbac/src/utils/rbac-utils.ts | 46 +++++ yarn.lock | 106 +++++++++- 18 files changed, 1038 insertions(+), 46 deletions(-) create mode 100644 plugins/rbac/src/components/DeleteDialogContext.tsx create mode 100644 plugins/rbac/src/components/DeleteRole.tsx create mode 100644 plugins/rbac/src/components/DeleteRoleDialog.tsx create mode 100644 plugins/rbac/src/components/RolesList.test.tsx create mode 100644 plugins/rbac/src/components/RolesList.tsx create mode 100644 plugins/rbac/src/components/RolesListColumns.tsx create mode 100644 plugins/rbac/src/components/ToastContext.tsx create mode 100644 plugins/rbac/src/hooks/useRoles.ts create mode 100644 plugins/rbac/src/types.ts create mode 100644 plugins/rbac/src/utils/rbac-utils.test.ts create mode 100644 plugins/rbac/src/utils/rbac-utils.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 03d1692515..eb006ac93f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -36,7 +36,7 @@ "@backstage/plugin-search-backend-module-pg": "^0.5.15", "@backstage/plugin-search-backend-node": "^1.2.10", "@backstage/plugin-techdocs-backend": "^1.8.0", - "@janus-idp/backstage-plugin-rbac-backend": "^1.0.0", + "@janus-idp/backstage-plugin-rbac-backend": "^1.6.4", "better-sqlite3": "^9.0.0", "dockerode": "^4.0.0", "express": "^4.18.2", diff --git a/plugins/rbac/dev/index.tsx b/plugins/rbac/dev/index.tsx index 605fb6671d..b8988a4fd9 100644 --- a/plugins/rbac/dev/index.tsx +++ b/plugins/rbac/dev/index.tsx @@ -7,6 +7,9 @@ import { } from '@backstage/plugin-permission-react'; import { TestApiProvider } from '@backstage/test-utils'; +import { Role, RoleBasedPolicy } from '@janus-idp/backstage-plugin-rbac-common'; + +import { RBACAPI, rbacApiRef } from '../src/api/RBACBackendClient'; import { RbacPage, rbacPlugin } from '../src/plugin'; class MockPermissionApi implements PermissionApi { @@ -21,12 +24,125 @@ class MockPermissionApi implements PermissionApi { } } -const mockApi = new MockPermissionApi({ result: 'ALLOW' }); +class MockRBACApi implements RBACAPI { + readonly resources; + + constructor(fixtureData: Role[]) { + this.resources = fixtureData; + } + async getRoles(): Promise { + return this.resources; + } + async getPolicies(): Promise { + return [ + { + entityReference: 'role:default/guests', + permission: 'catalog-entity', + policy: 'read', + effect: 'deny', + }, + { + entityReference: 'role:default/guests', + permission: 'catalog.entity.create', + policy: 'use', + effect: 'deny', + }, + { + entityReference: 'role:default/guests', + permission: 'catalog-entity', + policy: 'read', + effect: 'allow', + }, + { + entityReference: 'role:default/guests', + permission: 'catalog.entity.create', + policy: 'use', + effect: 'allow', + }, + { + entityReference: 'role:default/guests', + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + { + entityReference: 'role:default/guests', + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + { + entityReference: 'role:default/guests', + permission: 'policy.entity.read', + policy: 'use', + effect: 'allow', + }, + { + entityReference: 'role:default/guests', + permission: 'policy-entity', + policy: 'delete', + effect: 'allow', + }, + { + entityReference: 'role:default/rbac_admin', + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + { + entityReference: 'role:default/rbac_admin', + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + { + entityReference: 'role:default/rbac_admin', + permission: 'policy-entity', + policy: 'delete', + effect: 'allow', + }, + { + entityReference: 'role:default/rbac_admin', + permission: 'policy-entity', + policy: 'update', + effect: 'allow', + }, + ]; + } + + async getUserAuthorization(): Promise<{ status: string }> { + return { + status: 'Authorized', + }; + } + + async deleteRole(_roleName: string): Promise { + return { status: 204 }; + } +} + +const mockPermissionApi = new MockPermissionApi({ result: 'ALLOW' }); +const mockRBACApi = new MockRBACApi([ + { + memberReferences: ['user:default/guest'], + name: 'role:default/guests', + }, + { + memberReferences: ['user:default/xyz', 'group:default/admins'], + name: 'role:default/rbac_admin', + }, +]); + createDevApp() .registerPlugin(rbacPlugin) .addPage({ element: ( - + ), diff --git a/plugins/rbac/package.json b/plugins/rbac/package.json index 5b3e577609..ee9f5bcc68 100644 --- a/plugins/rbac/package.json +++ b/plugins/rbac/package.json @@ -25,16 +25,18 @@ "tsc": "tsc" }, "dependencies": { + "@backstage/catalog-model": "^1.4.3", "@backstage/core-components": "^0.13.6", "@backstage/core-plugin-api": "^1.7.0", + "@backstage/plugin-permission-react": "^0.4.16", "@backstage/theme": "^0.4.3", + "@janus-idp/backstage-plugin-rbac-common": "1.1.0", "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.11.3", "@material-ui/lab": "^4.0.0-alpha.45", - "react-use": "^17.4.0", - "@backstage/plugin-permission-react": "^0.4.16", - "@janus-idp/backstage-plugin-rbac-common": "1.1.0", - "@mui/icons-material": "5.14.11" + "@mui/icons-material": "5.14.11", + "@mui/material": "^5.14.18", + "react-use": "^17.4.0" }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0" diff --git a/plugins/rbac/src/api/RBACBackendClient.ts b/plugins/rbac/src/api/RBACBackendClient.ts index d6de45cb6a..508e8b1119 100644 --- a/plugins/rbac/src/api/RBACBackendClient.ts +++ b/plugins/rbac/src/api/RBACBackendClient.ts @@ -4,10 +4,14 @@ import { IdentityApi, } from '@backstage/core-plugin-api'; +import { Role, RoleBasedPolicy } from '@janus-idp/backstage-plugin-rbac-common'; + // @public export type RBACAPI = { getUserAuthorization: () => Promise<{ status: string }>; - getRoles: () => Promise; + getRoles: () => Promise; + getPolicies: () => Promise; + deleteRole: (role: string) => Promise; }; export type Options = { @@ -51,4 +55,36 @@ export class RBACBackendClient implements RBACAPI { }); return jsonResponse.json(); } + + async getPolicies() { + const { token: idToken } = await this.identityApi.getCredentials(); + const backendUrl = this.configApi.getString('backend.baseUrl'); + const jsonResponse = await fetch(`${backendUrl}/api/permission/policies`, { + headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), + }, + }); + return jsonResponse.json(); + } + + async deleteRole(role: string) { + const { token: idToken } = await this.identityApi.getCredentials(); + const backendUrl = this.configApi.getString('backend.baseUrl'); + const str = role.split(':'); + const kind = str[0]; + const namespace = str[1].split('/')[0]; + const name = str[1].split('/')[1]; + const jsonResponse = await fetch( + `${backendUrl}/api/permission/roles/${kind}/${namespace}/${name}`, + { + headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + method: 'DELETE', + }, + ); + return jsonResponse; + } } diff --git a/plugins/rbac/src/components/DeleteDialogContext.tsx b/plugins/rbac/src/components/DeleteDialogContext.tsx new file mode 100644 index 0000000000..28eda5f8ed --- /dev/null +++ b/plugins/rbac/src/components/DeleteDialogContext.tsx @@ -0,0 +1,31 @@ +import React, { createContext, useContext } from 'react'; + +type DeleteDialogContextType = { + deleteRoleName: string; + setDeleteRoleName: (name: string) => void; + openDialog: boolean; + setOpenDialog: (open: boolean) => void; +}; + +export const DeleteDialogContext = createContext({ + deleteRoleName: '', + setDeleteRoleName: () => {}, + openDialog: false, + setOpenDialog: () => {}, +}); + +export const DeleteDialogContextProvider = (props: any) => { + const [openDialog, setOpenDialog] = React.useState(false); + const [deleteRoleName, setDeleteRoleName] = React.useState(''); + + const deleteDialogContextProviderValue = React.useMemo( + () => ({ openDialog, setOpenDialog, deleteRoleName, setDeleteRoleName }), + [openDialog, setOpenDialog, deleteRoleName, setDeleteRoleName], + ); + return ( + + {props.children} + + ); +}; +export const useDeleteDialog = () => useContext(DeleteDialogContext); diff --git a/plugins/rbac/src/components/DeleteRole.tsx b/plugins/rbac/src/components/DeleteRole.tsx new file mode 100644 index 0000000000..2376051e58 --- /dev/null +++ b/plugins/rbac/src/components/DeleteRole.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { IconButton, Tooltip } from '@material-ui/core'; +import Delete from '@mui/icons-material/Delete'; + +import { useDeleteDialog } from './DeleteDialogContext'; + +type DeleteRoleProps = { + roleName: string; + disable: boolean; + tooltip?: string; + dataTestId: string; +}; + +const DeleteRole = ({ + roleName, + tooltip, + disable, + dataTestId, +}: DeleteRoleProps) => { + const { setDeleteRoleName, setOpenDialog } = useDeleteDialog(); + + const openDialog = (name: string) => { + setDeleteRoleName(name); + setOpenDialog(true); + }; + + return ( + + + openDialog(roleName)} + aria-label="Delete" + disabled={disable} + title={tooltip || 'Delete Role'} + > + + + + + ); +}; +export default DeleteRole; diff --git a/plugins/rbac/src/components/DeleteRoleDialog.tsx b/plugins/rbac/src/components/DeleteRoleDialog.tsx new file mode 100644 index 0000000000..99f20e75ad --- /dev/null +++ b/plugins/rbac/src/components/DeleteRoleDialog.tsx @@ -0,0 +1,181 @@ +import React from 'react'; + +import { useApi } from '@backstage/core-plugin-api'; + +import { + Box, + Button, + createStyles, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + makeStyles, + TextField, + Theme, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import ErrorIcon from '@material-ui/icons/Error'; +import { Alert } from '@material-ui/lab'; + +import { rbacApiRef } from '../api/RBACBackendClient'; +import { getMembers } from '../utils/rbac-utils'; +import { useToast } from './ToastContext'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + titleContainer: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, + }), +); + +type DeleteRoleDialogProps = { + open: boolean; + closeDialog: () => void; + roleName: string; + propOptions: { + memberRefs: string[]; + permissions: number; + }; +}; + +const DeleteRoleDialog = ({ + open, + closeDialog, + roleName, + propOptions, +}: DeleteRoleDialogProps) => { + const classes = useStyles(); + const { setToastMessage } = useToast(); + const [deleteRoleValue, setDeleteRoleValue] = React.useState(); + const [disableDelete, setDisableDelete] = React.useState(false); + const [error, setError] = React.useState(''); + + const rbacApi = useApi(rbacApiRef); + + const deleteRole = async () => { + try { + const response = await rbacApi.deleteRole(roleName); + if (response.status === 200 || response.status === 204) { + setToastMessage(`Role ${roleName} deleted successfully`); + closeDialog(); + } else { + setError(response.statusText); + } + } catch (err) { + setError(err instanceof Error ? err.message : `${err}`); + } + }; + + const onTextInput = (value: string) => { + setDeleteRoleValue(value); + if (value === '') { + setDisableDelete(true); + } else if (value === roleName) { + setDisableDelete(false); + } else { + setDisableDelete(true); + } + }; + + return ( + + + + + {' '} + Delete this role? + + + + + + + + + Are you sure you want to delete the role{' '} + {roleName} ? +
+
+ Deleting this role is irreversible and will remove its functionality + from the system. Proceed with caution. +
+
+ The{' '} + {`${getMembers( + propOptions.memberRefs, + ).toLowerCase()}`}{' '} + associated with this role will lose access to all the{' '} + {`${propOptions.permissions} permission policies`}{' '} + specified in this role. +
+
+
+ onTextInput(value)} + onBlur={({ target: { value } }) => onTextInput(value)} + /> +
+ {error && ( + + {`Unable to delete. ${error}`} + + )} + + + + +
+ ); +}; + +export default DeleteRoleDialog; diff --git a/plugins/rbac/src/components/RbacPage.test.tsx b/plugins/rbac/src/components/RbacPage.test.tsx index 6c7f4a42dc..3d741d3efb 100644 --- a/plugins/rbac/src/components/RbacPage.test.tsx +++ b/plugins/rbac/src/components/RbacPage.test.tsx @@ -4,48 +4,39 @@ import { RequirePermission, usePermission, } from '@backstage/plugin-permission-react'; -import { - renderInTestApp, - setupRequestMockHandlers, -} from '@backstage/test-utils'; +import { renderInTestApp } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; +import { useRoles } from '../hooks/useRoles'; import { RbacPage } from './RbacPage'; jest.mock('@backstage/plugin-permission-react', () => ({ usePermission: jest.fn(), RequirePermission: jest.fn(), })); + +jest.mock('../hooks/useRoles', () => ({ + useRoles: jest.fn(), +})); + const mockUsePermission = usePermission as jest.MockedFunction< typeof usePermission >; +const mockUseRoles = useRoles as jest.MockedFunction; + const RequirePermissionMock = RequirePermission as jest.MockedFunction< typeof RequirePermission >; describe('RbacPage', () => { - const server = setupServer(); - // Enable sane handlers for network requests - setupRequestMockHandlers(server); - // setup mock response - beforeEach(() => { - server.use( - rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))), - ); - }); - it('should render if authorized', async () => { RequirePermissionMock.mockImplementation(props => <>{props.children}); mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + mockUseRoles.mockReturnValue({ loading: true, data: [], retry: () => {} }); await renderInTestApp(); expect(screen.getByText('Administration')).toBeInTheDocument(); - expect( - screen.getByText('All content should be wrapped in a card like this.'), - ).toBeTruthy(); }); it('should not render if not authorized', async () => { diff --git a/plugins/rbac/src/components/RbacPage.tsx b/plugins/rbac/src/components/RbacPage.tsx index 8f2232ce3c..714ce07166 100644 --- a/plugins/rbac/src/components/RbacPage.tsx +++ b/plugins/rbac/src/components/RbacPage.tsx @@ -1,12 +1,14 @@ import React from 'react'; -import { Content, Header, InfoCard, Page } from '@backstage/core-components'; +import { Header, Page, TabbedLayout } from '@backstage/core-components'; import { RequirePermission } from '@backstage/plugin-permission-react'; -import { Grid, Typography } from '@material-ui/core'; - import { policyEntityReadPermission } from '@janus-idp/backstage-plugin-rbac-common'; +import { DeleteDialogContextProvider } from './DeleteDialogContext'; +import { RolesList } from './RolesList'; +import { ToastContextProvider } from './ToastContext'; + export const RbacPage = () => ( ( >
- - - - - - All content should be wrapped in a card like this. - - - - - + + + + + + + + + ); diff --git a/plugins/rbac/src/components/RolesList.test.tsx b/plugins/rbac/src/components/RolesList.test.tsx new file mode 100644 index 0000000000..5c79ae600e --- /dev/null +++ b/plugins/rbac/src/components/RolesList.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; + +import { + RequirePermission, + usePermission, +} from '@backstage/plugin-permission-react'; +import { renderInTestApp } from '@backstage/test-utils'; + +import { useRoles } from '../hooks/useRoles'; +import { RolesData } from '../types'; +import { RolesList } from './RolesList'; + +jest.mock('@backstage/plugin-permission-react', () => ({ + usePermission: jest.fn(), + RequirePermission: jest.fn(), +})); + +jest.mock('../hooks/useRoles', () => ({ + useRoles: jest.fn(), +})); + +const useRolesMockData: RolesData[] = [ + { + name: 'role:default/guests', + description: '-', + members: ['user:default/xyz'], + permissions: 2, + modifiedBy: '-', + lastModified: '-', + permissionResult: { allowed: true, loading: false }, + }, + { + name: 'role:default/rbac_admin', + description: '-', + members: ['user:default/xyz', 'group:default/hkhkh'], + permissions: 4, + modifiedBy: '-', + lastModified: '-', + permissionResult: { allowed: true, loading: false }, + }, +]; + +const mockUsePermission = usePermission as jest.MockedFunction< + typeof usePermission +>; + +const mockUseRoles = useRoles as jest.MockedFunction; + +const RequirePermissionMock = RequirePermission as jest.MockedFunction< + typeof RequirePermission +>; + +describe('RolesList', () => { + it('should show Progress when roles are still loading', async () => { + RequirePermissionMock.mockImplementation(props => <>{props.children}); + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + mockUseRoles.mockReturnValue({ loading: true, data: [], retry: () => {} }); + const { getByTestId } = await renderInTestApp(); + expect(getByTestId('roles-progress')).not.toBeNull(); + }); + + it('should show list of roles when the roles are loaded', async () => { + RequirePermissionMock.mockImplementation(props => <>{props.children}); + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + mockUseRoles.mockReturnValue({ + loading: false, + data: useRolesMockData, + retry: () => {}, + }); + const { queryByText } = await renderInTestApp(); + expect(queryByText('All roles (2)')).not.toBeNull(); + expect(queryByText('role:default/guests')).not.toBeNull(); + expect(queryByText('role:default/rbac_admin')).not.toBeNull(); + expect(queryByText('1 User, 1 Group')).not.toBeNull(); + }); + + it('should show empty state when there are no roles', async () => { + RequirePermissionMock.mockImplementation(props => <>{props.children}); + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + mockUseRoles.mockReturnValue({ loading: false, data: [], retry: () => {} }); + const { getByTestId } = await renderInTestApp(); + expect(getByTestId('roles-table-empty')).not.toBeNull(); + }); + + it('should show delete icon if user is authorized to delete roles', async () => { + RequirePermissionMock.mockImplementation(props => <>{props.children}); + mockUsePermission + .mockReturnValueOnce({ loading: false, allowed: true }) + .mockReturnValue({ loading: false, allowed: true }); + mockUseRoles.mockReturnValue({ + loading: false, + data: useRolesMockData, + retry: () => {}, + }); + const { getAllByTestId, getByText } = await renderInTestApp(); + expect(getAllByTestId('delete-role')).not.toBeNull(); + expect(getByText('Actions')).not.toBeNull(); + }); + + it('should show disabled delete icon if user is not authorized to delete roles', async () => { + RequirePermissionMock.mockImplementation(props => <>{props.children}); + mockUsePermission + .mockReturnValueOnce({ loading: false, allowed: true }) + .mockReturnValue({ loading: false, allowed: false }); + mockUseRoles.mockReturnValue({ + loading: false, + data: [ + { + ...useRolesMockData[0], + permissionResult: { allowed: false, loading: true }, + }, + { + ...useRolesMockData[0], + permissionResult: { allowed: false, loading: true }, + }, + ], + retry: () => {}, + }); + const { getAllByTestId } = await renderInTestApp(); + expect(getAllByTestId('disable-delete-role')).not.toBeNull(); + }); +}); diff --git a/plugins/rbac/src/components/RolesList.tsx b/plugins/rbac/src/components/RolesList.tsx new file mode 100644 index 0000000000..6430b4bd26 --- /dev/null +++ b/plugins/rbac/src/components/RolesList.tsx @@ -0,0 +1,101 @@ +import React from 'react'; + +import { Progress, Table } from '@backstage/core-components'; + +import { makeStyles } from '@material-ui/core'; +import Alert from '@mui/material/Alert'; +import Snackbar from '@mui/material/Snackbar'; + +import { useRoles } from '../hooks/useRoles'; +import { RolesData } from '../types'; +import { useDeleteDialog } from './DeleteDialogContext'; +import DeleteRoleDialog from './DeleteRoleDialog'; +import { columns } from './RolesListColumns'; +import { useToast } from './ToastContext'; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export const RolesList = () => { + const { toastMessage, setToastMessage } = useToast(); + const { openDialog, setOpenDialog, deleteRoleName } = useDeleteDialog(); + + const [roles, setRoles] = React.useState(); + const classes = useStyles(); + const { loading, data, retry } = useRoles(); + + const closeDialog = () => { + setOpenDialog(false); + retry(); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + const onAlertClose = () => { + setToastMessage(''); + }; + const onSearchResultsChange = (searchResults: RolesData[]) => { + setRoles(searchResults.length); + }; + + return ( + <> + + + {toastMessage} + + + onSearchResultsChange(summary.data)} + emptyContent={ +
+ No roles found  +
+ } + /> + {openDialog && ( + d.name === deleteRoleName)?.members || [], + permissions: + data.find(d => d.name === deleteRoleName)?.permissions || 0, + }} + /> + )} + + ); +}; diff --git a/plugins/rbac/src/components/RolesListColumns.tsx b/plugins/rbac/src/components/RolesListColumns.tsx new file mode 100644 index 0000000000..7f3dc4a80d --- /dev/null +++ b/plugins/rbac/src/components/RolesListColumns.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { TableColumn } from '@backstage/core-components'; + +import { RolesData } from '../types'; +import { getMembers } from '../utils/rbac-utils'; +import DeleteRole from './DeleteRole'; + +export const columns: TableColumn[] = [ + { + title: 'Name', + field: 'name', + type: 'string', + }, + { + title: 'Users and groups', + field: 'members', + type: 'string', + align: 'left', + render: props => getMembers(props.members), + customSort: (a, b) => { + if (a.members.length === 0) { + return -1; + } + if (b.members.length === 0) { + return 1; + } + if (a.members.length === b.members.length) { + return 0; + } + return a.members.length < b.members.length ? -1 : 1; + }, + }, + { + title: 'Permission Policies', + field: 'permissions', + type: 'numeric', + align: 'left', + }, + { + title: 'Actions', + sorting: false, + render: (props: RolesData) => ( + + ), + }, +]; diff --git a/plugins/rbac/src/components/ToastContext.tsx b/plugins/rbac/src/components/ToastContext.tsx new file mode 100644 index 0000000000..56244978b0 --- /dev/null +++ b/plugins/rbac/src/components/ToastContext.tsx @@ -0,0 +1,25 @@ +import React, { createContext, useContext } from 'react'; + +type ToastContextType = { + toastMessage: string; + setToastMessage: (message: string) => void; +}; + +export const ToastContext = createContext({ + toastMessage: '', + setToastMessage: () => {}, +}); + +export const ToastContextProvider = (props: any) => { + const [toastMessage, setToastMessage] = React.useState(''); + const toastContextProviderValue = React.useMemo( + () => ({ setToastMessage, toastMessage }), + [setToastMessage, toastMessage], + ); + return ( + + {props.children} + + ); +}; +export const useToast = () => useContext(ToastContext); diff --git a/plugins/rbac/src/hooks/useRoles.ts b/plugins/rbac/src/hooks/useRoles.ts new file mode 100644 index 0000000000..b60b4b78af --- /dev/null +++ b/plugins/rbac/src/hooks/useRoles.ts @@ -0,0 +1,63 @@ +import React from 'react'; +import { useAsync, useAsyncRetry, useInterval } from 'react-use'; + +import { useApi } from '@backstage/core-plugin-api'; +import { usePermission } from '@backstage/plugin-permission-react'; + +import { + policyEntityDeletePermission, + Role, + RoleBasedPolicy, +} from '@janus-idp/backstage-plugin-rbac-common'; + +import { rbacApiRef } from '../api/RBACBackendClient'; +import { RolesData } from '../types'; +import { getPermissions } from '../utils/rbac-utils'; + +export const useRoles = (pollInterval?: number) => { + const rbacApi = useApi(rbacApiRef); + const { + loading: rolesLoading, + value: roles, + retry, + } = useAsyncRetry(async () => await rbacApi.getRoles()); + + const { loading: policiesLoading, value: policies } = useAsync( + async () => await rbacApi.getPolicies(), + [], + ); + + const permissionResult = usePermission({ + permission: policyEntityDeletePermission, + resourceRef: policyEntityDeletePermission.resourceType, + }); + const data: RolesData[] = React.useMemo( + () => + roles && roles?.length > 0 + ? roles.reduce( + (acc: any, role: Role) => [ + ...acc, + { + id: role.name, + name: role.name, + description: '-', + members: role.memberReferences, + permissions: getPermissions( + role.name, + policies as RoleBasedPolicy[], + ), + modifiedBy: '-', + lastModified: '-', + permissionResult, + }, + ], + [], + ) + : [], + [roles, policies, permissionResult], + ); + const loading = rolesLoading && policiesLoading; + useInterval(() => retry(), loading ? null : pollInterval || 5000); + + return { loading, data, retry }; +}; diff --git a/plugins/rbac/src/types.ts b/plugins/rbac/src/types.ts new file mode 100644 index 0000000000..a26b1c29a6 --- /dev/null +++ b/plugins/rbac/src/types.ts @@ -0,0 +1,9 @@ +export type RolesData = { + name: string; + description: string; + members: string[]; + permissions: number; + modifiedBy: string; + lastModified: string; + permissionResult: { allowed: boolean; loading: boolean }; +}; diff --git a/plugins/rbac/src/utils/rbac-utils.test.ts b/plugins/rbac/src/utils/rbac-utils.test.ts new file mode 100644 index 0000000000..c79319dcab --- /dev/null +++ b/plugins/rbac/src/utils/rbac-utils.test.ts @@ -0,0 +1,72 @@ +import { getMembers, getPermissions } from './rbac-utils'; + +const mockPolicies = [ + { + entityReference: 'role:default/guests', + permission: 'catalog-entity', + policy: 'read', + effect: 'deny', + }, + { + entityReference: 'role:default/guests', + permission: 'catalog.entity.create', + policy: 'use', + effect: 'deny', + }, + { + entityReference: 'user:default/xyz', + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + { + entityReference: 'user:default/xyz', + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + { + entityReference: 'user:default/xyz', + permission: 'policy-entity', + policy: 'delete', + effect: 'allow', + }, + { + entityReference: 'user:default/xyz', + permission: 'catalog-entity', + policy: 'read', + effect: 'allow', + }, + { + entityReference: 'user:default/xyz', + permission: 'catalog.entity.create', + policy: 'use', + effect: 'allow', + }, +]; + +describe('rbac utils', () => { + it('should list associated permissions for a role', () => { + expect(getPermissions('role:default/guests', mockPolicies)).toBe(2); + }); + + it('should return number of users and groups in member references', () => { + expect(getMembers(['user:default/xyz', 'group:default/admins'])).toBe( + '1 User, 1 Group', + ); + + expect( + getMembers([ + 'user:default/xyz', + 'group:default/admins', + 'user:default/alice', + ]), + ).toBe('2 Users, 1 Group'); + + expect(getMembers(['user:default/xyz'])).toBe('1 User'); + + expect(getMembers(['group:default/xyz'])).toBe('1 Group'); + + expect(getMembers([])).toBe('No members'); + }); +}); diff --git a/plugins/rbac/src/utils/rbac-utils.ts b/plugins/rbac/src/utils/rbac-utils.ts new file mode 100644 index 0000000000..8a1c35003f --- /dev/null +++ b/plugins/rbac/src/utils/rbac-utils.ts @@ -0,0 +1,46 @@ +import { isUserEntity, parseEntityRef } from '@backstage/catalog-model'; + +import { RoleBasedPolicy } from '@janus-idp/backstage-plugin-rbac-common'; + +export const getPermissions = ( + role: string, + policies: RoleBasedPolicy[], +): number => { + if (!policies || policies?.length === 0) { + return 0; + } + return policies.filter( + (policy: RoleBasedPolicy) => policy.entityReference === role, + ).length; +}; + +export const getMembers = (memberReferences: string[]): string => { + if (!memberReferences || memberReferences.length === 0) { + return 'No members'; + } + + const res = memberReferences.reduce( + (acc, member) => { + const entity = parseEntityRef(member) as any; + if (isUserEntity(entity)) { + acc.users++; + } else { + acc.groups++; + } + return acc; + }, + { users: 0, groups: 0 }, + ); + + let members = ''; + if (res.users > 0) { + members = `${res.users} ${res.users > 1 ? 'Users' : 'User'}`; + } + if (res.groups > 0) { + members = members.concat( + members.length > 0 ? ', ' : '', + `${res.groups} ${res.groups > 1 ? 'Groups' : 'Group'}`, + ); + } + return members; +}; diff --git a/yarn.lock b/yarn.lock index b8a09cc78b..384831f7e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2626,7 +2626,7 @@ dependencies: regenerator-runtime "^0.13.11" -"@babel/runtime@^7.13.10", "@babel/runtime@^7.17.8": +"@babel/runtime@^7.13.10", "@babel/runtime@^7.17.8", "@babel/runtime@^7.23.2": version "7.23.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.4.tgz#36fa1d2b36db873d25ec631dcc4923fdc1cf2e2e" integrity sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg== @@ -5425,7 +5425,7 @@ "@floating-ui/core" "^1.4.2" "@floating-ui/utils" "^0.1.3" -"@floating-ui/react-dom@^2.0.0": +"@floating-ui/react-dom@^2.0.0", "@floating-ui/react-dom@^2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.4.tgz#b076fafbdfeb881e1d86ae748b7ff95150e9f3ec" integrity sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ== @@ -6237,11 +6237,29 @@ clsx "^2.0.0" prop-types "^15.8.1" +"@mui/base@5.0.0-beta.24": + version "5.0.0-beta.24" + resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-beta.24.tgz#1a0638388291828dacf9547b466bc21fbaad3a2a" + integrity sha512-bKt2pUADHGQtqWDZ8nvL2Lvg2GNJyd/ZUgZAJoYzRgmnxBL9j36MSlS3+exEdYkikcnvVafcBtD904RypFKb0w== + dependencies: + "@babel/runtime" "^7.23.2" + "@floating-ui/react-dom" "^2.0.4" + "@mui/types" "^7.2.9" + "@mui/utils" "^5.14.18" + "@popperjs/core" "^2.11.8" + clsx "^2.0.0" + prop-types "^15.8.1" + "@mui/core-downloads-tracker@^5.14.14": version "5.14.14" resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.14.tgz#a54894e9b4dc908ab2d59eac543219d9018448e6" integrity sha512-Rw/xKiTOUgXD8hdKqj60aC6QcGprMipG7ne2giK6Mz7b4PlhL/xog9xLeclY3BxsRLkZQ05egFnIEY1CSibTbw== +"@mui/core-downloads-tracker@^5.14.18": + version "5.14.18" + resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.18.tgz#f8b187dc89756fa5c0b7d15aea537a6f73f0c2d8" + integrity sha512-yFpF35fEVDV81nVktu0BE9qn2dD/chs7PsQhlyaV3EnTeZi9RZBuvoEfRym1/jmhJ2tcfeWXiRuHG942mQXJJQ== + "@mui/icons-material@5.14.11": version "5.14.11" resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.14.11.tgz#ce563d1b6c7abc76f8a8048c970135601e7b49b5" @@ -6267,6 +6285,24 @@ react-is "^18.2.0" react-transition-group "^4.4.5" +"@mui/material@^5.14.18": + version "5.14.18" + resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.14.18.tgz#d0a89be3e27afe90135d542ddbf160b3f34e869c" + integrity sha512-y3UiR/JqrkF5xZR0sIKj6y7xwuEiweh9peiN3Zfjy1gXWXhz5wjlaLdoxFfKIEBUFfeQALxr/Y8avlHH+B9lpQ== + dependencies: + "@babel/runtime" "^7.23.2" + "@mui/base" "5.0.0-beta.24" + "@mui/core-downloads-tracker" "^5.14.18" + "@mui/system" "^5.14.18" + "@mui/types" "^7.2.9" + "@mui/utils" "^5.14.18" + "@types/react-transition-group" "^4.4.8" + clsx "^2.0.0" + csstype "^3.1.2" + prop-types "^15.8.1" + react-is "^18.2.0" + react-transition-group "^4.4.5" + "@mui/private-theming@^5.14.14": version "5.14.14" resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.14.14.tgz#035dde1eb30c896c69a12b7dee1dce3a323c66e9" @@ -6276,6 +6312,15 @@ "@mui/utils" "^5.14.13" prop-types "^15.8.1" +"@mui/private-theming@^5.14.18": + version "5.14.18" + resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.14.18.tgz#98f97139ea21570493391ab377c1deb47fc6d1a2" + integrity sha512-WSgjqRlzfHU+2Rou3HlR2Gqfr4rZRsvFgataYO3qQ0/m6gShJN+lhVEvwEiJ9QYyVzMDvNpXZAcqp8Y2Vl+PAw== + dependencies: + "@babel/runtime" "^7.23.2" + "@mui/utils" "^5.14.18" + prop-types "^15.8.1" + "@mui/styled-engine@^5.14.13": version "5.14.14" resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.14.14.tgz#b0ededf531fff1ef110f7b263c2d3d95a0b8ec9a" @@ -6286,6 +6331,16 @@ csstype "^3.1.2" prop-types "^15.8.1" +"@mui/styled-engine@^5.14.18": + version "5.14.18" + resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.14.18.tgz#82d427bc975b85cecdbab2fd9353ed6c2df7eae1" + integrity sha512-pW8bpmF9uCB5FV2IPk6mfbQCjPI5vGI09NOLhtGXPeph/4xIfC3JdIX0TILU0WcTs3aFQqo6s2+1SFgIB9rCXA== + dependencies: + "@babel/runtime" "^7.23.2" + "@emotion/cache" "^11.11.0" + csstype "^3.1.2" + prop-types "^15.8.1" + "@mui/system@^5.14.14": version "5.14.14" resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.14.14.tgz#f33327e74230523169107ace960e8bb51cbdbab7" @@ -6300,11 +6355,30 @@ csstype "^3.1.2" prop-types "^15.8.1" +"@mui/system@^5.14.18": + version "5.14.18" + resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.14.18.tgz#0f671e8f0a5e8e965b79235d77c50098f54195b5" + integrity sha512-hSQQdb3KF72X4EN2hMEiv8EYJZSflfdd1TRaGPoR7CIAG347OxCslpBUwWngYobaxgKvq6xTrlIl+diaactVww== + dependencies: + "@babel/runtime" "^7.23.2" + "@mui/private-theming" "^5.14.18" + "@mui/styled-engine" "^5.14.18" + "@mui/types" "^7.2.9" + "@mui/utils" "^5.14.18" + clsx "^2.0.0" + csstype "^3.1.2" + prop-types "^15.8.1" + "@mui/types@^7.2.6": version "7.2.6" resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.6.tgz#d72b9e9eb0032e107e76033932d65c3f731d2608" integrity sha512-7sjLQrUmBwufm/M7jw/quNiPK/oor2+pGUQP2CULRcFCArYTq78oJ3D5esTaL0UMkXKJvDqXn6Ike69yAOBQng== +"@mui/types@^7.2.9": + version "7.2.9" + resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.9.tgz#730ee83a37af292a5973962f78ce5c95f31213a7" + integrity sha512-k1lN/PolaRZfNsRdAqXtcR71sTnv3z/VCCGPxU8HfdftDkzi335MdJ6scZxvofMAd/K/9EbzCZTFBmlNpQVdCg== + "@mui/utils@^5.14.13": version "5.14.14" resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.14.14.tgz#7b2a0bcfb44c3376fc81f85500f9bd01706682ac" @@ -6315,6 +6389,16 @@ prop-types "^15.8.1" react-is "^18.2.0" +"@mui/utils@^5.14.18": + version "5.14.18" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.14.18.tgz#d2a46df9b06230423ba6b6317748b27185a56ac3" + integrity sha512-HZDRsJtEZ7WMSnrHV9uwScGze4wM/Y+u6pDVo+grUjt5yXzn+wI8QX/JwTHh9YSw/WpnUL80mJJjgCnWj2VrzQ== + dependencies: + "@babel/runtime" "^7.23.2" + "@types/prop-types" "^15.7.10" + prop-types "^15.8.1" + react-is "^18.2.0" + "@n1ru4l/push-pull-async-iterable-iterator@^3.1.0": version "3.2.0" resolved "https://registry.yarnpkg.com/@n1ru4l/push-pull-async-iterable-iterator/-/push-pull-async-iterable-iterator-3.2.0.tgz#c15791112db68dd9315d329d652b7e797f737655" @@ -11668,6 +11752,11 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.9.tgz#b6f785caa7ea1fe4414d9df42ee0ab67f23d8a6d" integrity sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g== +"@types/prop-types@^15.7.10": + version "15.7.11" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563" + integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng== + "@types/qs@*": version "6.9.9" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.9.tgz#66f7b26288f6799d279edf13da7ccd40d2fa9197" @@ -11735,10 +11824,17 @@ dependencies: "@types/react" "*" +"@types/react-transition-group@^4.4.8": + version "4.4.9" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.9.tgz#12a1a1b5b8791067198149867b0823fbace31579" + integrity sha512-ZVNmWumUIh5NhH8aMD9CR2hdW0fNuYInlocZHaZ+dgk/1K49j1w/HoAuK1ki+pgscQrOFRTlXeoURtuzEkV3dg== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@17.0.68", "@types/react@>=16", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^17", "@types/react@^17.0.68": - version "17.0.71" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.71.tgz#3673d446ad482b1564e44bf853b3ab5bcbc942c4" - integrity sha512-lfqOu9mp16nmaGRrS8deS2Taqhd5Ih0o92Te5Ws6I1py4ytHBcXLqh0YIqVsViqwVI5f+haiFM6hju814BzcmA== + version "17.0.70" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.70.tgz#35301a9cb94ba1a65dc306b7ce169a2c4fda1986" + integrity sha512-yqYMK49/cnqw+T8R9/C+RNjRddYmPDGI5lKHi3bOYceQCBAh8X2ngSbZP0gnVeyvHr0T7wEgIIGKT1usNol08w== dependencies: "@types/prop-types" "*" "@types/scheduler" "*"