From c86f66ab981aafa4b6365a7661bad0685f367ad5 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 25 Mar 2026 21:48:33 +0000 Subject: [PATCH 1/2] feat: add hosted database password update webhook endpoint Add POST /saas/connection/hosted/password endpoint that rocketadmin-saas calls when a hosted database password is reset, to update the stored connection credentials in the backend. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/common/data-injection.tokens.ts | 1 + .../update-hosted-connection-password.dto.ts | 29 +++++++++++++ .../saas-microservice/saas.controller.ts | 23 ++++++++-- .../saas-microservice/saas.module.ts | 10 ++++- .../use-cases/saas-use-cases.interface.ts | 5 +++ ...ate-hosted-connection-password.use.case.ts | 43 +++++++++++++++++++ 6 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 backend/src/microservices/saas-microservice/data-structures/update-hosted-connection-password.dto.ts create mode 100644 backend/src/microservices/saas-microservice/use-cases/update-hosted-connection-password.use.case.ts diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index 68acf0f62..b2d3cc3df 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -116,6 +116,7 @@ export enum UseCaseType { SAAS_REGISTER_USER_WITH_SAML = 'SAAS_REGISTER_USER_WITH_SAML', SAAS_CREATE_CONNECTION_FOR_HOSTED_DB = 'SAAS_CREATE_CONNECTION_FOR_HOSTED_DB', SAAS_DELETE_CONNECTION_FOR_HOSTED_DB = 'SAAS_DELETE_CONNECTION_FOR_HOSTED_DB', + SAAS_UPDATE_HOSTED_CONNECTION_PASSWORD = 'SAAS_UPDATE_HOSTED_CONNECTION_PASSWORD', INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP = 'INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP', VERIFY_INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP = 'VERIFY_INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP', diff --git a/backend/src/microservices/saas-microservice/data-structures/update-hosted-connection-password.dto.ts b/backend/src/microservices/saas-microservice/data-structures/update-hosted-connection-password.dto.ts new file mode 100644 index 000000000..5b3305b62 --- /dev/null +++ b/backend/src/microservices/saas-microservice/data-structures/update-hosted-connection-password.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; + +export class UpdateHostedConnectionPasswordDto { + @ApiProperty({ + description: 'Company ID', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsNotEmpty() + @IsString() + @IsUUID() + companyId: string; + + @ApiProperty({ + description: 'Database name', + example: 'my_database', + }) + @IsNotEmpty() + @IsString() + databaseName: string; + + @ApiProperty({ + description: 'New database password', + example: 'new_secure_password', + }) + @IsNotEmpty() + @IsString() + password: string; +} diff --git a/backend/src/microservices/saas-microservice/saas.controller.ts b/backend/src/microservices/saas-microservice/saas.controller.ts index 32263dd57..b4eed8fbb 100644 --- a/backend/src/microservices/saas-microservice/saas.controller.ts +++ b/backend/src/microservices/saas-microservice/saas.controller.ts @@ -16,6 +16,7 @@ import { SkipThrottle } from '@nestjs/throttler'; import { UseCaseType } from '../../common/data-injection.tokens.js'; import { Timeout } from '../../decorators/timeout.decorator.js'; import { CompanyInfoEntity } from '../../entities/company-info/company-info.entity.js'; +import { CreatedConnectionDTO } from '../../entities/connection/application/dto/created-connection.dto.js'; import { SaasUsualUserRegisterDS } from '../../entities/user/application/data-structures/usual-register-user.ds.js'; import { FoundUserDto } from '../../entities/user/dto/found-user.dto.js'; import { ExternalRegistrationProviderEnum } from '../../entities/user/enums/external-registration-provider.enum.js'; @@ -24,11 +25,14 @@ import { InTransactionEnum } from '../../enums/in-transaction.enum.js'; import { Messages } from '../../exceptions/text/messages.js'; import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js'; import { SuccessResponse } from './data-structures/common-responce.ds.js'; +import { CreateConnectionForHostedDbDto } from './data-structures/create-connecttion-for-selfhosted-db.dto.js'; +import { DeleteConnectionForHostedDbDto } from './data-structures/delete-connection-for-hosted-db.dto.js'; import { RegisterCompanyWebhookDS } from './data-structures/register-company.ds.js'; import { RegisteredCompanyDS } from './data-structures/registered-company.ds.js'; import { SaasRegisterUserWithGithub } from './data-structures/saas-register-user-with-github.js'; import { SaasSAMLUserRegisterDS } from './data-structures/saas-saml-user-register.ds.js'; import { SaasRegisterUserWithGoogleDS } from './data-structures/sass-register-user-with-google.js'; +import { UpdateHostedConnectionPasswordDto } from './data-structures/update-hosted-connection-password.dto.js'; import { ICompanyRegistration, ICreateConnectionForHostedDb, @@ -45,10 +49,8 @@ import { ISaasSAMLRegisterUser, ISuspendUsers, ISuspendUsersOverLimit, + IUpdateHostedConnectionPassword, } from './use-cases/saas-use-cases.interface.js'; -import { CreatedConnectionDTO } from '../../entities/connection/application/dto/created-connection.dto.js'; -import { CreateConnectionForHostedDbDto } from './data-structures/create-connecttion-for-selfhosted-db.dto.js'; -import { DeleteConnectionForHostedDbDto } from './data-structures/delete-connection-for-hosted-db.dto.js'; @UseInterceptors(SentryInterceptor) @SkipThrottle() @@ -91,6 +93,8 @@ export class SaasController { private readonly createConnectionForHostedDbUseCase: ICreateConnectionForHostedDb, @Inject(UseCaseType.SAAS_DELETE_CONNECTION_FOR_HOSTED_DB) private readonly deleteConnectionForHostedDbUseCase: IDeleteConnectionForHostedDb, + @Inject(UseCaseType.SAAS_UPDATE_HOSTED_CONNECTION_PASSWORD) + private readonly updateHostedConnectionPasswordUseCase: IUpdateHostedConnectionPassword, ) {} @ApiOperation({ summary: 'Company registered webhook' }) @@ -309,4 +313,17 @@ export class SaasController { ): Promise { return await this.deleteConnectionForHostedDbUseCase.execute(deleteConnectionData); } + + @ApiOperation({ summary: 'Update password of hosted database connection' }) + @ApiBody({ type: UpdateHostedConnectionPasswordDto }) + @ApiResponse({ + status: 201, + type: SuccessResponse, + }) + @Post('/connection/hosted/password') + async updateHostedConnectionPassword( + @Body() updatePasswordData: UpdateHostedConnectionPasswordDto, + ): Promise { + return await this.updateHostedConnectionPasswordUseCase.execute(updatePasswordData); + } } diff --git a/backend/src/microservices/saas-microservice/saas.module.ts b/backend/src/microservices/saas-microservice/saas.module.ts index 3c47d5289..88b28bbb7 100644 --- a/backend/src/microservices/saas-microservice/saas.module.ts +++ b/backend/src/microservices/saas-microservice/saas.module.ts @@ -7,6 +7,8 @@ import { UserEntity } from '../../entities/user/user.entity.js'; import { SignInAuditEntity } from '../../entities/user-sign-in-audit/sign-in-audit.entity.js'; import { SignInAuditService } from '../../entities/user-sign-in-audit/sign-in-audit.service.js'; import { SaasController } from './saas.controller.js'; +import { CreateConnectionForHostedDbUseCase } from './use-cases/create-connection-for-hosted-db.use.case.js'; +import { DeleteConnectionForHostedDbUseCase } from './use-cases/delete-connection-for-hosted-db.use.case.js'; import { FreezeConnectionsInCompanyUseCase } from './use-cases/freeze-connections-in-company.use.case.js'; import { GetFullCompanyInfoByUserIdUseCase } from './use-cases/get-full-company-info-by-user-id.use.case.js'; import { GetUserInfoUseCase } from './use-cases/get-user-info.use.case.js'; @@ -20,9 +22,8 @@ import { SaaSRegisterUserWIthSamlUseCase } from './use-cases/register-user-with- import { SaasUsualRegisterUseCase } from './use-cases/saas-usual-register-user.use.case.js'; import { SuspendUsersUseCase } from './use-cases/suspend-users.use.case.js'; import { SuspendUsersOverLimitUseCase } from './use-cases/suspend-users-over-limit.use.case.js'; -import { CreateConnectionForHostedDbUseCase } from './use-cases/create-connection-for-hosted-db.use.case.js'; -import { DeleteConnectionForHostedDbUseCase } from './use-cases/delete-connection-for-hosted-db.use.case.js'; import { UnFreezeConnectionsInCompanyUseCase } from './use-cases/unfreeze-connections-in-company-use.case.js'; +import { UpdateHostedConnectionPasswordUseCase } from './use-cases/update-hosted-connection-password.use.case.js'; @Module({ imports: [TypeOrmModule.forFeature([SignInAuditEntity, UserEntity])], @@ -95,6 +96,10 @@ import { UnFreezeConnectionsInCompanyUseCase } from './use-cases/unfreeze-connec provide: UseCaseType.SAAS_DELETE_CONNECTION_FOR_HOSTED_DB, useClass: DeleteConnectionForHostedDbUseCase, }, + { + provide: UseCaseType.SAAS_UPDATE_HOSTED_CONNECTION_PASSWORD, + useClass: UpdateHostedConnectionPasswordUseCase, + }, SignInAuditService, ], controllers: [SaasController], @@ -120,6 +125,7 @@ export class SaasModule { { path: 'saas/user/saml/login', method: RequestMethod.POST }, { path: 'saas/connection/hosted', method: RequestMethod.POST }, { path: 'saas/connection/hosted/delete', method: RequestMethod.POST }, + { path: 'saas/connection/hosted/password', method: RequestMethod.POST }, ); } } diff --git a/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts b/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts index bbf223d9d..9a96ccabe 100644 --- a/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts +++ b/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts @@ -17,6 +17,7 @@ import { SaasRegisterUserWithGithub } from '../data-structures/saas-register-use import { SaasSAMLUserRegisterDS } from '../data-structures/saas-saml-user-register.ds.js'; import { SaasRegisterUserWithGoogleDS } from '../data-structures/sass-register-user-with-google.js'; import { SuspendUsersDS } from '../data-structures/suspend-users.ds.js'; +import { UpdateHostedConnectionPasswordDto } from '../data-structures/update-hosted-connection-password.dto.js'; export interface ICompanyRegistration { execute(inputData: RegisterCompanyWebhookDS): Promise; @@ -77,3 +78,7 @@ export interface ICreateConnectionForHostedDb { export interface IDeleteConnectionForHostedDb { execute(inputData: DeleteConnectionForHostedDbDto): Promise; } + +export interface IUpdateHostedConnectionPassword { + execute(inputData: UpdateHostedConnectionPasswordDto): Promise; +} 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 new file mode 100644 index 000000000..91bebd191 --- /dev/null +++ b/backend/src/microservices/saas-microservice/use-cases/update-hosted-connection-password.use.case.ts @@ -0,0 +1,43 @@ +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 { 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'; +import { IUpdateHostedConnectionPassword } from './saas-use-cases.interface.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class UpdateHostedConnectionPasswordUseCase + extends AbstractUseCase + implements IUpdateHostedConnectionPassword +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation(inputData: UpdateHostedConnectionPasswordDto): Promise { + const { companyId, databaseName, password } = inputData; + + const foundCompany = + await this._dbContext.companyInfoRepository.findCompanyInfoByCompanyIdWithoutConnections(companyId); + if (!foundCompany) { + throw new NotFoundException(Messages.COMPANY_NOT_FOUND); + } + + const connection = await this._dbContext.connectionRepository.findOne({ + where: { company: { id: companyId }, database: databaseName }, + }); + if (!connection) { + throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); + } + + connection.password = password; + await this._dbContext.connectionRepository.saveUpdatedConnection(connection); + + return { success: true }; + } +} From 6c61cf97850b601d7dc6772953c47ff912742a2e Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 26 Mar 2026 08:39:49 +0000 Subject: [PATCH 2/2] fix: fix encrypted field lookup in password update and return connectionId from create endpoint The database column is stored encrypted and only decrypted in @AfterLoad, so querying by plaintext value would never match. Now fetches connections by company and filters by decrypted database name in code. Also simplifies create hosted connection response to return just the connectionId. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../data-structures/common-responce.ds.ts | 5 +++ .../saas-microservice/saas.controller.ts | 6 +-- ...reate-connection-for-hosted-db.use.case.ts | 41 ++++++++----------- .../use-cases/saas-use-cases.interface.ts | 4 +- ...ate-hosted-connection-password.use.case.ts | 5 ++- 5 files changed, 29 insertions(+), 32 deletions(-) diff --git a/backend/src/microservices/saas-microservice/data-structures/common-responce.ds.ts b/backend/src/microservices/saas-microservice/data-structures/common-responce.ds.ts index 88594c82d..4a51be5e7 100644 --- a/backend/src/microservices/saas-microservice/data-structures/common-responce.ds.ts +++ b/backend/src/microservices/saas-microservice/data-structures/common-responce.ds.ts @@ -4,3 +4,8 @@ export class SuccessResponse { @ApiProperty() success: boolean; } + +export class CreatedConnectionResponse { + @ApiProperty() + connectionId: string; +} diff --git a/backend/src/microservices/saas-microservice/saas.controller.ts b/backend/src/microservices/saas-microservice/saas.controller.ts index b4eed8fbb..f9f876ff9 100644 --- a/backend/src/microservices/saas-microservice/saas.controller.ts +++ b/backend/src/microservices/saas-microservice/saas.controller.ts @@ -24,7 +24,7 @@ import { UserEntity } from '../../entities/user/user.entity.js'; import { InTransactionEnum } from '../../enums/in-transaction.enum.js'; import { Messages } from '../../exceptions/text/messages.js'; import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js'; -import { SuccessResponse } from './data-structures/common-responce.ds.js'; +import { CreatedConnectionResponse, SuccessResponse } from './data-structures/common-responce.ds.js'; import { CreateConnectionForHostedDbDto } from './data-structures/create-connecttion-for-selfhosted-db.dto.js'; import { DeleteConnectionForHostedDbDto } from './data-structures/delete-connection-for-hosted-db.dto.js'; import { RegisterCompanyWebhookDS } from './data-structures/register-company.ds.js'; @@ -292,12 +292,12 @@ export class SaasController { @ApiBody({ type: CreateConnectionForHostedDbDto }) @ApiResponse({ status: 201, - type: CreatedConnectionDTO, + type: CreatedConnectionResponse, }) @Post('/connection/hosted') async createConnectionForHostedDb( @Body() connectionData: CreateConnectionForHostedDbDto, - ): Promise { + ): Promise { return await this.createConnectionForHostedDbUseCase.execute(connectionData); } diff --git a/backend/src/microservices/saas-microservice/use-cases/create-connection-for-hosted-db.use.case.ts b/backend/src/microservices/saas-microservice/use-cases/create-connection-for-hosted-db.use.case.ts index 517b1670d..04fdd1081 100644 --- a/backend/src/microservices/saas-microservice/use-cases/create-connection-for-hosted-db.use.case.ts +++ b/backend/src/microservices/saas-microservice/use-cases/create-connection-for-hosted-db.use.case.ts @@ -4,20 +4,19 @@ import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/en 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 { Messages } from '../../../exceptions/text/messages.js'; -import { slackPostMessage } from '../../../helpers/index.js'; -import { AccessLevelEnum } from '../../../enums/index.js'; import { generateCedarPolicyForGroup } from '../../../entities/cedar-authorization/cedar-policy-generator.js'; -import { CreatedConnectionDTO } from '../../../entities/connection/application/dto/created-connection.dto.js'; import { ConnectionEntity } from '../../../entities/connection/connection.entity.js'; import { readSslCertificate } from '../../../entities/connection/ssl-certificate/read-certificate.js'; -import { buildCreatedConnectionDs } from '../../../entities/connection/utils/build-created-connection.ds.js'; +import { AccessLevelEnum } from '../../../enums/index.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { slackPostMessage } from '../../../helpers/index.js'; +import { CreatedConnectionResponse } from '../data-structures/common-responce.ds.js'; import { CreateConnectionForHostedDbDto } from '../data-structures/create-connecttion-for-selfhosted-db.dto.js'; import { ICreateConnectionForHostedDb } from './saas-use-cases.interface.js'; @Injectable({ scope: Scope.REQUEST }) export class CreateConnectionForHostedDbUseCase - extends AbstractUseCase + extends AbstractUseCase implements ICreateConnectionForHostedDb { constructor( @@ -27,7 +26,7 @@ export class CreateConnectionForHostedDbUseCase super(); } - protected async implementation(inputData: CreateConnectionForHostedDbDto): Promise { + protected async implementation(inputData: CreateConnectionForHostedDbDto): Promise { const { companyId, userId, databaseName, hostname, port, username, password } = inputData; const connectionAuthor = await this._dbContext.userRepository.findOneUserById(userId); @@ -35,9 +34,7 @@ export class CreateConnectionForHostedDbUseCase throw new InternalServerErrorException(Messages.USER_NOT_FOUND); } - await slackPostMessage( - Messages.USER_TRY_CREATE_CONNECTION(connectionAuthor.email, ConnectionTypesEnum.postgres), - ); + await slackPostMessage(Messages.USER_TRY_CREATE_CONNECTION(connectionAuthor.email, ConnectionTypesEnum.postgres)); const cert = await readSslCertificate(); @@ -85,21 +82,18 @@ export class CreateConnectionForHostedDbUseCase savedConnection, connectionAuthor, ); - createdAdminGroup.cedarPolicy = generateCedarPolicyForGroup( - savedConnection.id, - true, - { - connection: { connectionId: savedConnection.id, accessLevel: AccessLevelEnum.edit }, - group: { groupId: createdAdminGroup.id, accessLevel: AccessLevelEnum.edit }, - tables: [], - }, - ); + createdAdminGroup.cedarPolicy = generateCedarPolicyForGroup(savedConnection.id, true, { + connection: { connectionId: savedConnection.id, accessLevel: AccessLevelEnum.edit }, + group: { groupId: createdAdminGroup.id, accessLevel: AccessLevelEnum.edit }, + tables: [], + }); await this._dbContext.groupRepository.saveNewOrUpdatedGroup(createdAdminGroup); delete createdAdminGroup.connection; await this._dbContext.userRepository.saveUserEntity(connectionAuthor); savedConnection.groups = [createdAdminGroup]; - const foundCompany = await this._dbContext.companyInfoRepository.findCompanyInfoByCompanyIdWithoutConnections(companyId); + const foundCompany = + await this._dbContext.companyInfoRepository.findCompanyInfoByCompanyIdWithoutConnections(companyId); if (foundCompany) { const connectionToUpdate = await this._dbContext.connectionRepository.findOne({ where: { id: savedConnection.id }, @@ -108,11 +102,8 @@ export class CreateConnectionForHostedDbUseCase await this._dbContext.connectionRepository.saveUpdatedConnection(connectionToUpdate); } - await slackPostMessage( - Messages.USER_CREATED_CONNECTION(connectionAuthor.email, ConnectionTypesEnum.postgres), - ); + await slackPostMessage(Messages.USER_CREATED_CONNECTION(connectionAuthor.email, ConnectionTypesEnum.postgres)); - const connectionRO = buildCreatedConnectionDs(savedConnection, null, null); - return connectionRO; + return { connectionId: savedConnection.id }; } } diff --git a/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts b/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts index 9a96ccabe..40b996e4e 100644 --- a/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts +++ b/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts @@ -5,7 +5,7 @@ import { SaasUsualUserRegisterDS } from '../../../entities/user/application/data import { FoundUserDto } from '../../../entities/user/dto/found-user.dto.js'; import { UserEntity } from '../../../entities/user/user.entity.js'; import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; -import { SuccessResponse } from '../data-structures/common-responce.ds.js'; +import { CreatedConnectionResponse, SuccessResponse } from '../data-structures/common-responce.ds.js'; import { CreateConnectionForHostedDbDto } from '../data-structures/create-connecttion-for-selfhosted-db.dto.js'; import { DeleteConnectionForHostedDbDto } from '../data-structures/delete-connection-for-hosted-db.dto.js'; import { FreezeConnectionsInCompanyDS } from '../data-structures/freeze-connections-in-company.ds.js'; @@ -72,7 +72,7 @@ export interface ISaasSAMLRegisterUser { } export interface ICreateConnectionForHostedDb { - execute(inputData: CreateConnectionForHostedDbDto): Promise; + execute(inputData: CreateConnectionForHostedDbDto): Promise; } export interface IDeleteConnectionForHostedDb { 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 91bebd191..76beba9db 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 @@ -28,9 +28,10 @@ export class UpdateHostedConnectionPasswordUseCase throw new NotFoundException(Messages.COMPANY_NOT_FOUND); } - const connection = await this._dbContext.connectionRepository.findOne({ - where: { company: { id: companyId }, database: databaseName }, + const companyConnections = await this._dbContext.connectionRepository.find({ + where: { company: { id: companyId } }, }); + const connection = companyConnections.find((conn) => conn.database === databaseName); if (!connection) { throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); }