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:
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+328
-407
@@ -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');
|
||||
|
||||
+37
-40
@@ -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(
|
||||
|
||||
+8
-3
@@ -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(
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+7
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user