From 7c02292d18e66f9ae954ede2a182670aa87a3c6e Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Fri, 3 Apr 2026 15:12:38 +0000 Subject: [PATCH 1/2] feat: implement user connection access levels for multiple connections - Added `getUserConnectionAccessLevelsForMultipleConnections` method in `CedarPermissionsService` to evaluate user access levels for multiple connections. - Enhanced `company-info-custom-repository` to decrypt connection credentials after fetching company info. - Updated `connection.controller` to increase timeout for fetching connections. - Refactored `connection.entity` to remove `@AfterLoad` decryption and moved it to a new utility function. - Introduced `decrypt-connection-credentials-async.ts` for handling connection credential decryption asynchronously. - Updated various repository methods to call the new decryption utility after fetching connections. - Added end-to-end tests to ensure performance with a large number of connections without causing out-of-memory errors. --- .../cedar-permissions.service.ts | 63 ++++++++ ...ompany-info-custom-repository.extension.ts | 21 ++- .../entities/connection/connection.entity.ts | 59 ++----- .../custom-connection-repository-extension.ts | 57 +++++-- .../find-all-connections.use.case.ts | 41 ++--- .../use-cases/restore-connection-use.case.ts | 2 + .../use-cases/test-connection.use.case.ts | 2 + .../decrypt-connection-credentials-async.ts | 52 ++++++ .../group-custom-repository-extension.ts | 10 ++ .../repository/group.repository.interface.ts | 2 + .../action-rules-custom-repository.ts | 17 +- ...ble-actions-custom-repository.extension.ts | 26 ++- .../user-custom-repository-extension.ts | 7 +- backend/src/helpers/encryption/encryptor.ts | 44 +++++ ...ate-hosted-connection-password.use.case.ts | 2 + .../non-saas-many-connections-e2e.test.ts | 150 ++++++++++++++++++ 16 files changed, 461 insertions(+), 94 deletions(-) create mode 100644 backend/src/entities/connection/utils/decrypt-connection-credentials-async.ts create mode 100644 backend/test/ava-tests/non-saas-tests/non-saas-many-connections-e2e.test.ts diff --git a/backend/src/entities/cedar-authorization/cedar-permissions.service.ts b/backend/src/entities/cedar-authorization/cedar-permissions.service.ts index 7d85424d0..3aed9cc6c 100644 --- a/backend/src/entities/cedar-authorization/cedar-permissions.service.ts +++ b/backend/src/entities/cedar-authorization/cedar-permissions.service.ts @@ -58,6 +58,69 @@ export class CedarPermissionsService implements IUserAccessRepository { return AccessLevelEnum.none; } + async getUserConnectionAccessLevelsForMultipleConnections( + userId: string, + connectionIds: Array, + ): Promise> { + const result = new Map(); + if (connectionIds.length === 0) return result; + + const allGroups = await this.globalDbContext.groupRepository.findAllUserGroupsInConnections(connectionIds, userId); + + const groupsByConnection = new Map>(); + for (const group of allGroups) { + const connId = group.connection?.id; + if (!connId) continue; + if (!groupsByConnection.has(connId)) { + groupsByConnection.set(connId, []); + } + groupsByConnection.get(connId).push(group); + } + + for (const connectionId of connectionIds) { + const userGroups = groupsByConnection.get(connectionId); + if (!userGroups || userGroups.length === 0) { + result.set(connectionId, AccessLevelEnum.none); + continue; + } + + const policies = userGroups.map((g) => g.cedarPolicy).filter(Boolean); + if (policies.length === 0) { + result.set(connectionId, AccessLevelEnum.none); + continue; + } + + const entities = buildCedarEntities(userId, userGroups, connectionId); + if ( + this.evaluatePolicies( + userId, + CedarAction.ConnectionEdit, + CedarResourceType.Connection, + connectionId, + policies, + entities, + ) + ) { + result.set(connectionId, AccessLevelEnum.edit); + } else if ( + this.evaluatePolicies( + userId, + CedarAction.ConnectionRead, + CedarResourceType.Connection, + connectionId, + policies, + entities, + ) + ) { + result.set(connectionId, AccessLevelEnum.readonly); + } else { + result.set(connectionId, AccessLevelEnum.none); + } + } + + return result; + } + async checkUserConnectionRead(cognitoUserName: string, connectionId: string): Promise { const ctx = await this.loadContext(connectionId, cognitoUserName); if (!ctx) return false; diff --git a/backend/src/entities/company-info/repository/company-info-custom-repository.extension.ts b/backend/src/entities/company-info/repository/company-info-custom-repository.extension.ts index 07a9e3931..a9a49be87 100644 --- a/backend/src/entities/company-info/repository/company-info-custom-repository.extension.ts +++ b/backend/src/entities/company-info/repository/company-info-custom-repository.extension.ts @@ -1,5 +1,6 @@ import { Constants } from '../../../helpers/constants/constants.js'; import { ConnectionEntity } from '../../connection/connection.entity.js'; +import { decryptConnectionsCredentialsAsync } from '../../connection/utils/decrypt-connection-credentials-async.js'; import { CompanyInfoEntity } from '../company-info.entity.js'; import { ICompanyInfoRepository } from './company-info-repository.interface.js'; @@ -19,11 +20,15 @@ export const companyInfoRepositoryExtension: ICompanyInfoRepository = { }, async findOneCompanyInfoByUserIdWithConnections(userId: string): Promise { - return await this.createQueryBuilder('company_info') + const result = await this.createQueryBuilder('company_info') .leftJoinAndSelect('company_info.users', 'users') .leftJoinAndSelect('company_info.connections', 'connections') .where('users.id = :userId', { userId }) .getOne(); + if (result?.connections?.length) { + await decryptConnectionsCredentialsAsync(result.connections); + } + return result; }, async findCompanyInfoByUserId(userId: string): Promise { @@ -55,7 +60,7 @@ export const companyInfoRepositoryExtension: ICompanyInfoRepository = { // returns groups and connections where user is invited async findFullCompanyInfoByUserId(userId: string): Promise { - return await this.createQueryBuilder('company_info') + const result = await this.createQueryBuilder('company_info') .leftJoinAndSelect('company_info.logo', 'logo') .leftJoinAndSelect('company_info.favicon', 'favicon') .leftJoinAndSelect('company_info.tab_title', 'tab_title') @@ -68,6 +73,10 @@ export const companyInfoRepositoryExtension: ICompanyInfoRepository = { .leftJoinAndSelect('groups.users', 'groups_users') .where('current_user.id = :userId', { userId }) .getOne(); + if (result?.connections?.length) { + await decryptConnectionsCredentialsAsync(result.connections); + } + return result; }, async findCompanyInfosByUserEmail(userEmail: string): Promise { @@ -87,10 +96,12 @@ export const companyInfoRepositoryExtension: ICompanyInfoRepository = { .andWhere('connections.isTestConnection IS FALSE') .andWhere('connections.is_frozen IS FALSE') .getMany(); - return foundCompaniesWithPaidConnections + const connections = foundCompaniesWithPaidConnections .map((companyInfo: CompanyInfoEntity) => companyInfo.connections) .filter(Boolean) .flat(); + await decryptConnectionsCredentialsAsync(connections); + return connections; }, async findCompanyFrozenPaidConnections(companyIds: Array): Promise> { @@ -102,10 +113,12 @@ export const companyInfoRepositoryExtension: ICompanyInfoRepository = { .andWhere('connections.isTestConnection IS FALSE') .andWhere('connections.is_frozen IS TRUE') .getMany(); - return foundCompaniesWithPaidConnections + const connections = foundCompaniesWithPaidConnections .map((companyInfo: CompanyInfoEntity) => companyInfo.connections) .filter(Boolean) .flat(); + await decryptConnectionsCredentialsAsync(connections); + return connections; }, async findCompanyWithLogo(companyId: string): Promise { diff --git a/backend/src/entities/connection/connection.entity.ts b/backend/src/entities/connection/connection.entity.ts index a48774e0b..4f3ac550e 100644 --- a/backend/src/entities/connection/connection.entity.ts +++ b/backend/src/entities/connection/connection.entity.ts @@ -1,7 +1,6 @@ import { Expose } from 'class-transformer'; import { nanoid } from 'nanoid'; import { - AfterLoad, BeforeInsert, BeforeUpdate, Column, @@ -14,7 +13,6 @@ import { Relation, } from 'typeorm'; import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; -import { Constants } from '../../helpers/constants/constants.js'; import { Encryptor } from '../../helpers/encryption/encryptor.js'; import { isConnectionTypeAgent } from '../../helpers/index.js'; import { AgentEntity } from '../agent/agent.entity.js'; @@ -118,9 +116,18 @@ export class ConnectionEntity { @Column({ default: null }) master_hash?: string | null; + /** + * Non-persisted flag indicating whether credentials are currently in decrypted state. + * Used by @BeforeUpdate to decide whether encryption is needed. + */ + credentialsDecrypted = false; + @BeforeUpdate() updateTimestampEncryptCredentials(): void { this.updatedAt = new Date(); + if (!this.credentialsDecrypted) { + return; + } if (!isConnectionTypeAgent(this.type)) { this.host = Encryptor.encryptData(this.host); this.database = Encryptor.encryptData(this.database); @@ -138,6 +145,7 @@ export class ConnectionEntity { this.cert = Encryptor.encryptData(this.cert); } } + this.credentialsDecrypted = false; } @BeforeInsert() @@ -168,51 +176,8 @@ export class ConnectionEntity { } } - @AfterLoad() - decryptCredentials(): void { - if (this.isTestConnection) { - const testConnectionsArray = Constants.getTestConnectionsArr(); - const foundTestConnectionByType = testConnectionsArray.find( - (testConnection) => testConnection.type === this.type, - ); - if (foundTestConnectionByType) { - this.host = foundTestConnectionByType.host; - this.database = foundTestConnectionByType.database; - this.username = foundTestConnectionByType.username; - this.password = foundTestConnectionByType.password; - this.port = foundTestConnectionByType.port; - this.ssh = foundTestConnectionByType.ssh; - this.privateSSHKey = foundTestConnectionByType.privateSSHKey; - this.sshHost = foundTestConnectionByType.sshHost; - this.sshPort = foundTestConnectionByType.sshPort; - this.sshUsername = foundTestConnectionByType.sshUsername; - this.ssl = foundTestConnectionByType.ssl; - this.cert = foundTestConnectionByType.cert; - this.authSource = foundTestConnectionByType.authSource; - this.sid = foundTestConnectionByType.sid; - this.schema = foundTestConnectionByType.schema; - this.azure_encryption = foundTestConnectionByType.azure_encryption; - } - } else { - if (!isConnectionTypeAgent(this.type)) { - this.host = Encryptor.decryptData(this.host); - this.database = Encryptor.decryptData(this.database); - this.password = Encryptor.decryptData(this.password); - this.username = Encryptor.decryptData(this.username); - if (this.authSource) { - this.authSource = Encryptor.decryptData(this.authSource); - } - if (this.ssh) { - this.privateSSHKey = Encryptor.decryptData(this.privateSSHKey); - this.sshHost = Encryptor.decryptData(this.sshHost); - this.sshUsername = Encryptor.decryptData(this.sshUsername); - } - if (this.ssl && this.cert) { - this.cert = Encryptor.decryptData(this.cert); - } - } - } - } + // Decryption moved to async utility: decrypt-connection-credentials-async.ts + // All repository methods must call decryptConnectionCredentialsAsync() after loading. @ManyToOne( (_) => UserEntity, diff --git a/backend/src/entities/connection/repository/custom-connection-repository-extension.ts b/backend/src/entities/connection/repository/custom-connection-repository-extension.ts index 159669333..cd527dd54 100644 --- a/backend/src/entities/connection/repository/custom-connection-repository-extension.ts +++ b/backend/src/entities/connection/repository/custom-connection-repository-extension.ts @@ -5,6 +5,10 @@ import { Encryptor } from '../../../helpers/encryption/encryptor.js'; import { isConnectionTypeAgent } from '../../../helpers/index.js'; import { UserEntity } from '../../user/user.entity.js'; import { ConnectionEntity } from '../connection.entity.js'; +import { + decryptConnectionCredentialsAsync, + decryptConnectionsCredentialsAsync, +} from '../utils/decrypt-connection-credentials-async.js'; import { isTestConnectionUtil } from '../utils/is-test-connection-util.js'; import { IConnectionRepository } from './connection.repository.interface.js'; @@ -26,6 +30,7 @@ export const customConnectionRepositoryExtension: IConnectionRepository & savedConnection.cert = this.decryptConnectionField(savedConnection.cert); } } + savedConnection.credentialsDecrypted = true; return savedConnection; }, @@ -38,7 +43,9 @@ export const customConnectionRepositoryExtension: IConnectionRepository & if (!includeTestConnections) { connectionQb.andWhere('connection.isTestConnection = :isTest', { isTest: false }); } - return await connectionQb.getMany(); + const connections = await connectionQb.getMany(); + await decryptConnectionsCredentialsAsync(connections); + return connections; }, async findAllUserTestConnections(userId: string): Promise> { @@ -48,7 +55,9 @@ export const customConnectionRepositoryExtension: IConnectionRepository & .leftJoinAndSelect('connection.connection_properties', 'connection_properties') .andWhere('user.id = :userId', { userId: userId }) .andWhere('connection.isTestConnection = :isTest', { isTest: true }); - return await connectionQb.getMany(); + const connections = await connectionQb.getMany(); + await decryptConnectionsCredentialsAsync(connections); + return connections; }, async findAllUserNonTestsConnections(userId: string): Promise> { @@ -81,6 +90,7 @@ export const customConnectionRepositoryExtension: IConnectionRepository & connection.signing_key = Encryptor.generateRandomString(40); await this.save(connection); } + await decryptConnectionCredentialsAsync(connection); return connection; }, @@ -96,6 +106,7 @@ export const customConnectionRepositoryExtension: IConnectionRepository & connection.signing_key = Encryptor.generateRandomString(40); await this.save(connection); } + await decryptConnectionCredentialsAsync(connection); if (connection.masterEncryption && !masterPwd) { throw new Error(Messages.MASTER_PASSWORD_MISSING); @@ -121,11 +132,15 @@ export const customConnectionRepositoryExtension: IConnectionRepository & const qb = this.createQueryBuilder('connection') .leftJoinAndSelect('connection.groups', 'group') .andWhere('connection.id = :connectionId', { connectionId: connectionId }); - return await qb.getOne(); + const connection = await qb.getOne(); + if (connection) { + await decryptConnectionCredentialsAsync(connection); + } + return connection; }, async getWorkedConnectionsInTwoWeeks(): Promise> { - const freshNonTestConnectionsWithLogs = await this.createQueryBuilder('connection') + const connections = await this.createQueryBuilder('connection') .leftJoinAndSelect('connection.author', 'author') .leftJoin('connection.logs', 'logs') .where('connection.createdAt > :date', { date: Constants.TWO_WEEKS_AGO() }) @@ -133,7 +148,8 @@ export const customConnectionRepositoryExtension: IConnectionRepository & .andWhere('connection.isTestConnection = :isTest', { isTest: false }) .andWhere('logs.id IS NOT NULL') .getMany(); - return freshNonTestConnectionsWithLogs; + await decryptConnectionsCredentialsAsync(connections); + return connections; }, async getConnectionByGroupIdWithCompanyAndUsersInCompany(groupId: string): Promise { @@ -142,17 +158,29 @@ export const customConnectionRepositoryExtension: IConnectionRepository & .leftJoinAndSelect('connection.company', 'company') .leftJoinAndSelect('company.users', 'user'); qb.andWhere('group.id = :groupId', { groupId: groupId }); - return await qb.getOne(); + const connection = await qb.getOne(); + if (connection) { + await decryptConnectionCredentialsAsync(connection); + } + return connection; }, async findOneById(connectionId: string): Promise { - return await this.findOne({ where: { id: connectionId } }); + const connection = await this.findOne({ where: { id: connectionId } }); + if (connection) { + await decryptConnectionCredentialsAsync(connection); + } + return connection; }, async findOneAgentConnectionByToken(connectionToken: string): Promise { const qb = this.createQueryBuilder('connection').leftJoinAndSelect('connection.agent', 'agent'); qb.andWhere('agent.token = :agentToken', { agentToken: connectionToken }); - return await qb.getOne(); + const connection = await qb.getOne(); + if (connection) { + await decryptConnectionCredentialsAsync(connection); + } + return connection; }, async isTestConnectionById(connectionId: string): Promise { @@ -179,13 +207,12 @@ export const customConnectionRepositoryExtension: IConnectionRepository & async findAllCompanyUsersNonTestsConnections(companyId: string): Promise> { const connectionQb = this.createQueryBuilder('connection') - .leftJoin('connection.groups', 'group') - .leftJoin('group.users', 'user') - .leftJoin('user.company', 'company') .leftJoinAndSelect('connection.connection_properties', 'connection_properties') .where('connection.isTestConnection = :isTest', { isTest: false }) - .andWhere('company.id = :companyId', { companyId: companyId }); - return await connectionQb.getMany(); + .andWhere('connection.companyId = :companyId', { companyId: companyId }); + const connections = await connectionQb.getMany(); + await decryptConnectionsCredentialsAsync(connections); + return connections; }, async freezeConnections(connectionsIds: Array): Promise { @@ -210,7 +237,9 @@ export const customConnectionRepositoryExtension: IConnectionRepository & .where('user.id = :userId', { userId: userId }) .andWhere('connection.isTestConnection = :isTest', { isTest: true }) .andWhere('connection.company IS NULL'); - return await qb.getMany(); + const connections = await qb.getMany(); + await decryptConnectionsCredentialsAsync(connections); + return connections; }, decryptConnectionField(field: string): string { diff --git a/backend/src/entities/connection/use-cases/find-all-connections.use.case.ts b/backend/src/entities/connection/use-cases/find-all-connections.use.case.ts index 7e22c3b5e..fbcdc3b05 100644 --- a/backend/src/entities/connection/use-cases/find-all-connections.use.case.ts +++ b/backend/src/entities/connection/use-cases/find-all-connections.use.case.ts @@ -115,28 +115,29 @@ export class FindAllConnectionsUseCase }, {} as FilteredConnection); }; - const connectionsWithPermissions = await Promise.all( - allFoundUserConnections.map(async (connection) => { - const accessLevel = await this.cedarPermissions.getUserConnectionAccessLevel( - user.id, - connection.id, - ); - let filteredConnection: FilteredConnection = connection; + const connectionIds = allFoundUserConnections.map((c) => c.id); + const accessLevelsMap = await this.cedarPermissions.getUserConnectionAccessLevelsForMultipleConnections( + user.id, + connectionIds, + ); - if (accessLevel === AccessLevelEnum.none) { - filteredConnection = filterConnectionKeys(connection, Constants.CONNECTION_KEYS_NONE_PERMISSION); - } else if (accessLevel !== AccessLevelEnum.edit) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { signing_key, ...rest } = connection; - filteredConnection = rest; - } + const connectionsWithPermissions = allFoundUserConnections.map((connection) => { + const accessLevel = accessLevelsMap.get(connection.id) ?? AccessLevelEnum.none; + let filteredConnection: FilteredConnection = connection; - return { - connection: buildFoundConnectionDs(filteredConnection), - accessLevel: accessLevel, - }; - }), - ); + if (accessLevel === AccessLevelEnum.none) { + filteredConnection = filterConnectionKeys(connection, Constants.CONNECTION_KEYS_NONE_PERMISSION); + } else if (accessLevel !== AccessLevelEnum.edit) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { signing_key, ...rest } = connection; + filteredConnection = rest; + } + + return { + connection: buildFoundConnectionDs(filteredConnection), + accessLevel: accessLevel, + }; + }); await this.amplitudeService.formAndSendLogRecord(AmplitudeEventTypeEnum.connectionListReceived, user.id); return { diff --git a/backend/src/entities/connection/use-cases/restore-connection-use.case.ts b/backend/src/entities/connection/use-cases/restore-connection-use.case.ts index cc4fab3c3..a3a5afbb0 100644 --- a/backend/src/entities/connection/use-cases/restore-connection-use.case.ts +++ b/backend/src/entities/connection/use-cases/restore-connection-use.case.ts @@ -8,6 +8,7 @@ import { isConnectionEntityAgent } from '../../../helpers/index.js'; import { RestoredConnectionDs } from '../application/data-structures/restored-connection.ds.js'; import { UpdateConnectionDs } from '../application/data-structures/update-connection.ds.js'; import { buildCreatedConnectionDs } from '../utils/build-created-connection.ds.js'; +import { decryptConnectionCredentialsAsync } from '../utils/decrypt-connection-credentials-async.js'; import { isHostAllowed } from '../utils/is-host-allowed.js'; import { isHostTest } from '../utils/is-test-connection-util.js'; import { updateConnectionEntityForRestoration } from '../utils/update-connection-entity-for-restoration.js'; @@ -73,6 +74,7 @@ export class RestoreConnectionUseCase const foundConnectionAfterSave = await this._dbContext.connectionRepository.findOne({ where: { id: savedConnection.id }, }); + await decryptConnectionCredentialsAsync(foundConnectionAfterSave); const token = updatedConnection.agent?.token || null; return { connection: buildCreatedConnectionDs(foundConnectionAfterSave, token, connectionData.update_info.masterPwd), diff --git a/backend/src/entities/connection/use-cases/test-connection.use.case.ts b/backend/src/entities/connection/use-cases/test-connection.use.case.ts index 289b6c77f..0abeadd76 100644 --- a/backend/src/entities/connection/use-cases/test-connection.use.case.ts +++ b/backend/src/entities/connection/use-cases/test-connection.use.case.ts @@ -18,6 +18,7 @@ import { UpdateConnectionDs } from '../application/data-structures/update-connec import { ConnectionEntity } from '../connection.entity.js'; import { isHostAllowed } from '../utils/is-host-allowed.js'; import { processAWSConnection } from '../utils/process-aws-connection.util.js'; +import { decryptConnectionCredentialsAsync } from '../utils/decrypt-connection-credentials-async.js'; import { ITestConnection } from './use-cases.interfaces.js'; @Injectable() @@ -56,6 +57,7 @@ export class TestConnectionUseCase message: Messages.CONNECTION_NOT_FOUND, }; } + await decryptConnectionCredentialsAsync(toUpdate); if (toUpdate?.masterEncryption && !masterPwd) { return { result: false, diff --git a/backend/src/entities/connection/utils/decrypt-connection-credentials-async.ts b/backend/src/entities/connection/utils/decrypt-connection-credentials-async.ts new file mode 100644 index 000000000..106d7cca6 --- /dev/null +++ b/backend/src/entities/connection/utils/decrypt-connection-credentials-async.ts @@ -0,0 +1,52 @@ +import { Constants } from '../../../helpers/constants/constants.js'; +import { Encryptor } from '../../../helpers/encryption/encryptor.js'; +import { isConnectionTypeAgent } from '../../../helpers/index.js'; +import { ConnectionEntity } from '../connection.entity.js'; + +export async function decryptConnectionCredentialsAsync(connection: ConnectionEntity): Promise { + if (connection.isTestConnection) { + const testConnectionsArray = Constants.getTestConnectionsArr(); + const foundTestConnectionByType = testConnectionsArray.find( + (testConnection) => testConnection.type === connection.type, + ); + if (foundTestConnectionByType) { + connection.host = foundTestConnectionByType.host; + connection.database = foundTestConnectionByType.database; + connection.username = foundTestConnectionByType.username; + connection.password = foundTestConnectionByType.password; + connection.port = foundTestConnectionByType.port; + connection.ssh = foundTestConnectionByType.ssh; + connection.privateSSHKey = foundTestConnectionByType.privateSSHKey; + connection.sshHost = foundTestConnectionByType.sshHost; + connection.sshPort = foundTestConnectionByType.sshPort; + connection.sshUsername = foundTestConnectionByType.sshUsername; + connection.ssl = foundTestConnectionByType.ssl; + connection.cert = foundTestConnectionByType.cert; + connection.authSource = foundTestConnectionByType.authSource; + connection.sid = foundTestConnectionByType.sid; + connection.schema = foundTestConnectionByType.schema; + connection.azure_encryption = foundTestConnectionByType.azure_encryption; + } + } else if (!isConnectionTypeAgent(connection.type)) { + connection.host = await Encryptor.decryptDataAsync(connection.host); + connection.database = await Encryptor.decryptDataAsync(connection.database); + connection.password = await Encryptor.decryptDataAsync(connection.password); + connection.username = await Encryptor.decryptDataAsync(connection.username); + if (connection.authSource) { + connection.authSource = await Encryptor.decryptDataAsync(connection.authSource); + } + if (connection.ssh) { + connection.privateSSHKey = await Encryptor.decryptDataAsync(connection.privateSSHKey); + connection.sshHost = await Encryptor.decryptDataAsync(connection.sshHost); + connection.sshUsername = await Encryptor.decryptDataAsync(connection.sshUsername); + } + if (connection.ssl && connection.cert) { + connection.cert = await Encryptor.decryptDataAsync(connection.cert); + } + } + connection.credentialsDecrypted = true; +} + +export async function decryptConnectionsCredentialsAsync(connections: Array): Promise { + await Promise.all(connections.map((connection) => decryptConnectionCredentialsAsync(connection))); +} diff --git a/backend/src/entities/group/repository/group-custom-repository-extension.ts b/backend/src/entities/group/repository/group-custom-repository-extension.ts index 38afa14e3..c50152830 100644 --- a/backend/src/entities/group/repository/group-custom-repository-extension.ts +++ b/backend/src/entities/group/repository/group-custom-repository-extension.ts @@ -74,6 +74,16 @@ export const groupCustomRepositoryExtension: IGroupRepository = { return await qb.getOne(); }, + async findAllUserGroupsInConnections(connectionIds: Array, userId: string): Promise> { + if (connectionIds.length === 0) return []; + const qb = this.createQueryBuilder('group') + .leftJoinAndSelect('group.connection', 'connection') + .leftJoinAndSelect('group.users', 'user') + .andWhere('connection.id IN (:...connectionIds)', { connectionIds }) + .andWhere('user.id = :userId', { userId }); + return await qb.getMany(); + }, + async findAllUsersInGroupsWhereUserIsAdmin(userId: string, connectionId: string): Promise> { // Find groups where the user is a member, in the given connection, // and the Cedar policy grants group:edit access (wildcard or explicit) diff --git a/backend/src/entities/group/repository/group.repository.interface.ts b/backend/src/entities/group/repository/group.repository.interface.ts index 5f803603c..564061bc1 100644 --- a/backend/src/entities/group/repository/group.repository.interface.ts +++ b/backend/src/entities/group/repository/group.repository.interface.ts @@ -24,4 +24,6 @@ export interface IGroupRepository { findGroupById(groupId: string): Promise; findAllUsersInGroupsWhereUserIsAdmin(userId: string, connectionId: string): Promise>; + + findAllUserGroupsInConnections(connectionIds: Array, userId: string): Promise>; } diff --git a/backend/src/entities/table-actions/table-action-rules-module/repository/action-rules-custom-repository.ts b/backend/src/entities/table-actions/table-action-rules-module/repository/action-rules-custom-repository.ts index 42b9859b4..84ab650a3 100644 --- a/backend/src/entities/table-actions/table-action-rules-module/repository/action-rules-custom-repository.ts +++ b/backend/src/entities/table-actions/table-action-rules-module/repository/action-rules-custom-repository.ts @@ -1,4 +1,5 @@ import { TableActionEventEnum } from '../../../../enums/table-action-event-enum.js'; +import { decryptConnectionCredentialsAsync, decryptConnectionsCredentialsAsync } from '../../../connection/utils/decrypt-connection-credentials-async.js'; import { ActionRulesEntity } from '../action-rules.entity.js'; import { IActionRulesRepository } from './action-rules-custom-repository.interface.js'; @@ -11,27 +12,34 @@ export const actionRulesCustomRepositoryExtension: IActionRulesRepository = { connectionId: string, tableName: string, ): Promise> { - return await this.createQueryBuilder('action_rules') + const results = await this.createQueryBuilder('action_rules') .leftJoinAndSelect('action_rules.table_actions', 'table_actions') .leftJoinAndSelect('action_rules.action_events', 'action_events') .leftJoinAndSelect('action_rules.connection', 'connection') .where('connection.id = :connectionId', { connectionId }) .andWhere('action_rules.table_name = :tableName', { tableName }) .getMany(); + const connections = results.flatMap((r) => (r.connection ? [r.connection] : [])); + await decryptConnectionsCredentialsAsync(connections); + return results; }, async findOneWithActionsAndEvents(ruleId: string, connectionId: string): Promise { - return await this.createQueryBuilder('action_rules') + const result = await this.createQueryBuilder('action_rules') .leftJoinAndSelect('action_rules.table_actions', 'table_actions') .leftJoinAndSelect('action_rules.action_events', 'action_events') .leftJoinAndSelect('action_rules.connection', 'connection') .where('action_rules.id = :ruleId', { ruleId }) .andWhere('connection.id = :connectionId', { connectionId }) .getOne(); + if (result?.connection) { + await decryptConnectionCredentialsAsync(result.connection); + } + return result; }, async findActionRulesWithCustomEvents(connectionId: string, tableName: string): Promise> { - return await this.createQueryBuilder('action_rules') + const results = await this.createQueryBuilder('action_rules') .leftJoinAndSelect('action_rules.table_actions', 'table_actions') .leftJoinAndSelect('action_rules.action_events', 'action_events') .leftJoinAndSelect('action_rules.connection', 'connection') @@ -39,5 +47,8 @@ export const actionRulesCustomRepositoryExtension: IActionRulesRepository = { .andWhere('action_rules.table_name = :tableName', { tableName }) .andWhere('action_events.event = :event', { event: TableActionEventEnum.CUSTOM }) .getMany(); + const connections = results.flatMap((r) => (r.connection ? [r.connection] : [])); + await decryptConnectionsCredentialsAsync(connections); + return results; }, }; diff --git a/backend/src/entities/table-actions/table-actions-module/repository/table-actions-custom-repository.extension.ts b/backend/src/entities/table-actions/table-actions-module/repository/table-actions-custom-repository.extension.ts index 28438b267..8d83cf139 100644 --- a/backend/src/entities/table-actions/table-actions-module/repository/table-actions-custom-repository.extension.ts +++ b/backend/src/entities/table-actions/table-actions-module/repository/table-actions-custom-repository.extension.ts @@ -1,4 +1,5 @@ import { TableActionEventEnum } from '../../../../enums/table-action-event-enum.js'; +import { decryptConnectionsCredentialsAsync } from '../../../connection/utils/decrypt-connection-credentials-async.js'; import { TableActionEntity } from '../table-action.entity.js'; import { ITableActionRepository } from './table-action-custom-repository.interface.js'; @@ -15,7 +16,10 @@ export const tableActionsCustomRepositoryExtension: ITableActionRepository = { .leftJoinAndSelect('table_actions.settings', 'table_settings') .where('connection.id = :connectionId', { connectionId }) .andWhere('action_rule.table_name = :tableName', { tableName }); - return await qb.getMany(); + const results = await qb.getMany(); + const connections = results.flatMap((r) => (r.action_rule?.connection ? [r.action_rule.connection] : [])); + await decryptConnectionsCredentialsAsync(connections); + return results; }, async findTableActionsWithAddRowEvents(connectionId: string, tableName: string): Promise> { @@ -27,7 +31,10 @@ export const tableActionsCustomRepositoryExtension: ITableActionRepository = { .where('connection.id = :connectionId', { connectionId }) .andWhere('action_rule.table_name = :tableName', { tableName }) .andWhere('action_events.event = :eventType', { eventType: TableActionEventEnum.ADD_ROW }); - return await qb.getMany(); + const results = await qb.getMany(); + const connections = results.flatMap((r) => (r.action_rule?.connection ? [r.action_rule.connection] : [])); + await decryptConnectionsCredentialsAsync(connections); + return results; }, async findTableActionsWithUpdateRowEvents( @@ -42,7 +49,10 @@ export const tableActionsCustomRepositoryExtension: ITableActionRepository = { .where('connection.id = :connectionId', { connectionId }) .andWhere('action_rule.table_name = :tableName', { tableName }) .andWhere('action_events.event = :eventType', { eventType: TableActionEventEnum.UPDATE_ROW }); - return qb.getMany(); + const results = await qb.getMany(); + const connections = results.flatMap((r) => (r.action_rule?.connection ? [r.action_rule.connection] : [])); + await decryptConnectionsCredentialsAsync(connections); + return results; }, async findTableActionsWithDeleteRowEvents( @@ -57,7 +67,10 @@ export const tableActionsCustomRepositoryExtension: ITableActionRepository = { .where('connection.id = :connectionId', { connectionId }) .andWhere('action_rule.table_name = :tableName', { tableName }) .andWhere('action_events.event = :eventType', { eventType: TableActionEventEnum.DELETE_ROW }); - return qb.getMany(); + const results = await qb.getMany(); + const connections = results.flatMap((r) => (r.action_rule?.connection ? [r.action_rule.connection] : [])); + await decryptConnectionsCredentialsAsync(connections); + return results; }, async findActionsWithCustomEventsByEventIdConnectionId( @@ -71,7 +84,10 @@ export const tableActionsCustomRepositoryExtension: ITableActionRepository = { .where('connection.id = :connectionId', { connectionId }) .andWhere('action_events.id = :eventId', { eventId }) .andWhere('action_events.event = :eventType', { eventType: TableActionEventEnum.CUSTOM }); - return await qb.getMany(); + const results = await qb.getMany(); + const connections = results.flatMap((r) => (r.action_rule?.connection ? [r.action_rule.connection] : [])); + await decryptConnectionsCredentialsAsync(connections); + return results; }, async findTableActionById(actionId: string): Promise { diff --git a/backend/src/entities/user/repository/user-custom-repository-extension.ts b/backend/src/entities/user/repository/user-custom-repository-extension.ts index e8299c206..dcfd5d14d 100644 --- a/backend/src/entities/user/repository/user-custom-repository-extension.ts +++ b/backend/src/entities/user/repository/user-custom-repository-extension.ts @@ -1,4 +1,5 @@ import { Constants } from '../../../helpers/constants/constants.js'; +import { decryptConnectionsCredentialsAsync } from '../../connection/utils/decrypt-connection-credentials-async.js'; import { CreateUserDs } from '../application/data-structures/create-user.ds.js'; import { RegisterUserDs } from '../application/data-structures/register-user-ds.js'; import { ExternalRegistrationProviderEnum } from '../enums/external-registration-provider.enum.js'; @@ -152,10 +153,14 @@ export const userCustomRepositoryExtension: IUserRepository = { }, async findUserWithConnections(userId: string): Promise { - return await this.findOne({ + const user = await this.findOne({ where: { id: userId }, relations: ['connections'], }); + if (user?.connections?.length) { + await decryptConnectionsCredentialsAsync(user.connections); + } + return user; }, async findUsersWithoutLogs(): Promise> { diff --git a/backend/src/helpers/encryption/encryptor.ts b/backend/src/helpers/encryption/encryptor.ts index 54bdfbd1c..58ab6b862 100644 --- a/backend/src/helpers/encryption/encryptor.ts +++ b/backend/src/helpers/encryption/encryptor.ts @@ -23,6 +23,15 @@ export class Encryptor { return crypto.pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, KEY_LENGTH, 'sha256'); } + private static deriveKeyAsync(passphrase: string, salt: Buffer): Promise { + return new Promise((resolve, reject) => { + crypto.pbkdf2(passphrase, salt, KDF_ITERATIONS, KEY_LENGTH, 'sha256', (err, key) => { + if (err) reject(err); + else resolve(key); + }); + }); + } + private static encryptDataV2(data: string, passphrase: string): string { const salt = randomBytes(SALT_LENGTH); const iv = randomBytes(IV_LENGTH); @@ -58,6 +67,24 @@ export class Encryptor { return decrypted.toString('utf8'); } + private static async decryptDataV2Async(encryptedData: string, passphrase: string): Promise { + const dataWithoutPrefix = encryptedData.substring(ENCRYPTION_VERSION_PREFIX.length); + const parts = dataWithoutPrefix.split('.'); + if (parts.length !== 4) { + throw new Error('Invalid V2 encrypted data format'); + } + const [saltB64, ivB64, authTagB64, encryptedB64] = parts; + const salt = Buffer.from(saltB64, 'base64'); + const iv = Buffer.from(ivB64, 'base64'); + const authTag = Buffer.from(authTagB64, 'base64'); + const encrypted = Buffer.from(encryptedB64, 'base64'); + const key = await Encryptor.deriveKeyAsync(passphrase, salt); + const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString('utf8'); + } + private static decryptDataV1Legacy(encryptedData: string, passphrase: string): string { const bytes = CryptoJS.AES.decrypt(encryptedData, passphrase); return bytes.toString(CryptoJS.enc.Utf8); @@ -97,6 +124,23 @@ export class Encryptor { } } + static async decryptDataAsync(encryptedData: string): Promise { + if (encryptedData === null || encryptedData === undefined) { + return encryptedData; + } + try { + const privateKey = Encryptor.getPrivateKey(); + + if (Encryptor.isV2Format(encryptedData)) { + return await Encryptor.decryptDataV2Async(encryptedData, privateKey); + } + + return Encryptor.decryptDataV1Legacy(encryptedData, privateKey); + } catch (e) { + throw new Error('Data decryption failed with error: ' + e); + } + } + static encryptDataMasterPwd(data: string, masterPwd: string): string { if (data === null || data === undefined) { return data; diff --git a/backend/src/microservices/saas-microservice/use-cases/update-hosted-connection-password.use.case.ts b/backend/src/microservices/saas-microservice/use-cases/update-hosted-connection-password.use.case.ts index 76beba9db..8f38ca904 100644 --- a/backend/src/microservices/saas-microservice/use-cases/update-hosted-connection-password.use.case.ts +++ b/backend/src/microservices/saas-microservice/use-cases/update-hosted-connection-password.use.case.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; +import { decryptConnectionsCredentialsAsync } from '../../../entities/connection/utils/decrypt-connection-credentials-async.js'; import { Messages } from '../../../exceptions/text/messages.js'; import { SuccessResponse } from '../data-structures/common-responce.ds.js'; import { UpdateHostedConnectionPasswordDto } from '../data-structures/update-hosted-connection-password.dto.js'; @@ -31,6 +32,7 @@ export class UpdateHostedConnectionPasswordUseCase const companyConnections = await this._dbContext.connectionRepository.find({ where: { company: { id: companyId } }, }); + await decryptConnectionsCredentialsAsync(companyConnections); const connection = companyConnections.find((conn) => conn.database === databaseName); if (!connection) { throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-many-connections-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-many-connections-e2e.test.ts new file mode 100644 index 000000000..5b83d4e71 --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-many-connections-e2e.test.ts @@ -0,0 +1,150 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { DataSource } from 'typeorm'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { BaseType } from '../../../src/common/data-injection.tokens.js'; +import { generateCedarPolicyForGroup } from '../../../src/entities/cedar-authorization/cedar-policy-generator.js'; +import { ConnectionEntity } from '../../../src/entities/connection/connection.entity.js'; +import { GroupEntity } from '../../../src/entities/group/group.entity.js'; +import { AccessLevelEnum } from '../../../src/enums/index.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { registerUserAndReturnUserInfo } from '../../utils/register-user-and-return-user-info.js'; +import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +let app: INestApplication; +let _testUtils: TestUtils; + +test.before(async () => { + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + _testUtils = moduleFixture.get(TestUtils); + + app = moduleFixture.createNestApplication() as any; + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +const VALID_CONNECTIONS_COUNT = 5; +const INVALID_CONNECTIONS_COUNT = 200; +const TOTAL_CONNECTIONS_COUNT = VALID_CONNECTIONS_COUNT + INVALID_CONNECTIONS_COUNT; + +test.serial( + `> GET /connections > should return all connections without OOM when user has ${TOTAL_CONNECTIONS_COUNT} connections (${VALID_CONNECTIONS_COUNT} valid + ${INVALID_CONNECTIONS_COUNT} invalid)`, + async (t) => { + const { token, email, password } = await registerUserAndReturnUserInfo(app); + console.log('🚀 ~ token:\n', token, '\n'); + console.log('🚀 ~ password:', password); + console.log('🚀 ~ email:', email); + + const dataSource = app.get(BaseType.DATA_SOURCE); + const connectionRepository = dataSource.getRepository(ConnectionEntity); + const groupRepository = dataSource.getRepository(GroupEntity); + + // Resolve user from token + const userResponse = await request(app.getHttpServer()) + .get('/user') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(userResponse.status, 200); + const user = userResponse.body; + + // Build all connection entities + const connections: ConnectionEntity[] = []; + + for (let i = 0; i < VALID_CONNECTIONS_COUNT; i++) { + const conn = connectionRepository.create({ + title: `Valid Connection ${i + 1}`, + type: 'postgres' as any, + host: 'testPg-e2e-testing', + port: 5432, + username: 'postgres', + password: '123', + database: 'postgres', + ssh: false, + masterEncryption: false, + author: { id: user.id } as any, + company: { id: user.company.id } as any, + }); + connections.push(conn); + } + + for (let i = 0; i < INVALID_CONNECTIONS_COUNT; i++) { + const conn = connectionRepository.create({ + title: `Invalid Connection ${i + 1}`, + type: 'postgres' as any, + host: `nonexistent-host-${i}.invalid`, + port: 5432, + username: 'fakeuser', + password: 'fakepass', + database: 'fakedb', + ssh: false, + masterEncryption: false, + author: { id: user.id } as any, + company: { id: user.company.id } as any, + }); + connections.push(conn); + } + + // Save all connections in bulk + const savedConnections = await connectionRepository.save(connections); + + // Create admin groups with cedar policies for each connection + const groups: GroupEntity[] = savedConnections.map((conn) => { + const group = groupRepository.create({ + title: 'Admin', + isMain: true, + connection: conn, + users: [{ id: user.id } as any], + }); + group.cedarPolicy = generateCedarPolicyForGroup(conn.id, true, { + connection: { connectionId: conn.id, accessLevel: AccessLevelEnum.edit }, + group: { groupId: '', accessLevel: AccessLevelEnum.edit }, + tables: [], + }); + return group; + }); + + await groupRepository.save(groups); + + // Fetch all connections - this should not cause OOM + const findAllResponse = await request(app.getHttpServer()) + .get('/connections') + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(findAllResponse.status, 200); + t.is(findAllResponse.body.connections.length, TOTAL_CONNECTIONS_COUNT); + t.is(findAllResponse.body.connectionsCount, TOTAL_CONNECTIONS_COUNT); + }, +); From 2ebcb8ba598069aaef201e653f5caa1c734c947b Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Fri, 3 Apr 2026 15:16:47 +0000 Subject: [PATCH 2/2] refactor: remove debug logging from connection retrieval test --- .../non-saas-tests/non-saas-many-connections-e2e.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-many-connections-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-many-connections-e2e.test.ts index 5b83d4e71..60cae4b2c 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-many-connections-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-many-connections-e2e.test.ts @@ -61,9 +61,6 @@ test.serial( `> GET /connections > should return all connections without OOM when user has ${TOTAL_CONNECTIONS_COUNT} connections (${VALID_CONNECTIONS_COUNT} valid + ${INVALID_CONNECTIONS_COUNT} invalid)`, async (t) => { const { token, email, password } = await registerUserAndReturnUserInfo(app); - console.log('🚀 ~ token:\n', token, '\n'); - console.log('🚀 ~ password:', password); - console.log('🚀 ~ email:', email); const dataSource = app.get(BaseType.DATA_SOURCE); const connectionRepository = dataSource.getRepository(ConnectionEntity);