feat: optimize hot database queries with multi-layer caching (#19068)

## Summary

Introduces multi-layer caching for the 5 most frequent database queries
identified in production (Sentry data), targeting the JWT authentication
hot path and cron job logic.

### Problem
Our database is under heavy load from uncached queries on the auth hot
path:
- `WorkspaceEntity` lookups: **638 queries/min**
- `ApiKeyEntity` lookups: **491 queries/min**
- `UserEntity` lookups: **147 queries/min**
- `UserWorkspaceEntity` lookups: **143 queries/min**
- `LogicFunctionEntity` lookups: **1800 queries/min** (cron job)

### Solution

**1. New `CoreEntityCacheService`** for non-workspace-scoped entities
(Workspace, User, UserWorkspace):
- Mirrors `WorkspaceCacheService` architecture (in-process Map + Redis
with hash validation)
- Provider pattern with `@CoreEntityCache` decorator
- Keyed by entity primary key (not workspaceId)
- 100ms local TTL, Redis-backed hash validation for cross-instance
consistency
- Three providers: `WorkspaceEntityCacheProviderService`,
`UserEntityCacheProviderService`,
`UserWorkspaceEntityCacheProviderService`

**2. New `apiKeyMap` WorkspaceCache** for workspace-scoped API key
lookups:
- `WorkspaceApiKeyMapCacheService` loads all API keys for a workspace
into a map by ID
- Leverages existing `WorkspaceCacheService` infrastructure
- Cache invalidation on API key create/update/revoke

**3. `CronTriggerCronJob` refactored** to use existing
`flatLogicFunctionMaps` workspace cache:
- Eliminates per-workspace `LogicFunctionEntity` repository queries
(~1800/min)
- Filters cached data in-memory instead

**4. `JwtAuthStrategy` refactored** to use caches for all entity
lookups:
- Workspace, User, UserWorkspace → `CoreEntityCacheService`
- ApiKey → `WorkspaceCacheService` (`apiKeyMap`)
- Impersonation queries kept as direct DB queries (rare path, requires
relations)

**5. Cache invalidation** wired into mutation paths:
- `WorkspaceService` → invalidates `workspaceEntity` on
save/update/delete
- `ApiKeyService` → invalidates `apiKeyMap` on create/update/revoke

### Architecture

```
Request → JwtAuthStrategy
  ├── Workspace lookup → CoreEntityCacheService (in-process → Redis → DB)
  ├── User lookup → CoreEntityCacheService (in-process → Redis → DB)
  ├── UserWorkspace lookup → CoreEntityCacheService (in-process → Redis → DB)
  └── ApiKey lookup → WorkspaceCacheService (in-process → Redis → DB)

CronTriggerCronJob
  └── LogicFunction lookup → WorkspaceCacheService (flatLogicFunctionMaps)
```

### Expected Impact
| Query | Before | After |
|-------|--------|-------|
| WorkspaceEntity | 638/min | ~0 (cached) |
| ApiKeyEntity | 491/min | ~0 (cached) |
| UserEntity | 147/min | ~0 (cached) |
| UserWorkspaceEntity | 143/min | ~0 (cached) |
| LogicFunctionEntity | 1800/min | ~0 (cached) |

### Not included (ongoing separately)
- DataSourceEntity query optimization (IS_DATASOURCE_MIGRATED migration)
- ObjectMetadataEntity query optimization (already partially cached)
This commit is contained in:
Charles Bochet
2026-03-28 22:53:34 +01:00
committed by GitHub
parent 81fc960712
commit c407341912
69 changed files with 1423 additions and 554 deletions
+9 -8
View File
@@ -1,21 +1,22 @@
import { type APP_LOCALES } from 'twenty-shared/translations';
import { type ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type User } from 'src/engine/core-modules/user/user.entity';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
declare module 'express-serve-static-core' {
interface Request {
user?: User | null;
apiKey?: ApiKey | null;
user?: FlatAuthContextUser | null;
apiKey?: FlatApiKey | null;
application?: ApplicationEntity | null;
userWorkspace?: UserWorkspace;
userWorkspace?: FlatUserWorkspace;
locale: keyof typeof APP_LOCALES;
workspace?: Workspace;
workspace?: FlatWorkspace;
workspaceId?: string;
workspaceMetadataVersion?: number;
workspaceMemberId?: string;
@@ -5,7 +5,7 @@ import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { DataArgProcessorService } from 'src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service';
import { type SystemWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -30,7 +30,7 @@ describe('DataArgProcessorService', () => {
const createMockAuthContext = (): SystemWorkspaceAuthContext => ({
type: 'system',
workspace: { id: mockWorkspaceId } as WorkspaceEntity,
workspace: { id: mockWorkspaceId } as FlatWorkspace,
});
const createFlatFieldMetadataMaps = (
@@ -32,15 +32,15 @@ import { useValidateGraphqlQueryComplexity } from 'src/engine/core-modules/graph
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { DataloaderService } from 'src/engine/dataloaders/dataloader.service';
import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util';
import { renderApolloPlayground } from 'src/engine/utils/render-apollo-playground.util';
export interface GraphQLContext extends YogaDriverServerContext<'express'> {
user?: UserEntity;
workspace?: WorkspaceEntity;
user?: FlatAuthContextUser;
workspace?: FlatWorkspace;
}
@Injectable()
@@ -172,7 +172,7 @@ export class GraphQLConfigService
async createSchema(
context: YogaDriverServerContext<'express'> & YogaInitialContext,
workspace: WorkspaceEntity,
workspace: FlatWorkspace,
applicationId?: string,
): Promise<GraphQLSchemaWithContext<YogaDriverServerContext<'express'>>> {
// Create a new contextId for each request
@@ -1,9 +1,9 @@
import { type YogaDriverServerContext } from '@graphql-yoga/nestjs';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
export interface GraphQLContext extends YogaDriverServerContext<'express'> {
user?: UserEntity;
workspace?: WorkspaceEntity;
user?: FlatAuthContextUser;
workspace?: FlatWorkspace;
}
@@ -12,7 +12,7 @@ import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/work
import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory';
import { WorkspaceGraphQLSchemaGenerator } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import {
FlatEntityMapsException,
@@ -41,7 +41,7 @@ export class WorkspaceSchemaFactory {
) {}
async createGraphQLSchema(
workspace: WorkspaceEntity,
workspace: FlatWorkspace,
applicationId?: string,
): Promise<GraphQLSchema> {
const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled(
@@ -9,12 +9,12 @@ import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.contr
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@@ -69,10 +69,10 @@ describe('McpCoreController', () => {
});
describe('handleMcpCore', () => {
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
const mockWorkspace = { id: 'workspace-1' } as FlatWorkspace;
const mockUser = { id: 'user-1' } as UserEntity;
const mockUserWorkspaceId = 'user-workspace-1';
const mockApiKey = { id: 'api-key-1' } as ApiKeyEntity;
const mockApiKey = { id: 'api-key-1' } as FlatApiKey;
const mockRes = {
status: jest.fn().mockReturnThis(),
} as unknown as import('express').Response;
@@ -18,9 +18,9 @@ import { JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
@@ -45,8 +45,8 @@ export class McpCoreController {
)
async handleMcpCore(
@Body() body: JsonRpc,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@AuthWorkspace() workspace: FlatWorkspace,
@AuthApiKey() apiKey: FlatApiKey | undefined,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@@ -8,14 +8,14 @@ import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { EXECUTE_TOOL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
import { GET_TOOL_CATALOG_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/get-tool-catalog.tool';
import { LEARN_TOOLS_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
import { LOAD_SKILL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/load-skill.tool';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -26,14 +26,14 @@ describe('McpProtocolService', () => {
let mcpToolExecutorService: jest.Mocked<McpToolExecutorService>;
let apiKeyRoleService: jest.Mocked<ApiKeyRoleService>;
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
const mockWorkspace = { id: 'workspace-1' } as FlatWorkspace;
const mockUserWorkspaceId = 'user-workspace-1';
const mockRoleId = 'role-1';
const mockAdminRoleId = 'admin-role-1';
const mockApiKey = {
id: 'api-key-1',
workspaceId: mockWorkspace.id,
} as ApiKeyEntity;
} as FlatApiKey;
const EXPECTED_MCP_TOOL_NAMES = [
GET_TOOL_CATALOG_TOOL_NAME,
@@ -10,7 +10,7 @@ import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { buildApiKeyAuthContext } from 'src/engine/core-modules/auth/utils/build-api-key-auth-context.util';
@@ -36,7 +36,7 @@ import {
LOAD_SKILL_TOOL_NAME,
loadSkillInputSchema,
} from 'src/engine/core-modules/tool-provider/tools/load-skill.tool';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -70,7 +70,7 @@ export class McpProtocolService {
async getRoleId(
workspaceId: string,
userWorkspaceId?: string,
apiKey?: ApiKeyEntity,
apiKey?: FlatApiKey,
) {
if (isDefined(apiKey)) {
return this.apiKeyRoleService.getRoleIdForApiKeyId(
@@ -99,7 +99,7 @@ export class McpProtocolService {
}
private async buildMcpToolSet(
workspace: WorkspaceEntity,
workspace: FlatWorkspace,
roleId: string,
options?: {
authContext?: WorkspaceAuthContext;
@@ -165,10 +165,10 @@ export class McpProtocolService {
userWorkspaceId,
apiKey,
}: {
workspace: WorkspaceEntity;
workspace: FlatWorkspace;
userId?: string;
userWorkspaceId?: string;
apiKey: ApiKeyEntity | undefined;
apiKey: FlatApiKey | undefined;
},
): Promise<Record<string, unknown> | null> {
try {
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
@Module({
imports: [CacheStorageModule, DiscoveryModule],
providers: [CoreEntityCacheService],
exports: [CoreEntityCacheService],
})
export class CoreEntityCacheModule {}
@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
import { type CoreEntityCacheKeyName } from 'src/engine/core-entity-cache/types/core-entity-cache-key.type';
export const CORE_ENTITY_CACHE_KEY = 'CORE_ENTITY_CACHE_KEY';
export const CoreEntityCache = (
coreEntityCacheKeyName: CoreEntityCacheKeyName,
): ClassDecorator => {
return (target) => {
SetMetadata(CORE_ENTITY_CACHE_KEY, coreEntityCacheKeyName)(target);
};
};
@@ -0,0 +1,41 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export const CoreEntityCacheExceptionCode = appendCommonExceptionCode({
INVALID_PARAMETERS: 'INVALID_PARAMETERS',
} as const);
const getCoreEntityCacheExceptionUserFriendlyMessage = (
code: keyof typeof CoreEntityCacheExceptionCode,
) => {
switch (code) {
case CoreEntityCacheExceptionCode.INVALID_PARAMETERS:
return msg`Invalid parameters provided.`;
case CoreEntityCacheExceptionCode.INTERNAL_SERVER_ERROR:
return msg`An unexpected error occurred.`;
default:
assertUnreachable(code);
}
};
export class CoreEntityCacheException extends CustomException<
keyof typeof CoreEntityCacheExceptionCode
> {
constructor(
message: string,
code: keyof typeof CoreEntityCacheExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getCoreEntityCacheExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import {
type CoreEntityCacheDataMap,
type CoreEntityCacheKeyName,
} from 'src/engine/core-entity-cache/types/core-entity-cache-key.type';
type CoreEntityCacheDataType = CoreEntityCacheDataMap[CoreEntityCacheKeyName];
@Injectable()
export abstract class CoreEntityCacheProvider<
T extends CoreEntityCacheDataType = CoreEntityCacheDataType,
> {
abstract computeForCache(entityId: string): Promise<T | null>;
}
@@ -0,0 +1,332 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, Reflector } from '@nestjs/core';
import crypto from 'crypto';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { CoreEntityCacheProvider } from 'src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { PromiseMemoizer } from 'src/engine/twenty-orm/storage/promise-memoizer.storage';
import { CORE_ENTITY_CACHE_KEY } from 'src/engine/core-entity-cache/decorators/core-entity-cache.decorator';
import {
CORE_ENTITY_CACHE_KEYS,
CoreEntityCacheKeyName,
type CoreEntityCacheDataMap,
} from 'src/engine/core-entity-cache/types/core-entity-cache-key.type';
import { type CoreEntityLocalCacheEntry } from 'src/engine/core-entity-cache/types/core-entity-local-cache-entry.type';
const LOCAL_TTL_MS = 100; // 100ms
const LOCAL_ENTRY_TTL_MS = 30 * 60 * 1000; // 30 minutes
const MEMOIZER_TTL_MS = 10_000; // 10 seconds
const STALE_VERSION_TTL_MS = 5_000; // 5 seconds
const MAX_LOCAL_STALE_VERSIONS = 5;
const MAX_LOCAL_CACHE_ENTRIES = 5_000;
const MIN_EVICT_KEYS = 100;
const NOT_FOUND_SENTINEL = '__CORE_ENTITY_NOT_FOUND__';
type CacheDataType = CoreEntityCacheDataMap[CoreEntityCacheKeyName];
type CacheableValue = CacheDataType | typeof NOT_FOUND_SENTINEL;
@Injectable()
export class CoreEntityCacheService implements OnModuleInit {
private readonly localCache = new Map<
string,
CoreEntityLocalCacheEntry<CacheableValue>
>();
private readonly coreEntityCacheProviders = new Map<
CoreEntityCacheKeyName,
CoreEntityCacheProvider<CacheDataType>
>();
private readonly memoizer = new PromiseMemoizer<CacheableValue>(
MEMOIZER_TTL_MS,
);
private readonly logger = new Logger(CoreEntityCacheService.name);
constructor(
@InjectCacheStorage(CacheStorageNamespace.EngineCoreEntity)
private readonly cacheStorage: CacheStorageService,
private readonly discoveryService: DiscoveryService,
private readonly reflector: Reflector,
) {}
async onModuleInit() {
const providers = this.discoveryService.getProviders();
for (const wrapper of providers) {
const { instance } = wrapper;
if (!isDefined(instance) || typeof instance !== 'object') {
continue;
}
const coreEntityCacheKeyName = this.reflector.get<CoreEntityCacheKeyName>(
CORE_ENTITY_CACHE_KEY,
instance.constructor,
);
if (
isDefined(coreEntityCacheKeyName) &&
instance instanceof CoreEntityCacheProvider
) {
this.coreEntityCacheProviders.set(coreEntityCacheKeyName, instance);
}
}
}
public async get<K extends CoreEntityCacheKeyName>(
cacheKeyName: K,
entityId: string,
): Promise<CoreEntityCacheDataMap[K] | null> {
this.evictExpiredLocalEntries();
if (!isDefined(entityId) || !isValidUuid(entityId)) {
return null;
}
const memoKey = `${cacheKeyName}-${entityId}` as const;
const result = await this.memoizer.memoizePromiseAndExecute(
memoKey,
async () => {
const localKey = this.buildCacheKey(entityId, cacheKeyName);
const localEntry = this.localCache.get(localKey);
const now = Date.now();
// Stage 1: Check local TTL
if (
isDefined(localEntry) &&
now - localEntry.lastHashCheckedAt < LOCAL_TTL_MS
) {
const version = localEntry.versions.get(localEntry.latestHash);
if (isDefined(version)) {
version.lastReadAt = now;
return version.data;
}
}
// Stage 2: Validate against Redis hash
const hashKey = `${localKey}:hash`;
const redisHash = await this.cacheStorage.get<string>(hashKey);
if (
isDefined(localEntry) &&
isDefined(redisHash) &&
localEntry.latestHash === redisHash
) {
localEntry.lastHashCheckedAt = now;
const version = localEntry.versions.get(localEntry.latestHash);
if (isDefined(version)) {
version.lastReadAt = now;
return version.data;
}
}
// Stage 3: Fetch from Redis
const [redisData, redisDataHash] = await this.cacheStorage.mget<
CacheableValue | string
>([`${localKey}:data`, hashKey]);
if (isDefined(redisData) && isDefined(redisDataHash)) {
this.setInLocalCache(
entityId,
cacheKeyName,
redisData as CacheableValue,
redisDataHash as string,
);
return redisData as CacheableValue;
}
// Stage 4: Recompute from provider
return this.recomputeFromProvider(entityId, cacheKeyName);
},
);
if (result === NOT_FOUND_SENTINEL || result === null) {
return null;
}
return result as CoreEntityCacheDataMap[K];
}
public async invalidate(
cacheKeyName: CoreEntityCacheKeyName,
entityId: string,
): Promise<void> {
await this.memoizer.clearKeys(`${cacheKeyName}-${entityId}`);
await this.flush(entityId, cacheKeyName);
await this.memoizer.clearKeys(`${cacheKeyName}-${entityId}`);
}
public async invalidateAndRecompute(
cacheKeyName: CoreEntityCacheKeyName,
entityId: string,
): Promise<void> {
await this.memoizer.clearKeys(`${cacheKeyName}-${entityId}`);
await this.flush(entityId, cacheKeyName);
await this.recomputeFromProvider(entityId, cacheKeyName);
await this.memoizer.clearKeys(`${cacheKeyName}-${entityId}`);
}
private async recomputeFromProvider(
entityId: string,
cacheKeyName: CoreEntityCacheKeyName,
): Promise<CacheableValue> {
const provider = this.getProviderOrThrow(cacheKeyName);
const data = await provider.computeForCache(entityId);
const hash = crypto.randomUUID();
const valueToCache: CacheableValue =
data === null ? NOT_FOUND_SENTINEL : data;
const localKey = this.buildCacheKey(entityId, cacheKeyName);
await this.cacheStorage.mset<unknown>([
{ key: `${localKey}:hash`, value: hash },
{ key: `${localKey}:data`, value: valueToCache },
]);
this.setInLocalCache(entityId, cacheKeyName, valueToCache, hash);
return valueToCache;
}
private async flush(
entityId: string,
cacheKeyName: CoreEntityCacheKeyName,
): Promise<void> {
const localKey = this.buildCacheKey(entityId, cacheKeyName);
await this.cacheStorage.mdel([`${localKey}:data`, `${localKey}:hash`]);
const entry = this.localCache.get(localKey);
if (isDefined(entry)) {
entry.lastHashCheckedAt = 0;
}
}
private setInLocalCache(
entityId: string,
keyName: CoreEntityCacheKeyName,
data: CacheableValue,
hash: string,
): void {
const localKey = this.buildCacheKey(entityId, keyName);
let entry = this.localCache.get(localKey);
if (!isDefined(entry)) {
entry = { versions: new Map(), latestHash: '', lastHashCheckedAt: 0 };
this.localCache.set(localKey, entry);
}
entry.versions.set(hash, { data, lastReadAt: Date.now() });
entry.latestHash = hash;
entry.lastHashCheckedAt = Date.now();
this.cleanupStaleVersions(entry);
this.evictLRUEntriesIfNeeded();
}
private evictLRUEntriesIfNeeded(): void {
if (this.localCache.size <= MAX_LOCAL_CACHE_ENTRIES) {
return;
}
const entries = [...this.localCache.entries()].sort(
(a, b) => a[1].lastHashCheckedAt - b[1].lastHashCheckedAt,
);
const toEvict = entries.slice(
0,
Math.max(MIN_EVICT_KEYS, this.localCache.size - MAX_LOCAL_CACHE_ENTRIES),
);
for (const [key] of toEvict) {
this.localCache.delete(key);
}
}
private cleanupStaleVersions(
entry: CoreEntityLocalCacheEntry<CacheableValue>,
): void {
const now = Date.now();
for (const [hash, version] of entry.versions) {
if (
hash !== entry.latestHash &&
now - version.lastReadAt > STALE_VERSION_TTL_MS
) {
entry.versions.delete(hash);
}
}
if (entry.versions.size >= MAX_LOCAL_STALE_VERSIONS) {
const sorted = [...entry.versions.entries()]
.filter(([hash]) => hash !== entry.latestHash)
.sort((entryA, entryB) => entryA[1].lastReadAt - entryB[1].lastReadAt);
while (
entry.versions.size >= MAX_LOCAL_STALE_VERSIONS &&
sorted.length > 0
) {
const oldestEntry = sorted.shift();
if (isDefined(oldestEntry)) {
entry.versions.delete(oldestEntry[0]);
}
}
}
}
private evictExpiredLocalEntries(): void {
const now = Date.now();
for (const [localKey, entry] of this.localCache) {
for (const [hash, version] of entry.versions) {
if (now - version.lastReadAt > LOCAL_ENTRY_TTL_MS) {
entry.versions.delete(hash);
}
}
if (entry.versions.size === 0) {
this.localCache.delete(localKey);
continue;
}
if (!entry.versions.has(entry.latestHash)) {
this.localCache.delete(localKey);
}
}
}
private getProviderOrThrow(
keyName: CoreEntityCacheKeyName,
): CoreEntityCacheProvider<CacheDataType> {
const provider = this.coreEntityCacheProviders.get(keyName);
if (!isDefined(provider)) {
throw new Error(
`Core entity cache provider with key name "${keyName}" not found`,
);
}
return provider;
}
private buildCacheKey(
entityId: string,
keyName: CoreEntityCacheKeyName,
): string {
return `${CORE_ENTITY_CACHE_KEYS[keyName]}:${entityId}`;
}
}
@@ -0,0 +1,17 @@
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { type FlatUser } from 'src/engine/core-modules/user/types/flat-user.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
export type CoreEntityCacheDataMap = {
workspaceEntity: FlatWorkspace;
user: FlatUser;
userWorkspaceEntity: FlatUserWorkspace;
};
export type CoreEntityCacheKeyName = keyof CoreEntityCacheDataMap;
export const CORE_ENTITY_CACHE_KEYS: Record<CoreEntityCacheKeyName, string> = {
workspaceEntity: 'core-entity:workspace',
user: 'core-entity:user',
userWorkspaceEntity: 'core-entity:user-workspace',
};
@@ -0,0 +1,10 @@
export type CoreEntityVersionEntry<T> = {
data: T;
lastReadAt: number;
};
export type CoreEntityLocalCacheEntry<T> = {
versions: Map<string, CoreEntityVersionEntry<T>>;
latestHash: string;
lastHashCheckedAt: number;
};
@@ -1,9 +1,9 @@
import { type ActorMetadata, FieldActorSource } from 'twenty-shared/types';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
type BuildCreatedByFromApiKeyArgs = {
apiKey: ApiKeyEntity;
apiKey: FlatApiKey;
};
export const buildCreatedByFromApiKey = ({
apiKey,
@@ -6,6 +6,7 @@ import { ApiKeyResolver } from 'src/engine/core-modules/api-key/api-key.resolver
import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { WorkspaceApiKeyMapCacheService } from 'src/engine/core-modules/api-key/services/workspace-api-key-map-cache.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@@ -39,6 +40,7 @@ import { ApiKeyController } from './controllers/api-key.controller';
ApiKeyService,
ApiKeyResolver,
ApiKeyRoleService,
WorkspaceApiKeyMapCacheService,
GenerateApiKeyCommand,
],
controllers: [ApiKeyController],
@@ -0,0 +1,5 @@
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
export const API_KEY_ENTITY_NON_CACHED_PROPERTIES = [
'workspace',
] as const satisfies ReadonlyArray<keyof ApiKeyEntity>;
@@ -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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
describe('ApiKeyService', () => {
let service: ApiKeyService;
@@ -111,6 +112,12 @@ describe('ApiKeyService', () => {
provide: getDataSourceToken(),
useValue: mockDataSource,
},
{
provide: WorkspaceCacheService,
useValue: {
invalidateAndRecompute: jest.fn(),
},
},
],
}).compile();
@@ -14,6 +14,7 @@ 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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class ApiKeyService {
@@ -22,6 +23,7 @@ export class ApiKeyService {
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
private readonly jwtWrapperService: JwtWrapperService,
private readonly roleTargetService: RoleTargetService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async create(
@@ -44,6 +46,8 @@ export class ApiKeyService {
throw error;
}
await this.invalidateApiKeyCache(savedApiKey.workspaceId);
return savedApiKey;
}
@@ -88,6 +92,7 @@ export class ApiKeyService {
}
await this.apiKeyRepository.update(id, updateData);
await this.invalidateApiKeyCache(workspaceId);
return this.findById(id, workspaceId);
}
@@ -184,4 +189,10 @@ export class ApiKeyService {
isActive(apiKey: ApiKeyEntity): boolean {
return !this.isRevoked(apiKey) && !this.isExpired(apiKey);
}
private async invalidateApiKeyCache(workspaceId: string): Promise<void> {
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'apiKeyMap',
]);
}
}
@@ -0,0 +1,41 @@
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 { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
@Injectable()
@WorkspaceCache('apiKeyMap')
export class WorkspaceApiKeyMapCacheService extends WorkspaceCacheProvider<
Record<string, FlatApiKey>
> {
constructor(
@InjectRepository(ApiKeyEntity)
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
) {
super();
}
async computeForCache(
workspaceId: string,
): Promise<Record<string, FlatApiKey>> {
const apiKeys = await this.apiKeyRepository.find({
where: { workspaceId },
});
return apiKeys.reduce(
(map, apiKey) => {
map[apiKey.id] = fromApiKeyEntityToFlat(apiKey);
return map;
},
{} as Record<string, FlatApiKey>,
);
}
}
@@ -0,0 +1,14 @@
import { type CastRecordTypeOrmDatePropertiesToString } from 'src/engine/metadata-modules/flat-entity/types/cast-record-typeorm-date-properties-to-string.type';
import { type API_KEY_ENTITY_NON_CACHED_PROPERTIES } from 'src/engine/core-modules/api-key/constants/api-key-entity-non-cached-properties.constant';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
type ApiKeyEntityNonCachedProperties =
(typeof API_KEY_ENTITY_NON_CACHED_PROPERTIES)[number];
type ApiKeyCachedFields = Omit<ApiKeyEntity, ApiKeyEntityNonCachedProperties>;
export type FlatApiKey = Omit<
ApiKeyCachedFields,
keyof CastRecordTypeOrmDatePropertiesToString<ApiKeyCachedFields>
> &
CastRecordTypeOrmDatePropertiesToString<ApiKeyCachedFields>;
@@ -0,0 +1,12 @@
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
export const fromApiKeyEntityToFlat = (entity: ApiKeyEntity): FlatApiKey => ({
id: entity.id,
name: entity.name,
workspaceId: entity.workspaceId,
expiresAt: entity.expiresAt.toISOString(),
revokedAt: entity.revokedAt?.toISOString() ?? null,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
});
@@ -57,6 +57,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
@@ -117,6 +118,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
ApplicationRegistrationModule,
ApplicationModule,
WorkspaceCacheModule,
CoreEntityCacheModule,
SecureHttpClientModule,
EnterpriseModule,
FileModule,
@@ -512,8 +512,10 @@ export class AuthResolver {
@AuthUser() currentUser: AuthContextUser,
@AuthProvider() authProvider: AuthProviderEnum,
): Promise<SignUpDTO> {
const fullUser = await this.userService.findUserByIdOrThrow(currentUser.id);
const { user, workspace } = await this.signInUpService.signUpOnNewWorkspace(
{ type: 'existingUser', existingUser: currentUser },
{ type: 'existingUser', existingUser: fullUser },
);
const loginToken = await this.loginTokenService.generateLoginToken(
@@ -21,7 +21,6 @@ import {
compareHash,
hashPassword,
} from 'src/engine/core-modules/auth/auth.util';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import {
type AuthProviderWithPasswordType,
type ExistingUserOrPartialUserWithPicture,
@@ -314,7 +313,7 @@ export class SignInUpService {
workspace,
shouldShowConnectAccountStep,
}: {
user: AuthContextUser;
user: Pick<UserEntity, 'id' | 'firstName' | 'lastName'>;
workspace: WorkspaceEntity;
shouldShowConnectAccountStep: boolean;
},
@@ -577,7 +576,11 @@ export class SignInUpService {
);
await this.activateOnboardingForUser(
{ user, workspace, shouldShowConnectAccountStep: true },
{
user,
workspace,
shouldShowConnectAccountStep: true,
},
queryRunner,
);
@@ -16,38 +16,31 @@ import { JwtAuthStrategy } from './jwt.auth.strategy';
describe('JwtAuthStrategy', () => {
let strategy: JwtAuthStrategy;
let workspaceRepository: any;
let userWorkspaceRepository: any;
let userRepository: any;
let apiKeyRepository: any;
let applicationRepository: any;
let jwtWrapperService: any;
let permissionsService: any;
let workspaceCacheService: any;
let workspaceMemberRepository: any;
let coreEntityCacheService: any;
const jwt = {
sub: 'sub-default',
jti: 'jti-default',
};
beforeEach(() => {
workspaceRepository = {
findOneBy: jest.fn(),
};
let workspaceStore: Record<string, any>;
let userStore: Record<string, any>;
let apiKeyStore: Record<string, Record<string, any>>;
userRepository = {
findOne: jest.fn(),
};
beforeEach(() => {
workspaceStore = {};
userStore = {};
apiKeyStore = {};
userWorkspaceRepository = {
findOne: jest.fn(),
};
apiKeyRepository = {
findOne: jest.fn(),
};
applicationRepository = {
findOne: jest.fn(),
};
@@ -60,33 +53,55 @@ describe('JwtAuthStrategy', () => {
userHasWorkspaceSettingPermission: jest.fn(),
};
workspaceMemberRepository = {
findOne: jest.fn(),
};
workspaceMemberRepository.findOne.mockResolvedValue({
id: 'workspace-member-id',
});
workspaceCacheService = {
getOrRecompute: jest.fn(async (_workspaceId, _cacheKeys) => {
return {
flatWorkspaceMemberMaps: {
byId: {
'workspace-member-id': {
id: 'workspace-member-id',
userId: 'valid-user-id',
workspaceId: 'workspace-id',
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
getOrRecompute: jest.fn(
async (workspaceId: string, cacheKeys: string[]) => {
const result: Record<string, any> = {};
if (cacheKeys.includes('flatWorkspaceMemberMaps')) {
result.flatWorkspaceMemberMaps = {
byId: {
'workspace-member-id': {
id: 'workspace-member-id',
userId: 'valid-user-id',
workspaceId: 'workspace-id',
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
},
},
idByUserId: {
'valid-user-id': 'workspace-member-id',
},
},
};
idByUserId: {
'valid-user-id': 'workspace-member-id',
},
};
}
if (cacheKeys.includes('apiKeyMap')) {
result.apiKeyMap = apiKeyStore[workspaceId] ?? {};
}
return result;
},
),
};
coreEntityCacheService = {
get: jest.fn(async (keyName: string, entityId: string) => {
if (keyName === 'workspaceEntity') {
return workspaceStore[entityId] ?? null;
}
if (keyName === 'user') {
return userStore[entityId] ?? null;
}
if (keyName === 'userWorkspaceEntity') {
return userWorkspaceRepository.findOne({ where: { id: entityId } });
}
return null;
}),
invalidate: jest.fn(),
};
});
@@ -94,6 +109,16 @@ describe('JwtAuthStrategy', () => {
jest.clearAllMocks();
});
const createStrategy = () =>
new JwtAuthStrategy(
jwtWrapperService,
applicationRepository,
userWorkspaceRepository,
permissionsService,
workspaceCacheService,
coreEntityCacheService,
);
describe('API_KEY validation', () => {
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = {
@@ -101,18 +126,7 @@ describe('JwtAuthStrategy', () => {
type: JwtTokenTypeEnum.API_KEY,
};
workspaceRepository.findOneBy.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -131,20 +145,10 @@ describe('JwtAuthStrategy', () => {
const mockWorkspace = new WorkspaceEntity();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
workspaceStore[payload.sub] = mockWorkspace;
apiKeyStore['workspace-id'] = {};
apiKeyRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -163,23 +167,15 @@ describe('JwtAuthStrategy', () => {
const mockWorkspace = new WorkspaceEntity();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
workspaceStore[payload.sub] = mockWorkspace;
apiKeyStore['workspace-id'] = {
[payload.jti]: {
id: 'api-key-id',
revokedAt: new Date(),
},
};
apiKeyRepository.findOne.mockResolvedValue({
id: 'api-key-id',
revokedAt: new Date(),
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -198,35 +194,20 @@ describe('JwtAuthStrategy', () => {
const mockWorkspace = new WorkspaceEntity();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
workspaceStore[payload.sub] = mockWorkspace;
apiKeyStore['workspace-id'] = {
[payload.jti]: {
id: 'api-key-id',
revokedAt: null,
},
};
apiKeyRepository.findOne.mockResolvedValue({
id: 'api-key-id',
revokedAt: null,
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
const result = await strategy.validate(payload as JwtPayload);
expect(result).toBeTruthy();
expect(result.apiKey?.id).toBe('api-key-id');
expect(apiKeyRepository.findOne).toHaveBeenCalledWith({
where: {
id: payload.jti,
workspaceId: mockWorkspace.id,
},
});
});
});
@@ -243,20 +224,9 @@ describe('JwtAuthStrategy', () => {
workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity());
workspaceStore[validWorkspaceId] = new WorkspaceEntity();
userRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -287,22 +257,12 @@ describe('JwtAuthStrategy', () => {
workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity());
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
workspaceStore[validWorkspaceId] = new WorkspaceEntity();
userStore[validUserId] = { lastName: 'lastNameDefault' };
userWorkspaceRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -333,30 +293,36 @@ describe('JwtAuthStrategy', () => {
workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity());
userRepository.findOne.mockResolvedValue({
workspaceStore[validWorkspaceId] = new WorkspaceEntity();
userStore[validUserId] = {
id: validUserId,
lastName: 'lastNameDefault',
});
};
userWorkspaceRepository.findOne.mockResolvedValue({
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
});
coreEntityCacheService.get.mockImplementation(
async (keyName: string, entityId: string) => {
if (keyName === 'workspaceEntity') {
return workspaceStore[entityId] ?? null;
}
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
if (keyName === 'user') {
return userStore[entityId] ?? null;
}
if (keyName === 'userWorkspaceEntity') {
return {
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
};
}
return null;
},
);
strategy = createStrategy();
const user = await strategy.validate(payload as JwtPayload);
expect(user.user?.lastName).toBe('lastNameDefault');
@@ -376,20 +342,11 @@ describe('JwtAuthStrategy', () => {
workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity());
workspaceStore[validWorkspaceId] = new WorkspaceEntity();
applicationRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('Application not found', expect.any(String), {
@@ -418,32 +375,20 @@ describe('JwtAuthStrategy', () => {
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatedUserWorkspaceId: validUserWorkspaceId,
// Missing impersonatorUserWorkspaceId
};
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
};
const mockWorkspace = new WorkspaceEntity();
mockWorkspace.id = validWorkspaceId;
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
workspaceStore[validWorkspaceId] = mockWorkspace;
userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
userWorkspaceRepository.findOne.mockResolvedValue({
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -466,31 +411,20 @@ describe('JwtAuthStrategy', () => {
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId,
// Missing impersonatedUserWorkspaceId
};
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
};
const mockWorkspace = new WorkspaceEntity();
mockWorkspace.id = validWorkspaceId;
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
workspaceStore[validWorkspaceId] = mockWorkspace;
userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
userWorkspaceRepository.findOne.mockResolvedValue({
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -512,34 +446,23 @@ describe('JwtAuthStrategy', () => {
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId: validUserWorkspaceId,
impersonatedUserWorkspaceId: validUserWorkspaceId, // Same as impersonator
};
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
impersonatedUserWorkspaceId: validUserWorkspaceId,
};
const mockWorkspace = new WorkspaceEntity();
mockWorkspace.id = validWorkspaceId;
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
workspaceStore[validWorkspaceId] = mockWorkspace;
userWorkspaceRepository.findOne.mockResolvedValue({
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
});
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
true,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -572,34 +495,40 @@ describe('JwtAuthStrategy', () => {
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
workspaceStore[validWorkspaceId] = mockWorkspace;
userStore[validUserId] = mockUser;
coreEntityCacheService.get.mockImplementation(
async (keyName: string, entityId: string) => {
if (keyName === 'workspaceEntity') {
return workspaceStore[entityId] ?? null;
}
if (keyName === 'user') {
return userStore[entityId] ?? null;
}
if (keyName === 'userWorkspaceEntity') {
return {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
}
return null;
},
);
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(null) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
// For impersonatedUserWorkspace lookup
id: validUserWorkspaceId,
user: { id: 'valid-user-id' },
workspace: mockWorkspace,
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -633,29 +562,35 @@ describe('JwtAuthStrategy', () => {
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
workspaceStore[validWorkspaceId] = mockWorkspace;
userStore[validUserId] = mockUser;
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(null); // For impersonatedUserWorkspace lookup
coreEntityCacheService.get.mockImplementation(
async (keyName: string, entityId: string) => {
if (keyName === 'workspaceEntity') {
return workspaceStore[entityId] ?? null;
}
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
if (keyName === 'user') {
return userStore[entityId] ?? null;
}
if (keyName === 'userWorkspaceEntity') {
return {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
}
return null;
},
);
userWorkspaceRepository.findOne.mockResolvedValueOnce(null);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Invalid impersonation token, cannot find impersonator or impersonated user workspace',
@@ -684,49 +619,52 @@ describe('JwtAuthStrategy', () => {
const mockWorkspace = new WorkspaceEntity();
mockWorkspace.id = validWorkspaceId;
mockWorkspace.allowImpersonation = false; // Disabled
mockWorkspace.allowImpersonation = false;
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
workspaceStore[validWorkspaceId] = mockWorkspace;
userStore[validUserId] = mockUser;
const mockImpersonatorUserWorkspace = {
id: impersonatorUserWorkspaceId,
user: { id: 'valid-user-id', canImpersonate: false }, // No server level permission
workspace: { id: differentWorkspaceId }, // Different workspace
};
coreEntityCacheService.get.mockImplementation(
async (keyName: string, entityId: string) => {
if (keyName === 'workspaceEntity') {
return workspaceStore[entityId] ?? null;
}
const mockImpersonatedUserWorkspace = {
id: validUserWorkspaceId,
user: { id: 'valid-user-id' },
workspace: mockWorkspace,
};
if (keyName === 'user') {
return userStore[entityId] ?? null;
}
if (keyName === 'userWorkspaceEntity') {
return {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
}
return null;
},
);
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
.mockResolvedValueOnce({
id: impersonatorUserWorkspaceId,
user: { id: 'valid-user-id', canImpersonate: false },
workspace: { id: differentWorkspaceId },
})
.mockResolvedValueOnce({
id: validUserWorkspaceId,
user: { id: 'valid-user-id' },
workspace: mockWorkspace,
});
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
false,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -759,45 +697,48 @@ describe('JwtAuthStrategy', () => {
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
workspaceStore[validWorkspaceId] = mockWorkspace;
userStore[validUserId] = mockUser;
const mockImpersonatorUserWorkspace = {
id: impersonatorUserWorkspaceId,
user: { id: 'valid-user-id', canImpersonate: false },
workspace: mockWorkspace, // Same workspace
};
coreEntityCacheService.get.mockImplementation(
async (keyName: string, entityId: string) => {
if (keyName === 'workspaceEntity') {
return workspaceStore[entityId] ?? null;
}
const mockImpersonatedUserWorkspace = {
id: validUserWorkspaceId,
user: { id: 'valid-user-id' },
workspace: mockWorkspace,
};
if (keyName === 'user') {
return userStore[entityId] ?? null;
}
if (keyName === 'userWorkspaceEntity') {
return {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
}
return null;
},
);
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
.mockResolvedValueOnce({
id: impersonatorUserWorkspaceId,
user: { id: 'valid-user-id', canImpersonate: false },
workspace: mockWorkspace,
})
.mockResolvedValueOnce({
id: validUserWorkspaceId,
user: { id: 'valid-user-id' },
workspace: mockWorkspace,
});
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
false,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -821,7 +762,7 @@ describe('JwtAuthStrategy', () => {
workspaceId: validWorkspaceId,
isImpersonating: true,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId, // Different from userWorkspaceId
impersonatedUserWorkspaceId,
};
const mockWorkspace = new WorkspaceEntity();
@@ -829,47 +770,9 @@ describe('JwtAuthStrategy', () => {
mockWorkspace.id = validWorkspaceId;
mockWorkspace.allowImpersonation = true;
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
workspaceStore[validWorkspaceId] = mockWorkspace;
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
const mockImpersonatorUserWorkspace = {
id: impersonatorUserWorkspaceId,
user: { id: 'valid-user-id', canImpersonate: true },
workspace: mockWorkspace,
};
const mockImpersonatedUserWorkspace = {
id: impersonatedUserWorkspaceId,
user: { id: 'valid-user-id' },
workspace: mockWorkspace,
};
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
true,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
@@ -898,43 +801,52 @@ describe('JwtAuthStrategy', () => {
const mockWorkspace = new WorkspaceEntity();
mockWorkspace.id = validWorkspaceId;
mockWorkspace.allowImpersonation = false; // Server level disabled
mockWorkspace.allowImpersonation = false;
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
workspaceStore[validWorkspaceId] = mockWorkspace;
userStore[validUserId] = mockUser;
const mockImpersonatorUserWorkspace = {
id: impersonatorUserWorkspaceId,
user: { id: 'valid-user-id', canImpersonate: false },
workspace: mockWorkspace, // Same workspace
};
coreEntityCacheService.get.mockImplementation(
async (keyName: string, entityId: string) => {
if (keyName === 'workspaceEntity') {
return workspaceStore[entityId] ?? null;
}
if (keyName === 'user') {
return userStore[entityId] ?? null;
}
if (keyName === 'userWorkspaceEntity') {
return {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
}
return null;
},
);
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce(mockUserWorkspace); // For impersonatedUserWorkspace lookup (same as main)
.mockResolvedValueOnce({
id: impersonatorUserWorkspaceId,
user: { id: 'valid-user-id', canImpersonate: false },
workspace: mockWorkspace,
})
.mockResolvedValueOnce({
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
});
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
true,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
);
strategy = createStrategy();
const result = await strategy.validate(payload as JwtPayload);
@@ -969,40 +881,49 @@ describe('JwtAuthStrategy', () => {
const mockWorkspace = new WorkspaceEntity();
mockWorkspace.id = validWorkspaceId;
mockWorkspace.allowImpersonation = true; // Server level enabled
mockWorkspace.allowImpersonation = true;
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
const mockImpersonatorUserWorkspace = {
id: impersonatorUserWorkspaceId,
user: { id: 'valid-user-id', canImpersonate: true }, // Server level permission
workspace: { id: differentWorkspaceId }, // Different workspace
};
workspaceStore[validWorkspaceId] = mockWorkspace;
userStore[validUserId] = mockUser;
const mockImpersonatedUserWorkspace = {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
coreEntityCacheService.get.mockImplementation(
async (keyName: string, entityId: string) => {
if (keyName === 'workspaceEntity') {
return workspaceStore[entityId] ?? null;
}
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
userRepository.findOne.mockResolvedValue(mockUser);
userWorkspaceRepository.findOne
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
.mockResolvedValueOnce(mockImpersonatedUserWorkspace) // For impersonatedUserWorkspace lookup
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For access token lookup
if (keyName === 'user') {
return userStore[entityId] ?? null;
}
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
workspaceCacheService,
if (keyName === 'userWorkspaceEntity') {
return {
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
};
}
return null;
},
);
userWorkspaceRepository.findOne
.mockResolvedValueOnce({
id: impersonatorUserWorkspaceId,
user: { id: 'valid-user-id', canImpersonate: true },
workspace: { id: differentWorkspaceId },
})
.mockResolvedValueOnce({
id: validUserWorkspaceId,
user: mockUser,
workspace: mockWorkspace,
});
strategy = createStrategy();
const result = await strategy.validate(payload as JwtPayload);
expect(result.user?.lastName).toBe('lastNameDefault');
@@ -9,7 +9,6 @@ import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
AuthException,
@@ -19,7 +18,6 @@ import {
type AccessTokenJwtPayload,
type ApiKeyTokenJwtPayload,
ApplicationAccessTokenJwtPayload,
AUTH_CONTEXT_USER_SELECT_FIELDS,
type AuthContext,
type AuthContextUser,
FileTokenJwtPayloadLegacy,
@@ -27,11 +25,10 @@ import {
JwtTokenTypeEnum,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@@ -39,18 +36,13 @@ import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/works
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
@InjectRepository(ApiKeyEntity)
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
private readonly permissionsService: PermissionsService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly coreEntityCacheService: CoreEntityCacheService,
) {
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
// @ts-expect-error legacy noImplicitAny
@@ -88,9 +80,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
private async validateAPIKey(
payload: ApiKeyTokenJwtPayload,
): Promise<AuthContext> {
const workspace = await this.workspaceRepository.findOneBy({
id: payload.sub,
});
const workspace = await this.coreEntityCacheService.get(
'workspaceEntity',
payload.sub,
);
assertIsDefinedOrThrow(
workspace,
@@ -100,12 +93,12 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
),
);
const apiKey = await this.apiKeyRepository.findOne({
where: {
id: payload.jti,
workspaceId: workspace.id,
},
});
const { apiKeyMap } = await this.workspaceCacheService.getOrRecompute(
workspace.id,
['apiKeyMap'],
);
const apiKey = payload.jti ? apiKeyMap[payload.jti] : undefined;
if (!apiKey || apiKey.revokedAt) {
throw new AuthException(
@@ -114,6 +107,13 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
if (new Date(apiKey.expiresAt) < new Date()) {
throw new AuthException(
'This API Key is expired',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return { apiKey, workspace, workspaceMemberId: payload.workspaceMemberId };
}
@@ -123,9 +123,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
let user: AuthContextUser | null = null;
let context: AuthContext = {};
const workspace = await this.workspaceRepository.findOneBy({
id: payload.workspaceId,
});
const workspace = await this.coreEntityCacheService.get(
'workspaceEntity',
payload.workspaceId,
);
if (!isDefined(workspace)) {
throw new AuthException(
@@ -224,20 +225,18 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
expectedWorkspaceId?: string;
}): Promise<{
user: AuthContextUser;
userWorkspace: UserWorkspaceEntity;
userWorkspace: FlatUserWorkspace;
} | null> {
const user = await this.userRepository.findOne({
where: { id: params.userId },
select: [...AUTH_CONTEXT_USER_SELECT_FIELDS],
});
const user = await this.coreEntityCacheService.get('user', params.userId);
if (!isDefined(user)) {
return null;
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: params.userWorkspaceId },
});
const userWorkspace = await this.coreEntityCacheService.get(
'userWorkspaceEntity',
params.userWorkspaceId,
);
if (!isDefined(userWorkspace)) {
return null;
@@ -254,7 +253,6 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
}
private async validateImpersonation(payload: AccessTokenJwtPayload) {
// Validate required impersonation fields
if (
!payload.impersonatorUserWorkspaceId ||
!payload.impersonatedUserWorkspaceId
@@ -282,6 +280,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
// Impersonation validation requires relations -- not cached
const impersonatorUserWorkspace =
await this.userWorkspaceRepository.findOne({
where: { id: payload.impersonatorUserWorkspaceId },
@@ -348,12 +347,9 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
private async validateWorkspaceAgnosticToken(
payload: WorkspaceAgnosticTokenJwtPayload,
): Promise<AuthContext> {
const user = await this.userRepository.findOne({
where: { id: payload.sub },
select: [...AUTH_CONTEXT_USER_SELECT_FIELDS],
});
const user = await this.coreEntityCacheService.get('user', payload.sub);
userValidator.assertIsDefinedOrThrow(
assertIsDefinedOrThrow(
user,
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
);
@@ -364,9 +360,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
private async validateApplicationToken(
payload: ApplicationAccessTokenJwtPayload,
): Promise<AuthContext> {
const workspace = await this.workspaceRepository.findOneBy({
id: payload.workspaceId,
});
const workspace = await this.coreEntityCacheService.get(
'workspaceEntity',
payload.workspaceId,
);
if (!isDefined(workspace)) {
throw new AuthException(
@@ -135,7 +135,12 @@ describe('WorkspaceAgnosticToken', () => {
userId: userId,
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
};
const mockUser = { id: userId };
const mockUser = {
id: userId,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
deletedAt: null,
} as unknown as UserEntity;
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
jest.spyOn(jwtWrapperService, 'verify').mockReturnValue({});
@@ -145,8 +150,8 @@ describe('WorkspaceAgnosticToken', () => {
const result = await service.validateToken(mockToken);
expect(result).toEqual({
user: mockUser,
expect(result.user).toMatchObject({
id: userId,
});
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.verify).toHaveBeenCalledWith(
@@ -5,6 +5,7 @@ import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { Repository } from 'typeorm';
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
import {
AuthException,
AuthExceptionCode,
@@ -88,7 +89,7 @@ export class WorkspaceAgnosticTokenService {
userValidator.assertIsDefinedOrThrow(user);
return { user };
return { user: fromUserEntityToFlat(user) };
} catch (error) {
if (error instanceof AuthException) {
throw error;
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
@@ -16,6 +15,7 @@ import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -28,13 +28,13 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
AppTokenEntity,
WorkspaceEntity,
UserWorkspaceEntity,
ApiKeyEntity,
ApplicationEntity,
]),
TypeORMModule,
DataSourceModule,
PermissionsModule,
WorkspaceCacheModule,
CoreEntityCacheModule,
],
providers: [
RenewTokenService,
@@ -1,23 +1,23 @@
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context-user.type';
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export { AUTH_CONTEXT_USER_SELECT_FIELDS } from 'src/engine/core-modules/auth/constants/auth-context-user-select-fields.constants';
export { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context-user.type';
export { type FlatAuthContextUser as AuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
export type RawAuthContext = {
user?: AuthContextUser | null | undefined;
apiKey?: ApiKeyEntity | null | undefined;
user?: FlatAuthContextUser | null | undefined;
apiKey?: FlatApiKey | null | undefined;
workspaceMemberId?: string;
workspaceMember?: WorkspaceMemberWorkspaceEntity;
workspace?: WorkspaceEntity;
workspace?: FlatWorkspace;
application?: ApplicationEntity | null | undefined;
userWorkspaceId?: string;
userWorkspace?: UserWorkspaceEntity;
userWorkspace?: FlatUserWorkspace;
authProvider?: AuthProviderEnum;
impersonationContext?: {
impersonatorUserWorkspaceId?: string;
@@ -0,0 +1,7 @@
import { type AUTH_CONTEXT_USER_SELECT_FIELDS } from 'src/engine/core-modules/auth/constants/auth-context-user-select-fields.constants';
import { type FlatUser } from 'src/engine/core-modules/user/types/flat-user.type';
export type FlatAuthContextUser = Pick<
FlatUser,
(typeof AUTH_CONTEXT_USER_SELECT_FIELDS)[number]
>;
@@ -1,7 +1,6 @@
import { type APP_LOCALES } from 'twenty-shared/translations';
import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -42,7 +41,7 @@ export type ExistingUserOrNewUser = {
export type ExistingUserOrPartialUserWithPicture = {
userData:
| { type: 'existingUser'; existingUser: AuthContextUser }
| { type: 'existingUser'; existingUser: UserEntity }
| {
type: 'newUserWithPicture';
newUserWithPicture: PartialUserWithPicture;
@@ -3,6 +3,7 @@ export enum CacheStorageNamespace {
ModuleCalendar = 'module:calendar',
ModuleWorkflow = 'module:workflow',
EngineWorkspace = 'engine:workspace',
EngineCoreEntity = 'engine:core-entity',
EngineLock = 'engine:lock',
EngineHealth = 'engine:health',
EngineMetrics = 'engine:metrics',
@@ -73,7 +73,7 @@ export const useGraphQLErrorHandlerHook = <
return {
id: req.workspace.id,
displayName: req.workspace.displayName,
createdAt: req.workspace.createdAt?.toISOString() ?? null,
createdAt: req.workspace.createdAt ?? null,
activationStatus: req.workspace.activationStatus,
};
}
@@ -2,7 +2,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { IsNull, Not, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -15,7 +15,7 @@ import {
LogicFunctionTriggerJob,
LogicFunctionTriggerJobData,
} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { shouldRunNow } from 'src/utils/should-run-now.utils';
export const CRON_TRIGGER_CRON_PATTERN = '* * * * *';
@@ -27,8 +27,7 @@ export class CronTriggerCronJob {
private readonly messageQueueService: MessageQueueService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(LogicFunctionEntity)
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@Process(CronTriggerCronJob.name)
@@ -44,22 +43,30 @@ export class CronTriggerCronJob {
const now = new Date();
for (const activeWorkspace of activeWorkspaces) {
const logicFunctionsWithCronTrigger =
await this.logicFunctionRepository.find({
where: {
workspaceId: activeWorkspace.id,
cronTriggerSettings: Not(IsNull()),
},
select: ['id', 'cronTriggerSettings', 'workspaceId'],
});
const { flatLogicFunctionMaps } =
await this.workspaceCacheService.getOrRecompute(activeWorkspace.id, [
'flatLogicFunctionMaps',
]);
const logicFunctions = Object.values(
flatLogicFunctionMaps.byUniversalIdentifier,
);
for (const logicFunction of logicFunctions) {
if (!isDefined(logicFunction)) {
continue;
}
for (const logicFunction of logicFunctionsWithCronTrigger) {
const cronSettings = logicFunction.cronTriggerSettings;
if (!isDefined(cronSettings?.pattern)) {
continue;
}
if (isDefined(logicFunction.deletedAt)) {
continue;
}
if (!shouldRunNow(cronSettings.pattern, now)) {
continue;
}
@@ -69,7 +76,7 @@ export class CronTriggerCronJob {
[
{
logicFunctionId: logicFunction.id,
workspaceId: logicFunction.workspaceId,
workspaceId: activeWorkspace.id,
payload: {},
},
],
@@ -19,7 +19,7 @@ import {
SdkClientException,
SdkClientExceptionCode,
} from 'src/engine/core-modules/sdk-client/exceptions/sdk-client.exception';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const SDK_CLIENT_ARCHIVE_NAME = 'twenty-client-sdk.zip';
@@ -46,7 +46,7 @@ export class SdkClientGenerationService {
applicationUniversalIdentifier: string;
}): Promise<Buffer> {
const graphqlSchema = await this.workspaceSchemaFactory.createGraphQLSchema(
{ id: workspaceId } as WorkspaceEntity,
{ id: workspaceId } as FlatWorkspace,
applicationId,
);
@@ -5,6 +5,8 @@ import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import {
@@ -28,7 +30,6 @@ import {
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { stripLoadingMessage } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
// Handler for individually registered static tools (e.g., action tools)
@@ -303,9 +304,9 @@ export class ToolExecutorService {
}
return buildUserAuthContext({
workspace: { id: context.workspaceId } as WorkspaceEntity,
workspace: { id: context.workspaceId } as FlatWorkspace,
userWorkspaceId: context.userWorkspaceId,
user,
user: fromUserEntityToFlat(user),
workspaceMemberId,
workspaceMember,
});
@@ -7,6 +7,7 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -53,10 +54,23 @@ describe('TwoFactorAuthenticationResolver', () => {
>;
let repository: ReturnType<typeof createMockRepository>;
const mockUser: UserEntity = {
const MOCK_USER_ISO = '2024-01-01T00:00:00.000Z';
const mockUser = {
id: 'user-123',
firstName: '',
lastName: '',
email: 'test@example.com',
} as UserEntity;
defaultAvatarUrl: '',
isEmailVerified: true,
disabled: false,
canImpersonate: false,
canAccessFullAdminPanel: false,
createdAt: MOCK_USER_ISO,
updatedAt: MOCK_USER_ISO,
deletedAt: null,
locale: 'en',
} as unknown as FlatAuthContextUser;
const mockWorkspace: WorkspaceEntity = {
id: 'workspace-123',
@@ -132,7 +146,9 @@ describe('TwoFactorAuthenticationResolver', () => {
workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace.mockResolvedValue(
mockWorkspace,
);
userService.findUserByEmailOrThrow.mockResolvedValue(mockUser);
userService.findUserByEmailOrThrow.mockResolvedValue(
mockUser as unknown as UserEntity,
);
twoFactorAuthenticationService.initiateStrategyConfiguration.mockResolvedValue(
'otpauth://totp/Twenty:test@example.com?secret=SECRETKEY&issuer=Twenty',
);
@@ -0,0 +1,11 @@
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
export const USER_WORKSPACE_ENTITY_NON_CACHED_PROPERTIES = [
'workspace',
'user',
'twoFactorAuthenticationMethods',
'permissionFlags',
'objectPermissions',
'objectsPermissions',
'twoFactorAuthenticationMethodSummary',
] as const satisfies ReadonlyArray<keyof UserWorkspaceEntity>;
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CoreEntityCache } from 'src/engine/core-entity-cache/decorators/core-entity-cache.decorator';
import { CoreEntityCacheProvider } from 'src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { fromUserWorkspaceEntityToFlat } from 'src/engine/core-modules/user-workspace/utils/from-user-workspace-entity-to-flat.util';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@Injectable()
@CoreEntityCache('userWorkspaceEntity')
export class UserWorkspaceEntityCacheProviderService extends CoreEntityCacheProvider<FlatUserWorkspace> {
constructor(
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {
super();
}
async computeForCache(entityId: string): Promise<FlatUserWorkspace | null> {
const entity = await this.userWorkspaceRepository.findOne({
where: { id: entityId },
});
if (entity === null) {
return null;
}
return fromUserWorkspaceEntityToFlat(entity);
}
}
@@ -0,0 +1,17 @@
import { type CastRecordTypeOrmDatePropertiesToString } from 'src/engine/metadata-modules/flat-entity/types/cast-record-typeorm-date-properties-to-string.type';
import { type USER_WORKSPACE_ENTITY_NON_CACHED_PROPERTIES } from 'src/engine/core-modules/user-workspace/constants/user-workspace-entity-non-cached-properties.constant';
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
type UserWorkspaceEntityNonCachedProperties =
(typeof USER_WORKSPACE_ENTITY_NON_CACHED_PROPERTIES)[number];
type UserWorkspaceCachedFields = Omit<
UserWorkspaceEntity,
UserWorkspaceEntityNonCachedProperties
>;
export type FlatUserWorkspace = Omit<
UserWorkspaceCachedFields,
keyof CastRecordTypeOrmDatePropertiesToString<UserWorkspaceCachedFields>
> &
CastRecordTypeOrmDatePropertiesToString<UserWorkspaceCachedFields>;
@@ -12,6 +12,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UploadProfilePicturePermissionGuard } from 'src/engine/core-modules/user-workspace/guards/upload-profile-picture-permission.guard';
import { UserWorkspaceEntityCacheProviderService } from 'src/engine/core-modules/user-workspace/services/user-workspace-entity-cache-provider.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -57,6 +58,10 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
}),
],
exports: [UserWorkspaceService],
providers: [UserWorkspaceService, UploadProfilePicturePermissionGuard],
providers: [
UserWorkspaceService,
UserWorkspaceEntityCacheProviderService,
UploadProfilePicturePermissionGuard,
],
})
export class UserWorkspaceModule {}
@@ -6,6 +6,7 @@ import { type DataSource, type Repository } from 'typeorm';
import { type ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@@ -249,7 +250,14 @@ describe('UserWorkspaceService', () => {
lastName: 'Doe',
defaultAvatarUrl: 'avatar-url',
locale: 'en',
} as UserEntity;
isEmailVerified: false,
disabled: false,
canImpersonate: false,
canAccessFullAdminPanel: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
} as unknown as AuthContextUser;
const mainDataSource = {
query: jest.fn(),
} as unknown as DataSource;
@@ -300,7 +308,10 @@ describe('UserWorkspaceService', () => {
const user = {
id: 'user-id',
email: 'test@example.com',
} as UserEntity;
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
deletedAt: null,
} as unknown as UserEntity;
const workspace = {
id: 'workspace-id',
defaultRoleId: 'default-role-id',
@@ -335,7 +346,7 @@ describe('UserWorkspaceService', () => {
});
expect(service.createWorkspaceMember).toHaveBeenCalledWith(
workspace.id,
user,
expect.objectContaining({ id: user.id, email: user.email }),
);
expect(
userRoleService.assignRoleToManyUserWorkspace,
@@ -361,7 +372,10 @@ describe('UserWorkspaceService', () => {
const user = {
id: 'user-id',
email: 'test@example.com',
} as UserEntity;
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
deletedAt: null,
} as unknown as UserEntity;
const workspace = {
id: 'workspace-id',
defaultRoleId: 'default-role-id',
@@ -392,7 +406,10 @@ describe('UserWorkspaceService', () => {
const user = {
id: 'user-id',
email: 'test@example.com',
} as UserEntity;
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
deletedAt: null,
} as unknown as UserEntity;
const workspace = {
id: 'workspace-id',
defaultRoleId: undefined,
@@ -23,7 +23,6 @@ import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/u
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@@ -103,7 +102,13 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
: this.userWorkspaceRepository.save(userWorkspace);
}
async createWorkspaceMember(workspaceId: string, user: AuthContextUser) {
async createWorkspaceMember(
workspaceId: string,
user: Pick<
UserEntity,
'id' | 'firstName' | 'lastName' | 'email' | 'locale'
>,
) {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
@@ -536,7 +541,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
appToken?: AppTokenEntity;
}>;
},
user: AuthContextUser,
user: Pick<UserEntity, 'email'>,
authProvider: AuthProviderEnum,
) {
return {
@@ -0,0 +1,15 @@
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
export const fromUserWorkspaceEntityToFlat = (
entity: UserWorkspaceEntity,
): FlatUserWorkspace => ({
id: entity.id,
workspaceId: entity.workspaceId,
userId: entity.userId,
defaultAvatarUrl: entity.defaultAvatarUrl,
locale: entity.locale,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
deletedAt: entity.deletedAt?.toISOString() ?? null,
});
@@ -0,0 +1,13 @@
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
export const USER_ENTITY_NON_CACHED_PROPERTIES = [
'formatEmail',
'passwordHash',
'appTokens',
'keyValuePairs',
'workspaceMember',
'userWorkspaces',
'onboardingStatus',
'currentWorkspace',
'currentUserWorkspace',
] as const satisfies ReadonlyArray<keyof UserEntity>;
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CoreEntityCache } from 'src/engine/core-entity-cache/decorators/core-entity-cache.decorator';
import { CoreEntityCacheProvider } from 'src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service';
import { type FlatUser } from 'src/engine/core-modules/user/types/flat-user.type';
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@Injectable()
@CoreEntityCache('user')
export class UserEntityCacheProviderService extends CoreEntityCacheProvider<FlatUser> {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {
super();
}
async computeForCache(entityId: string): Promise<FlatUser | null> {
const entity = await this.userRepository.findOne({
where: { id: entityId },
});
if (entity === null) {
return null;
}
return fromUserEntityToFlat(entity);
}
}
@@ -6,6 +6,7 @@ import { type Repository, type UpdateResult } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@@ -19,6 +20,7 @@ import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@@ -95,6 +97,12 @@ describe('UserService', () => {
provide: ApplicationService,
useValue: {},
},
{
provide: CoreEntityCacheService,
useValue: {
invalidate: jest.fn(),
},
},
],
}).compile();
@@ -114,7 +122,7 @@ describe('UserService', () => {
// isWorkspaceActiveOrSuspendedSpy.mockReturnValue(false);
const res = await service.loadWorkspaceMember(
{ id: 'u1' } as UserEntity,
{ id: 'u1' } as Pick<AuthContextUser, 'id'>,
{ id: 'w1' } as WorkspaceEntity,
);
@@ -133,7 +141,7 @@ describe('UserService', () => {
.mockResolvedValue(mockWorkspaceMemberRepo);
const res = await service.loadWorkspaceMember(
{ id: 'u1' } as UserEntity,
{ id: 'u1' } as Pick<AuthContextUser, 'id'>,
{
id: 'w1',
activationStatus: WorkspaceActivationStatus.ACTIVE,
@@ -38,6 +38,7 @@ import {
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@@ -55,11 +56,15 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
private readonly userWorkspaceService: UserWorkspaceService,
@InjectMessageQueue(MessageQueue.workspaceQueue)
private readonly workspaceQueueService: MessageQueueService,
private readonly coreEntityCacheService: CoreEntityCacheService,
) {
super(userRepository);
}
async loadWorkspaceMember(user: AuthContextUser, workspace: WorkspaceEntity) {
async loadWorkspaceMember(
user: Pick<AuthContextUser, 'id'>,
workspace: Pick<WorkspaceEntity, 'id' | 'activationStatus'>,
) {
if (!isWorkspaceActiveOrSuspended(workspace)) {
return null;
}
@@ -85,7 +90,10 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
);
}
async loadWorkspaceMembers(workspace: WorkspaceEntity, withDeleted = false) {
async loadWorkspaceMembers(
workspace: Pick<WorkspaceEntity, 'id' | 'activationStatus'>,
withDeleted = false,
) {
if (!isWorkspaceActiveOrSuspended(workspace)) {
return [];
}
@@ -109,7 +117,9 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
);
}
async loadDeletedWorkspaceMembersOnly(workspace: WorkspaceEntity) {
async loadDeletedWorkspaceMembersOnly(
workspace: Pick<WorkspaceEntity, 'id' | 'activationStatus'>,
) {
if (!isWorkspaceActiveOrSuspended(workspace)) {
return [];
}
@@ -151,6 +161,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
}
await this.userRepository.softDelete({ id: userId });
await this.coreEntityCacheService.invalidate('user', userId);
return await this.userRepository.findOne({
where: {
@@ -190,6 +201,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
if (user.userWorkspaces.length === 1) {
await this.userRepository.softDelete(userId);
await this.coreEntityCacheService.invalidate('user', userId);
}
return userWorkspace;
@@ -0,0 +1,14 @@
import { type CastRecordTypeOrmDatePropertiesToString } from 'src/engine/metadata-modules/flat-entity/types/cast-record-typeorm-date-properties-to-string.type';
import { type USER_ENTITY_NON_CACHED_PROPERTIES } from 'src/engine/core-modules/user/constants/user-entity-non-cached-properties.constant';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
type UserEntityNonCachedProperties =
(typeof USER_ENTITY_NON_CACHED_PROPERTIES)[number];
type UserCachedFields = Omit<UserEntity, UserEntityNonCachedProperties>;
export type FlatUser = Omit<
UserCachedFields,
keyof CastRecordTypeOrmDatePropertiesToString<UserCachedFields>
> &
CastRecordTypeOrmDatePropertiesToString<UserCachedFields>;
@@ -15,6 +15,7 @@ import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { GlobalWorkspaceMemberListener } from 'src/engine/core-modules/user/services/global-workspace-member.listener';
import { UserEntityCacheProviderService } from 'src/engine/core-modules/user/services/user-entity-cache-provider.service';
import { WorkspaceFlatWorkspaceMemberMapCacheService } from 'src/engine/core-modules/user/services/workspace-flat-workspace-member-map-cache.service';
import { WorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/workspace-member-transpiler.service';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
@@ -25,6 +26,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { userAutoResolverOpts } from './user.auto-resolver-opts';
@@ -55,11 +57,13 @@ import { UserService } from './services/user.service';
EmailVerificationModule,
WorkspaceDomainsModule,
WorkspaceCacheModule,
CoreEntityCacheModule,
],
exports: [UserService, WorkspaceMemberTranspiler],
providers: [
UserService,
UserResolver,
UserEntityCacheProviderService,
WorkspaceMemberTranspiler,
WorkspaceFlatWorkspaceMemberMapCacheService,
GlobalWorkspaceMemberListener,
@@ -0,0 +1,18 @@
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type FlatUser } from 'src/engine/core-modules/user/types/flat-user.type';
export const fromUserEntityToFlat = (entity: UserEntity): FlatUser => ({
id: entity.id,
firstName: entity.firstName,
lastName: entity.lastName,
email: entity.email,
defaultAvatarUrl: entity.defaultAvatarUrl,
isEmailVerified: entity.isEmailVerified,
disabled: entity.disabled,
canImpersonate: entity.canImpersonate,
canAccessFullAdminPanel: entity.canAccessFullAdminPanel,
locale: entity.locale,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
deletedAt: entity.deletedAt?.toISOString(),
});
@@ -0,0 +1,27 @@
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
export const WORKSPACE_ENTITY_NON_CACHED_PROPERTIES = [
'logoFile',
'appTokens',
'keyValuePairs',
'workspaceUsers',
'featureFlags',
'approvedAccessDomains',
'emailingDomains',
'publicDomains',
'workspaceMembersCount',
'allPostgresCredentials',
'workspaceSSOIdentityProviders',
'agents',
'webhooks',
'apiKeys',
'views',
'viewFields',
'viewFilters',
'viewFilterGroups',
'viewGroups',
'viewSorts',
'defaultRole',
'workspaceCustomApplication',
'applications',
] as const satisfies ReadonlyArray<keyof WorkspaceEntity>;
@@ -31,6 +31,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
@@ -158,6 +159,12 @@ describe('WorkspaceService', () => {
add: jest.fn(),
},
},
{
provide: CoreEntityCacheService,
useValue: {
invalidate: jest.fn(),
},
},
{
provide: getDataSourceToken(),
useValue: {
@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CoreEntityCache } from 'src/engine/core-entity-cache/decorators/core-entity-cache.decorator';
import { CoreEntityCacheProvider } from 'src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { fromWorkspaceEntityToFlat } from 'src/engine/core-modules/workspace/utils/from-workspace-entity-to-flat.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
@CoreEntityCache('workspaceEntity')
export class WorkspaceEntityCacheProviderService extends CoreEntityCacheProvider<FlatWorkspace> {
constructor(
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {
super();
}
async computeForCache(entityId: string): Promise<FlatWorkspace | null> {
const entity = await this.workspaceRepository.findOneBy({ id: entityId });
if (entity === null) {
return null;
}
return fromWorkspaceEntityToFlat(entity);
}
}
@@ -38,6 +38,7 @@ import {
WorkspaceExceptionCode,
WorkspaceNotFoundDefaultError,
} from 'src/engine/core-modules/workspace/workspace.exception';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { isModelAllowedByWorkspace } from 'src/engine/metadata-modules/ai/ai-models/utils/is-model-allowed.util';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -124,6 +125,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
private readonly messageQueueService: MessageQueueService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly coreEntityCacheService: CoreEntityCacheService,
) {
super(workspaceRepository);
}
@@ -290,6 +292,11 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
throw error;
}
await this.coreEntityCacheService.invalidate(
'workspaceEntity',
workspace.id,
);
if (payload.logo === null && isDefined(workspace.logoFileId)) {
await this.fileCorePictureService.deleteCorePicture({
fileId: workspace.logoFileId,
@@ -325,6 +332,11 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
activationStatus: WorkspaceActivationStatus.ONGOING_CREATION,
});
await this.coreEntityCacheService.invalidate(
'workspaceEntity',
workspace.id,
);
await this.featureFlagService.enableFeatureFlags(
DEFAULT_FEATURE_FLAGS,
workspace.id,
@@ -350,6 +362,11 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
version: extractVersionMajorMinorPatch(appVersion),
});
await this.coreEntityCacheService.invalidate(
'workspaceEntity',
workspace.id,
);
return await this.workspaceRepository.findOneBy({
id: workspace.id,
});
@@ -387,6 +404,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
if (softDelete) {
await this.workspaceRepository.softDelete({ id });
await this.coreEntityCacheService.invalidate('workspaceEntity', id);
this.logger.log(`workspace ${id} soft deleted`);
@@ -418,6 +436,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
}
await this.workspaceRepository.delete(id);
await this.coreEntityCacheService.invalidate('workspaceEntity', id);
this.logger.log(`workspace ${id} hard deleted`);
@@ -571,6 +590,10 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
userWorkspaceId: userWorkspaceOfRemovedWorkspaceMember.id,
softDelete,
});
await this.coreEntityCacheService.invalidate(
'userWorkspaceEntity',
userWorkspaceOfRemovedWorkspaceMember.id,
);
}
const hasOtherUserWorkspaces = isDefined(
@@ -581,6 +604,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
if (!hasOtherUserWorkspaces) {
await this.userRepository.softDelete(userId);
await this.coreEntityCacheService.invalidate('user', userId);
}
}
@@ -0,0 +1,17 @@
import { type CastRecordTypeOrmDatePropertiesToString } from 'src/engine/metadata-modules/flat-entity/types/cast-record-typeorm-date-properties-to-string.type';
import { type WORKSPACE_ENTITY_NON_CACHED_PROPERTIES } from 'src/engine/core-modules/workspace/constants/workspace-entity-non-cached-properties.constant';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
type WorkspaceEntityNonCachedProperties =
(typeof WORKSPACE_ENTITY_NON_CACHED_PROPERTIES)[number];
type WorkspaceCachedFields = Omit<
WorkspaceEntity,
WorkspaceEntityNonCachedProperties
>;
export type FlatWorkspace = Omit<
WorkspaceCachedFields,
keyof CastRecordTypeOrmDatePropertiesToString<WorkspaceCachedFields>
> &
CastRecordTypeOrmDatePropertiesToString<WorkspaceCachedFields>;
@@ -0,0 +1,43 @@
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
export const fromWorkspaceEntityToFlat = (
entity: WorkspaceEntity,
): FlatWorkspace => ({
id: entity.id,
displayName: entity.displayName,
logo: entity.logo,
logoFileId: entity.logoFileId,
inviteHash: entity.inviteHash,
allowImpersonation: entity.allowImpersonation,
isPublicInviteLinkEnabled: entity.isPublicInviteLinkEnabled,
trashRetentionDays: entity.trashRetentionDays,
eventLogRetentionDays: entity.eventLogRetentionDays,
activationStatus: entity.activationStatus,
metadataVersion: entity.metadataVersion,
databaseSchema: entity.databaseSchema,
subdomain: entity.subdomain,
customDomain: entity.customDomain,
isGoogleAuthEnabled: entity.isGoogleAuthEnabled,
isGoogleAuthBypassEnabled: entity.isGoogleAuthBypassEnabled,
isTwoFactorAuthenticationEnforced: entity.isTwoFactorAuthenticationEnforced,
isPasswordAuthEnabled: entity.isPasswordAuthEnabled,
isPasswordAuthBypassEnabled: entity.isPasswordAuthBypassEnabled,
isMicrosoftAuthEnabled: entity.isMicrosoftAuthEnabled,
isMicrosoftAuthBypassEnabled: entity.isMicrosoftAuthBypassEnabled,
isCustomDomainEnabled: entity.isCustomDomainEnabled,
editableProfileFields: entity.editableProfileFields,
defaultRoleId: entity.defaultRoleId,
version: entity.version,
fastModel: entity.fastModel,
smartModel: entity.smartModel,
aiAdditionalInstructions: entity.aiAdditionalInstructions,
enabledAiModelIds: entity.enabledAiModelIds,
useRecommendedModels: entity.useRecommendedModels,
workspaceCustomApplicationId: entity.workspaceCustomApplicationId,
routerModel: entity.routerModel,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
deletedAt: entity.deletedAt?.toISOString(),
suspendedAt: entity.suspendedAt?.toISOString() ?? null,
});
@@ -25,6 +25,8 @@ import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
import { CheckCustomDomainValidRecordsCronJob } from 'src/engine/core-modules/workspace/crons/jobs/check-custom-domain-valid-records.cron.job';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { WorkspaceEntityCacheProviderService } from 'src/engine/core-modules/workspace/services/workspace-entity-cache-provider.service';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { WorkspaceGaugeService } from 'src/engine/core-modules/workspace/workspace-gauge.service';
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
@@ -82,6 +84,7 @@ import { StandardObjectsPrefillModule } from 'src/engine/workspace-manager/stand
ApplicationModule,
EnterpriseModule,
StandardObjectsPrefillModule,
CoreEntityCacheModule,
],
services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts,
@@ -92,6 +95,7 @@ import { StandardObjectsPrefillModule } from 'src/engine/workspace-manager/stand
WorkspaceResolver,
WorkspaceService,
WorkspaceGaugeService,
WorkspaceEntityCacheProviderService,
BillingDisabledGuard,
CheckCustomDomainValidRecordsCronCommand,
CheckCustomDomainValidRecordsCronJob,
@@ -1,6 +1,6 @@
import { type SystemWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { buildSystemAuthContext as buildSystemAuthContextUtil } from 'src/engine/core-modules/auth/utils/build-system-auth-context.util';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
// Builds a minimal WorkspaceAuthContext for system operations (jobs, commands, crons)
// that don't have a user context but need to operate on a workspace
@@ -8,6 +8,6 @@ export const buildSystemAuthContext = (
workspaceId: string,
): SystemWorkspaceAuthContext => {
return buildSystemAuthContextUtil({
workspace: { id: workspaceId } as WorkspaceEntity,
workspace: { id: workspaceId } as FlatWorkspace,
});
};
@@ -5,6 +5,7 @@ import {
import { type EntityMetadata } from 'typeorm';
import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type';
@@ -52,6 +53,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = {
flatFrontComponentMaps: 'flat-maps:front-component',
flatWebhookMaps: 'flat-maps:webhook',
flatWorkspaceMemberMaps: 'flat-maps:workspace-member',
apiKeyMap: 'cache:api-key-map',
applicationVariableMaps: 'cache:application-variable',
graphQLResolverNameMap: 'direct-execution:graphql-resolver-name-map',
} as const satisfies Record<WorkspaceCacheKeyName, string>;
@@ -61,6 +63,7 @@ export type AdditionalCacheDataMaps = {
rolesPermissions: ObjectsPermissionsByRoleId;
userWorkspaceRoleMap: UserWorkspaceRoleMap;
apiKeyRoleMap: Record<string, string>;
apiKeyMap: Record<string, FlatApiKey>;
flatApplicationMaps: FlatApplicationCacheMaps;
ORMEntityMetadatas: EntityMetadata[];
flatRoleTargetByAgentIdMaps: FlatRoleTargetByAgentIdMaps;
@@ -29,7 +29,9 @@ export const seedApiKeys = async ({
{
id: API_KEY_DATA_SEED_IDS.ID_1,
name: 'My api key',
expiresAt: '2025-12-31T23:59:59.000Z',
expiresAt: new Date(
Date.now() + 1000 * 60 * 60 * 24 * 365 * 100,
).toISOString(),
workspaceId: workspaceId,
},
])
@@ -7,6 +7,8 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { buildApplicationAuthContext } from 'src/engine/core-modules/auth/utils/build-application-auth-context.util';
import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util';
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
import { fromWorkspaceEntityToFlat } from 'src/engine/core-modules/workspace/utils/from-workspace-entity-to-flat.util';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -72,9 +74,9 @@ export class WorkflowExecutionContextService {
});
const authContext: WorkspaceAuthContext = buildUserAuthContext({
workspace: userWorkspace.workspace,
workspace: fromWorkspaceEntityToFlat(userWorkspace.workspace),
userWorkspaceId: userWorkspace.id,
user: userWorkspace.user,
user: fromUserEntityToFlat(userWorkspace.user),
workspaceMemberId: workspaceMember.id,
workspaceMember,
});
@@ -115,7 +117,7 @@ export class WorkflowExecutionContextService {
: { shouldBypassPermissionChecks: true as const };
const authContext: WorkspaceAuthContext = buildApplicationAuthContext({
workspace,
workspace: fromWorkspaceEntityToFlat(workspace),
application: {
...application,
defaultRoleId: roleId,
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import {
PermissionsException,
@@ -30,7 +30,7 @@ export class WorkspaceMemberPreQueryHookService {
workspaceMemberId?: string;
targettedWorkspaceMemberId?: string;
workspaceId: string;
apiKey?: ApiKeyEntity | null;
apiKey?: FlatApiKey | null;
}) {
if (isDefined(apiKey)) {
return;
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -35,6 +36,7 @@ import { WorkspaceMemberUpdateOnePreQueryHook } from 'src/modules/workspace-memb
WorkspaceMemberUpdateManyPreQueryHook,
],
imports: [
CoreEntityCacheModule,
FeatureFlagModule,
OnboardingModule,
PermissionsModule,
@@ -8,6 +8,7 @@ import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/works
import { type UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import {
AuthException,
AuthExceptionCode,
@@ -27,6 +28,7 @@ export class WorkspaceMemberUpdateOnePreQueryHook
private readonly workspaceMemberPreQueryHookService: WorkspaceMemberPreQueryHookService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly coreEntityCacheService: CoreEntityCacheService,
) {}
async execute(
@@ -73,6 +75,11 @@ export class WorkspaceMemberUpdateOnePreQueryHook
...userWorkspace,
locale: payload.data.locale,
});
await this.coreEntityCacheService.invalidate(
'userWorkspaceEntity',
authContext.userWorkspaceId,
);
}
await this.workspaceMemberPreQueryHookService.completeOnboardingProfileStepIfNameProvided(