mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-31 00:16:47 +01:00
feat: permission matrix (PoC) (#6223)
## 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 <github@nunogois.com>
This commit is contained in:
parent
4a81f0932f
commit
7a48fb57a6
122
frontend/src/component/admin/users/AccessMatrix/AccessMatrix.tsx
Normal file
122
frontend/src/component/admin/users/AccessMatrix/AccessMatrix.tsx
Normal file
@ -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 = (
|
||||||
|
<StyledActionsContainer>
|
||||||
|
<AccessMatrixSelect
|
||||||
|
label='Project'
|
||||||
|
options={projects}
|
||||||
|
getOptionLabel={(option) => option?.name ?? ''}
|
||||||
|
value={projects.find(({ id }) => id === project)}
|
||||||
|
setValue={(value) => setProject(value?.id ?? '')}
|
||||||
|
/>
|
||||||
|
<AccessMatrixSelect
|
||||||
|
label='Environment'
|
||||||
|
options={environments}
|
||||||
|
getOptionLabel={(option) =>
|
||||||
|
option?.name.concat(
|
||||||
|
!option.enabled ? ' - deprecated' : '',
|
||||||
|
) ?? ''
|
||||||
|
}
|
||||||
|
value={environments.find(({ name }) => name === environment)}
|
||||||
|
setValue={(value) => setEnvironment(value?.name)}
|
||||||
|
/>
|
||||||
|
</StyledActionsContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContent
|
||||||
|
isLoading={loading}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title={`Access for ${user.name ?? user.username}`}
|
||||||
|
actions={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={!isSmallScreen}
|
||||||
|
show={AccessActions}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={isSmallScreen}
|
||||||
|
show={AccessActions}
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StyledTitle>
|
||||||
|
Root permissions for role {rootRole?.name}
|
||||||
|
</StyledTitle>
|
||||||
|
<PermissionsTable permissions={matrix?.root ?? []} />
|
||||||
|
<StyledTitle>
|
||||||
|
Project permissions for project {project} with project roles [
|
||||||
|
{projectRoles?.map((role: any) => role.name).join(', ')}]
|
||||||
|
</StyledTitle>
|
||||||
|
<PermissionsTable permissions={matrix?.project ?? []} />
|
||||||
|
<StyledTitle>
|
||||||
|
Environment permissions for environment {environment}
|
||||||
|
</StyledTitle>
|
||||||
|
<PermissionsTable permissions={matrix?.environment ?? []} />
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,29 @@
|
|||||||
|
import { Autocomplete, AutocompleteProps, TextField } from '@mui/material';
|
||||||
|
|
||||||
|
interface IAccessMatrixSelectProps<T>
|
||||||
|
extends Partial<AutocompleteProps<T, false, false, false>> {
|
||||||
|
label: string;
|
||||||
|
options: T[];
|
||||||
|
value: T;
|
||||||
|
setValue: (role: T | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessMatrixSelect = <T,>({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
...rest
|
||||||
|
}: IAccessMatrixSelectProps<T>) => (
|
||||||
|
<Autocomplete
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={(_, value) => setValue(value)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label={label} fullWidth />
|
||||||
|
)}
|
||||||
|
size='small'
|
||||||
|
fullWidth
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
@ -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 }) => (
|
||||||
|
<IconCell
|
||||||
|
icon={
|
||||||
|
value ? (
|
||||||
|
<Check color='success' />
|
||||||
|
) : (
|
||||||
|
<Close color='error' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[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<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxHeight: 500, overflow: 'auto' }} ref={parentRef}>
|
||||||
|
<VirtualizedTable
|
||||||
|
rows={rows}
|
||||||
|
headerGroups={headerGroups}
|
||||||
|
prepareRow={prepareRow}
|
||||||
|
parentRef={parentRef}
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={rows.length === 0}
|
||||||
|
show={
|
||||||
|
<TablePlaceholder>No permissions found.</TablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
@ -6,6 +6,7 @@ import { Route, Routes } from 'react-router-dom';
|
|||||||
import EditUser from './EditUser/EditUser';
|
import EditUser from './EditUser/EditUser';
|
||||||
import NotFound from 'component/common/NotFound/NotFound';
|
import NotFound from 'component/common/NotFound/NotFound';
|
||||||
import { InactiveUsersList } from './InactiveUsersList/InactiveUsersList';
|
import { InactiveUsersList } from './InactiveUsersList/InactiveUsersList';
|
||||||
|
import { AccessMatrix } from './AccessMatrix/AccessMatrix';
|
||||||
|
|
||||||
export const UsersAdmin = () => (
|
export const UsersAdmin = () => (
|
||||||
<div>
|
<div>
|
||||||
@ -21,6 +22,7 @@ export const UsersAdmin = () => (
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path=':id/edit' element={<EditUser />} />
|
<Route path=':id/edit' element={<EditUser />} />
|
||||||
|
<Route path=':id/access' element={<AccessMatrix />} />
|
||||||
<Route path='inactive' element={<InactiveUsersList />} />
|
<Route path='inactive' element={<InactiveUsersList />} />
|
||||||
<Route path='*' element={<NotFound />} />
|
<Route path='*' element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
@ -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 { Box, styled } from '@mui/material';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||||
import { VFC } from 'react';
|
import { VFC } from 'react';
|
||||||
@ -11,6 +12,7 @@ const StyledBox = styled(Box)(() => ({
|
|||||||
|
|
||||||
interface IUsersActionsCellProps {
|
interface IUsersActionsCellProps {
|
||||||
onEdit: (event: React.SyntheticEvent) => void;
|
onEdit: (event: React.SyntheticEvent) => void;
|
||||||
|
onViewAccess?: (event: React.SyntheticEvent) => void;
|
||||||
onChangePassword: (event: React.SyntheticEvent) => void;
|
onChangePassword: (event: React.SyntheticEvent) => void;
|
||||||
onResetPassword: (event: React.SyntheticEvent) => void;
|
onResetPassword: (event: React.SyntheticEvent) => void;
|
||||||
onDelete: (event: React.SyntheticEvent) => void;
|
onDelete: (event: React.SyntheticEvent) => void;
|
||||||
@ -18,6 +20,7 @@ interface IUsersActionsCellProps {
|
|||||||
|
|
||||||
export const UsersActionsCell: VFC<IUsersActionsCellProps> = ({
|
export const UsersActionsCell: VFC<IUsersActionsCellProps> = ({
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onViewAccess,
|
||||||
onChangePassword,
|
onChangePassword,
|
||||||
onResetPassword,
|
onResetPassword,
|
||||||
onDelete,
|
onDelete,
|
||||||
@ -34,6 +37,23 @@ export const UsersActionsCell: VFC<IUsersActionsCellProps> = ({
|
|||||||
>
|
>
|
||||||
<Edit />
|
<Edit />
|
||||||
</PermissionIconButton>
|
</PermissionIconButton>
|
||||||
|
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(onViewAccess)}
|
||||||
|
show={
|
||||||
|
<PermissionIconButton
|
||||||
|
data-loading
|
||||||
|
onClick={onViewAccess!}
|
||||||
|
permission={ADMIN}
|
||||||
|
tooltipProps={{
|
||||||
|
title: 'Access matrix',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Key />
|
||||||
|
</PermissionIconButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<PermissionIconButton
|
<PermissionIconButton
|
||||||
data-loading
|
data-loading
|
||||||
onClick={onChangePassword}
|
onClick={onChangePassword}
|
||||||
|
@ -34,8 +34,8 @@ import { UserLimitWarning } from './UserLimitWarning/UserLimitWarning';
|
|||||||
import { RoleCell } from 'component/common/Table/cells/RoleCell/RoleCell';
|
import { RoleCell } from 'component/common/Table/cells/RoleCell/RoleCell';
|
||||||
import { useSearch } from 'hooks/useSearch';
|
import { useSearch } from 'hooks/useSearch';
|
||||||
import { Download } from '@mui/icons-material';
|
import { Download } from '@mui/icons-material';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|
||||||
import { StyledUsersLinkDiv } from '../Users.styles';
|
import { StyledUsersLinkDiv } from '../Users.styles';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
|
||||||
const UsersList = () => {
|
const UsersList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -52,7 +52,7 @@ const UsersList = () => {
|
|||||||
}>({
|
}>({
|
||||||
open: false,
|
open: false,
|
||||||
});
|
});
|
||||||
const { isEnterprise } = useUiConfig();
|
const userAccessUIEnabled = useUiFlag('userAccessUIEnabled');
|
||||||
const [delDialog, setDelDialog] = useState(false);
|
const [delDialog, setDelDialog] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [emailSent, setEmailSent] = useState(false);
|
const [emailSent, setEmailSent] = useState(false);
|
||||||
@ -195,12 +195,21 @@ const UsersList = () => {
|
|||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
navigate(`/admin/users/${user.id}/edit`);
|
navigate(`/admin/users/${user.id}/edit`);
|
||||||
}}
|
}}
|
||||||
|
onViewAccess={
|
||||||
|
userAccessUIEnabled
|
||||||
|
? () => {
|
||||||
|
navigate(
|
||||||
|
`/admin/users/${user.id}/access`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onChangePassword={openPwDialog(user)}
|
onChangePassword={openPwDialog(user)}
|
||||||
onResetPassword={openResetPwDialog(user)}
|
onResetPassword={openResetPwDialog(user)}
|
||||||
onDelete={openDelDialog(user)}
|
onDelete={openDelDialog(user)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
width: 200,
|
width: userAccessUIEnabled ? 240 : 200,
|
||||||
disableSortBy: true,
|
disableSortBy: true,
|
||||||
},
|
},
|
||||||
// Always hidden -- for search
|
// Always hidden -- for search
|
||||||
@ -216,7 +225,7 @@ const UsersList = () => {
|
|||||||
searchable: true,
|
searchable: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[roles, navigate, isBillingUsers],
|
[roles, navigate, isBillingUsers, userAccessUIEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialState = useMemo(() => {
|
const initialState = useMemo(() => {
|
||||||
|
@ -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<IUserAccessMatrixResponse> {
|
||||||
|
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<IUserAccessMatrixResponse>(
|
||||||
|
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());
|
||||||
|
};
|
@ -36,3 +36,7 @@ export interface IPermissionCategory {
|
|||||||
type: PermissionType;
|
type: PermissionType;
|
||||||
permissions: IPermission[];
|
permissions: IPermission[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IMatrixPermission extends IPermission {
|
||||||
|
hasPermission: boolean;
|
||||||
|
}
|
||||||
|
@ -79,6 +79,7 @@ export type UiFlags = {
|
|||||||
displayUpgradeEdgeBanner?: boolean;
|
displayUpgradeEdgeBanner?: boolean;
|
||||||
showInactiveUsers?: boolean;
|
showInactiveUsers?: boolean;
|
||||||
featureSearchFeedbackPosting?: boolean;
|
featureSearchFeedbackPosting?: boolean;
|
||||||
|
userAccessUIEnabled?: boolean;
|
||||||
sdkReporting?: boolean;
|
sdkReporting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -138,6 +138,7 @@ exports[`should create default config 1`] = `
|
|||||||
"strictSchemaValidation": false,
|
"strictSchemaValidation": false,
|
||||||
"stripClientHeadersOn304": false,
|
"stripClientHeadersOn304": false,
|
||||||
"useMemoizedActiveTokens": false,
|
"useMemoizedActiveTokens": false,
|
||||||
|
"userAccessUIEnabled": false,
|
||||||
},
|
},
|
||||||
"externalResolver": {
|
"externalResolver": {
|
||||||
"getVariant": [Function],
|
"getVariant": [Function],
|
||||||
|
@ -56,6 +56,7 @@ import {
|
|||||||
createUserResponseSchema,
|
createUserResponseSchema,
|
||||||
CreateUserResponseSchema,
|
CreateUserResponseSchema,
|
||||||
} from '../../openapi/spec/create-user-response-schema';
|
} from '../../openapi/spec/create-user-response-schema';
|
||||||
|
import { IRoleWithPermissions } from '../../types/stores/access-store';
|
||||||
|
|
||||||
export default class UserAdminController extends Controller {
|
export default class UserAdminController extends Controller {
|
||||||
private flagResolver: IFlagResolver;
|
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({
|
this.route({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/admin-count',
|
path: '/admin-count',
|
||||||
@ -636,4 +675,45 @@ export default class UserAdminController extends Controller {
|
|||||||
adminCount,
|
adminCount,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPermissions(
|
||||||
|
req: IAuthRequest<
|
||||||
|
{ id: number },
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
{ project?: string; environment?: string }
|
||||||
|
>,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -63,6 +63,14 @@ const PROJECT_ADMIN = [
|
|||||||
export type IdPermissionRef = Pick<IPermission, 'id' | 'environment'>;
|
export type IdPermissionRef = Pick<IPermission, 'id' | 'environment'>;
|
||||||
export type NamePermissionRef = Pick<IPermission, 'name' | 'environment'>;
|
export type NamePermissionRef = Pick<IPermission, 'name' | 'environment'>;
|
||||||
export type PermissionRef = IdPermissionRef | NamePermissionRef;
|
export type PermissionRef = IdPermissionRef | NamePermissionRef;
|
||||||
|
type MatrixPermission = IPermission & {
|
||||||
|
hasPermission: boolean;
|
||||||
|
};
|
||||||
|
type PermissionMatrix = {
|
||||||
|
root: MatrixPermission[];
|
||||||
|
project: MatrixPermission[];
|
||||||
|
environment: MatrixPermission[];
|
||||||
|
};
|
||||||
|
|
||||||
type APIUser = Pick<IUser, 'id' | 'permissions'> & { isAPI: true };
|
type APIUser = Pick<IUser, 'id' | 'permissions'> & { isAPI: true };
|
||||||
type NonAPIUser = Pick<IUser, 'id'> & { isAPI?: false };
|
type NonAPIUser = Pick<IUser, 'id'> & { isAPI?: false };
|
||||||
@ -138,6 +146,32 @@ export class AccessService {
|
|||||||
this.eventService = eventService;
|
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
|
* Used to check if a user has access to the requested resource
|
||||||
*
|
*
|
||||||
@ -166,24 +200,12 @@ export class AccessService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const userP = await this.getPermissionsForUser(user);
|
const userP = await this.getPermissionsForUser(user);
|
||||||
return userP
|
return this.meetsAllPermissions(
|
||||||
.filter(
|
userP,
|
||||||
(p) =>
|
permissionsArray,
|
||||||
!p.project ||
|
projectId,
|
||||||
p.project === projectId ||
|
environment,
|
||||||
p.project === ALL_PROJECTS,
|
);
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
!p.environment ||
|
|
||||||
p.environment === environment ||
|
|
||||||
p.environment === ALL_ENVS,
|
|
||||||
)
|
|
||||||
.some(
|
|
||||||
(p) =>
|
|
||||||
permissionsArray.includes(p.permission) ||
|
|
||||||
p.permission === ADMIN,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error checking ${permissionLogInfo}, userId=${user.id} projectId=${projectId}`,
|
`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<PermissionMatrix> {
|
||||||
|
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(
|
async getPermissionsForUser(
|
||||||
user: APIUser | NonAPIUser,
|
user: APIUser | NonAPIUser,
|
||||||
): Promise<IUserPermission[]> {
|
): Promise<IUserPermission[]> {
|
||||||
|
@ -49,6 +49,7 @@ export type IFlagKey =
|
|||||||
| 'inMemoryScheduledChangeRequests'
|
| 'inMemoryScheduledChangeRequests'
|
||||||
| 'collectTrafficDataUsage'
|
| 'collectTrafficDataUsage'
|
||||||
| 'useMemoizedActiveTokens'
|
| 'useMemoizedActiveTokens'
|
||||||
|
| 'userAccessUIEnabled'
|
||||||
| 'sdkReporting';
|
| 'sdkReporting';
|
||||||
|
|
||||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
@ -242,6 +243,10 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_COLLECT_TRAFFIC_DATA_USAGE,
|
process.env.UNLEASH_EXPERIMENTAL_COLLECT_TRAFFIC_DATA_USAGE,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
userAccessUIEnabled: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_USER_ACCESS_UI_ENABLED,
|
||||||
|
false,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||||
|
@ -49,6 +49,7 @@ process.nextTick(async () => {
|
|||||||
featureSearchFeedbackPosting: true,
|
featureSearchFeedbackPosting: true,
|
||||||
extendedUsageMetricsUI: true,
|
extendedUsageMetricsUI: true,
|
||||||
executiveDashboard: true,
|
executiveDashboard: true,
|
||||||
|
userAccessUIEnabled: true,
|
||||||
sdkReporting: true,
|
sdkReporting: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user