Introduce permission context into logic functions

This commit is contained in:
Weiko
2026-05-27 19:04:49 +02:00
parent 6f3541fd7c
commit ebb5758810
19 changed files with 579 additions and 2 deletions
@@ -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<PermissionContextDemoBody>,
): Promise<object> => {
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,
},
});
@@ -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';
@@ -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,
};
};
@@ -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],
@@ -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<ApplicationRegistrationVariableEntity>,
) {}
@@ -86,12 +90,14 @@ export class LogicFunctionExecutorService {
payload,
userId,
userWorkspaceId,
roleId,
}: {
logicFunctionId: string;
workspaceId: string;
payload: object;
userId?: string;
userWorkspaceId?: string;
roleId?: string;
}): Promise<LogicFunctionExecuteResult> {
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<LogicFunctionPermissionContext> {
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).
@@ -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,
}),
),
);
@@ -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<LogicFunctionEntity>,
) {}
private async resolveCallerRoleId({
authContext,
workspaceId,
}: {
authContext: AuthContext;
workspaceId: string;
}): Promise<string | undefined> {
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) {
@@ -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) {
@@ -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,
@@ -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<LogicFunctionExecutionResultDTO> {
try {
return await this.logicFunctionFromSourceService.executeOneFromSource({
id,
payload,
workspaceId,
userWorkspaceId,
});
} catch (error) {
return logicFunctionGraphQLApiExceptionHandler(error);
@@ -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<LogicFunctionExecutionResultDTO> {
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 {
@@ -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<string, boolean>;
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<string, boolean> = {};
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: {
@@ -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;
};
@@ -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 {}
@@ -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) {
@@ -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<object> => {
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<object> => {
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<string, boolean>;
objectsPermissions: Record<string, unknown>;
} | 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);
});
});
@@ -0,0 +1 @@
export const DEFAULT_PERMISSION_CONTEXT_NAME = 'TWENTY_PERMISSION_CONTEXT';
@@ -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';
@@ -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<string, boolean>;
objectsPermissions: Record<string, LogicFunctionObjectPermissions>;
};