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:
Félix Malfait
2026-05-27 18:52:53 +02:00
committed by GitHub
parent c8b9dace72
commit 4797d2f270
102 changed files with 1937 additions and 836 deletions
@@ -5,6 +5,7 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
@@ -15,6 +16,9 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
WorkspaceCacheModule,
],
exports: [FeatureFlagService],
providers: [FeatureFlagService],
providers: [
FeatureFlagService,
provideWorkspaceScopedRepository(FeatureFlagEntity),
],
})
export class FeatureFlagModule {}
@@ -1,5 +1,4 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
@@ -11,6 +10,7 @@ import {
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate';
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
jest.mock(
@@ -51,7 +51,7 @@ describe('FeatureFlagService', () => {
providers: [
FeatureFlagService,
{
provide: getRepositoryToken(FeatureFlagEntity),
provide: getWorkspaceScopedRepositoryToken(FeatureFlagEntity),
useValue: mockFeatureFlagRepository,
},
{
@@ -178,7 +178,8 @@ describe('FeatureFlagService', () => {
// Assert
expect(mockFeatureFlagRepository.upsert).toHaveBeenCalledWith(
keys.map((key) => ({ workspaceId, key, value: true })),
workspaceId,
keys.map((key) => ({ key, value: true })),
{
conflictPaths: ['workspaceId', 'key'],
skipUpdateIfNoValuesChanged: true,
@@ -218,10 +219,9 @@ describe('FeatureFlagService', () => {
// Assert
expect(result).toEqual(mockFeatureFlag);
expect(mockFeatureFlagRepository.save).toHaveBeenCalledWith({
expect(mockFeatureFlagRepository.save).toHaveBeenCalledWith(workspaceId, {
key: FeatureFlagKey[featureFlag],
value,
workspaceId,
});
expect(
mockWorkspaceCacheService.invalidateAndRecompute,
@@ -1,8 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { type FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
@@ -14,13 +12,15 @@ import {
} from 'src/engine/core-modules/feature-flag/feature-flag.exception';
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate';
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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class FeatureFlagService {
constructor(
@InjectRepository(FeatureFlagEntity)
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@InjectWorkspaceScopedRepository(FeatureFlagEntity)
private readonly featureFlagRepository: WorkspaceScopedRepository<FeatureFlagEntity>,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@@ -64,7 +64,8 @@ export class FeatureFlagService {
): Promise<void> {
if (keys.length > 0) {
await this.featureFlagRepository.upsert(
keys.map((key) => ({ workspaceId, key, value: true })),
workspaceId,
keys.map((key) => ({ key, value: true })),
{
conflictPaths: ['workspaceId', 'key'],
skipUpdateIfNoValuesChanged: true,
@@ -106,25 +107,19 @@ export class FeatureFlagService {
);
}
const existingFeatureFlag = await this.featureFlagRepository.findOne({
where: {
key: featureFlag,
workspaceId: workspaceId,
},
});
const existingFeatureFlag = await this.featureFlagRepository.findOne(
workspaceId,
{ where: { key: featureFlag } },
);
const featureFlagToSave = existingFeatureFlag
? {
...existingFeatureFlag,
value,
}
: {
key: featureFlag,
value,
workspaceId: workspaceId,
};
? { ...existingFeatureFlag, value }
: { key: featureFlag, value };
const result = await this.featureFlagRepository.save(featureFlagToSave);
const result = await this.featureFlagRepository.save(
workspaceId,
featureFlagToSave,
);
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'featureFlagsMap',