Permission checks on twentyORM global manager (#11477)

In this PR we are handling permissions when using
twentyORMGlobalManager,
and handling permissions for rest api and api key
This commit is contained in:
Marie
2025-04-23 17:57:48 +02:00
committed by GitHub
parent 28a1354928
commit 4257f30f12
54 changed files with 547 additions and 116 deletions

View File

@ -9,6 +9,7 @@ declare module '@jest/types' {
INVALID_ACCESS_TOKEN: string;
MEMBER_ACCESS_TOKEN: string;
GUEST_ACCESS_TOKEN: string;
API_KEY_ACCESS_TOKEN: string;
}
}
}
@ -20,6 +21,7 @@ declare global {
const INVALID_ACCESS_TOKEN: string;
const MEMBER_ACCESS_TOKEN: string;
const GUEST_ACCESS_TOKEN: string;
const API_KEY_ACCESS_TOKEN: string;
}
export {};

View File

@ -76,6 +76,8 @@ const jestConfig: JestConfigWithTsJest = {
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNzdkNS00Y2I2LWI2MGEtZjRhODM1YTg1ZDYxIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMzk1Ny00OTA4LTljMzYtMjkyOWEyM2Y4MzUzIiwiaWF0IjoxNzM5NDU5NTcwLCJleHAiOjMzMjk3MDU5NTcwfQ.Er7EEU4IP4YlGN79jCLR_6sUBqBfKx2M3G_qGiDpPRo',
GUEST_ACCESS_TOKEN:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC03MTY5LTQyY2YtYmM0Ny0xY2ZlZjE1MjY0YjgiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMTU1My00NWM2LWEwMjgtNWE5MDY0Y2NlMDdmIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtNzE2OS00MmNmLWJjNDctMWNmZWYxNTI2NGIxIiwiaWF0IjoxNzM5ODg4NDcwLCJleHAiOjMzMjk3NDg4NDcwfQ.0NEu-AWGv3l77rs-56Z5Gt0UTU7HDl6qUTHUcMWNrCc',
API_KEY_ACCESS_TOKEN:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzQ0OTgzNzUwLCJleHAiOjQ4OTg1ODM2OTMsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.4xkkwz_uu2xzs_V8hJSaM15fGziT5zS3vq2lM48OHr0',
},
};

View File

@ -12,6 +12,7 @@ import {
getDevSeedCompanyCustomFields,
getDevSeedPeopleCustomFields,
} from 'src/database/typeorm-seeds/metadata/fieldsMetadata';
import { seedApiKey } from 'src/database/typeorm-seeds/workspace/api-key';
import { seedCalendarChannels } from 'src/database/typeorm-seeds/workspace/calendar-channel';
import { seedCalendarChannelEventAssociations } from 'src/database/typeorm-seeds/workspace/calendar-channel-event-association';
import { seedCalendarEventParticipants } from 'src/database/typeorm-seeds/workspace/calendar-event-participants';
@ -184,6 +185,7 @@ export class DataSeedWorkspaceCommand extends CommandRunner {
);
if (dataSourceMetadata.workspaceId === SEED_APPLE_WORKSPACE_ID) {
await seedApiKey(entityManager, dataSourceMetadata.schema);
await seedMessageThread(entityManager, dataSourceMetadata.schema);
await seedConnectedAccount(entityManager, dataSourceMetadata.schema);

View File

@ -88,7 +88,6 @@ export class AddTasksAssignedToMeViewCommand extends ActiveOrSuspendedWorkspaces
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewWorkspaceEntity>(
workspaceId,
'view',
false,
);
const existingView = await viewRepository.findOne({
@ -126,7 +125,6 @@ export class AddTasksAssignedToMeViewCommand extends ActiveOrSuspendedWorkspaces
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewFieldWorkspaceEntity>(
workspaceId,
'viewField',
false,
);
const viewFields = viewDefinition.fields.map((field) => ({
@ -145,7 +143,6 @@ export class AddTasksAssignedToMeViewCommand extends ActiveOrSuspendedWorkspaces
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewFilterWorkspaceEntity>(
workspaceId,
'viewFilter',
false,
);
const viewFilters = viewDefinition.filters.map((filter) => ({
@ -202,7 +199,6 @@ export class AddTasksAssignedToMeViewCommand extends ActiveOrSuspendedWorkspaces
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewGroupWorkspaceEntity>(
workspaceId,
'viewGroup',
false,
);
await viewGroupRepository.insert(viewGroups);

View File

@ -81,7 +81,9 @@ export class UpdateDefaultViewRecordOpeningOnWorkflowObjectsCommand extends Acti
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'view',
failOnMetadataCacheMiss,
{
shouldFailIfMetadataNotFound: failOnMetadataCacheMiss,
},
);
await viewRepository.update(

View File

@ -0,0 +1,26 @@
import { EntityManager } from 'typeorm';
const tableName = 'apiKey';
const API_KEY_ID = '20202020-f401-4d8a-a731-64d007c27bad';
export const seedApiKey = async (
entityManager: EntityManager,
schemaName: string,
) => {
await entityManager
.createQueryBuilder()
.insert()
.into(`${schemaName}.${tableName}`, ['id', 'name', 'expiresAt'])
.orIgnore()
.values([
{
id: API_KEY_ID,
name: 'My api key',
expiresAt: new Date(
new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 100, // In 100 years
),
},
])
.execute();
};

View File

@ -42,6 +42,7 @@ export class ProcessNestedRelationsV2Helper {
authContext,
dataSource,
roleId,
shouldBypassPermissionChecks,
}: {
objectMetadataMaps: ObjectMetadataMaps;
parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps;
@ -52,6 +53,7 @@ export class ProcessNestedRelationsV2Helper {
limit: number;
authContext: AuthContext;
dataSource: WorkspaceDataSource;
shouldBypassPermissionChecks: boolean;
roleId?: string;
}): Promise<void> {
const processRelationTasks = Object.entries(relations).map(
@ -67,6 +69,7 @@ export class ProcessNestedRelationsV2Helper {
limit,
authContext,
dataSource,
shouldBypassPermissionChecks,
roleId,
}),
);
@ -85,6 +88,7 @@ export class ProcessNestedRelationsV2Helper {
limit,
authContext,
dataSource,
shouldBypassPermissionChecks,
roleId,
}: {
objectMetadataMaps: ObjectMetadataMaps;
@ -97,6 +101,7 @@ export class ProcessNestedRelationsV2Helper {
limit: number;
authContext: AuthContext;
dataSource: WorkspaceDataSource;
shouldBypassPermissionChecks: boolean;
roleId?: string;
}): Promise<void> {
const sourceFieldMetadata =
@ -129,6 +134,7 @@ export class ProcessNestedRelationsV2Helper {
const targetObjectRepository = dataSource.getRepository(
targetObjectMetadata.nameSingular,
shouldBypassPermissionChecks,
roleId,
);
@ -199,6 +205,8 @@ export class ProcessNestedRelationsV2Helper {
limit,
authContext,
dataSource,
shouldBypassPermissionChecks,
roleId,
});
}
}

View File

@ -46,6 +46,7 @@ export class ProcessNestedRelationsHelper {
authContext,
dataSource,
isNewRelationEnabled,
shouldBypassPermissionChecks,
roleId,
}: {
objectMetadataMaps: ObjectMetadataMaps;
@ -58,6 +59,7 @@ export class ProcessNestedRelationsHelper {
authContext: AuthContext;
dataSource: WorkspaceDataSource;
isNewRelationEnabled: boolean;
shouldBypassPermissionChecks: boolean;
roleId?: string;
}): Promise<void> {
if (isNewRelationEnabled) {
@ -71,6 +73,7 @@ export class ProcessNestedRelationsHelper {
limit,
authContext,
dataSource,
shouldBypassPermissionChecks,
roleId,
});
}
@ -89,6 +92,7 @@ export class ProcessNestedRelationsHelper {
authContext,
dataSource,
isNewRelationEnabled,
shouldBypassPermissionChecks,
roleId,
}),
);
@ -108,6 +112,7 @@ export class ProcessNestedRelationsHelper {
authContext,
dataSource,
isNewRelationEnabled,
shouldBypassPermissionChecks,
roleId,
}: {
objectMetadataMaps: ObjectMetadataMaps;
@ -118,9 +123,10 @@ export class ProcessNestedRelationsHelper {
nestedRelations: any;
aggregate: Record<string, AggregationField>;
limit: number;
authContext: any;
authContext: AuthContext;
dataSource: DataSource;
isNewRelationEnabled: boolean;
shouldBypassPermissionChecks: boolean;
roleId?: string;
}): Promise<void> {
const relationFieldMetadata =
@ -148,6 +154,7 @@ export class ProcessNestedRelationsHelper {
authContext,
dataSource,
isNewRelationEnabled,
shouldBypassPermissionChecks,
roleId,
});
}
@ -164,6 +171,7 @@ export class ProcessNestedRelationsHelper {
authContext,
dataSource,
isNewRelationEnabled,
shouldBypassPermissionChecks,
roleId,
}: {
objectMetadataMaps: ObjectMetadataMaps;
@ -177,6 +185,7 @@ export class ProcessNestedRelationsHelper {
authContext: AuthContext;
dataSource: WorkspaceDataSource;
isNewRelationEnabled: boolean;
shouldBypassPermissionChecks: boolean;
roleId?: string;
}): Promise<void> {
const { inverseRelationName, referenceObjectMetadata } =
@ -188,6 +197,7 @@ export class ProcessNestedRelationsHelper {
const relationRepository = dataSource.getRepository(
referenceObjectMetadata.nameSingular,
shouldBypassPermissionChecks,
roleId,
);
@ -248,6 +258,8 @@ export class ProcessNestedRelationsHelper {
authContext,
dataSource,
isNewRelationEnabled,
shouldBypassPermissionChecks,
roleId,
});
}
}
@ -264,6 +276,7 @@ export class ProcessNestedRelationsHelper {
authContext,
dataSource,
isNewRelationEnabled,
shouldBypassPermissionChecks,
roleId,
}: {
objectMetadataMaps: ObjectMetadataMaps;
@ -277,6 +290,7 @@ export class ProcessNestedRelationsHelper {
authContext: any;
dataSource: WorkspaceDataSource;
isNewRelationEnabled: boolean;
shouldBypassPermissionChecks: boolean;
roleId?: string;
}): Promise<void> {
const { referenceObjectMetadata } = this.getRelationMetadata({
@ -287,6 +301,7 @@ export class ProcessNestedRelationsHelper {
const relationRepository = dataSource.getRepository(
referenceObjectMetadata.nameSingular,
shouldBypassPermissionChecks,
roleId,
);
@ -346,6 +361,8 @@ export class ProcessNestedRelationsHelper {
authContext,
dataSource,
isNewRelationEnabled,
shouldBypassPermissionChecks,
roleId,
});
}
}

View File

@ -45,6 +45,7 @@ export type GraphqlQueryResolverExecutionArgs<Input extends ResolverArgs> = {
repository: WorkspaceRepository<ObjectLiteral>;
graphqlQueryParser: GraphqlQueryParser;
graphqlQuerySelectedFieldsResult: GraphqlQuerySelectedFieldsResult;
isExecutedByApiKey: boolean;
roleId?: string;
};
@ -123,8 +124,12 @@ export abstract class GraphqlQueryBaseResolverService<
workspaceId: authContext.workspace.id,
});
const executedByApiKey = isDefined(authContext.apiKey);
const shouldBypassPermissionChecks = executedByApiKey;
const repository = dataSource.getRepository(
objectMetadataItemWithFieldMaps.nameSingular,
shouldBypassPermissionChecks,
roleId,
);
@ -150,6 +155,7 @@ export abstract class GraphqlQueryBaseResolverService<
repository,
graphqlQueryParser,
graphqlQuerySelectedFieldsResult,
isExecutedByApiKey: executedByApiKey,
roleId,
};

View File

@ -53,12 +53,15 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
objectMetadataItemWithFieldMaps,
);
const shouldBypassPermissionChecks = executionArgs.isExecutedByApiKey;
await this.processNestedRelationsIfNeeded(
executionArgs,
upsertedRecords,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
featureFlagsMap,
shouldBypassPermissionChecks,
roleId,
);
@ -329,6 +332,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
objectMetadataMaps: ObjectMetadataMaps,
featureFlagsMap: Record<FeatureFlagKey, boolean>,
shouldBypassPermissionChecks: boolean,
roleId?: string,
): Promise<void> {
if (!executionArgs.graphqlQuerySelectedFieldsResult.relations) {
@ -346,6 +350,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks,
});
}

View File

@ -74,6 +74,7 @@ export class GraphqlQueryCreateOneResolverService extends GraphqlQueryBaseResolv
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
});
}

View File

@ -75,6 +75,7 @@ export class GraphqlQueryDeleteManyResolverService extends GraphqlQueryBaseResol
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
});
}

View File

@ -77,6 +77,7 @@ export class GraphqlQueryDeleteOneResolverService extends GraphqlQueryBaseResolv
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
});
}

View File

@ -73,6 +73,7 @@ export class GraphqlQueryDestroyManyResolverService extends GraphqlQueryBaseReso
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
});
}

View File

@ -73,6 +73,7 @@ export class GraphqlQueryDestroyOneResolverService extends GraphqlQueryBaseResol
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
});
}

View File

@ -160,6 +160,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
});
}

View File

@ -83,6 +83,7 @@ export class GraphqlQueryFindOneResolverService extends GraphqlQueryBaseResolver
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
});
}

View File

@ -75,6 +75,7 @@ export class GraphqlQueryRestoreManyResolverService extends GraphqlQueryBaseReso
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
});
}

View File

@ -77,6 +77,7 @@ export class GraphqlQueryRestoreOneResolverService extends GraphqlQueryBaseResol
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
});
}

View File

@ -113,6 +113,7 @@ export class GraphqlQueryUpdateManyResolverService extends GraphqlQueryBaseResol
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
});
}

View File

@ -107,6 +107,7 @@ export class GraphqlQueryUpdateOneResolverService extends GraphqlQueryBaseResolv
isNewRelationEnabled:
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
});
}

View File

@ -1,16 +1,17 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { Request } from 'express';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class RestApiCoreServiceV2 {
@ -19,6 +20,7 @@ export class RestApiCoreServiceV2 {
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly recordInputTransformerService: RecordInputTransformerService,
protected readonly apiEventEmitterService: ApiEventEmitterService,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
) {}
async delete(request: Request) {
@ -137,7 +139,7 @@ export class RestApiCoreServiceV2 {
}
private async getRepositoryAndMetadataOrFail(request: Request) {
const { workspace } = request;
const { workspace, apiKey, userWorkspaceId } = request;
const { object: parsedObject } = parseCorePath(request);
const objectMetadata = await this.coreQueryBuilderFactory.getObjectMetadata(
@ -153,13 +155,25 @@ export class RestApiCoreServiceV2 {
throw new BadRequestException('Workspace not found');
}
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace(workspace.id);
const objectMetadataNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ObjectRecord>(
workspace.id,
objectMetadataNameSingular,
);
const shouldBypassPermissionChecks = !!apiKey;
const roleId =
await this.workspacePermissionsCacheService.getRoleIdFromUserWorkspaceId({
workspaceId: workspace.id,
userWorkspaceId,
});
const repository = dataSource.getRepository<ObjectRecord>(
objectMetadataNameSingular,
shouldBypassPermissionChecks,
roleId,
);
return {
objectMetadataNameSingular,

View File

@ -1,6 +1,7 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { RestApiCoreBatchController } from 'src/engine/api/rest/core/controllers/rest-api-core-batch.controller';
import { RestApiCoreController } from 'src/engine/api/rest/core/controllers/rest-api-core.controller';
import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/core-query-builder.module';
@ -15,9 +16,9 @@ import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-me
import { RestApiService } from 'src/engine/api/rest/rest-api.service';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
@Module({
imports: [
@ -28,6 +29,7 @@ import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-run
HttpModule,
TwentyORMModule,
RecordTransformerModule,
WorkspacePermissionsCacheModule,
],
controllers: [
RestApiMetadataController,

View File

@ -72,7 +72,6 @@ export class AccessTokenService {
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
false,
);
const workspaceMember = await workspaceMemberRepository.findOne({

View File

@ -5,6 +5,7 @@ import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@ -12,6 +13,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
@ -19,8 +21,6 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
describe('UserWorkspaceService', () => {
let service: UserWorkspaceService;

View File

@ -20,6 +20,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
@ -32,7 +33,6 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { assert } from 'src/utils/assert';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
constructor(

View File

@ -31,6 +31,7 @@ import { RemoteTableRelationsModule } from 'src/engine/metadata-modules/remote-s
import { SearchVectorModule } from 'src/engine/metadata-modules/search-vector/search-vector.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { ObjectMetadataEntity } from './object-metadata.entity';
@ -59,6 +60,7 @@ import { UpdateObjectPayload } from './dtos/update-object.input';
IndexMetadataModule,
FeatureFlagModule,
PermissionsModule,
WorkspacePermissionsCacheModule,
],
services: [
ObjectMetadataService,

View File

@ -40,6 +40,7 @@ import { RemoteTableRelationsService } from 'src/engine/metadata-modules/remote-
import { SearchVectorService } from 'src/engine/metadata-modules/search-vector/search-vector.service';
import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { CUSTOM_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
@ -68,6 +69,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
private readonly objectMetadataMigrationService: ObjectMetadataMigrationService,
private readonly objectMetadataRelatedRecordsService: ObjectMetadataRelatedRecordsService,
private readonly indexMetadataService: IndexMetadataService,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly featureFlagService: FeatureFlagService,
) {
super(objectMetadataRepository);
@ -236,6 +238,10 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
objectMetadataInput.workspaceId,
);
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache({
workspaceId: objectMetadataInput.workspaceId,
});
return createdObjectMetadata;
}
@ -441,6 +447,10 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
workspaceId,
);
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache({
workspaceId,
});
return objectMetadata;
}

View File

@ -5,6 +5,7 @@ import {
ObjectRecordsPermissions,
ObjectRecordsPermissionsByRoleId,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@ -51,19 +52,15 @@ export class WorkspacePermissionsCacheService {
ignoreLock?: boolean;
roleIds?: string[];
}): Promise<void> {
const isPermissionsV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsV2Enabled,
workspaceId,
);
if (!ignoreLock) {
const isAlreadyCaching =
await this.workspacePermissionsCacheStorageService.getRolesPermissionsOngoingCachingLock(
workspaceId,
);
const isAlreadyCaching =
await this.workspacePermissionsCacheStorageService.getRolesPermissionsOngoingCachingLock(
workspaceId,
);
if (!ignoreLock && isAlreadyCaching) {
return;
if (isAlreadyCaching) {
return;
}
}
await this.workspacePermissionsCacheStorageService.addRolesPermissionsOngoingCachingLock(
@ -80,6 +77,12 @@ export class WorkspacePermissionsCacheService {
);
}
const isPermissionsV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsV2Enabled,
workspaceId,
);
const recomputedRolesPermissions =
await this.getObjectRecordPermissionsForRoles({
workspaceId,
@ -109,13 +112,15 @@ export class WorkspacePermissionsCacheService {
workspaceId: string;
ignoreLock?: boolean;
}): Promise<void> {
const isAlreadyCaching =
await this.workspacePermissionsCacheStorageService.getUserWorkspaceRoleMapOngoingCachingLock(
workspaceId,
);
if (!ignoreLock) {
const isAlreadyCaching =
await this.workspacePermissionsCacheStorageService.getUserWorkspaceRoleMapOngoingCachingLock(
workspaceId,
);
if (!ignoreLock && isAlreadyCaching) {
return;
if (isAlreadyCaching) {
return;
}
}
await this.workspacePermissionsCacheStorageService.addUserWorkspaceRoleMapOngoingCachingLock(
@ -183,6 +188,24 @@ export class WorkspacePermissionsCacheService {
});
}
async getRoleIdFromUserWorkspaceId({
workspaceId,
userWorkspaceId,
}: {
workspaceId: string;
userWorkspaceId?: string;
}): Promise<string | undefined> {
if (!isDefined(userWorkspaceId)) {
return;
}
const userWorkspaceRoleMap = await this.getUserWorkspaceRoleMapFromCache({
workspaceId,
});
return userWorkspaceRoleMap[userWorkspaceId];
}
private async getObjectRecordPermissionsForRoles({
workspaceId,
isPermissionsV2Enabled,

View File

@ -41,10 +41,19 @@ export class WorkspaceDataSource extends DataSource {
override getRepository<Entity extends ObjectLiteral>(
target: EntityTarget<Entity>,
shouldBypassPermissionChecks = false,
roleId?: string,
): WorkspaceRepository<Entity> {
if (shouldBypassPermissionChecks === true) {
return this.manager.getRepository(target, shouldBypassPermissionChecks);
}
if (roleId) {
return this.manager.getRepository(target, roleId);
return this.manager.getRepository(
target,
shouldBypassPermissionChecks,
roleId,
);
}
return this.manager.getRepository(target);
@ -64,11 +73,11 @@ export class WorkspaceDataSource extends DataSource {
this.permissionsPerRoleId = permissionsPerRoleId;
}
setFeatureFlagsMap(featureFlagMap: FeatureFlagMap) {
setFeatureFlagMap(featureFlagMap: FeatureFlagMap) {
this.featureFlagMap = featureFlagMap;
}
setFeatureFlagsMapVersion(featureFlagMapVersion: string) {
setFeatureFlagMapVersion(featureFlagMapVersion: string) {
this.featureFlagMapVersion = featureFlagMapVersion;
}
}

View File

@ -28,10 +28,17 @@ export class WorkspaceEntityManager extends EntityManager {
override getRepository<Entity extends ObjectLiteral>(
target: EntityTarget<Entity>,
shouldBypassPermissionChecks = false,
roleId?: string,
): WorkspaceRepository<Entity> {
const dataSource = this.connection as WorkspaceDataSource;
const repositoryKey = `${dataSource.getMetadata(target).name}_${roleId ?? 'default'}${dataSource.rolesPermissionsVersion ? `_${dataSource.rolesPermissionsVersion}` : ''}${dataSource.featureFlagMapVersion ? `_${dataSource.featureFlagMapVersion}` : ''}`;
const repositoryKey = this.getRepositoryKey({
target,
dataSource,
roleId,
shouldBypassPermissionChecks,
});
const repoFromMap = this.repositories.get(repositoryKey);
if (repoFromMap) {
@ -53,10 +60,36 @@ export class WorkspaceEntityManager extends EntityManager {
dataSource.featureFlagMap,
this.queryRunner,
objectPermissions,
shouldBypassPermissionChecks,
);
this.repositories.set(repositoryKey, newRepository);
return newRepository;
}
private getRepositoryKey({
target,
dataSource,
roleId,
shouldBypassPermissionChecks,
}: {
target: EntityTarget<any>;
dataSource: WorkspaceDataSource;
shouldBypassPermissionChecks: boolean;
roleId?: string;
}) {
const repositoryPrefix = dataSource.getMetadata(target).name;
const roleIdSuffix = roleId ? `_${roleId}` : '';
const rolesPermissionsVersionSuffix = dataSource.rolesPermissionsVersion
? `_${dataSource.rolesPermissionsVersion}`
: '';
const featureFlagMapVersionSuffix = dataSource.featureFlagMapVersion
? `_${dataSource.featureFlagMapVersion}`
: '';
return shouldBypassPermissionChecks
? `${repositoryPrefix}_bypass${featureFlagMapVersionSuffix}`
: `${repositoryPrefix}${roleIdSuffix}${rolesPermissionsVersionSuffix}${featureFlagMapVersionSuffix}`;
}
}

View File

@ -13,6 +13,7 @@ export class ScopedWorkspaceContextFactory {
workspaceId: string | null;
workspaceMetadataVersion: number | null;
userWorkspaceId: string | null;
isExecutedByApiKey: boolean;
} {
const workspaceId: string | undefined =
this.request?.['req']?.['workspaceId'] ||
@ -24,6 +25,7 @@ export class ScopedWorkspaceContextFactory {
workspaceId: workspaceId ?? null,
workspaceMetadataVersion: workspaceMetadataVersion ?? null,
userWorkspaceId: this.request?.['req']?.['userWorkspaceId'] ?? null,
isExecutedByApiKey: !!this.request?.['req']?.['apiKey'],
};
}
}

View File

@ -294,9 +294,9 @@ export class WorkspaceDatasourceFactory {
currentVersion: workspaceDataSource.featureFlagMapVersion,
newVersion: cachedFeatureFlagMapVersion,
newData: cachedFeatureFlagMap,
setData: (data) => workspaceDataSource.setFeatureFlagsMap(data),
setData: (data) => workspaceDataSource.setFeatureFlagMap(data),
setVersion: (version) =>
workspaceDataSource.setFeatureFlagsMapVersion(version),
workspaceDataSource.setFeatureFlagMapVersion(version),
});
}

View File

@ -6,6 +6,7 @@ import {
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
const getTargetEntityAndOperationType = (expressionMap: QueryExpressionMap) => {
const mainEntity = expressionMap.aliases[0].metadata.name;
@ -20,10 +21,26 @@ const getTargetEntityAndOperationType = (expressionMap: QueryExpressionMap) => {
export const validateQueryIsPermittedOrThrow = (
expressionMap: QueryExpressionMap,
objectRecordsPermissions: ObjectRecordsPermissions,
objectMetadataMaps: ObjectMetadataMaps,
shouldBypassPermissionChecks: boolean,
) => {
if (shouldBypassPermissionChecks) {
return;
}
const { mainEntity, operationType } =
getTargetEntityAndOperationType(expressionMap);
const objectMetadataIdForEntity =
objectMetadataMaps.idByNameSingular[mainEntity];
const objectMetadataIsSystem =
objectMetadataMaps.byId[objectMetadataIdForEntity]?.isSystem === true;
if (objectMetadataIsSystem) {
return;
}
const permissionsForEntity = objectRecordsPermissions[mainEntity];
switch (operationType) {

View File

@ -1,6 +1,8 @@
import { ObjectRecordsPermissions } from 'twenty-shared/types';
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
export class WorkspaceQueryBuilder<
@ -9,9 +11,15 @@ export class WorkspaceQueryBuilder<
constructor(
queryBuilder: SelectQueryBuilder<T>,
objectRecordsPermissions: ObjectRecordsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
) {
super(queryBuilder, objectRecordsPermissions);
this.objectRecordsPermissions = objectRecordsPermissions;
super(
queryBuilder,
objectRecordsPermissions,
internalContext,
shouldBypassPermissionChecks,
);
}
override clone(): this {
@ -20,6 +28,8 @@ export class WorkspaceQueryBuilder<
return new WorkspaceQueryBuilder(
clonedQueryBuilder,
this.objectRecordsPermissions,
this.internalContext,
this.shouldBypassPermissionChecks,
) as this;
}
}

View File

@ -2,6 +2,8 @@ import { ObjectRecordsPermissions } from 'twenty-shared/types';
import { ObjectLiteral, SelectQueryBuilder, UpdateQueryBuilder } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.util';
import { WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder';
@ -9,12 +11,18 @@ export class WorkspaceSelectQueryBuilder<
T extends ObjectLiteral,
> extends SelectQueryBuilder<T> {
objectRecordsPermissions: ObjectRecordsPermissions;
shouldBypassPermissionChecks: boolean;
internalContext: WorkspaceInternalContext;
constructor(
queryBuilder: SelectQueryBuilder<T>,
objectRecordsPermissions: ObjectRecordsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
) {
super(queryBuilder);
this.objectRecordsPermissions = objectRecordsPermissions;
this.internalContext = internalContext;
this.shouldBypassPermissionChecks = shouldBypassPermissionChecks;
}
override update(): WorkspaceUpdateQueryBuilder<T>;
@ -33,6 +41,8 @@ export class WorkspaceSelectQueryBuilder<
return new WorkspaceUpdateQueryBuilder<T>(
updateQueryBuilder,
this.objectRecordsPermissions,
this.internalContext,
this.shouldBypassPermissionChecks,
);
}
@ -40,6 +50,8 @@ export class WorkspaceSelectQueryBuilder<
validateQueryIsPermittedOrThrow(
this.expressionMap,
this.objectRecordsPermissions,
this.internalContext.objectMetadataMaps,
this.shouldBypassPermissionChecks,
);
return super.execute();
@ -49,6 +61,8 @@ export class WorkspaceSelectQueryBuilder<
validateQueryIsPermittedOrThrow(
this.expressionMap,
this.objectRecordsPermissions,
this.internalContext.objectMetadataMaps,
this.shouldBypassPermissionChecks,
);
return super.getMany();

View File

@ -1,24 +1,34 @@
import { ObjectRecordsPermissions } from 'twenty-shared/types';
import { ObjectLiteral, UpdateQueryBuilder, UpdateResult } from 'typeorm';
import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.util';
export class WorkspaceUpdateQueryBuilder<
Entity extends ObjectLiteral,
> extends UpdateQueryBuilder<Entity> {
private objectRecordsPermissions: ObjectRecordsPermissions;
private shouldBypassPermissionChecks: boolean;
private internalContext: WorkspaceInternalContext;
constructor(
queryBuilder: UpdateQueryBuilder<Entity>,
objectRecordsPermissions: ObjectRecordsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
) {
super(queryBuilder);
this.objectRecordsPermissions = objectRecordsPermissions;
this.internalContext = internalContext;
this.shouldBypassPermissionChecks = shouldBypassPermissionChecks;
}
override execute(): Promise<UpdateResult> {
validateQueryIsPermittedOrThrow(
this.expressionMap,
this.objectRecordsPermissions,
this.internalContext.objectMetadataMaps,
this.shouldBypassPermissionChecks,
);
return super.execute();

View File

@ -36,9 +36,9 @@ export class WorkspaceRepository<
T extends ObjectLiteral,
> extends Repository<T> {
private readonly internalContext: WorkspaceInternalContext;
private shouldBypassPermissionChecks: boolean;
private featureFlagMap: FeatureFlagMap;
private objectRecordsPermissions?: ObjectRecordsPermissions;
constructor(
internalContext: WorkspaceInternalContext,
target: EntityTarget<T>,
@ -46,11 +46,13 @@ export class WorkspaceRepository<
featureFlagMap: FeatureFlagMap,
queryRunner?: QueryRunner,
objectRecordsPermissions?: ObjectRecordsPermissions,
shouldBypassPermissionChecks = false,
) {
super(target, manager, queryRunner);
this.internalContext = internalContext;
this.featureFlagMap = featureFlagMap;
this.objectRecordsPermissions = objectRecordsPermissions;
this.shouldBypassPermissionChecks = shouldBypassPermissionChecks;
}
override createQueryBuilder<U extends T>(
@ -74,6 +76,8 @@ export class WorkspaceRepository<
return new WorkspaceQueryBuilder(
queryBuilder,
this.objectRecordsPermissions,
this.internalContext,
this.shouldBypassPermissionChecks,
);
}
}

View File

@ -15,19 +15,31 @@ export class TwentyORMGlobalManager {
async getRepositoryForWorkspace<T extends ObjectLiteral>(
workspaceId: string,
workspaceEntity: Type<T>,
shouldFailIfMetadataNotFound?: boolean,
options?: {
shouldBypassPermissionChecks?: boolean;
shouldFailIfMetadataNotFound?: boolean;
},
): Promise<WorkspaceRepository<T>>;
async getRepositoryForWorkspace<T extends ObjectLiteral>(
workspaceId: string,
objectMetadataName: string,
shouldFailIfMetadataNotFound?: boolean,
options?: {
shouldBypassPermissionChecks?: boolean;
shouldFailIfMetadataNotFound?: boolean;
},
): Promise<WorkspaceRepository<T>>;
async getRepositoryForWorkspace<T extends ObjectLiteral>(
workspaceId: string,
workspaceEntityOrobjectMetadataName: Type<T> | string,
shouldFailIfMetadataNotFound = true,
options: {
shouldBypassPermissionChecks?: boolean;
shouldFailIfMetadataNotFound?: boolean;
} = {
shouldBypassPermissionChecks: false,
shouldFailIfMetadataNotFound: true,
},
): Promise<WorkspaceRepository<T>> {
let objectMetadataName: string;
@ -42,10 +54,13 @@ export class TwentyORMGlobalManager {
const workspaceDataSource = await this.workspaceDataSourceFactory.create(
workspaceId,
null,
shouldFailIfMetadataNotFound,
options.shouldFailIfMetadataNotFound,
);
const repository = workspaceDataSource.getRepository<T>(objectMetadataName);
const repository = workspaceDataSource.getRepository<T>(
objectMetadataName,
options.shouldBypassPermissionChecks,
);
return repository;
}

View File

@ -30,8 +30,12 @@ export class TwentyORMManager {
async getRepository<T extends ObjectLiteral>(
workspaceEntityOrobjectMetadataName: Type<T> | string,
): Promise<WorkspaceRepository<T>> {
const { workspaceId, workspaceMetadataVersion, userWorkspaceId } =
this.scopedWorkspaceContextFactory.create();
const {
workspaceId,
workspaceMetadataVersion,
userWorkspaceId,
isExecutedByApiKey,
} = this.scopedWorkspaceContextFactory.create();
let objectMetadataName: string;
@ -65,7 +69,13 @@ export class TwentyORMManager {
roleId = userWorkspaceRole?.roleId;
}
return workspaceDataSource.getRepository<T>(objectMetadataName, roleId);
const shouldBypassPermissionChecks = !!isExecutedByApiKey;
return workspaceDataSource.getRepository<T>(
objectMetadataName,
shouldBypassPermissionChecks,
roleId,
);
}
async getDatasource() {

View File

@ -1,7 +1,7 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Any, Repository } from 'typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Any, Repository } from 'typeorm';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';

View File

@ -1,7 +1,7 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Equal, Repository } from 'typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Equal, Repository } from 'typeorm';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';

View File

@ -1,13 +1,13 @@
import { Injectable } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@Injectable()
export class ConnectedAccountListener {

View File

@ -55,6 +55,9 @@ export class CreateCompanyAndContactService {
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
PersonWorkspaceEntity,
{
shouldBypassPermissionChecks: true,
},
);
const workspaceMembers =

View File

@ -2,9 +2,9 @@ import { Injectable } from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
import uniqBy from 'lodash.uniqby';
import { DeepPartial, EntityManager, ILike } from 'typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { TWENTY_COMPANIES_BASE_URL } from 'twenty-shared/constants';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { DeepPartial, EntityManager, ILike } from 'typeorm';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@ -49,6 +49,9 @@ export class CreateCompanyService {
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
CompanyWorkspaceEntity,
{
shouldBypassPermissionChecks: true,
},
);
// Avoid creating duplicate companies

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { DeepPartial, EntityManager } from 'typeorm';
import { v4 } from 'uuid';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@ -82,6 +82,9 @@ export class CreateContactService {
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
PersonWorkspaceEntity,
{
shouldBypassPermissionChecks: true,
},
);
const lastPersonPosition = await this.getLastPersonPosition(

View File

@ -1,7 +1,7 @@
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { In, Repository } from 'typeorm';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';

View File

@ -1,7 +1,7 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';

View File

@ -2,8 +2,8 @@ import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import snakeCase from 'lodash.snakecase';
import { Repository } from 'typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';

View File

@ -8,7 +8,6 @@ import {
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@WorkspaceQueryHook(`view.deleteOne`)
export class ViewDeleteOnePreQueryHook implements WorkspaceQueryHookInstance {
constructor(
@ -21,7 +20,6 @@ export class ViewDeleteOnePreQueryHook implements WorkspaceQueryHookInstance {
payload: DeleteOneResolverArgs,
): Promise<DeleteOneResolverArgs> {
const targettedViewId = payload.id;
const viewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
authContext.workspace.id,

View File

@ -2,8 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
import { z } from 'zod';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { z } from 'zod';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';

View File

@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
@ -16,8 +18,6 @@ import {
WorkflowTriggerJob,
WorkflowTriggerJobData,
} from 'src/modules/workflow/workflow-trigger/jobs/workflow-trigger.job';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@Injectable()
export class DatabaseEventTriggerListener {

View File

@ -2,67 +2,219 @@ import { randomUUID } from 'node:crypto';
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util';
import { makeGraphqlAPIRequestWithApiKey } from 'test/integration/graphql/utils/make-graphql-api-request-with-api-key.util';
import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-role.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
describe('updateOneObjectRecordsPermissions', () => {
const personId = randomUUID();
beforeAll(async () => {
const createPersonOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: personId,
jobTitle: 'Software Engineer',
},
});
await makeGraphqlAPIRequest(createPersonOperation);
});
it('should throw a permission error when user does not have permission (guest role)', async () => {
describe('permissions V2 disabled', () => {
const personId = randomUUID();
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
recordId: personId,
data: {
jobTitle: 'Senior Software Engineer',
},
beforeAll(async () => {
const createPersonOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: personId,
jobTitle: 'Software Engineer',
},
});
await makeGraphqlAPIRequest(createPersonOperation);
});
const response = await makeGraphqlAPIRequestWithGuestRole(graphqlOperation);
it('should throw a permission error when user does not have permission (guest role)', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
recordId: personId,
data: {
jobTitle: 'Senior Software Engineer',
},
});
expect(response.body.data).toStrictEqual({ updatePerson: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
const response =
await makeGraphqlAPIRequestWithGuestRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ updatePerson: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should update an object record when user has permission (admin role)', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
recordId: personId,
data: {
jobTitle: 'Senior Software Engineer',
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data).toBeDefined();
expect(response.body.data.updatePerson).toBeDefined();
expect(response.body.data.updatePerson.id).toBe(personId);
expect(response.body.data.updatePerson.jobTitle).toBe(
'Senior Software Engineer',
);
});
});
it('should update an object record when user has permission (admin role)', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
recordId: personId,
data: {
jobTitle: 'Senior Software Engineer',
},
describe('permissions V2 enabled', () => {
const personId = randomUUID();
let allPetsViewId: string;
beforeAll(async () => {
const createPersonOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: personId,
jobTitle: 'Software Engineer',
},
});
await makeGraphqlAPIRequest(createPersonOperation);
const enablePermissionsQuery = updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
'IsPermissionsV2Enabled',
true,
);
await makeGraphqlAPIRequest(enablePermissionsQuery);
const findAllPetsViewOperation = findOneOperationFactory({
objectMetadataSingularName: 'view',
gqlFields: 'id',
filter: {
icon: {
eq: 'IconCat',
},
},
});
const findAllPetsViewResponse = await makeGraphqlAPIRequest(
findAllPetsViewOperation,
);
allPetsViewId = findAllPetsViewResponse.body.data.view.id;
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
afterAll(async () => {
const disablePermissionsQuery = updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
'IsPermissionsV2Enabled',
false,
);
expect(response.body.data).toBeDefined();
expect(response.body.data.updatePerson).toBeDefined();
expect(response.body.data.updatePerson.id).toBe(personId);
expect(response.body.data.updatePerson.jobTitle).toBe(
'Senior Software Engineer',
);
await makeGraphqlAPIRequest(disablePermissionsQuery);
const updateViewOperation = updateOneOperationFactory({
objectMetadataSingularName: 'view',
gqlFields: 'id',
recordId: allPetsViewId,
data: {
icon: 'IconCat',
},
});
await makeGraphqlAPIRequest(updateViewOperation);
});
it('should throw a permission error when user does not have permission (guest role)', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
recordId: personId,
data: {
jobTitle: 'Senior Software Engineer',
},
});
const response =
await makeGraphqlAPIRequestWithGuestRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ updatePerson: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should allow to update a system object record even without update permission (guest role)', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'view',
gqlFields: `
id
icon
`,
recordId: allPetsViewId,
data: {
icon: 'IconDog',
},
});
const response =
await makeGraphqlAPIRequestWithGuestRole(graphqlOperation);
expect(response.body.data).toBeDefined();
expect(response.body.data.updateView).toBeDefined();
expect(response.body.data.updateView.id).toBe(allPetsViewId);
expect(response.body.data.updateView.icon).toBe('IconDog');
});
it('should update an object record when user has permission (admin role)', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
recordId: personId,
data: {
jobTitle: 'Senior Software Engineer',
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data).toBeDefined();
expect(response.body.data.updatePerson).toBeDefined();
expect(response.body.data.updatePerson.id).toBe(personId);
expect(response.body.data.updatePerson.jobTitle).toBe(
'Senior Software Engineer',
);
});
it('should update an object record when executed by api key', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
recordId: personId,
data: {
jobTitle: 'Senior Software Engineer',
},
});
const response = await makeGraphqlAPIRequestWithApiKey(graphqlOperation);
expect(response.body.data).toBeDefined();
expect(response.body.data.updatePerson).toBeDefined();
expect(response.body.data.updatePerson.id).toBe(personId);
expect(response.body.data.updatePerson.jobTitle).toBe(
'Senior Software Engineer',
);
});
});
});

View File

@ -0,0 +1,21 @@
import { ASTNode, print } from 'graphql';
import request from 'supertest';
type GraphqlOperation = {
query: ASTNode;
variables?: Record<string, unknown>;
};
export const makeGraphqlAPIRequestWithApiKey = (
graphqlOperation: GraphqlOperation,
) => {
const client = request(`http://localhost:${APP_PORT}`);
return client
.post('/graphql')
.set('Authorization', `Bearer ${API_KEY_ACCESS_TOKEN}`)
.send({
query: print(graphqlOperation.query),
variables: graphqlOperation.variables || {},
});
};