diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index 8ec57cb0ba..f0ce32270c 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -182,38 +182,36 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const dynamicSecretCfg = await server.services.dynamicSecret.updateByName({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - name: req.params.name, - path: req.body.path, - projectSlug: req.body.projectSlug, - environmentSlug: req.body.environmentSlug, - ...req.body.data - }); + const { dynamicSecret, updatedFields, projectId, environment, secretPath } = + await server.services.dynamicSecret.updateByName({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.params.name, + path: req.body.path, + projectSlug: req.body.projectSlug, + environmentSlug: req.body.environmentSlug, + ...req.body.data + }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: dynamicSecretCfg.projectId, + projectId, event: { type: EventType.UPDATE_DYNAMIC_SECRET, metadata: { - dynamicSecretName: dynamicSecretCfg.name, - newDynamicSecretName: req.body.data.newName, - dynamicSecretType: dynamicSecretCfg.type, - dynamicSecretId: dynamicSecretCfg.id, - newDefaultTTL: req.body.data.defaultTTL, - newMaxTTL: req.body.data.maxTTL, - newUsernameTemplate: req.body.data.usernameTemplate, - environment: dynamicSecretCfg.environment, - secretPath: dynamicSecretCfg.secretPath, - projectId: dynamicSecretCfg.projectId + dynamicSecretName: dynamicSecret.name, + dynamicSecretType: dynamicSecret.type, + dynamicSecretId: dynamicSecret.id, + environment, + secretPath, + projectId, + updatedFields } } }); - return { dynamicSecret: dynamicSecretCfg }; + return { dynamicSecret }; } }); @@ -428,7 +426,6 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => environment, secretPath, projectId, - leaseCount: leases.length } } diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 4f4f429829..8d82d6f1ce 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -4594,10 +4594,7 @@ interface UpdateDynamicSecretEvent { dynamicSecretName: string; dynamicSecretId: string; dynamicSecretType: string; - newDynamicSecretName?: string; - newDefaultTTL?: string; - newMaxTTL?: string | null; - newUsernameTemplate?: string | null; + updatedFields: string[]; environment: string; secretPath: string; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 600e1d2a65..4da278c090 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -9,6 +9,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { extractObjectFieldPaths } from "@app/lib/fn"; import { OrderByDirection } from "@app/lib/types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; @@ -44,6 +45,34 @@ type TDynamicSecretServiceFactoryDep = { resourceMetadataDAL: Pick; }; +const getUpdatedFieldPaths = ( + oldData: Record | null | undefined, + newData: Record | null | undefined +): string[] => { + const updatedPaths = new Set(); + + if (!newData || typeof newData !== "object") { + return []; + } + + if (!oldData || typeof oldData !== "object") { + return []; + } + + Object.keys(newData).forEach((key) => { + const oldValue = oldData?.[key]; + const newValue = newData[key]; + + if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { + // Extract paths from the new value + const paths = extractObjectFieldPaths(newValue, key); + paths.forEach((path) => updatedPaths.add(path)); + } + }); + + return Array.from(updatedPaths).sort(); +}; + export const dynamicSecretServiceFactory = ({ dynamicSecretDAL, dynamicSecretLeaseDAL, @@ -284,8 +313,26 @@ export const dynamicSecretServiceFactory = ({ secretManagerDecryptor({ cipherTextBlob: dynamicSecretCfg.encryptedInput }).toString() ) as object; const newInput = { ...decryptedStoredInput, ...(inputs || {}) }; + const oldInput = await selectedProvider.validateProviderInputs(decryptedStoredInput, { projectId }); const updatedInput = await selectedProvider.validateProviderInputs(newInput, { projectId }); + const updatedFields = getUpdatedFieldPaths( + { + ...(oldInput as object), + maxTTL: dynamicSecretCfg.maxTTL, + defaultTTL: dynamicSecretCfg.defaultTTL, + name: dynamicSecretCfg.name, + usernameTemplate + }, + { + ...(updatedInput as object), + maxTTL, + defaultTTL, + name: newName ?? name, + usernameTemplate + } + ); + let selectedGatewayId: string | null = null; let isGatewayV1 = true; if (updatedInput && typeof updatedInput === "object" && "gatewayId" in updatedInput && updatedInput?.gatewayId) { @@ -364,8 +411,8 @@ export const dynamicSecretServiceFactory = ({ }); return { - ...updatedDynamicCfg, - inputs: updatedInput, + dynamicSecret: updatedDynamicCfg, + updatedFields, projectId: project.id, environment: environmentSlug, secretPath: path diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts index b8dc999f1d..d5fa291b5a 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts @@ -89,9 +89,13 @@ export type TDynamicSecretServiceFactory = { create: ( arg: TCreateDynamicSecretDTO ) => Promise; - updateByName: ( - arg: TUpdateDynamicSecretDTO - ) => Promise; + updateByName: (arg: TUpdateDynamicSecretDTO) => Promise<{ + dynamicSecret: TDynamicSecrets; + updatedFields: string[]; + projectId: string; + environment: string; + secretPath: string; + }>; deleteByName: ( arg: TDeleteDynamicSecretDTO ) => Promise; diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index c437e484aa..c40e5c53c0 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -134,3 +134,67 @@ export const deterministicStringify = (value: unknown): string => { return JSON.stringify(value); }; + +/** + * Recursively extracts all field paths from a nested object structure. + * Returns an array of dot-notation paths (e.g., ["password", "username", "field.nestedField"]) + */ +export const extractObjectFieldPaths = (obj: unknown, prefix = ""): string[] => { + const paths: string[] = []; + + if (obj === null || obj === undefined) { + return paths; + } + + if (typeof obj !== "object") { + // return the path if it exists + if (prefix) { + paths.push(prefix); + } + return paths; + } + + if (Array.isArray(obj)) { + // for arrays, we log the array itself and optionally nested paths + if (prefix) { + paths.push(prefix); + } + // we just want to know the array field changed + obj.forEach((item, index) => { + if (typeof item === "object" && item !== null) { + const nestedPaths = extractObjectFieldPaths(item, `${prefix}[${index}]`); + paths.push(...nestedPaths); + } + }); + return paths; + } + + // for objects, extract all keys and recurse + const keys = Object.keys(obj); + if (keys.length === 0 && prefix) { + // empty object with prefix + paths.push(prefix); + } + + keys.forEach((key) => { + const currentPath = prefix ? `${prefix}.${key}` : key; + const value = (obj as Record)[key]; + + if (value === null || value === undefined) { + paths.push(currentPath); + } else if (typeof value === "object") { + // recurse into nested objects/arrays + const nestedPaths = extractObjectFieldPaths(value, currentPath); + if (nestedPaths.length === 0) { + // if nested object is empty, add the path itself + paths.push(currentPath); + } else { + paths.push(...nestedPaths); + } + } else { + paths.push(currentPath); + } + }); + + return paths; +};