4797d2f270
## 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)
100 lines
3.5 KiB
TypeScript
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,
|
|
},
|
|
});
|