1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +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:
Gastón Fournier 2024-02-16 14:31:33 +01:00 committed by GitHub
parent 4a81f0932f
commit 7a48fb57a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 502 additions and 23 deletions

View 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>
);
};

View File

@ -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}
/>
);

View File

@ -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>
);
};

View File

@ -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 = () => (
<div>
@ -21,6 +22,7 @@ export const UsersAdmin = () => (
}
/>
<Route path=':id/edit' element={<EditUser />} />
<Route path=':id/access' element={<AccessMatrix />} />
<Route path='inactive' element={<InactiveUsersList />} />
<Route path='*' element={<NotFound />} />
</Routes>

View File

@ -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<IUsersActionsCellProps> = ({
onEdit,
onViewAccess,
onChangePassword,
onResetPassword,
onDelete,
@ -34,6 +37,23 @@ export const UsersActionsCell: VFC<IUsersActionsCellProps> = ({
>
<Edit />
</PermissionIconButton>
<ConditionallyRender
condition={Boolean(onViewAccess)}
show={
<PermissionIconButton
data-loading
onClick={onViewAccess!}
permission={ADMIN}
tooltipProps={{
title: 'Access matrix',
}}
>
<Key />
</PermissionIconButton>
}
/>
<PermissionIconButton
data-loading
onClick={onChangePassword}

View File

@ -34,8 +34,8 @@ import { UserLimitWarning } from './UserLimitWarning/UserLimitWarning';
import { RoleCell } from 'component/common/Table/cells/RoleCell/RoleCell';
import { useSearch } from 'hooks/useSearch';
import { Download } from '@mui/icons-material';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { StyledUsersLinkDiv } from '../Users.styles';
import { useUiFlag } from 'hooks/useUiFlag';
const UsersList = () => {
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(() => {

View File

@ -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());
};

View File

@ -36,3 +36,7 @@ export interface IPermissionCategory {
type: PermissionType;
permissions: IPermission[];
}
export interface IMatrixPermission extends IPermission {
hasPermission: boolean;
}

View File

@ -79,6 +79,7 @@ export type UiFlags = {
displayUpgradeEdgeBanner?: boolean;
showInactiveUsers?: boolean;
featureSearchFeedbackPosting?: boolean;
userAccessUIEnabled?: boolean;
sdkReporting?: boolean;
};

View File

@ -138,6 +138,7 @@ exports[`should create default config 1`] = `
"strictSchemaValidation": false,
"stripClientHeadersOn304": false,
"useMemoizedActiveTokens": false,
"userAccessUIEnabled": false,
},
"externalResolver": {
"getVariant": [Function],

View File

@ -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<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,
});
}
}

View File

@ -63,6 +63,14 @@ const PROJECT_ADMIN = [
export type IdPermissionRef = Pick<IPermission, 'id' | 'environment'>;
export type NamePermissionRef = Pick<IPermission, 'name' | 'environment'>;
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 NonAPIUser = Pick<IUser, 'id'> & { 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<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(
user: APIUser | NonAPIUser,
): Promise<IUserPermission[]> {

View File

@ -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 = {

View File

@ -49,6 +49,7 @@ process.nextTick(async () => {
featureSearchFeedbackPosting: true,
extendedUsageMetricsUI: true,
executiveDashboard: true,
userAccessUIEnabled: true,
sdkReporting: true,
},
},