Files
twenty/packages/twenty-oxlint-rules/oxlint-plugin.ts
T
Félix Malfait 4797d2f270 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)
2026-05-27 18:52:53 +02:00

100 lines
3.5 KiB
TypeScript

import { definePlugin } from '@oxlint/plugins';
import {
rule as componentPropsNaming,
RULE_NAME as componentPropsNamingName,
} from './rules/component-props-naming';
import {
rule as effectComponents,
RULE_NAME as effectComponentsName,
} from './rules/effect-components';
import {
rule as enforceModuleBoundaries,
RULE_NAME as enforceModuleBoundariesName,
} from './rules/enforce-module-boundaries';
import {
rule as folderStructure,
RULE_NAME as folderStructureName,
} from './rules/folder-structure';
import {
rule as graphqlResolversShouldBeGuarded,
RULE_NAME as graphqlResolversShouldBeGuardedName,
} from './rules/graphql-resolvers-should-be-guarded';
import {
rule as injectWorkspaceRepository,
RULE_NAME as injectWorkspaceRepositoryName,
} from './rules/inject-workspace-repository';
import {
rule as matchingStateVariable,
RULE_NAME as matchingStateVariableName,
} from './rules/matching-state-variable';
import {
rule as maxConstsPerFile,
RULE_NAME as maxConstsPerFileName,
} from './rules/max-consts-per-file';
import {
rule as noDirectAtomFamilyInSelector,
RULE_NAME as noDirectAtomFamilyInSelectorName,
} from './rules/no-direct-atom-family-in-selector';
import {
rule as noHardcodedColors,
RULE_NAME as noHardcodedColorsName,
} from './rules/no-hardcoded-colors';
import {
rule as noJotaiStoreInSelector,
RULE_NAME as noJotaiStoreInSelectorName,
} from './rules/no-jotai-store-in-selector';
import {
rule as noNavigatePreferLink,
RULE_NAME as noNavigatePreferLinkName,
} from './rules/no-navigate-prefer-link';
import {
rule as noStateUseref,
RULE_NAME as noStateUserefName,
} from './rules/no-state-useref';
import {
rule as preferWorkspaceScopedRepository,
RULE_NAME as preferWorkspaceScopedRepositoryName,
} from './rules/prefer-workspace-scoped-repository';
import {
rule as restApiMethodsShouldBeGuarded,
RULE_NAME as restApiMethodsShouldBeGuardedName,
} from './rules/rest-api-methods-should-be-guarded';
import {
rule as sortCssPropertiesAlphabetically,
RULE_NAME as sortCssPropertiesAlphabeticallyName,
} from './rules/sort-css-properties-alphabetically';
import {
rule as styledComponentsPrefixedWithStyled,
RULE_NAME as styledComponentsPrefixedWithStyledName,
} from './rules/styled-components-prefixed-with-styled';
import {
rule as upgradeCommandFilename,
RULE_NAME as upgradeCommandFilenameName,
} from './rules/upgrade-command-filename';
export default definePlugin({
meta: { name: 'twenty' },
rules: {
[componentPropsNamingName]: componentPropsNaming,
[effectComponentsName]: effectComponents,
[enforceModuleBoundariesName]: enforceModuleBoundaries,
[folderStructureName]: folderStructure,
[graphqlResolversShouldBeGuardedName]: graphqlResolversShouldBeGuarded,
[injectWorkspaceRepositoryName]: injectWorkspaceRepository,
[matchingStateVariableName]: matchingStateVariable,
[maxConstsPerFileName]: maxConstsPerFile,
[noDirectAtomFamilyInSelectorName]: noDirectAtomFamilyInSelector,
[noHardcodedColorsName]: noHardcodedColors,
[noJotaiStoreInSelectorName]: noJotaiStoreInSelector,
[noNavigatePreferLinkName]: noNavigatePreferLink,
[noStateUserefName]: noStateUseref,
[preferWorkspaceScopedRepositoryName]: preferWorkspaceScopedRepository,
[restApiMethodsShouldBeGuardedName]: restApiMethodsShouldBeGuarded,
[sortCssPropertiesAlphabeticallyName]: sortCssPropertiesAlphabetically,
[styledComponentsPrefixedWithStyledName]:
styledComponentsPrefixedWithStyled,
[upgradeCommandFilenameName]: upgradeCommandFilename,
},
});