From 2c596e7b1ef27f63a4764f2a265cb0ed298218cb Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:41:48 +0100 Subject: [PATCH] [Apps] App misc - fixes + settings permissions for apps + uploadFile (#17167) In this PR - handle settings permission check for applications. Until then this was unhandled and applications could not perform actions requiring settings permissions, even if they were granted them - fix ties to attachment, noteTarget etc: When an object had their fields synchronized in the app, the system fields created as a side-effect of the object creation (relations to noteTarget, attachment, taskTarget, favorites, timelineActivities - created with `isCustom: true`, not sure that is correct btw) were then deleted because they are not declared in the app, and identified as deletable because of `isCustom: true`. Updating the logic to exclude system fields from the logic that detects fields to delete. I think this outline the confusion we have around isCustom, isSystem etc. - introduce uploadFile util in generated twenty client as it cannot be handled by the client's query / mutation. I had to use this for my invoicing app --- .../src/cli/services/generate.service.ts | 43 ++++++++++++++-- .../application/application-sync.service.ts | 49 +++++++------------ .../application/application.service.ts | 21 ++++++++ .../guards/settings-permission.guard.ts | 3 +- .../__tests__/permissions.service.spec.ts | 5 ++ .../permissions/permissions.exception.ts | 4 ++ .../permissions/permissions.module.ts | 6 ++- .../permissions/permissions.service.ts | 29 +++++++++++ ...sion-graphql-api-exception-handler.util.ts | 1 + 9 files changed, 124 insertions(+), 37 deletions(-) diff --git a/packages/twenty-sdk/src/cli/services/generate.service.ts b/packages/twenty-sdk/src/cli/services/generate.service.ts index 9162a723c66..a3350484518 100644 --- a/packages/twenty-sdk/src/cli/services/generate.service.ts +++ b/packages/twenty-sdk/src/cli/services/generate.service.ts @@ -1,12 +1,12 @@ -import { generate } from '@genql/cli'; -import chalk from 'chalk'; -import { join, resolve } from 'path'; import { ApiService } from '@/cli/services/api.service'; import { ConfigService } from '@/cli/services/config.service'; +import { generate } from '@genql/cli'; +import chalk from 'chalk'; import * as fs from 'fs-extra'; +import { join, resolve } from 'path'; import { - DEFAULT_API_URL_NAME, DEFAULT_API_KEY_NAME, + DEFAULT_API_URL_NAME, } from 'twenty-shared/application'; export const GENERATED_FOLDER_NAME = 'generated'; @@ -107,6 +107,41 @@ export default class Twenty { mutation(request: R & { __name?: string }) { return this.client.mutation(request); } + + async uploadFile( + fileBuffer: Buffer, + filename: string, + contentType: string = 'application/octet-stream', + fileFolder: string = 'Attachment', + ): Promise<{ path: string; token: string }> { + const form = new FormData(); + + form.append('operations', JSON.stringify({ + query: \`mutation UploadFile($file: Upload!, $fileFolder: FileFolder) { + uploadFile(file: $file, fileFolder: $fileFolder) { path token } + }\`, + variables: { file: null, fileFolder }, + })); + form.append('map', JSON.stringify({ '0': ['variables.file'] })); + form.append('0', new Blob([fileBuffer], { type: contentType }), filename); + + + const response = await fetch(\`\${process.env.TWENTY_API_URL}/graphql\`, { + method: 'POST', + headers: { + Authorization: \`Bearer \${process.env.TWENTY_API_KEY}\`, + }, + body: form, + }); + + const result = await response.json(); + + if (result.errors) { + throw new GenqlError(result.errors, result.data); + } + + return result.data.uploadFile; + } } `; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts index 39560c50c2c..793f0d0cf1a 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts @@ -369,12 +369,16 @@ export class ApplicationSyncService { private isRelationFieldManifest( field: FieldManifest | RelationFieldManifest, ): field is RelationFieldManifest { - return field.type === FieldMetadataType.RELATION; + return this.isFieldTypeRelation(field.type); } - private async syncFields({ + private isFieldTypeRelation(type: FieldMetadataType): boolean { + return type === FieldMetadataType.RELATION; + } + + private async syncFieldsWithoutRelations({ objectId, - fieldsToSync, + fieldsToSync: allFieldsToSync, workspaceId, applicationId, }: { @@ -383,33 +387,14 @@ export class ApplicationSyncService { applicationId: string; fieldsToSync?: (FieldManifest | RelationFieldManifest)[]; }) { - if (!isDefined(fieldsToSync)) { + if (!isDefined(allFieldsToSync)) { return; } - const regularFields = fieldsToSync.filter( + const fieldsToSync = allFieldsToSync.filter( (field): field is FieldManifest => !this.isRelationFieldManifest(field), ); - await this.syncRegularFields({ - objectId, - fieldsToSync: regularFields, - workspaceId, - applicationId, - }); - } - - private async syncRegularFields({ - objectId, - fieldsToSync, - workspaceId, - applicationId, - }: { - objectId: string; - workspaceId: string; - applicationId: string; - fieldsToSync: FieldManifest[]; - }) { if (fieldsToSync.length === 0) { return; } @@ -425,7 +410,10 @@ export class ApplicationSyncService { const existingFields = Object.values( existingFlatFieldMetadataMaps.byId, ).filter( - (field) => isDefined(field) && field.objectMetadataId === objectId, + (field) => + isDefined(field) && + field.objectMetadataId === objectId && + !this.isFieldTypeRelation(field.type), ) as FlatFieldMetadata[]; const fieldsToSyncUniversalIds = fieldsToSync.map( @@ -440,7 +428,8 @@ export class ApplicationSyncService { (field) => isDefined(field.universalIdentifier) && !fieldsToSyncUniversalIds.includes(field.universalIdentifier) && - field.isCustom === true, + field.isCustom === true && + field.isSystem === false, ); const fieldsToUpdate = existingFields.filter( @@ -690,7 +679,7 @@ export class ApplicationSyncService { workspaceId, }); - await this.syncFields({ + await this.syncFieldsWithoutRelations({ fieldsToSync: objectToSync.fields, objectId: objectToUpdate.id, workspaceId, @@ -712,8 +701,8 @@ export class ApplicationSyncService { icon: objectToCreate.icon || undefined, description: objectToCreate.description || undefined, standardId: objectToCreate.universalIdentifier, - universalIdentifier: objectToCreate.universalIdentifier, dataSourceId: dataSourceMetadata.id, + universalIdentifier: objectToCreate.universalIdentifier, applicationId, }; @@ -723,7 +712,7 @@ export class ApplicationSyncService { workspaceId, }); - await this.syncFields({ + await this.syncFieldsWithoutRelations({ fieldsToSync: objectToCreate.fields, objectId: createdObject.id, workspaceId, @@ -837,7 +826,7 @@ export class ApplicationSyncService { } // Sync regular fields for this extension - await this.syncFields({ + await this.syncFieldsWithoutRelations({ objectId: targetObjectId, fieldsToSync: fields, workspaceId, diff --git a/packages/twenty-server/src/engine/core-modules/application/application.service.ts b/packages/twenty-server/src/engine/core-modules/application/application.service.ts index f858505919e..82e0ac08576 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.service.ts @@ -25,6 +25,27 @@ export class ApplicationService { private readonly workspaceRepository: Repository, ) {} + async findApplicationRoleId( + applicationId: string, + workspaceId: string, + ): Promise { + const application = await this.applicationRepository.findOne({ + where: { id: applicationId, workspaceId }, + }); + + if ( + !isDefined(application) || + !isDefined(application.defaultServerlessFunctionRoleId) + ) { + throw new ApplicationException( + `Could not find application ${applicationId}`, + ApplicationExceptionCode.APPLICATION_NOT_FOUND, + ); + } + + return application.defaultServerlessFunctionRoleId; + } + async findWorkspaceTwentyStandardAndCustomApplicationOrThrow({ workspace: workspaceInput, workspaceId, diff --git a/packages/twenty-server/src/engine/guards/settings-permission.guard.ts b/packages/twenty-server/src/engine/guards/settings-permission.guard.ts index 17642353f26..bc4395b3be0 100644 --- a/packages/twenty-server/src/engine/guards/settings-permission.guard.ts +++ b/packages/twenty-server/src/engine/guards/settings-permission.guard.ts @@ -8,8 +8,8 @@ import { import { GqlExecutionContext } from '@nestjs/graphql'; import { msg } from '@lingui/core/macro'; -import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { type PermissionFlagType } from 'twenty-shared/constants'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { PermissionsException, @@ -47,6 +47,7 @@ export const SettingsPermissionGuard = ( setting: requiredPermission, workspaceId, apiKeyId: ctx.getContext().req.apiKey?.id, + applicationId: ctx.getContext().req.application?.id, }); if (hasPermission === true) { diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/__tests__/permissions.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/__tests__/permissions.service.spec.ts index ff5d5477766..34ed3189d15 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/__tests__/permissions.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/__tests__/permissions.service.spec.ts @@ -4,6 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { PermissionFlagType } from 'twenty-shared/constants'; import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; @@ -32,6 +33,10 @@ describe('PermissionsService', () => { provide: WorkspaceCacheService, useValue: {}, }, + { + provide: ApplicationService, + useValue: {}, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts index 7822e067b1b..28bf63a3b34 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts @@ -47,6 +47,7 @@ export enum PermissionsExceptionCode { COMPOSITE_TYPE_NOT_FOUND = 'COMPOSITE_TYPE_NOT_FOUND', ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET = 'ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET', ROLE_CANNOT_BE_ASSIGNED_TO_USERS = 'ROLE_CANNOT_BE_ASSIGNED_TO_USERS', + APPLICATION_ROLE_NOT_FOUND = 'APPLICATION_ROLE_NOT_FOUND', } const getPermissionsExceptionUserFriendlyMessage = ( @@ -137,6 +138,8 @@ const getPermissionsExceptionUserFriendlyMessage = ( return msg`Role must have at least one target.`; case PermissionsExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_USERS: return msg`This role cannot be assigned to users.`; + case PermissionsExceptionCode.APPLICATION_ROLE_NOT_FOUND: + return msg`No role assigned to the application.`; default: assertUnreachable(code); } @@ -184,4 +187,5 @@ export enum PermissionsExceptionMessage { EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'Empty field permission not allowed', ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET = 'Role must be assignable to at least one target type', ROLE_CANNOT_BE_ASSIGNED_TO_USERS = 'Role cannot be assigned to users', + APPLICATION_ROLE_NOT_FOUND = 'Application role not found', } diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts index 8685741891c..a2f64f024ce 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module'; @@ -19,13 +20,14 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache RoleEntity, RoleTargetEntity, ApiKeyEntity, - WorkspaceEntity, + ApplicationEntity, ]), FeatureFlagModule, TypeOrmModule.forFeature([UserWorkspaceEntity]), UserRoleModule, WorkspaceCacheModule, RoleTargetModule, + ApplicationModule, ], providers: [ApiKeyRoleService, PermissionsService], exports: [PermissionsService, ApiKeyRoleService], diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts index 93e1e19f3b6..d7ae8ecfb65 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts @@ -7,6 +7,7 @@ import { isDefined } from 'twenty-shared/utils'; import { In, Repository } from 'typeorm'; import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { TOOL_PERMISSION_FLAGS } from 'src/engine/metadata-modules/permissions/constants/tool-permission-flags'; import { PermissionsException, @@ -27,6 +28,7 @@ export class PermissionsService { private readonly apiKeyRoleService: ApiKeyRoleService, @InjectRepository(RoleEntity) private readonly roleRepository: Repository, + private readonly applicationService: ApplicationService, ) {} private isToolPermission(feature: string) { @@ -126,11 +128,13 @@ export class PermissionsService { workspaceId, setting, apiKeyId, + applicationId, }: { userWorkspaceId?: string; workspaceId: string; setting: PermissionFlagType; apiKeyId?: string; + applicationId?: string; }): Promise { if (isDefined(apiKeyId)) { const roleId = await this.apiKeyRoleService.getRoleIdForApiKeyId( @@ -177,6 +181,31 @@ export class PermissionsService { return this.checkRolePermissions(roleOfUserWorkspace, setting); } + if (applicationId) { + const applicationRoleId = + await this.applicationService.findApplicationRoleId( + applicationId, + workspaceId, + ); + + const role = await this.roleRepository.findOne({ + where: { id: applicationRoleId, workspaceId }, + relations: ['permissionFlags'], + }); + + if (!isDefined(role)) { + throw new PermissionsException( + PermissionsExceptionMessage.APPLICATION_ROLE_NOT_FOUND, + PermissionsExceptionCode.APPLICATION_ROLE_NOT_FOUND, + { + userFriendlyMessage: msg`The application does not have a valid role assigned. Please check your application configuration.`, + }, + ); + } + + return this.checkRolePermissions(role, setting); + } + throw new PermissionsException( PermissionsExceptionMessage.NO_AUTHENTICATION_CONTEXT, PermissionsExceptionCode.NO_AUTHENTICATION_CONTEXT, diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts index 03ca35cd4d8..01074ffd324 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts @@ -69,6 +69,7 @@ export const permissionGraphqlApiExceptionHandler = ( case PermissionsExceptionCode.JOIN_COLUMN_NAME_REQUIRED: case PermissionsExceptionCode.COMPOSITE_TYPE_NOT_FOUND: case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND: + case PermissionsExceptionCode.APPLICATION_ROLE_NOT_FOUND: throw error; default: { return assertUnreachable(error.code);