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
@@ -0,0 +1,351 @@
import { type Repository } from 'typeorm';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
type FakeEntity = {
id: string;
status: string;
workspaceId: string;
[key: string]: unknown;
};
const WORKSPACE_ID = 'workspace-1';
const OTHER_WORKSPACE_ID = 'workspace-2';
const createMockRepository = (): jest.Mocked<Repository<FakeEntity>> =>
({
findOne: jest.fn().mockResolvedValue(null),
findOneOrFail: jest.fn(),
find: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
update: jest.fn(),
delete: jest.fn(),
softDelete: jest.fn(),
insert: jest.fn(),
upsert: jest.fn(),
save: jest.fn(),
createQueryBuilder: jest.fn(),
}) as unknown as jest.Mocked<Repository<FakeEntity>>;
describe('WorkspaceScopedRepository', () => {
let repository: jest.Mocked<Repository<FakeEntity>>;
let scoped: WorkspaceScopedRepository<FakeEntity>;
beforeEach(() => {
repository = createMockRepository();
scoped = new WorkspaceScopedRepository(repository);
});
describe('workspaceId guard', () => {
// TypeORM drops `undefined` values from WHERE/criteria, so a
// missing workspaceId would otherwise produce an unscoped query.
// Each public method must trip before reaching the repository.
it.each([
['findOne', () => scoped.findOne(undefined as never, { where: {} })],
[
'findOneOrFail',
() => scoped.findOneOrFail(undefined as never, { where: {} }),
],
['find', () => scoped.find(undefined as never)],
['count', () => scoped.count(undefined as never)],
['update', () => scoped.update(undefined as never, {}, {})],
['delete', () => scoped.delete(undefined as never, {})],
['softDelete', () => scoped.softDelete(undefined as never, {})],
['insert', () => scoped.insert(undefined as never, {})],
['upsert', () => scoped.upsert(undefined as never, {}, ['id'])],
['save', () => scoped.save(undefined as never, {})],
['saveMany', () => scoped.saveMany(undefined as never, [{}])],
])('%s throws when workspaceId is undefined', (_name, call) => {
expect(call).toThrow(/workspaceId must be a non-empty string/);
});
it.each([null, ''])('throws when workspaceId is %p', (badWorkspaceId) => {
expect(() =>
scoped.findOne(badWorkspaceId as never, { where: {} }),
).toThrow(/workspaceId must be a non-empty string/);
});
});
describe('findOne', () => {
it('merges workspaceId into a plain where clause', async () => {
await scoped.findOne(WORKSPACE_ID, { where: { id: 'a' } });
expect(repository.findOne).toHaveBeenCalledWith({
where: { id: 'a', workspaceId: WORKSPACE_ID },
});
});
it('merges workspaceId into every clause of an OR (array) where', async () => {
await scoped.findOne(WORKSPACE_ID, {
where: [{ id: 'a' }, { status: 'queued' }],
});
expect(repository.findOne).toHaveBeenCalledWith({
where: [
{ id: 'a', workspaceId: WORKSPACE_ID },
{ status: 'queued', workspaceId: WORKSPACE_ID },
],
});
});
it('throws if the caller includes workspaceId in the WHERE clause', () => {
expect(() =>
scoped.findOne(WORKSPACE_ID, {
where: { id: 'a', workspaceId: OTHER_WORKSPACE_ID } as never,
}),
).toThrow(/do not include `workspaceId`/);
expect(repository.findOne).not.toHaveBeenCalled();
});
it('throws if any clause of an array WHERE includes workspaceId', () => {
expect(() =>
scoped.findOne(WORKSPACE_ID, {
where: [
{ id: 'a' },
{ id: 'b', workspaceId: OTHER_WORKSPACE_ID } as never,
],
}),
).toThrow(/do not include `workspaceId`/);
});
it('places workspaceId first in the merged WHERE clause', async () => {
await scoped.findOne(WORKSPACE_ID, {
where: { id: 'a', status: 'queued' },
});
const callArg = repository.findOne.mock.calls[0][0];
const whereKeys = Object.keys(
(callArg as { where: Record<string, unknown> }).where,
);
expect(whereKeys[0]).toBe('workspaceId');
});
it('preserves relations and other options', async () => {
await scoped.findOne(WORKSPACE_ID, {
where: { id: 'a' },
relations: ['messages'],
select: ['id'],
});
expect(repository.findOne).toHaveBeenCalledWith({
where: { id: 'a', workspaceId: WORKSPACE_ID },
relations: ['messages'],
select: ['id'],
});
});
});
describe('find', () => {
it('adds workspaceId when no where is provided', async () => {
await scoped.find(WORKSPACE_ID);
expect(repository.find).toHaveBeenCalledWith({
where: { workspaceId: WORKSPACE_ID },
});
});
it('merges workspaceId into provided where', async () => {
await scoped.find(WORKSPACE_ID, { where: { status: 'queued' } });
expect(repository.find).toHaveBeenCalledWith({
where: { status: 'queued', workspaceId: WORKSPACE_ID },
});
});
});
describe('update', () => {
it('merges workspaceId into the criteria, not the patch', async () => {
await scoped.update(WORKSPACE_ID, { id: 'a' }, { status: 'completed' });
expect(repository.update).toHaveBeenCalledWith(
{ id: 'a', workspaceId: WORKSPACE_ID },
{ status: 'completed' },
);
});
it('throws if the caller includes workspaceId in the criteria', () => {
expect(() =>
scoped.update(
WORKSPACE_ID,
{ id: 'a', workspaceId: OTHER_WORKSPACE_ID } as never,
{ status: 'completed' },
),
).toThrow(/do not include `workspaceId`/);
expect(repository.update).not.toHaveBeenCalled();
});
});
describe('delete and softDelete', () => {
it('delete merges workspaceId into criteria', async () => {
await scoped.delete(WORKSPACE_ID, { id: 'a' });
expect(repository.delete).toHaveBeenCalledWith({
id: 'a',
workspaceId: WORKSPACE_ID,
});
});
it('softDelete merges workspaceId into criteria', async () => {
await scoped.softDelete(WORKSPACE_ID, { id: 'a' });
expect(repository.softDelete).toHaveBeenCalledWith({
id: 'a',
workspaceId: WORKSPACE_ID,
});
});
});
describe('insert', () => {
it('stamps workspaceId on a single entity', async () => {
await scoped.insert(WORKSPACE_ID, { id: 'a', status: 'queued' });
expect(repository.insert).toHaveBeenCalledWith({
id: 'a',
status: 'queued',
workspaceId: WORKSPACE_ID,
});
});
it('stamps workspaceId on each entity in an array', async () => {
await scoped.insert(WORKSPACE_ID, [
{ id: 'a', status: 'queued' },
{ id: 'b', status: 'sent' },
]);
expect(repository.insert).toHaveBeenCalledWith([
{ id: 'a', status: 'queued', workspaceId: WORKSPACE_ID },
{ id: 'b', status: 'sent', workspaceId: WORKSPACE_ID },
]);
});
it('overrides caller-supplied workspaceId on the entity', async () => {
await scoped.insert(WORKSPACE_ID, {
id: 'a',
workspaceId: OTHER_WORKSPACE_ID,
});
expect(repository.insert).toHaveBeenCalledWith({
id: 'a',
workspaceId: WORKSPACE_ID,
});
});
});
describe('upsert', () => {
it('stamps workspaceId on a single entity and forwards conflict opts', async () => {
await scoped.upsert(WORKSPACE_ID, { id: 'a', status: 'queued' }, ['id']);
expect(repository.upsert).toHaveBeenCalledWith(
{ id: 'a', status: 'queued', workspaceId: WORKSPACE_ID },
['id'],
);
});
it('stamps workspaceId on each entity in an array', async () => {
await scoped.upsert(
WORKSPACE_ID,
[
{ id: 'a', status: 'queued' },
{ id: 'b', status: 'sent' },
],
{ conflictPaths: ['id'] },
);
expect(repository.upsert).toHaveBeenCalledWith(
[
{ id: 'a', status: 'queued', workspaceId: WORKSPACE_ID },
{ id: 'b', status: 'sent', workspaceId: WORKSPACE_ID },
],
{ conflictPaths: ['id'] },
);
});
});
describe('save', () => {
it('stamps workspaceId on the entity passed to save', async () => {
await scoped.save(WORKSPACE_ID, { id: 'a', status: 'queued' });
expect(repository.save).toHaveBeenCalledWith(
{ id: 'a', status: 'queued', workspaceId: WORKSPACE_ID },
undefined,
);
});
it('saveMany stamps workspaceId on each entity', async () => {
await scoped.saveMany(WORKSPACE_ID, [
{ id: 'a', status: 'queued' },
{ id: 'b', status: 'sent' },
]);
expect(repository.save).toHaveBeenCalledWith(
[
{ id: 'a', status: 'queued', workspaceId: WORKSPACE_ID },
{ id: 'b', status: 'sent', workspaceId: WORKSPACE_ID },
],
undefined,
);
});
it('overrides caller-supplied workspaceId on the entity', async () => {
await scoped.save(WORKSPACE_ID, {
id: 'a',
workspaceId: OTHER_WORKSPACE_ID,
});
expect(repository.save).toHaveBeenCalledWith(
{ id: 'a', workspaceId: WORKSPACE_ID },
undefined,
);
});
});
describe('count', () => {
it('merges workspaceId into where', async () => {
await scoped.count(WORKSPACE_ID, { where: { status: 'queued' } });
expect(repository.count).toHaveBeenCalledWith({
where: { status: 'queued', workspaceId: WORKSPACE_ID },
});
});
it('adds workspaceId when no options are provided', async () => {
await scoped.count(WORKSPACE_ID);
expect(repository.count).toHaveBeenCalledWith({
where: { workspaceId: WORKSPACE_ID },
});
});
});
describe('createQueryBuilder', () => {
it('returns the underlying QueryBuilder unchanged (escape hatch)', () => {
scoped.createQueryBuilder('t');
expect(repository.createQueryBuilder).toHaveBeenCalledWith('t');
});
});
describe('withManager', () => {
it('returns a new wrapper bound to the manager-provided repository', async () => {
const txRepository = createMockRepository();
const manager = {
getRepository: jest.fn().mockReturnValue(txRepository),
} as unknown as import('typeorm').EntityManager;
(repository as unknown as { target: unknown }).target = 'FakeEntity';
const tx = scoped.withManager(manager);
expect(tx).not.toBe(scoped);
expect(manager.getRepository).toHaveBeenCalledWith('FakeEntity');
await tx.findOne(WORKSPACE_ID, { where: { id: 'a' } });
expect(txRepository.findOne).toHaveBeenCalledWith({
where: { id: 'a', workspaceId: WORKSPACE_ID },
});
expect(repository.findOne).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,13 @@
import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
const getEntityName = (entity: EntityClassOrSchema): string => {
if (typeof entity === 'function') {
return entity.name;
}
return entity.options?.name ?? entity.constructor.name;
};
export const getWorkspaceScopedRepositoryToken = (
entity: EntityClassOrSchema,
): string => `WorkspaceScopedRepository<${getEntityName(entity)}>`;
@@ -0,0 +1,9 @@
import { Inject } from '@nestjs/common';
import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
export const InjectWorkspaceScopedRepository = (
entity: EntityClassOrSchema,
): ParameterDecorator => Inject(getWorkspaceScopedRepositoryToken(entity));
@@ -0,0 +1,19 @@
import { type Provider } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
import { type Repository } from 'typeorm';
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
import { type WorkspaceScopedEntity } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
// Requires TypeOrmModule.forFeature([entity]) in the same module.
export const provideWorkspaceScopedRepository = (
entity: EntityClassOrSchema,
): Provider => ({
provide: getWorkspaceScopedRepositoryToken(entity),
useFactory: <T extends WorkspaceScopedEntity>(repository: Repository<T>) =>
new WorkspaceScopedRepository<T>(repository),
inject: [getRepositoryToken(entity)],
});
@@ -0,0 +1,3 @@
import { type ObjectLiteral } from 'typeorm';
export type WorkspaceScopedEntity = ObjectLiteral & { workspaceId: string };
@@ -0,0 +1,217 @@
import {
type DeepPartial,
type DeleteResult,
type EntityManager,
type FindManyOptions,
type FindOneOptions,
type FindOptionsWhere,
type InsertResult,
type Repository,
type SaveOptions,
type SelectQueryBuilder,
type UpdateResult,
} from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { type UpsertOptions } from 'typeorm/repository/UpsertOptions';
import { type WorkspaceScopedEntity } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type';
// Wraps a TypeORM Repository to scope every operation by workspaceId.
// For workspace-data entities, use WorkspaceRepository instead.
export class WorkspaceScopedRepository<T extends WorkspaceScopedEntity> {
constructor(private readonly repository: Repository<T>) {}
findOne(workspaceId: string, options: FindOneOptions<T>): Promise<T | null> {
this.assertWorkspaceId(workspaceId);
return this.repository.findOne({
...options,
where: this.mergeWorkspaceIdIntoWhere(workspaceId, options.where),
});
}
findOneOrFail(workspaceId: string, options: FindOneOptions<T>): Promise<T> {
this.assertWorkspaceId(workspaceId);
return this.repository.findOneOrFail({
...options,
where: this.mergeWorkspaceIdIntoWhere(workspaceId, options.where),
});
}
find(workspaceId: string, options?: FindManyOptions<T>): Promise<T[]> {
this.assertWorkspaceId(workspaceId);
return this.repository.find({
...options,
where: this.mergeWorkspaceIdIntoWhere(workspaceId, options?.where),
});
}
count(workspaceId: string, options?: FindManyOptions<T>): Promise<number> {
this.assertWorkspaceId(workspaceId);
return this.repository.count({
...options,
where: this.mergeWorkspaceIdIntoWhere(workspaceId, options?.where),
});
}
update(
workspaceId: string,
criteria: FindOptionsWhere<T>,
partialEntity: QueryDeepPartialEntity<T>,
): Promise<UpdateResult> {
this.assertWorkspaceId(workspaceId);
return this.repository.update(
this.mergeWorkspaceIdIntoCriteria(workspaceId, criteria),
partialEntity,
);
}
delete(
workspaceId: string,
criteria: FindOptionsWhere<T>,
): Promise<DeleteResult> {
this.assertWorkspaceId(workspaceId);
return this.repository.delete(
this.mergeWorkspaceIdIntoCriteria(workspaceId, criteria),
);
}
softDelete(
workspaceId: string,
criteria: FindOptionsWhere<T>,
): Promise<UpdateResult> {
this.assertWorkspaceId(workspaceId);
return this.repository.softDelete(
this.mergeWorkspaceIdIntoCriteria(workspaceId, criteria),
);
}
insert(
workspaceId: string,
entity: QueryDeepPartialEntity<T> | QueryDeepPartialEntity<T>[],
): Promise<InsertResult> {
this.assertWorkspaceId(workspaceId);
return this.repository.insert(
this.stampWorkspaceIdOnEntities(workspaceId, entity),
);
}
upsert(
workspaceId: string,
entity: QueryDeepPartialEntity<T> | QueryDeepPartialEntity<T>[],
conflictPathsOrOptions: string[] | UpsertOptions<T>,
): Promise<InsertResult> {
this.assertWorkspaceId(workspaceId);
return this.repository.upsert(
this.stampWorkspaceIdOnEntities(workspaceId, entity),
conflictPathsOrOptions,
);
}
save<E extends DeepPartial<T>>(
workspaceId: string,
entity: E,
options?: SaveOptions,
): Promise<E & T> {
this.assertWorkspaceId(workspaceId);
return this.repository.save({ ...entity, workspaceId } as E, options);
}
saveMany<E extends DeepPartial<T>>(
workspaceId: string,
entities: E[],
options?: SaveOptions,
): Promise<(E & T)[]> {
this.assertWorkspaceId(workspaceId);
return this.repository.save(
entities.map((entity) => ({ ...entity, workspaceId }) as E),
options,
);
}
// Escape hatch. Caller MUST add the workspaceId predicate themselves.
createQueryBuilder(alias?: string): SelectQueryBuilder<T> {
return this.repository.createQueryBuilder(alias);
}
// Returns a wrapper bound to the given EntityManager (transactions).
withManager(manager: EntityManager): WorkspaceScopedRepository<T> {
return new WorkspaceScopedRepository<T>(
manager.getRepository(this.repository.target),
);
}
// TypeORM drops `undefined` values from WHERE, which would emit an
// unscoped query. Reject falsy workspaceId at the boundary.
private assertWorkspaceId(workspaceId: string): void {
if (
workspaceId === undefined ||
workspaceId === null ||
workspaceId === ''
) {
throw new Error(
'WorkspaceScopedRepository: workspaceId must be a non-empty string.',
);
}
}
private mergeWorkspaceIdIntoWhere(
workspaceId: string,
where: FindOneOptions<T>['where'] | undefined,
): FindOptionsWhere<T> | FindOptionsWhere<T>[] {
if (where === undefined) {
return { workspaceId } as FindOptionsWhere<T>;
}
if (Array.isArray(where)) {
return where.map((clause) =>
this.prependWorkspaceId(workspaceId, clause),
);
}
return this.prependWorkspaceId(workspaceId, where as FindOptionsWhere<T>);
}
private mergeWorkspaceIdIntoCriteria(
workspaceId: string,
criteria: FindOptionsWhere<T>,
): FindOptionsWhere<T> {
return this.prependWorkspaceId(workspaceId, criteria);
}
private prependWorkspaceId(
workspaceId: string,
clause: FindOptionsWhere<T>,
): FindOptionsWhere<T> {
if ('workspaceId' in clause) {
throw new Error(
'WorkspaceScopedRepository: do not include `workspaceId` in the WHERE clause — it is provided as the first argument and merged automatically.',
);
}
return { workspaceId, ...clause } as FindOptionsWhere<T>;
}
private stampWorkspaceIdOnEntities(
workspaceId: string,
entity: QueryDeepPartialEntity<T> | QueryDeepPartialEntity<T>[],
): QueryDeepPartialEntity<T> | QueryDeepPartialEntity<T>[] {
if (Array.isArray(entity)) {
return entity.map(
(item) => ({ ...item, workspaceId }) as QueryDeepPartialEntity<T>,
);
}
return { ...entity, workspaceId } as QueryDeepPartialEntity<T>;
}
}