feat(twenty-orm): introduce WorkspaceScopedRepository for core/metadata workspace-scoped entities (#20953)
## Summary Adds a third tenancy enforcement layer for entities that live in shared schemas (`core`, `metadata`) and carry a `workspaceId` column — previously the only safeguard at this layer was developer discipline (remembering to put `workspaceId` in every WHERE clause). ### The three layers, after this PR | Layer | Scope | How it's enforced | |---|---|---| | 1. Workspace data | per-workspace schema (companies, people, custom objects) | `twentyORMManager.getRepository(workspace, E)` — physical isolation (own data source) | | 2. Metadata | shared `metadata` schema (objectMetadata, fieldMetadata, views, roles…) | Flat-entity-maps cache — workspace-scoped in-memory map, lookups by id within it | | 3. Core (new) | shared `core` schema (agent threads/turns/messages, app tokens, etc.) | `WorkspaceScopedRepository<T>` — `workspaceId` is a required positional argument on every read/write | ## What's in the PR ### The wrapper (`packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/`) - `WorkspaceScopedRepository<T extends WorkspaceScopedEntity>` — wraps a TypeORM `Repository<T>`, requires `workspaceId` on every `find`/`findOne`/`findOneOrFail`/`update`/`delete`/`softDelete`/`insert`/`save`/`count` call, merging it into the WHERE or stamping it on the entity. `createQueryBuilder` is an explicit escape hatch (caller scopes manually). - Provided via Nest DI with `@InjectWorkspaceScopedRepository(EntityClass)` and the `provideWorkspaceScopedRepository(EntityClass)` provider factory. - 19 unit tests cover the merge behavior, override-on-conflict, and the array-where (OR) case. ### Lint enforcement (`packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts`) - New `twenty/prefer-workspace-scoped-repository` rule (level: **error**). - Blacklist of entity names: raw `@InjectRepository(E)` is rejected if `E` is on the list. - Initial list: `AgentTurnEntity`, `AgentMessageEntity`, `AgentMessagePartEntity`, `AgentChatThreadEntity`, `AgentTurnEvaluationEntity`, `AgentEntity`. - Designed to grow over time as more consumers are migrated. - 5 rule tests. ### Migration in this PR All consumers of the six blacklisted entities, including: - AI agent / chat / monitor resolvers, services, and jobs - `AgentService`, `AiAgentRoleService`, `AiAgentWorkflowAction`, `ApplicationService`, `WorkspaceFlatAgentMapCacheService` - Admin-panel chat (migrated where the lookup is workspace-known; one documented `eslint-disable` on the threadId-discovery lookup that necessarily precedes the `allowImpersonation` permission check) - `AiAgentRoleService` unit spec updated to mock the scoped wrapper ## Future work (deliberately not in this PR) A standalone audit identified ~14 additional `core`/`metadata` entities with `workspaceId` that currently use raw `@InjectRepository` and could be added to the blacklist. Notable candidates: `UserWorkspaceEntity` (42 sites), `AppTokenEntity` (10), `FileEntity` (7), `BillingCustomerEntity`/`BillingSubscriptionEntity` (~22 combined). Each should be its own PR — the migration is mechanical but the surface is wide. ## Test plan - [x] `npx nx typecheck twenty-server` — clean - [x] `npx nx lint twenty-server` — 0 warnings, 0 errors - [x] `npx jest workspace-scoped-repository` — 19/19 pass - [x] `npx nx test twenty-oxlint-rules` — 215/215 pass - [x] `npx jest src/engine/metadata-modules/ai` — 44/44 pass - [ ] Manual smoke: end-to-end AI agent chat send/receive (reviewer) - [ ] Manual smoke: AI agent monitor — list turns, run evaluation (reviewer) - [ ] Manual smoke: admin-panel chat thread inspection (reviewer)
This commit is contained in:
+6
-2
@@ -10,7 +10,7 @@ import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule,
|
||||
@@ -20,7 +20,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
FileUrlModule,
|
||||
SecureHttpClientModule,
|
||||
],
|
||||
providers: [FileCorePictureService, FileCorePictureResolver],
|
||||
providers: [
|
||||
FileCorePictureService,
|
||||
FileCorePictureResolver,
|
||||
provideWorkspaceScopedRepository(FileEntity),
|
||||
],
|
||||
exports: [FileCorePictureService],
|
||||
})
|
||||
export class FileCorePictureModule {}
|
||||
|
||||
+13
-10
@@ -23,6 +23,8 @@ import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extra
|
||||
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { getImageBufferFromUrl } from 'src/utils/image';
|
||||
|
||||
@Injectable()
|
||||
@@ -33,8 +35,8 @@ export class FileCorePictureService {
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
@InjectWorkspaceScopedRepository(FileEntity)
|
||||
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
private readonly secureHttpClientService: SecureHttpClientService,
|
||||
) {}
|
||||
@@ -175,11 +177,10 @@ export class FileCorePictureService {
|
||||
fileId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
const file = await this.fileRepository.findOneOrFail({
|
||||
const file = await this.fileRepository.findOneOrFail(workspaceId, {
|
||||
where: {
|
||||
id: fileId,
|
||||
path: Like(`${FileFolder.CorePicture}/%`),
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -287,13 +288,15 @@ export class FileCorePictureService {
|
||||
targetApplicationUniversalIdentifier?: string;
|
||||
queryRunner?: QueryRunner;
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const sourceFile = await this.fileRepository.findOneOrFail({
|
||||
where: {
|
||||
id: sourceFileId,
|
||||
workspaceId: sourceWorkspaceId,
|
||||
path: Like(`${FileFolder.CorePicture}/%`),
|
||||
const sourceFile = await this.fileRepository.findOneOrFail(
|
||||
sourceWorkspaceId,
|
||||
{
|
||||
where: {
|
||||
id: sourceFileId,
|
||||
path: Like(`${FileFolder.CorePicture}/%`),
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const sourceApplicationUniversalIdentifier =
|
||||
await this.findCustomApplicationUniversalIdentifier(sourceWorkspaceId);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
|
||||
import { FileController } from './controllers/file.controller';
|
||||
import { FileEntity } from './entities/file.entity';
|
||||
import { FileCorePictureModule } from './file-core-picture/file-core-picture.module';
|
||||
@@ -42,6 +42,7 @@ import { FileService } from './services/file.service';
|
||||
FileByIdGuard,
|
||||
FileWorkspaceFolderDeletionJob,
|
||||
FileDeletionJob,
|
||||
provideWorkspaceScopedRepository(FileEntity),
|
||||
],
|
||||
exports: [
|
||||
FileService,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
|
||||
import { FileService } from './file.service';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
@@ -33,7 +33,7 @@ describe('FileService', () => {
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(FileEntity),
|
||||
provide: getWorkspaceScopedRepositoryToken(FileEntity),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,6 +18,8 @@ import { getContentDisposition } from 'src/engine/core-modules/file/utils/get-co
|
||||
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@Injectable()
|
||||
@@ -28,8 +30,8 @@ export class FileService {
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
@InjectWorkspaceScopedRepository(FileEntity)
|
||||
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {}
|
||||
@@ -56,10 +58,9 @@ export class FileService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = await this.fileRepository.findOne({
|
||||
const file = await this.fileRepository.findOne(workspaceId, {
|
||||
where: {
|
||||
path: `${fileFolder}/${filepath}`,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
},
|
||||
});
|
||||
@@ -101,10 +102,9 @@ export class FileService {
|
||||
workspaceId: string;
|
||||
fileFolder: FileFolder;
|
||||
}): Promise<{ stream: Readable; mimeType: string } | null> {
|
||||
const file = await this.fileRepository.findOne({
|
||||
const file = await this.fileRepository.findOne(workspaceId, {
|
||||
where: {
|
||||
id: fileId,
|
||||
workspaceId,
|
||||
path: Like(`${fileFolder}/%`),
|
||||
},
|
||||
});
|
||||
@@ -157,10 +157,9 @@ export class FileService {
|
||||
workspaceId: string;
|
||||
fileFolder: FileFolder;
|
||||
}): Promise<FileResponse | null> {
|
||||
const file = await this.fileRepository.findOne({
|
||||
const file = await this.fileRepository.findOne(params.workspaceId, {
|
||||
where: {
|
||||
id: params.fileId,
|
||||
workspaceId: params.workspaceId,
|
||||
path: Like(`${params.fileFolder}/%`),
|
||||
},
|
||||
});
|
||||
@@ -230,10 +229,9 @@ export class FileService {
|
||||
workspaceId: string;
|
||||
fileFolder: FileFolder;
|
||||
}): Promise<{ buffer: Buffer; mimeType: string } | null> {
|
||||
const file = await this.fileRepository.findOne({
|
||||
const file = await this.fileRepository.findOne(workspaceId, {
|
||||
where: {
|
||||
id: fileId,
|
||||
workspaceId,
|
||||
path: Like(`${fileFolder}/%`),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user