Enhance role-check system with stricter checks (#15392)

## Overview

This PR strengthens our permission system by introducing more granular
role-based access control across the platform.

## Changes

### New Permissions Added
- **Applications** - Control who can install and manage applications
- **Layouts** - Control who can customize page layouts and UI structure
- **AI** - Control access to AI features and agents
- **Upload File** - Separate permission for file uploads
- **Download File** - Separate permission for file downloads (frontend
visibility)

### Security Enhancements
- Implemented whitelist-based validation for workspace field updates
- Added explicit permission guards to core entity resolvers
- Enhanced ESLint rule to enforce permission checks on all mutations
- Created `CustomPermissionGuard` and `NoPermissionGuard` for better
code documentation

### Affected Components
- Core entity resolvers: webhooks, files, domains, applications,
layouts, postgres credentials
- Workspace update mutations now use whitelist validation
- Settings UI updated with new permission controls

### Developer Experience
- ESLint now catches missing permission guards during development
- Explicit guard markers make permission requirements clear in code
review
- Comprehensive test coverage for new permission logic

## Testing
-  All TypeScript type checks pass
-  ESLint validation passes
-  New permission guards properly enforced
-  Frontend UI displays new permissions correctly

## Migration Notes
Existing workspaces will need to assign the new permissions to roles as
needed. By default, all new permissions are set to `false` for non-admin
roles.
This commit is contained in:
Félix Malfait
2025-11-07 15:37:17 +01:00
committed by GitHub
parent 44d6ec2594
commit cff17db6cb
306 changed files with 13863 additions and 976 deletions
@@ -8,6 +8,7 @@ import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUp
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentFamilyState';
import { hasInitializedCurrentRecordFiltersComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
@@ -86,8 +87,8 @@ export const AdvancedFilterCommandMenuCreateRootFilterButton = ({
variant="secondary"
accent="default"
onClick={addRootRecordFilterGroup}
ariaLabel="Add filter"
title="Add filter"
ariaLabel={t`Add filter`}
title={t`Add filter`}
disabled={readonly}
/>
);
@@ -9,6 +9,7 @@ import { ObjectOptionsDropdownMenuContent } from '@/object-record/object-options
import { ObjectOptionsDropdownRecordGroupFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent';
import { ObjectOptionsDropdownRecordGroupsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent';
import { ObjectOptionsDropdownRecordGroupSortContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent';
import { ObjectOptionsDropdownVisibilityContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownVisibilityContent';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
export const ObjectOptionsDropdownContent = () => {
@@ -35,6 +36,8 @@ export const ObjectOptionsDropdownContent = () => {
return <ObjectOptionsDropdownCalendarViewContent />;
case 'calendarFields':
return <ObjectOptionsDropdownCalendarFieldsContent />;
case 'visibility':
return <ObjectOptionsDropdownVisibilityContent />;
default:
return <ObjectOptionsDropdownMenuContent />;
}
@@ -4,10 +4,10 @@ import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdow
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
@@ -18,7 +18,6 @@ import { ViewKey } from '@/views/types/ViewKey';
import { ViewType, viewTypeIconMapping } from '@/views/types/ViewType';
import { useDeleteViewFromCurrentState } from '@/views/view-picker/hooks/useDeleteViewFromCurrentState';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
@@ -26,9 +25,9 @@ import {
AppTooltip,
IconCalendar,
IconCalendarWeek,
IconCopy,
IconLayoutList,
IconListDetails,
IconShare,
IconTrash,
} from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
@@ -92,17 +91,14 @@ export const ObjectOptionsDropdownCustomView = ({
onBackToDefault?.();
};
const theme = useTheme();
const { enqueueSuccessSnackBar } = useSnackBar();
const selectableItemIdArray = [
'Layout',
'Visibility',
'Fields',
...(customViewData?.type === ViewType.Calendar
? ['CalendarDateField', 'CalendarView']
: []),
...(customViewData?.type !== ViewType.Calendar ? ['Group'] : []),
'Copy link to view',
'Delete view',
];
@@ -116,7 +112,7 @@ export const ObjectOptionsDropdownCustomView = ({
}
return (
<DropdownContent>
<DropdownContent widthInPixels={GenericDropdownContentWidth.Large}>
<ObjectOptionsDropdownMenuViewName currentView={customViewData} />
<DropdownMenuSeparator />
<SelectableList
@@ -141,6 +137,24 @@ export const ObjectOptionsDropdownCustomView = ({
hasSubMenu
/>
</SelectableListItem>
<SelectableListItem
itemId="Visibility"
onEnter={() => onContentChange('visibility')}
>
<MenuItem
focused={selectedItemId === 'Visibility'}
onClick={() => onContentChange('visibility')}
LeftIcon={IconShare}
text={t`Visibility`}
contextualText={
customViewData?.visibility === 'UNLISTED'
? t`Unlisted`
: t`Workspace`
}
contextualTextPosition="right"
hasSubMenu
/>
</SelectableListItem>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
@@ -245,37 +259,6 @@ export const ObjectOptionsDropdownCustomView = ({
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
<SelectableListItem
itemId="Copy link to view"
onEnter={() => {
const currentUrl = window.location.href;
navigator.clipboard.writeText(currentUrl);
enqueueSuccessSnackBar({
message: t`Link copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
}}
>
<MenuItem
focused={selectedItemId === 'Copy link to view'}
onClick={() => {
const currentUrl = window.location.href;
navigator.clipboard.writeText(currentUrl);
enqueueSuccessSnackBar({
message: t`Link copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
}}
LeftIcon={IconCopy}
text={t`Copy link to view`}
/>
</SelectableListItem>
<div id="delete-view-menu-item">
<SelectableListItem
itemId="Delete view"
@@ -0,0 +1,162 @@
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { createPortal } from 'react-dom';
import {
AppTooltip,
IconChevronLeft,
IconCircle,
IconCircleDashed,
IconCopy,
} from 'twenty-ui/display';
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
import { ViewVisibility } from '~/generated-metadata/graphql';
import { PermissionFlagType } from '~/generated/graphql';
export const ObjectOptionsDropdownVisibilityContent = () => {
const { t } = useLingui();
const theme = useTheme();
const { resetContent } = useObjectOptionsDropdown();
const { currentView } = useGetCurrentViewOnly();
const { updateCurrentView } = useUpdateCurrentView();
const { enqueueSuccessSnackBar } = useSnackBar();
const hasViewsPermission = useHasPermissionFlag(PermissionFlagType.VIEWS);
const { canPersistChanges } = useCanPersistViewChanges();
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
OBJECT_OPTIONS_DROPDOWN_ID,
);
const selectableItemIdArray = [
ViewVisibility.WORKSPACE,
ViewVisibility.UNLISTED,
];
const handleVisibilityChange = (visibility: ViewVisibility) => {
if (!canPersistChanges || !currentView) return;
updateCurrentView({
visibility,
});
resetContent();
};
const handleCopyLink = () => {
const currentUrl = window.location.href;
navigator.clipboard.writeText(currentUrl);
enqueueSuccessSnackBar({
message: t`Link copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
};
const currentVisibility = currentView?.visibility ?? ViewVisibility.WORKSPACE;
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.Large}>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={resetContent}
Icon={IconChevronLeft}
/>
}
>
{t`Visibility`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={OBJECT_OPTIONS_DROPDOWN_ID}
focusId={OBJECT_OPTIONS_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
>
<SelectableListItem
itemId={ViewVisibility.WORKSPACE}
onEnter={() =>
hasViewsPermission &&
canPersistChanges &&
handleVisibilityChange(ViewVisibility.WORKSPACE)
}
>
<>
<div id="workspace-visibility-option">
<MenuItemSelect
LeftIcon={IconCircle}
text={t`Workspace`}
contextualText={t`Everyone`}
selected={currentVisibility === ViewVisibility.WORKSPACE}
focused={selectedItemId === ViewVisibility.WORKSPACE}
onClick={() =>
handleVisibilityChange(ViewVisibility.WORKSPACE)
}
disabled={!hasViewsPermission || !canPersistChanges}
/>
</div>
{!hasViewsPermission &&
createPortal(
<AppTooltip
anchorSelect="#workspace-visibility-option"
content={t`Workspace views require manage views permission`}
positionStrategy="fixed"
/>,
document.body,
)}
</>
</SelectableListItem>
<SelectableListItem
itemId={ViewVisibility.UNLISTED}
onEnter={() =>
canPersistChanges &&
handleVisibilityChange(ViewVisibility.UNLISTED)
}
>
<MenuItemSelect
LeftIcon={IconCircleDashed}
text={t`Unlisted`}
contextualText={t`Visible to you`}
selected={currentVisibility === ViewVisibility.UNLISTED}
focused={selectedItemId === ViewVisibility.UNLISTED}
onClick={() => handleVisibilityChange(ViewVisibility.UNLISTED)}
disabled={!canPersistChanges}
/>
</SelectableListItem>
{currentVisibility === ViewVisibility.WORKSPACE && (
<>
<DropdownMenuSeparator />
<SelectableListItem
itemId="Copy view link"
onEnter={handleCopyLink}
>
<MenuItem
focused={selectedItemId === 'Copy view link'}
onClick={handleCopyLink}
LeftIcon={IconCopy}
text={t`Copy view link`}
/>
</SelectableListItem>
</>
)}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -8,4 +8,5 @@ export type ObjectOptionsContentId =
| 'recordGroupFields'
| 'recordGroupSort'
| 'calendarFields'
| 'calendarView';
| 'calendarView'
| 'visibility';
@@ -44,7 +44,7 @@ export const AggregateDropdownContent = () => {
return (
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
availableAggregations={availableAggregations}
title="Count"
title={t`Count`}
/>
);
}
@@ -57,7 +57,7 @@ export const AggregateDropdownContent = () => {
return (
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
availableAggregations={availableAggregations}
title="Percent"
title={t`Percent`}
/>
);
}
@@ -86,7 +86,7 @@ export const AggregateDropdownContent = () => {
return (
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
availableAggregations={availableAggregationsWithoutDates}
title="More options"
title={t`More options`}
/>
);
}
@@ -11,6 +11,7 @@ import { type DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { addMonths, format, subMonths } from 'date-fns';
import { useRecoilValue } from 'recoil';
import { type Nullable } from 'twenty-shared/types';
@@ -144,7 +145,7 @@ export const RecordCalendarTopBar = () => {
<Button
size="small"
variant="tertiary"
title="Today"
title={t`Today`}
onClick={handleTodayClick}
/>
<StyledNavigationButton
@@ -6,6 +6,7 @@ import { RecordDetailRecordsListContainer } from '@/object-record/record-field-l
import { RecordDetailRecordsListItemContainer } from '@/object-record/record-field-list/record-detail-section/components/RecordDetailRecordsListItemContainer';
import { RecordDetailSectionContainer } from '@/object-record/record-field-list/record-detail-section/components/RecordDetailSectionContainer';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconArrowMerge } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
@@ -44,7 +45,7 @@ export const RecordDetailDuplicatesSection = ({
return (
<RecordDetailSectionContainer
title="Duplicates"
title={t`Duplicates`}
rightAdornment={
<LightIconButton
className="displayOnHover"
@@ -2,6 +2,7 @@ import { RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID } from '@/object-record/reco
import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
import { createPortal } from 'react-dom';
@@ -21,10 +22,10 @@ export const RecordGroupReorderConfirmationModal = ({
{createPortal(
<ConfirmationModal
modalId={RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID}
title="Group sorting"
subtitle={`Would you like to remove ${recordGroupSort} group sorting?`}
title={t`Group sorting`}
subtitle={t`Would you like to remove ${recordGroupSort} group sorting?`}
onConfirmClick={onConfirmClick}
confirmButtonText="Remove"
confirmButtonText={t`Remove`}
/>,
document.body,
)}