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.'); }