1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-27 01:19:00 +02:00

feat: root roles from groups (#3559)

feat: adds a way to specify a root role on a group, which will cause any user entering into that group to take on the permissions of that root role

Co-authored-by: Nuno Góis <github@nunogois.com>
This commit is contained in:
Simon Hornby 2023-04-20 12:29:30 +02:00 committed by GitHub
parent 163791a094
commit 3b42e866ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 464 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -53,6 +53,7 @@ export interface IFlags {
personalAccessTokensKillSwitch?: boolean; personalAccessTokensKillSwitch?: boolean;
demo?: boolean; demo?: boolean;
strategyTitle?: boolean; strategyTitle?: boolean;
groupRootRoles?: boolean;
} }
export interface IVersionInfo { export interface IVersionInfo {

View File

@ -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,
@ -105,6 +106,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,

View File

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

View File

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

View File

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

View File

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

View File

@ -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),
strategyTitle: parseEnvVarBoolean( strategyTitle: parseEnvVarBoolean(

View File

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

View File

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

View 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,
);
};

View File

@ -2357,6 +2357,11 @@ The provider you choose for your addon dictates what properties the \`parameters
}, },
"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",

View File

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