1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-15 01:16:22 +02:00

feat: multiple project roles (#4512)

https://linear.app/unleash/issue/2-1128/change-the-api-to-support-adding-multiple-roles-to-a-usergroup-on-a

https://linear.app/unleash/issue/2-1125/be-able-to-fetch-all-roles-for-a-user-in-a-project

https://linear.app/unleash/issue/2-1127/adapt-the-ui-to-be-able-to-do-a-multi-select-on-role-permissions-for

- Allows assigning project roles to groups with root roles
- Implements new methods that support assigning, editing, removing and
retrieving multiple project roles in project access, along with other
auxiliary methods
- Adds new events for updating and removing assigned roles
- Adapts `useProjectApi` to new methods that use new endpoints that
support multiple roles
- Adds the `multipleRoles` feature flag that controls the possibility of
selecting multiple roles on the UI
- Adapts `ProjectAccessAssign` to support multiple role, using the new
methods
- Adds a new `MultipleRoleSelect` component that allows you to select
multiple roles based on the `RoleSelect` component
- Adapts the `RoleCell` component to support either a single role or
multiple roles
- Updates the `access.spec.ts` Cypress e2e test to reflect our new logic
- Updates `access-service.e2e.test.ts` with tests covering the multiple
roles logic and covering some corner cases
- Updates `project-service.e2e.test.ts` to adapt to the new logic,
adding a test that covers adding access with `[roles], [groups],
[users]`
- Misc refactors and boy scouting


![image](https://github.com/Unleash/unleash/assets/14320932/d1cc7626-9387-4ab8-9860-cd293a0d4f62)

---------

Co-authored-by: David Leek <david@getunleash.io>
Co-authored-by: Mateusz Kwasniewski <kwasniewski.mateusz@gmail.com>
Co-authored-by: Nuno Góis <github@nunogois.com>
This commit is contained in:
Christopher Kolstad 2023-08-25 10:31:37 +02:00 committed by GitHub
parent 1f96c1646c
commit 21b4ada577
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1929 additions and 432 deletions

View File

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

View File

@ -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) => (
<RoleCell value={value} roleId={serviceAccount.rootRole} />
),
Cell: ({
row: { original: serviceAccount },
value,
}: {
row: { original: IServiceAccount };
value: string;
}) => <RoleCell value={value} role={serviceAccount.rootRole} />,
maxWidth: 120,
},
{

View File

@ -125,9 +125,13 @@ const UsersList = () => {
accessor: (row: any) =>
roles.find((role: IRole) => role.id === row.rootRole)
?.name || '',
Cell: ({ row: { original: user }, value }: any) => (
<RoleCell value={value} roleId={user.rootRole} />
),
Cell: ({
row: { original: user },
value,
}: {
row: { original: IUser };
value: string;
}) => <RoleCell value={value} role={user.rootRole} />,
maxWidth: 120,
},
{

View File

@ -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<AutocompleteProps<IRole, true, false, false>> {
roles: IRole[];
value: IRole[];
setValue: (role: IRole[]) => void;
required?: boolean;
}
export const MultipleRoleSelect = ({
roles,
value,
setValue,
required,
...rest
}: IMultipleRoleSelectProps) => {
const renderRoleOption = (
props: React.HTMLAttributes<HTMLLIElement>,
option: IRole,
state: AutocompleteRenderOptionState
) => (
<li {...props}>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
style={{ marginRight: 8 }}
checked={state.selected}
/>
<StyledRoleOption>
<span>{option.name}</span>
<span>{option.description}</span>
</StyledRoleOption>
</li>
);
return (
<>
<Autocomplete
multiple
disableCloseOnSelect
openOnFocus
size="small"
value={value}
onChange={(_, roles) => setValue(roles)}
options={roles}
renderOption={renderRoleOption}
getOptionLabel={option => option.name}
renderInput={params => (
<TextField {...params} label="Role" required={required} />
)}
{...rest}
/>
<ConditionallyRender
condition={value.length > 0}
show={() =>
value.map(({ id }) => (
<RoleDescription
key={id}
sx={{ marginTop: 1 }}
roleId={id}
/>
))
}
/>
</>
);
};

View File

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

View File

@ -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<IRoleCellProps> = ({ roleId, value }) => {
type TMultipleRolesProps = {
value: string;
roles: number[];
role?: never;
};
type TRoleCellProps = TSingleRoleProps | TMultipleRolesProps;
export const RoleCell: VFC<TRoleCellProps> = ({ role, roles, value }) => {
const { isEnterprise } = useUiConfig();
if (isEnterprise()) {
const rolesArray = roles ? roles : [role];
return (
<TextCell>
<TooltipLink
tooltip={<RoleDescription roleId={roleId} tooltip />}
tooltip={
<StyledRoleDescriptions>
{rolesArray.map(roleId => (
<RoleDescription
key={roleId}
roleId={roleId}
tooltip
/>
))}
</StyledRoleDescriptions>
}
>
{value}
</TooltipLink>

View File

@ -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<IRole | null>(
roles.find(({ id }) => id === selected?.entity.roleId) ?? null
const [selectedRoles, setRoles] = useState<IRole[]>(
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<HTMLFormElement>) => {
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 (
<SidebarModal
@ -430,11 +441,28 @@ export const ProjectAccessAssign = ({
Select the role to assign for this project
</StyledInputDescription>
<StyledAutocompleteWrapper>
<RoleSelect
data-testid={PA_ROLE_ID}
roles={roles}
value={role}
setValue={role => setRole(role || null)}
<ConditionallyRender
condition={Boolean(
uiConfig.flags.multipleRoles
)}
show={() => (
<MultipleRoleSelect
data-testid={PA_ROLE_ID}
roles={roles}
value={selectedRoles}
setValue={setRoles}
/>
)}
elseShow={() => (
<RoleSelect
data-testid={PA_ROLE_ID}
roles={roles}
value={selectedRoles[0]}
setValue={role =>
setRoles(role ? [role] : [])
}
/>
)}
/>
</StyledAutocompleteWrapper>
</div>

View File

@ -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<IProjectAccess>();
@ -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) => (
<RoleCell roleId={row.entity.roleId} value={value} />
),
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;
}) => <RoleCell value={value} roles={row.entity.roles} />,
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({

View File

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

View File

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

View File

@ -57,6 +57,7 @@ export interface IFlags {
lastSeenByEnvironment?: boolean;
newApplicationList?: boolean;
integrationsRework?: boolean;
multipleRoles?: boolean;
}
export interface IVersionInfo {

View File

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

View File

@ -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<IUserWithProjectRoles[]> {
const rows = await this.db
.select(['user_id', 'ru.created_at', 'ru.role_id'])
.from<IRole>(`${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<IRoleWithProject[]> {
return this.db
.select(['id', 'name', 'type', 'project', 'description'])
@ -310,13 +338,13 @@ export class AccessStore implements IAccessStore {
): Promise<IProjectRoleUsage[]> {
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<void> {
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<void> {
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<number[]> {
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<void> {
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<number[]> {
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<void> {
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<void> {
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[],

View File

@ -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<IGroupWithProjectRoles[]> {
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<IGroupProject[]> {
const rows = await this.db
.select('group_id', 'project')

View File

@ -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<void> {
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<void> {
return this.store.addAccessToProject(
roles,
groups,
users,
projectId,
createdBy,
);
}
async setProjectRolesForUser(
projectId: string,
userId: number,
roles: number[],
): Promise<void> {
await this.store.setProjectRolesForUser(projectId, userId, roles);
}
async getProjectRolesForUser(
projectId: string,
userId: number,
): Promise<number[]> {
return this.store.getProjectRolesForUser(projectId, userId);
}
async setProjectRolesForGroup(
projectId: string,
groupId: number,
roles: number[],
createdBy: string,
): Promise<void> {
await this.store.setProjectRolesForGroup(
projectId,
groupId,
roles,
createdBy,
);
}
async getProjectRolesForGroup(
projectId: string,
groupId: number,
): Promise<number[]> {
return this.store.getProjectRolesForGroup(projectId, groupId);
}
async getRoleByName(roleName: string): Promise<IRole> {
return this.roleStore.getRoleByName(roleName);
}
async removeUserAccess(projectId: string, userId: number): Promise<void> {
await this.store.removeUserAccess(projectId, userId);
}
async removeGroupAccess(projectId: string, groupId: number): Promise<void> {
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<IProjectUser[]> {
): Promise<IUserWithRole[]> {
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<IUserWithProjectRoles[]> {
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<AccessWithRoles> {
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<IProjectRoleUsage[]> {

View File

@ -147,29 +147,27 @@ export class GroupService {
}
async getProjectGroups(
projectId?: string,
projectId: string,
): Promise<IGroupModelWithProjectRole[]> {
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 [];

View File

@ -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<AccessWithRoles> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<Array<Pick<IUser, 'id' | 'email' | 'username'>>> {
const [, users, groups] = await this.accessService.getProjectRoleAccess(
const { groups, users } = await this.accessService.getProjectRoleAccess(
projectId,
);
const actualUsers = users.map((user) => ({

View File

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

View File

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

View File

@ -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<IRole, number> {
getAvailablePermissions(): Promise<IPermission[]>;
@ -74,6 +86,8 @@ export interface IAccessStore extends Store<IRole, number> {
projectId?: string,
): Promise<IUserRole[]>;
getProjectUsers(projectId?: string): Promise<IUserWithProjectRoles[]>;
getUserIdsForRole(roleId: number, projectId?: string): Promise<number[]>;
getGroupIdsForRole(roleId: number, projectId?: string): Promise<number[]>;
@ -95,7 +109,7 @@ export interface IAccessStore extends Store<IRole, number> {
projectId?: string,
): Promise<void>;
addAccessToProject(
addRoleAccessToProject(
users: IAccessInfo[],
groups: IAccessInfo[],
projectId: string,
@ -103,6 +117,14 @@ export interface IAccessStore extends Store<IRole, number> {
createdBy: string,
): Promise<void>;
addAccessToProject(
roles: number[],
groups: number[],
users: number[],
projectId: string,
createdBy: string,
): Promise<void>;
removeUserFromRole(
userId: number,
roleId: number,
@ -155,4 +177,26 @@ export interface IAccessStore extends Store<IRole, number> {
sourceEnvironment: string,
destinationEnvironment: string,
): Promise<void>;
setProjectRolesForUser(
projectId: string,
userId: number,
roles: number[],
): Promise<void>;
getProjectRolesForUser(
projectId: string,
userId: number,
): Promise<number[]>;
setProjectRolesForGroup(
projectId: string,
groupId: number,
roles: number[],
createdBy: string,
): Promise<void>;
getProjectRolesForGroup(
projectId: string,
groupId: number,
): Promise<number[]>;
removeUserAccess(projectId: string, userId: number): Promise<void>;
removeGroupAccess(projectId: string, groupId: number): Promise<void>;
}

View File

@ -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<IGroup, number> {
getProjectGroupRoles(projectId: string): Promise<IGroupRole[]>;
getProjectGroups(projectId: string): Promise<IGroupWithProjectRoles[]>;
getAllWithId(ids: number[]): Promise<IGroup[]>;
updateGroupUsers(

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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<AccessWithRoles> {
throw new Error('Method not implemented.');
}

View File

@ -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<void> {
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<IUserWithProjectRoles[]> {
throw new Error('Method not implemented.');
}
getProjectRoles(): Promise<IRole[]> {
throw new Error('Method not implemented.');
}
@ -198,7 +213,7 @@ class AccessStoreMock implements IAccessStore {
}
get(key: number): Promise<IRole> {
return Promise.resolve(undefined);
throw new Error('Not implemented yet');
}
getAll(): Promise<IRole[]> {
@ -234,6 +249,45 @@ class AccessStoreMock implements IAccessStore {
clearPublicSignupUserTokens(userId: number): Promise<void> {
return Promise.resolve(undefined);
}
getProjectRolesForGroup(
projectId: string,
groupId: number,
): Promise<number[]> {
throw new Error('Method not implemented.');
}
getProjectRolesForUser(
projectId: string,
userId: number,
): Promise<number[]> {
throw new Error('Method not implemented.');
}
setProjectRolesForGroup(
projectId: string,
groupId: number,
roles: number[],
createdBy: string,
): Promise<void> {
throw new Error('Method not implemented.');
}
setProjectRolesForUser(
projectId: string,
userId: number,
roles: number[],
): Promise<void> {
throw new Error('Method not implemented.');
}
removeUserAccess(projectId: string, userId: number): Promise<void> {
throw new Error('Method not implemented.');
}
removeGroupAccess(projectId: string, groupId: number): Promise<void> {
throw new Error('Method not implemented.');
}
}
module.exports = AccessStoreMock;

View File

@ -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<number> {
@ -83,6 +84,10 @@ export default class FakeGroupStore implements IGroupStore {
throw new Error('Method not implemented.');
}
getProjectGroups(projectId: string): Promise<IGroupWithProjectRoles[]> {
throw new Error('Method not implemented.');
}
getGroupProjects(groupIds: number[]): Promise<IGroupProject[]> {
throw new Error('Method not implemented.');
}