Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rbac): display administration to authorized users #895

Merged
merged 1 commit into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions plugins/rbac/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,44 @@
# rbac
# RBAC frontend plugin for Backstage

Welcome to the rbac plugin!
The RBAC UI plugin offers a streamlined user interface for effectively managing permissions in your Backstage instance. It allows you to assign permissions to users and groups, empowering them to view, create, modify and delete Roles, provided they have the necessary permissions.

_This plugin was created through the Backstage CLI_
## For administrators

## Getting started
### Installation

Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/rbac](http://localhost:3000/rbac).
#### Prerequisites

You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
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

#### Procedure

1. Install the RBAC UI plugin using the following command:

```console
yarn workspace app add @janus-idp/backstage-plugin-rbac
```

2. Add Route in `packages/app/src/App.tsx`:

```tsx title="packages/app/src/App.tsx"
/* highlight-add-next-line */
import { RbacPage } from '@janus-idp/backstage-plugin-rbac';

<Route path="/rbac" element={<RbacPage />} />;
```

3. Add **Administration** Sidebar Item in `packages/app/src/components/Root/Root.tsx`:

```tsx title="packages/app/src/components/Root/Root.tsx"
/* highlight-add-next-line */
import { Administration } from '@janus-idp/backstage-plugin-rbac';

export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
<Sidebar>
...
<Administration />
...
</SidebarPage>
);
```
24 changes: 23 additions & 1 deletion plugins/rbac/dev/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
import React from 'react';

import { createDevApp } from '@backstage/dev-utils';
import {
PermissionApi,
permissionApiRef,
} from '@backstage/plugin-permission-react';
import { TestApiProvider } from '@backstage/test-utils';

import { RbacPage, rbacPlugin } from '../src/plugin';

class MockPermissionApi implements PermissionApi {
readonly result;

constructor(fixtureData: any) {
this.result = fixtureData;
}

async authorize(_request: any): Promise<any> {
return this.result;
}
}

const mockApi = new MockPermissionApi({ result: 'ALLOW' });
createDevApp()
.registerPlugin(rbacPlugin)
.addPage({
element: <RbacPage />,
element: (
<TestApiProvider apis={[[permissionApiRef, mockApi]]}>
<RbacPage />
</TestApiProvider>
),
title: 'Administration',
path: '/rbac',
})
Expand Down
5 changes: 4 additions & 1 deletion plugins/rbac/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@
"@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"
"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"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
Expand Down
54 changes: 54 additions & 0 deletions plugins/rbac/src/api/RBACBackendClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
ConfigApi,
createApiRef,
IdentityApi,
} from '@backstage/core-plugin-api';

// @public
export type RBACAPI = {
getUserAuthorization: () => Promise<{ status: string }>;
getRoles: () => Promise<any>;
};

export type Options = {
configApi: ConfigApi;
identityApi: IdentityApi;
};

// @public
export const rbacApiRef = createApiRef<RBACAPI>({
id: 'plugin.rbac.service',
});

export class RBACBackendClient implements RBACAPI {
// @ts-ignore
private readonly configApi: ConfigApi;
private readonly identityApi: IdentityApi;

constructor(options: Options) {
this.configApi = options.configApi;
this.identityApi = options.identityApi;
}

async getUserAuthorization() {
const { token: idToken } = await this.identityApi.getCredentials();
const backendUrl = this.configApi.getString('backend.baseUrl');
const jsonResponse = await fetch(`${backendUrl}/api/permission/`, {
headers: {
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
});
return jsonResponse.json();
}

async getRoles() {
const { token: idToken } = await this.identityApi.getCredentials();
const backendUrl = this.configApi.getString('backend.baseUrl');
const jsonResponse = await fetch(`${backendUrl}/api/permission/roles`, {
headers: {
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
});
return jsonResponse.json();
}
}
28 changes: 28 additions & 0 deletions plugins/rbac/src/components/Administration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';
import { useAsync } from 'react-use';

import { SidebarItem } from '@backstage/core-components';
import { IconComponent, useApi } from '@backstage/core-plugin-api';

import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';

import { rbacApiRef } from '../api/RBACBackendClient';

export const Administration = () => {
const rbacApi = useApi(rbacApiRef);
const { loading: isUserLoading, value: result } = useAsync(
async () => await rbacApi.getUserAuthorization(),
[],
);

if (!isUserLoading) {
return result?.status === 'Authorized' ? (
<SidebarItem
text="Administration"
to="rbac"
icon={AdminPanelSettingsOutlinedIcon as IconComponent}
/>
) : null;
}
return null;
};
67 changes: 67 additions & 0 deletions plugins/rbac/src/components/RbacPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';

import {
RequirePermission,
usePermission,
} from '@backstage/plugin-permission-react';
import {
renderInTestApp,
setupRequestMockHandlers,
} from '@backstage/test-utils';

import { screen } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';

import { RbacPage } from './RbacPage';

jest.mock('@backstage/plugin-permission-react', () => ({
usePermission: jest.fn(),
RequirePermission: jest.fn(),
}));
const mockUsePermission = usePermission as jest.MockedFunction<
typeof usePermission
>;

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 });
await renderInTestApp(<RbacPage />);
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 () => {
RequirePermissionMock.mockImplementation(_props => <>Not Found</>);
mockUsePermission.mockReturnValue({ loading: false, allowed: false });

await renderInTestApp(<RbacPage />);
expect(screen.getByText('Not Found')).toBeInTheDocument();
});

it('should not render if loading', async () => {
RequirePermissionMock.mockImplementation(_props => null);
mockUsePermission.mockReturnValue({ loading: false, allowed: false });

const { queryByText } = await renderInTestApp(<RbacPage />);
expect(queryByText('Not Found')).not.toBeInTheDocument();
expect(queryByText('Administration')).not.toBeInTheDocument();
});
});
30 changes: 30 additions & 0 deletions plugins/rbac/src/components/RbacPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';

import { Content, Header, InfoCard, Page } 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';

export const RbacPage = () => (
<RequirePermission
permission={policyEntityReadPermission}
resourceRef={policyEntityReadPermission.resourceType}
>
<Page themeId="tool">
<Header title="Administration" />
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard title="Information card">
<Typography variant="body1">
All content should be wrapped in a card like this.
</Typography>
</InfoCard>
</Grid>
</Grid>
</Content>
</Page>
</RequirePermission>
);
30 changes: 0 additions & 30 deletions plugins/rbac/src/components/RbacPage/RbacPage.test.tsx

This file was deleted.

22 changes: 0 additions & 22 deletions plugins/rbac/src/components/RbacPage/RbacPage.tsx

This file was deleted.

1 change: 0 additions & 1 deletion plugins/rbac/src/components/RbacPage/index.ts

This file was deleted.

2 changes: 2 additions & 0 deletions plugins/rbac/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { RbacPage } from './RbacPage';
export { Administration } from './Administration';
2 changes: 1 addition & 1 deletion plugins/rbac/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { rbacPlugin, RbacPage } from './plugin';
export { rbacPlugin, RbacPage, Administration } from './plugin';
27 changes: 26 additions & 1 deletion plugins/rbac/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,46 @@
import {
configApiRef,
createApiFactory,
createComponentExtension,
createPlugin,
createRoutableExtension,
identityApiRef,
} from '@backstage/core-plugin-api';

import { rbacApiRef, RBACBackendClient } from './api/RBACBackendClient';
import { rootRouteRef } from './routes';

export const rbacPlugin = createPlugin({
id: 'rbac',
routes: {
root: rootRouteRef,
},
apis: [
createApiFactory({
api: rbacApiRef,
deps: {
configApi: configApiRef,
identityApi: identityApiRef,
},
factory: ({ configApi, identityApi }) =>
new RBACBackendClient({ configApi, identityApi }),
}),
],
});

export const RbacPage = rbacPlugin.provide(
createRoutableExtension({
name: 'RbacPage',
component: () => import('./components/RbacPage').then(m => m.RbacPage),
component: () => import('./components').then(m => m.RbacPage),
mountPoint: rootRouteRef,
}),
);

export const Administration = rbacPlugin.provide(
createComponentExtension({
name: 'Administration',
component: {
lazy: () => import('./components').then(m => m.Administration),
},
}),
);
Loading