From 3b42e866ecf2a61c219975f9e111cce7469e062c Mon Sep 17 00:00:00 2001 From: Simon Hornby Date: Thu, 20 Apr 2023 12:29:30 +0200 Subject: [PATCH] feat: root roles from groups (#3559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: adds a way to specify a root role on a group, which will cause any user entering into that group to take on the permissions of that root role Co-authored-by: Nuno Góis --- .../admin/groups/CreateGroup/CreateGroup.tsx | 4 + .../admin/groups/EditGroup/EditGroup.tsx | 7 +- .../Group/EditGroupUsers/EditGroupUsers.tsx | 3 +- .../admin/groups/GroupForm/GroupForm.tsx | 86 +++++++- .../groups/GroupsList/GroupCard/GroupCard.tsx | 37 +++- .../admin/groups/GroupsList/GroupsList.tsx | 8 + .../admin/groups/hooks/useGroupForm.ts | 7 +- .../ProjectAccessAssign.tsx | 83 +++++--- frontend/src/interfaces/group.ts | 1 + frontend/src/interfaces/uiConfig.ts | 1 + .../__snapshots__/create-config.test.ts.snap | 2 + src/lib/db/access-store.ts | 24 ++- src/lib/db/group-store.ts | 6 + src/lib/openapi/spec/group-schema.ts | 6 + src/lib/routes/admin-api/user-admin.ts | 1 + src/lib/types/experimental.ts | 4 + src/lib/types/group.ts | 6 + src/lib/types/stores/group-store.ts | 1 + .../20230414105818-add-root-role-to-groups.js | 24 +++ .../__snapshots__/openapi.e2e.test.ts.snap | 5 + .../e2e/services/access-service.e2e.test.ts | 185 ++++++++++++++++++ 21 files changed, 464 insertions(+), 37 deletions(-) create mode 100644 src/migrations/20230414105818-add-root-role-to-groups.js diff --git a/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx index 83a45eeed8..491c25bf40 100644 --- a/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx +++ b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx @@ -26,6 +26,8 @@ export const CreateGroup = () => { setMappingsSSO, users, setUsers, + rootRole, + setRootRole, getGroupPayload, clearErrors, errors, @@ -95,10 +97,12 @@ export const CreateGroup = () => { name={name} description={description} mappingsSSO={mappingsSSO} + rootRole={rootRole} users={users} setName={onSetName} setDescription={setDescription} setMappingsSSO={setMappingsSSO} + setRootRole={setRootRole} setUsers={setUsers} errors={errors} handleSubmit={handleSubmit} diff --git a/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx index a7844e36b0..fa69a31819 100644 --- a/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx +++ b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx @@ -55,6 +55,8 @@ export const EditGroup = ({ setMappingsSSO, users, setUsers, + rootRole, + setRootRole, getGroupPayload, clearErrors, errors, @@ -63,7 +65,8 @@ export const EditGroup = ({ group?.name, group?.description, group?.mappingsSSO, - group?.users + group?.users, + group?.rootRole ); const { groups } = useGroups(); @@ -129,10 +132,12 @@ export const EditGroup = ({ description={description} mappingsSSO={mappingsSSO} users={users} + rootRole={rootRole} setName={onSetName} setDescription={setDescription} setMappingsSSO={setMappingsSSO} setUsers={setUsers} + setRootRole={setRootRole} errors={errors} handleSubmit={handleSubmit} handleCancel={handleCancel} diff --git a/frontend/src/component/admin/groups/Group/EditGroupUsers/EditGroupUsers.tsx b/frontend/src/component/admin/groups/Group/EditGroupUsers/EditGroupUsers.tsx index 2ab82b8264..7721507f04 100644 --- a/frontend/src/component/admin/groups/Group/EditGroupUsers/EditGroupUsers.tsx +++ b/frontend/src/component/admin/groups/Group/EditGroupUsers/EditGroupUsers.tsx @@ -60,7 +60,8 @@ export const EditGroupUsers: FC = ({ group.name, group.description, group.mappingsSSO, - group.users + group.users, + group.rootRole ); useEffect(() => { diff --git a/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx index e2b6267fc1..80830401a3 100644 --- a/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx +++ b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx @@ -1,5 +1,5 @@ import React, { FC } from 'react'; -import { Box, Button, styled } from '@mui/material'; +import { Autocomplete, Box, Button, styled, TextField } from '@mui/material'; import { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds'; import Input from 'component/common/Input/Input'; import { IGroupUser } from 'interfaces/group'; @@ -10,6 +10,9 @@ import { ItemList } from 'component/common/ItemList/ItemList'; import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings'; import { Link } from 'react-router-dom'; import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; +import { IProjectRole } from 'interfaces/role'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; const StyledForm = styled('form')(() => ({ display: 'flex', @@ -63,15 +66,34 @@ const StyledDescriptionBlock = styled('div')(({ theme }) => ({ }, })); +const StyledAutocompleteWrapper = styled('div')(({ theme }) => ({ + '& > div:first-of-type': { + width: '100%', + maxWidth: theme.spacing(50), + marginBottom: theme.spacing(2), + }, +})); + +const StyledRoleOption = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& > span:last-of-type': { + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.text.secondary, + }, +})); + interface IGroupForm { name: string; description: string; mappingsSSO: string[]; users: IGroupUser[]; + rootRole: number | null; setName: (name: string) => void; setDescription: React.Dispatch>; setMappingsSSO: React.Dispatch>; setUsers: React.Dispatch>; + setRootRole: React.Dispatch>; handleSubmit: (e: any) => void; handleCancel: () => void; errors: { [key: string]: string }; @@ -83,23 +105,47 @@ export const GroupForm: FC = ({ description, mappingsSSO, users, + rootRole, setName, setDescription, setMappingsSSO, setUsers, handleSubmit, handleCancel, + setRootRole, errors, mode, children, }) => { const { config: oidcSettings } = useAuthSettings('oidc'); const { config: samlSettings } = useAuthSettings('saml'); + const { uiConfig } = useUiConfig(); + const { roles } = useUsers(); const isGroupSyncingEnabled = (oidcSettings?.enabled && oidcSettings.enableGroupSyncing) || (samlSettings?.enabled && samlSettings.enableGroupSyncing); + const groupRootRolesEnabled = Boolean(uiConfig.flags.groupRootRoles); + + const roleIdToRole = (rootRoleId: number | null): IProjectRole | null => { + return ( + roles.find((role: IProjectRole) => role.id === rootRoleId) || null + ); + }; + + const renderRoleOption = ( + props: React.HTMLAttributes, + option: IProjectRole + ) => ( +
  • + + {option.name} + {option.description} + +
  • + ); + return (
    @@ -146,9 +192,9 @@ export const GroupForm: FC = ({ elseShow={() => ( - You can enable SSO groups syncronization if + You can enable SSO groups synchronization if needed - + View SSO configuration @@ -156,6 +202,40 @@ export const GroupForm: FC = ({ )} /> + + + + Do you want to associate a root role with + this group? + + + + + + setRootRole(newValue?.id || null) + } + options={roles.filter( + (role: IProjectRole) => + role.name !== 'Viewer' + )} + renderOption={renderRoleOption} + getOptionLabel={option => option.name} + renderInput={params => ( + + )} + /> + + + } + /> ({ textDecoration: 'none', @@ -75,14 +77,24 @@ const ProjectBadgeContainer = styled('div')(({ theme }) => ({ flexWrap: 'wrap', })); +const InfoBadgeDescription = styled('span')(({ theme }) => ({ + display: 'flex', + color: theme.palette.text.secondary, + alignItems: 'center', + gap: theme.spacing(1), + fontSize: theme.fontSizes.smallBody, +})); + interface IGroupCardProps { group: IGroup; + rootRoles: IProjectRole[]; onEditUsers: (group: IGroup) => void; onRemoveGroup: (group: IGroup) => void; } export const GroupCard = ({ group, + rootRoles, onEditUsers, onRemoveGroup, }: IGroupCardProps) => { @@ -101,6 +113,26 @@ export const GroupCard = ({ /> + +

    Root role:

    + } + > + { + rootRoles.find( + (role: IProjectRole) => + role.id === group.rootRole + )?.name + } + + + } + /> + {group.description} - Not used + Not used} + /> } /> diff --git a/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx index 11b1ff1594..02e475d03f 100644 --- a/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx +++ b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx @@ -18,6 +18,8 @@ import { Add } from '@mui/icons-material'; import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds'; import { EditGroupUsers } from '../Group/EditGroupUsers/EditGroupUsers'; import { RemoveGroup } from '../RemoveGroup/RemoveGroup'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import { IProjectRole } from 'interfaces/role'; type PageQueryType = Partial>; @@ -49,6 +51,7 @@ export const GroupsList: VFC = () => { const [searchValue, setSearchValue] = useState( searchParams.get('search') || '' ); + const { roles } = useUsers(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); @@ -82,6 +85,10 @@ export const GroupsList: VFC = () => { setRemoveOpen(true); }; + const getBindableRootRoles = () => { + return roles.filter((role: IProjectRole) => role.type === 'root'); + }; + return ( { diff --git a/frontend/src/component/admin/groups/hooks/useGroupForm.ts b/frontend/src/component/admin/groups/hooks/useGroupForm.ts index 826b9a3ad5..e5cd3787f2 100644 --- a/frontend/src/component/admin/groups/hooks/useGroupForm.ts +++ b/frontend/src/component/admin/groups/hooks/useGroupForm.ts @@ -6,7 +6,8 @@ export const useGroupForm = ( initialName = '', initialDescription = '', initialMappingsSSO: string[] = [], - initialUsers: IGroupUser[] = [] + initialUsers: IGroupUser[] = [], + initialRootRole: number | null = null ) => { const params = useQueryParams(); const groupQueryName = params.get('name'); @@ -14,6 +15,7 @@ export const useGroupForm = ( const [description, setDescription] = useState(initialDescription); const [mappingsSSO, setMappingsSSO] = useState(initialMappingsSSO); const [users, setUsers] = useState(initialUsers); + const [rootRole, setRootRole] = useState(initialRootRole); const [errors, setErrors] = useState({}); const getGroupPayload = () => { @@ -24,6 +26,7 @@ export const useGroupForm = ( users: users.map(({ id }) => ({ user: { id }, })), + rootRole: rootRole || undefined, }; }; @@ -44,5 +47,7 @@ export const useGroupForm = ( clearErrors, errors, setErrors, + rootRole, + setRootRole, }; }; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx index e817bd4ce1..e6e51c8778 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign.tsx @@ -6,6 +6,7 @@ import { Checkbox, styled, TextField, + Tooltip, } from '@mui/material'; import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; import CheckBoxIcon from '@mui/icons-material/CheckBox'; @@ -255,6 +256,12 @@ export const ProjectAccessAssign = ({ --data-raw '${JSON.stringify(payload, undefined, 2)}'`; }; + const createRootGroupWarning = (group?: IGroup): string | undefined => { + if (group && Boolean(group.rootRole)) { + return 'This group has an Admin or Editor role associated with it. Groups with a root role association cannot be assigned to projects, and users in this group already have the role applied globally.'; + } + }; + const renderOption = ( props: React.HTMLAttributes, option: IAccessOption, @@ -268,35 +275,45 @@ export const ProjectAccessAssign = ({ optionUser = option.entity as IUser; } return ( -
  • - } - checkedIcon={} - style={{ marginRight: 8 }} - checked={selected} - /> - - {optionGroup?.name} - {optionGroup?.userCount} users - - } - elseShow={ - - - {optionUser?.name || optionUser?.username} - - - {optionUser?.name && optionUser?.username - ? optionUser?.username - : optionUser?.email} - - - } - /> -
  • + + +
  • + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + + + {optionGroup?.name} + + {optionGroup?.userCount} users + + + + } + elseShow={ + + + {optionUser?.name || + optionUser?.username} + + + {optionUser?.name && + optionUser?.username + ? optionUser?.username + : optionUser?.email} + + + } + /> +
  • +
    +
    ); }; @@ -346,6 +363,14 @@ export const ProjectAccessAssign = ({ disableCloseOnSelect disabled={edit} value={selectedOptions} + getOptionDisabled={option => { + if (option.type === ENTITY_TYPE.GROUP) { + const optionGroup = + option.entity as IGroup; + return Boolean(optionGroup.rootRole); + } + return false; + }} onChange={(event, newValue, reason) => { if ( event.type === 'keydown' && diff --git a/frontend/src/interfaces/group.ts b/frontend/src/interfaces/group.ts index dac0351698..c9f932a501 100644 --- a/frontend/src/interfaces/group.ts +++ b/frontend/src/interfaces/group.ts @@ -10,6 +10,7 @@ export interface IGroup { addedAt?: string; userCount?: number; mappingsSSO: string[]; + rootRole?: number; } export interface IGroupUser extends IUser { diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 6a1adf4d2a..1c17fa1920 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -53,6 +53,7 @@ export interface IFlags { personalAccessTokensKillSwitch?: boolean; demo?: boolean; strategyTitle?: boolean; + groupRootRoles?: 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 0b6e3bc1bf..66585bb673 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -77,6 +77,7 @@ exports[`should create default config 1`] = ` "embedProxy": true, "embedProxyFrontend": true, "featuresExportImport": true, + "groupRootRoles": false, "loginHistory": false, "maintenanceMode": false, "messageBanner": false, @@ -105,6 +106,7 @@ exports[`should create default config 1`] = ` "embedProxy": true, "embedProxyFrontend": true, "featuresExportImport": true, + "groupRootRoles": false, "loginHistory": false, "maintenanceMode": false, "messageBanner": false, diff --git a/src/lib/db/access-store.ts b/src/lib/db/access-store.ts index ed6ea93a41..cf7c3c5f66 100644 --- a/src/lib/db/access-store.ts +++ b/src/lib/db/access-store.ts @@ -142,8 +142,30 @@ 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') - .where('gu.user_id', '=', userId); + .whereNull('g.root_role_id') + .andWhere('gu.user_id', '=', userId); }); + + userPermissionQuery = userPermissionQuery.union((db) => { + db.select( + this.db.raw("'default' as project"), + 'permission', + 'environment', + 'p.type', + 'g.root_role_id as role_id', + ) + .from(`${T.GROUP_USER} as gu`) + .join(`${T.GROUPS} AS g`, 'g.id', 'gu.group_id') + .join( + `${T.ROLE_PERMISSION} as rp`, + 'rp.role_id', + 'g.root_role_id', + ) + .join(`${T.PERMISSIONS} as p`, 'p.id', 'rp.permission_id') + .whereNotNull('g.root_role_id') + .andWhere('gu.user_id', '=', userId); + }); + const rows = await userPermissionQuery; stopTimer(); return rows.map(this.mapUserPermission); diff --git a/src/lib/db/group-store.ts b/src/lib/db/group-store.ts index 6f6942afcd..b7e2d203d2 100644 --- a/src/lib/db/group-store.ts +++ b/src/lib/db/group-store.ts @@ -28,6 +28,7 @@ const GROUP_COLUMNS = [ 'mappings_sso', 'created_at', 'created_by', + 'root_role_id', ]; const rowToGroup = (row) => { @@ -41,6 +42,7 @@ const rowToGroup = (row) => { mappingsSSO: row.mappings_sso, createdAt: row.created_at, createdBy: row.created_by, + rootRole: row.root_role_id, }); }; @@ -53,6 +55,7 @@ const rowToGroupUser = (row) => { groupId: row.group_id, joinedAt: row.created_at, createdBy: row.created_by, + rootRoleId: row.root_role_id, }; }; @@ -60,6 +63,7 @@ const groupToRow = (group: IStoreGroup) => ({ name: group.name, description: group.description, mappings_sso: JSON.stringify(group.mappingsSSO), + root_role_id: group.rootRole || null, }); export default class GroupStore implements IGroupStore { @@ -124,9 +128,11 @@ export default class GroupStore implements IGroupStore { 'u.id as user_id', 'gu.created_at', 'gu.created_by', + 'g.root_role_id', ) .from(`${T.GROUP_USER} AS gu`) .join(`${T.USERS} AS u`, 'u.id', 'gu.user_id') + .join(`${T.GROUPS} AS g`, 'g.id', 'gu.group_id') .whereIn('gu.group_id', groupIds); return rows.map(rowToGroupUser); } diff --git a/src/lib/openapi/spec/group-schema.ts b/src/lib/openapi/spec/group-schema.ts index d8f2ccf565..e013c3e0f0 100644 --- a/src/lib/openapi/spec/group-schema.ts +++ b/src/lib/openapi/spec/group-schema.ts @@ -24,6 +24,12 @@ export const groupSchema = { type: 'string', }, }, + rootRole: { + type: 'number', + nullable: true, + description: + 'A role id that is used as the root role for all users in this group. This can be either the id of the Editor or Admin role.', + }, createdBy: { type: 'string', nullable: true, diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index 6eadb17b01..ba351649fa 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -341,6 +341,7 @@ export default class UserAdminController extends Controller { id: g.id, name: g.name, userCount: g.users.length, + rootRole: g.rootRole, } as IGroup; }); this.openApiService.respondWithValidation( diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 35e7ef961f..32da3c011d 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -76,6 +76,10 @@ const flags = { process.env.UNLEASH_EXPERIMENTAL_OPTIMAL_304_DIFFER, false, ), + groupRootRoles: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_ROOT_ROLE_GROUPS, + false, + ), migrationLock: parseEnvVarBoolean(process.env.MIGRATION_LOCK, false), demo: parseEnvVarBoolean(process.env.UNLEASH_DEMO, false), strategyTitle: parseEnvVarBoolean( diff --git a/src/lib/types/group.ts b/src/lib/types/group.ts index 8d5b505751..886c7e16cd 100644 --- a/src/lib/types/group.ts +++ b/src/lib/types/group.ts @@ -6,6 +6,7 @@ export interface IGroup { name: string; description?: string; mappingsSSO?: string[]; + rootRole?: number; createdAt?: Date; userCount?: number; createdBy?: string; @@ -15,6 +16,7 @@ export interface IGroupUser { groupId: number; userId: number; joinedAt: Date; + rootRoleId?: number; seenAt?: Date; createdBy?: string; } @@ -58,6 +60,8 @@ export default class Group implements IGroup { name: string; + rootRole?: number; + description: string; mappingsSSO: string[]; @@ -67,6 +71,7 @@ export default class Group implements IGroup { name, description, mappingsSSO, + rootRole, createdBy, createdAt, }: IGroup) { @@ -78,6 +83,7 @@ export default class Group implements IGroup { this.id = id; this.name = name; + this.rootRole = rootRole; this.description = description; this.mappingsSSO = mappingsSSO; this.createdBy = createdBy; diff --git a/src/lib/types/stores/group-store.ts b/src/lib/types/stores/group-store.ts index c21acf90fc..5fdf51089a 100644 --- a/src/lib/types/stores/group-store.ts +++ b/src/lib/types/stores/group-store.ts @@ -12,6 +12,7 @@ export interface IStoreGroup { name: string; description?: string; mappingsSSO?: string[]; + rootRole?: number; } export interface IGroupStore extends Store { diff --git a/src/migrations/20230414105818-add-root-role-to-groups.js b/src/migrations/20230414105818-add-root-role-to-groups.js new file mode 100644 index 0000000000..14773c1045 --- /dev/null +++ b/src/migrations/20230414105818-add-root-role-to-groups.js @@ -0,0 +1,24 @@ +'use strict'; + +exports.up = function (db, callback) { + db.runSql( + ` + ALTER TABLE groups ADD COLUMN root_role_id INTEGER DEFAULT NULL; + ALTER TABLE groups + ADD CONSTRAINT fk_group_role_id + FOREIGN KEY(root_role_id) + REFERENCES roles(id); + `, + callback, + ); +}; + +exports.down = function (db, callback) { + db.runSql( + ` + ALTER TABLE groups DROP CONSTRAINT fk_group_role_id; + ALTER TABLE groups DROP COLUMN root_role_id; + `, + callback, + ); +}; diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 8f404dfa55..489a2d7395 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -2357,6 +2357,11 @@ The provider you choose for your addon dictates what properties the \`parameters }, "type": "array", }, + "rootRole": { + "description": "A role id that is used as the root role for all users in this group. This can be either the id of the Editor or Admin role.", + "nullable": true, + "type": "number", + }, "users": { "items": { "$ref": "#/components/schemas/groupUserModelSchema", diff --git a/src/test/e2e/services/access-service.e2e.test.ts b/src/test/e2e/services/access-service.e2e.test.ts index 8bec72329f..3fbfcd7cb4 100644 --- a/src/test/e2e/services/access-service.e2e.test.ts +++ b/src/test/e2e/services/access-service.e2e.test.ts @@ -44,6 +44,13 @@ const createUserViewerAccess = async (name, email) => { return user; }; +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; +}; + const hasCommonProjectAccess = async (user, projectName, condition) => { const defaultEnv = 'default'; const developmentEnv = 'development'; @@ -1062,3 +1069,181 @@ test('Should allow user to take multiple group roles and have expected permissio ), ).toBe(true); }); + +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 createUserViewerAccess( + 'Vincent Viewer', + 'vincent@getunleash.io', + ); + + const groupWithRootAdminRole = await groupStore.create({ + name: 'GroupThatGrantsAdminRights', + description: '', + rootRole: adminRole.id, + }); + + 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 createUserViewerAccess( + 'Violet Viewer', + 'violet@getunleash.io', + ); + + const viewerUserNotInGroup = await createUserViewerAccess( + 'Veronica Viewer', + 'veronica@getunleash.io', + ); + + const groupWithRootAdminRole = await groupStore.create({ + name: 'GroupThatGrantsAdminRights', + description: '', + rootRole: adminRole.id, + }); + + await groupStore.addUsersToGroup( + groupWithRootAdminRole.id!, + [{ user: viewerUser }], + 'Admin', + ); + + expect( + await accessService.hasPermission(viewerUser, permissions.ADMIN), + ).toBe(true); + + expect( + await accessService.hasPermission( + viewerUserNotInGroup, + permissions.ADMIN, + ), + ).toBe(false); +}); + +test('Should not reduce permissions for an admin user that enters an editor group', async () => { + const groupStore = stores.groupStore; + + const adminUser = await createUserAdminAccess( + 'Austin Admin', + 'austin@getunleash.io', + ); + + const groupWithRootEditorRole = await groupStore.create({ + name: 'GroupThatGrantsEditorRights', + description: '', + rootRole: editorRole.id, + }); + + await groupStore.addUsersToGroup( + groupWithRootEditorRole.id!, + [{ user: adminUser }], + 'Admin', + ); + + expect( + await accessService.hasPermission(adminUser, permissions.ADMIN), + ).toBe(true); +}); + +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 groupWithoutRootRole = await groupStore.create({ + name: 'GroupWithNoRootRole', + description: '', + }); + + const preAddedToGroupPermissions = + await accessService.getPermissionsForUser(viewerUser); + + await groupStore.addUsersToGroup( + groupWithoutRootRole.id!, + [{ user: viewerUser }], + 'Admin', + ); + + const postAddedToGroupPermissions = + await accessService.getPermissionsForUser(viewerUser); + + expect( + JSON.stringify(preAddedToGroupPermissions) === + JSON.stringify(postAddedToGroupPermissions), + ).toBe(true); +}); + +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 groupWithoutRootRole = await groupStore.create({ + name: 'GroupWithNoRootRole', + description: '', + }); + + await groupStore.addUsersToGroup( + groupWithoutRootRole.id!, + [{ user: viewerUser }], + 'Admin', + ); + + expect( + await accessService.hasPermission(viewerUser, permissions.ADMIN), + ).toBe(false); + + await groupStore.update({ + id: groupWithoutRootRole.id!, + name: 'GroupWithNoRootRole', + rootRole: adminRole.id, + users: [{ user: viewerUser }], + }); + + expect( + await accessService.hasPermission(viewerUser, permissions.ADMIN), + ).toBe(true); +}); + +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 createUserViewerAccess( + 'Vee viewer', + 'vee@getunleash.io', + ); + + const groupWithRootEditorRole = await groupStore.create({ + name: 'GroupThatGrantsEditorRights', + description: '', + rootRole: editorRole.id, + }); + + await groupStore.addUsersToGroup( + groupWithRootEditorRole.id!, + [{ user: viewerUser }], + 'Admin', + ); + + await hasFullProjectAccess(viewerUser, projectName, true); +});