From 724abc6bce658f32258b56eacc1970ae64b0d70f Mon Sep 17 00:00:00 2001 From: debsmita1 Date: Wed, 15 Nov 2023 23:32:31 +0530 Subject: [PATCH] feat(rbac): list roles --- packages/backend/package.json | 2 +- plugins/rbac/dev/index.tsx | 120 ++++++++++++- plugins/rbac/package.json | 9 +- plugins/rbac/src/api/RBACBackendClient.ts | 38 +++- plugins/rbac/src/components/DeleteRole.tsx | 35 ++++ .../rbac/src/components/DeleteRoleDialog.tsx | 164 ++++++++++++++++++ plugins/rbac/src/components/RbacPage.test.tsx | 29 ++-- plugins/rbac/src/components/RbacPage.tsx | 22 +-- .../rbac/src/components/RolesList.test.tsx | 122 +++++++++++++ plugins/rbac/src/components/RolesList.tsx | 97 +++++++++++ .../rbac/src/components/RolesListColumns.ts | 37 ++++ 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 +++++ 15 files changed, 824 insertions(+), 41 deletions(-) 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.ts 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 03d16925154..eb006ac93f9 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 605fb6671df..3309b9fe1b7 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 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 5b3e5776095..ea117d9f9db 100644 --- a/plugins/rbac/package.json +++ b/plugins/rbac/package.json @@ -25,16 +25,17 @@ "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", + "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 d6de45cb6af..bbde2203584 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.status; + } } diff --git a/plugins/rbac/src/components/DeleteRole.tsx b/plugins/rbac/src/components/DeleteRole.tsx new file mode 100644 index 00000000000..9bb256ad6e6 --- /dev/null +++ b/plugins/rbac/src/components/DeleteRole.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import { IconButton, Tooltip } from '@material-ui/core'; +import Delete from '@mui/icons-material/Delete'; + +type DeleteRoleProps = { + openDialog: (name: string) => void; + roleName: string; + disable: any; + tooltip?: string | undefined; + dataTestId: string; +}; + +const DeleteRole = ({ + openDialog, + roleName, + tooltip, + disable, + dataTestId, +}: DeleteRoleProps) => ( + + + 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 00000000000..0bcaf0ae722 --- /dev/null +++ b/plugins/rbac/src/components/DeleteRoleDialog.tsx @@ -0,0 +1,164 @@ +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'; + +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?: any; +}; + +const DeleteRoleDialog = ({ + open, + closeDialog, + roleName, + propOptions, +}: DeleteRoleDialogProps) => { + const classes = useStyles(); + const [deleteRoleValue, setDeleteRoleValue] = React.useState(); + const [errorText, setErrorText] = React.useState(''); + const [error, setError] = React.useState(''); + + const rbacApi = useApi(rbacApiRef); + + const deleteRole = async () => { + try { + await rbacApi.deleteRole(roleName); + } catch (err: any) { + setError(err instanceof Error ? err.message : `${err}`); + } + closeDialog(); + }; + + const onTextInput = (value: string) => { + setDeleteRoleValue(value); + if (value === '') { + setErrorText('Required'); + } else if (value === roleName) { + setErrorText(''); + } else { + setErrorText('Names donot match'); + } + }; + + 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 6c7f4a42dc5..3d741d3efba 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 8f2232ce3c8..1626a683319 100644 --- a/plugins/rbac/src/components/RbacPage.tsx +++ b/plugins/rbac/src/components/RbacPage.tsx @@ -1,12 +1,12 @@ 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 { RolesList } from './RolesList'; + 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 00000000000..1e05b9cee19 --- /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('disabled-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 00000000000..998f18c7618 --- /dev/null +++ b/plugins/rbac/src/components/RolesList.tsx @@ -0,0 +1,97 @@ +import React from 'react'; + +import { Progress, Table } from '@backstage/core-components'; + +import { makeStyles } from '@material-ui/core'; + +import { useRoles } from '../hooks/useRoles'; +import DeleteRole from './DeleteRole'; +import DeleteRoleDialog from './DeleteRoleDialog'; +import { columns } from './RolesListColumns'; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export const RolesList = () => { + const [open, setOpen] = React.useState(false); + const [deleteRole, setDeleteRole] = React.useState(''); + const classes = useStyles(); + const { loading, data, retry } = useRoles(); + const openDialog = (name: string) => { + setDeleteRole(name); + setOpen(true); + }; + + const closeDialog = () => { + setOpen(false); + retry(); + }; + + React.useEffect(() => { + columns.push({ + title: 'Actions', + sorting: false, + render: props => ( + + ), + }); + + return () => { + columns.pop(); + }; + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( + <> + + No roles found  + + } + /> + {open && ( + d.name === deleteRole)?.members, + permissions: data.find(d => d.name === deleteRole)?.permissions, + }} + /> + )} + + ); +}; diff --git a/plugins/rbac/src/components/RolesListColumns.ts b/plugins/rbac/src/components/RolesListColumns.ts new file mode 100644 index 00000000000..b80ca41ce83 --- /dev/null +++ b/plugins/rbac/src/components/RolesListColumns.ts @@ -0,0 +1,37 @@ +import { TableColumn } from '@backstage/core-components'; + +import { RolesData } from '../types'; +import { getMembers } from '../utils/rbac-utils'; + +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', + }, +]; diff --git a/plugins/rbac/src/hooks/useRoles.ts b/plugins/rbac/src/hooks/useRoles.ts new file mode 100644 index 00000000000..b60b4b78af0 --- /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 00000000000..a26b1c29a68 --- /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 00000000000..c79319dcab7 --- /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 00000000000..8a1c35003f4 --- /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; +};