Skip to content

Commit

Permalink
Create usePermissions UI hook (#413)
Browse files Browse the repository at this point in the history
* move types and run license check

* pulling in most recent main

* added useAppSelector

* ran license check

* updated from PR feedback

* rewrote hook and permission structure based on feedback

* added check for single permission

* persistent arg names

* additional refactor

* refactored permissions definitions

* removed fake permission
  • Loading branch information
maffkipp authored Mar 8, 2024
1 parent 143bfa0 commit abb5ff7
Show file tree
Hide file tree
Showing 5 changed files with 382 additions and 59 deletions.
101 changes: 101 additions & 0 deletions cmd/ui/src/hooks/usePermissions/usePermissions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright 2024 Specter Ops, Inc.
//
// Licensed under the Apache License, Version 2.0
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

import { Permission, PERMISSIONS } from 'bh-shared-ui';
import { renderHook } from 'src/test-utils';
import usePermissions, { PermissionsFns } from './usePermissions';

describe('usePermissions', () => {
const getPermissionsWithUser = (permissions: Permission[]): PermissionsFns => {
return renderHook(() => usePermissions(), {
initialState: {
auth: {
user: {
roles: [
{
permissions: permissions.map((p) => PERMISSIONS[p]),
},
],
},
},
},
}).result.current;
};

const allPermissions = [
Permission.CLIENTS_MANAGE,
Permission.AUTH_CREATE_TOKEN,
Permission.APP_READ_APPLICATION_CONFIGURATION,
];

it('passes check if the user has a required permission', () => {
const permissions = getPermissionsWithUser([Permission.CLIENTS_MANAGE]);

const has = permissions.checkPermission(Permission.CLIENTS_MANAGE);
const hasAll = permissions.checkAllPermissions([Permission.CLIENTS_MANAGE]);
const hasAtLeastOne = permissions.checkAtLeastOnePermission([Permission.CLIENTS_MANAGE]);

expect(has).toBe(true);
expect(hasAll).toBe(true);
expect(hasAtLeastOne).toBe(true);
});

it('passes checks if the user has multiple required permissions', () => {
const permissions = getPermissionsWithUser(allPermissions);

const hasAll = permissions.checkAllPermissions(allPermissions);
const hasAtLeastOne = permissions.checkAtLeastOnePermission(allPermissions);

expect(hasAll).toBe(true);
expect(hasAtLeastOne).toBe(true);
});

it('fails checks if the user does not have a matching permission', () => {
const permissions = getPermissionsWithUser([Permission.CLIENTS_MANAGE]);

const has = permissions.checkPermission(Permission.AUTH_CREATE_TOKEN);
const hasAll = permissions.checkAllPermissions([Permission.AUTH_CREATE_TOKEN]);
const hasAtLeastOne = permissions.checkAtLeastOnePermission([Permission.AUTH_CREATE_TOKEN]);

expect(has).toBe(false);
expect(hasAll).toBe(false);
expect(hasAtLeastOne).toBe(false);
});

it('passes the check for at least one permission if the user is missing one of many required permissions', () => {
const permissions = getPermissionsWithUser([
Permission.APP_READ_APPLICATION_CONFIGURATION,
Permission.AUTH_CREATE_TOKEN,
]);

const hasAll = permissions.checkAllPermissions(allPermissions);
const hasAtLeastOne = permissions.checkAtLeastOnePermission(allPermissions);

expect(hasAll).toBe(false);
expect(hasAtLeastOne).toBe(true);
});

it('returns a list of the users current permissions', () => {
const permissions = getPermissionsWithUser(allPermissions);
const userPermissionsResult = permissions.getUserPermissions();

expect(allPermissions.length).toEqual(userPermissionsResult.length);

for (const permission of allPermissions) {
expect(userPermissionsResult).toContain(permission);
}
});
});
74 changes: 74 additions & 0 deletions cmd/ui/src/hooks/usePermissions/usePermissions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2024 Specter Ops, Inc.
//
// Licensed under the Apache License, Version 2.0
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

import { Permission, PERMISSIONS } from 'bh-shared-ui';
import { useCallback, useEffect, useState } from 'react';
import { useAppSelector } from 'src/store';

export type PermissionsFns = {
getUserPermissions: () => Permission[];
checkPermission: (permission: Permission) => boolean;
checkAllPermissions: (permissions: Permission[]) => boolean;
checkAtLeastOnePermission: (permissions: Permission[]) => boolean;
};

const usePermissions = (): PermissionsFns => {
const auth = useAppSelector((state) => state.auth);
const [userPermMap, setUserPermMap] = useState<Record<string, boolean>>({});

const formatKey = useCallback((p: { authority: string; name: string }) => `${p.authority}-${p.name}`, []);

useEffect(() => {
if (auth.user) {
const userPermissions = auth.user.roles.map((role) => role.permissions).flat();
const newPermMap: Record<string, boolean> = {};
userPermissions.forEach((perm) => (newPermMap[formatKey(perm)] = true));
setUserPermMap(newPermMap);
}
}, [auth.user, formatKey]);

const getUserPermissions = (): Permission[] => {
if (auth.user) {
return Object.entries(PERMISSIONS)
.filter(([, definition]) => userPermMap[formatKey(definition)])
.map(([name]) => parseInt(name));
}
return [];
};

const checkPermission = (permission: Permission): boolean => {
const definition = PERMISSIONS[permission];
return definition && !!userPermMap[formatKey(definition)];
};

const checkAllPermissions = (permissions: Permission[]): boolean => {
for (const permission of permissions) {
if (!checkPermission(permission)) return false;
}
return true;
};

const checkAtLeastOnePermission = (permissions: Permission[]): boolean => {
for (const permission of permissions) {
if (checkPermission(permission)) return true;
}
return false;
};

return { getUserPermissions, checkPermission, checkAllPermissions, checkAtLeastOnePermission };
};

export default usePermissions;
144 changes: 86 additions & 58 deletions cmd/ui/src/test-utils.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2023 Specter Ops, Inc.
// Copyright 2024 Specter Ops, Inc.
//
// Licensed under the Apache License, Version 2.0
// you may not use this file except in compliance with the License.
Expand All @@ -17,7 +17,7 @@
import { createTheme } from '@mui/material/styles';
import { CssBaseline, StyledEngineProvider, ThemeProvider } from '@mui/material';
import { configureStore } from '@reduxjs/toolkit';
import { render } from '@testing-library/react';
import { render, renderHook } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { SnackbarProvider } from 'notistack';
import { QueryClient, QueryClientProvider } from 'react-query';
Expand All @@ -27,75 +27,103 @@ import createSagaMiddleware from 'redux-saga';
import { rootReducer } from 'src/store';
import { NotificationsProvider } from 'bh-shared-ui';

const defaultTheme = {
palette: {
primary: {
main: '#406f8e',
light: '#709dbe',
dark: '#064460',
contrastText: '#ffffff',
},
neutral: {
main: '#e0e0e0',
light: '#ffffff',
dark: '#cccccc',
contrastText: '#000000',
},
background: {
paper: '#fafafa',
default: '#e4e9eb',
},
low: 'rgb(255, 195, 15)',
moderate: 'rgb(255, 97, 66)',
high: 'rgb(205, 0, 117)',
critical: 'rgb(76, 29, 143)',
},
}

const createDefaultQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
}

const createDefaultStore = (state) => {
return configureStore({
reducer: rootReducer,
preloadedState: state,
middleware: (getDefaultMiddleware) => {
return [...getDefaultMiddleware({ serializableCheck: false }), createSagaMiddleware()];
},
})
}

const createProviders = ({ queryClient, history, theme, store, children }) => {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<NotificationsProvider>
<CssBaseline />
<Router location={history.location} navigator={history}>
<SnackbarProvider>{children}</SnackbarProvider>
</Router>
</NotificationsProvider>
</ThemeProvider>
</StyledEngineProvider>
</QueryClientProvider>
</Provider>
)
}

const customRender = (
ui,
{
initialState = {},
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
}),
queryClient = createDefaultQueryClient(),
history = createMemoryHistory(),
theme = createTheme({
palette: {
primary: {
main: '#406f8e',
light: '#709dbe',
dark: '#064460',
contrastText: '#ffffff',
},
neutral: {
main: '#e0e0e0',
light: '#ffffff',
dark: '#cccccc',
contrastText: '#000000',
},
background: {
paper: '#fafafa',
default: '#e4e9eb',
},
low: 'rgb(255, 195, 15)',
moderate: 'rgb(255, 97, 66)',
high: 'rgb(205, 0, 117)',
critical: 'rgb(76, 29, 143)',
},
}),
store = configureStore({
reducer: rootReducer,
preloadedState: initialState,
middleware: (getDefaultMiddleware) => {
return [...getDefaultMiddleware({ serializableCheck: false }), createSagaMiddleware()];
},
}),
theme = createTheme(defaultTheme),
store = createDefaultStore(initialState),
...renderOptions
} = {}
) => {
const AllTheProviders = ({ children }) => {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<NotificationsProvider>
<CssBaseline />
<Router location={history.location} navigator={history}>
<SnackbarProvider>{children}</SnackbarProvider>
</Router>
</NotificationsProvider>
</ThemeProvider>
</StyledEngineProvider>
</QueryClientProvider>
</Provider>
);
};
const AllTheProviders = ({ children }) => createProviders({ queryClient, history, theme, store, children });
return render(ui, { wrapper: AllTheProviders, ...renderOptions });
};

const customRenderHook = (
hook,
{
initialState = {},
queryClient = createDefaultQueryClient(),
history = createMemoryHistory(),
theme = createTheme(defaultTheme),
store = createDefaultStore(initialState),
...renderOptions
} = {}
) => {
const AllTheProviders = ({ children }) => createProviders({ queryClient, history, theme, store, children });
return renderHook(hook, { wrapper: AllTheProviders, ...renderOptions });
};

// re-export everything
// eslint-disable-next-line react-refresh/only-export-components
export * from '@testing-library/react';
// override render method
export { customRender as render };
export { customRenderHook as renderHook };
3 changes: 2 additions & 1 deletion packages/javascript/bh-shared-ui/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ export * from './entityInfoDisplay';
export * from './passwd';
export * from './user';
export * from './icons';
export * from './copyToClipboard'
export * from './permissions';
export * from './copyToClipboard';
Loading

0 comments on commit abb5ff7

Please sign in to comment.