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:
@@ -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: [
|
||||
|
||||
+19
-24
@@ -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);
|
||||
});
|
||||
|
||||
+8
-10
@@ -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;
|
||||
|
||||
+19
-27
@@ -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> {
|
||||
|
||||
+5
-8
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user