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