Introduce permission context into logic functions
This commit is contained in:
+67
@@ -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';
|
||||
|
||||
+113
@@ -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,
|
||||
};
|
||||
};
|
||||
+2
@@ -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],
|
||||
|
||||
+95
@@ -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).
|
||||
|
||||
+2
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
+44
@@ -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) {
|
||||
|
||||
+5
@@ -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) {
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+13
@@ -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: {
|
||||
|
||||
+25
@@ -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;
|
||||
};
|
||||
+15
-2
@@ -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
@@ -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) {
|
||||
|
||||
+69
@@ -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>;
|
||||
};
|
||||
Reference in New Issue
Block a user