mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Merge remote-tracking branch 'origin/v4' into v4
This commit is contained in:
		
						commit
						de94a4af2c
					
				| @ -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} | ||||
|  | ||||
| @ -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} | ||||
|  | ||||
| @ -60,7 +60,8 @@ export const EditGroupUsers: FC<IEditGroupUsersProps> = ({ | ||||
|         group.name, | ||||
|         group.description, | ||||
|         group.mappingsSSO, | ||||
|         group.users | ||||
|         group.users, | ||||
|         group.rootRole | ||||
|     ); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|  | ||||
| @ -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<React.SetStateAction<string>>; | ||||
|     setMappingsSSO: React.Dispatch<React.SetStateAction<string[]>>; | ||||
|     setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>; | ||||
|     setRootRole: React.Dispatch<React.SetStateAction<number | null>>; | ||||
|     handleSubmit: (e: any) => void; | ||||
|     handleCancel: () => void; | ||||
|     errors: { [key: string]: string }; | ||||
| @ -83,23 +105,47 @@ export const GroupForm: FC<IGroupForm> = ({ | ||||
|     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<HTMLLIElement>, | ||||
|         option: IProjectRole | ||||
|     ) => ( | ||||
|         <li {...props}> | ||||
|             <StyledRoleOption> | ||||
|                 <span>{option.name}</span> | ||||
|                 <span>{option.description}</span> | ||||
|             </StyledRoleOption> | ||||
|         </li> | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledForm onSubmit={handleSubmit}> | ||||
|             <div> | ||||
| @ -146,9 +192,9 @@ export const GroupForm: FC<IGroupForm> = ({ | ||||
|                     elseShow={() => ( | ||||
|                         <StyledDescriptionBlock> | ||||
|                             <Box sx={{ display: 'flex' }}> | ||||
|                                 You can enable SSO groups syncronization if | ||||
|                                 You can enable SSO groups synchronization if | ||||
|                                 needed | ||||
|                                 <HelpIcon tooltip="SSO groups syncronization allows SSO groups to be mapped to Unleash groups, so that user group membership is properly synchronized." /> | ||||
|                                 <HelpIcon tooltip="SSO groups synchronization allows SSO groups to be mapped to Unleash groups, so that user group membership is properly synchronized." /> | ||||
|                             </Box> | ||||
|                             <Link data-loading to={`/admin/auth`}> | ||||
|                                 <span data-loading>View SSO configuration</span> | ||||
| @ -156,6 +202,40 @@ export const GroupForm: FC<IGroupForm> = ({ | ||||
|                         </StyledDescriptionBlock> | ||||
|                     )} | ||||
|                 /> | ||||
|                 <ConditionallyRender | ||||
|                     condition={groupRootRolesEnabled} | ||||
|                     show={ | ||||
|                         <> | ||||
|                             <StyledInputDescription> | ||||
|                                 <Box sx={{ display: 'flex' }}> | ||||
|                                     Do you want to associate a root role with | ||||
|                                     this group? | ||||
|                                     <HelpIcon tooltip="When you associate an Admin or Editor role with this group, users in this group will automatically inherit the role globally. Note that groups with a root role association cannot be assigned to projects." /> | ||||
|                                 </Box> | ||||
|                             </StyledInputDescription> | ||||
|                             <StyledAutocompleteWrapper> | ||||
|                                 <Autocomplete | ||||
|                                     data-testid="GROUP_ROOT_ROLE" | ||||
|                                     size="small" | ||||
|                                     openOnFocus | ||||
|                                     value={roleIdToRole(rootRole)} | ||||
|                                     onChange={(_, newValue) => | ||||
|                                         setRootRole(newValue?.id || null) | ||||
|                                     } | ||||
|                                     options={roles.filter( | ||||
|                                         (role: IProjectRole) => | ||||
|                                             role.name !== 'Viewer' | ||||
|                                     )} | ||||
|                                     renderOption={renderRoleOption} | ||||
|                                     getOptionLabel={option => option.name} | ||||
|                                     renderInput={params => ( | ||||
|                                         <TextField {...params} label="Role" /> | ||||
|                                     )} | ||||
|                                 /> | ||||
|                             </StyledAutocompleteWrapper> | ||||
|                         </> | ||||
|                     } | ||||
|                 /> | ||||
|                 <ConditionallyRender | ||||
|                     condition={mode === 'Create'} | ||||
|                     show={ | ||||
|  | ||||
| @ -6,6 +6,8 @@ import { GroupCardAvatars } from './GroupCardAvatars/GroupCardAvatars'; | ||||
| import { Badge } from 'component/common/Badge/Badge'; | ||||
| import { GroupCardActions } from './GroupCardActions/GroupCardActions'; | ||||
| import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined'; | ||||
| import { IProjectRole } from 'interfaces/role'; | ||||
| import { IProject } from 'interfaces/project'; | ||||
| 
 | ||||
| const StyledLink = styled(Link)(({ theme }) => ({ | ||||
|     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 = ({ | ||||
|                             /> | ||||
|                         </StyledHeaderActions> | ||||
|                     </StyledTitleRow> | ||||
|                     <ConditionallyRender | ||||
|                         condition={Boolean(group.rootRole)} | ||||
|                         show={ | ||||
|                             <InfoBadgeDescription> | ||||
|                                 <p>Root role:</p> | ||||
|                                 <Badge | ||||
|                                     color="success" | ||||
|                                     icon={<TopicOutlinedIcon />} | ||||
|                                 > | ||||
|                                     { | ||||
|                                         rootRoles.find( | ||||
|                                             (role: IProjectRole) => | ||||
|                                                 role.id === group.rootRole | ||||
|                                         )?.name | ||||
|                                     } | ||||
|                                 </Badge> | ||||
|                             </InfoBadgeDescription> | ||||
|                         } | ||||
|                     /> | ||||
| 
 | ||||
|                     <StyledDescription>{group.description}</StyledDescription> | ||||
|                     <StyledBottomRow> | ||||
|                         <ConditionallyRender | ||||
| @ -143,7 +175,10 @@ export const GroupCard = ({ | ||||
|                                         arrow | ||||
|                                         describeChild | ||||
|                                     > | ||||
|                                         <Badge>Not used</Badge> | ||||
|                                         <ConditionallyRender | ||||
|                                             condition={!group.rootRole} | ||||
|                                             show={<Badge>Not used</Badge>} | ||||
|                                         /> | ||||
|                                     </Tooltip> | ||||
|                                 } | ||||
|                             /> | ||||
|  | ||||
| @ -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<Record<'search', string>>; | ||||
| 
 | ||||
| @ -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 ( | ||||
|         <PageContent | ||||
|             isLoading={loading} | ||||
| @ -134,6 +141,7 @@ export const GroupsList: VFC = () => { | ||||
|                         <Grid key={group.id} item xs={12} md={6}> | ||||
|                             <GroupCard | ||||
|                                 group={group} | ||||
|                                 rootRoles={getBindableRootRoles()} | ||||
|                                 onEditUsers={onEditUsers} | ||||
|                                 onRemoveGroup={onRemoveGroup} | ||||
|                             /> | ||||
|  | ||||
| @ -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<IGroupUser[]>(initialUsers); | ||||
|     const [rootRole, setRootRole] = useState<number | null>(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, | ||||
|     }; | ||||
| }; | ||||
|  | ||||
| @ -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<HTMLLIElement>, | ||||
|         option: IAccessOption, | ||||
| @ -268,6 +275,8 @@ export const ProjectAccessAssign = ({ | ||||
|             optionUser = option.entity as IUser; | ||||
|         } | ||||
|         return ( | ||||
|             <Tooltip title={createRootGroupWarning(optionGroup)}> | ||||
|                 <span> | ||||
|                     <li {...props}> | ||||
|                         <Checkbox | ||||
|                             icon={<CheckBoxOutlineBlankIcon fontSize="small" />} | ||||
| @ -278,18 +287,24 @@ export const ProjectAccessAssign = ({ | ||||
|                         <ConditionallyRender | ||||
|                             condition={option.type === ENTITY_TYPE.GROUP} | ||||
|                             show={ | ||||
|                                 <span> | ||||
|                                     <StyledGroupOption> | ||||
|                                         <span>{optionGroup?.name}</span> | ||||
|                             <span>{optionGroup?.userCount} users</span> | ||||
|                                         <span> | ||||
|                                             {optionGroup?.userCount} users | ||||
|                                         </span> | ||||
|                                     </StyledGroupOption> | ||||
|                                 </span> | ||||
|                             } | ||||
|                             elseShow={ | ||||
|                                 <StyledUserOption> | ||||
|                                     <span> | ||||
|                                 {optionUser?.name || optionUser?.username} | ||||
|                                         {optionUser?.name || | ||||
|                                             optionUser?.username} | ||||
|                                     </span> | ||||
|                                     <span> | ||||
|                                 {optionUser?.name && optionUser?.username | ||||
|                                         {optionUser?.name && | ||||
|                                         optionUser?.username | ||||
|                                             ? optionUser?.username | ||||
|                                             : optionUser?.email} | ||||
|                                     </span> | ||||
| @ -297,6 +312,8 @@ export const ProjectAccessAssign = ({ | ||||
|                             } | ||||
|                         /> | ||||
|                     </li> | ||||
|                 </span> | ||||
|             </Tooltip> | ||||
|         ); | ||||
|     }; | ||||
| 
 | ||||
| @ -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' && | ||||
|  | ||||
| @ -10,6 +10,7 @@ export interface IGroup { | ||||
|     addedAt?: string; | ||||
|     userCount?: number; | ||||
|     mappingsSSO: string[]; | ||||
|     rootRole?: number; | ||||
| } | ||||
| 
 | ||||
| export interface IGroupUser extends IUser { | ||||
|  | ||||
| @ -52,6 +52,8 @@ export interface IFlags { | ||||
|     projectScopedStickiness?: boolean; | ||||
|     personalAccessTokensKillSwitch?: boolean; | ||||
|     demo?: boolean; | ||||
|     strategyTitle?: boolean; | ||||
|     groupRootRoles?: boolean; | ||||
| } | ||||
| 
 | ||||
| export interface IVersionInfo { | ||||
|  | ||||
| @ -77,6 +77,7 @@ exports[`should create default config 1`] = ` | ||||
|       "embedProxy": true, | ||||
|       "embedProxyFrontend": true, | ||||
|       "featuresExportImport": true, | ||||
|       "groupRootRoles": false, | ||||
|       "loginHistory": false, | ||||
|       "maintenanceMode": false, | ||||
|       "messageBanner": false, | ||||
| @ -104,6 +105,7 @@ exports[`should create default config 1`] = ` | ||||
|       "embedProxy": true, | ||||
|       "embedProxyFrontend": true, | ||||
|       "featuresExportImport": true, | ||||
|       "groupRootRoles": false, | ||||
|       "loginHistory": false, | ||||
|       "maintenanceMode": false, | ||||
|       "messageBanner": false, | ||||
|  | ||||
| @ -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<IPermissionRow>(`${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); | ||||
|  | ||||
| @ -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); | ||||
|     } | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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( | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -12,6 +12,7 @@ export interface IStoreGroup { | ||||
|     name: string; | ||||
|     description?: string; | ||||
|     mappingsSSO?: string[]; | ||||
|     rootRole?: number; | ||||
| } | ||||
| 
 | ||||
| export interface IGroupStore extends Store<IGroup, number> { | ||||
|  | ||||
							
								
								
									
										24
									
								
								src/migrations/20230414105818-add-root-role-to-groups.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/migrations/20230414105818-add-root-role-to-groups.js
									
									
									
									
									
										Normal file
									
								
							| @ -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, | ||||
|     ); | ||||
| }; | ||||
| @ -1938,6 +1938,11 @@ exports[`should serve the OpenAPI spec 1`] = ` | ||||
|             }, | ||||
|             "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", | ||||
|  | ||||
| @ -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); | ||||
| }); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user