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:
+3
@@ -27,6 +27,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { RowLevelPermissionModule } from 'src/engine/metadata-modules/row-level-permission-predicate/row-level-permission.module';
|
||||
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Module({
|
||||
@@ -62,6 +63,8 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
BillingWebhookSubscriptionService,
|
||||
BillingWebhookSubscriptionScheduleService,
|
||||
BillingWebhookEntitlementService,
|
||||
provideWorkspaceScopedRepository(BillingEntitlementEntity),
|
||||
provideWorkspaceScopedRepository(BillingCustomerEntity),
|
||||
],
|
||||
})
|
||||
export class BillingWebhookModule {}
|
||||
|
||||
+6
-10
@@ -1,9 +1,6 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
@@ -12,13 +9,14 @@ import {
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.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';
|
||||
@Injectable()
|
||||
export class BillingWebhookCustomerService {
|
||||
protected readonly logger = new Logger(BillingWebhookCustomerService.name);
|
||||
constructor(
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
) {}
|
||||
|
||||
async processStripeEvent(data: Stripe.CustomerCreatedEvent.Data) {
|
||||
@@ -34,10 +32,8 @@ export class BillingWebhookCustomerService {
|
||||
}
|
||||
|
||||
await this.billingCustomerRepository.upsert(
|
||||
{
|
||||
stripeCustomerId,
|
||||
workspaceId,
|
||||
},
|
||||
workspaceId,
|
||||
{ stripeCustomerId },
|
||||
{
|
||||
conflictPaths: ['workspaceId'],
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
|
||||
+14
-7
@@ -16,14 +16,17 @@ import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/
|
||||
import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
|
||||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||
import { RowLevelPermissionPredicateGroupService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate-group.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';
|
||||
@Injectable()
|
||||
export class BillingWebhookEntitlementService {
|
||||
constructor(
|
||||
// Stripe webhook: workspace discovered from BillingCustomer by stripeCustomerId.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectRepository(BillingEntitlementEntity)
|
||||
private readonly billingEntitlementRepository: Repository<BillingEntitlementEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingEntitlementEntity)
|
||||
private readonly billingEntitlementRepository: WorkspaceScopedRepository<BillingEntitlementEntity>,
|
||||
private readonly rowLevelPermissionPredicateGroupService: RowLevelPermissionPredicateGroupService,
|
||||
) {}
|
||||
|
||||
@@ -49,10 +52,14 @@ export class BillingWebhookEntitlementService {
|
||||
data,
|
||||
);
|
||||
|
||||
await this.billingEntitlementRepository.upsert(billingEntitlements, {
|
||||
conflictPaths: ['workspaceId', 'key'],
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
});
|
||||
await this.billingEntitlementRepository.upsert(
|
||||
workspaceId,
|
||||
billingEntitlements,
|
||||
{
|
||||
conflictPaths: ['workspaceId', 'key'],
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
const isRowLevelPermissionDisabled = billingEntitlements.some(
|
||||
(entitlement) =>
|
||||
|
||||
+3
@@ -33,6 +33,8 @@ export class BillingWebhookInvoiceService {
|
||||
constructor(
|
||||
@InjectRepository(BillingSubscriptionItemEntity)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
|
||||
// Stripe webhook: workspace discovered from BillingCustomer by stripeCustomerId.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
@@ -120,6 +122,7 @@ export class BillingWebhookInvoiceService {
|
||||
): Promise<void> {
|
||||
const params =
|
||||
await this.resourceCreditService.getResourceCreditRolloverParameters(
|
||||
subscription.workspaceId,
|
||||
subscription.id,
|
||||
);
|
||||
|
||||
|
||||
+2
@@ -18,6 +18,8 @@ export class BillingWebhookSubscriptionScheduleService {
|
||||
);
|
||||
|
||||
constructor(
|
||||
// Stripe webhook: subscription lookup by stripeSubscriptionId.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
|
||||
|
||||
+7
-2
@@ -31,6 +31,8 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import {
|
||||
CleanWorkspaceDeletionWarningUserVarsJob,
|
||||
@@ -47,14 +49,16 @@ export class BillingWebhookSubscriptionService {
|
||||
private readonly stripeCustomerService: StripeCustomerService,
|
||||
@InjectMessageQueue(MessageQueue.workspaceQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
// Stripe webhook upserts conflict-resolve globally on stripeSubscriptionId.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
@InjectRepository(BillingSubscriptionItemEntity)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
@@ -99,6 +103,7 @@ export class BillingWebhookSubscriptionService {
|
||||
}
|
||||
|
||||
await this.billingCustomerRepository.upsert(
|
||||
workspaceId,
|
||||
transformStripeSubscriptionEventToDatabaseCustomer(workspaceId, data),
|
||||
{
|
||||
conflictPaths: ['workspaceId'],
|
||||
|
||||
Reference in New Issue
Block a user