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
@@ -16,6 +16,9 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-sort-permission.guard';
import { DeleteViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-sort-permission.guard';
import { UpdateViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-sort-permission.guard';
import { CreateViewSortInput } from 'src/engine/metadata-modules/view-sort/dtos/inputs/create-view-sort.input';
import { UpdateViewSortInput } from 'src/engine/metadata-modules/view-sort/dtos/inputs/update-view-sort.input';
import { type ViewSortDTO } from 'src/engine/metadata-modules/view-sort/dtos/view-sort.dto';
@@ -73,6 +76,7 @@ export class ViewSortController {
}
@Post()
@UseGuards(CreateViewSortPermissionGuard)
async create(
@Body() input: CreateViewSortInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -84,6 +88,7 @@ export class ViewSortController {
}
@Patch(':id')
@UseGuards(UpdateViewSortPermissionGuard)
async update(
@Param('id') id: string,
@Body() input: UpdateViewSortInput,
@@ -99,6 +104,7 @@ export class ViewSortController {
}
@Delete(':id')
@UseGuards(DeleteViewSortPermissionGuard)
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -1,5 +1,5 @@
import { msg, t } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
@@ -18,6 +18,7 @@ export class ViewSortException extends CustomException {
export enum ViewSortExceptionCode {
VIEW_SORT_NOT_FOUND = 'VIEW_SORT_NOT_FOUND',
INVALID_VIEW_SORT_DATA = 'INVALID_VIEW_SORT_DATA',
VIEW_NOT_FOUND = 'VIEW_NOT_FOUND',
}
export enum ViewSortExceptionMessageKey {
@@ -26,39 +27,29 @@ export enum ViewSortExceptionMessageKey {
VIEW_SORT_NOT_FOUND = 'VIEW_SORT_NOT_FOUND',
INVALID_VIEW_SORT_DATA = 'INVALID_VIEW_SORT_DATA',
FIELD_METADATA_ID_REQUIRED = 'FIELD_METADATA_ID_REQUIRED',
VIEW_NOT_FOUND = 'VIEW_NOT_FOUND',
}
export const generateViewSortExceptionMessage = (
key: ViewSortExceptionMessageKey,
id?: string,
) => {
let message = '';
switch (key) {
case ViewSortExceptionMessageKey.WORKSPACE_ID_REQUIRED:
message = t`WorkspaceId is required`;
break;
return 'WorkspaceId is required';
case ViewSortExceptionMessageKey.VIEW_ID_REQUIRED:
message = t`ViewId is required`;
break;
return 'ViewId is required';
case ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND:
message = id
? t`View sort (id: ${id}) not found`
: t`View sort not found`;
break;
return `View sort${id ? ` (id: ${id})` : ''} not found`;
case ViewSortExceptionMessageKey.INVALID_VIEW_SORT_DATA:
message = id
? t`Invalid view sort data for view sort id: ${id}`
: t`Invalid view sort data`;
break;
return `Invalid view sort data${id ? ` for view sort id: ${id}` : ''}`;
case ViewSortExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
message = t`FieldMetadataId is required`;
break;
return 'FieldMetadataId is required';
case ViewSortExceptionMessageKey.VIEW_NOT_FOUND:
return `View${id ? ` (id: ${id})` : ''} not found`;
default:
assertUnreachable(key);
}
return message;
};
export const generateViewSortUserFriendlyExceptionMessage = (
@@ -6,6 +6,10 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-sort-permission.guard';
import { DeleteViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-sort-permission.guard';
import { DestroyViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/destroy-view-sort-permission.guard';
import { UpdateViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-sort-permission.guard';
import { CreateViewSortInput } from 'src/engine/metadata-modules/view-sort/dtos/inputs/create-view-sort.input';
import { UpdateViewSortInput } from 'src/engine/metadata-modules/view-sort/dtos/inputs/update-view-sort.input';
import { ViewSortDTO } from 'src/engine/metadata-modules/view-sort/dtos/view-sort.dto';
@@ -40,6 +44,7 @@ export class ViewSortResolver {
}
@Mutation(() => ViewSortDTO)
@UseGuards(CreateViewSortPermissionGuard)
async createCoreViewSort(
@Args('input') input: CreateViewSortInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -51,6 +56,7 @@ export class ViewSortResolver {
}
@Mutation(() => ViewSortDTO)
@UseGuards(UpdateViewSortPermissionGuard)
async updateCoreViewSort(
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdateViewSortInput,
@@ -60,6 +66,7 @@ export class ViewSortResolver {
}
@Mutation(() => Boolean)
@UseGuards(DeleteViewSortPermissionGuard)
async deleteCoreViewSort(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -70,15 +77,11 @@ export class ViewSortResolver {
}
@Mutation(() => Boolean)
@UseGuards(DestroyViewSortPermissionGuard)
async destroyCoreViewSort(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const deletedViewSort = await this.viewSortService.destroy(
id,
workspace.id,
);
return isDefined(deletedViewSort);
return this.viewSortService.destroy(id, workspace.id);
}
}
@@ -295,12 +295,16 @@ describe('ViewSortService', () => {
const id = 'view-sort-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewSortService, 'findById').mockResolvedValue(mockViewSort);
jest.spyOn(viewSortRepository, 'findOne').mockResolvedValue(mockViewSort);
jest.spyOn(viewSortRepository, 'delete').mockResolvedValue({} as any);
const result = await viewSortService.destroy(id, workspaceId);
expect(viewSortService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewSortRepository.findOne).toHaveBeenCalledWith({
where: { id, workspaceId },
relations: ['workspace', 'view'],
withDeleted: true,
});
expect(viewSortRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual(true);
});
@@ -163,7 +163,14 @@ export class ViewSortService {
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
const viewSort = await this.findById(id, workspaceId);
const viewSort = await this.viewSortRepository.findOne({
where: {
id,
workspaceId,
},
relations: ['workspace', 'view'],
withDeleted: true,
});
if (!isDefined(viewSort)) {
throw new ViewSortException(
@@ -1,16 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
import { ViewSortController } from 'src/engine/metadata-modules/view-sort/controllers/view-sort.controller';
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import { ViewSortResolver } from 'src/engine/metadata-modules/view-sort/resolvers/view-sort.resolver';
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([ViewSortEntity]),
TypeOrmModule.forFeature([ViewSortEntity, ViewEntity]),
PermissionsModule,
WorkspaceCacheStorageModule,
ViewPermissionsModule,
],
controllers: [ViewSortController],
providers: [ViewSortService, ViewSortResolver],