diff --git a/frontend/cypress/integration/projects/access.spec.ts b/frontend/cypress/integration/projects/access.spec.ts
index c2d48bea32..d3f26a433f 100644
--- a/frontend/cypress/integration/projects/access.spec.ts
+++ b/frontend/cypress/integration/projects/access.spec.ts
@@ -47,6 +47,19 @@ describe('project-access', () => {
id: groupAndProjectName,
name: groupAndProjectName,
});
+
+ cy.intercept('GET', `${baseUrl}/api/admin/ui-config`, req => {
+ req.headers['cache-control'] =
+ 'no-cache, no-store, must-revalidate';
+ req.on('response', res => {
+ if (res.body) {
+ res.body.flags = {
+ ...res.body.flags,
+ multipleRoles: true,
+ };
+ }
+ });
+ });
});
after(() => {
@@ -76,7 +89,7 @@ describe('project-access', () => {
cy.intercept(
'POST',
- `/api/admin/projects/${groupAndProjectName}/role/4/access`
+ `/api/admin/projects/${groupAndProjectName}/access`
).as('assignAccess');
cy.get(`[data-testid='${PA_USERS_GROUPS_ID}']`).click();
@@ -95,7 +108,7 @@ describe('project-access', () => {
cy.intercept(
'POST',
- `/api/admin/projects/${groupAndProjectName}/role/4/access`
+ `/api/admin/projects/${groupAndProjectName}/access`
).as('assignAccess');
cy.get(`[data-testid='${PA_USERS_GROUPS_ID}']`).click();
@@ -114,9 +127,10 @@ describe('project-access', () => {
cy.intercept(
'PUT',
- `/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles/5`
+ `/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles`
).as('editAccess');
+ cy.get(`[data-testid='CancelIcon']`).last().click();
cy.get(`[data-testid='${PA_ROLE_ID}']`).click();
cy.contains('update feature toggles within a project').click({
force: true,
@@ -128,12 +142,31 @@ describe('project-access', () => {
cy.get("td span:contains('Member')").should('have.length', 1);
});
+ it('can edit role to multiple roles', () => {
+ cy.get(`[data-testid='${PA_EDIT_BUTTON_ID}']`).first().click();
+
+ cy.intercept(
+ 'PUT',
+ `/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles`
+ ).as('editAccess');
+
+ cy.get(`[data-testid='${PA_ROLE_ID}']`).click();
+ cy.contains('full control over the project').click({
+ force: true,
+ });
+
+ cy.get(`[data-testid='${PA_ASSIGN_CREATE_ID}']`).click();
+ cy.wait('@editAccess');
+ cy.get("td span:contains('Owner')").should('have.length', 2);
+ cy.get("td span:contains('2 roles')").should('have.length', 1);
+ });
+
it('can remove access', () => {
cy.get(`[data-testid='${PA_REMOVE_BUTTON_ID}']`).first().click();
cy.intercept(
'DELETE',
- `/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles/5`
+ `/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles`
).as('removeAccess');
cy.contains("Yes, I'm sure").click();
diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx
index bcf457d7c2..02531b8486 100644
--- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx
+++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx
@@ -93,9 +93,13 @@ export const ServiceAccountsTable = () => {
accessor: (row: any) =>
roles.find((role: IRole) => role.id === row.rootRole)
?.name || '',
- Cell: ({ row: { original: serviceAccount }, value }: any) => (
-
- ),
+ Cell: ({
+ row: { original: serviceAccount },
+ value,
+ }: {
+ row: { original: IServiceAccount };
+ value: string;
+ }) => ,
maxWidth: 120,
},
{
diff --git a/frontend/src/component/admin/users/UsersList/UsersList.tsx b/frontend/src/component/admin/users/UsersList/UsersList.tsx
index 0f2cad9f44..9589027085 100644
--- a/frontend/src/component/admin/users/UsersList/UsersList.tsx
+++ b/frontend/src/component/admin/users/UsersList/UsersList.tsx
@@ -125,9 +125,13 @@ const UsersList = () => {
accessor: (row: any) =>
roles.find((role: IRole) => role.id === row.rootRole)
?.name || '',
- Cell: ({ row: { original: user }, value }: any) => (
-
- ),
+ Cell: ({
+ row: { original: user },
+ value,
+ }: {
+ row: { original: IUser };
+ value: string;
+ }) => ,
maxWidth: 120,
},
{
diff --git a/frontend/src/component/common/MultipleRoleSelect/MultipleRoleSelect.tsx b/frontend/src/component/common/MultipleRoleSelect/MultipleRoleSelect.tsx
new file mode 100644
index 0000000000..1237856bc5
--- /dev/null
+++ b/frontend/src/component/common/MultipleRoleSelect/MultipleRoleSelect.tsx
@@ -0,0 +1,89 @@
+import {
+ Autocomplete,
+ AutocompleteProps,
+ AutocompleteRenderOptionState,
+ Checkbox,
+ TextField,
+ styled,
+} from '@mui/material';
+import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
+import CheckBoxIcon from '@mui/icons-material/CheckBox';
+import { IRole } from 'interfaces/role';
+import { RoleDescription } from '../RoleDescription/RoleDescription';
+import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
+
+const StyledRoleOption = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ '& > span:last-of-type': {
+ fontSize: theme.fontSizes.smallerBody,
+ color: theme.palette.text.secondary,
+ },
+}));
+
+interface IMultipleRoleSelectProps
+ extends Partial> {
+ roles: IRole[];
+ value: IRole[];
+ setValue: (role: IRole[]) => void;
+ required?: boolean;
+}
+
+export const MultipleRoleSelect = ({
+ roles,
+ value,
+ setValue,
+ required,
+ ...rest
+}: IMultipleRoleSelectProps) => {
+ const renderRoleOption = (
+ props: React.HTMLAttributes,
+ option: IRole,
+ state: AutocompleteRenderOptionState
+ ) => (
+
+ }
+ checkedIcon={}
+ style={{ marginRight: 8 }}
+ checked={state.selected}
+ />
+
+ {option.name}
+ {option.description}
+
+
+ );
+
+ return (
+ <>
+ setValue(roles)}
+ options={roles}
+ renderOption={renderRoleOption}
+ getOptionLabel={option => option.name}
+ renderInput={params => (
+
+ )}
+ {...rest}
+ />
+ 0}
+ show={() =>
+ value.map(({ id }) => (
+
+ ))
+ }
+ />
+ >
+ );
+};
diff --git a/frontend/src/component/common/RoleDescription/RoleDescription.tsx b/frontend/src/component/common/RoleDescription/RoleDescription.tsx
index 04b83539a6..2ace95f1f4 100644
--- a/frontend/src/component/common/RoleDescription/RoleDescription.tsx
+++ b/frontend/src/component/common/RoleDescription/RoleDescription.tsx
@@ -21,7 +21,7 @@ const StyledDescription = styled('div', {
: theme.palette.neutral.light,
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
- borderRadius: theme.shape.borderRadiusMedium,
+ borderRadius: tooltip ? 0 : theme.shape.borderRadiusMedium,
}));
const StyledDescriptionBlock = styled('div')(({ theme }) => ({
diff --git a/frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx b/frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx
index 28b97df2f1..aee98d9096 100644
--- a/frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx
+++ b/frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx
@@ -3,20 +3,52 @@ import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { RoleDescription } from 'component/common/RoleDescription/RoleDescription';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { styled } from '@mui/material';
-interface IRoleCellProps {
- roleId: number;
+const StyledRoleDescriptions = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(0.5),
+ '& > *:not(:last-child)': {
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ paddingBottom: theme.spacing(1),
+ },
+}));
+
+type TSingleRoleProps = {
value: string;
-}
+ role: number;
+ roles?: never;
+};
-export const RoleCell: VFC = ({ roleId, value }) => {
+type TMultipleRolesProps = {
+ value: string;
+ roles: number[];
+ role?: never;
+};
+
+type TRoleCellProps = TSingleRoleProps | TMultipleRolesProps;
+
+export const RoleCell: VFC = ({ role, roles, value }) => {
const { isEnterprise } = useUiConfig();
if (isEnterprise()) {
+ const rolesArray = roles ? roles : [role];
+
return (
}
+ tooltip={
+
+ {rolesArray.map(roleId => (
+
+ ))}
+
+ }
>
{value}
diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx
index 70fceade6d..a3bb51b1fb 100644
--- a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx
+++ b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx
@@ -35,6 +35,7 @@ import {
} from 'utils/testIds';
import { caseInsensitiveSearch } from 'utils/search';
import { IServiceAccount } from 'interfaces/service-account';
+import { MultipleRoleSelect } from 'component/common/MultipleRoleSelect/MultipleRoleSelect';
import { RoleSelect } from 'component/common/RoleSelect/RoleSelect';
const StyledForm = styled('form')(() => ({
@@ -111,7 +112,7 @@ export const ProjectAccessAssign = ({
const projectId = useRequiredPathParam('projectId');
const { refetchProjectAccess } = useProjectAccess(projectId);
- const { addAccessToProject, changeUserRole, changeGroupRole, loading } =
+ const { addAccessToProject, setUserRoles, setGroupRoles, loading } =
useProjectApi();
const edit = Boolean(selected);
@@ -181,38 +182,46 @@ export const ProjectAccessAssign = ({
id === selected?.entity.id && type === selected?.type
)
);
- const [role, setRole] = useState(
- roles.find(({ id }) => id === selected?.entity.roleId) ?? null
+ const [selectedRoles, setRoles] = useState(
+ roles.filter(({ id }) => selected?.entity?.roles?.includes(id))
);
const payload = {
+ roles: selectedRoles.map(({ id }) => id),
+ groups: selectedOptions
+ ?.filter(({ type }) => type === ENTITY_TYPE.GROUP)
+ .map(({ id }) => id),
users: selectedOptions
?.filter(
({ type }) =>
type === ENTITY_TYPE.USER ||
type === ENTITY_TYPE.SERVICE_ACCOUNT
)
- .map(({ id }) => ({ id })),
- groups: selectedOptions
- ?.filter(({ type }) => type === ENTITY_TYPE.GROUP)
- .map(({ id }) => ({ id })),
+ .map(({ id }) => id),
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!isValid) return;
-
try {
if (!edit) {
- await addAccessToProject(projectId, role.id, payload);
+ await addAccessToProject(projectId, payload);
} else if (
selected?.type === ENTITY_TYPE.USER ||
selected?.type === ENTITY_TYPE.SERVICE_ACCOUNT
) {
- await changeUserRole(projectId, role.id, selected.entity.id);
+ await setUserRoles(
+ projectId,
+ selectedRoles.map(({ id }) => id),
+ selected.entity.id
+ );
} else if (selected?.type === ENTITY_TYPE.GROUP) {
- await changeGroupRole(projectId, role.id, selected.entity.id);
+ await setGroupRoles(
+ projectId,
+ selectedRoles.map(({ id }) => id),
+ selected.entity.id
+ );
}
refetchProjectAccess();
navigate(GO_BACK);
@@ -229,22 +238,24 @@ export const ProjectAccessAssign = ({
const formatApiCode = () => {
if (edit) {
- return `curl --location --request ${edit ? 'PUT' : 'POST'} '${
+ return `curl --location --request PUT '${
uiConfig.unleashUrl
}/api/admin/projects/${projectId}/${
selected?.type === ENTITY_TYPE.USER ||
selected?.type === ENTITY_TYPE.SERVICE_ACCOUNT
? 'users'
: 'groups'
- }/${selected?.entity.id}/roles/${role?.id}' \\
- --header 'Authorization: INSERT_API_KEY'`;
+ }/${selected?.entity.id}/roles' \\
+--header 'Authorization: INSERT_API_KEY' \\
+--header 'Content-Type: application/json' \\
+--data-raw '${JSON.stringify({ roles: payload.roles }, undefined, 2)}'`;
}
- return `curl --location --request ${edit ? 'PUT' : 'POST'} '${
+ return `curl --location --request POST '${
uiConfig.unleashUrl
- }/api/admin/projects/${projectId}/role/${role?.id}/access' \\
- --header 'Authorization: INSERT_API_KEY' \\
- --header 'Content-Type: application/json' \\
- --data-raw '${JSON.stringify(payload, undefined, 2)}'`;
+ }/api/admin/projects/${projectId}/access' \\
+--header 'Authorization: INSERT_API_KEY' \\
+--header 'Content-Type: application/json' \\
+--data-raw '${JSON.stringify(payload, undefined, 2)}'`;
};
const createRootGroupWarning = (group?: IGroup): string | undefined => {
@@ -308,7 +319,7 @@ export const ProjectAccessAssign = ({
);
};
- const isValid = selectedOptions.length > 0 && role;
+ const isValid = selectedOptions.length > 0 && selectedRoles.length > 0;
return (
- setRole(role || null)}
+ (
+
+ )}
+ elseShow={() => (
+
+ setRoles(role ? [role] : [])
+ }
+ />
+ )}
/>
diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx
index f4fd627e27..94a350ccfc 100644
--- a/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx
+++ b/frontend/src/component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable.tsx
@@ -93,7 +93,7 @@ export const ProjectAccessTable: VFC = () => {
const { setToastData } = useToast();
const { access, refetchProjectAccess } = useProjectAccess(projectId);
- const { removeUserFromRole, removeGroupFromRole } = useProjectApi();
+ const { removeUserAccess, removeGroupAccess } = useProjectApi();
const [removeOpen, setRemoveOpen] = useState(false);
const [groupOpen, setGroupOpen] = useState(false);
const [selectedRow, setSelectedRow] = useState();
@@ -151,11 +151,18 @@ export const ProjectAccessTable: VFC = () => {
id: 'role',
Header: 'Role',
accessor: (row: IProjectAccess) =>
- access?.roles.find(({ id }) => id === row.entity.roleId)
- ?.name,
- Cell: ({ value, row: { original: row } }: any) => (
-
- ),
+ row.entity.roles.length > 1
+ ? `${row.entity.roles.length} roles`
+ : access?.roles.find(
+ ({ id }) => id === row.entity.roleId
+ )?.name,
+ Cell: ({
+ value,
+ row: { original: row },
+ }: {
+ row: { original: IProjectAccess };
+ value: string;
+ }) => ,
maxWidth: 125,
filterName: 'role',
},
@@ -345,7 +352,7 @@ export const ProjectAccessTable: VFC = () => {
const removeAccess = async (userOrGroup?: IProjectAccess) => {
if (!userOrGroup) return;
- const { id, roleId } = userOrGroup.entity;
+ const { id } = userOrGroup.entity;
let name = userOrGroup.entity.name;
if (userOrGroup.type !== ENTITY_TYPE.GROUP) {
const user = userOrGroup.entity as IUser;
@@ -354,9 +361,9 @@ export const ProjectAccessTable: VFC = () => {
try {
if (userOrGroup.type !== ENTITY_TYPE.GROUP) {
- await removeUserFromRole(projectId, roleId, id);
+ await removeUserAccess(projectId, id);
} else {
- await removeGroupFromRole(projectId, roleId, id);
+ await removeGroupAccess(projectId, id);
}
refetchProjectAccess();
setToastData({
diff --git a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts
index d1690dbec8..7e92c4df0e 100644
--- a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts
+++ b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts
@@ -9,9 +9,10 @@ interface ICreatePayload {
defaultStickiness: string;
}
-interface IAccessesPayload {
- users: { id: number }[];
- groups: { id: number }[];
+interface IAccessPayload {
+ roles: number[];
+ groups: number[];
+ users: number[];
}
const useProjectApi = () => {
@@ -116,93 +117,59 @@ const useProjectApi = () => {
const addAccessToProject = async (
projectId: string,
- roleId: number,
- accesses: IAccessesPayload
+ payload: IAccessPayload
) => {
- const path = `api/admin/projects/${projectId}/role/${roleId}/access`;
+ const path = `api/admin/projects/${projectId}/access`;
const req = createRequest(path, {
method: 'POST',
- body: JSON.stringify(accesses),
+ body: JSON.stringify(payload),
});
- try {
- const res = await makeRequest(req.caller, req.id);
-
- return res;
- } catch (e) {
- throw e;
- }
+ return await makeRequest(req.caller, req.id);
};
- const removeUserFromRole = async (
- projectId: string,
- roleId: number,
- userId: number
- ) => {
- const path = `api/admin/projects/${projectId}/users/${userId}/roles/${roleId}`;
+ const removeUserAccess = async (projectId: string, userId: number) => {
+ const path = `api/admin/projects/${projectId}/users/${userId}/roles`;
const req = createRequest(path, { method: 'DELETE' });
- try {
- const res = await makeRequest(req.caller, req.id);
-
- return res;
- } catch (e) {
- throw e;
- }
+ return await makeRequest(req.caller, req.id);
};
- const removeGroupFromRole = async (
- projectId: string,
- roleId: number,
- groupId: number
- ) => {
- const path = `api/admin/projects/${projectId}/groups/${groupId}/roles/${roleId}`;
+ const removeGroupAccess = async (projectId: string, groupId: number) => {
+ const path = `api/admin/projects/${projectId}/groups/${groupId}/roles`;
const req = createRequest(path, { method: 'DELETE' });
- try {
- const res = await makeRequest(req.caller, req.id);
-
- return res;
- } catch (e) {
- throw e;
- }
+ return await makeRequest(req.caller, req.id);
};
- const searchProjectUser = async (query: string): Promise => {
- const path = `api/admin/user-admin/search?q=${query}`;
-
- const req = createRequest(path, { method: 'GET' });
-
- try {
- const res = await makeRequest(req.caller, req.id);
-
- return res;
- } catch (e) {
- throw e;
- }
- };
-
- const changeUserRole = (
+ const setUserRoles = (
projectId: string,
- roleId: number,
+ roleIds: number[],
userId: number
) => {
- const path = `api/admin/projects/${projectId}/users/${userId}/roles/${roleId}`;
- const req = createRequest(path, { method: 'PUT' });
+ const path = `api/admin/projects/${projectId}/users/${userId}/roles`;
+ const req = createRequest(path, {
+ method: 'PUT',
+ body: JSON.stringify({ roles: roleIds }),
+ });
return makeRequest(req.caller, req.id);
};
- const changeGroupRole = (
+ const setGroupRoles = (
projectId: string,
- roleId: number,
+ roleIds: number[],
groupId: number
) => {
- const path = `api/admin/projects/${projectId}/groups/${groupId}/roles/${roleId}`;
- const req = createRequest(path, { method: 'PUT' });
+ const path = `api/admin/projects/${projectId}/groups/${groupId}/roles`;
+ const req = createRequest(path, {
+ method: 'PUT',
+ body: JSON.stringify({ roles: roleIds }),
+ });
return makeRequest(req.caller, req.id);
};
+
const archiveFeatures = async (projectId: string, featureIds: string[]) => {
const path = `api/admin/projects/${projectId}/archive`;
const req = createRequest(path, {
@@ -283,16 +250,15 @@ const useProjectApi = () => {
addEnvironmentToProject,
removeEnvironmentFromProject,
addAccessToProject,
- removeUserFromRole,
- removeGroupFromRole,
- changeUserRole,
- changeGroupRole,
+ removeUserAccess,
+ removeGroupAccess,
+ setUserRoles,
+ setGroupRoles,
archiveFeatures,
reviveFeatures,
staleFeatures,
deleteFeature,
deleteFeatures,
- searchProjectUser,
updateDefaultStrategy,
errors,
loading,
diff --git a/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts b/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts
index e5797a9037..d638d37711 100644
--- a/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts
+++ b/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts
@@ -20,10 +20,12 @@ export interface IProjectAccess {
}
export interface IProjectAccessUser extends IUser {
+ roles: number[];
roleId: number;
}
export interface IProjectAccessGroup extends IGroup {
+ roles: number[];
roleId: number;
}
diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts
index 20668013bd..812e3cfb51 100644
--- a/frontend/src/interfaces/uiConfig.ts
+++ b/frontend/src/interfaces/uiConfig.ts
@@ -57,6 +57,7 @@ export interface IFlags {
lastSeenByEnvironment?: boolean;
newApplicationList?: boolean;
integrationsRework?: boolean;
+ multipleRoles?: boolean;
}
export interface IVersionInfo {
diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap
index cce04bf77f..3431f8b6d7 100644
--- a/src/lib/__snapshots__/create-config.test.ts.snap
+++ b/src/lib/__snapshots__/create-config.test.ts.snap
@@ -92,6 +92,7 @@ exports[`should create default config 1`] = `
},
},
"migrationLock": true,
+ "multipleRoles": false,
"personalAccessTokensKillSwitch": false,
"proPlanAutoCharge": false,
"responseTimeWithAppNameKillSwitch": false,
@@ -127,6 +128,7 @@ exports[`should create default config 1`] = `
},
},
"migrationLock": true,
+ "multipleRoles": false,
"personalAccessTokensKillSwitch": false,
"proPlanAutoCharge": false,
"responseTimeWithAppNameKillSwitch": false,
diff --git a/src/lib/db/access-store.ts b/src/lib/db/access-store.ts
index 64314e7fb5..0b18093c83 100644
--- a/src/lib/db/access-store.ts
+++ b/src/lib/db/access-store.ts
@@ -10,11 +10,13 @@ import {
IRoleWithProject,
IUserPermission,
IUserRole,
+ IUserWithProjectRoles,
} from '../types/stores/access-store';
import { IPermission } from '../types/model';
import NotFoundError from '../error/notfound-error';
import {
ENVIRONMENT_PERMISSION_TYPE,
+ PROJECT_ROLE_TYPES,
ROOT_PERMISSION_TYPE,
} from '../util/constants';
import { Db } from './db';
@@ -144,7 +146,6 @@ export class AccessStore implements IAccessStore {
.join(`${T.GROUP_ROLE} AS gr`, 'gu.group_id', 'gr.group_id')
.join(`${T.ROLE_PERMISSION} AS rp`, 'rp.role_id', 'gr.role_id')
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
- .whereNull('g.root_role_id')
.andWhere('gu.user_id', '=', userId);
});
@@ -167,7 +168,6 @@ export class AccessStore implements IAccessStore {
.whereNotNull('g.root_role_id')
.andWhere('gu.user_id', '=', userId);
});
-
const rows = await userPermissionQuery;
stopTimer();
return rows.map(this.mapUserPermission);
@@ -281,6 +281,34 @@ export class AccessStore implements IAccessStore {
}));
}
+ async getProjectUsers(
+ projectId?: string,
+ ): Promise {
+ const rows = await this.db
+ .select(['user_id', 'ru.created_at', 'ru.role_id'])
+ .from(`${T.ROLE_USER} AS ru`)
+ .join(`${T.ROLES} as r`, 'ru.role_id', 'id')
+ .whereIn('r.type', PROJECT_ROLE_TYPES)
+ .andWhere('ru.project', projectId);
+
+ return rows.reduce((acc, row) => {
+ const existingUser = acc.find((user) => user.id === row.user_id);
+
+ if (existingUser) {
+ existingUser.roles.push(row.role_id);
+ } else {
+ acc.push({
+ id: row.user_id,
+ addedAt: row.created_at,
+ roleId: row.role_id,
+ roles: [row.role_id],
+ });
+ }
+
+ return acc;
+ }, []);
+ }
+
async getRolesForUserId(userId: number): Promise {
return this.db
.select(['id', 'name', 'type', 'project', 'description'])
@@ -310,13 +338,13 @@ export class AccessStore implements IAccessStore {
): Promise {
const query = await this.db.raw(
`
- SELECT
+ SELECT
uq.project,
sum(uq.user_count) AS user_count,
sum(uq.svc_account_count) AS svc_account_count,
sum(uq.group_count) AS group_count
FROM (
- SELECT
+ SELECT
project,
0 AS user_count,
0 AS svc_account_count,
@@ -341,12 +369,6 @@ export class AccessStore implements IAccessStore {
[roleId, roleId],
);
- /*
- const rows2 = await this.db(T.ROLE_USER)
- .select('project', this.db.raw('count(project) as user_count'))
- .where('role_id', roleId)
- .groupBy('project');
- */
return query.rows.map((r) => {
return {
project: r.project,
@@ -446,7 +468,7 @@ export class AccessStore implements IAccessStore {
.update('role_id', roleId);
}
- async addAccessToProject(
+ async addRoleAccessToProject(
users: IAccessInfo[],
groups: IAccessInfo[],
projectId: string,
@@ -486,6 +508,181 @@ export class AccessStore implements IAccessStore {
});
}
+ async addAccessToProject(
+ roles: number[],
+ groups: number[],
+ users: number[],
+ projectId: string,
+ createdBy: string,
+ ): Promise {
+ const validatedProjectRoleIds = await this.db(T.ROLES)
+ .select('id')
+ .whereIn('id', roles)
+ .whereIn('type', PROJECT_ROLE_TYPES)
+ .pluck('id');
+
+ const groupRows = groups.flatMap((group) =>
+ validatedProjectRoleIds.map((role) => ({
+ group_id: group,
+ project: projectId,
+ role_id: role,
+ created_by: createdBy,
+ })),
+ );
+
+ const userRows = users.flatMap((user) =>
+ validatedProjectRoleIds.map((role) => ({
+ user_id: user,
+ project: projectId,
+ role_id: role,
+ })),
+ );
+
+ await this.db.transaction(async (tx) => {
+ if (groupRows.length > 0) {
+ await tx(T.GROUP_ROLE)
+ .insert(groupRows)
+ .onConflict(['project', 'role_id', 'group_id'])
+ .merge();
+ }
+ if (userRows.length > 0) {
+ await tx(T.ROLE_USER)
+ .insert(userRows)
+ .onConflict(['project', 'role_id', 'user_id'])
+ .merge();
+ }
+ });
+ }
+
+ async setProjectRolesForUser(
+ projectId: string,
+ userId: number,
+ roles: number[],
+ ): Promise {
+ const projectRoleIds = await this.db(T.ROLES)
+ .select('id')
+ .whereIn('type', PROJECT_ROLE_TYPES)
+ .pluck('id');
+
+ const projectRoleIdsSet = new Set(projectRoleIds);
+
+ const userRows = roles
+ .filter((role) => projectRoleIdsSet.has(role))
+ .map((role) => ({
+ user_id: userId,
+ project: projectId,
+ role_id: role,
+ }));
+
+ await this.db.transaction(async (tx) => {
+ await tx(T.ROLE_USER)
+ .where('project', projectId)
+ .andWhere('user_id', userId)
+ .whereIn('role_id', projectRoleIds)
+ .delete();
+
+ if (userRows.length > 0) {
+ await tx(T.ROLE_USER)
+ .insert(userRows)
+ .onConflict(['project', 'role_id', 'user_id'])
+ .ignore();
+ }
+ });
+ }
+
+ async getProjectRolesForUser(
+ projectId: string,
+ userId: number,
+ ): Promise {
+ const rows = await this.db(`${T.ROLE_USER} as ru`)
+ .join(`${T.ROLES} as r`, 'ru.role_id', 'r.id')
+ .select('ru.role_id')
+ .where('ru.project', projectId)
+ .whereIn('r.type', PROJECT_ROLE_TYPES)
+ .andWhere('ru.user_id', userId);
+ return rows.map((r) => r.role_id as number);
+ }
+
+ async setProjectRolesForGroup(
+ projectId: string,
+ groupId: number,
+ roles: number[],
+ createdBy: string,
+ ): Promise {
+ const projectRoleIds = await this.db(T.ROLES)
+ .select('id')
+ .whereIn('type', PROJECT_ROLE_TYPES)
+ .pluck('id');
+
+ const projectRoleIdsSet = new Set(projectRoleIds);
+
+ const groupRows = roles
+ .filter((role) => projectRoleIdsSet.has(role))
+ .map((role) => ({
+ group_id: groupId,
+ project: projectId,
+ role_id: role,
+ created_by: createdBy,
+ }));
+
+ await this.db.transaction(async (tx) => {
+ await tx(T.GROUP_ROLE)
+ .where('project', projectId)
+ .andWhere('group_id', groupId)
+ .whereIn('role_id', projectRoleIds)
+ .delete();
+ if (groupRows.length > 0) {
+ await tx(T.GROUP_ROLE)
+ .insert(groupRows)
+ .onConflict(['project', 'role_id', 'group_id'])
+ .ignore();
+ }
+ });
+ }
+
+ async getProjectRolesForGroup(
+ projectId: string,
+ groupId: number,
+ ): Promise {
+ const rows = await this.db(`${T.GROUP_ROLE} as gr`)
+ .join(`${T.ROLES} as r`, 'gr.role_id', 'r.id')
+ .select('gr.role_id')
+ .where('gr.project', projectId)
+ .whereIn('r.type', PROJECT_ROLE_TYPES)
+ .andWhere('gr.group_id', groupId);
+ return rows.map((row) => row.role_id as number);
+ }
+
+ async removeUserAccess(projectId: string, userId: number): Promise {
+ return this.db(T.ROLE_USER)
+ .where({
+ user_id: userId,
+ project: projectId,
+ })
+ .whereIn(
+ 'role_id',
+ this.db(T.ROLES)
+ .select('id as role_id')
+ .whereIn('type', PROJECT_ROLE_TYPES),
+ )
+ .delete();
+ }
+
+ async removeGroupAccess(projectId: string, groupId: number): Promise {
+ return this.db(T.GROUP_ROLE)
+ .where({
+ group_id: groupId,
+ project: projectId,
+ })
+ .whereIn(
+ 'role_id',
+ this.db(T.ROLES)
+ .select('id as role_id')
+ .whereIn('type', PROJECT_ROLE_TYPES),
+ )
+ .delete();
+ }
+
async removeRolesOfTypeForUser(
userId: number,
roleTypes: string[],
diff --git a/src/lib/db/group-store.ts b/src/lib/db/group-store.ts
index cd32b7b95e..f8ae1091bd 100644
--- a/src/lib/db/group-store.ts
+++ b/src/lib/db/group-store.ts
@@ -10,6 +10,8 @@ import Group, {
} from '../types/group';
import { Db } from './db';
import { BadDataError, FOREIGN_KEY_VIOLATION } from '../error';
+import { IGroupWithProjectRoles } from '../types/stores/access-store';
+import { PROJECT_ROLE_TYPES } from '../util';
const T = {
GROUPS: 'groups',
@@ -116,6 +118,36 @@ export default class GroupStore implements IGroupStore {
});
}
+ async getProjectGroups(
+ projectId: string,
+ ): Promise {
+ const rows = await this.db
+ .select(['gr.group_id', 'gr.created_at', 'gr.role_id'])
+ .from(`${T.GROUP_ROLE} AS gr`)
+ .join(`${T.ROLES} as r`, 'gr.role_id', 'r.id')
+ .whereIn('r.type', PROJECT_ROLE_TYPES)
+ .andWhere('project', projectId);
+
+ return rows.reduce((acc, row) => {
+ const existingGroup = acc.find(
+ (group) => group.id === row.group_id,
+ );
+
+ if (existingGroup) {
+ existingGroup.roles.push(row.role_id);
+ } else {
+ acc.push({
+ id: row.group_id,
+ addedAt: row.created_at,
+ roleId: row.role_id,
+ roles: [row.role_id],
+ });
+ }
+
+ return acc;
+ }, []);
+ }
+
async getGroupProjects(groupIds: number[]): Promise {
const rows = await this.db
.select('group_id', 'project')
diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts
index 90a71512ff..ae59879fe2 100644
--- a/src/lib/services/access-service.ts
+++ b/src/lib/services/access-service.ts
@@ -1,14 +1,17 @@
import * as permissions from '../types/permissions';
-import User, { IProjectUser, IUser } from '../types/user';
+import User, { IUser } from '../types/user';
import {
IAccessInfo,
IAccessStore,
+ IGroupWithProjectRoles,
IProjectRoleUsage,
IRole,
+ IRoleDescriptor,
IRoleWithPermissions,
IRoleWithProject,
IUserPermission,
IUserRole,
+ IUserWithProjectRoles,
} from '../types/stores/access-store';
import { Logger } from '../logger';
import { IAccountStore, IGroupStore, IUnleashStores } from '../types/stores';
@@ -35,7 +38,7 @@ import {
import { DEFAULT_PROJECT } from '../types/project';
import InvalidOperationError from '../error/invalid-operation-error';
import BadDataError from '../error/bad-data-error';
-import { IGroup, IGroupModelWithProjectRole } from '../types/group';
+import { IGroup } from '../types/group';
import { GroupService } from './group-service';
import { IFlagResolver, IUnleashConfig } from 'lib/types';
@@ -70,6 +73,12 @@ interface IRoleUpdate {
permissions?: IPermission[];
}
+export interface AccessWithRoles {
+ roles: IRoleDescriptor[];
+ groups: IGroupWithProjectRoles[];
+ users: IUserWithProjectRoles[];
+}
+
const isProjectPermission = (permission) => PROJECT_ADMIN.includes(permission);
export class AccessService {
@@ -235,14 +244,14 @@ export class AccessService {
return this.store.addGroupToRole(groupId, roleId, createdBy, projectId);
}
- async addAccessToProject(
+ async addRoleAccessToProject(
users: IAccessInfo[],
groups: IAccessInfo[],
projectId: string,
roleId: number,
createdBy: string,
): Promise {
- return this.store.addAccessToProject(
+ return this.store.addRoleAccessToProject(
users,
groups,
projectId,
@@ -251,10 +260,70 @@ export class AccessService {
);
}
+ async addAccessToProject(
+ roles: number[],
+ groups: number[],
+ users: number[],
+ projectId: string,
+ createdBy: string,
+ ): Promise {
+ return this.store.addAccessToProject(
+ roles,
+ groups,
+ users,
+ projectId,
+ createdBy,
+ );
+ }
+
+ async setProjectRolesForUser(
+ projectId: string,
+ userId: number,
+ roles: number[],
+ ): Promise {
+ await this.store.setProjectRolesForUser(projectId, userId, roles);
+ }
+
+ async getProjectRolesForUser(
+ projectId: string,
+ userId: number,
+ ): Promise {
+ return this.store.getProjectRolesForUser(projectId, userId);
+ }
+
+ async setProjectRolesForGroup(
+ projectId: string,
+ groupId: number,
+ roles: number[],
+ createdBy: string,
+ ): Promise {
+ await this.store.setProjectRolesForGroup(
+ projectId,
+ groupId,
+ roles,
+ createdBy,
+ );
+ }
+
+ async getProjectRolesForGroup(
+ projectId: string,
+ groupId: number,
+ ): Promise {
+ return this.store.getProjectRolesForGroup(projectId, groupId);
+ }
+
async getRoleByName(roleName: string): Promise {
return this.roleStore.getRoleByName(roleName);
}
+ async removeUserAccess(projectId: string, userId: number): Promise {
+ await this.store.removeUserAccess(projectId, userId);
+ }
+
+ async removeGroupAccess(projectId: string, groupId: number): Promise {
+ await this.store.removeGroupAccess(projectId, groupId);
+ }
+
async setUserRootRole(
userId: number,
role: number | RoleName,
@@ -417,7 +486,7 @@ export class AccessService {
async getProjectUsersForRole(
roleId: number,
projectId?: string,
- ): Promise {
+ ): Promise {
const userRoleList = await this.store.getProjectUsersForRole(
roleId,
projectId,
@@ -430,28 +499,44 @@ export class AccessService {
return {
...user,
addedAt: role.addedAt!,
+ roleId,
};
});
}
return [];
}
- async getProjectRoleAccess(
- projectId: string,
- ): Promise<[IRole[], IUserWithRole[], IGroupModelWithProjectRole[]]> {
+ async getProjectUsers(projectId: string): Promise {
+ const projectUsers = await this.store.getProjectUsers(projectId);
+
+ if (projectUsers.length > 0) {
+ const users = await this.accountStore.getAllWithId(
+ projectUsers.map((u) => u.id),
+ );
+ return users.flatMap((user) => {
+ return projectUsers
+ .filter((u) => u.id === user.id)
+ .map((groupUser) => ({
+ ...user,
+ ...groupUser,
+ }));
+ });
+ }
+ return [];
+ }
+
+ async getProjectRoleAccess(projectId: string): Promise {
const roles = await this.roleStore.getProjectRoles();
- const users = await Promise.all(
- roles.map(async (role) => {
- const projectUsers = await this.getProjectUsersForRole(
- role.id,
- projectId,
- );
- return projectUsers.map((u) => ({ ...u, roleId: role.id }));
- }),
- );
+ const users = await this.getProjectUsers(projectId);
+
const groups = await this.groupService.getProjectGroups(projectId);
- return [roles, users.flat(), groups];
+
+ return {
+ roles,
+ groups,
+ users,
+ };
}
async getProjectRoleUsage(roleId: number): Promise {
diff --git a/src/lib/services/group-service.ts b/src/lib/services/group-service.ts
index b3e10f37a8..d079819e8d 100644
--- a/src/lib/services/group-service.ts
+++ b/src/lib/services/group-service.ts
@@ -147,29 +147,27 @@ export class GroupService {
}
async getProjectGroups(
- projectId?: string,
+ projectId: string,
): Promise {
- const groupRoles = await this.groupStore.getProjectGroupRoles(
- projectId,
- );
- if (groupRoles.length > 0) {
+ const projectGroups = await this.groupStore.getProjectGroups(projectId);
+
+ if (projectGroups.length > 0) {
const groups = await this.groupStore.getAllWithId(
- groupRoles.map((a) => a.groupId),
+ projectGroups.map((g) => g.id!),
);
const groupUsers = await this.groupStore.getAllUsersByGroups(
- groups.map((g) => g.id),
+ groups.map((g) => g.id!),
);
-
const users = await this.accountStore.getAllWithId(
groupUsers.map((u) => u.userId),
);
- return groups.map((group) => {
- const groupRole = groupRoles.find((g) => g.groupId == group.id);
- return {
- ...this.mapGroupWithUsers(group, groupUsers, users),
- roleId: groupRole.roleId,
- addedAt: groupRole.createdAt,
- };
+ return groups.flatMap((group) => {
+ return projectGroups
+ .filter((gr) => gr.id === group.id)
+ .map((groupRole) => ({
+ ...this.mapGroupWithUsers(group, groupUsers, users),
+ ...groupRole,
+ }));
});
}
return [];
diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts
index 26c216118d..c3b0012df0 100644
--- a/src/lib/services/project-service.ts
+++ b/src/lib/services/project-service.ts
@@ -1,7 +1,7 @@
import { subDays } from 'date-fns';
import { ValidationError } from 'joi';
import User, { IUser } from '../types/user';
-import { AccessService } from './access-service';
+import { AccessService, AccessWithRoles } from './access-service';
import NameExistsError from '../error/name-exists-error';
import InvalidOperationError from '../error/invalid-operation-error';
import { nameType } from '../routes/util';
@@ -21,7 +21,6 @@ import {
IProjectWithCount,
IUnleashConfig,
IUnleashStores,
- IUserWithRole,
MOVE_FEATURE_TOGGLE,
PROJECT_CREATED,
PROJECT_DELETED,
@@ -35,7 +34,10 @@ import {
RoleName,
IFlagResolver,
ProjectAccessAddedEvent,
+ ProjectAccessUserRolesUpdated,
+ ProjectAccessGroupRolesUpdated,
IProjectRoleUsage,
+ ProjectAccessUserRolesDeleted,
} from '../types';
import { IProjectQuery, IProjectStore } from '../types/stores/project-store';
import {
@@ -48,7 +50,7 @@ import { IFeatureTagStore } from 'lib/types/stores/feature-tag-store';
import ProjectWithoutOwnerError from '../error/project-without-owner-error';
import { arraysHaveSameItems } from '../util';
import { GroupService } from './group-service';
-import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
+import { IGroupRole } from 'lib/types/group';
import { FavoritesService } from './favorites-service';
import { calculateAverageTimeToProd } from '../features/feature-toggle/time-to-production/time-to-production';
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
@@ -57,12 +59,6 @@ import { PermissionError } from '../error';
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
-export interface AccessWithRoles {
- users: IUserWithRole[];
- roles: IRoleDescriptor[];
- groups: IGroupModelWithProjectRole[];
-}
-
type Days = number;
type Count = number;
@@ -335,14 +331,7 @@ export default class ProjectService {
// RBAC methods
async getAccessToProject(projectId: string): Promise {
- const [roles, users, groups] =
- await this.accessService.getProjectRoleAccess(projectId);
-
- return {
- roles,
- users,
- groups,
- };
+ return this.accessService.getProjectRoleAccess(projectId);
}
// Deprecated: See addAccess instead.
@@ -352,7 +341,7 @@ export default class ProjectService {
userId: number,
createdBy: string,
): Promise {
- const [roles, users] = await this.accessService.getProjectRoleAccess(
+ const { roles, users } = await this.accessService.getProjectRoleAccess(
projectId,
);
const user = await this.accountStore.get(userId);
@@ -413,6 +402,54 @@ export default class ProjectService {
);
}
+ async removeUserAccess(
+ projectId: string,
+ userId: number,
+ createdBy: string,
+ ): Promise {
+ const existingRoles = await this.accessService.getProjectRolesForUser(
+ projectId,
+ userId,
+ );
+
+ await this.accessService.removeUserAccess(projectId, userId);
+
+ await this.eventStore.store(
+ new ProjectAccessUserRolesDeleted({
+ project: projectId,
+ createdBy,
+ preData: {
+ roles: existingRoles,
+ userId,
+ },
+ }),
+ );
+ }
+
+ async removeGroupAccess(
+ projectId: string,
+ groupId: number,
+ createdBy: string,
+ ): Promise {
+ const existingRoles = await this.accessService.getProjectRolesForGroup(
+ projectId,
+ groupId,
+ );
+
+ await this.accessService.removeGroupAccess(projectId, groupId);
+
+ await this.eventStore.store(
+ new ProjectAccessUserRolesDeleted({
+ project: projectId,
+ createdBy,
+ preData: {
+ roles: existingRoles,
+ groupId,
+ },
+ }),
+ );
+ }
+
async addGroup(
projectId: string,
roleId: number,
@@ -484,13 +521,13 @@ export default class ProjectService {
);
}
- async addAccess(
+ async addRoleAccess(
projectId: string,
roleId: number,
usersAndGroups: IProjectAccessModel,
createdBy: string,
): Promise {
- await this.accessService.addAccessToProject(
+ await this.accessService.addRoleAccessToProject(
usersAndGroups.users,
usersAndGroups.groups,
projectId,
@@ -511,6 +548,97 @@ export default class ProjectService {
);
}
+ async addAccess(
+ projectId: string,
+ roles: number[],
+ groups: number[],
+ users: number[],
+ createdBy: string,
+ ): Promise {
+ await this.accessService.addAccessToProject(
+ roles,
+ groups,
+ users,
+ projectId,
+ createdBy,
+ );
+
+ await this.eventStore.store(
+ new ProjectAccessAddedEvent({
+ project: projectId,
+ createdBy,
+ data: {
+ roles,
+ groups,
+ users,
+ },
+ }),
+ );
+ }
+
+ async setRolesForUser(
+ projectId: string,
+ userId: number,
+ roles: number[],
+ createdByUserName: string,
+ ): Promise {
+ const existingRoles = await this.accessService.getProjectRolesForUser(
+ projectId,
+ userId,
+ );
+ await this.accessService.setProjectRolesForUser(
+ projectId,
+ userId,
+ roles,
+ );
+ await this.eventStore.store(
+ new ProjectAccessUserRolesUpdated({
+ project: projectId,
+ createdBy: createdByUserName,
+ data: {
+ roles,
+ userId,
+ },
+ preData: {
+ roles: existingRoles,
+ userId,
+ },
+ }),
+ );
+ }
+
+ async setRolesForGroup(
+ projectId: string,
+ groupId: number,
+ roles: number[],
+ createdBy: string,
+ ): Promise {
+ const existingRoles = await this.accessService.getProjectRolesForGroup(
+ projectId,
+ groupId,
+ );
+ await this.accessService.setProjectRolesForGroup(
+ projectId,
+ groupId,
+ roles,
+ createdBy,
+ );
+ await this.eventStore.store(
+ new ProjectAccessGroupRolesUpdated({
+ project: projectId,
+ createdBy,
+ data: {
+ roles,
+ groupId,
+ },
+ preData: {
+ roles: existingRoles,
+ groupId,
+ },
+ }),
+ );
+ }
+
async findProjectGroupRole(
projectId: string,
roleId: number,
@@ -670,7 +798,7 @@ export default class ProjectService {
async getProjectUsers(
projectId: string,
): Promise>> {
- const [, users, groups] = await this.accessService.getProjectRoleAccess(
+ const { groups, users } = await this.accessService.getProjectRoleAccess(
projectId,
);
const actualUsers = users.map((user) => ({
diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts
index 5518ad5a7f..14f4cb804a 100644
--- a/src/lib/types/events.ts
+++ b/src/lib/types/events.ts
@@ -43,6 +43,19 @@ export const CONTEXT_FIELD_CREATED = 'context-field-created' as const;
export const CONTEXT_FIELD_UPDATED = 'context-field-updated' as const;
export const CONTEXT_FIELD_DELETED = 'context-field-deleted' as const;
export const PROJECT_ACCESS_ADDED = 'project-access-added' as const;
+
+export const PROJECT_ACCESS_USER_ROLES_UPDATED =
+ 'project-access-user-roles-updated';
+
+export const PROJECT_ACCESS_GROUP_ROLES_UPDATED =
+ 'project-access-group-roles-updated';
+
+export const PROJECT_ACCESS_USER_ROLES_DELETED =
+ 'project-access-user-roles-deleted';
+
+export const PROJECT_ACCESS_GROUP_ROLES_DELETED =
+ 'project-access-group-roles-deleted';
+
export const PROJECT_CREATED = 'project-created' as const;
export const PROJECT_UPDATED = 'project-updated' as const;
export const PROJECT_DELETED = 'project-deleted' as const;
@@ -162,6 +175,10 @@ export const IEventTypes = [
CONTEXT_FIELD_UPDATED,
CONTEXT_FIELD_DELETED,
PROJECT_ACCESS_ADDED,
+ PROJECT_ACCESS_USER_ROLES_UPDATED,
+ PROJECT_ACCESS_GROUP_ROLES_UPDATED,
+ PROJECT_ACCESS_USER_ROLES_DELETED,
+ PROJECT_ACCESS_GROUP_ROLES_DELETED,
PROJECT_CREATED,
PROJECT_UPDATED,
PROJECT_DELETED,
@@ -817,6 +834,100 @@ export class ProjectAccessAddedEvent extends BaseEvent {
}
}
+export class ProjectAccessUserRolesUpdated extends BaseEvent {
+ readonly project: string;
+
+ readonly data: any;
+
+ readonly preData: any;
+
+ /**
+ * @param createdBy accepts a string for backward compatibility. Prefer using IUser for standardization
+ */
+ constructor(p: {
+ project: string;
+ createdBy: string | IUser;
+ data: any;
+ preData: any;
+ }) {
+ super(PROJECT_ACCESS_USER_ROLES_UPDATED, p.createdBy);
+ const { project, data, preData } = p;
+ this.project = project;
+ this.data = data;
+ this.preData = preData;
+ }
+}
+
+export class ProjectAccessGroupRolesUpdated extends BaseEvent {
+ readonly project: string;
+
+ readonly data: any;
+
+ readonly preData: any;
+
+ /**
+ * @param createdBy accepts a string for backward compatibility. Prefer using IUser for standardization
+ */
+ constructor(p: {
+ project: string;
+ createdBy: string | IUser;
+ data: any;
+ preData: any;
+ }) {
+ super(PROJECT_ACCESS_GROUP_ROLES_UPDATED, p.createdBy);
+ const { project, data, preData } = p;
+ this.project = project;
+ this.data = data;
+ this.preData = preData;
+ }
+}
+
+export class ProjectAccessUserRolesDeleted extends BaseEvent {
+ readonly project: string;
+
+ readonly data: null;
+
+ readonly preData: any;
+
+ /**
+ * @param createdBy accepts a string for backward compatibility. Prefer using IUser for standardization
+ */
+ constructor(p: {
+ project: string;
+ createdBy: string | IUser;
+ preData: any;
+ }) {
+ super(PROJECT_ACCESS_USER_ROLES_DELETED, p.createdBy);
+ const { project, preData } = p;
+ this.project = project;
+ this.data = null;
+ this.preData = preData;
+ }
+}
+
+export class ProjectAccessGroupRolesDeleted extends BaseEvent {
+ readonly project: string;
+
+ readonly data: null;
+
+ readonly preData: any;
+
+ /**
+ * @param createdBy accepts a string for backward compatibility. Prefer using IUser for standardization
+ */
+ constructor(p: {
+ project: string;
+ createdBy: string | IUser;
+ preData: any;
+ }) {
+ super(PROJECT_ACCESS_GROUP_ROLES_DELETED, p.createdBy);
+ const { project, preData } = p;
+ this.project = project;
+ this.data = null;
+ this.preData = preData;
+ }
+}
+
export class SettingCreatedEvent extends BaseEvent {
readonly data: any;
diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts
index 12b8641be6..4f6bce1ec0 100644
--- a/src/lib/types/experimental.ts
+++ b/src/lib/types/experimental.ts
@@ -28,7 +28,8 @@ export type IFlagKey =
| 'changeRequestReject'
| 'customRootRolesKillSwitch'
| 'newApplicationList'
- | 'integrationsRework';
+ | 'integrationsRework'
+ | 'multipleRoles';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@@ -128,6 +129,10 @@ const flags: IFlags = {
process.env.UNLEASH_INTEGRATIONS,
false,
),
+ multipleRoles: parseEnvVarBoolean(
+ process.env.UNLEASH_EXPERIMENTAL_MULTIPLE_ROLES,
+ false,
+ ),
};
export const defaultExperimentalOptions: IExperimentalOptions = {
diff --git a/src/lib/types/stores/access-store.ts b/src/lib/types/stores/access-store.ts
index 44c93962d7..229624d796 100644
--- a/src/lib/types/stores/access-store.ts
+++ b/src/lib/types/stores/access-store.ts
@@ -1,4 +1,5 @@
-import { IPermission } from '../model';
+import { IGroupModelWithProjectRole } from '../group';
+import { IPermission, IUserWithRole } from '../model';
import { Store } from './store';
export interface IUserPermission {
@@ -52,6 +53,17 @@ export interface IUserRole {
addedAt?: Date;
}
+interface IEntityWithProjectRoles {
+ roles?: number[];
+}
+
+export interface IUserWithProjectRoles
+ extends IUserWithRole,
+ IEntityWithProjectRoles {}
+export interface IGroupWithProjectRoles
+ extends IGroupModelWithProjectRole,
+ IEntityWithProjectRoles {}
+
export interface IAccessStore extends Store {
getAvailablePermissions(): Promise;
@@ -74,6 +86,8 @@ export interface IAccessStore extends Store {
projectId?: string,
): Promise;
+ getProjectUsers(projectId?: string): Promise;
+
getUserIdsForRole(roleId: number, projectId?: string): Promise;
getGroupIdsForRole(roleId: number, projectId?: string): Promise;
@@ -95,7 +109,7 @@ export interface IAccessStore extends Store {
projectId?: string,
): Promise;
- addAccessToProject(
+ addRoleAccessToProject(
users: IAccessInfo[],
groups: IAccessInfo[],
projectId: string,
@@ -103,6 +117,14 @@ export interface IAccessStore extends Store {
createdBy: string,
): Promise;
+ addAccessToProject(
+ roles: number[],
+ groups: number[],
+ users: number[],
+ projectId: string,
+ createdBy: string,
+ ): Promise;
+
removeUserFromRole(
userId: number,
roleId: number,
@@ -155,4 +177,26 @@ export interface IAccessStore extends Store {
sourceEnvironment: string,
destinationEnvironment: string,
): Promise;
+
+ setProjectRolesForUser(
+ projectId: string,
+ userId: number,
+ roles: number[],
+ ): Promise;
+ getProjectRolesForUser(
+ projectId: string,
+ userId: number,
+ ): Promise;
+ setProjectRolesForGroup(
+ projectId: string,
+ groupId: number,
+ roles: number[],
+ createdBy: string,
+ ): Promise;
+ getProjectRolesForGroup(
+ projectId: string,
+ groupId: number,
+ ): Promise;
+ removeUserAccess(projectId: string, userId: number): Promise;
+ removeGroupAccess(projectId: string, groupId: number): Promise;
}
diff --git a/src/lib/types/stores/group-store.ts b/src/lib/types/stores/group-store.ts
index e30928392a..e4cbdfa2f9 100644
--- a/src/lib/types/stores/group-store.ts
+++ b/src/lib/types/stores/group-store.ts
@@ -7,6 +7,7 @@ import Group, {
IGroupRole,
IGroupUser,
} from '../group';
+import { IGroupWithProjectRoles } from './access-store';
export interface IStoreGroup {
name: string;
@@ -34,6 +35,8 @@ export interface IGroupStore extends Store {
getProjectGroupRoles(projectId: string): Promise;
+ getProjectGroups(projectId: string): Promise;
+
getAllWithId(ids: number[]): Promise;
updateGroupUsers(
diff --git a/src/test/e2e/services/access-service.e2e.test.ts b/src/test/e2e/services/access-service.e2e.test.ts
index 07c8547fa2..3959f27e66 100644
--- a/src/test/e2e/services/access-service.e2e.test.ts
+++ b/src/test/e2e/services/access-service.e2e.test.ts
@@ -6,7 +6,11 @@ import { AccessService } from '../../../lib/services/access-service';
import * as permissions from '../../../lib/types/permissions';
import { RoleName } from '../../../lib/types/model';
-import { IUnleashStores } from '../../../lib/types';
+import {
+ ICreateGroupUserModel,
+ IPermission,
+ IUnleashStores,
+} from '../../../lib/types';
import FeatureToggleService from '../../../lib/services/feature-toggle-service';
import ProjectService from '../../../lib/services/project-service';
import { createTestConfig } from '../../config/test-config';
@@ -20,35 +24,56 @@ import { ChangeRequestAccessReadModel } from '../../../lib/features/change-reque
let db: ITestDb;
let stores: IUnleashStores;
let accessService: AccessService;
-let groupService;
+let groupService: GroupService;
let featureToggleService;
let favoritesService;
let projectService;
let editorUser;
-let superUser;
let editorRole;
let adminRole;
let readRole;
-const createUserEditorAccess = async (name, email) => {
+let userIndex = 0;
+const createUser = async (role?: number) => {
+ const name = `User ${userIndex}`;
+ const email = `user-${userIndex}@getunleash.io`;
+ userIndex++;
+
const { userStore } = stores;
const user = await userStore.insert({ name, email });
- await accessService.addUserToRole(user.id, editorRole.id, 'default');
+ if (role)
+ await accessService.addUserToRole(
+ user.id,
+ role,
+ role === readRole.id ? ALL_PROJECTS : DEFAULT_PROJECT,
+ );
return user;
};
-const createUserViewerAccess = async (name, email) => {
- const { userStore } = stores;
- const user = await userStore.insert({ name, email });
- await accessService.addUserToRole(user.id, readRole.id, ALL_PROJECTS);
- return user;
+let groupIndex = 0;
+const createGroup = async ({
+ users,
+ role,
+}: {
+ users: ICreateGroupUserModel[];
+ role?: number;
+}) => {
+ const { groupStore } = stores;
+ const group = await stores.groupStore.create({
+ name: `Group ${groupIndex}`,
+ rootRole: role,
+ });
+ if (users) await groupStore.addUsersToGroup(group.id!, users, 'Admin');
+ return group;
};
-const createUserAdminAccess = async (name, email) => {
- const { userStore } = stores;
- const user = await userStore.insert({ name, email });
- await accessService.addUserToRole(user.id, adminRole.id, 'default');
- return user;
+let roleIndex = 0;
+const createRole = async (rolePermissions: IPermission[]) => {
+ return accessService.createRole({
+ name: `Role ${roleIndex}`,
+ description: `Role ${roleIndex++} description`,
+ permissions: rolePermissions,
+ });
};
const hasCommonProjectAccess = async (user, projectName, condition) => {
@@ -199,16 +224,6 @@ const hasFullProjectAccess = async (user, projectName: string, condition) => {
await hasCommonProjectAccess(user, projectName, condition);
};
-const createSuperUser = async () => {
- const { userStore } = stores;
- const user = await userStore.insert({
- name: 'Alice Admin',
- email: 'admin@getunleash.io',
- });
- await accessService.addUserToRole(user.id, adminRole.id, ALL_PROJECTS);
- return user;
-};
-
beforeAll(async () => {
db = await dbInit('access_service_serial', getLogger);
stores = db.stores;
@@ -245,8 +260,7 @@ beforeAll(async () => {
favoritesService,
);
- editorUser = await createUserEditorAccess('Bob Test', 'bob@getunleash.io');
- superUser = await createSuperUser();
+ editorUser = await createUser(editorRole.id);
});
afterAll(async () => {
@@ -370,7 +384,7 @@ test('admin should be admin', async () => {
DELETE_FEATURE,
ADMIN,
} = permissions;
- const user = superUser;
+ const user = await createUser(adminRole.id);
expect(
await accessService.hasPermission(user, DELETE_PROJECT, 'default'),
).toBe(true);
@@ -407,10 +421,7 @@ test('should grant user access to project', async () => {
const { DELETE_PROJECT, UPDATE_PROJECT } = permissions;
const project = 'another-project';
const user = editorUser;
- const sUser = await createUserViewerAccess(
- 'Some Random',
- 'random@getunleash.io',
- );
+ const sUser = await createUser(readRole.id);
await accessService.createDefaultProjectRoles(user, project);
const projectRole = await accessService.getRoleByName(RoleName.MEMBER);
@@ -431,10 +442,7 @@ test('should grant user access to project', async () => {
test('should not get access if not specifying project', async () => {
const project = 'another-project-2';
const user = editorUser;
- const sUser = await createUserViewerAccess(
- 'Some Random',
- 'random22@getunleash.io',
- );
+ const sUser = await createUser(readRole.id);
await accessService.createDefaultProjectRoles(user, project);
const projectRole = await accessService.getRoleByName(RoleName.MEMBER);
@@ -708,26 +716,20 @@ test('Should be denied access to delete a role that is in use', async () => {
email: 'custom@getunleash.io',
});
- const customRole = await accessService.createRole({
- name: 'RoleInUse',
- description: '',
- permissions: [
- {
- id: 2,
- name: 'CREATE_FEATURE',
- environment: undefined,
- displayName: 'Create Feature Toggles',
- type: 'project',
- },
- {
- id: 8,
- name: 'DELETE_FEATURE',
- environment: undefined,
- displayName: 'Delete Feature Toggles',
- type: 'project',
- },
- ],
- });
+ const customRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
await projectService.addUser(project.id, customRole.id, projectMember.id);
@@ -742,10 +744,7 @@ test('Should be denied access to delete a role that is in use', async () => {
test('Should be denied move feature toggle to project where the user does not have access', async () => {
const user = editorUser;
- const editorUser2 = await createUserEditorAccess(
- 'seconduser',
- 'bob2@gmail.com',
- );
+ const editorUser2 = await createUser(editorRole.id);
const projectOrigin = {
id: 'projectOrigin',
@@ -888,25 +887,14 @@ test('Should be allowed move feature toggle to project when given access through
name: 'yet-another-project1',
};
- const groupStore = stores.groupStore;
- const viewerUser = await createUserViewerAccess(
- 'Victoria Viewer',
- 'vickyv@getunleash.io',
- );
+ const viewerUser = await createUser(readRole.id);
await projectService.createProject(project, editorUser);
- const groupWithProjectAccess = await groupStore.create({
- name: 'Project Editors',
- description: '',
+ const groupWithProjectAccess = await createGroup({
+ users: [{ user: viewerUser }],
});
- await groupStore.addUsersToGroup(
- groupWithProjectAccess.id!,
- [{ user: viewerUser }],
- 'Admin',
- );
-
const projectRole = await accessService.getRoleByName(RoleName.MEMBER);
await hasCommonProjectAccess(viewerUser, project.id, false);
@@ -927,23 +915,13 @@ test('Should not lose user role access when given permissions from a group', asy
name: 'yet-another-project-lose',
};
const user = editorUser;
- const groupStore = stores.groupStore;
await projectService.createProject(project, user);
- // await accessService.createDefaultProjectRoles(user, project.id);
-
- const groupWithNoAccess = await groupStore.create({
- name: 'ViewersOnly',
- description: '',
+ const groupWithNoAccess = await createGroup({
+ users: [{ user }],
});
- await groupStore.addUsersToGroup(
- groupWithNoAccess.id!,
- [{ user: user }],
- 'Admin',
- );
-
const viewerRole = await accessService.getRoleByName(RoleName.VIEWER);
await accessService.addGroupToRole(
@@ -968,64 +946,36 @@ test('Should allow user to take multiple group roles and have expected permissio
description: 'Blah',
};
- const groupStore = stores.groupStore;
- const viewerUser = await createUserViewerAccess(
- 'Victor Viewer',
- 'victore@getunleash.io',
- );
+ const viewerUser = await createUser(readRole.id);
await projectService.createProject(projectForCreate, editorUser);
await projectService.createProject(projectForDelete, editorUser);
- const groupWithCreateAccess = await groupStore.create({
- name: 'ViewersOnly',
- description: '',
+ const groupWithCreateAccess = await createGroup({
+ users: [{ user: viewerUser }],
});
- const groupWithDeleteAccess = await groupStore.create({
- name: 'ViewersOnly',
- description: '',
+ const groupWithDeleteAccess = await createGroup({
+ users: [{ user: viewerUser }],
});
- await groupStore.addUsersToGroup(
- groupWithCreateAccess.id!,
- [{ user: viewerUser }],
- 'Admin',
- );
+ const createFeatureRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ ]);
- await groupStore.addUsersToGroup(
- groupWithDeleteAccess.id!,
- [{ user: viewerUser }],
- 'Admin',
- );
-
- const createFeatureRole = await accessService.createRole({
- name: 'CreateRole',
- description: '',
- permissions: [
- {
- id: 2,
- name: 'CREATE_FEATURE',
- environment: undefined,
- displayName: 'Create Feature Toggles',
- type: 'project',
- },
- ],
- });
-
- const deleteFeatureRole = await accessService.createRole({
- name: 'DeleteRole',
- description: '',
- permissions: [
- {
- id: 8,
- name: 'DELETE_FEATURE',
- environment: undefined,
- displayName: 'Delete Feature Toggles',
- type: 'project',
- },
- ],
- });
+ const deleteFeatureRole = await createRole([
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
await accessService.addGroupToRole(
groupWithCreateAccess.id!,
@@ -1073,55 +1023,28 @@ test('Should allow user to take multiple group roles and have expected permissio
});
test('Should allow user to take on root role through a group that has a root role defined', async () => {
- const groupStore = stores.groupStore;
+ const viewerUser = await createUser(readRole.id);
- const viewerUser = await createUserViewerAccess(
- 'Vincent Viewer',
- 'vincent@getunleash.io',
- );
-
- const groupWithRootAdminRole = await groupStore.create({
- name: 'GroupThatGrantsAdminRights',
- description: '',
- rootRole: adminRole.id,
+ await createGroup({
+ role: adminRole.id,
+ users: [{ user: viewerUser }],
});
- await groupStore.addUsersToGroup(
- groupWithRootAdminRole.id!,
- [{ user: viewerUser }],
- 'Admin',
- );
-
expect(
await accessService.hasPermission(viewerUser, permissions.ADMIN),
).toBe(true);
});
test('Should not elevate permissions for a user that is not present in a root role group', async () => {
- const groupStore = stores.groupStore;
+ const viewerUser = await createUser(readRole.id);
- const viewerUser = await createUserViewerAccess(
- 'Violet Viewer',
- 'violet@getunleash.io',
- );
+ const viewerUserNotInGroup = await createUser(readRole.id);
- const viewerUserNotInGroup = await createUserViewerAccess(
- 'Veronica Viewer',
- 'veronica@getunleash.io',
- );
-
- const groupWithRootAdminRole = await groupStore.create({
- name: 'GroupThatGrantsAdminRights',
- description: '',
- rootRole: adminRole.id,
+ await createGroup({
+ role: adminRole.id,
+ users: [{ user: viewerUser }],
});
- await groupStore.addUsersToGroup(
- groupWithRootAdminRole.id!,
- [{ user: viewerUser }],
- 'Admin',
- );
-
expect(
await accessService.hasPermission(viewerUser, permissions.ADMIN),
).toBe(true);
@@ -1135,25 +1058,13 @@ test('Should not elevate permissions for a user that is not present in a root ro
});
test('Should not reduce permissions for an admin user that enters an editor group', async () => {
- const groupStore = stores.groupStore;
+ const adminUser = await createUser(adminRole.id);
- const adminUser = await createUserAdminAccess(
- 'Austin Admin',
- 'austin@getunleash.io',
- );
-
- const groupWithRootEditorRole = await groupStore.create({
- name: 'GroupThatGrantsEditorRights',
- description: '',
- rootRole: editorRole.id,
+ await createGroup({
+ role: editorRole.id,
+ users: [{ user: adminUser }],
});
- await groupStore.addUsersToGroup(
- groupWithRootEditorRole.id!,
- [{ user: adminUser }],
- 'Admin',
- );
-
expect(
await accessService.hasPermission(adminUser, permissions.ADMIN),
).toBe(true);
@@ -1162,10 +1073,7 @@ test('Should not reduce permissions for an admin user that enters an editor grou
test('Should not change permissions for a user in a group without a root role', async () => {
const groupStore = stores.groupStore;
- const viewerUser = await createUserViewerAccess(
- 'Virgil Viewer',
- 'virgil@getunleash.io',
- );
+ const viewerUser = await createUser(readRole.id);
const groupWithoutRootRole = await groupStore.create({
name: 'GroupWithNoRootRole',
@@ -1193,22 +1101,12 @@ test('Should not change permissions for a user in a group without a root role',
test('Should add permissions to user when a group is given a root role after the user has been added to the group', async () => {
const groupStore = stores.groupStore;
- const viewerUser = await createUserViewerAccess(
- 'Vera Viewer',
- 'vera@getunleash.io',
- );
+ const viewerUser = await createUser(readRole.id);
- const groupWithoutRootRole = await groupStore.create({
- name: 'GroupWithNoRootRole',
- description: '',
+ const groupWithoutRootRole = await createGroup({
+ users: [{ user: viewerUser }],
});
- await groupStore.addUsersToGroup(
- groupWithoutRootRole.id!,
- [{ user: viewerUser }],
- 'Admin',
- );
-
expect(
await accessService.hasPermission(viewerUser, permissions.ADMIN),
).toBe(false);
@@ -1228,24 +1126,728 @@ test('Should add permissions to user when a group is given a root role after the
test('Should give full project access to the default project to user in a group with an editor root role', async () => {
const projectName = 'default';
- const groupStore = stores.groupStore;
+ const viewerUser = await createUser(readRole.id);
- const viewerUser = await createUserViewerAccess(
- 'Vee viewer',
- 'vee@getunleash.io',
- );
-
- const groupWithRootEditorRole = await groupStore.create({
- name: 'GroupThatGrantsEditorRights',
- description: '',
- rootRole: editorRole.id,
+ await createGroup({
+ role: editorRole.id,
+ users: [{ user: viewerUser }],
});
- await groupStore.addUsersToGroup(
- groupWithRootEditorRole.id!,
- [{ user: viewerUser }],
- 'Admin',
- );
-
await hasFullProjectAccess(viewerUser, projectName, true);
});
+
+test('if user has two roles user has union of permissions from the two roles', async () => {
+ const projectName = 'default';
+
+ const emptyUser = await createUser();
+
+ const firstRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
+ const secondRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 13,
+ name: 'UPDATE_PROJECT',
+ displayName: 'Can update projects',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForUser(projectName, emptyUser.id, [
+ firstRole.id,
+ secondRole.id,
+ ]);
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+ const permissionNameSet = new Set(
+ assignedPermissions.map((p) => p.permission),
+ );
+
+ expect(permissionNameSet.size).toBe(3);
+});
+
+test('calling set for user overwrites existing roles', async () => {
+ const projectName = 'default';
+
+ const emptyUser = await createUser();
+
+ const firstRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
+ const secondRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 13,
+ name: 'UPDATE_PROJECT',
+ displayName: 'Can update projects',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForUser(projectName, emptyUser.id, [
+ firstRole.id,
+ secondRole.id,
+ ]);
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+ const permissionNameSet = new Set(
+ assignedPermissions.map((p) => p.permission),
+ );
+
+ expect(permissionNameSet.size).toBe(3);
+
+ await accessService.setProjectRolesForUser(projectName, emptyUser.id, [
+ firstRole.id,
+ ]);
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(2);
+ expect(newAssignedPermissions).toContainEqual({
+ project: projectName,
+ permission: 'CREATE_FEATURE',
+ });
+ expect(newAssignedPermissions).toContainEqual({
+ project: projectName,
+ permission: 'DELETE_FEATURE',
+ });
+});
+
+test('if group has two roles user has union of permissions from the two roles', async () => {
+ const projectName = 'default';
+
+ const emptyUser = await createUser();
+
+ const emptyGroup = await createGroup({
+ users: [{ user: emptyUser }],
+ });
+
+ const firstRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
+ const secondRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 13,
+ name: 'UPDATE_PROJECT',
+ displayName: 'Can update projects',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForGroup(
+ projectName,
+ emptyGroup.id!,
+ [firstRole.id, secondRole.id],
+ 'testusr',
+ );
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+ const permissionNameSet = new Set(
+ assignedPermissions.map((p) => p.permission),
+ );
+
+ expect(permissionNameSet.size).toBe(3);
+});
+
+test('calling set for group overwrites existing roles', async () => {
+ const projectName = 'default';
+
+ const emptyUser = await createUser();
+
+ const emptyGroup = await createGroup({
+ users: [{ user: emptyUser }],
+ });
+
+ const firstRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
+ const secondRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 13,
+ name: 'UPDATE_PROJECT',
+ displayName: 'Can update projects',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForGroup(
+ projectName,
+ emptyGroup.id!,
+ [firstRole.id, secondRole.id],
+ 'testusr',
+ );
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+ const permissionNameSet = new Set(
+ assignedPermissions.map((p) => p.permission),
+ );
+
+ expect(permissionNameSet.size).toBe(3);
+
+ await accessService.setProjectRolesForGroup(
+ projectName,
+ emptyGroup.id!,
+ [firstRole.id],
+ 'testusr',
+ );
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(2);
+ expect(newAssignedPermissions).toContainEqual({
+ project: projectName,
+ permission: 'CREATE_FEATURE',
+ });
+ expect(newAssignedPermissions).toContainEqual({
+ project: projectName,
+ permission: 'DELETE_FEATURE',
+ });
+});
+
+test('group with root role can be assigned a project specific role', async () => {
+ const projectName = 'default';
+
+ const emptyUser = await createUser();
+
+ const emptyGroup = await createGroup({
+ role: readRole.id,
+ users: [{ user: emptyUser }],
+ });
+
+ const firstRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForGroup(
+ projectName,
+ emptyGroup.id!,
+ [firstRole.id],
+ 'testusr',
+ );
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(assignedPermissions).toContainEqual({
+ project: projectName,
+ permission: 'CREATE_FEATURE',
+ });
+});
+
+test('calling add access with invalid project role ids should not assign those roles', async () => {
+ const projectName = 'default';
+ const emptyUser = await createUser();
+
+ const adminRootRole = await accessService.getRoleByName(RoleName.ADMIN);
+
+ accessService.addAccessToProject(
+ [adminRootRole.id, 9999],
+ [],
+ [emptyUser.id],
+ projectName,
+ 'some-admin-user',
+ );
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(0);
+});
+
+test('calling set roles for user with invalid project role ids should not assign those roles', async () => {
+ const projectName = 'default';
+ const emptyUser = await createUser();
+
+ const adminRootRole = await accessService.getRoleByName(RoleName.ADMIN);
+
+ accessService.setProjectRolesForUser(projectName, emptyUser.id, [
+ adminRootRole.id,
+ 9999,
+ ]);
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(0);
+});
+
+test('calling set roles for user with empty role array removes all roles', async () => {
+ const projectName = 'default';
+ const emptyUser = await createUser();
+
+ const role = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForUser(projectName, emptyUser.id, [
+ role.id,
+ ]);
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(assignedPermissions.length).toBe(1);
+
+ await accessService.setProjectRolesForUser(projectName, emptyUser.id, []);
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(0);
+});
+
+test('calling set roles for user with empty role array should not remove root roles', async () => {
+ const projectName = 'default';
+ const adminUser = await createUser(adminRole.id);
+
+ const firstRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForUser(projectName, adminUser.id, [
+ firstRole.id,
+ ]);
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ adminUser,
+ );
+
+ expect(assignedPermissions.length).toBe(3);
+
+ await accessService.setProjectRolesForUser(projectName, adminUser.id, []);
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ adminUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(1);
+ expect(newAssignedPermissions[0].permission).toBe(permissions.ADMIN);
+});
+
+test('remove user access should remove all project roles', async () => {
+ const projectName = 'default';
+ const emptyUser = await createUser();
+
+ const firstRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
+
+ const secondRole = await createRole([
+ {
+ id: 13,
+ name: 'UPDATE_PROJECT',
+ displayName: 'Can update projects',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForUser(projectName, emptyUser.id, [
+ firstRole.id,
+ secondRole.id,
+ ]);
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(assignedPermissions.length).toBe(3);
+
+ await accessService.removeUserAccess(projectName, emptyUser.id);
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(0);
+});
+
+test('remove user access should remove all project roles, while leaving root roles untouched', async () => {
+ const projectName = 'default';
+ const adminUser = await createUser(adminRole.id);
+
+ const firstRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
+
+ const secondRole = await createRole([
+ {
+ id: 13,
+ name: 'UPDATE_PROJECT',
+ displayName: 'Can update projects',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForUser(projectName, adminUser.id, [
+ firstRole.id,
+ secondRole.id,
+ ]);
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ adminUser,
+ );
+
+ expect(assignedPermissions.length).toBe(4);
+
+ await accessService.removeUserAccess(projectName, adminUser.id);
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ adminUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(1);
+ expect(newAssignedPermissions[0].permission).toBe(permissions.ADMIN);
+});
+
+test('calling set roles for group with invalid project role ids should not assign those roles', async () => {
+ const projectName = 'default';
+
+ const emptyUser = await createUser();
+ const emptyGroup = await createGroup({
+ users: [{ user: emptyUser }],
+ });
+
+ const adminRootRole = await accessService.getRoleByName(RoleName.ADMIN);
+
+ accessService.setProjectRolesForGroup(
+ projectName,
+ emptyGroup.id!,
+ [adminRootRole.id, 9999],
+ 'admin',
+ );
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(0);
+});
+
+test('calling set roles for group with empty role array removes all roles', async () => {
+ const projectName = 'default';
+
+ const emptyUser = await createUser();
+ const emptyGroup = await createGroup({
+ users: [{ user: emptyUser }],
+ });
+
+ const role = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForGroup(
+ projectName,
+ emptyGroup.id!,
+ [role.id],
+ 'admin',
+ );
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(assignedPermissions.length).toBe(1);
+
+ await accessService.setProjectRolesForGroup(
+ projectName,
+ emptyGroup.id!,
+ [],
+ 'admin',
+ );
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(0);
+});
+
+test('calling set roles for group with empty role array should not remove root roles', async () => {
+ const projectName = 'default';
+
+ const adminUser = await createUser(adminRole.id);
+ const group = await createGroup({
+ users: [{ user: adminUser }],
+ });
+
+ const role = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForGroup(
+ projectName,
+ group.id!,
+ [role.id],
+ 'admin',
+ );
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ adminUser,
+ );
+
+ expect(assignedPermissions.length).toBe(3);
+
+ await accessService.setProjectRolesForGroup(
+ projectName,
+ group.id!,
+ [],
+ 'admin',
+ );
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ adminUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(1);
+ expect(newAssignedPermissions[0].permission).toBe(permissions.ADMIN);
+});
+
+test('remove group access should remove all project roles', async () => {
+ const projectName = 'default';
+ const emptyUser = await createUser();
+ const group = await createGroup({
+ users: [{ user: emptyUser }],
+ });
+
+ const firstRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
+
+ const secondRole = await createRole([
+ {
+ id: 13,
+ name: 'UPDATE_PROJECT',
+ displayName: 'Can update projects',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForGroup(
+ projectName,
+ group.id!,
+ [firstRole.id, secondRole.id],
+ 'admin',
+ );
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(assignedPermissions.length).toBe(3);
+
+ await accessService.removeGroupAccess(projectName, group.id!);
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ emptyUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(0);
+});
+
+test('remove group access should remove all project roles, while leaving root roles untouched', async () => {
+ const projectName = 'default';
+ const adminUser = await createUser(adminRole.id);
+ const group = await createGroup({
+ users: [{ user: adminUser }],
+ });
+
+ const firstRole = await createRole([
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create Feature Toggles',
+ type: 'project',
+ },
+ {
+ id: 8,
+ name: 'DELETE_FEATURE',
+ displayName: 'Delete Feature Toggles',
+ type: 'project',
+ },
+ ]);
+
+ const secondRole = await createRole([
+ {
+ id: 13,
+ name: 'UPDATE_PROJECT',
+ displayName: 'Can update projects',
+ type: 'project',
+ },
+ ]);
+
+ await accessService.setProjectRolesForGroup(
+ projectName,
+ group.id!,
+ [firstRole.id, secondRole.id],
+ 'admin',
+ );
+
+ const assignedPermissions = await accessService.getPermissionsForUser(
+ adminUser,
+ );
+
+ expect(assignedPermissions.length).toBe(4);
+
+ await accessService.removeGroupAccess(projectName, group.id!);
+
+ const newAssignedPermissions = await accessService.getPermissionsForUser(
+ adminUser,
+ );
+
+ expect(newAssignedPermissions.length).toBe(1);
+ expect(newAssignedPermissions[0].permission).toBe(permissions.ADMIN);
+});
diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts
index bd6f68321f..ad5ef64a40 100644
--- a/src/test/e2e/services/project-service.e2e.test.ts
+++ b/src/test/e2e/services/project-service.e2e.test.ts
@@ -1109,11 +1109,9 @@ test('Should allow bulk update of group permissions', async () => {
await projectService.addAccess(
project.id,
- createFeatureRole.id,
- {
- users: [{ id: user1.id }],
- groups: [{ id: group1.id }],
- },
+ [createFeatureRole.id],
+ [group1.id],
+ [user1.id],
'some-admin-user',
);
});
@@ -1142,11 +1140,9 @@ test('Should bulk update of only users', async () => {
await projectService.addAccess(
project,
- createFeatureRole.id,
- {
- users: [{ id: user1.id }],
- groups: [],
- },
+ [createFeatureRole.id],
+ [],
+ [user1.id],
'some-admin-user',
);
});
@@ -1183,15 +1179,88 @@ test('Should allow bulk update of only groups', async () => {
await projectService.addAccess(
project.id,
- createFeatureRole.id,
- {
- users: [],
- groups: [{ id: group1.id }],
- },
+ [createFeatureRole.id],
+ [group1.id],
+ [],
'some-admin-user',
);
});
+test('Should allow permutations of roles, groups and users when adding a new access', async () => {
+ const project = {
+ id: 'project-access-permutations',
+ name: 'project-access-permutations',
+ mode: 'open' as const,
+ defaultStickiness: 'clientId',
+ };
+
+ await projectService.createProject(project, user.id);
+
+ const group1 = await stores.groupStore.create({
+ name: 'permutation-group-1',
+ description: '',
+ });
+
+ const group2 = await stores.groupStore.create({
+ name: 'permutation-group-2',
+ description: '',
+ });
+
+ const user1 = await stores.userStore.insert({
+ name: 'permutation-user-1',
+ email: 'pu1@getunleash.io',
+ });
+
+ const user2 = await stores.userStore.insert({
+ name: 'permutation-user-2',
+ email: 'pu2@getunleash.io',
+ });
+
+ const role1 = await accessService.createRole({
+ name: 'permutation-role-1',
+ description: '',
+ permissions: [
+ {
+ id: 2,
+ name: 'CREATE_FEATURE',
+ displayName: 'Create feature toggles',
+ type: 'project',
+ },
+ ],
+ });
+
+ const role2 = await accessService.createRole({
+ name: 'permutation-role-2',
+ description: '',
+ permissions: [
+ {
+ id: 7,
+ name: 'UPDATE_FEATURE',
+ displayName: 'Update feature toggles',
+ type: 'project',
+ },
+ ],
+ });
+
+ await projectService.addAccess(
+ project.id,
+ [role1.id, role2.id],
+ [group1.id, group2.id],
+ [user1.id, user2.id],
+ 'some-admin-user',
+ );
+
+ const { users, groups } = await projectService.getAccessToProject(
+ project.id,
+ );
+
+ expect(users).toHaveLength(2);
+ expect(groups).toHaveLength(2);
+
+ expect(users[0].roles).toStrictEqual([role1.id, role2.id]);
+ expect(groups[0].roles).toStrictEqual([role1.id, role2.id]);
+});
+
test('should only count active feature toggles for project', async () => {
const project = {
id: 'only-active',
diff --git a/src/test/fixtures/access-service-mock.ts b/src/test/fixtures/access-service-mock.ts
index f692c4eec1..c658a0d027 100644
--- a/src/test/fixtures/access-service-mock.ts
+++ b/src/test/fixtures/access-service-mock.ts
@@ -1,15 +1,13 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-unused-vars */
-import { AccessService } from '../../lib/services/access-service';
+import {
+ AccessService,
+ AccessWithRoles,
+} from '../../lib/services/access-service';
import User from '../../lib/types/user';
import noLoggerProvider from './no-logger';
import { IRole } from '../../lib/types/stores/access-store';
-import {
- IAvailablePermissions,
- IRoleData,
- IUserWithRole,
-} from '../../lib/types/model';
-import { IGroupModelWithProjectRole } from '../../lib/types/group';
+import { IAvailablePermissions } from '../../lib/types/model';
class AccessServiceMock extends AccessService {
constructor() {
@@ -78,9 +76,7 @@ class AccessServiceMock extends AccessService {
throw new Error('Method not implemented.');
}
- getProjectRoleAccess(
- projectId: string,
- ): Promise<[IRole[], IUserWithRole[], IGroupModelWithProjectRole[]]> {
+ getProjectRoleAccess(projectId: string): Promise {
throw new Error('Method not implemented.');
}
diff --git a/src/test/fixtures/fake-access-store.ts b/src/test/fixtures/fake-access-store.ts
index cce7c3a333..7a5bfb5dd3 100644
--- a/src/test/fixtures/fake-access-store.ts
+++ b/src/test/fixtures/fake-access-store.ts
@@ -7,6 +7,7 @@ import {
IRoleWithProject,
IUserPermission,
IUserRole,
+ IUserWithProjectRoles,
} from '../../lib/types/stores/access-store';
import { IPermission } from 'lib/types/model';
import { IRoleStore } from 'lib/types';
@@ -27,7 +28,7 @@ class AccessStoreMock implements IAccessStore {
throw new Error('Method not implemented.');
}
- addAccessToProject(
+ addRoleAccessToProject(
users: IAccessInfo[],
groups: IAccessInfo[],
projectId: string,
@@ -37,6 +38,16 @@ class AccessStoreMock implements IAccessStore {
throw new Error('Method not implemented.');
}
+ addAccessToProject(
+ roles: number[],
+ groups: number[],
+ users: number[],
+ projectId: string,
+ createdBy: string,
+ ): Promise {
+ throw new Error('Method not implemented.');
+ }
+
updateGroupProjectRole(
userId: number,
roleId: number,
@@ -97,6 +108,10 @@ class AccessStoreMock implements IAccessStore {
throw new Error('Method not implemented.');
}
+ getProjectUsers(projectId?: string): Promise {
+ throw new Error('Method not implemented.');
+ }
+
getProjectRoles(): Promise {
throw new Error('Method not implemented.');
}
@@ -198,7 +213,7 @@ class AccessStoreMock implements IAccessStore {
}
get(key: number): Promise {
- return Promise.resolve(undefined);
+ throw new Error('Not implemented yet');
}
getAll(): Promise {
@@ -234,6 +249,45 @@ class AccessStoreMock implements IAccessStore {
clearPublicSignupUserTokens(userId: number): Promise {
return Promise.resolve(undefined);
}
+
+ getProjectRolesForGroup(
+ projectId: string,
+ groupId: number,
+ ): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ getProjectRolesForUser(
+ projectId: string,
+ userId: number,
+ ): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ setProjectRolesForGroup(
+ projectId: string,
+ groupId: number,
+ roles: number[],
+ createdBy: string,
+ ): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ setProjectRolesForUser(
+ projectId: string,
+ userId: number,
+ roles: number[],
+ ): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ removeUserAccess(projectId: string, userId: number): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ removeGroupAccess(projectId: string, groupId: number): Promise {
+ throw new Error('Method not implemented.');
+ }
}
module.exports = AccessStoreMock;
diff --git a/src/test/fixtures/fake-group-store.ts b/src/test/fixtures/fake-group-store.ts
index 5befbf235f..c44d472e49 100644
--- a/src/test/fixtures/fake-group-store.ts
+++ b/src/test/fixtures/fake-group-store.ts
@@ -7,6 +7,7 @@ import Group, {
IGroupRole,
IGroupUser,
} from '../../lib/types/group';
+import { IGroupWithProjectRoles } from '../../lib/types/stores/access-store';
/* eslint-disable @typescript-eslint/no-unused-vars */
export default class FakeGroupStore implements IGroupStore {
count(): Promise {
@@ -83,6 +84,10 @@ export default class FakeGroupStore implements IGroupStore {
throw new Error('Method not implemented.');
}
+ getProjectGroups(projectId: string): Promise {
+ throw new Error('Method not implemented.');
+ }
+
getGroupProjects(groupIds: number[]): Promise {
throw new Error('Method not implemented.');
}