diff --git a/plugins/rbac-common/src/types.ts b/plugins/rbac-common/src/types.ts index b4067159203..b4fbba82720 100644 --- a/plugins/rbac-common/src/types.ts +++ b/plugins/rbac-common/src/types.ts @@ -17,3 +17,8 @@ export type UpdatePolicy = { oldPolicy: Policy; newPolicy: Policy; }; + +export type PermissionPolicy = { + pluginId?: string; + policies?: Policy[]; +}; diff --git a/plugins/rbac/dev/index.tsx b/plugins/rbac/dev/index.tsx index 3309b9fe1b7..71641c7bff7 100644 --- a/plugins/rbac/dev/index.tsx +++ b/plugins/rbac/dev/index.tsx @@ -7,10 +7,16 @@ import { } from '@backstage/plugin-permission-react'; import { TestApiProvider } from '@backstage/test-utils'; -import { Role, RoleBasedPolicy } from '@janus-idp/backstage-plugin-rbac-common'; +import { + PermissionPolicy, + Policy, + Role, + RoleBasedPolicy, +} from '@janus-idp/backstage-plugin-rbac-common'; import { RBACAPI, rbacApiRef } from '../src/api/RBACBackendClient'; import { RbacPage, rbacPlugin } from '../src/plugin'; +import { MemberEntity } from '../src/types'; class MockPermissionApi implements PermissionApi { readonly result; @@ -116,9 +122,358 @@ class MockRBACApi implements RBACAPI { }; } + async getRole(role: string): Promise { + const roleresource = this.resources.find(res => res.name === role); + return roleresource ? [roleresource] : []; + } + async deleteRole(_roleName: string): Promise { return 204; } + + async getMembers(): Promise { + return [ + { + metadata: { + namespace: 'default', + annotations: {}, + name: 'team-d', + description: 'Team D', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: { + displayName: 'Team D', + }, + parent: 'boxoffice', + children: [], + }, + relations: [ + { + type: 'childOf', + targetRef: 'group:default/boxoffice', + }, + { + type: 'hasMember', + targetRef: 'user:default/eva.macdowell', + }, + { + type: 'hasMember', + targetRef: 'user:default/lucy.sheehan', + }, + ], + }, + { + metadata: { + namespace: 'default', + annotations: {}, + name: 'infrastructure', + description: 'The infra department', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'department', + parent: 'acme-corp', + children: ['backstage', 'boxoffice'], + }, + relations: [], + }, + { + metadata: { + namespace: 'default', + annotations: {}, + name: 'guest', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + spec: { + profile: { + displayName: 'Guest User', + }, + memberOf: ['team-a'], + }, + relations: [ + { + type: 'memberOf', + targetRef: 'group:default/team-a', + }, + ], + }, + { + metadata: { + namespace: 'default', + annotations: {}, + name: 'janus-authors', + title: 'Janus-IDP Authors', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + children: [], + }, + relations: [], + }, + { + metadata: { + namespace: 'default', + annotations: {}, + name: 'team-a', + description: 'Team A', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: {}, + parent: 'backstage', + children: [], + }, + relations: [ + { + type: 'childOf', + targetRef: 'group:default/backstage', + }, + { + type: 'hasMember', + targetRef: 'user:default/breanna.davison', + }, + { + type: 'hasMember', + targetRef: 'user:default/guest', + }, + { + type: 'hasMember', + targetRef: 'user:default/janelle.dawe', + }, + { + type: 'hasMember', + targetRef: 'user:default/nigel.manning', + }, + ], + }, + { + metadata: { + namespace: 'default', + annotations: {}, + name: 'backstage', + description: 'The backstage sub-department', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'sub-department', + profile: { + displayName: 'Backstage', + }, + parent: 'infrastructure', + children: ['team-a', 'team-b'], + }, + relations: [], + }, + { + metadata: { + namespace: 'default', + annotations: {}, + name: 'team-b', + description: 'Team B', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: { + displayName: 'Team B', + }, + parent: 'backstage', + children: [], + }, + relations: [ + { + type: 'hasMember', + targetRef: 'user:default/amelia.park', + }, + { + type: 'hasMember', + targetRef: 'user:default/colette.brock', + }, + { + type: 'hasMember', + targetRef: 'user:default/jenny.doe', + }, + { + type: 'hasMember', + targetRef: 'user:default/jonathon.page', + }, + { + type: 'hasMember', + targetRef: 'user:default/justine.barrow', + }, + ], + }, + { + metadata: { + namespace: 'default', + annotations: {}, + name: 'lucy.sheehan', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + spec: { + profile: { + displayName: 'Lucy Sheehan', + }, + memberOf: ['team-d'], + }, + relations: [ + { + type: 'memberOf', + targetRef: 'group:default/team-d', + }, + ], + }, + { + metadata: { + namespace: 'default', + annotations: {}, + name: 'boxoffice', + description: 'The boxoffice sub-department', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'sub-department', + profile: { + displayName: 'Box Office', + }, + parent: 'infrastructure', + children: ['team-c', 'team-d'], + }, + relations: [ + { + type: 'childOf', + targetRef: 'group:default/infrastructure', + }, + { + type: 'parentOf', + targetRef: 'group:default/team-c', + }, + { + type: 'parentOf', + targetRef: 'group:default/team-d', + }, + ], + }, + { + metadata: { + namespace: 'default', + annotations: {}, + name: 'amelia.park', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + spec: { + profile: { + displayName: 'Amelia Park', + }, + memberOf: ['team-b'], + }, + relations: [ + { + type: 'memberOf', + targetRef: 'group:default/team-b', + }, + ], + }, + ]; + } + + async listPermissions(): Promise { + return [ + { + pluginId: 'catalog', + policies: [ + { + permission: 'catalog-entity', + policy: 'read', + }, + { + permission: 'catalog.entity.create', + policy: 'create', + }, + { + permission: 'catalog-entity', + policy: 'delete', + }, + { + permission: 'catalog-entity', + policy: 'update', + }, + { + permission: 'catalog.location.read', + policy: 'read', + }, + { + permission: 'catalog.location.create', + policy: 'create', + }, + { + permission: 'catalog.location.delete', + policy: 'delete', + }, + ], + }, + { + pluginId: 'scaffolder', + policies: [ + { + permission: 'scaffolder-template', + policy: 'read', + }, + { + permission: 'scaffolder-template', + policy: 'read', + }, + { + permission: 'scaffolder-action', + policy: 'use', + }, + ], + }, + { + pluginId: 'permission', + policies: [ + { + permission: 'policy-entity', + policy: 'read', + }, + { + permission: 'policy-entity', + policy: 'create', + }, + { + permission: 'policy-entity', + policy: 'delete', + }, + { + permission: 'policy-entity', + policy: 'update', + }, + ], + }, + ]; + } + + async deletePolicy( + _entityRef: string, + _permission: string, + _policies: Policy[], + ): Promise { + return 204; + } } const mockPermissionApi = new MockPermissionApi({ result: 'ALLOW' }); diff --git a/plugins/rbac/package.json b/plugins/rbac/package.json index ea117d9f9db..33c0378af6a 100644 --- a/plugins/rbac/package.json +++ b/plugins/rbac/package.json @@ -28,6 +28,7 @@ "@backstage/catalog-model": "^1.4.3", "@backstage/core-components": "^0.13.6", "@backstage/core-plugin-api": "^1.7.0", + "@backstage/plugin-catalog": "^1.15.1", "@backstage/plugin-permission-react": "^0.4.16", "@backstage/theme": "^0.4.3", "@janus-idp/backstage-plugin-rbac-common": "1.1.0", @@ -38,7 +39,8 @@ "react-use": "^17.4.0" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0" + "react": "^16.13.1 || ^17.0.0", + "react-router-dom": "^6.20.0" }, "devDependencies": { "@backstage/cli": "0.23.0", diff --git a/plugins/rbac/src/api/RBACBackendClient.ts b/plugins/rbac/src/api/RBACBackendClient.ts index bbde2203584..fd05b36687f 100644 --- a/plugins/rbac/src/api/RBACBackendClient.ts +++ b/plugins/rbac/src/api/RBACBackendClient.ts @@ -4,7 +4,14 @@ import { IdentityApi, } from '@backstage/core-plugin-api'; -import { Role, RoleBasedPolicy } from '@janus-idp/backstage-plugin-rbac-common'; +import { + PermissionPolicy, + Role, + RoleBasedPolicy, +} from '@janus-idp/backstage-plugin-rbac-common'; + +import { MemberEntity } from '../types'; +import { getKindNamespaceName } from '../utils/rbac-utils'; // @public export type RBACAPI = { @@ -12,6 +19,9 @@ export type RBACAPI = { getRoles: () => Promise; getPolicies: () => Promise; deleteRole: (role: string) => Promise; + getRole: (role: string) => Promise; + getMembers: () => Promise; + listPermissions: () => Promise; }; export type Options = { @@ -70,21 +80,63 @@ export class RBACBackendClient implements RBACAPI { 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 { kind, namespace, name } = getKindNamespaceName(role); 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; } + + async getRole(role: string) { + const { token: idToken } = await this.identityApi.getCredentials(); + const backendUrl = this.configApi.getString('backend.baseUrl'); + const { kind, namespace, name } = getKindNamespaceName(role); + const jsonResponse = await fetch( + `${backendUrl}/api/permission/roles/${kind}/${namespace}/${name}`, + { + headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), + 'Content-Type': 'application/json', + }, + }, + ); + return jsonResponse.json(); + } + + async getMembers() { + const { token: idToken } = await this.identityApi.getCredentials(); + const backendUrl = this.configApi.getString('backend.baseUrl'); + const jsonResponse = await fetch( + `${backendUrl}/api/catalog/entities?filter=kind=user&filter=kind=group`, + { + headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), + 'Content-Type': 'application/json', + }, + }, + ); + return jsonResponse.json(); + } + + async listPermissions() { + const { token: idToken } = await this.identityApi.getCredentials(); + const backendUrl = this.configApi.getString('backend.baseUrl'); + const jsonResponse = await fetch( + `${backendUrl}/api/permission/plugins/policies`, + { + headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), + 'Content-Type': 'application/json', + }, + }, + ); + return jsonResponse.json(); + } } diff --git a/plugins/rbac/src/components/AboutCard.tsx b/plugins/rbac/src/components/AboutCard.tsx new file mode 100644 index 00000000000..71ab7b32386 --- /dev/null +++ b/plugins/rbac/src/components/AboutCard.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +import { MarkdownContent } from '@backstage/core-components'; +import { AboutField } from '@backstage/plugin-catalog'; + +import { + Card, + CardContent, + CardHeader, + Grid, + makeStyles, +} from '@material-ui/core'; + +const useStyles = makeStyles({ + gridItemCard: { + display: 'flex', + flexDirection: 'column', + height: 'calc(100% - 10px)', // for pages without content header + marginBottom: '10px', + }, + fullHeightCard: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + gridItemCardContent: { + flex: 1, + }, + fullHeightCardContent: { + flex: 1, + }, + text: { + wordBreak: 'break-word', + }, +}); + +export const AboutCard = () => { + const classes = useStyles(); + const cardClass = classes.gridItemCard; + const cardContentClass = classes.gridItemCardContent; + + return ( + + + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/rbac/src/components/DeleteRole.tsx b/plugins/rbac/src/components/DeleteRole.tsx index 9bb256ad6e6..6a3e80b68bf 100644 --- a/plugins/rbac/src/components/DeleteRole.tsx +++ b/plugins/rbac/src/components/DeleteRole.tsx @@ -7,7 +7,7 @@ type DeleteRoleProps = { openDialog: (name: string) => void; roleName: string; disable: any; - tooltip?: string | undefined; + tooltip?: string | null; dataTestId: string; }; diff --git a/plugins/rbac/src/components/MembersCard.tsx b/plugins/rbac/src/components/MembersCard.tsx new file mode 100644 index 00000000000..18fc9813242 --- /dev/null +++ b/plugins/rbac/src/components/MembersCard.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { useAsync, useAsyncRetry } from 'react-use'; + +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { Table } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; + +import { + Card, + CardContent, + CardHeader, + IconButton, + makeStyles, +} from '@material-ui/core'; +import CachedIcon from '@material-ui/icons/Cached'; + +import { rbacApiRef } from '../api/RBACBackendClient'; +import { MembersData } from '../types'; +import { getMembers, getMembersFromGroup } from '../utils/rbac-utils'; +import { columns } from './MembersListColumns'; + +type MembersCardProps = { + roleName: string; +}; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export const MembersCard = ({ roleName }: MembersCardProps) => { + const rbacApi = useApi(rbacApiRef); + const { + loading: rolesLoading, + value: role, + retry, + } = useAsyncRetry(async () => { + return await rbacApi.getRole(roleName); + }); + + const classes = useStyles(); + const { + loading: membersLoading, + value: members, + error, + } = useAsync(async () => { + return await rbacApi.getMembers(); + }); + + if (!membersLoading && error) { + return null; + } + + const data = role?.[0].memberReferences.reduce((acc: MembersData[], ref) => { + const memberResource = members?.find( + member => stringifyEntityRef(member) === ref, + ); + if (memberResource) { + acc.push({ + name: + memberResource.spec.profile?.displayName ?? + memberResource.metadata.name, + type: memberResource.kind, + ref: { + namespace: memberResource.metadata.namespace ?? '', + kind: memberResource.kind.toLowerCase(), + name: memberResource.metadata.name, + }, + members: + memberResource.kind === 'Group' + ? getMembersFromGroup(memberResource) + : 1, + }); + } + return acc; + }, []); + + return ( + + + + + } + /> + + + No members found  + + } + /> + + + ); +}; diff --git a/plugins/rbac/src/components/MembersListColumns.tsx b/plugins/rbac/src/components/MembersListColumns.tsx new file mode 100644 index 00000000000..940c2d54bec --- /dev/null +++ b/plugins/rbac/src/components/MembersListColumns.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { Link, TableColumn } from '@backstage/core-components'; + +import { MembersData } from '../types'; + +export const columns: TableColumn[] = [ + { + title: 'Name', + field: 'name', + type: 'string', + render: props => { + return ( + + {props.name} + + ); + }, + }, + { + title: 'Type', + field: 'type', + type: 'string', + }, + { + title: 'Users and groups', + field: 'members', + type: 'numeric', + align: 'left', + customSort: (a, b) => { + if (a.members === 0) { + return -1; + } + if (b.members === 0) { + return 1; + } + if (a.members === b.members) { + return 0; + } + return a.members < b.members ? -1 : 1; + }, + }, +]; diff --git a/plugins/rbac/src/components/PermissionsCard.tsx b/plugins/rbac/src/components/PermissionsCard.tsx new file mode 100644 index 00000000000..5648f2278cb --- /dev/null +++ b/plugins/rbac/src/components/PermissionsCard.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { useAsync, useAsyncRetry } from 'react-use'; + +import { Table } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; + +import { + Card, + CardContent, + CardHeader, + IconButton, + makeStyles, +} from '@material-ui/core'; +import CachedIcon from '@material-ui/icons/Cached'; + +import { rbacApiRef } from '../api/RBACBackendClient'; +import { getPermissionsData } from '../utils/rbac-utils'; +import { columns } from './PermissionsListColumns'; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +type PermissionsCardProps = { + entityReference: string; +}; + +export const PermissionsCard = ({ entityReference }: PermissionsCardProps) => { + const rbacApi = useApi(rbacApiRef); + const { + loading: policiesLoading, + value: policies, + error, + retry, + } = useAsyncRetry(async () => { + return await rbacApi.getPolicies(); + }); + const { value: permissionPolicies } = useAsync(async () => { + return await rbacApi.listPermissions(); + }); + + const classes = useStyles(); + + if (!policiesLoading && error) { + return null; + } + + const data = getPermissionsData( + policies || [], + permissionPolicies || [], + entityReference, + ); + + return ( + + + + + } + /> + +
+ No permissions found  + + } + /> + + + ); +}; diff --git a/plugins/rbac/src/components/PermissionsListColumns.tsx b/plugins/rbac/src/components/PermissionsListColumns.tsx new file mode 100644 index 00000000000..8e2f09aa93b --- /dev/null +++ b/plugins/rbac/src/components/PermissionsListColumns.tsx @@ -0,0 +1,33 @@ +import { TableColumn } from '@backstage/core-components'; + +import { PermissionsData } from '../types'; + +export const columns: TableColumn[] = [ + { + title: 'Plugin', + field: 'plugin', + type: 'string', + }, + { + title: 'Permission', + field: 'permission', + type: 'string', + }, + { + title: 'Policies', + field: 'policyString', + type: 'string', + customSort: (a, b) => { + if (a.policies.size === 0) { + return -1; + } + if (b.policies.size === 0) { + return 1; + } + if (a.policies.size === b.policies.size) { + return 0; + } + return a.policies.size < b.policies.size ? -1 : 1; + }, + }, +]; diff --git a/plugins/rbac/src/components/RoleOverviewPage.tsx b/plugins/rbac/src/components/RoleOverviewPage.tsx new file mode 100644 index 00000000000..1466522a570 --- /dev/null +++ b/plugins/rbac/src/components/RoleOverviewPage.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { + Breadcrumbs, + Content, + Link, + Page, + TabbedLayout, +} from '@backstage/core-components'; + +import { Grid, Typography } from '@material-ui/core'; + +import { AboutCard } from './AboutCard'; +import { MembersCard } from './MembersCard'; +import { PermissionsCard } from './PermissionsCard'; + +export const RoleOverviewPage = () => { + const { roleName, roleKind } = useParams(); + + return ( + + + + RBAC + {`${roleKind}/${roleName}`} + + + {`${roleKind}/${roleName}`} + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/rbac/src/components/RolesList.test.tsx b/plugins/rbac/src/components/RolesList.test.tsx index 1e05b9cee19..ab9cecec369 100644 --- a/plugins/rbac/src/components/RolesList.test.tsx +++ b/plugins/rbac/src/components/RolesList.test.tsx @@ -51,14 +51,6 @@ const RequirePermissionMock = RequirePermission as jest.MockedFunction< >; 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 }); diff --git a/plugins/rbac/src/components/RolesList.tsx b/plugins/rbac/src/components/RolesList.tsx index 998f18c7618..26c88d70019 100644 --- a/plugins/rbac/src/components/RolesList.tsx +++ b/plugins/rbac/src/components/RolesList.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Progress, Table } from '@backstage/core-components'; +import { Table } from '@backstage/core-components'; import { makeStyles } from '@material-ui/core'; @@ -60,20 +60,13 @@ export const RolesList = () => { }; }, []); - if (loading) { - return ( -
- -
- ); - } - return ( <>
diff --git a/plugins/rbac/src/components/RolesListColumns.ts b/plugins/rbac/src/components/RolesListColumns.tsx similarity index 79% rename from plugins/rbac/src/components/RolesListColumns.ts rename to plugins/rbac/src/components/RolesListColumns.tsx index b80ca41ce83..853e6a66c9e 100644 --- a/plugins/rbac/src/components/RolesListColumns.ts +++ b/plugins/rbac/src/components/RolesListColumns.tsx @@ -1,4 +1,6 @@ -import { TableColumn } from '@backstage/core-components'; +import React from 'react'; + +import { Link, TableColumn } from '@backstage/core-components'; import { RolesData } from '../types'; import { getMembers } from '../utils/rbac-utils'; @@ -8,6 +10,9 @@ export const columns: TableColumn[] = [ title: 'Name', field: 'name', type: 'string', + render: (props: RolesData) => ( + {props.name} + ), }, { title: 'Users and groups', diff --git a/plugins/rbac/src/components/Router.tsx b/plugins/rbac/src/components/Router.tsx new file mode 100644 index 00000000000..ddbe95405dd --- /dev/null +++ b/plugins/rbac/src/components/Router.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Route, Routes } from 'react-router-dom'; + +import { roleRouteRef } from '../routes'; +import { RbacPage } from './RbacPage'; +import { RoleOverviewPage } from './RoleOverviewPage'; + +/** + * + * @public + */ +export const Router = () => ( + + } /> + } /> + +); diff --git a/plugins/rbac/src/components/index.ts b/plugins/rbac/src/components/index.ts index 4cdc014f03b..27096780920 100644 --- a/plugins/rbac/src/components/index.ts +++ b/plugins/rbac/src/components/index.ts @@ -1,2 +1,3 @@ export { RbacPage } from './RbacPage'; export { Administration } from './Administration'; +export { RoleOverviewPage } from './RoleOverviewPage'; diff --git a/plugins/rbac/src/plugin.ts b/plugins/rbac/src/plugin.ts index e920e1a830c..e66fa311fd0 100644 --- a/plugins/rbac/src/plugin.ts +++ b/plugins/rbac/src/plugin.ts @@ -8,12 +8,13 @@ import { } from '@backstage/core-plugin-api'; import { rbacApiRef, RBACBackendClient } from './api/RBACBackendClient'; -import { rootRouteRef } from './routes'; +import { roleRouteRef, rootRouteRef } from './routes'; export const rbacPlugin = createPlugin({ id: 'rbac', routes: { root: rootRouteRef, + role: roleRouteRef, }, apis: [ createApiFactory({ @@ -31,7 +32,7 @@ export const rbacPlugin = createPlugin({ export const RbacPage = rbacPlugin.provide( createRoutableExtension({ name: 'RbacPage', - component: () => import('./components').then(m => m.RbacPage), + component: () => import('./components/Router').then(m => m.Router), mountPoint: rootRouteRef, }), ); diff --git a/plugins/rbac/src/routes.ts b/plugins/rbac/src/routes.ts index 723cae6f9dc..f5a6b6976fa 100644 --- a/plugins/rbac/src/routes.ts +++ b/plugins/rbac/src/routes.ts @@ -1,5 +1,11 @@ -import { createRouteRef } from '@backstage/core-plugin-api'; +import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; export const rootRouteRef = createRouteRef({ id: 'rbac', }); + +export const roleRouteRef = createSubRouteRef({ + id: 'rbac-role-overview', + parent: rootRouteRef, + path: '/roles/:roleKind/:roleName', +}); diff --git a/plugins/rbac/src/types.ts b/plugins/rbac/src/types.ts index a26b1c29a68..39d41fca015 100644 --- a/plugins/rbac/src/types.ts +++ b/plugins/rbac/src/types.ts @@ -1,3 +1,5 @@ +import { GroupEntity, UserEntity } from '@backstage/catalog-model'; + export type RolesData = { name: string; description: string; @@ -7,3 +9,23 @@ export type RolesData = { lastModified: string; permissionResult: { allowed: boolean; loading: boolean }; }; + +export type MembersData = { + name: string; + type: string; + members: number; + ref: { + name: string; + namespace: string; + kind: string; + }; +}; + +export type PermissionsData = { + plugin: string; + permission: string; + policies: Set<{ policy: string; effect: string }>; + policyString: Set; +}; + +export type MemberEntity = UserEntity | GroupEntity; diff --git a/plugins/rbac/src/utils/rbac-utils.ts b/plugins/rbac/src/utils/rbac-utils.ts index 8a1c35003f4..8a209b41beb 100644 --- a/plugins/rbac/src/utils/rbac-utils.ts +++ b/plugins/rbac/src/utils/rbac-utils.ts @@ -1,6 +1,15 @@ -import { isUserEntity, parseEntityRef } from '@backstage/catalog-model'; +import { + GroupEntity, + isUserEntity, + parseEntityRef, +} from '@backstage/catalog-model'; -import { RoleBasedPolicy } from '@janus-idp/backstage-plugin-rbac-common'; +import { + PermissionPolicy, + RoleBasedPolicy, +} from '@janus-idp/backstage-plugin-rbac-common'; + +import { MembersData, PermissionsData } from '../types'; export const getPermissions = ( role: string, @@ -14,33 +23,110 @@ export const getPermissions = ( ).length; }; -export const getMembers = (memberReferences: string[]): string => { - if (!memberReferences || memberReferences.length === 0) { +export const getMembers = (members: (string | MembersData)[]): string => { + if (!members || members.length === 0) { return 'No members'; } - const res = memberReferences.reduce( + const res = members.reduce( (acc, member) => { - const entity = parseEntityRef(member) as any; - if (isUserEntity(entity)) { - acc.users++; + if (typeof member === 'object') { + if (member.type === 'User') { + acc.users++; + } else { + acc.groups++; + } } else { - acc.groups++; + const entity = parseEntityRef(member) as any; + if (isUserEntity(entity)) { + acc.users++; + } else { + acc.groups++; + } } return acc; }, { users: 0, groups: 0 }, ); - let members = ''; + let membersString = ''; if (res.users > 0) { - members = `${res.users} ${res.users > 1 ? 'Users' : 'User'}`; + membersString = `${res.users} ${res.users > 1 ? 'Users' : 'User'}`; } if (res.groups > 0) { - members = members.concat( - members.length > 0 ? ', ' : '', + membersString = membersString.concat( + membersString.length > 0 ? ', ' : '', `${res.groups} ${res.groups > 1 ? 'Groups' : 'Group'}`, ); } - return members; + return membersString; +}; + +export const getMembersFromGroup = (group: GroupEntity): number => { + const membersList = group.relations?.reduce((acc, relation) => { + if (relation.type === 'hasMember') { + acc = acc + 1; + } + return acc; + }, 0); + return membersList || 1; +}; + +export const getPluginId = ( + permissions: PermissionPolicy[] | undefined, + permission: string | undefined, +): string => + permissions + ? permissions.find( + p => p.policies?.find(pol => pol.permission === permission), + )?.pluginId || '-' + : '-'; + +export const getPermissionsData = ( + policies: RoleBasedPolicy[], + permissionPolicies: PermissionPolicy[], + entityReference: string, +) => + policies?.reduce((acc: PermissionsData[], policy: RoleBasedPolicy) => { + if (policy?.effect && policy.effect !== 'deny') { + const permission = acc.find( + plugin => + policy.entityReference === entityReference && + plugin.permission === policy.permission && + !plugin.policies.has({ + policy: policy?.policy || 'use', + effect: 'allow', + }), + ); + if (permission) { + permission.policyString.add( + policy.policy ? `, ${policy.policy}` : ', use', + ); + permission.policies.add({ + policy: policy.policy || 'use', + effect: policy.effect, + }); + } else if (policy.entityReference === entityReference) { + const policyString = new Set(); + const policiesSet = new Set<{ policy: string; effect: string }>(); + acc.push({ + permission: policy.permission || '-', + plugin: getPluginId(permissionPolicies, policy?.permission) || '-', + policyString: policyString.add(policy.policy || 'use'), + policies: policiesSet.add({ + policy: policy.policy || 'use', + effect: policy.effect, + }), + }); + } + } + return acc; + }, []); + +export const getKindNamespaceName = (roleRef: string) => { + const refs: string[] = roleRef.split(':'); + const kind = refs[0]; + const namespace = refs[1].split('/')[0]; + const name = refs[1].split('/')[1]; + return { kind, namespace, name }; };