mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	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
This commit is contained in:
		
							parent
							
								
									c97294aee4
								
							
						
					
					
						commit
						da984326da
					
				@ -26,6 +26,8 @@ export const CreateGroup = () => {
 | 
				
			|||||||
        setMappingsSSO,
 | 
					        setMappingsSSO,
 | 
				
			||||||
        users,
 | 
					        users,
 | 
				
			||||||
        setUsers,
 | 
					        setUsers,
 | 
				
			||||||
 | 
					        rootRole,
 | 
				
			||||||
 | 
					        setRootRole,
 | 
				
			||||||
        getGroupPayload,
 | 
					        getGroupPayload,
 | 
				
			||||||
        clearErrors,
 | 
					        clearErrors,
 | 
				
			||||||
        errors,
 | 
					        errors,
 | 
				
			||||||
@ -95,10 +97,12 @@ export const CreateGroup = () => {
 | 
				
			|||||||
                name={name}
 | 
					                name={name}
 | 
				
			||||||
                description={description}
 | 
					                description={description}
 | 
				
			||||||
                mappingsSSO={mappingsSSO}
 | 
					                mappingsSSO={mappingsSSO}
 | 
				
			||||||
 | 
					                rootRole={rootRole}
 | 
				
			||||||
                users={users}
 | 
					                users={users}
 | 
				
			||||||
                setName={onSetName}
 | 
					                setName={onSetName}
 | 
				
			||||||
                setDescription={setDescription}
 | 
					                setDescription={setDescription}
 | 
				
			||||||
                setMappingsSSO={setMappingsSSO}
 | 
					                setMappingsSSO={setMappingsSSO}
 | 
				
			||||||
 | 
					                setRootRole={setRootRole}
 | 
				
			||||||
                setUsers={setUsers}
 | 
					                setUsers={setUsers}
 | 
				
			||||||
                errors={errors}
 | 
					                errors={errors}
 | 
				
			||||||
                handleSubmit={handleSubmit}
 | 
					                handleSubmit={handleSubmit}
 | 
				
			||||||
 | 
				
			|||||||
@ -55,6 +55,8 @@ export const EditGroup = ({
 | 
				
			|||||||
        setMappingsSSO,
 | 
					        setMappingsSSO,
 | 
				
			||||||
        users,
 | 
					        users,
 | 
				
			||||||
        setUsers,
 | 
					        setUsers,
 | 
				
			||||||
 | 
					        rootRole,
 | 
				
			||||||
 | 
					        setRootRole,
 | 
				
			||||||
        getGroupPayload,
 | 
					        getGroupPayload,
 | 
				
			||||||
        clearErrors,
 | 
					        clearErrors,
 | 
				
			||||||
        errors,
 | 
					        errors,
 | 
				
			||||||
@ -63,7 +65,8 @@ export const EditGroup = ({
 | 
				
			|||||||
        group?.name,
 | 
					        group?.name,
 | 
				
			||||||
        group?.description,
 | 
					        group?.description,
 | 
				
			||||||
        group?.mappingsSSO,
 | 
					        group?.mappingsSSO,
 | 
				
			||||||
        group?.users
 | 
					        group?.users,
 | 
				
			||||||
 | 
					        group?.rootRole
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { groups } = useGroups();
 | 
					    const { groups } = useGroups();
 | 
				
			||||||
@ -129,10 +132,12 @@ export const EditGroup = ({
 | 
				
			|||||||
                description={description}
 | 
					                description={description}
 | 
				
			||||||
                mappingsSSO={mappingsSSO}
 | 
					                mappingsSSO={mappingsSSO}
 | 
				
			||||||
                users={users}
 | 
					                users={users}
 | 
				
			||||||
 | 
					                rootRole={rootRole}
 | 
				
			||||||
                setName={onSetName}
 | 
					                setName={onSetName}
 | 
				
			||||||
                setDescription={setDescription}
 | 
					                setDescription={setDescription}
 | 
				
			||||||
                setMappingsSSO={setMappingsSSO}
 | 
					                setMappingsSSO={setMappingsSSO}
 | 
				
			||||||
                setUsers={setUsers}
 | 
					                setUsers={setUsers}
 | 
				
			||||||
 | 
					                setRootRole={setRootRole}
 | 
				
			||||||
                errors={errors}
 | 
					                errors={errors}
 | 
				
			||||||
                handleSubmit={handleSubmit}
 | 
					                handleSubmit={handleSubmit}
 | 
				
			||||||
                handleCancel={handleCancel}
 | 
					                handleCancel={handleCancel}
 | 
				
			||||||
 | 
				
			|||||||
@ -60,7 +60,8 @@ export const EditGroupUsers: FC<IEditGroupUsersProps> = ({
 | 
				
			|||||||
        group.name,
 | 
					        group.name,
 | 
				
			||||||
        group.description,
 | 
					        group.description,
 | 
				
			||||||
        group.mappingsSSO,
 | 
					        group.mappingsSSO,
 | 
				
			||||||
        group.users
 | 
					        group.users,
 | 
				
			||||||
 | 
					        group.rootRole
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    useEffect(() => {
 | 
					    useEffect(() => {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
import React, { FC } from 'react';
 | 
					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 { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds';
 | 
				
			||||||
import Input from 'component/common/Input/Input';
 | 
					import Input from 'component/common/Input/Input';
 | 
				
			||||||
import { IGroupUser } from 'interfaces/group';
 | 
					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 useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings';
 | 
				
			||||||
import { Link } from 'react-router-dom';
 | 
					import { Link } from 'react-router-dom';
 | 
				
			||||||
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
 | 
					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')(() => ({
 | 
					const StyledForm = styled('form')(() => ({
 | 
				
			||||||
    display: 'flex',
 | 
					    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 {
 | 
					interface IGroupForm {
 | 
				
			||||||
    name: string;
 | 
					    name: string;
 | 
				
			||||||
    description: string;
 | 
					    description: string;
 | 
				
			||||||
    mappingsSSO: string[];
 | 
					    mappingsSSO: string[];
 | 
				
			||||||
    users: IGroupUser[];
 | 
					    users: IGroupUser[];
 | 
				
			||||||
 | 
					    rootRole: number | null;
 | 
				
			||||||
    setName: (name: string) => void;
 | 
					    setName: (name: string) => void;
 | 
				
			||||||
    setDescription: React.Dispatch<React.SetStateAction<string>>;
 | 
					    setDescription: React.Dispatch<React.SetStateAction<string>>;
 | 
				
			||||||
    setMappingsSSO: React.Dispatch<React.SetStateAction<string[]>>;
 | 
					    setMappingsSSO: React.Dispatch<React.SetStateAction<string[]>>;
 | 
				
			||||||
    setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>;
 | 
					    setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>;
 | 
				
			||||||
 | 
					    setRootRole: React.Dispatch<React.SetStateAction<number | null>>;
 | 
				
			||||||
    handleSubmit: (e: any) => void;
 | 
					    handleSubmit: (e: any) => void;
 | 
				
			||||||
    handleCancel: () => void;
 | 
					    handleCancel: () => void;
 | 
				
			||||||
    errors: { [key: string]: string };
 | 
					    errors: { [key: string]: string };
 | 
				
			||||||
@ -83,23 +105,47 @@ export const GroupForm: FC<IGroupForm> = ({
 | 
				
			|||||||
    description,
 | 
					    description,
 | 
				
			||||||
    mappingsSSO,
 | 
					    mappingsSSO,
 | 
				
			||||||
    users,
 | 
					    users,
 | 
				
			||||||
 | 
					    rootRole,
 | 
				
			||||||
    setName,
 | 
					    setName,
 | 
				
			||||||
    setDescription,
 | 
					    setDescription,
 | 
				
			||||||
    setMappingsSSO,
 | 
					    setMappingsSSO,
 | 
				
			||||||
    setUsers,
 | 
					    setUsers,
 | 
				
			||||||
    handleSubmit,
 | 
					    handleSubmit,
 | 
				
			||||||
    handleCancel,
 | 
					    handleCancel,
 | 
				
			||||||
 | 
					    setRootRole,
 | 
				
			||||||
    errors,
 | 
					    errors,
 | 
				
			||||||
    mode,
 | 
					    mode,
 | 
				
			||||||
    children,
 | 
					    children,
 | 
				
			||||||
}) => {
 | 
					}) => {
 | 
				
			||||||
    const { config: oidcSettings } = useAuthSettings('oidc');
 | 
					    const { config: oidcSettings } = useAuthSettings('oidc');
 | 
				
			||||||
    const { config: samlSettings } = useAuthSettings('saml');
 | 
					    const { config: samlSettings } = useAuthSettings('saml');
 | 
				
			||||||
 | 
					    const { uiConfig } = useUiConfig();
 | 
				
			||||||
 | 
					    const { roles } = useUsers();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const isGroupSyncingEnabled =
 | 
					    const isGroupSyncingEnabled =
 | 
				
			||||||
        (oidcSettings?.enabled && oidcSettings.enableGroupSyncing) ||
 | 
					        (oidcSettings?.enabled && oidcSettings.enableGroupSyncing) ||
 | 
				
			||||||
        (samlSettings?.enabled && samlSettings.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 (
 | 
					    return (
 | 
				
			||||||
        <StyledForm onSubmit={handleSubmit}>
 | 
					        <StyledForm onSubmit={handleSubmit}>
 | 
				
			||||||
            <div>
 | 
					            <div>
 | 
				
			||||||
@ -146,9 +192,9 @@ export const GroupForm: FC<IGroupForm> = ({
 | 
				
			|||||||
                    elseShow={() => (
 | 
					                    elseShow={() => (
 | 
				
			||||||
                        <StyledDescriptionBlock>
 | 
					                        <StyledDescriptionBlock>
 | 
				
			||||||
                            <Box sx={{ display: 'flex' }}>
 | 
					                            <Box sx={{ display: 'flex' }}>
 | 
				
			||||||
                                You can enable SSO groups syncronization if
 | 
					                                You can enable SSO groups synchronization if
 | 
				
			||||||
                                needed
 | 
					                                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>
 | 
					                            </Box>
 | 
				
			||||||
                            <Link data-loading to={`/admin/auth`}>
 | 
					                            <Link data-loading to={`/admin/auth`}>
 | 
				
			||||||
                                <span data-loading>View SSO configuration</span>
 | 
					                                <span data-loading>View SSO configuration</span>
 | 
				
			||||||
@ -156,6 +202,40 @@ export const GroupForm: FC<IGroupForm> = ({
 | 
				
			|||||||
                        </StyledDescriptionBlock>
 | 
					                        </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
 | 
					                <ConditionallyRender
 | 
				
			||||||
                    condition={mode === 'Create'}
 | 
					                    condition={mode === 'Create'}
 | 
				
			||||||
                    show={
 | 
					                    show={
 | 
				
			||||||
 | 
				
			|||||||
@ -6,6 +6,8 @@ import { GroupCardAvatars } from './GroupCardAvatars/GroupCardAvatars';
 | 
				
			|||||||
import { Badge } from 'component/common/Badge/Badge';
 | 
					import { Badge } from 'component/common/Badge/Badge';
 | 
				
			||||||
import { GroupCardActions } from './GroupCardActions/GroupCardActions';
 | 
					import { GroupCardActions } from './GroupCardActions/GroupCardActions';
 | 
				
			||||||
import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined';
 | 
					import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined';
 | 
				
			||||||
 | 
					import { IProjectRole } from 'interfaces/role';
 | 
				
			||||||
 | 
					import { IProject } from 'interfaces/project';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const StyledLink = styled(Link)(({ theme }) => ({
 | 
					const StyledLink = styled(Link)(({ theme }) => ({
 | 
				
			||||||
    textDecoration: 'none',
 | 
					    textDecoration: 'none',
 | 
				
			||||||
@ -75,14 +77,24 @@ const ProjectBadgeContainer = styled('div')(({ theme }) => ({
 | 
				
			|||||||
    flexWrap: 'wrap',
 | 
					    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 {
 | 
					interface IGroupCardProps {
 | 
				
			||||||
    group: IGroup;
 | 
					    group: IGroup;
 | 
				
			||||||
 | 
					    rootRoles: IProjectRole[];
 | 
				
			||||||
    onEditUsers: (group: IGroup) => void;
 | 
					    onEditUsers: (group: IGroup) => void;
 | 
				
			||||||
    onRemoveGroup: (group: IGroup) => void;
 | 
					    onRemoveGroup: (group: IGroup) => void;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const GroupCard = ({
 | 
					export const GroupCard = ({
 | 
				
			||||||
    group,
 | 
					    group,
 | 
				
			||||||
 | 
					    rootRoles,
 | 
				
			||||||
    onEditUsers,
 | 
					    onEditUsers,
 | 
				
			||||||
    onRemoveGroup,
 | 
					    onRemoveGroup,
 | 
				
			||||||
}: IGroupCardProps) => {
 | 
					}: IGroupCardProps) => {
 | 
				
			||||||
@ -101,6 +113,26 @@ export const GroupCard = ({
 | 
				
			|||||||
                            />
 | 
					                            />
 | 
				
			||||||
                        </StyledHeaderActions>
 | 
					                        </StyledHeaderActions>
 | 
				
			||||||
                    </StyledTitleRow>
 | 
					                    </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>
 | 
					                    <StyledDescription>{group.description}</StyledDescription>
 | 
				
			||||||
                    <StyledBottomRow>
 | 
					                    <StyledBottomRow>
 | 
				
			||||||
                        <ConditionallyRender
 | 
					                        <ConditionallyRender
 | 
				
			||||||
@ -143,7 +175,10 @@ export const GroupCard = ({
 | 
				
			|||||||
                                        arrow
 | 
					                                        arrow
 | 
				
			||||||
                                        describeChild
 | 
					                                        describeChild
 | 
				
			||||||
                                    >
 | 
					                                    >
 | 
				
			||||||
                                        <Badge>Not used</Badge>
 | 
					                                        <ConditionallyRender
 | 
				
			||||||
 | 
					                                            condition={!group.rootRole}
 | 
				
			||||||
 | 
					                                            show={<Badge>Not used</Badge>}
 | 
				
			||||||
 | 
					                                        />
 | 
				
			||||||
                                    </Tooltip>
 | 
					                                    </Tooltip>
 | 
				
			||||||
                                }
 | 
					                                }
 | 
				
			||||||
                            />
 | 
					                            />
 | 
				
			||||||
 | 
				
			|||||||
@ -18,6 +18,8 @@ import { Add } from '@mui/icons-material';
 | 
				
			|||||||
import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds';
 | 
					import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds';
 | 
				
			||||||
import { EditGroupUsers } from '../Group/EditGroupUsers/EditGroupUsers';
 | 
					import { EditGroupUsers } from '../Group/EditGroupUsers/EditGroupUsers';
 | 
				
			||||||
import { RemoveGroup } from '../RemoveGroup/RemoveGroup';
 | 
					import { RemoveGroup } from '../RemoveGroup/RemoveGroup';
 | 
				
			||||||
 | 
					import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
 | 
				
			||||||
 | 
					import { IProjectRole } from 'interfaces/role';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type PageQueryType = Partial<Record<'search', string>>;
 | 
					type PageQueryType = Partial<Record<'search', string>>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -49,6 +51,7 @@ export const GroupsList: VFC = () => {
 | 
				
			|||||||
    const [searchValue, setSearchValue] = useState(
 | 
					    const [searchValue, setSearchValue] = useState(
 | 
				
			||||||
        searchParams.get('search') || ''
 | 
					        searchParams.get('search') || ''
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					    const { roles } = useUsers();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
 | 
					    const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -82,6 +85,10 @@ export const GroupsList: VFC = () => {
 | 
				
			|||||||
        setRemoveOpen(true);
 | 
					        setRemoveOpen(true);
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const getBindableRootRoles = () => {
 | 
				
			||||||
 | 
					        return roles.filter((role: IProjectRole) => role.type === 'root');
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <PageContent
 | 
					        <PageContent
 | 
				
			||||||
            isLoading={loading}
 | 
					            isLoading={loading}
 | 
				
			||||||
@ -134,6 +141,7 @@ export const GroupsList: VFC = () => {
 | 
				
			|||||||
                        <Grid key={group.id} item xs={12} md={6}>
 | 
					                        <Grid key={group.id} item xs={12} md={6}>
 | 
				
			||||||
                            <GroupCard
 | 
					                            <GroupCard
 | 
				
			||||||
                                group={group}
 | 
					                                group={group}
 | 
				
			||||||
 | 
					                                rootRoles={getBindableRootRoles()}
 | 
				
			||||||
                                onEditUsers={onEditUsers}
 | 
					                                onEditUsers={onEditUsers}
 | 
				
			||||||
                                onRemoveGroup={onRemoveGroup}
 | 
					                                onRemoveGroup={onRemoveGroup}
 | 
				
			||||||
                            />
 | 
					                            />
 | 
				
			||||||
 | 
				
			|||||||
@ -6,7 +6,8 @@ export const useGroupForm = (
 | 
				
			|||||||
    initialName = '',
 | 
					    initialName = '',
 | 
				
			||||||
    initialDescription = '',
 | 
					    initialDescription = '',
 | 
				
			||||||
    initialMappingsSSO: string[] = [],
 | 
					    initialMappingsSSO: string[] = [],
 | 
				
			||||||
    initialUsers: IGroupUser[] = []
 | 
					    initialUsers: IGroupUser[] = [],
 | 
				
			||||||
 | 
					    initialRootRole: number | null = null
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
    const params = useQueryParams();
 | 
					    const params = useQueryParams();
 | 
				
			||||||
    const groupQueryName = params.get('name');
 | 
					    const groupQueryName = params.get('name');
 | 
				
			||||||
@ -14,6 +15,7 @@ export const useGroupForm = (
 | 
				
			|||||||
    const [description, setDescription] = useState(initialDescription);
 | 
					    const [description, setDescription] = useState(initialDescription);
 | 
				
			||||||
    const [mappingsSSO, setMappingsSSO] = useState(initialMappingsSSO);
 | 
					    const [mappingsSSO, setMappingsSSO] = useState(initialMappingsSSO);
 | 
				
			||||||
    const [users, setUsers] = useState<IGroupUser[]>(initialUsers);
 | 
					    const [users, setUsers] = useState<IGroupUser[]>(initialUsers);
 | 
				
			||||||
 | 
					    const [rootRole, setRootRole] = useState<number | null>(initialRootRole);
 | 
				
			||||||
    const [errors, setErrors] = useState({});
 | 
					    const [errors, setErrors] = useState({});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const getGroupPayload = () => {
 | 
					    const getGroupPayload = () => {
 | 
				
			||||||
@ -24,6 +26,7 @@ export const useGroupForm = (
 | 
				
			|||||||
            users: users.map(({ id }) => ({
 | 
					            users: users.map(({ id }) => ({
 | 
				
			||||||
                user: { id },
 | 
					                user: { id },
 | 
				
			||||||
            })),
 | 
					            })),
 | 
				
			||||||
 | 
					            rootRole: rootRole || undefined,
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -44,5 +47,7 @@ export const useGroupForm = (
 | 
				
			|||||||
        clearErrors,
 | 
					        clearErrors,
 | 
				
			||||||
        errors,
 | 
					        errors,
 | 
				
			||||||
        setErrors,
 | 
					        setErrors,
 | 
				
			||||||
 | 
					        rootRole,
 | 
				
			||||||
 | 
					        setRootRole,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -6,6 +6,7 @@ import {
 | 
				
			|||||||
    Checkbox,
 | 
					    Checkbox,
 | 
				
			||||||
    styled,
 | 
					    styled,
 | 
				
			||||||
    TextField,
 | 
					    TextField,
 | 
				
			||||||
 | 
					    Tooltip,
 | 
				
			||||||
} from '@mui/material';
 | 
					} from '@mui/material';
 | 
				
			||||||
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
 | 
					import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
 | 
				
			||||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
 | 
					import CheckBoxIcon from '@mui/icons-material/CheckBox';
 | 
				
			||||||
@ -255,6 +256,12 @@ export const ProjectAccessAssign = ({
 | 
				
			|||||||
        --data-raw '${JSON.stringify(payload, undefined, 2)}'`;
 | 
					        --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 = (
 | 
					    const renderOption = (
 | 
				
			||||||
        props: React.HTMLAttributes<HTMLLIElement>,
 | 
					        props: React.HTMLAttributes<HTMLLIElement>,
 | 
				
			||||||
        option: IAccessOption,
 | 
					        option: IAccessOption,
 | 
				
			||||||
@ -268,35 +275,45 @@ export const ProjectAccessAssign = ({
 | 
				
			|||||||
            optionUser = option.entity as IUser;
 | 
					            optionUser = option.entity as IUser;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        return (
 | 
					        return (
 | 
				
			||||||
            <li {...props}>
 | 
					            <Tooltip title={createRootGroupWarning(optionGroup)}>
 | 
				
			||||||
                <Checkbox
 | 
					                <span>
 | 
				
			||||||
                    icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
 | 
					                    <li {...props}>
 | 
				
			||||||
                    checkedIcon={<CheckBoxIcon fontSize="small" />}
 | 
					                        <Checkbox
 | 
				
			||||||
                    style={{ marginRight: 8 }}
 | 
					                            icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
 | 
				
			||||||
                    checked={selected}
 | 
					                            checkedIcon={<CheckBoxIcon fontSize="small" />}
 | 
				
			||||||
                />
 | 
					                            style={{ marginRight: 8 }}
 | 
				
			||||||
                <ConditionallyRender
 | 
					                            checked={selected}
 | 
				
			||||||
                    condition={option.type === ENTITY_TYPE.GROUP}
 | 
					                        />
 | 
				
			||||||
                    show={
 | 
					                        <ConditionallyRender
 | 
				
			||||||
                        <StyledGroupOption>
 | 
					                            condition={option.type === ENTITY_TYPE.GROUP}
 | 
				
			||||||
                            <span>{optionGroup?.name}</span>
 | 
					                            show={
 | 
				
			||||||
                            <span>{optionGroup?.userCount} users</span>
 | 
					                                <span>
 | 
				
			||||||
                        </StyledGroupOption>
 | 
					                                    <StyledGroupOption>
 | 
				
			||||||
                    }
 | 
					                                        <span>{optionGroup?.name}</span>
 | 
				
			||||||
                    elseShow={
 | 
					                                        <span>
 | 
				
			||||||
                        <StyledUserOption>
 | 
					                                            {optionGroup?.userCount} users
 | 
				
			||||||
                            <span>
 | 
					                                        </span>
 | 
				
			||||||
                                {optionUser?.name || optionUser?.username}
 | 
					                                    </StyledGroupOption>
 | 
				
			||||||
                            </span>
 | 
					                                </span>
 | 
				
			||||||
                            <span>
 | 
					                            }
 | 
				
			||||||
                                {optionUser?.name && optionUser?.username
 | 
					                            elseShow={
 | 
				
			||||||
                                    ? optionUser?.username
 | 
					                                <StyledUserOption>
 | 
				
			||||||
                                    : optionUser?.email}
 | 
					                                    <span>
 | 
				
			||||||
                            </span>
 | 
					                                        {optionUser?.name ||
 | 
				
			||||||
                        </StyledUserOption>
 | 
					                                            optionUser?.username}
 | 
				
			||||||
                    }
 | 
					                                    </span>
 | 
				
			||||||
                />
 | 
					                                    <span>
 | 
				
			||||||
            </li>
 | 
					                                        {optionUser?.name &&
 | 
				
			||||||
 | 
					                                        optionUser?.username
 | 
				
			||||||
 | 
					                                            ? optionUser?.username
 | 
				
			||||||
 | 
					                                            : optionUser?.email}
 | 
				
			||||||
 | 
					                                    </span>
 | 
				
			||||||
 | 
					                                </StyledUserOption>
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                        />
 | 
				
			||||||
 | 
					                    </li>
 | 
				
			||||||
 | 
					                </span>
 | 
				
			||||||
 | 
					            </Tooltip>
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -346,6 +363,14 @@ export const ProjectAccessAssign = ({
 | 
				
			|||||||
                                disableCloseOnSelect
 | 
					                                disableCloseOnSelect
 | 
				
			||||||
                                disabled={edit}
 | 
					                                disabled={edit}
 | 
				
			||||||
                                value={selectedOptions}
 | 
					                                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) => {
 | 
					                                onChange={(event, newValue, reason) => {
 | 
				
			||||||
                                    if (
 | 
					                                    if (
 | 
				
			||||||
                                        event.type === 'keydown' &&
 | 
					                                        event.type === 'keydown' &&
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,7 @@ export interface IGroup {
 | 
				
			|||||||
    addedAt?: string;
 | 
					    addedAt?: string;
 | 
				
			||||||
    userCount?: number;
 | 
					    userCount?: number;
 | 
				
			||||||
    mappingsSSO: string[];
 | 
					    mappingsSSO: string[];
 | 
				
			||||||
 | 
					    rootRole?: number;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface IGroupUser extends IUser {
 | 
					export interface IGroupUser extends IUser {
 | 
				
			||||||
 | 
				
			|||||||
@ -52,6 +52,8 @@ export interface IFlags {
 | 
				
			|||||||
    projectScopedStickiness?: boolean;
 | 
					    projectScopedStickiness?: boolean;
 | 
				
			||||||
    personalAccessTokensKillSwitch?: boolean;
 | 
					    personalAccessTokensKillSwitch?: boolean;
 | 
				
			||||||
    demo?: boolean;
 | 
					    demo?: boolean;
 | 
				
			||||||
 | 
					    strategyTitle?: boolean;
 | 
				
			||||||
 | 
					    groupRootRoles?: boolean;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface IVersionInfo {
 | 
					export interface IVersionInfo {
 | 
				
			||||||
 | 
				
			|||||||
@ -77,6 +77,7 @@ exports[`should create default config 1`] = `
 | 
				
			|||||||
      "embedProxy": true,
 | 
					      "embedProxy": true,
 | 
				
			||||||
      "embedProxyFrontend": true,
 | 
					      "embedProxyFrontend": true,
 | 
				
			||||||
      "featuresExportImport": true,
 | 
					      "featuresExportImport": true,
 | 
				
			||||||
 | 
					      "groupRootRoles": false,
 | 
				
			||||||
      "loginHistory": false,
 | 
					      "loginHistory": false,
 | 
				
			||||||
      "maintenanceMode": false,
 | 
					      "maintenanceMode": false,
 | 
				
			||||||
      "messageBanner": false,
 | 
					      "messageBanner": false,
 | 
				
			||||||
@ -104,6 +105,7 @@ exports[`should create default config 1`] = `
 | 
				
			|||||||
      "embedProxy": true,
 | 
					      "embedProxy": true,
 | 
				
			||||||
      "embedProxyFrontend": true,
 | 
					      "embedProxyFrontend": true,
 | 
				
			||||||
      "featuresExportImport": true,
 | 
					      "featuresExportImport": true,
 | 
				
			||||||
 | 
					      "groupRootRoles": false,
 | 
				
			||||||
      "loginHistory": false,
 | 
					      "loginHistory": false,
 | 
				
			||||||
      "maintenanceMode": false,
 | 
					      "maintenanceMode": false,
 | 
				
			||||||
      "messageBanner": 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.GROUP_ROLE} AS gr`, 'gu.group_id', 'gr.group_id')
 | 
				
			||||||
                .join(`${T.ROLE_PERMISSION} AS rp`, 'rp.role_id', 'gr.role_id')
 | 
					                .join(`${T.ROLE_PERMISSION} AS rp`, 'rp.role_id', 'gr.role_id')
 | 
				
			||||||
                .join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_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;
 | 
					        const rows = await userPermissionQuery;
 | 
				
			||||||
        stopTimer();
 | 
					        stopTimer();
 | 
				
			||||||
        return rows.map(this.mapUserPermission);
 | 
					        return rows.map(this.mapUserPermission);
 | 
				
			||||||
 | 
				
			|||||||
@ -28,6 +28,7 @@ const GROUP_COLUMNS = [
 | 
				
			|||||||
    'mappings_sso',
 | 
					    'mappings_sso',
 | 
				
			||||||
    'created_at',
 | 
					    'created_at',
 | 
				
			||||||
    'created_by',
 | 
					    'created_by',
 | 
				
			||||||
 | 
					    'root_role_id',
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const rowToGroup = (row) => {
 | 
					const rowToGroup = (row) => {
 | 
				
			||||||
@ -41,6 +42,7 @@ const rowToGroup = (row) => {
 | 
				
			|||||||
        mappingsSSO: row.mappings_sso,
 | 
					        mappingsSSO: row.mappings_sso,
 | 
				
			||||||
        createdAt: row.created_at,
 | 
					        createdAt: row.created_at,
 | 
				
			||||||
        createdBy: row.created_by,
 | 
					        createdBy: row.created_by,
 | 
				
			||||||
 | 
					        rootRole: row.root_role_id,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -53,6 +55,7 @@ const rowToGroupUser = (row) => {
 | 
				
			|||||||
        groupId: row.group_id,
 | 
					        groupId: row.group_id,
 | 
				
			||||||
        joinedAt: row.created_at,
 | 
					        joinedAt: row.created_at,
 | 
				
			||||||
        createdBy: row.created_by,
 | 
					        createdBy: row.created_by,
 | 
				
			||||||
 | 
					        rootRoleId: row.root_role_id,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -60,6 +63,7 @@ const groupToRow = (group: IStoreGroup) => ({
 | 
				
			|||||||
    name: group.name,
 | 
					    name: group.name,
 | 
				
			||||||
    description: group.description,
 | 
					    description: group.description,
 | 
				
			||||||
    mappings_sso: JSON.stringify(group.mappingsSSO),
 | 
					    mappings_sso: JSON.stringify(group.mappingsSSO),
 | 
				
			||||||
 | 
					    root_role_id: group.rootRole || null,
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class GroupStore implements IGroupStore {
 | 
					export default class GroupStore implements IGroupStore {
 | 
				
			||||||
@ -124,9 +128,11 @@ export default class GroupStore implements IGroupStore {
 | 
				
			|||||||
                'u.id as user_id',
 | 
					                'u.id as user_id',
 | 
				
			||||||
                'gu.created_at',
 | 
					                'gu.created_at',
 | 
				
			||||||
                'gu.created_by',
 | 
					                'gu.created_by',
 | 
				
			||||||
 | 
					                'g.root_role_id',
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            .from(`${T.GROUP_USER} AS gu`)
 | 
					            .from(`${T.GROUP_USER} AS gu`)
 | 
				
			||||||
            .join(`${T.USERS} AS u`, 'u.id', 'gu.user_id')
 | 
					            .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);
 | 
					            .whereIn('gu.group_id', groupIds);
 | 
				
			||||||
        return rows.map(rowToGroupUser);
 | 
					        return rows.map(rowToGroupUser);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -24,6 +24,12 @@ export const groupSchema = {
 | 
				
			|||||||
                type: 'string',
 | 
					                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: {
 | 
					        createdBy: {
 | 
				
			||||||
            type: 'string',
 | 
					            type: 'string',
 | 
				
			||||||
            nullable: true,
 | 
					            nullable: true,
 | 
				
			||||||
 | 
				
			|||||||
@ -341,6 +341,7 @@ export default class UserAdminController extends Controller {
 | 
				
			|||||||
                id: g.id,
 | 
					                id: g.id,
 | 
				
			||||||
                name: g.name,
 | 
					                name: g.name,
 | 
				
			||||||
                userCount: g.users.length,
 | 
					                userCount: g.users.length,
 | 
				
			||||||
 | 
					                rootRole: g.rootRole,
 | 
				
			||||||
            } as IGroup;
 | 
					            } as IGroup;
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
        this.openApiService.respondWithValidation(
 | 
					        this.openApiService.respondWithValidation(
 | 
				
			||||||
 | 
				
			|||||||
@ -76,6 +76,10 @@ const flags = {
 | 
				
			|||||||
        process.env.UNLEASH_EXPERIMENTAL_OPTIMAL_304_DIFFER,
 | 
					        process.env.UNLEASH_EXPERIMENTAL_OPTIMAL_304_DIFFER,
 | 
				
			||||||
        false,
 | 
					        false,
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
 | 
					    groupRootRoles: parseEnvVarBoolean(
 | 
				
			||||||
 | 
					        process.env.UNLEASH_EXPERIMENTAL_ROOT_ROLE_GROUPS,
 | 
				
			||||||
 | 
					        false,
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
    migrationLock: parseEnvVarBoolean(process.env.MIGRATION_LOCK, false),
 | 
					    migrationLock: parseEnvVarBoolean(process.env.MIGRATION_LOCK, false),
 | 
				
			||||||
    demo: parseEnvVarBoolean(process.env.UNLEASH_DEMO, false),
 | 
					    demo: parseEnvVarBoolean(process.env.UNLEASH_DEMO, false),
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -6,6 +6,7 @@ export interface IGroup {
 | 
				
			|||||||
    name: string;
 | 
					    name: string;
 | 
				
			||||||
    description?: string;
 | 
					    description?: string;
 | 
				
			||||||
    mappingsSSO?: string[];
 | 
					    mappingsSSO?: string[];
 | 
				
			||||||
 | 
					    rootRole?: number;
 | 
				
			||||||
    createdAt?: Date;
 | 
					    createdAt?: Date;
 | 
				
			||||||
    userCount?: number;
 | 
					    userCount?: number;
 | 
				
			||||||
    createdBy?: string;
 | 
					    createdBy?: string;
 | 
				
			||||||
@ -15,6 +16,7 @@ export interface IGroupUser {
 | 
				
			|||||||
    groupId: number;
 | 
					    groupId: number;
 | 
				
			||||||
    userId: number;
 | 
					    userId: number;
 | 
				
			||||||
    joinedAt: Date;
 | 
					    joinedAt: Date;
 | 
				
			||||||
 | 
					    rootRoleId?: number;
 | 
				
			||||||
    seenAt?: Date;
 | 
					    seenAt?: Date;
 | 
				
			||||||
    createdBy?: string;
 | 
					    createdBy?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -58,6 +60,8 @@ export default class Group implements IGroup {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    name: string;
 | 
					    name: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    rootRole?: number;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    description: string;
 | 
					    description: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    mappingsSSO: string[];
 | 
					    mappingsSSO: string[];
 | 
				
			||||||
@ -67,6 +71,7 @@ export default class Group implements IGroup {
 | 
				
			|||||||
        name,
 | 
					        name,
 | 
				
			||||||
        description,
 | 
					        description,
 | 
				
			||||||
        mappingsSSO,
 | 
					        mappingsSSO,
 | 
				
			||||||
 | 
					        rootRole,
 | 
				
			||||||
        createdBy,
 | 
					        createdBy,
 | 
				
			||||||
        createdAt,
 | 
					        createdAt,
 | 
				
			||||||
    }: IGroup) {
 | 
					    }: IGroup) {
 | 
				
			||||||
@ -78,6 +83,7 @@ export default class Group implements IGroup {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        this.id = id;
 | 
					        this.id = id;
 | 
				
			||||||
        this.name = name;
 | 
					        this.name = name;
 | 
				
			||||||
 | 
					        this.rootRole = rootRole;
 | 
				
			||||||
        this.description = description;
 | 
					        this.description = description;
 | 
				
			||||||
        this.mappingsSSO = mappingsSSO;
 | 
					        this.mappingsSSO = mappingsSSO;
 | 
				
			||||||
        this.createdBy = createdBy;
 | 
					        this.createdBy = createdBy;
 | 
				
			||||||
 | 
				
			|||||||
@ -12,6 +12,7 @@ export interface IStoreGroup {
 | 
				
			|||||||
    name: string;
 | 
					    name: string;
 | 
				
			||||||
    description?: string;
 | 
					    description?: string;
 | 
				
			||||||
    mappingsSSO?: string[];
 | 
					    mappingsSSO?: string[];
 | 
				
			||||||
 | 
					    rootRole?: number;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface IGroupStore extends Store<IGroup, 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",
 | 
					            "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": {
 | 
					          "users": {
 | 
				
			||||||
            "items": {
 | 
					            "items": {
 | 
				
			||||||
              "$ref": "#/components/schemas/groupUserModelSchema",
 | 
					              "$ref": "#/components/schemas/groupUserModelSchema",
 | 
				
			||||||
 | 
				
			|||||||
@ -44,6 +44,13 @@ const createUserViewerAccess = async (name, email) => {
 | 
				
			|||||||
    return user;
 | 
					    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 hasCommonProjectAccess = async (user, projectName, condition) => {
 | 
				
			||||||
    const defaultEnv = 'default';
 | 
					    const defaultEnv = 'default';
 | 
				
			||||||
    const developmentEnv = 'development';
 | 
					    const developmentEnv = 'development';
 | 
				
			||||||
@ -1062,3 +1069,181 @@ test('Should allow user to take multiple group roles and have expected permissio
 | 
				
			|||||||
        ),
 | 
					        ),
 | 
				
			||||||
    ).toBe(true);
 | 
					    ).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