mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01: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:
parent
163791a094
commit
3b42e866ec
@ -26,6 +26,8 @@ export const CreateGroup = () => {
|
||||
setMappingsSSO,
|
||||
users,
|
||||
setUsers,
|
||||
rootRole,
|
||||
setRootRole,
|
||||
getGroupPayload,
|
||||
clearErrors,
|
||||
errors,
|
||||
@ -95,10 +97,12 @@ export const CreateGroup = () => {
|
||||
name={name}
|
||||
description={description}
|
||||
mappingsSSO={mappingsSSO}
|
||||
rootRole={rootRole}
|
||||
users={users}
|
||||
setName={onSetName}
|
||||
setDescription={setDescription}
|
||||
setMappingsSSO={setMappingsSSO}
|
||||
setRootRole={setRootRole}
|
||||
setUsers={setUsers}
|
||||
errors={errors}
|
||||
handleSubmit={handleSubmit}
|
||||
|
@ -55,6 +55,8 @@ export const EditGroup = ({
|
||||
setMappingsSSO,
|
||||
users,
|
||||
setUsers,
|
||||
rootRole,
|
||||
setRootRole,
|
||||
getGroupPayload,
|
||||
clearErrors,
|
||||
errors,
|
||||
@ -63,7 +65,8 @@ export const EditGroup = ({
|
||||
group?.name,
|
||||
group?.description,
|
||||
group?.mappingsSSO,
|
||||
group?.users
|
||||
group?.users,
|
||||
group?.rootRole
|
||||
);
|
||||
|
||||
const { groups } = useGroups();
|
||||
@ -129,10 +132,12 @@ export const EditGroup = ({
|
||||
description={description}
|
||||
mappingsSSO={mappingsSSO}
|
||||
users={users}
|
||||
rootRole={rootRole}
|
||||
setName={onSetName}
|
||||
setDescription={setDescription}
|
||||
setMappingsSSO={setMappingsSSO}
|
||||
setUsers={setUsers}
|
||||
setRootRole={setRootRole}
|
||||
errors={errors}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={handleCancel}
|
||||
|
@ -60,7 +60,8 @@ export const EditGroupUsers: FC<IEditGroupUsersProps> = ({
|
||||
group.name,
|
||||
group.description,
|
||||
group.mappingsSSO,
|
||||
group.users
|
||||
group.users,
|
||||
group.rootRole
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Box, Button, styled } from '@mui/material';
|
||||
import { Autocomplete, Box, Button, styled, TextField } from '@mui/material';
|
||||
import { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds';
|
||||
import Input from 'component/common/Input/Input';
|
||||
import { IGroupUser } from 'interfaces/group';
|
||||
@ -10,6 +10,9 @@ import { ItemList } from 'component/common/ItemList/ItemList';
|
||||
import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
|
||||
import { IProjectRole } from 'interfaces/role';
|
||||
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
|
||||
const StyledForm = styled('form')(() => ({
|
||||
display: 'flex',
|
||||
@ -63,15 +66,34 @@ const StyledDescriptionBlock = styled('div')(({ theme }) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledAutocompleteWrapper = styled('div')(({ theme }) => ({
|
||||
'& > div:first-of-type': {
|
||||
width: '100%',
|
||||
maxWidth: theme.spacing(50),
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledRoleOption = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& > span:last-of-type': {
|
||||
fontSize: theme.fontSizes.smallerBody,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}));
|
||||
|
||||
interface IGroupForm {
|
||||
name: string;
|
||||
description: string;
|
||||
mappingsSSO: string[];
|
||||
users: IGroupUser[];
|
||||
rootRole: number | null;
|
||||
setName: (name: string) => void;
|
||||
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
||||
setMappingsSSO: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>;
|
||||
setRootRole: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
handleSubmit: (e: any) => void;
|
||||
handleCancel: () => void;
|
||||
errors: { [key: string]: string };
|
||||
@ -83,23 +105,47 @@ export const GroupForm: FC<IGroupForm> = ({
|
||||
description,
|
||||
mappingsSSO,
|
||||
users,
|
||||
rootRole,
|
||||
setName,
|
||||
setDescription,
|
||||
setMappingsSSO,
|
||||
setUsers,
|
||||
handleSubmit,
|
||||
handleCancel,
|
||||
setRootRole,
|
||||
errors,
|
||||
mode,
|
||||
children,
|
||||
}) => {
|
||||
const { config: oidcSettings } = useAuthSettings('oidc');
|
||||
const { config: samlSettings } = useAuthSettings('saml');
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { roles } = useUsers();
|
||||
|
||||
const isGroupSyncingEnabled =
|
||||
(oidcSettings?.enabled && oidcSettings.enableGroupSyncing) ||
|
||||
(samlSettings?.enabled && samlSettings.enableGroupSyncing);
|
||||
|
||||
const groupRootRolesEnabled = Boolean(uiConfig.flags.groupRootRoles);
|
||||
|
||||
const roleIdToRole = (rootRoleId: number | null): IProjectRole | null => {
|
||||
return (
|
||||
roles.find((role: IProjectRole) => role.id === rootRoleId) || null
|
||||
);
|
||||
};
|
||||
|
||||
const renderRoleOption = (
|
||||
props: React.HTMLAttributes<HTMLLIElement>,
|
||||
option: IProjectRole
|
||||
) => (
|
||||
<li {...props}>
|
||||
<StyledRoleOption>
|
||||
<span>{option.name}</span>
|
||||
<span>{option.description}</span>
|
||||
</StyledRoleOption>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
<div>
|
||||
@ -146,9 +192,9 @@ export const GroupForm: FC<IGroupForm> = ({
|
||||
elseShow={() => (
|
||||
<StyledDescriptionBlock>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
You can enable SSO groups syncronization if
|
||||
You can enable SSO groups synchronization if
|
||||
needed
|
||||
<HelpIcon tooltip="SSO groups syncronization allows SSO groups to be mapped to Unleash groups, so that user group membership is properly synchronized." />
|
||||
<HelpIcon tooltip="SSO groups synchronization allows SSO groups to be mapped to Unleash groups, so that user group membership is properly synchronized." />
|
||||
</Box>
|
||||
<Link data-loading to={`/admin/auth`}>
|
||||
<span data-loading>View SSO configuration</span>
|
||||
@ -156,6 +202,40 @@ export const GroupForm: FC<IGroupForm> = ({
|
||||
</StyledDescriptionBlock>
|
||||
)}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={groupRootRolesEnabled}
|
||||
show={
|
||||
<>
|
||||
<StyledInputDescription>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
Do you want to associate a root role with
|
||||
this group?
|
||||
<HelpIcon tooltip="When you associate an Admin or Editor role with this group, users in this group will automatically inherit the role globally. Note that groups with a root role association cannot be assigned to projects." />
|
||||
</Box>
|
||||
</StyledInputDescription>
|
||||
<StyledAutocompleteWrapper>
|
||||
<Autocomplete
|
||||
data-testid="GROUP_ROOT_ROLE"
|
||||
size="small"
|
||||
openOnFocus
|
||||
value={roleIdToRole(rootRole)}
|
||||
onChange={(_, newValue) =>
|
||||
setRootRole(newValue?.id || null)
|
||||
}
|
||||
options={roles.filter(
|
||||
(role: IProjectRole) =>
|
||||
role.name !== 'Viewer'
|
||||
)}
|
||||
renderOption={renderRoleOption}
|
||||
getOptionLabel={option => option.name}
|
||||
renderInput={params => (
|
||||
<TextField {...params} label="Role" />
|
||||
)}
|
||||
/>
|
||||
</StyledAutocompleteWrapper>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={mode === 'Create'}
|
||||
show={
|
||||
|
@ -6,6 +6,8 @@ import { GroupCardAvatars } from './GroupCardAvatars/GroupCardAvatars';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
import { GroupCardActions } from './GroupCardActions/GroupCardActions';
|
||||
import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined';
|
||||
import { IProjectRole } from 'interfaces/role';
|
||||
import { IProject } from 'interfaces/project';
|
||||
|
||||
const StyledLink = styled(Link)(({ theme }) => ({
|
||||
textDecoration: 'none',
|
||||
@ -75,14 +77,24 @@ const ProjectBadgeContainer = styled('div')(({ theme }) => ({
|
||||
flexWrap: 'wrap',
|
||||
}));
|
||||
|
||||
const InfoBadgeDescription = styled('span')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
color: theme.palette.text.secondary,
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
}));
|
||||
|
||||
interface IGroupCardProps {
|
||||
group: IGroup;
|
||||
rootRoles: IProjectRole[];
|
||||
onEditUsers: (group: IGroup) => void;
|
||||
onRemoveGroup: (group: IGroup) => void;
|
||||
}
|
||||
|
||||
export const GroupCard = ({
|
||||
group,
|
||||
rootRoles,
|
||||
onEditUsers,
|
||||
onRemoveGroup,
|
||||
}: IGroupCardProps) => {
|
||||
@ -101,6 +113,26 @@ export const GroupCard = ({
|
||||
/>
|
||||
</StyledHeaderActions>
|
||||
</StyledTitleRow>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(group.rootRole)}
|
||||
show={
|
||||
<InfoBadgeDescription>
|
||||
<p>Root role:</p>
|
||||
<Badge
|
||||
color="success"
|
||||
icon={<TopicOutlinedIcon />}
|
||||
>
|
||||
{
|
||||
rootRoles.find(
|
||||
(role: IProjectRole) =>
|
||||
role.id === group.rootRole
|
||||
)?.name
|
||||
}
|
||||
</Badge>
|
||||
</InfoBadgeDescription>
|
||||
}
|
||||
/>
|
||||
|
||||
<StyledDescription>{group.description}</StyledDescription>
|
||||
<StyledBottomRow>
|
||||
<ConditionallyRender
|
||||
@ -143,7 +175,10 @@ export const GroupCard = ({
|
||||
arrow
|
||||
describeChild
|
||||
>
|
||||
<Badge>Not used</Badge>
|
||||
<ConditionallyRender
|
||||
condition={!group.rootRole}
|
||||
show={<Badge>Not used</Badge>}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
|
@ -18,6 +18,8 @@ import { Add } from '@mui/icons-material';
|
||||
import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds';
|
||||
import { EditGroupUsers } from '../Group/EditGroupUsers/EditGroupUsers';
|
||||
import { RemoveGroup } from '../RemoveGroup/RemoveGroup';
|
||||
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
|
||||
import { IProjectRole } from 'interfaces/role';
|
||||
|
||||
type PageQueryType = Partial<Record<'search', string>>;
|
||||
|
||||
@ -49,6 +51,7 @@ export const GroupsList: VFC = () => {
|
||||
const [searchValue, setSearchValue] = useState(
|
||||
searchParams.get('search') || ''
|
||||
);
|
||||
const { roles } = useUsers();
|
||||
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
@ -82,6 +85,10 @@ export const GroupsList: VFC = () => {
|
||||
setRemoveOpen(true);
|
||||
};
|
||||
|
||||
const getBindableRootRoles = () => {
|
||||
return roles.filter((role: IProjectRole) => role.type === 'root');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
@ -134,6 +141,7 @@ export const GroupsList: VFC = () => {
|
||||
<Grid key={group.id} item xs={12} md={6}>
|
||||
<GroupCard
|
||||
group={group}
|
||||
rootRoles={getBindableRootRoles()}
|
||||
onEditUsers={onEditUsers}
|
||||
onRemoveGroup={onRemoveGroup}
|
||||
/>
|
||||
|
@ -6,7 +6,8 @@ export const useGroupForm = (
|
||||
initialName = '',
|
||||
initialDescription = '',
|
||||
initialMappingsSSO: string[] = [],
|
||||
initialUsers: IGroupUser[] = []
|
||||
initialUsers: IGroupUser[] = [],
|
||||
initialRootRole: number | null = null
|
||||
) => {
|
||||
const params = useQueryParams();
|
||||
const groupQueryName = params.get('name');
|
||||
@ -14,6 +15,7 @@ export const useGroupForm = (
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const [mappingsSSO, setMappingsSSO] = useState(initialMappingsSSO);
|
||||
const [users, setUsers] = useState<IGroupUser[]>(initialUsers);
|
||||
const [rootRole, setRootRole] = useState<number | null>(initialRootRole);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const getGroupPayload = () => {
|
||||
@ -24,6 +26,7 @@ export const useGroupForm = (
|
||||
users: users.map(({ id }) => ({
|
||||
user: { id },
|
||||
})),
|
||||
rootRole: rootRole || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@ -44,5 +47,7 @@ export const useGroupForm = (
|
||||
clearErrors,
|
||||
errors,
|
||||
setErrors,
|
||||
rootRole,
|
||||
setRootRole,
|
||||
};
|
||||
};
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
Checkbox,
|
||||
styled,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||
@ -255,6 +256,12 @@ export const ProjectAccessAssign = ({
|
||||
--data-raw '${JSON.stringify(payload, undefined, 2)}'`;
|
||||
};
|
||||
|
||||
const createRootGroupWarning = (group?: IGroup): string | undefined => {
|
||||
if (group && Boolean(group.rootRole)) {
|
||||
return 'This group has an Admin or Editor role associated with it. Groups with a root role association cannot be assigned to projects, and users in this group already have the role applied globally.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderOption = (
|
||||
props: React.HTMLAttributes<HTMLLIElement>,
|
||||
option: IAccessOption,
|
||||
@ -268,35 +275,45 @@ export const ProjectAccessAssign = ({
|
||||
optionUser = option.entity as IUser;
|
||||
}
|
||||
return (
|
||||
<li {...props}>
|
||||
<Checkbox
|
||||
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
|
||||
checkedIcon={<CheckBoxIcon fontSize="small" />}
|
||||
style={{ marginRight: 8 }}
|
||||
checked={selected}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={option.type === ENTITY_TYPE.GROUP}
|
||||
show={
|
||||
<StyledGroupOption>
|
||||
<span>{optionGroup?.name}</span>
|
||||
<span>{optionGroup?.userCount} users</span>
|
||||
</StyledGroupOption>
|
||||
}
|
||||
elseShow={
|
||||
<StyledUserOption>
|
||||
<span>
|
||||
{optionUser?.name || optionUser?.username}
|
||||
</span>
|
||||
<span>
|
||||
{optionUser?.name && optionUser?.username
|
||||
? optionUser?.username
|
||||
: optionUser?.email}
|
||||
</span>
|
||||
</StyledUserOption>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
<Tooltip title={createRootGroupWarning(optionGroup)}>
|
||||
<span>
|
||||
<li {...props}>
|
||||
<Checkbox
|
||||
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
|
||||
checkedIcon={<CheckBoxIcon fontSize="small" />}
|
||||
style={{ marginRight: 8 }}
|
||||
checked={selected}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={option.type === ENTITY_TYPE.GROUP}
|
||||
show={
|
||||
<span>
|
||||
<StyledGroupOption>
|
||||
<span>{optionGroup?.name}</span>
|
||||
<span>
|
||||
{optionGroup?.userCount} users
|
||||
</span>
|
||||
</StyledGroupOption>
|
||||
</span>
|
||||
}
|
||||
elseShow={
|
||||
<StyledUserOption>
|
||||
<span>
|
||||
{optionUser?.name ||
|
||||
optionUser?.username}
|
||||
</span>
|
||||
<span>
|
||||
{optionUser?.name &&
|
||||
optionUser?.username
|
||||
? optionUser?.username
|
||||
: optionUser?.email}
|
||||
</span>
|
||||
</StyledUserOption>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@ -346,6 +363,14 @@ export const ProjectAccessAssign = ({
|
||||
disableCloseOnSelect
|
||||
disabled={edit}
|
||||
value={selectedOptions}
|
||||
getOptionDisabled={option => {
|
||||
if (option.type === ENTITY_TYPE.GROUP) {
|
||||
const optionGroup =
|
||||
option.entity as IGroup;
|
||||
return Boolean(optionGroup.rootRole);
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
onChange={(event, newValue, reason) => {
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
|
@ -10,6 +10,7 @@ export interface IGroup {
|
||||
addedAt?: string;
|
||||
userCount?: number;
|
||||
mappingsSSO: string[];
|
||||
rootRole?: number;
|
||||
}
|
||||
|
||||
export interface IGroupUser extends IUser {
|
||||
|
@ -53,6 +53,7 @@ export interface IFlags {
|
||||
personalAccessTokensKillSwitch?: boolean;
|
||||
demo?: boolean;
|
||||
strategyTitle?: boolean;
|
||||
groupRootRoles?: boolean;
|
||||
}
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -77,6 +77,7 @@ exports[`should create default config 1`] = `
|
||||
"embedProxy": true,
|
||||
"embedProxyFrontend": true,
|
||||
"featuresExportImport": true,
|
||||
"groupRootRoles": false,
|
||||
"loginHistory": false,
|
||||
"maintenanceMode": false,
|
||||
"messageBanner": false,
|
||||
@ -105,6 +106,7 @@ exports[`should create default config 1`] = `
|
||||
"embedProxy": true,
|
||||
"embedProxyFrontend": true,
|
||||
"featuresExportImport": true,
|
||||
"groupRootRoles": false,
|
||||
"loginHistory": false,
|
||||
"maintenanceMode": false,
|
||||
"messageBanner": false,
|
||||
|
@ -142,8 +142,30 @@ export class AccessStore implements IAccessStore {
|
||||
.join(`${T.GROUP_ROLE} AS gr`, 'gu.group_id', 'gr.group_id')
|
||||
.join(`${T.ROLE_PERMISSION} AS rp`, 'rp.role_id', 'gr.role_id')
|
||||
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
|
||||
.where('gu.user_id', '=', userId);
|
||||
.whereNull('g.root_role_id')
|
||||
.andWhere('gu.user_id', '=', userId);
|
||||
});
|
||||
|
||||
userPermissionQuery = userPermissionQuery.union((db) => {
|
||||
db.select(
|
||||
this.db.raw("'default' as project"),
|
||||
'permission',
|
||||
'environment',
|
||||
'p.type',
|
||||
'g.root_role_id as role_id',
|
||||
)
|
||||
.from<IPermissionRow>(`${T.GROUP_USER} as gu`)
|
||||
.join(`${T.GROUPS} AS g`, 'g.id', 'gu.group_id')
|
||||
.join(
|
||||
`${T.ROLE_PERMISSION} as rp`,
|
||||
'rp.role_id',
|
||||
'g.root_role_id',
|
||||
)
|
||||
.join(`${T.PERMISSIONS} as p`, 'p.id', 'rp.permission_id')
|
||||
.whereNotNull('g.root_role_id')
|
||||
.andWhere('gu.user_id', '=', userId);
|
||||
});
|
||||
|
||||
const rows = await userPermissionQuery;
|
||||
stopTimer();
|
||||
return rows.map(this.mapUserPermission);
|
||||
|
@ -28,6 +28,7 @@ const GROUP_COLUMNS = [
|
||||
'mappings_sso',
|
||||
'created_at',
|
||||
'created_by',
|
||||
'root_role_id',
|
||||
];
|
||||
|
||||
const rowToGroup = (row) => {
|
||||
@ -41,6 +42,7 @@ const rowToGroup = (row) => {
|
||||
mappingsSSO: row.mappings_sso,
|
||||
createdAt: row.created_at,
|
||||
createdBy: row.created_by,
|
||||
rootRole: row.root_role_id,
|
||||
});
|
||||
};
|
||||
|
||||
@ -53,6 +55,7 @@ const rowToGroupUser = (row) => {
|
||||
groupId: row.group_id,
|
||||
joinedAt: row.created_at,
|
||||
createdBy: row.created_by,
|
||||
rootRoleId: row.root_role_id,
|
||||
};
|
||||
};
|
||||
|
||||
@ -60,6 +63,7 @@ const groupToRow = (group: IStoreGroup) => ({
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
mappings_sso: JSON.stringify(group.mappingsSSO),
|
||||
root_role_id: group.rootRole || null,
|
||||
});
|
||||
|
||||
export default class GroupStore implements IGroupStore {
|
||||
@ -124,9 +128,11 @@ export default class GroupStore implements IGroupStore {
|
||||
'u.id as user_id',
|
||||
'gu.created_at',
|
||||
'gu.created_by',
|
||||
'g.root_role_id',
|
||||
)
|
||||
.from(`${T.GROUP_USER} AS gu`)
|
||||
.join(`${T.USERS} AS u`, 'u.id', 'gu.user_id')
|
||||
.join(`${T.GROUPS} AS g`, 'g.id', 'gu.group_id')
|
||||
.whereIn('gu.group_id', groupIds);
|
||||
return rows.map(rowToGroupUser);
|
||||
}
|
||||
|
@ -24,6 +24,12 @@ export const groupSchema = {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
rootRole: {
|
||||
type: 'number',
|
||||
nullable: true,
|
||||
description:
|
||||
'A role id that is used as the root role for all users in this group. This can be either the id of the Editor or Admin role.',
|
||||
},
|
||||
createdBy: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
|
@ -341,6 +341,7 @@ export default class UserAdminController extends Controller {
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
userCount: g.users.length,
|
||||
rootRole: g.rootRole,
|
||||
} as IGroup;
|
||||
});
|
||||
this.openApiService.respondWithValidation(
|
||||
|
@ -76,6 +76,10 @@ const flags = {
|
||||
process.env.UNLEASH_EXPERIMENTAL_OPTIMAL_304_DIFFER,
|
||||
false,
|
||||
),
|
||||
groupRootRoles: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_ROOT_ROLE_GROUPS,
|
||||
false,
|
||||
),
|
||||
migrationLock: parseEnvVarBoolean(process.env.MIGRATION_LOCK, false),
|
||||
demo: parseEnvVarBoolean(process.env.UNLEASH_DEMO, false),
|
||||
strategyTitle: parseEnvVarBoolean(
|
||||
|
@ -6,6 +6,7 @@ export interface IGroup {
|
||||
name: string;
|
||||
description?: string;
|
||||
mappingsSSO?: string[];
|
||||
rootRole?: number;
|
||||
createdAt?: Date;
|
||||
userCount?: number;
|
||||
createdBy?: string;
|
||||
@ -15,6 +16,7 @@ export interface IGroupUser {
|
||||
groupId: number;
|
||||
userId: number;
|
||||
joinedAt: Date;
|
||||
rootRoleId?: number;
|
||||
seenAt?: Date;
|
||||
createdBy?: string;
|
||||
}
|
||||
@ -58,6 +60,8 @@ export default class Group implements IGroup {
|
||||
|
||||
name: string;
|
||||
|
||||
rootRole?: number;
|
||||
|
||||
description: string;
|
||||
|
||||
mappingsSSO: string[];
|
||||
@ -67,6 +71,7 @@ export default class Group implements IGroup {
|
||||
name,
|
||||
description,
|
||||
mappingsSSO,
|
||||
rootRole,
|
||||
createdBy,
|
||||
createdAt,
|
||||
}: IGroup) {
|
||||
@ -78,6 +83,7 @@ export default class Group implements IGroup {
|
||||
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.rootRole = rootRole;
|
||||
this.description = description;
|
||||
this.mappingsSSO = mappingsSSO;
|
||||
this.createdBy = createdBy;
|
||||
|
@ -12,6 +12,7 @@ export interface IStoreGroup {
|
||||
name: string;
|
||||
description?: string;
|
||||
mappingsSSO?: string[];
|
||||
rootRole?: number;
|
||||
}
|
||||
|
||||
export interface IGroupStore extends Store<IGroup, number> {
|
||||
|
24
src/migrations/20230414105818-add-root-role-to-groups.js
Normal file
24
src/migrations/20230414105818-add-root-role-to-groups.js
Normal file
@ -0,0 +1,24 @@
|
||||
'use strict';
|
||||
|
||||
exports.up = function (db, callback) {
|
||||
db.runSql(
|
||||
`
|
||||
ALTER TABLE groups ADD COLUMN root_role_id INTEGER DEFAULT NULL;
|
||||
ALTER TABLE groups
|
||||
ADD CONSTRAINT fk_group_role_id
|
||||
FOREIGN KEY(root_role_id)
|
||||
REFERENCES roles(id);
|
||||
`,
|
||||
callback,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function (db, callback) {
|
||||
db.runSql(
|
||||
`
|
||||
ALTER TABLE groups DROP CONSTRAINT fk_group_role_id;
|
||||
ALTER TABLE groups DROP COLUMN root_role_id;
|
||||
`,
|
||||
callback,
|
||||
);
|
||||
};
|
@ -2357,6 +2357,11 @@ The provider you choose for your addon dictates what properties the \`parameters
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"rootRole": {
|
||||
"description": "A role id that is used as the root role for all users in this group. This can be either the id of the Editor or Admin role.",
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
},
|
||||
"users": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/groupUserModelSchema",
|
||||
|
@ -44,6 +44,13 @@ const createUserViewerAccess = async (name, email) => {
|
||||
return user;
|
||||
};
|
||||
|
||||
const createUserAdminAccess = async (name, email) => {
|
||||
const { userStore } = stores;
|
||||
const user = await userStore.insert({ name, email });
|
||||
await accessService.addUserToRole(user.id, adminRole.id, 'default');
|
||||
return user;
|
||||
};
|
||||
|
||||
const hasCommonProjectAccess = async (user, projectName, condition) => {
|
||||
const defaultEnv = 'default';
|
||||
const developmentEnv = 'development';
|
||||
@ -1062,3 +1069,181 @@ test('Should allow user to take multiple group roles and have expected permissio
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('Should allow user to take on root role through a group that has a root role defined', async () => {
|
||||
const groupStore = stores.groupStore;
|
||||
|
||||
const viewerUser = await createUserViewerAccess(
|
||||
'Vincent Viewer',
|
||||
'vincent@getunleash.io',
|
||||
);
|
||||
|
||||
const groupWithRootAdminRole = await groupStore.create({
|
||||
name: 'GroupThatGrantsAdminRights',
|
||||
description: '',
|
||||
rootRole: adminRole.id,
|
||||
});
|
||||
|
||||
await groupStore.addUsersToGroup(
|
||||
groupWithRootAdminRole.id!,
|
||||
[{ user: viewerUser }],
|
||||
'Admin',
|
||||
);
|
||||
|
||||
expect(
|
||||
await accessService.hasPermission(viewerUser, permissions.ADMIN),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('Should not elevate permissions for a user that is not present in a root role group', async () => {
|
||||
const groupStore = stores.groupStore;
|
||||
|
||||
const viewerUser = await createUserViewerAccess(
|
||||
'Violet Viewer',
|
||||
'violet@getunleash.io',
|
||||
);
|
||||
|
||||
const viewerUserNotInGroup = await createUserViewerAccess(
|
||||
'Veronica Viewer',
|
||||
'veronica@getunleash.io',
|
||||
);
|
||||
|
||||
const groupWithRootAdminRole = await groupStore.create({
|
||||
name: 'GroupThatGrantsAdminRights',
|
||||
description: '',
|
||||
rootRole: adminRole.id,
|
||||
});
|
||||
|
||||
await groupStore.addUsersToGroup(
|
||||
groupWithRootAdminRole.id!,
|
||||
[{ user: viewerUser }],
|
||||
'Admin',
|
||||
);
|
||||
|
||||
expect(
|
||||
await accessService.hasPermission(viewerUser, permissions.ADMIN),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
await accessService.hasPermission(
|
||||
viewerUserNotInGroup,
|
||||
permissions.ADMIN,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('Should not reduce permissions for an admin user that enters an editor group', async () => {
|
||||
const groupStore = stores.groupStore;
|
||||
|
||||
const adminUser = await createUserAdminAccess(
|
||||
'Austin Admin',
|
||||
'austin@getunleash.io',
|
||||
);
|
||||
|
||||
const groupWithRootEditorRole = await groupStore.create({
|
||||
name: 'GroupThatGrantsEditorRights',
|
||||
description: '',
|
||||
rootRole: editorRole.id,
|
||||
});
|
||||
|
||||
await groupStore.addUsersToGroup(
|
||||
groupWithRootEditorRole.id!,
|
||||
[{ user: adminUser }],
|
||||
'Admin',
|
||||
);
|
||||
|
||||
expect(
|
||||
await accessService.hasPermission(adminUser, permissions.ADMIN),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('Should not change permissions for a user in a group without a root role', async () => {
|
||||
const groupStore = stores.groupStore;
|
||||
|
||||
const viewerUser = await createUserViewerAccess(
|
||||
'Virgil Viewer',
|
||||
'virgil@getunleash.io',
|
||||
);
|
||||
|
||||
const groupWithoutRootRole = await groupStore.create({
|
||||
name: 'GroupWithNoRootRole',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const preAddedToGroupPermissions =
|
||||
await accessService.getPermissionsForUser(viewerUser);
|
||||
|
||||
await groupStore.addUsersToGroup(
|
||||
groupWithoutRootRole.id!,
|
||||
[{ user: viewerUser }],
|
||||
'Admin',
|
||||
);
|
||||
|
||||
const postAddedToGroupPermissions =
|
||||
await accessService.getPermissionsForUser(viewerUser);
|
||||
|
||||
expect(
|
||||
JSON.stringify(preAddedToGroupPermissions) ===
|
||||
JSON.stringify(postAddedToGroupPermissions),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('Should add permissions to user when a group is given a root role after the user has been added to the group', async () => {
|
||||
const groupStore = stores.groupStore;
|
||||
|
||||
const viewerUser = await createUserViewerAccess(
|
||||
'Vera Viewer',
|
||||
'vera@getunleash.io',
|
||||
);
|
||||
|
||||
const groupWithoutRootRole = await groupStore.create({
|
||||
name: 'GroupWithNoRootRole',
|
||||
description: '',
|
||||
});
|
||||
|
||||
await groupStore.addUsersToGroup(
|
||||
groupWithoutRootRole.id!,
|
||||
[{ user: viewerUser }],
|
||||
'Admin',
|
||||
);
|
||||
|
||||
expect(
|
||||
await accessService.hasPermission(viewerUser, permissions.ADMIN),
|
||||
).toBe(false);
|
||||
|
||||
await groupStore.update({
|
||||
id: groupWithoutRootRole.id!,
|
||||
name: 'GroupWithNoRootRole',
|
||||
rootRole: adminRole.id,
|
||||
users: [{ user: viewerUser }],
|
||||
});
|
||||
|
||||
expect(
|
||||
await accessService.hasPermission(viewerUser, permissions.ADMIN),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('Should give full project access to the default project to user in a group with an editor root role', async () => {
|
||||
const projectName = 'default';
|
||||
|
||||
const groupStore = stores.groupStore;
|
||||
|
||||
const viewerUser = await createUserViewerAccess(
|
||||
'Vee viewer',
|
||||
'vee@getunleash.io',
|
||||
);
|
||||
|
||||
const groupWithRootEditorRole = await groupStore.create({
|
||||
name: 'GroupThatGrantsEditorRights',
|
||||
description: '',
|
||||
rootRole: editorRole.id,
|
||||
});
|
||||
|
||||
await groupStore.addUsersToGroup(
|
||||
groupWithRootEditorRole.id!,
|
||||
[{ user: viewerUser }],
|
||||
'Admin',
|
||||
);
|
||||
|
||||
await hasFullProjectAccess(viewerUser, projectName, true);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user