Skip to content

Commit 7c02292

Browse files
committed
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.
1 parent 1230ce8 commit 7c02292

16 files changed

Lines changed: 461 additions & 94 deletions

File tree

backend/src/entities/cedar-authorization/cedar-permissions.service.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,69 @@ export class CedarPermissionsService implements IUserAccessRepository {
5858
return AccessLevelEnum.none;
5959
}
6060

61+
async getUserConnectionAccessLevelsForMultipleConnections(
62+
userId: string,
63+
connectionIds: Array<string>,
64+
): Promise<Map<string, AccessLevelEnum>> {
65+
const result = new Map<string, AccessLevelEnum>();
66+
if (connectionIds.length === 0) return result;
67+
68+
const allGroups = await this.globalDbContext.groupRepository.findAllUserGroupsInConnections(connectionIds, userId);
69+
70+
const groupsByConnection = new Map<string, Array<GroupEntity>>();
71+
for (const group of allGroups) {
72+
const connId = group.connection?.id;
73+
if (!connId) continue;
74+
if (!groupsByConnection.has(connId)) {
75+
groupsByConnection.set(connId, []);
76+
}
77+
groupsByConnection.get(connId).push(group);
78+
}
79+
80+
for (const connectionId of connectionIds) {
81+
const userGroups = groupsByConnection.get(connectionId);
82+
if (!userGroups || userGroups.length === 0) {
83+
result.set(connectionId, AccessLevelEnum.none);
84+
continue;
85+
}
86+
87+
const policies = userGroups.map((g) => g.cedarPolicy).filter(Boolean);
88+
if (policies.length === 0) {
89+
result.set(connectionId, AccessLevelEnum.none);
90+
continue;
91+
}
92+
93+
const entities = buildCedarEntities(userId, userGroups, connectionId);
94+
if (
95+
this.evaluatePolicies(
96+
userId,
97+
CedarAction.ConnectionEdit,
98+
CedarResourceType.Connection,
99+
connectionId,
100+
policies,
101+
entities,
102+
)
103+
) {
104+
result.set(connectionId, AccessLevelEnum.edit);
105+
} else if (
106+
this.evaluatePolicies(
107+
userId,
108+
CedarAction.ConnectionRead,
109+
CedarResourceType.Connection,
110+
connectionId,
111+
policies,
112+
entities,
113+
)
114+
) {
115+
result.set(connectionId, AccessLevelEnum.readonly);
116+
} else {
117+
result.set(connectionId, AccessLevelEnum.none);
118+
}
119+
}
120+
121+
return result;
122+
}
123+
61124
async checkUserConnectionRead(cognitoUserName: string, connectionId: string): Promise<boolean> {
62125
const ctx = await this.loadContext(connectionId, cognitoUserName);
63126
if (!ctx) return false;

backend/src/entities/company-info/repository/company-info-custom-repository.extension.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Constants } from '../../../helpers/constants/constants.js';
22
import { ConnectionEntity } from '../../connection/connection.entity.js';
3+
import { decryptConnectionsCredentialsAsync } from '../../connection/utils/decrypt-connection-credentials-async.js';
34
import { CompanyInfoEntity } from '../company-info.entity.js';
45
import { ICompanyInfoRepository } from './company-info-repository.interface.js';
56

@@ -19,11 +20,15 @@ export const companyInfoRepositoryExtension: ICompanyInfoRepository = {
1920
},
2021

2122
async findOneCompanyInfoByUserIdWithConnections(userId: string): Promise<CompanyInfoEntity> {
22-
return await this.createQueryBuilder('company_info')
23+
const result = await this.createQueryBuilder('company_info')
2324
.leftJoinAndSelect('company_info.users', 'users')
2425
.leftJoinAndSelect('company_info.connections', 'connections')
2526
.where('users.id = :userId', { userId })
2627
.getOne();
28+
if (result?.connections?.length) {
29+
await decryptConnectionsCredentialsAsync(result.connections);
30+
}
31+
return result;
2732
},
2833

2934
async findCompanyInfoByUserId(userId: string): Promise<CompanyInfoEntity> {
@@ -55,7 +60,7 @@ export const companyInfoRepositoryExtension: ICompanyInfoRepository = {
5560

5661
// returns groups and connections where user is invited
5762
async findFullCompanyInfoByUserId(userId: string): Promise<CompanyInfoEntity> {
58-
return await this.createQueryBuilder('company_info')
63+
const result = await this.createQueryBuilder('company_info')
5964
.leftJoinAndSelect('company_info.logo', 'logo')
6065
.leftJoinAndSelect('company_info.favicon', 'favicon')
6166
.leftJoinAndSelect('company_info.tab_title', 'tab_title')
@@ -68,6 +73,10 @@ export const companyInfoRepositoryExtension: ICompanyInfoRepository = {
6873
.leftJoinAndSelect('groups.users', 'groups_users')
6974
.where('current_user.id = :userId', { userId })
7075
.getOne();
76+
if (result?.connections?.length) {
77+
await decryptConnectionsCredentialsAsync(result.connections);
78+
}
79+
return result;
7180
},
7281

7382
async findCompanyInfosByUserEmail(userEmail: string): Promise<CompanyInfoEntity[]> {
@@ -87,10 +96,12 @@ export const companyInfoRepositoryExtension: ICompanyInfoRepository = {
8796
.andWhere('connections.isTestConnection IS FALSE')
8897
.andWhere('connections.is_frozen IS FALSE')
8998
.getMany();
90-
return foundCompaniesWithPaidConnections
99+
const connections = foundCompaniesWithPaidConnections
91100
.map((companyInfo: CompanyInfoEntity) => companyInfo.connections)
92101
.filter(Boolean)
93102
.flat();
103+
await decryptConnectionsCredentialsAsync(connections);
104+
return connections;
94105
},
95106

96107
async findCompanyFrozenPaidConnections(companyIds: Array<string>): Promise<Array<ConnectionEntity>> {
@@ -102,10 +113,12 @@ export const companyInfoRepositoryExtension: ICompanyInfoRepository = {
102113
.andWhere('connections.isTestConnection IS FALSE')
103114
.andWhere('connections.is_frozen IS TRUE')
104115
.getMany();
105-
return foundCompaniesWithPaidConnections
116+
const connections = foundCompaniesWithPaidConnections
106117
.map((companyInfo: CompanyInfoEntity) => companyInfo.connections)
107118
.filter(Boolean)
108119
.flat();
120+
await decryptConnectionsCredentialsAsync(connections);
121+
return connections;
109122
},
110123

111124
async findCompanyWithLogo(companyId: string): Promise<CompanyInfoEntity> {

backend/src/entities/connection/connection.entity.ts

Lines changed: 12 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Expose } from 'class-transformer';
22
import { nanoid } from 'nanoid';
33
import {
4-
AfterLoad,
54
BeforeInsert,
65
BeforeUpdate,
76
Column,
@@ -14,7 +13,6 @@ import {
1413
Relation,
1514
} from 'typeorm';
1615
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';
17-
import { Constants } from '../../helpers/constants/constants.js';
1816
import { Encryptor } from '../../helpers/encryption/encryptor.js';
1917
import { isConnectionTypeAgent } from '../../helpers/index.js';
2018
import { AgentEntity } from '../agent/agent.entity.js';
@@ -118,9 +116,18 @@ export class ConnectionEntity {
118116
@Column({ default: null })
119117
master_hash?: string | null;
120118

119+
/**
120+
* Non-persisted flag indicating whether credentials are currently in decrypted state.
121+
* Used by @BeforeUpdate to decide whether encryption is needed.
122+
*/
123+
credentialsDecrypted = false;
124+
121125
@BeforeUpdate()
122126
updateTimestampEncryptCredentials(): void {
123127
this.updatedAt = new Date();
128+
if (!this.credentialsDecrypted) {
129+
return;
130+
}
124131
if (!isConnectionTypeAgent(this.type)) {
125132
this.host = Encryptor.encryptData(this.host);
126133
this.database = Encryptor.encryptData(this.database);
@@ -138,6 +145,7 @@ export class ConnectionEntity {
138145
this.cert = Encryptor.encryptData(this.cert);
139146
}
140147
}
148+
this.credentialsDecrypted = false;
141149
}
142150

143151
@BeforeInsert()
@@ -168,51 +176,8 @@ export class ConnectionEntity {
168176
}
169177
}
170178

171-
@AfterLoad()
172-
decryptCredentials(): void {
173-
if (this.isTestConnection) {
174-
const testConnectionsArray = Constants.getTestConnectionsArr();
175-
const foundTestConnectionByType = testConnectionsArray.find(
176-
(testConnection) => testConnection.type === this.type,
177-
);
178-
if (foundTestConnectionByType) {
179-
this.host = foundTestConnectionByType.host;
180-
this.database = foundTestConnectionByType.database;
181-
this.username = foundTestConnectionByType.username;
182-
this.password = foundTestConnectionByType.password;
183-
this.port = foundTestConnectionByType.port;
184-
this.ssh = foundTestConnectionByType.ssh;
185-
this.privateSSHKey = foundTestConnectionByType.privateSSHKey;
186-
this.sshHost = foundTestConnectionByType.sshHost;
187-
this.sshPort = foundTestConnectionByType.sshPort;
188-
this.sshUsername = foundTestConnectionByType.sshUsername;
189-
this.ssl = foundTestConnectionByType.ssl;
190-
this.cert = foundTestConnectionByType.cert;
191-
this.authSource = foundTestConnectionByType.authSource;
192-
this.sid = foundTestConnectionByType.sid;
193-
this.schema = foundTestConnectionByType.schema;
194-
this.azure_encryption = foundTestConnectionByType.azure_encryption;
195-
}
196-
} else {
197-
if (!isConnectionTypeAgent(this.type)) {
198-
this.host = Encryptor.decryptData(this.host);
199-
this.database = Encryptor.decryptData(this.database);
200-
this.password = Encryptor.decryptData(this.password);
201-
this.username = Encryptor.decryptData(this.username);
202-
if (this.authSource) {
203-
this.authSource = Encryptor.decryptData(this.authSource);
204-
}
205-
if (this.ssh) {
206-
this.privateSSHKey = Encryptor.decryptData(this.privateSSHKey);
207-
this.sshHost = Encryptor.decryptData(this.sshHost);
208-
this.sshUsername = Encryptor.decryptData(this.sshUsername);
209-
}
210-
if (this.ssl && this.cert) {
211-
this.cert = Encryptor.decryptData(this.cert);
212-
}
213-
}
214-
}
215-
}
179+
// Decryption moved to async utility: decrypt-connection-credentials-async.ts
180+
// All repository methods must call decryptConnectionCredentialsAsync() after loading.
216181

217182
@ManyToOne(
218183
(_) => UserEntity,

backend/src/entities/connection/repository/custom-connection-repository-extension.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { Encryptor } from '../../../helpers/encryption/encryptor.js';
55
import { isConnectionTypeAgent } from '../../../helpers/index.js';
66
import { UserEntity } from '../../user/user.entity.js';
77
import { ConnectionEntity } from '../connection.entity.js';
8+
import {
9+
decryptConnectionCredentialsAsync,
10+
decryptConnectionsCredentialsAsync,
11+
} from '../utils/decrypt-connection-credentials-async.js';
812
import { isTestConnectionUtil } from '../utils/is-test-connection-util.js';
913
import { IConnectionRepository } from './connection.repository.interface.js';
1014

@@ -26,6 +30,7 @@ export const customConnectionRepositoryExtension: IConnectionRepository &
2630
savedConnection.cert = this.decryptConnectionField(savedConnection.cert);
2731
}
2832
}
33+
savedConnection.credentialsDecrypted = true;
2934
return savedConnection;
3035
},
3136

@@ -38,7 +43,9 @@ export const customConnectionRepositoryExtension: IConnectionRepository &
3843
if (!includeTestConnections) {
3944
connectionQb.andWhere('connection.isTestConnection = :isTest', { isTest: false });
4045
}
41-
return await connectionQb.getMany();
46+
const connections = await connectionQb.getMany();
47+
await decryptConnectionsCredentialsAsync(connections);
48+
return connections;
4249
},
4350

4451
async findAllUserTestConnections(userId: string): Promise<Array<ConnectionEntity>> {
@@ -48,7 +55,9 @@ export const customConnectionRepositoryExtension: IConnectionRepository &
4855
.leftJoinAndSelect('connection.connection_properties', 'connection_properties')
4956
.andWhere('user.id = :userId', { userId: userId })
5057
.andWhere('connection.isTestConnection = :isTest', { isTest: true });
51-
return await connectionQb.getMany();
58+
const connections = await connectionQb.getMany();
59+
await decryptConnectionsCredentialsAsync(connections);
60+
return connections;
5261
},
5362

5463
async findAllUserNonTestsConnections(userId: string): Promise<Array<ConnectionEntity>> {
@@ -81,6 +90,7 @@ export const customConnectionRepositoryExtension: IConnectionRepository &
8190
connection.signing_key = Encryptor.generateRandomString(40);
8291
await this.save(connection);
8392
}
93+
await decryptConnectionCredentialsAsync(connection);
8494
return connection;
8595
},
8696

@@ -96,6 +106,7 @@ export const customConnectionRepositoryExtension: IConnectionRepository &
96106
connection.signing_key = Encryptor.generateRandomString(40);
97107
await this.save(connection);
98108
}
109+
await decryptConnectionCredentialsAsync(connection);
99110

100111
if (connection.masterEncryption && !masterPwd) {
101112
throw new Error(Messages.MASTER_PASSWORD_MISSING);
@@ -121,19 +132,24 @@ export const customConnectionRepositoryExtension: IConnectionRepository &
121132
const qb = this.createQueryBuilder('connection')
122133
.leftJoinAndSelect('connection.groups', 'group')
123134
.andWhere('connection.id = :connectionId', { connectionId: connectionId });
124-
return await qb.getOne();
135+
const connection = await qb.getOne();
136+
if (connection) {
137+
await decryptConnectionCredentialsAsync(connection);
138+
}
139+
return connection;
125140
},
126141

127142
async getWorkedConnectionsInTwoWeeks(): Promise<Array<ConnectionEntity>> {
128-
const freshNonTestConnectionsWithLogs = await this.createQueryBuilder('connection')
143+
const connections = await this.createQueryBuilder('connection')
129144
.leftJoinAndSelect('connection.author', 'author')
130145
.leftJoin('connection.logs', 'logs')
131146
.where('connection.createdAt > :date', { date: Constants.TWO_WEEKS_AGO() })
132147
.andWhere('author.gclid IS NOT NULL')
133148
.andWhere('connection.isTestConnection = :isTest', { isTest: false })
134149
.andWhere('logs.id IS NOT NULL')
135150
.getMany();
136-
return freshNonTestConnectionsWithLogs;
151+
await decryptConnectionsCredentialsAsync(connections);
152+
return connections;
137153
},
138154

139155
async getConnectionByGroupIdWithCompanyAndUsersInCompany(groupId: string): Promise<ConnectionEntity | null> {
@@ -142,17 +158,29 @@ export const customConnectionRepositoryExtension: IConnectionRepository &
142158
.leftJoinAndSelect('connection.company', 'company')
143159
.leftJoinAndSelect('company.users', 'user');
144160
qb.andWhere('group.id = :groupId', { groupId: groupId });
145-
return await qb.getOne();
161+
const connection = await qb.getOne();
162+
if (connection) {
163+
await decryptConnectionCredentialsAsync(connection);
164+
}
165+
return connection;
146166
},
147167

148168
async findOneById(connectionId: string): Promise<ConnectionEntity | null> {
149-
return await this.findOne({ where: { id: connectionId } });
169+
const connection = await this.findOne({ where: { id: connectionId } });
170+
if (connection) {
171+
await decryptConnectionCredentialsAsync(connection);
172+
}
173+
return connection;
150174
},
151175

152176
async findOneAgentConnectionByToken(connectionToken: string): Promise<ConnectionEntity | null> {
153177
const qb = this.createQueryBuilder('connection').leftJoinAndSelect('connection.agent', 'agent');
154178
qb.andWhere('agent.token = :agentToken', { agentToken: connectionToken });
155-
return await qb.getOne();
179+
const connection = await qb.getOne();
180+
if (connection) {
181+
await decryptConnectionCredentialsAsync(connection);
182+
}
183+
return connection;
156184
},
157185

158186
async isTestConnectionById(connectionId: string): Promise<boolean> {
@@ -179,13 +207,12 @@ export const customConnectionRepositoryExtension: IConnectionRepository &
179207

180208
async findAllCompanyUsersNonTestsConnections(companyId: string): Promise<Array<ConnectionEntity>> {
181209
const connectionQb = this.createQueryBuilder('connection')
182-
.leftJoin('connection.groups', 'group')
183-
.leftJoin('group.users', 'user')
184-
.leftJoin('user.company', 'company')
185210
.leftJoinAndSelect('connection.connection_properties', 'connection_properties')
186211
.where('connection.isTestConnection = :isTest', { isTest: false })
187-
.andWhere('company.id = :companyId', { companyId: companyId });
188-
return await connectionQb.getMany();
212+
.andWhere('connection.companyId = :companyId', { companyId: companyId });
213+
const connections = await connectionQb.getMany();
214+
await decryptConnectionsCredentialsAsync(connections);
215+
return connections;
189216
},
190217

191218
async freezeConnections(connectionsIds: Array<string>): Promise<void> {
@@ -210,7 +237,9 @@ export const customConnectionRepositoryExtension: IConnectionRepository &
210237
.where('user.id = :userId', { userId: userId })
211238
.andWhere('connection.isTestConnection = :isTest', { isTest: true })
212239
.andWhere('connection.company IS NULL');
213-
return await qb.getMany();
240+
const connections = await qb.getMany();
241+
await decryptConnectionsCredentialsAsync(connections);
242+
return connections;
214243
},
215244

216245
decryptConnectionField(field: string): string {

0 commit comments

Comments
 (0)