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
@@ -2,6 +2,7 @@ import { type ConfigVariableFilterCategory } from '@/settings/admin-panel/config
import { type ConfigVariableGroupFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableGroupFilter';
import { type ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { IconSettings } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@@ -41,7 +42,7 @@ export const ConfigVariableFilterDropdown = ({
<Button
variant="secondary"
size="medium"
title="Options"
title={t`Options`}
Icon={IconSettings}
/>
}
@@ -3,12 +3,13 @@ import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
SettingsAvailableStandardObjectItemTableRow,
StyledAvailableStandardObjectTableRow,
} from './SettingsAvailableStandardObjectItemTableRow';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
type SettingsAvailableStandardObjectsSectionProps = {
objectItems: ObjectMetadataItem[];
@@ -23,8 +24,8 @@ export const SettingsAvailableStandardObjectsSection = ({
}: SettingsAvailableStandardObjectsSectionProps) => (
<Section>
<H2Title
title="Available"
description="Select one or several standard objects to activate below"
title={t`Available`}
description={t`Select one or several standard objects to activate below`}
/>
<Table>
<StyledAvailableStandardObjectTableRow>
@@ -1,7 +1,8 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsObjectIndexTable } from '~/pages/settings/data-model/SettingsObjectIndexTable';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { SettingsObjectIndexTable } from '~/pages/settings/data-model/SettingsObjectIndexTable';
type ObjectIndexesProps = {
objectMetadataItem: ObjectMetadataItem;
@@ -11,8 +12,8 @@ export const ObjectIndexes = ({ objectMetadataItem }: ObjectIndexesProps) => {
return (
<Section>
<H2Title
title="Indexes"
description={`Advanced feature to improve the performance of queries and to enforce unicity constraints.`}
title={t`Indexes`}
description={t`Advanced feature to improve the performance of queries and to enforce unicity constraints.`}
/>
<SettingsObjectIndexTable objectMetadataItem={objectMetadataItem} />
</Section>
@@ -1,5 +1,6 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsDnsRecordsTable } from '@/settings/components/SettingsDnsRecordsTable';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
@@ -57,8 +58,8 @@ export const SettingsDomainRecords = ({
return (
<Section>
<H2Title
title="Domain Setup"
description="Configure these DNS records with your domain provider"
title={t`Domain Setup`}
description={t`Configure these DNS records with your domain provider`}
/>
<SettingsDnsRecordsTable records={transformedRecords} />
</Section>
@@ -2,9 +2,10 @@ import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { type SettingsIntegration } from '@/settings/integrations/types/SettingsIntegration';
import { t } from '@lingui/core/macro';
import { Link } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { Pill } from 'twenty-ui/components';
import {
IconArrowUpRight,
IconBolt,
@@ -12,7 +13,7 @@ import {
IconPlus,
Status,
} from 'twenty-ui/display';
import { Pill } from 'twenty-ui/components';
import { Button } from 'twenty-ui/input';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
interface SettingsIntegrationComponentProps {
@@ -92,7 +93,7 @@ export const SettingsIntegrationComponent = ({
<Button
to={integration.link}
Icon={IconPlus}
title="Add"
title={t`Add`}
size="small"
/>
) : integration.type === 'Use' ? (
@@ -100,7 +101,7 @@ export const SettingsIntegrationComponent = ({
to={integration.link}
target="_blank"
Icon={IconBolt}
title="Use"
title={t`Use`}
size="small"
/>
) : integration.type === 'Copy' ? (
@@ -1,9 +1,10 @@
import { type FetchResult } from '@apollo/client';
import styled from '@emotion/styled';
import { type SyncRemoteTableSchemaChangesMutation } from '~/generated-metadata/graphql';
import { Button } from 'twenty-ui/input';
import { t } from '@lingui/core/macro';
import { IconReload } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { type SyncRemoteTableSchemaChangesMutation } from '~/generated-metadata/graphql';
const StyledText = styled.h3`
color: ${({ theme }) => theme.font.color.tertiary};
@@ -27,7 +28,7 @@ export const SettingsIntegrationRemoteTableSchemaUpdate = ({
{updatesText && (
<Button
Icon={IconReload}
title="Update"
title={t`Update`}
size="small"
onClick={onUpdate}
/>
@@ -3,23 +3,32 @@ import { SettingsRolePermissionsSettingsTableHeader } from '@/settings/roles/rol
import { SettingsRolePermissionsSettingsTableRow } from '@/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsTableRow';
import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { useRecoilState } from 'recoil';
import {
H2Title,
IconApps,
IconCode,
IconCreditCard,
IconHierarchy,
IconKey,
IconLayoutSidebarRightCollapse,
IconLockOpen,
IconSettings,
IconSettingsAutomation,
IconShield,
IconSparkles,
IconSpy,
IconUsers,
} from 'twenty-ui/display';
import { AnimatedExpandableContainer, Card, Section } from 'twenty-ui/layout';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import {
FeatureFlagKey,
PermissionFlagType,
} from '~/generated-metadata/graphql';
const StyledTable = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
@@ -47,8 +56,13 @@ export const SettingsRolePermissionsSettingsSection = ({
settingsDraftRoleFamilyState(roleId),
);
const settingsPermissionsConfig: SettingsRolePermissionsSettingPermission[] =
[
const isAIEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const isApplicationEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_APPLICATION_ENABLED,
);
const settingsPermissionsConfig = useMemo(() => {
const allPermissions: SettingsRolePermissionsSettingPermission[] = [
{
key: PermissionFlagType.API_KEYS_AND_WEBHOOKS,
name: t`API Keys & Webhooks`,
@@ -76,7 +90,7 @@ export const SettingsRolePermissionsSettingsSection = ({
{
key: PermissionFlagType.DATA_MODEL,
name: t`Data Model`,
description: t`Edit CRM data structure and fields`,
description: t`Edit data structure and fields`,
Icon: IconHierarchy,
},
{
@@ -103,8 +117,46 @@ export const SettingsRolePermissionsSettingsSection = ({
description: t`Impersonate workspace users`,
Icon: IconSpy,
},
{
key: PermissionFlagType.APPLICATIONS,
name: t`Applications`,
description: t`Install and manage applications`,
Icon: IconApps,
},
{
key: PermissionFlagType.LAYOUTS,
name: t`Layouts`,
description: t`Customize page layouts and UI structure`,
Icon: IconLayoutSidebarRightCollapse,
},
{
key: PermissionFlagType.BILLING,
name: t`Billing`,
description: t`Manage billing and subscriptions`,
Icon: IconCreditCard,
},
{
key: PermissionFlagType.AI_SETTINGS,
name: t`AI`,
description: t`Create and configure AI agents`,
Icon: IconSparkles,
},
];
return allPermissions.filter((permission) => {
if (permission.key === PermissionFlagType.AI_SETTINGS && !isAIEnabled) {
return false;
}
if (
permission.key === PermissionFlagType.APPLICATIONS &&
!isApplicationEnabled
) {
return false;
}
return true;
});
}, [isAIEnabled, isApplicationEnabled]);
return (
<Section>
<H2Title title={t`Settings`} description={t`Settings permissions`} />
@@ -3,19 +3,28 @@ import { SettingsRolePermissionsSettingsTableHeader } from '@/settings/roles/rol
import { SettingsRolePermissionsSettingsTableRow } from '@/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsTableRow';
import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilState } from 'recoil';
import {
H2Title,
IconAt,
IconDownload,
IconFileExport,
IconFileImport,
IconFileUpload,
IconMail,
IconSparkles,
IconTable,
IconTool,
} from 'twenty-ui/display';
import { AnimatedExpandableContainer, Card, Section } from 'twenty-ui/layout';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import {
FeatureFlagKey,
PermissionFlagType,
} from '~/generated-metadata/graphql';
const StyledTable = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
@@ -43,11 +52,34 @@ export const SettingsRolePermissionsToolSection = ({
settingsDraftRoleFamilyState(roleId),
);
const toolPermissionsConfig: SettingsRolePermissionsSettingPermission[] = [
const isAIEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const allPermissions: SettingsRolePermissionsSettingPermission[] = [
{
key: PermissionFlagType.AI,
name: t`Ask AI`,
description: t`Chat with AI agents and use AI features`,
Icon: IconSparkles,
isToolPermission: true,
},
{
key: PermissionFlagType.UPLOAD_FILE,
name: t`Upload Files`,
description: t`Allow uploading files and attachments`,
Icon: IconFileUpload,
isToolPermission: true,
},
{
key: PermissionFlagType.DOWNLOAD_FILE,
name: t`Download Files`,
description: t`Allow downloading files and attachments`,
Icon: IconDownload,
isToolPermission: true,
},
{
key: PermissionFlagType.SEND_EMAIL_TOOL,
name: t`Send Email`,
description: t`Allow sending emails using connected accounts`,
description: t`Send emails via connected accounts`,
Icon: IconMail,
isToolPermission: true,
},
@@ -65,8 +97,29 @@ export const SettingsRolePermissionsToolSection = ({
Icon: IconFileExport,
isToolPermission: true,
},
{
key: PermissionFlagType.CONNECTED_ACCOUNTS,
name: t`Sync Account`,
description: t`Sync email and calendar accounts`,
Icon: IconAt,
isToolPermission: true,
},
{
key: PermissionFlagType.VIEWS,
name: t`Manage Views`,
description: t`Create, edit, and delete workspace views`,
Icon: IconTable,
isToolPermission: true,
},
];
const toolPermissionsConfig = allPermissions.filter((permission) => {
if (permission.key === PermissionFlagType.AI && !isAIEnabled) {
return false;
}
return true;
});
return (
<Section>
<H2Title title={t`Actions`} description={t`Actions permissions`} />
@@ -133,7 +133,7 @@ export const SettingsSecurityAuthProvidersOptionsList = () => {
{authProviders.google === true && (
<SettingsOptionCardContentToggle
Icon={IconGoogle}
title="Google"
title={t`Google`}
description={t`Allow logins through Google's single sign-on functionality.`}
checked={currentWorkspace.isGoogleAuthEnabled}
advancedMode
@@ -146,7 +146,7 @@ export const SettingsSecurityAuthProvidersOptionsList = () => {
{authProviders.microsoft === true && (
<SettingsOptionCardContentToggle
Icon={IconMicrosoft}
title="Microsoft"
title={t`Microsoft`}
description={t`Allow logins through Microsoft's single sign-on functionality.`}
checked={currentWorkspace.isMicrosoftAuthEnabled}
advancedMode
@@ -2,6 +2,7 @@ import { type ServerlessFunctionNewFormValues } from '@/settings/serverless-func
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { TextArea } from '@/ui/input/components/TextArea';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
@@ -25,7 +26,10 @@ export const SettingsServerlessFunctionNewForm = ({
return (
<Section>
<H2Title title="About" description="Name and describe your function" />
<H2Title
title={t`About`}
description={t`Name and describe your function`}
/>
<StyledInputsContainer>
<SettingsTextInput
instanceId={nameTextInputId}
@@ -7,6 +7,7 @@ import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title, IconPlayerPlay } from 'twenty-ui/display';
import { Button, CoreEditorHeader } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
@@ -32,7 +33,7 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
);
const TestButton = (
<Button
title="Test"
title={t`Test`}
variant="primary"
accent="blue"
size="small"
@@ -54,8 +55,8 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
return (
<Section>
<H2Title
title="Code your function"
description="Write your function (in typescript) below"
title={t`Code your function`}
description={t`Write your function (in typescript) below`}
/>
<CoreEditorHeader leftNodes={[HeaderTabList]} rightNodes={[TestButton]} />
{activeTabId && (