From 7a48fb57a677dc0ddd92ad3bf6a8bf41727a7c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Fri, 16 Feb 2024 14:31:33 +0100 Subject: [PATCH] feat: permission matrix (PoC) (#6223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## About the changes This is a rough initial version as a PoC for a permission matrix. This is only available after enabling the flag `userAccessUIEnabled` that is set to true by default in local development. The access was added to the users' admin page but could be embedded in different contexts (e.g. when assigning a role to a user): ![image](https://github.com/Unleash/unleash/assets/455064/3f541f46-99bb-409b-a0fe-13f5d3f9572a) This is how the matrix looks like ![screencapture-localhost-3000-admin-users-3-access-2024-02-13-12_15_44](https://github.com/Unleash/unleash/assets/455064/183deeb6-a0dc-470f-924c-f435c6196407) --------- Co-authored-by: Nuno Góis --- .../admin/users/AccessMatrix/AccessMatrix.tsx | 122 ++++++++++++++++++ .../users/AccessMatrix/AccessMatrixSelect.tsx | 29 +++++ .../users/AccessMatrix/PermissionsTable.tsx | 80 ++++++++++++ .../src/component/admin/users/UsersAdmin.tsx | 2 + .../UsersActionsCell/UsersActionsCell.tsx | 22 +++- .../admin/users/UsersList/UsersList.tsx | 17 ++- .../useUserAccessMatrix.ts | 61 +++++++++ frontend/src/interfaces/permissions.ts | 4 + frontend/src/interfaces/uiConfig.ts | 1 + .../__snapshots__/create-config.test.ts.snap | 1 + src/lib/routes/admin-api/user-admin.ts | 80 ++++++++++++ src/lib/services/access-service.ts | 100 +++++++++++--- src/lib/types/experimental.ts | 5 + src/server-dev.ts | 1 + 14 files changed, 502 insertions(+), 23 deletions(-) create mode 100644 frontend/src/component/admin/users/AccessMatrix/AccessMatrix.tsx create mode 100644 frontend/src/component/admin/users/AccessMatrix/AccessMatrixSelect.tsx create mode 100644 frontend/src/component/admin/users/AccessMatrix/PermissionsTable.tsx create mode 100644 frontend/src/hooks/api/getters/useUserAccessMatrix/useUserAccessMatrix.ts diff --git a/frontend/src/component/admin/users/AccessMatrix/AccessMatrix.tsx b/frontend/src/component/admin/users/AccessMatrix/AccessMatrix.tsx new file mode 100644 index 0000000000..a10aa9dcdd --- /dev/null +++ b/frontend/src/component/admin/users/AccessMatrix/AccessMatrix.tsx @@ -0,0 +1,122 @@ +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import useUserInfo from 'hooks/api/getters/useUserInfo/useUserInfo'; +import { PermissionsTable } from './PermissionsTable'; +import { styled, useMediaQuery } from '@mui/material'; +import { useEffect, useState } from 'react'; +import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import theme from 'themes/theme'; +import { StringParam, useQueryParams } from 'use-query-params'; +import useProjects from 'hooks/api/getters/useProjects/useProjects'; +import { AccessMatrixSelect } from './AccessMatrixSelect'; +import { useUserAccessMatrix } from 'hooks/api/getters/useUserAccessMatrix/useUserAccessMatrix'; + +const StyledActionsContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flex: 1, + gap: theme.spacing(1), + maxWidth: 600, + [theme.breakpoints.down('md')]: { + flexDirection: 'column', + maxWidth: '100%', + }, +})); + +const StyledTitle = styled('h2')(({ theme }) => ({ + margin: theme.spacing(2, 0), +})); + +export const AccessMatrix = () => { + const id = useRequiredPathParam('id'); + const [query, setQuery] = useQueryParams({ + project: StringParam, + environment: StringParam, + }); + const { projects } = useProjects(); + const { environments } = useEnvironments(); + const { user, loading } = useUserInfo(id); + + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const [project, setProject] = useState(query.project ?? ''); + const [environment, setEnvironment] = useState( + query.environment ?? undefined, + ); + + const { matrix, rootRole, projectRoles } = useUserAccessMatrix( + id, + project, + environment, + ); + + useEffect(() => { + setQuery( + { + project: project || undefined, + environment, + }, + 'replace', + ); + }, [project, environment]); + + const AccessActions = ( + + option?.name ?? ''} + value={projects.find(({ id }) => id === project)} + setValue={(value) => setProject(value?.id ?? '')} + /> + + option?.name.concat( + !option.enabled ? ' - deprecated' : '', + ) ?? '' + } + value={environments.find(({ name }) => name === environment)} + setValue={(value) => setEnvironment(value?.name)} + /> + + ); + + return ( + + } + > + + + } + > + + Root permissions for role {rootRole?.name} + + + + Project permissions for project {project} with project roles [ + {projectRoles?.map((role: any) => role.name).join(', ')}] + + + + Environment permissions for environment {environment} + + + + ); +}; diff --git a/frontend/src/component/admin/users/AccessMatrix/AccessMatrixSelect.tsx b/frontend/src/component/admin/users/AccessMatrix/AccessMatrixSelect.tsx new file mode 100644 index 0000000000..d4a41cac5e --- /dev/null +++ b/frontend/src/component/admin/users/AccessMatrix/AccessMatrixSelect.tsx @@ -0,0 +1,29 @@ +import { Autocomplete, AutocompleteProps, TextField } from '@mui/material'; + +interface IAccessMatrixSelectProps + extends Partial> { + label: string; + options: T[]; + value: T; + setValue: (role: T | null) => void; +} + +export const AccessMatrixSelect = ({ + label, + options, + value, + setValue, + ...rest +}: IAccessMatrixSelectProps) => ( + setValue(value)} + renderInput={(params) => ( + + )} + size='small' + fullWidth + {...rest} + /> +); diff --git a/frontend/src/component/admin/users/AccessMatrix/PermissionsTable.tsx b/frontend/src/component/admin/users/AccessMatrix/PermissionsTable.tsx new file mode 100644 index 0000000000..72d632ec1b --- /dev/null +++ b/frontend/src/component/admin/users/AccessMatrix/PermissionsTable.tsx @@ -0,0 +1,80 @@ +import { useMemo, useRef } from 'react'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useFlexLayout, useSortBy, useTable } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; +import { Check, Close } from '@mui/icons-material'; +import { Box } from '@mui/material'; +import { IMatrixPermission } from 'interfaces/permissions'; + +export const PermissionsTable = ({ + permissions, +}: { + permissions: IMatrixPermission[]; +}) => { + const columns = useMemo( + () => [ + { + Header: 'Permission', + accessor: 'name', + minWidth: 100, + }, + { + Header: 'Description', + accessor: 'displayName', + minWidth: 180, + }, + { + Header: 'Has permission', + accessor: 'hasPermission', + Cell: ({ value }: { value: boolean }) => ( + + ) : ( + + ) + } + /> + ), + }, + ], + [permissions], + ); + + const initialState = { + sortBy: [{ id: 'name', desc: true }], + }; + + const { headerGroups, rows, prepareRow } = useTable( + { + columns: columns as any, + data: permissions ?? [], + initialState, + sortTypes, + }, + useSortBy, + useFlexLayout, + ); + + const parentRef = useRef(null); + + return ( + + + No permissions found. + } + /> + + ); +}; diff --git a/frontend/src/component/admin/users/UsersAdmin.tsx b/frontend/src/component/admin/users/UsersAdmin.tsx index c0664e48ca..a049366ab8 100644 --- a/frontend/src/component/admin/users/UsersAdmin.tsx +++ b/frontend/src/component/admin/users/UsersAdmin.tsx @@ -6,6 +6,7 @@ import { Route, Routes } from 'react-router-dom'; import EditUser from './EditUser/EditUser'; import NotFound from 'component/common/NotFound/NotFound'; import { InactiveUsersList } from './InactiveUsersList/InactiveUsersList'; +import { AccessMatrix } from './AccessMatrix/AccessMatrix'; export const UsersAdmin = () => (
@@ -21,6 +22,7 @@ export const UsersAdmin = () => ( } /> } /> + } /> } /> } /> diff --git a/frontend/src/component/admin/users/UsersList/UsersActionsCell/UsersActionsCell.tsx b/frontend/src/component/admin/users/UsersList/UsersActionsCell/UsersActionsCell.tsx index a79b1cbfbe..22cdce28fb 100644 --- a/frontend/src/component/admin/users/UsersList/UsersActionsCell/UsersActionsCell.tsx +++ b/frontend/src/component/admin/users/UsersList/UsersActionsCell/UsersActionsCell.tsx @@ -1,5 +1,6 @@ -import { Delete, Edit, Lock, LockReset } from '@mui/icons-material'; +import { Delete, Edit, Key, Lock, LockReset } from '@mui/icons-material'; import { Box, styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { VFC } from 'react'; @@ -11,6 +12,7 @@ const StyledBox = styled(Box)(() => ({ interface IUsersActionsCellProps { onEdit: (event: React.SyntheticEvent) => void; + onViewAccess?: (event: React.SyntheticEvent) => void; onChangePassword: (event: React.SyntheticEvent) => void; onResetPassword: (event: React.SyntheticEvent) => void; onDelete: (event: React.SyntheticEvent) => void; @@ -18,6 +20,7 @@ interface IUsersActionsCellProps { export const UsersActionsCell: VFC = ({ onEdit, + onViewAccess, onChangePassword, onResetPassword, onDelete, @@ -34,6 +37,23 @@ export const UsersActionsCell: VFC = ({ > + + + + + } + /> + { const navigate = useNavigate(); @@ -52,7 +52,7 @@ const UsersList = () => { }>({ open: false, }); - const { isEnterprise } = useUiConfig(); + const userAccessUIEnabled = useUiFlag('userAccessUIEnabled'); const [delDialog, setDelDialog] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const [emailSent, setEmailSent] = useState(false); @@ -195,12 +195,21 @@ const UsersList = () => { onEdit={() => { navigate(`/admin/users/${user.id}/edit`); }} + onViewAccess={ + userAccessUIEnabled + ? () => { + navigate( + `/admin/users/${user.id}/access`, + ); + } + : undefined + } onChangePassword={openPwDialog(user)} onResetPassword={openResetPwDialog(user)} onDelete={openDelDialog(user)} /> ), - width: 200, + width: userAccessUIEnabled ? 240 : 200, disableSortBy: true, }, // Always hidden -- for search @@ -216,7 +225,7 @@ const UsersList = () => { searchable: true, }, ], - [roles, navigate, isBillingUsers], + [roles, navigate, isBillingUsers, userAccessUIEnabled], ); const initialState = useMemo(() => { diff --git a/frontend/src/hooks/api/getters/useUserAccessMatrix/useUserAccessMatrix.ts b/frontend/src/hooks/api/getters/useUserAccessMatrix/useUserAccessMatrix.ts new file mode 100644 index 0000000000..f951463855 --- /dev/null +++ b/frontend/src/hooks/api/getters/useUserAccessMatrix/useUserAccessMatrix.ts @@ -0,0 +1,61 @@ +import { useMemo } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import useSWR from 'swr'; +import { IRole } from 'interfaces/role'; +import { IUser } from 'interfaces/user'; +import { IMatrixPermission } from 'interfaces/permissions'; + +interface IUserAccessMatrix { + root: IMatrixPermission[]; + project: IMatrixPermission[]; + environment: IMatrixPermission[]; +} + +interface IUserAccessMatrixResponse { + matrix: IUserAccessMatrix; + projectRoles: IRole[]; + rootRole: IRole; + user: IUser; +} + +interface IUserAccessMatrixOutput extends Partial { + loading: boolean; + refetch: () => void; + error?: Error; +} + +export const useUserAccessMatrix = ( + id: string, + project?: string, + environment?: string, +): IUserAccessMatrixOutput => { + const queryParams = `${project ? `?project=${project}` : ''}${ + environment ? `${project ? '&' : '?'}environment=${environment}` : '' + }`; + const url = `api/admin/user-admin/${id}/permissions${queryParams}`; + + const { data, error, mutate } = useSWR( + formatApiPath(url), + fetcher, + ); + + return useMemo( + () => ({ + matrix: data?.matrix, + projectRoles: data?.projectRoles, + rootRole: data?.rootRole, + user: data?.user, + loading: !error && !data, + refetch: () => mutate(), + error, + }), + [data, error, mutate], + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('User access matrix')) + .then((res) => res.json()); +}; diff --git a/frontend/src/interfaces/permissions.ts b/frontend/src/interfaces/permissions.ts index b48f1a05bd..7717b5a7b0 100644 --- a/frontend/src/interfaces/permissions.ts +++ b/frontend/src/interfaces/permissions.ts @@ -36,3 +36,7 @@ export interface IPermissionCategory { type: PermissionType; permissions: IPermission[]; } + +export interface IMatrixPermission extends IPermission { + hasPermission: boolean; +} diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 7c754fc6b9..0c1a6603eb 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -79,6 +79,7 @@ export type UiFlags = { displayUpgradeEdgeBanner?: boolean; showInactiveUsers?: boolean; featureSearchFeedbackPosting?: boolean; + userAccessUIEnabled?: boolean; sdkReporting?: boolean; }; diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index f6be6e39c4..bcb5db4f2e 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -138,6 +138,7 @@ exports[`should create default config 1`] = ` "strictSchemaValidation": false, "stripClientHeadersOn304": false, "useMemoizedActiveTokens": false, + "userAccessUIEnabled": false, }, "externalResolver": { "getVariant": [Function], diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index 0db2f0bc65..4c6826adb8 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -56,6 +56,7 @@ import { createUserResponseSchema, CreateUserResponseSchema, } from '../../openapi/spec/create-user-response-schema'; +import { IRoleWithPermissions } from '../../types/stores/access-store'; export default class UserAdminController extends Controller { private flagResolver: IFlagResolver; @@ -247,6 +248,44 @@ export default class UserAdminController extends Controller { ], }); + this.route({ + method: 'get', + path: '/:id/permissions', + permission: ADMIN, + handler: this.getPermissions, + middleware: [ + openApiService.validPath({ + tags: ['Auth'], + operationId: 'getUserPermissions', + summary: 'Returns the list of permissions for the user', + description: + 'Gets a list of permissions for a user, additional project and environment can be specified.', + parameters: [ + { + name: 'project', + in: 'query', + required: false, + schema: { + type: 'string', + }, + }, + { + name: 'environment', + in: 'query', + required: false, + schema: { + type: 'string', + }, + }, + ], + responses: { + 200: emptyResponse, // TODO define schema + ...getStandardResponses(401, 403, 415), + }, + }), + ], + }); + this.route({ method: 'get', path: '/admin-count', @@ -636,4 +675,45 @@ export default class UserAdminController extends Controller { adminCount, ); } + + async getPermissions( + req: IAuthRequest< + { id: number }, + unknown, + unknown, + { project?: string; environment?: string } + >, + res: Response, + ): Promise { + const { project, environment } = req.query; + const user = await this.userService.getUser(req.params.id); + const rootRole = await this.accessService.getRootRoleForUser(user.id); + let projectRoles: IRoleWithPermissions[] = []; + if (project) { + const projectRoleIds = + await this.accessService.getProjectRolesForUser( + project, + user.id, + ); + + projectRoles = await Promise.all( + projectRoleIds.map((roleId) => + this.accessService.getRole(roleId), + ), + ); + } + const matrix = await this.accessService.permissionsMatrixForUser( + user, + project, + environment, + ); + + // TODO add response validation based on the schema + res.status(200).json({ + matrix, + user, + rootRole, + projectRoles, + }); + } } diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts index c9508a2ea0..bce89f50c7 100644 --- a/src/lib/services/access-service.ts +++ b/src/lib/services/access-service.ts @@ -63,6 +63,14 @@ const PROJECT_ADMIN = [ export type IdPermissionRef = Pick; export type NamePermissionRef = Pick; export type PermissionRef = IdPermissionRef | NamePermissionRef; +type MatrixPermission = IPermission & { + hasPermission: boolean; +}; +type PermissionMatrix = { + root: MatrixPermission[]; + project: MatrixPermission[]; + environment: MatrixPermission[]; +}; type APIUser = Pick & { isAPI: true }; type NonAPIUser = Pick & { isAPI?: false }; @@ -138,6 +146,32 @@ export class AccessService { this.eventService = eventService; } + private meetsAllPermissions( + userP: IUserPermission[], + permissionsArray: string[], + projectId?: string, + environment?: string, + ) { + return userP + .filter( + (p) => + !p.project || + p.project === projectId || + p.project === ALL_PROJECTS, + ) + .filter( + (p) => + !p.environment || + p.environment === environment || + p.environment === ALL_ENVS, + ) + .some( + (p) => + permissionsArray.includes(p.permission) || + p.permission === ADMIN, + ); + } + /** * Used to check if a user has access to the requested resource * @@ -166,24 +200,12 @@ export class AccessService { try { const userP = await this.getPermissionsForUser(user); - return userP - .filter( - (p) => - !p.project || - p.project === projectId || - p.project === ALL_PROJECTS, - ) - .filter( - (p) => - !p.environment || - p.environment === environment || - p.environment === ALL_ENVS, - ) - .some( - (p) => - permissionsArray.includes(p.permission) || - p.permission === ADMIN, - ); + return this.meetsAllPermissions( + userP, + permissionsArray, + projectId, + environment, + ); } catch (e) { this.logger.error( `Error checking ${permissionLogInfo}, userId=${user.id} projectId=${projectId}`, @@ -193,6 +215,48 @@ export class AccessService { } } + /** + * Check a user against all available permissions. + * Provided a project, project permissions will be checked against that project. + * Provided an environment, environment permissions will be checked against that environment (and project). + */ + async permissionsMatrixForUser( + user: APIUser | NonAPIUser, + projectId?: string, + environment?: string, + ): Promise { + const permissions = await this.getPermissions(); + const userP = await this.getPermissionsForUser(user); + const matrix: PermissionMatrix = { + root: permissions.root.map((p) => ({ + ...p, + hasPermission: this.meetsAllPermissions(userP, [p.name]), + })), + project: permissions.project.map((p) => ({ + ...p, + hasPermission: this.meetsAllPermissions( + userP, + [p.name], + projectId, + ), + })), + environment: + permissions.environments + .find((ep) => ep.name === environment) + ?.permissions.map((p) => ({ + ...p, + hasPermission: this.meetsAllPermissions( + userP, + [p.name], + projectId, + environment, + ), + })) ?? [], + }; + + return matrix; + } + async getPermissionsForUser( user: APIUser | NonAPIUser, ): Promise { diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 4f8cb4ee11..5047f0f4a5 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -49,6 +49,7 @@ export type IFlagKey = | 'inMemoryScheduledChangeRequests' | 'collectTrafficDataUsage' | 'useMemoizedActiveTokens' + | 'userAccessUIEnabled' | 'sdkReporting'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -242,6 +243,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_COLLECT_TRAFFIC_DATA_USAGE, false, ), + userAccessUIEnabled: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_USER_ACCESS_UI_ENABLED, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/server-dev.ts b/src/server-dev.ts index d11b3ea212..092ed215a6 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -49,6 +49,7 @@ process.nextTick(async () => { featureSearchFeedbackPosting: true, extendedUsageMetricsUI: true, executiveDashboard: true, + userAccessUIEnabled: true, sdkReporting: true, }, },