From ebb5758810c8b60eb3f6ac6291e0f61db2f2e61e Mon Sep 17 00:00:00 2001 From: Weiko Date: Wed, 27 May 2026 19:04:49 +0200 Subject: [PATCH] Introduce permission context into logic functions --- .../permission-context-demo.function.ts | 67 +++++++++++ .../src/sdk/logic-function/index.ts | 8 ++ .../get-permission-context.ts | 113 ++++++++++++++++++ .../logic-function-executor.module.ts | 2 + .../logic-function-executor.service.ts | 95 +++++++++++++++ .../jobs/logic-function-trigger.job.ts | 2 + .../triggers/route/route-trigger.service.ts | 44 +++++++ .../services/tool-executor.service.ts | 5 + .../logic-function/logic-function.module.ts | 2 + .../logic-function/logic-function.resolver.ts | 3 + .../logic-function-from-source.service.ts | 13 ++ .../permissions/permissions.service.ts | 66 ++++++++++ ...ole-id-from-role-permission-config.util.ts | 25 ++++ .../logic-function-action.module.ts | 17 ++- .../logic-function.workflow-action.ts | 7 ++ ...gic-function-execution.integration-spec.ts | 69 +++++++++++ .../constants/DefaultPermissionContextName.ts | 1 + .../twenty-shared/src/application/index.ts | 9 ++ .../logicFunctionPermissionContextType.ts | 33 +++++ 19 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-apps/internal/self-hosting/src/logic-functions/permission-context-demo.function.ts create mode 100644 packages/twenty-sdk/src/sdk/logic-function/permission-context/get-permission-context.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/get-role-id-from-role-permission-config.util.ts create mode 100644 packages/twenty-shared/src/application/constants/DefaultPermissionContextName.ts create mode 100644 packages/twenty-shared/src/application/logicFunctionPermissionContextType.ts diff --git a/packages/twenty-apps/internal/self-hosting/src/logic-functions/permission-context-demo.function.ts b/packages/twenty-apps/internal/self-hosting/src/logic-functions/permission-context-demo.function.ts new file mode 100644 index 00000000000..3518cfbd860 --- /dev/null +++ b/packages/twenty-apps/internal/self-hosting/src/logic-functions/permission-context-demo.function.ts @@ -0,0 +1,67 @@ +import { defineLogicFunction } from 'twenty-sdk/define'; +import { + FieldPermissionLevel, + getPermissionContext, + ObjectPermissionLevel, + type RoutePayload, +} from 'twenty-sdk/logic-function'; + +type PermissionContextDemoBody = { + objectUniversalIdentifier?: string; + fieldUniversalIdentifier?: string; + permissionFlagUniversalIdentifier?: string; +}; + +export const main = async ( + params: RoutePayload, +): Promise => { + const { + objectUniversalIdentifier, + fieldUniversalIdentifier, + permissionFlagUniversalIdentifier, + } = params.body || {}; + + const permissions = getPermissionContext(); + + return { + canReadAllObjectRecords: permissions.canReadAllObjectRecords, + canUpdateAllObjectRecords: permissions.canUpdateAllObjectRecords, + canAccessAllTools: permissions.canAccessAllTools, + canUpdateAllSettings: permissions.canUpdateAllSettings, + canReadObject: objectUniversalIdentifier + ? permissions.hasObjectPermission( + objectUniversalIdentifier, + ObjectPermissionLevel.READ, + ) + : null, + canUpdateObject: objectUniversalIdentifier + ? permissions.hasObjectPermission( + objectUniversalIdentifier, + ObjectPermissionLevel.UPDATE, + ) + : null, + canReadField: + objectUniversalIdentifier && fieldUniversalIdentifier + ? permissions.hasFieldPermission( + objectUniversalIdentifier, + fieldUniversalIdentifier, + FieldPermissionLevel.READ, + ) + : null, + hasPermissionFlag: permissionFlagUniversalIdentifier + ? permissions.hasPermissionFlag(permissionFlagUniversalIdentifier) + : null, + }; +}; + +export default defineLogicFunction({ + universalIdentifier: '0ef6d1e4-1a89-4b30-9c43-703fec036c89', + name: 'permission-context-demo', + timeoutSeconds: 10, + handler: main, + httpRouteTriggerSettings: { + path: '/webhook/permission-context-demo', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-sdk/src/sdk/logic-function/index.ts b/packages/twenty-sdk/src/sdk/logic-function/index.ts index 812605b566c..f0275061dcf 100644 --- a/packages/twenty-sdk/src/sdk/logic-function/index.ts +++ b/packages/twenty-sdk/src/sdk/logic-function/index.ts @@ -44,3 +44,11 @@ export type { ListConnectionsFilter } from '@/sdk/logic-function/connections/lis export { findConnectionForRequest } from '@/sdk/logic-function/connections/find-connection-for-request'; export { AppConnectionAuthFailedError } from '@/sdk/logic-function/connections/errors/app-connection-auth-failed.error'; export type { AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type'; + +export { getPermissionContext } from '@/sdk/logic-function/permission-context/get-permission-context'; +export type { PermissionContext } from '@/sdk/logic-function/permission-context/get-permission-context'; +export { + ObjectPermissionLevel, + FieldPermissionLevel, +} from 'twenty-shared/application'; +export type { LogicFunctionPermissionContext } from 'twenty-shared/application'; diff --git a/packages/twenty-sdk/src/sdk/logic-function/permission-context/get-permission-context.ts b/packages/twenty-sdk/src/sdk/logic-function/permission-context/get-permission-context.ts new file mode 100644 index 00000000000..a177609172a --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/permission-context/get-permission-context.ts @@ -0,0 +1,113 @@ +import { + DEFAULT_PERMISSION_CONTEXT_NAME, + FieldPermissionLevel, + type LogicFunctionPermissionContext, + ObjectPermissionLevel, +} from 'twenty-shared/application'; + +const EMPTY_PERMISSION_CONTEXT: LogicFunctionPermissionContext = { + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canUpdateAllSettings: false, + canAccessAllTools: false, + permissionFlags: {}, + objectsPermissions: {}, +}; + +export type PermissionContext = LogicFunctionPermissionContext & { + hasObjectPermission: ( + objectUniversalIdentifier: string, + level: ObjectPermissionLevel, + ) => boolean; + hasFieldPermission: ( + objectUniversalIdentifier: string, + fieldUniversalIdentifier: string, + level: FieldPermissionLevel, + ) => boolean; + hasPermissionFlag: (flagUniversalIdentifier: string) => boolean; +}; + +const parsePermissionContext = (): LogicFunctionPermissionContext => { + const raw = process.env[DEFAULT_PERMISSION_CONTEXT_NAME]; + + if (!raw) { + return EMPTY_PERMISSION_CONTEXT; + } + + try { + return JSON.parse(raw) as LogicFunctionPermissionContext; + } catch { + return EMPTY_PERMISSION_CONTEXT; + } +}; + +export const getPermissionContext = (): PermissionContext => { + const context = parsePermissionContext(); + + const hasObjectPermission = ( + objectUniversalIdentifier: string, + level: ObjectPermissionLevel, + ): boolean => { + const objectPermissions = + context.objectsPermissions[objectUniversalIdentifier]; + + if (!objectPermissions) { + return false; + } + + switch (level) { + case ObjectPermissionLevel.READ: + return objectPermissions.canRead; + case ObjectPermissionLevel.UPDATE: + return objectPermissions.canUpdate; + case ObjectPermissionLevel.SOFT_DELETE: + return objectPermissions.canSoftDelete; + case ObjectPermissionLevel.DESTROY: + return objectPermissions.canDestroy; + default: + return false; + } + }; + + const hasFieldPermission = ( + objectUniversalIdentifier: string, + fieldUniversalIdentifier: string, + level: FieldPermissionLevel, + ): boolean => { + const objectLevel = + level === FieldPermissionLevel.READ + ? ObjectPermissionLevel.READ + : ObjectPermissionLevel.UPDATE; + + if (!hasObjectPermission(objectUniversalIdentifier, objectLevel)) { + return false; + } + + const restrictedField = + context.objectsPermissions[objectUniversalIdentifier]?.restrictedFields[ + fieldUniversalIdentifier + ]; + + if (!restrictedField) { + return true; + } + + if (level === FieldPermissionLevel.READ) { + return restrictedField.canRead !== false; + } + + return restrictedField.canUpdate !== false; + }; + + const hasPermissionFlag = (flagUniversalIdentifier: string): boolean => + context.permissionFlags[flagUniversalIdentifier] === true; + + return { + ...context, + hasObjectPermission, + hasFieldPermission, + hasPermissionFlag, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts index a5b020283a1..b50b0e06dee 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts @@ -8,6 +8,7 @@ import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @@ -20,6 +21,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache SubscriptionsModule, WorkspaceCacheModule, BillingModule, + PermissionsModule, TypeOrmModule.forFeature([ApplicationRegistrationVariableEntity]), ], providers: [LogicFunctionExecutorService], diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts index cd80acc724c..15932a68c52 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts @@ -5,6 +5,8 @@ import { DEFAULT_API_KEY_NAME, DEFAULT_API_URL_NAME, DEFAULT_APP_ACCESS_TOKEN_NAME, + DEFAULT_PERMISSION_CONTEXT_NAME, + type LogicFunctionPermissionContext, } from 'twenty-shared/application'; import { isDefined } from 'twenty-shared/utils'; import { Not, Repository } from 'typeorm'; @@ -38,6 +40,7 @@ import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum'; import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum'; import { SubscriptionService } from 'src/engine/subscriptions/subscription.service'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @@ -76,6 +79,7 @@ export class LogicFunctionExecutorService { private readonly workspaceEventEmitter: WorkspaceEventEmitter, private readonly billingService: BillingService, private readonly billingUsageService: BillingUsageService, + private readonly permissionsService: PermissionsService, @InjectRepository(ApplicationRegistrationVariableEntity) private readonly applicationRegistrationVariableRepository: Repository, ) {} @@ -86,12 +90,14 @@ export class LogicFunctionExecutorService { payload, userId, userWorkspaceId, + roleId, }: { logicFunctionId: string; workspaceId: string; payload: object; userId?: string; userWorkspaceId?: string; + roleId?: string; }): Promise { await this.throttleExecution(workspaceId); @@ -107,6 +113,7 @@ export class LogicFunctionExecutorService { flatApplicationVariables, userId, userWorkspaceId, + roleId, }); const driver = this.logicFunctionDriverFactory.getCurrentDriver(); @@ -232,12 +239,14 @@ export class LogicFunctionExecutorService { flatApplicationVariables, userId, userWorkspaceId, + roleId, }: { workspaceId: string; flatApplication: FlatApplication; flatApplicationVariables: FlatApplicationVariable[]; userId?: string; userWorkspaceId?: string; + roleId?: string; }) { const applicationAccessToken = await this.applicationTokenService.generateApplicationAccessToken({ @@ -257,10 +266,16 @@ export class LogicFunctionExecutorService { this.secretEncryptionService, ); + const permissionContext = await this.buildPermissionContext({ + workspaceId, + roleId, + }); + return { [DEFAULT_API_URL_NAME]: baseUrl ?? '', [DEFAULT_APP_ACCESS_TOKEN_NAME]: applicationAccessToken.token, [DEFAULT_API_KEY_NAME]: applicationAccessToken.token, + [DEFAULT_PERMISSION_CONTEXT_NAME]: JSON.stringify(permissionContext), APPLICATION_ID: flatApplication.id, // Server variables first, workspace variables override. Workspace-level // values let a specific tenant customize a server default. @@ -269,6 +284,86 @@ export class LogicFunctionExecutorService { }; } + private async buildPermissionContext({ + workspaceId, + roleId, + }: { + workspaceId: string; + roleId?: string; + }): Promise { + if (!isDefined(roleId)) { + return { + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canUpdateAllSettings: false, + canAccessAllTools: false, + permissionFlags: {}, + objectsPermissions: {}, + }; + } + + const { capabilities, permissionFlags, objectsPermissions } = + await this.permissionsService.getRolePermissionContext({ + roleId, + workspaceId, + }); + + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + ]); + + const objectsPermissionsByUniversalIdentifier: LogicFunctionPermissionContext['objectsPermissions'] = + {}; + + for (const [objectMetadataId, objectPermissions] of Object.entries( + objectsPermissions, + )) { + const objectUniversalIdentifier = + flatObjectMetadataMaps.universalIdentifierById[objectMetadataId]; + + if (!isDefined(objectUniversalIdentifier)) { + continue; + } + + const restrictedFields: LogicFunctionPermissionContext['objectsPermissions'][string]['restrictedFields'] = + {}; + + for (const [fieldMetadataId, fieldPermissions] of Object.entries( + objectPermissions.restrictedFields, + )) { + const fieldUniversalIdentifier = + flatFieldMetadataMaps.universalIdentifierById[fieldMetadataId]; + + if (!isDefined(fieldUniversalIdentifier)) { + continue; + } + + restrictedFields[fieldUniversalIdentifier] = { + canRead: fieldPermissions.canRead, + canUpdate: fieldPermissions.canUpdate, + }; + } + + objectsPermissionsByUniversalIdentifier[objectUniversalIdentifier] = { + canRead: objectPermissions.canReadObjectRecords, + canUpdate: objectPermissions.canUpdateObjectRecords, + canSoftDelete: objectPermissions.canSoftDeleteObjectRecords, + canDestroy: objectPermissions.canDestroyObjectRecords, + restrictedFields, + }; + } + + return { + ...capabilities, + permissionFlags, + objectsPermissions: objectsPermissionsByUniversalIdentifier, + }; + } + // Resolves encrypted server-level variables (ApplicationRegistrationVariable) // for the application's registration. Returns an empty object when the // application isn't linked to a registration (legacy LOCAL apps). diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job.ts index 901bdb64b7f..745c3b7d354 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job.ts @@ -11,6 +11,7 @@ export type LogicFunctionTriggerJobData = { payload?: object; userId?: string; userWorkspaceId?: string; + roleId?: string; }; @Processor({ @@ -33,6 +34,7 @@ export class LogicFunctionTriggerJob { payload: logicFunctionPayload.payload ?? {}, userId: logicFunctionPayload.userId, userWorkspaceId: logicFunctionPayload.userWorkspaceId, + roleId: logicFunctionPayload.roleId, }), ), ); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts index 7b37ed30a95..6a6192ef594 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts @@ -8,6 +8,7 @@ import { IsNull, Not, Repository } from 'typeorm'; import { HTTPMethod } from 'twenty-shared/types'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { RouteTriggerException, @@ -24,6 +25,7 @@ import { LogicFunctionExecutionExceptionCode, LogicFunctionExecutorService, } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { CustomException } from 'src/utils/custom-exception'; @Injectable() @@ -34,10 +36,46 @@ export class RouteTriggerService { private readonly accessTokenService: AccessTokenService, private readonly logicFunctionExecutorService: LogicFunctionExecutorService, private readonly workspaceDomainsService: WorkspaceDomainsService, + private readonly workspaceCacheService: WorkspaceCacheService, @InjectRepository(LogicFunctionEntity) private readonly logicFunctionRepository: Repository, ) {} + private async resolveCallerRoleId({ + authContext, + workspaceId, + }: { + authContext: AuthContext; + workspaceId: string; + }): Promise { + if (isDefined(authContext.apiKey)) { + const { apiKeyRoleMap } = await this.workspaceCacheService.getOrRecompute( + workspaceId, + ['apiKeyRoleMap'], + ); + + return apiKeyRoleMap[authContext.apiKey.id]; + } + + if ( + isDefined(authContext.application) && + isDefined(authContext.application.defaultRoleId) + ) { + return authContext.application.defaultRoleId; + } + + if (isDefined(authContext.userWorkspaceId)) { + const { userWorkspaceRoleMap } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'userWorkspaceRoleMap', + ]); + + return userWorkspaceRoleMap[authContext.userWorkspaceId]; + } + + return undefined; + } + private async getLogicFunctionWithPathParamsOrFail({ request, httpMethod, @@ -170,6 +208,7 @@ export class RouteTriggerService { let userWorkspaceId: string | null = null; let userId: string | null = null; + let roleId: string | undefined; if (httpRouteSettings?.isAuthRequired) { const authContext = await this.validateWorkspaceFromRequest({ @@ -179,6 +218,10 @@ export class RouteTriggerService { userWorkspaceId = authContext.userWorkspaceId ?? null; userId = authContext.user?.id ?? null; + roleId = await this.resolveCallerRoleId({ + authContext, + workspaceId: logicFunction.workspaceId, + }); } const event = buildLogicFunctionEvent({ @@ -197,6 +240,7 @@ export class RouteTriggerService { payload: event, ...(userId ? { userId } : {}), ...(userWorkspaceId ? { userWorkspaceId } : {}), + ...(roleId ? { roleId } : {}), }); } catch (error) { if (error instanceof RouteTriggerException) { diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts index 635382b00a0..404ed1770fb 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts @@ -240,6 +240,11 @@ export class ToolExecutorService { logicFunctionId: ref.logicFunctionId, workspaceId: context.workspaceId, payload: args, + roleId: context.roleId, + ...(isDefined(context.userId) ? { userId: context.userId } : {}), + ...(isDefined(context.userWorkspaceId) + ? { userWorkspaceId: context.userWorkspaceId } + : {}), }); if (result.error) { diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.module.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.module.ts index f52746eb4a6..2da0d6282dc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.module.ts @@ -20,6 +20,7 @@ import { LogicFunctionFromSourceHelperService } from 'src/engine/metadata-module import { LogicFunctionFromSourceService } from 'src/engine/metadata-modules/logic-function/services/logic-function-from-source.service'; import { WorkspaceFlatLogicFunctionMapCacheService } from 'src/engine/metadata-modules/logic-function/services/workspace-flat-logic-function-map-cache.service'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; @@ -32,6 +33,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace AuditModule, FeatureFlagModule, PermissionsModule, + UserRoleModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, WorkspaceMigrationModule, LogicFunctionLayerModule, diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.resolver.ts index 96c6cc603be..32aa30d945d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.resolver.ts @@ -9,6 +9,7 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { FeatureFlagGuard } from 'src/engine/guards/feature-flag.guard'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; @@ -170,12 +171,14 @@ export class LogicFunctionResolver { async executeOneLogicFunction( @Args('input') { id, payload }: ExecuteOneLogicFunctionInput, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + @AuthUserWorkspaceId() userWorkspaceId: string, ): Promise { try { return await this.logicFunctionFromSourceService.executeOneFromSource({ id, payload, workspaceId, + userWorkspaceId, }); } catch (error) { return logicFunctionGraphQLApiExceptionHandler(error); diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function-from-source.service.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function-from-source.service.ts index 92e4f0269ad..1ce788a07c9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function-from-source.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function-from-source.service.ts @@ -24,6 +24,7 @@ import { buildUniversalFlatLogicFunctionToCreate } from 'src/engine/metadata-mod import { fromCreateLogicFunctionFromSourceInputToUniversalFlatLogicFunctionToCreate } from 'src/engine/metadata-modules/logic-function/utils/from-create-logic-function-from-source-input-to-universal-flat-logic-function-to-create.util'; import { fromFlatLogicFunctionToLogicFunctionDto } from 'src/engine/metadata-modules/logic-function/utils/from-flat-logic-function-to-logic-function-dto.util'; import { fromUpdateLogicFunctionFromSourceInputToFlatLogicFunctionToUpdate } from 'src/engine/metadata-modules/logic-function/utils/from-update-logic-function-from-source-input-to-flat-logic-function-to-update.util'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; @@ -36,6 +37,7 @@ export class LogicFunctionFromSourceService { private readonly helperService: LogicFunctionFromSourceHelperService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly userRoleService: UserRoleService, ) {} async createOneFromSource({ @@ -346,10 +348,12 @@ export class LogicFunctionFromSourceService { id, payload, workspaceId, + userWorkspaceId, }: { id: string; payload: object; workspaceId: string; + userWorkspaceId?: string; }): Promise { const { flatLogicFunction } = await this.helperService.findLogicFunctionAndApplicationOrThrow({ @@ -361,10 +365,19 @@ export class LogicFunctionFromSourceService { await this.buildOneFromSource({ workspaceId, id }); } + const roleId = isDefined(userWorkspaceId) + ? await this.userRoleService.getRoleIdForUserWorkspace({ + workspaceId, + userWorkspaceId, + }) + : undefined; + const result = await this.logicFunctionExecutorService.execute({ logicFunctionId: id, workspaceId, payload, + ...(isDefined(userWorkspaceId) ? { userWorkspaceId } : {}), + ...(isDefined(roleId) ? { roleId } : {}), }); return { 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 05b1154eec4..3553957dddc 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 @@ -6,6 +6,7 @@ import { PermissionFlagType, SystemPermissionFlag, } from 'twenty-shared/constants'; +import { type ObjectsPermissions } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { In, Repository } from 'typeorm'; @@ -98,6 +99,71 @@ export class PermissionsService { }; } + public async getRolePermissionContext({ + roleId, + workspaceId, + }: { + roleId: string; + workspaceId: string; + }): Promise<{ + capabilities: { + canReadAllObjectRecords: boolean; + canUpdateAllObjectRecords: boolean; + canSoftDeleteAllObjectRecords: boolean; + canDestroyAllObjectRecords: boolean; + canUpdateAllSettings: boolean; + canAccessAllTools: boolean; + }; + permissionFlags: Record; + objectsPermissions: ObjectsPermissions; + }> { + const role = await this.roleRepository.findOne({ + where: { id: roleId, workspaceId }, + relations: ['rolePermissionFlags', 'rolePermissionFlags.permissionFlag'], + }); + + if (!isDefined(role)) { + throw new PermissionsException( + PermissionsExceptionMessage.ROLE_NOT_FOUND, + PermissionsExceptionCode.ROLE_NOT_FOUND, + ); + } + + const permissionFlags: Record = {}; + + for (const feature of Object.values(PermissionFlagType)) { + const hasBasePermission = this.isToolPermission(feature) + ? role.canAccessAllTools + : role.canUpdateAllSettings; + + permissionFlags[SystemPermissionFlag[feature]] = + hasBasePermission || this.roleHasPermissionFlag(role, feature); + } + + for (const rolePermissionFlag of role.rolePermissionFlags ?? []) { + permissionFlags[rolePermissionFlag.permissionFlag.universalIdentifier] = + true; + } + + const { rolesPermissions } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'rolesPermissions', + ]); + + return { + capabilities: { + canReadAllObjectRecords: role.canReadAllObjectRecords, + canUpdateAllObjectRecords: role.canUpdateAllObjectRecords, + canSoftDeleteAllObjectRecords: role.canSoftDeleteAllObjectRecords, + canDestroyAllObjectRecords: role.canDestroyAllObjectRecords, + canUpdateAllSettings: role.canUpdateAllSettings, + canAccessAllTools: role.canAccessAllTools, + }, + permissionFlags, + objectsPermissions: rolesPermissions[roleId] ?? {}, + }; + } + public getDefaultUserWorkspacePermissions = () => ({ permissionFlags: { diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-role-id-from-role-permission-config.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-role-id-from-role-permission-config.util.ts new file mode 100644 index 00000000000..fb55d16640e --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/get-role-id-from-role-permission-config.util.ts @@ -0,0 +1,25 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; + +export const getRoleIdFromRolePermissionConfig = ( + rolePermissionConfig: RolePermissionConfig | null | undefined, +): string | undefined => { + if (!isDefined(rolePermissionConfig)) { + return undefined; + } + + if ('shouldBypassPermissionChecks' in rolePermissionConfig) { + return undefined; + } + + if ('intersectionOf' in rolePermissionConfig) { + return rolePermissionConfig.intersectionOf[0]; + } + + if ('unionOf' in rolePermissionConfig) { + return rolePermissionConfig.unionOf[0]; + } + + return undefined; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function-action.module.ts index 38cdf996d31..f6cbfae7377 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function-action.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function-action.module.ts @@ -1,11 +1,24 @@ import { Module } from '@nestjs/common'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; +import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service'; import { LogicFunctionWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function.workflow-action'; +import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module'; @Module({ - imports: [WorkspaceManyOrAllFlatEntityMapsCacheModule], - providers: [LogicFunctionWorkflowAction], + imports: [ + WorkspaceManyOrAllFlatEntityMapsCacheModule, + ApplicationModule, + WorkflowRunModule, + UserWorkspaceModule, + UserRoleModule, + RoleModule, + ], + providers: [WorkflowExecutionContextService, LogicFunctionWorkflowAction], exports: [LogicFunctionWorkflowAction], }) export class LogicFunctionActionModule {} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function.workflow-action.ts index acf31b081a2..a8caa5c0f2a 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/logic-function/logic-function.workflow-action.ts @@ -7,6 +7,8 @@ import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/inte import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { getRoleIdFromRolePermissionConfig } from 'src/engine/twenty-orm/utils/get-role-id-from-role-permission-config.util'; +import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service'; import { WorkflowStepExecutorException, WorkflowStepExecutorExceptionCode, @@ -22,6 +24,7 @@ export class LogicFunctionWorkflowAction implements WorkflowAction { constructor( private readonly logicFunctionExecutorService: LogicFunctionExecutorService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly workflowExecutionContextService: WorkflowExecutionContextService, ) {} async execute({ @@ -76,10 +79,14 @@ export class LogicFunctionWorkflowAction implements WorkflowAction { ); } + const { rolePermissionConfig } = + await this.workflowExecutionContextService.getExecutionContext(runInfo); + const result = await this.logicFunctionExecutorService.execute({ logicFunctionId: workflowActionInput.logicFunctionId, workspaceId, payload: workflowActionInput.logicFunctionInput, + roleId: getRoleIdFromRolePermissionConfig(rolePermissionConfig), }); if (result.error) { diff --git a/packages/twenty-server/test/integration/metadata/suites/logic-function/logic-function-execution.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/logic-function/logic-function-execution.integration-spec.ts index ce075df8fd7..7177779f300 100644 --- a/packages/twenty-server/test/integration/metadata/suites/logic-function/logic-function-execution.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/logic-function/logic-function-execution.integration-spec.ts @@ -3,8 +3,17 @@ import { deleteLogicFunction } from 'test/integration/metadata/suites/logic-func import { executeLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/execute-logic-function.util'; import { updateLogicFunctionSource } from 'test/integration/metadata/suites/logic-function/utils/update-logic-function-source.util'; +import { SystemPermissionFlag } from 'twenty-shared/constants'; + import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto'; +// Function returning the permission context injected via TWENTY_PERMISSION_CONTEXT +const PERMISSION_CONTEXT_FUNCTION_CODE = `export const main = async (): Promise => { + const raw = process.env.TWENTY_PERMISSION_CONTEXT; + + return { permissionContext: raw ? JSON.parse(raw) : null }; +};`; + // Default template function code that matches the expected behavior const DEFAULT_TEMPLATE_FUNCTION_CODE = `export const main = async (params: { a: string; b: number }): Promise => { return { message: \`Toto: \${params.a} and \${params.b}\` }; @@ -290,4 +299,64 @@ describe('Logic Function Execution', () => { }); expect(errorResult?.data).toBeNull(); }); + + it('should inject the caller permission context as an env variable', async () => { + const { data: createData } = await createOneLogicFunction({ + input: { + name: 'Permission Context Test', + }, + expectToFail: false, + }); + + const functionId = createData?.createOneLogicFunction?.id; + + expect(functionId).toBeDefined(); + createdFunctionIds.push(functionId); + + await updateLogicFunctionSource({ + input: { + id: functionId, + update: { + sourceHandlerCode: PERMISSION_CONTEXT_FUNCTION_CODE, + }, + }, + expectToFail: false, + }); + + const { data: executeData } = await executeLogicFunction({ + input: { + id: functionId, + payload: {}, + }, + expectToFail: false, + }); + + const result = executeData?.executeOneLogicFunction; + + if (result?.status !== LogicFunctionExecutionStatus.SUCCESS) { + throw new Error(JSON.stringify(result?.error, null, 2)); + } + + const { permissionContext } = result?.data as unknown as { + permissionContext: { + canReadAllObjectRecords: boolean; + canUpdateAllObjectRecords: boolean; + canUpdateAllSettings: boolean; + canAccessAllTools: boolean; + permissionFlags: Record; + objectsPermissions: Record; + } | null; + }; + + expect(permissionContext).not.toBeNull(); + expect(typeof permissionContext?.canReadAllObjectRecords).toBe('boolean'); + expect(typeof permissionContext?.objectsPermissions).toBe('object'); + + // executeOneLogicFunction is guarded by the WORKFLOWS permission flag, so + // the resolved caller role is guaranteed to carry it — keyed by its + // universalIdentifier. + expect( + permissionContext?.permissionFlags[SystemPermissionFlag.WORKFLOWS], + ).toBe(true); + }); }); diff --git a/packages/twenty-shared/src/application/constants/DefaultPermissionContextName.ts b/packages/twenty-shared/src/application/constants/DefaultPermissionContextName.ts new file mode 100644 index 00000000000..09e4425f2d2 --- /dev/null +++ b/packages/twenty-shared/src/application/constants/DefaultPermissionContextName.ts @@ -0,0 +1 @@ +export const DEFAULT_PERMISSION_CONTEXT_NAME = 'TWENTY_PERMISSION_CONTEXT'; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 47a7081f967..e5fd6bb631d 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -21,6 +21,7 @@ export { ASSETS_DIR } from './constants/AssetDirectory'; export { DEFAULT_API_KEY_NAME } from './constants/DefaultApiKeyName'; export { DEFAULT_API_URL_NAME } from './constants/DefaultApiUrlName'; export { DEFAULT_APP_ACCESS_TOKEN_NAME } from './constants/DefaultAppAccessTokenName'; +export { DEFAULT_PERMISSION_CONTEXT_NAME } from './constants/DefaultPermissionContextName'; export { GENERATED_DIR } from './constants/GeneratedDirectory'; export { NODE_ESM_CJS_BANNER } from './constants/NodeEsmCjsBanner'; export { OUTPUT_DIR } from './constants/OutputDirectory'; @@ -44,6 +45,14 @@ export type { DatabaseEventTriggerSettings, HttpRouteTriggerSettings, } from './logicFunctionManifestType'; +export type { + LogicFunctionObjectPermissions, + LogicFunctionPermissionContext, +} from './logicFunctionPermissionContextType'; +export { + ObjectPermissionLevel, + FieldPermissionLevel, +} from './logicFunctionPermissionContextType'; export type { Manifest } from './manifestType'; export type { NavigationMenuItemManifest } from './navigationMenuItemManifestType'; export type { OAuthConnectionProviderConfig } from './oauthConnectionProviderConfigType'; diff --git a/packages/twenty-shared/src/application/logicFunctionPermissionContextType.ts b/packages/twenty-shared/src/application/logicFunctionPermissionContextType.ts new file mode 100644 index 00000000000..359a80334b7 --- /dev/null +++ b/packages/twenty-shared/src/application/logicFunctionPermissionContextType.ts @@ -0,0 +1,33 @@ +export enum ObjectPermissionLevel { + READ = 'READ', + UPDATE = 'UPDATE', + SOFT_DELETE = 'SOFT_DELETE', + DESTROY = 'DESTROY', +} + +export enum FieldPermissionLevel { + READ = 'READ', + UPDATE = 'UPDATE', +} + +export type LogicFunctionObjectPermissions = { + canRead: boolean; + canUpdate: boolean; + canSoftDelete: boolean; + canDestroy: boolean; + restrictedFields: Record< + string, + { canRead?: boolean | null; canUpdate?: boolean | null } + >; +}; + +export type LogicFunctionPermissionContext = { + canReadAllObjectRecords: boolean; + canUpdateAllObjectRecords: boolean; + canSoftDeleteAllObjectRecords: boolean; + canDestroyAllObjectRecords: boolean; + canUpdateAllSettings: boolean; + canAccessAllTools: boolean; + permissionFlags: Record; + objectsPermissions: Record; +};