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