diff --git a/backend/src/db/migrations/20251028064623_pam-account-rotation-status.ts b/backend/src/db/migrations/20251028064623_pam-account-rotation-status.ts new file mode 100644 index 000000000..b3ad123e8 --- /dev/null +++ b/backend/src/db/migrations/20251028064623_pam-account-rotation-status.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.PamAccount, "rotationStatus"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.string("rotationStatus").nullable(); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamAccount, "encryptedLastRotationMessage"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.binary("encryptedLastRotationMessage").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.PamAccount, "rotationStatus")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("rotationStatus"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "encryptedLastRotationMessage")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("encryptedLastRotationMessage"); + }); + } +} diff --git a/backend/src/db/schemas/pam-accounts.ts b/backend/src/db/schemas/pam-accounts.ts index 7e78e0874..4f097a16d 100644 --- a/backend/src/db/schemas/pam-accounts.ts +++ b/backend/src/db/schemas/pam-accounts.ts @@ -21,7 +21,9 @@ export const PamAccountsSchema = z.object({ updatedAt: z.date(), rotationEnabled: z.boolean().default(false), rotationIntervalSeconds: z.number().nullable().optional(), - lastRotatedAt: z.date().nullable().optional() + lastRotatedAt: z.date().nullable().optional(), + rotationStatus: z.string().nullable().optional(), + encryptedLastRotationMessage: zodBuffer.nullable().optional() }); export type TPamAccounts = z.infer; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index c6c0afcca..821532598 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -1,14 +1,14 @@ +import { + CreateMySQLResourceSchema, + MySQLResourceSchema, + UpdateMySQLResourceSchema +} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { CreatePostgresResourceSchema, SanitizedPostgresResourceSchema, UpdatePostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; -import { - CreateMySQLResourceSchema, - MySQLResourceSchema, - UpdateMySQLResourceSchema -} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { registerPamResourceEndpoints } from "./pam-resource-endpoints"; diff --git a/backend/src/ee/services/pam-account/pam-account-fns.ts b/backend/src/ee/services/pam-account/pam-account-fns.ts index fdc440991..aae703eeb 100644 --- a/backend/src/ee/services/pam-account/pam-account-fns.ts +++ b/backend/src/ee/services/pam-account/pam-account-fns.ts @@ -45,17 +45,47 @@ export const decryptAccountCredentials = async ({ return JSON.parse(decryptedPlainTextBlob.toString()) as TPamAccountCredentials; }; -export const decryptAccount = async ( +export const decryptAccountMessage = async ({ + projectId, + encryptedMessage, + kmsService +}: { + projectId: string; + encryptedMessage: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedMessage + }); + + return decryptedPlainTextBlob.toString(); +}; + +export const decryptAccount = async < + T extends { encryptedCredentials: Buffer; encryptedLastRotationMessage?: Buffer | null } +>( account: T, projectId: string, kmsService: Pick -): Promise => { +): Promise => { return { ...account, credentials: await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, projectId, kmsService - }) - } as T & { credentials: TPamAccountCredentials }; + }), + lastRotationMessage: account.encryptedLastRotationMessage + ? await decryptAccountMessage({ + encryptedMessage: account.encryptedLastRotationMessage, + projectId, + kmsService + }) + : null + }; }; diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index fd3615013..00b84943f 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -15,6 +15,7 @@ import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -353,6 +354,7 @@ export const pamAccountServiceFactory = ({ TPamAccounts & { resource: Pick & { rotationCredentialsConfigured: boolean }; credentials: TPamAccountCredentials; + lastRotationMessage: string | null; } > = []; @@ -376,6 +378,7 @@ export const pamAccountServiceFactory = ({ ) { // Decrypt the account only if the user has permission to read it const decryptedAccount = await decryptAccount(account, account.projectId, kmsService); + decryptedAndPermittedAccounts.push({ ...decryptedAccount, resource: { @@ -575,10 +578,10 @@ export const pamAccountServiceFactory = ({ for (let i = 0; i < accounts.length; i += ROTATION_CONCURRENCY_LIMIT) { const batch = accounts.slice(i, i + ROTATION_CONCURRENCY_LIMIT); - const rotationPromises = batch.map(async (account) => - pamAccountDAL.transaction(async (tx) => { - let logResourceType = "unknown"; - try { + const rotationPromises = batch.map(async (account) => { + let logResourceType = "unknown"; + try { + await pamAccountDAL.transaction(async (tx) => { const resource = await pamResourceDAL.findById(account.resourceId, tx); if (!resource || !resource.encryptedRotationAccountCredentials) return; logResourceType = resource.resourceType; @@ -619,7 +622,9 @@ export const pamAccountServiceFactory = ({ account.id, { encryptedCredentials, - lastRotatedAt: new Date() + lastRotatedAt: new Date(), + rotationStatus: "success", + encryptedLastRotationMessage: null }, tx ); @@ -640,32 +645,45 @@ export const pamAccountServiceFactory = ({ } } }); - } catch (error) { - logger.error(error, `Failed to rotate credentials for account [accountId=${account.id}]`); + }); + } catch (error) { + logger.error(error, `Failed to rotate credentials for account [accountId=${account.id}]`); - const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; + const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; - await auditLogService.createAuditLog({ - projectId: account.projectId, - actor: { - type: ActorType.PLATFORM, - metadata: {} - }, - event: { - type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED, - metadata: { - accountId: account.id, - accountName: account.name, - resourceId: account.resourceId, - resourceType: logResourceType, - errorMessage - } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: account.projectId + }); + + const { cipherTextBlob: encryptedMessage } = encryptor({ + plainText: Buffer.from(errorMessage) + }); + + await pamAccountDAL.updateById(account.id, { + rotationStatus: "failed", + encryptedLastRotationMessage: encryptedMessage + }); + + await auditLogService.createAuditLog({ + projectId: account.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED, + metadata: { + accountId: account.id, + accountName: account.name, + resourceId: account.resourceId, + resourceType: logResourceType, + errorMessage } - }); - throw error; // Rollback transaction - } - }) - ); + } + }); + } + }); // eslint-disable-next-line no-await-in-loop await Promise.all(rotationPromises); diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts index 7f6165d88..17ed1ccd1 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -33,7 +33,9 @@ export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({ resourceType: true }).extend({ rotationCredentialsConfigured: z.boolean() - }) + }), + lastRotationMessage: z.string().nullable().optional(), + rotationStatus: z.string().nullable().optional() }); export const BaseCreatePamAccountSchema = z.object({ diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts index a171b39b5..99ce2d25f 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -41,7 +41,10 @@ export interface SqlResourceConnection { * * @returns Promise to be resolved with the new credentials */ - rotateCredentials: (currentCredentials: TSqlAccountCredentials) => Promise; + rotateCredentials: ( + currentCredentials: TSqlAccountCredentials, + newPassword: string + ) => Promise; /** * Close the connection. @@ -113,8 +116,7 @@ const makeSqlConnection = ( }); } }, - rotateCredentials: async (currentCredentials) => { - const newPassword = alphaNumericNanoId(32); + rotateCredentials: async (currentCredentials, newPassword) => { // Note: The generated random password is not really going to make SQL Injection possible. // The reason we are not using parameters binding is that the "ALTER USER" syntax is DDL, // parameters binding is not supported. But just in case if the this code got copied @@ -295,6 +297,7 @@ export const sqlResourceFactory: TPamResourceFactory { + const newPassword = alphaNumericNanoId(32); try { return await executeWithGateway( { @@ -305,7 +308,7 @@ export const sqlResourceFactory: TPamResourceFactory client.rotateCredentials(currentCredentials) + (client) => client.rotateCredentials(currentCredentials, newPassword) ); } catch (error) { if (error instanceof BadRequestError) { @@ -328,8 +331,10 @@ export const sqlResourceFactory: TPamResourceFactory )} {account.lastRotatedAt && ( - - - Rotated {formatDistance(new Date(), account.lastRotatedAt)} ago - + + + + Rotated {formatDistance(new Date(), account.lastRotatedAt)} ago + + )}