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
@@ -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;