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
@@ -15,6 +15,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -42,6 +43,7 @@ import { ApiKeyController } from './controllers/api-key.controller';
ApiKeyRoleService,
WorkspaceApiKeyMapCacheService,
GenerateApiKeyCommand,
provideWorkspaceScopedRepository(ApiKeyEntity),
],
controllers: [ApiKeyController],
exports: [
@@ -14,6 +14,7 @@ import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-contex
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
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';
describe('ApiKeyService', () => {
@@ -88,7 +89,7 @@ describe('ApiKeyService', () => {
providers: [
ApiKeyService,
{
provide: getRepositoryToken(ApiKeyEntity),
provide: getWorkspaceScopedRepositoryToken(ApiKeyEntity),
useValue: mockApiKeyRepository,
},
{
@@ -143,7 +144,6 @@ describe('ApiKeyService', () => {
const expectedApiKeyFields = {
name: 'New API Key',
expiresAt: new Date('2025-12-31'),
workspaceId: mockWorkspaceId,
};
mockApiKeyRepository.save.mockResolvedValue(mockApiKey);
@@ -152,6 +152,7 @@ describe('ApiKeyService', () => {
const result = await service.create(apiKeyData);
expect(mockApiKeyRepository.save).toHaveBeenCalledWith(
mockWorkspaceId,
expectedApiKeyFields,
);
expect(mockRoleTargetService.create).toHaveBeenCalledWith({
@@ -185,7 +186,10 @@ describe('ApiKeyService', () => {
expect(mockApiKeyRepository.save).toHaveBeenCalled();
expect(mockRoleTargetService.create).toHaveBeenCalled();
expect(mockApiKeyRepository.delete).toHaveBeenCalledWith(mockApiKey.id);
expect(mockApiKeyRepository.delete).toHaveBeenCalledWith(
mockWorkspaceId,
{ id: mockApiKey.id },
);
});
it('should handle save failures gracefully', async () => {
@@ -211,12 +215,10 @@ describe('ApiKeyService', () => {
const result = await service.findById(mockApiKeyId, mockWorkspaceId);
expect(mockApiKeyRepository.findOne).toHaveBeenCalledWith({
where: {
id: mockApiKeyId,
workspaceId: mockWorkspaceId,
},
});
expect(mockApiKeyRepository.findOne).toHaveBeenCalledWith(
mockWorkspaceId,
{ where: { id: mockApiKeyId } },
);
expect(result).toEqual(mockApiKey);
});
@@ -237,11 +239,7 @@ describe('ApiKeyService', () => {
const result = await service.findByWorkspaceId(mockWorkspaceId);
expect(mockApiKeyRepository.find).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
},
});
expect(mockApiKeyRepository.find).toHaveBeenCalledWith(mockWorkspaceId);
expect(result).toEqual(mockApiKeys);
});
});
@@ -254,11 +252,8 @@ describe('ApiKeyService', () => {
const result = await service.findActiveByWorkspaceId(mockWorkspaceId);
expect(mockApiKeyRepository.find).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
revokedAt: IsNull(),
},
expect(mockApiKeyRepository.find).toHaveBeenCalledWith(mockWorkspaceId, {
where: { revokedAt: IsNull() },
});
expect(result).toEqual(activeApiKeys);
});
@@ -281,7 +276,8 @@ describe('ApiKeyService', () => {
);
expect(mockApiKeyRepository.update).toHaveBeenCalledWith(
mockApiKeyId,
mockWorkspaceId,
{ id: mockApiKeyId },
updateData,
);
expect(result).toEqual(updatedApiKey);
@@ -311,10 +307,9 @@ describe('ApiKeyService', () => {
const result = await service.revoke(mockApiKeyId, mockWorkspaceId);
expect(mockApiKeyRepository.update).toHaveBeenCalledWith(
mockApiKeyId,
expect.objectContaining({
revokedAt: expect.any(Date),
}),
mockWorkspaceId,
{ id: mockApiKeyId },
expect.objectContaining({ revokedAt: expect.any(Date) }),
);
expect(result).toEqual(revokedApiKey);
});
@@ -16,6 +16,8 @@ import { type RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { fromFlatRoleToRoleDto } from 'src/engine/metadata-modules/role/utils/fromFlatRoleToRoleDto.util';
import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util';
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()
@@ -26,8 +28,8 @@ export class ApiKeyRoleService {
@InjectRepository(RoleEntity)
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(ApiKeyEntity)
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
@InjectWorkspaceScopedRepository(ApiKeyEntity)
private readonly apiKeyRepository: WorkspaceScopedRepository<ApiKeyEntity>,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly roleTargetService: RoleTargetService,
) {}
@@ -128,8 +130,8 @@ export class ApiKeyRoleService {
workspaceId: string;
roleId: string;
}) {
const apiKey = await this.apiKeyRepository.findOne({
where: { id: apiKeyId, workspaceId },
const apiKey = await this.apiKeyRepository.findOne(workspaceId, {
where: { id: apiKeyId },
});
if (!apiKey) {
@@ -223,12 +225,8 @@ export class ApiKeyRoleService {
return [];
}
const apiKeys = await this.apiKeyRepository.find({
where: {
id: In(apiKeyIds),
workspaceId,
revokedAt: IsNull(),
},
const apiKeys = await this.apiKeyRepository.find(workspaceId, {
where: { id: In(apiKeyIds), revokedAt: IsNull() },
});
return apiKeys;
@@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { IsNull, Repository } from 'typeorm';
import { IsNull } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
@@ -14,23 +13,28 @@ import { type ApiKeyToken } from 'src/engine/core-modules/auth/dto/api-key-token
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class ApiKeyService {
constructor(
@InjectRepository(ApiKeyEntity)
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
@InjectWorkspaceScopedRepository(ApiKeyEntity)
private readonly apiKeyRepository: WorkspaceScopedRepository<ApiKeyEntity>,
private readonly jwtWrapperService: JwtWrapperService,
private readonly roleTargetService: RoleTargetService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async create(
apiKeyData: Partial<ApiKeyEntity> & { roleId: string },
apiKeyData: Partial<ApiKeyEntity> & { roleId: string; workspaceId: string },
): Promise<ApiKeyEntity> {
const { roleId, ...apiKeyFields } = apiKeyData;
const savedApiKey = await this.apiKeyRepository.save(apiKeyFields);
const { roleId, workspaceId, ...apiKeyFields } = apiKeyData;
const savedApiKey = await this.apiKeyRepository.save(
workspaceId,
apiKeyFields,
);
try {
await this.roleTargetService.create({
@@ -42,7 +46,7 @@ export class ApiKeyService {
workspaceId: savedApiKey.workspaceId,
});
} catch (error) {
await this.apiKeyRepository.delete(savedApiKey.id);
await this.apiKeyRepository.delete(workspaceId, { id: savedApiKey.id });
throw error;
}
@@ -55,28 +59,18 @@ export class ApiKeyService {
id: string,
workspaceId: string,
): Promise<ApiKeyEntity | null> {
return await this.apiKeyRepository.findOne({
where: {
id,
workspaceId,
},
return this.apiKeyRepository.findOne(workspaceId, {
where: { id },
});
}
async findByWorkspaceId(workspaceId: string): Promise<ApiKeyEntity[]> {
return await this.apiKeyRepository.find({
where: {
workspaceId,
},
});
return this.apiKeyRepository.find(workspaceId);
}
async findActiveByWorkspaceId(workspaceId: string): Promise<ApiKeyEntity[]> {
return await this.apiKeyRepository.find({
where: {
workspaceId,
revokedAt: IsNull(),
},
return this.apiKeyRepository.find(workspaceId, {
where: { revokedAt: IsNull() },
});
}
@@ -91,16 +85,14 @@ export class ApiKeyService {
return null;
}
await this.apiKeyRepository.update(id, updateData);
await this.apiKeyRepository.update(workspaceId, { id }, updateData);
await this.invalidateApiKeyCache(workspaceId);
return this.findById(id, workspaceId);
}
async revoke(id: string, workspaceId: string): Promise<ApiKeyEntity | null> {
return await this.update(id, workspaceId, {
revokedAt: new Date(),
});
return this.update(id, workspaceId, { revokedAt: new Date() });
}
async validateApiKey(id: string, workspaceId: string): Promise<ApiKeyEntity> {
@@ -1,13 +1,12 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { fromApiKeyEntityToFlat } from 'src/engine/core-modules/api-key/utils/from-api-key-entity-to-flat.util';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.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 { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
@Injectable()
@@ -16,8 +15,8 @@ export class WorkspaceApiKeyMapCacheService extends WorkspaceCacheProvider<
Record<string, FlatApiKey>
> {
constructor(
@InjectRepository(ApiKeyEntity)
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
@InjectWorkspaceScopedRepository(ApiKeyEntity)
private readonly apiKeyRepository: WorkspaceScopedRepository<ApiKeyEntity>,
) {
super();
}
@@ -25,9 +24,7 @@ export class WorkspaceApiKeyMapCacheService extends WorkspaceCacheProvider<
async computeForCache(
workspaceId: string,
): Promise<Record<string, FlatApiKey>> {
const apiKeys = await this.apiKeyRepository.find({
where: { workspaceId },
});
const apiKeys = await this.apiKeyRepository.find(workspaceId);
return apiKeys.reduce(
(map, apiKey) => {