diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 69a7d2129..84041d172 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -26,6 +26,7 @@ import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-con import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TPitServiceFactory } from "@app/ee/services/pit/pit-service"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; @@ -270,6 +271,7 @@ declare module "fastify" { assumePrivileges: TAssumePrivilegeServiceFactory; githubOrgSync: TGithubOrgSyncServiceFactory; folderCommit: TFolderCommitServiceFactory; + pit: TPitServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/ee/routes/v1/pit-router.ts b/backend/src/ee/routes/v1/pit-router.ts index a2a6ae992..e35091569 100644 --- a/backend/src/ee/routes/v1/pit-router.ts +++ b/backend/src/ee/routes/v1/pit-router.ts @@ -1,18 +1,13 @@ /* eslint-disable @typescript-eslint/no-base-to-string */ -import { ForbiddenError } from "@casl/ability"; import { z } from "zod"; -import { ActionProjectType } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { ProjectPermissionCommitsActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { NotFoundError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { booleanSchema } from "@app/server/routes/sanitizedSchemas"; -import { ActorAuthMethod, ActorType, AuthMode } from "@app/services/auth/auth-type"; -import { ChangeType } from "@app/services/folder-commit/folder-commit-service"; -import { commitChangesResponseSchema } from "@app/services/folder-commit/folder-commit-types"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { commitChangesResponseSchema, resourceChangeSchema } from "@app/services/folder-commit/folder-commit-schemas"; const commitHistoryItemSchema = z.object({ id: z.string(), @@ -49,7 +44,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { querystring: z.object({ environment: z.string().trim(), path: z.string().trim().default("/").transform(removeTrailingSlash), - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: z.object({ @@ -60,30 +55,30 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const res = await server.services.folderCommit.getCommitsCount({ + const result = await server.services.pit.getCommitsCount({ actor: req.permission?.type, actorId: req.permission?.id, actorOrgId: req.permission?.orgId, actorAuthMethod: req.permission?.authMethod, - projectId: req.query.workspaceId, + projectId: req.query.projectId, environment: req.query.environment, path: req.query.path }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.query.workspaceId, + projectId: req.query.projectId, event: { type: EventType.GET_PROJECT_PIT_COMMIT_COUNT, metadata: { environment: req.query.environment, path: req.query.path, - commitCount: res.count.toString() + commitCount: result.count.toString() } } }); - return res; + return result; } }); @@ -98,7 +93,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { querystring: z.object({ environment: z.string().trim(), path: z.string().trim().default("/").transform(removeTrailingSlash), - workspaceId: z.string().trim(), + projectId: z.string().trim(), offset: z.coerce.number().min(0).default(0), limit: z.coerce.number().min(1).max(100).default(20), search: z.string().trim().optional(), @@ -114,12 +109,12 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const result = await server.services.folderCommit.getCommitsForFolder({ + const result = await server.services.pit.getCommitsForFolder({ actor: req.permission?.type, actorId: req.permission?.id, actorOrgId: req.permission?.orgId, actorAuthMethod: req.permission?.authMethod, - projectId: req.query.workspaceId, + projectId: req.query.projectId, environment: req.query.environment, path: req.query.path, offset: req.query.offset, @@ -130,7 +125,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.query.workspaceId, + projectId: req.query.projectId, event: { type: EventType.GET_PROJECT_PIT_COMMITS, metadata: { @@ -145,87 +140,10 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { } }); - return { - commits: result.commits.map((commit) => ({ - ...commit, - commitId: commit.commitId.toString() - })), - total: result.total, - hasMore: result.hasMore - }; + return result; } }); - const getChangeVersions = async ( - change: { - secretVersion?: string; - secretId?: string; - id?: string; - isUpdate?: boolean; - changeType?: string; - }, - previousVersion: string, - actorId: string, - actor: ActorType, - actorOrgId: string, - actorAuthMethod: ActorAuthMethod, - folderId: string - ) => { - if (change.secretVersion) { - const currentVersion = change.secretVersion || "1"; - const secretId = change.secretId ? change.secretId : change.id; - if (!secretId) { - return; - } - // eslint-disable-next-line no-await-in-loop - const versions = await server.services.secret.getSecretVersionsV2ByIds({ - actorId, - actor, - actorOrgId, - actorAuthMethod, - secretId, - // if it's update add also the previous secretversionid - secretVersions: - change.isUpdate || change.changeType === ChangeType.UPDATE - ? [currentVersion, previousVersion] - : [currentVersion], - folderId - }); - return versions?.map((v) => ({ - secretKey: v.secretKey, - secretComment: v.secretComment, - skipMultilineEncoding: v.skipMultilineEncoding, - secretReminderRepeatDays: v.secretReminderRepeatDays, - secretReminderNote: v.secretReminderNote, - metadata: v.metadata, - tags: v.tags?.map((t) => t.name), - secretReminderRecipients: v.secretReminderRecipients?.map((r) => r.toString()), - secretValue: v.secretValue - })); - } - }; - - const getFolderVersions = async ( - change: { - folderVersion?: string; - isUpdate?: boolean; - changeType?: string; - }, - fromVersion: string, - folderId: string - ) => { - const currentVersion = change.folderVersion || "1"; - // eslint-disable-next-line no-await-in-loop - const versions = await server.services.folder.getFolderVersionsByIds({ - folderId, - folderVersions: - change.isUpdate || change.changeType === ChangeType.UPDATE ? [currentVersion, fromVersion] : [currentVersion] - }); - return versions.map((v) => ({ - name: v.name - })); - }; - // Get commit changes for a specific commit server.route({ method: "GET", @@ -238,7 +156,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { commitId: z.string().trim() }), querystring: z.object({ - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: commitChangesResponseSchema @@ -246,54 +164,28 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const changes = await server.services.folderCommit.getCommitChanges({ + const result = await server.services.pit.getCommitChanges({ actor: req.permission?.type, actorId: req.permission?.id, actorOrgId: req.permission?.orgId, actorAuthMethod: req.permission?.authMethod, - projectId: req.query.workspaceId, + projectId: req.query.projectId, commitId: req.params.commitId }); - for (const change of changes.changes) { - if (change.secretVersionId && change.secretVersion) { - // eslint-disable-next-line no-await-in-loop - change.versions = await getChangeVersions( - change, - (Number.parseInt(change.secretVersion, 10) - 1).toString(), - req.permission.id, - req.permission.type, - req.permission.orgId, - req.permission.authMethod, - change.folderId - ); - } else if (change.folderVersionId && change.folderChangeId && change.folderVersion) { - // eslint-disable-next-line no-await-in-loop - change.versions = await getFolderVersions( - change, - (Number.parseInt(change.folderVersion, 10) - 1).toString(), - change.folderChangeId - ); - } - } await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.query.workspaceId, + projectId: req.query.projectId, event: { type: EventType.GET_PROJECT_PIT_COMMIT_CHANGES, metadata: { commitId: req.params.commitId, - changesCount: (changes.changes?.length || 0).toString() + changesCount: (result.changes.changes?.length || 0).toString() } } }); - return { - changes: { - ...changes, - commitId: changes.commitId.toString() - } - }; + return result; } }); @@ -313,7 +205,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { envId: z.string().trim(), deepRollback: booleanSchema.default(false), secretPath: z.string().trim().default("/").transform(removeTrailingSlash), - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: z.array( @@ -321,89 +213,43 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { folderId: z.string(), folderName: z.string(), folderPath: z.string().optional(), - changes: z.any() + changes: z.array(resourceChangeSchema) }) ) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const latestCommit = await server.services.folderCommit.getLatestCommit({ - folderId: req.query.folderId, + const result = await server.services.pit.compareCommitChanges({ actor: req.permission?.type, actorId: req.permission?.id, actorOrgId: req.permission?.orgId, actorAuthMethod: req.permission?.authMethod, - projectId: req.query.workspaceId + projectId: req.query.projectId, + commitId: req.params.commitId, + folderId: req.query.folderId, + envId: req.query.envId, + deepRollback: req.query.deepRollback, + secretPath: req.query.secretPath }); - if (!latestCommit) { - throw new NotFoundError({ message: "Latest commit not found" }); - } - - let diffs; - if (req.query.deepRollback) { - diffs = await server.services.folderCommit.deepCompareFolder({ - targetCommitId: req.params.commitId, - envId: req.query.envId, - projectId: req.query.workspaceId - }); - } else { - const folder = await server.services.folder.getFolderById({ - actor: req.permission?.type, - actorId: req.permission?.id, - actorOrgId: req.permission?.orgId, - actorAuthMethod: req.permission?.authMethod, - id: req.query.folderId - }); - diffs = [ - { - folderId: folder.id, - folderName: folder.name, - folderPath: req.query.secretPath, - changes: await server.services.folderCommit.compareFolderStates({ - targetCommitId: req.params.commitId, - currentCommitId: latestCommit.id - }) - } - ]; - } - - for (const diff of diffs) { - for (const change of diff.changes) { - if (change.secretKey) { - // eslint-disable-next-line no-await-in-loop - change.versions = await getChangeVersions( - change, - change.fromVersion || "1", - req.permission.id, - req.permission.type, - req.permission.orgId, - req.permission.authMethod, - diff.folderId - ); - } - if (change.folderVersion) { - // eslint-disable-next-line no-await-in-loop - change.versions = await getFolderVersions(change, change.fromVersion || "1", change.id); - } - } - } await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.query.workspaceId, + projectId: req.query.projectId, event: { type: EventType.PIT_COMPARE_FOLDER_STATES, metadata: { targetCommitId: req.params.commitId, folderId: req.query.folderId, deepRollback: req.query.deepRollback, - diffsCount: diffs.length.toString() + diffsCount: result.length.toString(), + env: req.query.envId, + folderPath: req.query.secretPath } } }); - return diffs; + return result; } }); @@ -423,7 +269,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { deepRollback: z.boolean().default(false), message: z.string().trim().optional(), envId: z.string().trim(), - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: z.object({ @@ -436,79 +282,36 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const { permission } = await server.services.permission.getProjectPermission({ - actor: req.permission?.type, - actorId: req.permission?.id, - projectId: req.body.workspaceId, - actorAuthMethod: req.permission?.authMethod, - actorOrgId: req.permission?.orgId, - actionProjectType: ActionProjectType.SecretManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCommitsActions.PerformRollback, - ProjectPermissionSub.Commits - ); - const latestCommit = await server.services.folderCommit.getLatestCommit({ - folderId: req.body.folderId, + const result = await server.services.pit.rollbackToCommit({ actor: req.permission?.type, actorId: req.permission?.id, actorOrgId: req.permission?.orgId, actorAuthMethod: req.permission?.authMethod, - projectId: req.body.workspaceId - }); - if (!latestCommit) { - throw new NotFoundError({ message: "Latest commit not found" }); - } - - if (req.body.deepRollback) { - await server.services.folderCommit.deepRollbackFolder( - req.params.commitId, - req.body.envId, - req.permission.id, - req.permission.type, - req.body.workspaceId - ); - return { success: true }; - } - const diff = await server.services.folderCommit.compareFolderStates({ - currentCommitId: latestCommit.id, - targetCommitId: req.params.commitId - }); - - const response = await server.services.folderCommit.applyFolderStateDifferences({ - differences: diff, - actorInfo: { - actorType: req.permission.type, - actorId: req.permission.id, - message: req.body.message || "Rollback to previous commit" - }, + projectId: req.body.projectId, + commitId: req.params.commitId, folderId: req.body.folderId, - projectId: req.body.workspaceId, - reconstructNewFolders: req.body.deepRollback + deepRollback: req.body.deepRollback, + message: req.body.message, + envId: req.body.envId }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.body.workspaceId, + projectId: req.body.projectId, event: { type: EventType.PIT_ROLLBACK_COMMIT, metadata: { targetCommitId: req.params.commitId, + envId: req.body.envId, folderId: req.body.folderId, deepRollback: req.body.deepRollback, message: req.body.message || "Rollback to previous commit", - totalChanges: response.totalChanges.toString() + totalChanges: result.totalChanges?.toString() || "0" } } }); - return { - success: true, - secretChangesCount: response.secretChangesCount, - folderChangesCount: response.folderChangesCount, - totalChanges: response.totalChanges - }; + return result; } }); @@ -523,8 +326,8 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { params: z.object({ commitId: z.string().trim() }), - querystring: z.object({ - workspaceId: z.string().trim() + body: z.object({ + projectId: z.string().trim() }), response: { 200: z.object({ @@ -538,29 +341,29 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const response = await server.services.folderCommit.revertCommitChanges({ - commitId: req.params.commitId, + const result = await server.services.pit.revertCommit({ actor: req.permission?.type, actorId: req.permission?.id, - actorAuthMethod: req.permission?.authMethod, actorOrgId: req.permission?.orgId, - projectId: req.query.workspaceId + actorAuthMethod: req.permission?.authMethod, + projectId: req.body.projectId, + commitId: req.params.commitId }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.query.workspaceId, + projectId: req.body.projectId, event: { type: EventType.PIT_REVERT_COMMIT, metadata: { commitId: req.params.commitId, - revertCommitId: response.revertCommitId, - changesReverted: response.changesReverted?.toString() + revertCommitId: result.revertCommitId, + changesReverted: result.changesReverted?.toString() } } }); - return response; + return result; } }); @@ -577,7 +380,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { }), querystring: z.object({ folderId: z.string().trim(), - workspaceId: z.string().trim() + projectId: z.string().trim() }), response: { 200: folderStateSchema @@ -585,39 +388,29 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const { permission } = await server.services.permission.getProjectPermission({ + const result = await server.services.pit.getFolderStateAtCommit({ actor: req.permission?.type, actorId: req.permission?.id, - projectId: req.query.workspaceId, - actorAuthMethod: req.permission?.authMethod, actorOrgId: req.permission?.orgId, - actionProjectType: ActionProjectType.SecretManager + actorAuthMethod: req.permission?.authMethod, + projectId: req.query.projectId, + commitId: req.params.commitId }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCommitsActions.Read, - ProjectPermissionSub.Commits - ); - const response = await server.services.folderCommit.reconstructFolderState(req.params.commitId); - await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.query.workspaceId, + projectId: req.query.projectId, event: { type: EventType.PIT_GET_FOLDER_STATE, metadata: { commitId: req.params.commitId, folderId: req.query.folderId, - resourceCount: response.length.toString() + resourceCount: result.length.toString() } } }); - return response.map((item) => ({ - ...item, - secretVersion: item.secretVersion ? Number(item.secretVersion) : undefined, - folderVersion: item.folderVersion ? Number(item.folderVersion) : undefined - })); + return result; } }); }; 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 dc0315a82..1ace71efb 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2959,6 +2959,7 @@ interface PitRollbackCommitEvent { deepRollback: boolean; message: string; totalChanges: string; + envId: string; }; } @@ -2987,6 +2988,8 @@ interface PitCompareFolderStatesEvent { folderId: string; deepRollback: boolean; diffsCount: string; + env: string; + folderPath: string; }; } diff --git a/backend/src/ee/services/pit/pit-service.ts b/backend/src/ee/services/pit/pit-service.ts new file mode 100644 index 000000000..15a460e73 --- /dev/null +++ b/backend/src/ee/services/pit/pit-service.ts @@ -0,0 +1,465 @@ +/* eslint-disable no-await-in-loop */ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { ProjectPermissionCommitsActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; +import { ResourceType, TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; +import { + isFolderCommitChange, + isSecretCommitChange +} from "@app/services/folder-commit-changes/folder-commit-changes-dal"; +import { TSecretServiceFactory } from "@app/services/secret/secret-service"; +import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; + +type TPitServiceFactoryDep = { + folderCommitService: TFolderCommitServiceFactory; + secretService: Pick; + folderService: Pick; + permissionService: Pick; +}; + +export type TPitServiceFactory = ReturnType; + +export const pitServiceFactory = ({ + folderCommitService, + secretService, + folderService, + permissionService +}: TPitServiceFactoryDep) => { + const getCommitsCount = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + environment, + path + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + environment: string; + path: string; + }) => { + const result = await folderCommitService.getCommitsCount({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + environment, + path + }); + + return result; + }; + + const getCommitsForFolder = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + environment, + path, + offset, + limit, + search, + sort + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + environment: string; + path: string; + offset: number; + limit: number; + search?: string; + sort: "asc" | "desc"; + }) => { + const result = await folderCommitService.getCommitsForFolder({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + environment, + path, + offset, + limit, + search, + sort + }); + + return { + commits: result.commits.map((commit) => ({ + ...commit, + commitId: commit.commitId.toString() + })), + total: result.total, + hasMore: result.hasMore + }; + }; + + const getCommitChanges = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + commitId: string; + }) => { + const changes = await folderCommitService.getCommitChanges({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId + }); + + for (const change of changes.changes) { + if (isSecretCommitChange(change)) { + change.versions = await secretService.getChangeVersions( + { + secretVersion: change.secretVersion, + secretId: change.secretId, + id: change.id, + isUpdate: change.isUpdate, + changeType: change.changeType + }, + (Number.parseInt(change.secretVersion, 10) - 1).toString(), + actorId, + actor, + actorOrgId, + actorAuthMethod, + change.folderId + ); + } else if (isFolderCommitChange(change)) { + change.versions = await folderService.getFolderVersions( + change, + (Number.parseInt(change.folderVersion, 10) - 1).toString(), + change.folderChangeId + ); + } + } + + return { + changes: { + ...changes, + commitId: changes.commitId.toString() + } + }; + }; + + const compareCommitChanges = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId, + folderId, + envId, + deepRollback, + secretPath + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + commitId: string; + folderId: string; + envId: string; + deepRollback: boolean; + secretPath: string; + }) => { + const latestCommit = await folderCommitService.getLatestCommit({ + folderId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }); + + const targetCommit = await folderCommitService.getCommitById({ + commitId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }); + + if (!latestCommit) { + throw new NotFoundError({ message: "Latest commit not found" }); + } + + let diffs; + if (deepRollback) { + diffs = await folderCommitService.deepCompareFolder({ + targetCommitId: targetCommit.id, + envId, + projectId + }); + } else { + const folderData = await folderService.getFolderById({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + id: folderId + }); + + diffs = [ + { + folderId: folderData.id, + folderName: folderData.name, + folderPath: secretPath, + changes: await folderCommitService.compareFolderStates({ + targetCommitId: commitId, + currentCommitId: latestCommit.id + }) + } + ]; + } + + for (const diff of diffs) { + for (const change of diff.changes) { + // Use discriminated union type checking + if (change.type === ResourceType.SECRET) { + // TypeScript now knows this is a SecretChange + if (change.secretKey && change.secretVersion && change.secretId) { + change.versions = await secretService.getChangeVersions( + { + secretVersion: change.secretVersion, + secretId: change.secretId, + id: change.id, + isUpdate: change.isUpdate, + changeType: change.changeType + }, + change.fromVersion || "1", + actorId, + actor, + actorOrgId, + actorAuthMethod, + diff.folderId + ); + } + } else if (change.type === ResourceType.FOLDER) { + // TypeScript now knows this is a FolderChange + if (change.folderVersion) { + change.versions = await folderService.getFolderVersions(change, change.fromVersion || "1", change.id); + } + } + } + } + + return diffs; + }; + + const rollbackToCommit = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId, + folderId, + deepRollback, + message, + envId + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + commitId: string; + folderId: string; + deepRollback: boolean; + message?: string; + envId: string; + }) => { + const { permission: userPermission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + ForbiddenError.from(userPermission).throwUnlessCan( + ProjectPermissionCommitsActions.PerformRollback, + ProjectPermissionSub.Commits + ); + + const latestCommit = await folderCommitService.getLatestCommit({ + folderId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }); + + if (!latestCommit) { + throw new NotFoundError({ message: "Latest commit not found" }); + } + + logger.info(`PIT - Attempting to rollback folder ${folderId} from commit ${latestCommit.id} to commit ${commitId}`); + + const targetCommit = await folderCommitService.getCommitById({ + commitId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }); + + if (!targetCommit || targetCommit.folderId !== folderId || targetCommit.envId !== envId) { + throw new NotFoundError({ message: "Target commit not found" }); + } + + if (!latestCommit || latestCommit.envId !== envId) { + throw new NotFoundError({ message: "Latest commit not found" }); + } + + if (deepRollback) { + await folderCommitService.deepRollbackFolder(commitId, envId, actorId, actor, projectId, message); + return { success: true }; + } + + const diff = await folderCommitService.compareFolderStates({ + currentCommitId: latestCommit.id, + targetCommitId: commitId + }); + + const response = await folderCommitService.applyFolderStateDifferences({ + differences: diff, + actorInfo: { + actorType: actor, + actorId, + message: message || "Rollback to previous commit" + }, + folderId, + projectId, + reconstructNewFolders: deepRollback + }); + + return { + success: true, + secretChangesCount: response.secretChangesCount, + folderChangesCount: response.folderChangesCount, + totalChanges: response.totalChanges + }; + }; + + const revertCommit = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + commitId: string; + }) => { + const response = await folderCommitService.revertCommitChanges({ + commitId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }); + + return response; + }; + + const getFolderStateAtCommit = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + commitId: string; + }) => { + const { permission: userPermission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + ForbiddenError.from(userPermission).throwUnlessCan( + ProjectPermissionCommitsActions.Read, + ProjectPermissionSub.Commits + ); + + const commit = await folderCommitService.getCommitById({ + commitId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }); + + if (!commit) { + throw new NotFoundError({ message: `Commit with ID ${commitId} not found` }); + } + + const response = await folderCommitService.reconstructFolderState(commitId); + + return response.map((item) => ({ + ...item, + secretVersion: item.secretVersion ? Number(item.secretVersion) : undefined, + folderVersion: item.folderVersion ? Number(item.folderVersion) : undefined + })); + }; + + return { + getCommitsCount, + getCommitsForFolder, + getCommitChanges, + compareCommitChanges, + rollbackToCommit, + revertCommit, + getFolderStateAtCommit + }; +}; diff --git a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index 752c24bd6..d245578c5 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -8,7 +8,7 @@ import { InternalServerError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { ActorType } from "@app/services/auth/auth-type"; -import { TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; +import { CommitType, TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -584,7 +584,7 @@ export const secretSnapshotServiceFactory = ({ // Secret was deleted and re-added - this is an update only if versions are different if (deletedInfo.versionId !== addedSecret.id) { commitChanges.push({ - type: "add", // In the commit system, updates are tracked as "add" with isUpdate=true + type: CommitType.ADD, // In the commit system, updates are tracked as "add" with isUpdate=true secretVersionId: addedSecret.id, isUpdate: true }); @@ -594,7 +594,7 @@ export const secretSnapshotServiceFactory = ({ } else if (deletedInfo.versionId) { // Secret was only deleted commitChanges.push({ - type: "delete", + type: CommitType.DELETE, secretVersionId: deletedInfo.versionId }); } @@ -602,7 +602,7 @@ export const secretSnapshotServiceFactory = ({ // Add remaining new secrets (not updates) addedSecretsChanges.forEach((addedSecret) => { commitChanges.push({ - type: "add", + type: CommitType.ADD, secretVersionId: addedSecret.id }); }); @@ -614,7 +614,7 @@ export const secretSnapshotServiceFactory = ({ // Folder was deleted and re-added - this is an update only if versions are different if (deletedInfo.versionId !== addedFolder.id) { commitChanges.push({ - type: "add", + type: CommitType.ADD, folderVersionId: addedFolder.id, isUpdate: true }); @@ -624,7 +624,7 @@ export const secretSnapshotServiceFactory = ({ } else if (deletedInfo.versionId) { // Folder was only deleted commitChanges.push({ - type: "delete", + type: CommitType.DELETE, folderVersionId: deletedInfo.versionId }); } @@ -633,7 +633,7 @@ export const secretSnapshotServiceFactory = ({ // Add remaining new folders (not updates) addedFoldersChanges.forEach((addedFolder) => { commitChanges.push({ - type: "add", + type: CommitType.ADD, folderVersionId: addedFolder.id }); }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index bf5d65d00..19038d8cb 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -57,6 +57,7 @@ import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { pitServiceFactory } from "@app/ee/services/pit/pit-service"; import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal"; import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; @@ -1520,6 +1521,13 @@ export const registerRoutes = async ( permissionService }); + const pitService = pitServiceFactory({ + folderCommitService, + secretService, + folderService, + permissionService + }); + const identityOidcAuthService = identityOidcAuthServiceFactory({ identityOidcAuthDAL, identityOrgMembershipDAL, @@ -1824,6 +1832,7 @@ export const registerRoutes = async ( certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, certificateEst: certificateEstService, + pit: pitService, pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, pkiSubscriber: pkiSubscriberService, diff --git a/backend/src/services/folder-commit-changes/folder-commit-changes-dal.ts b/backend/src/services/folder-commit-changes/folder-commit-changes-dal.ts index e8807ddc4..4ce9d9199 100644 --- a/backend/src/services/folder-commit-changes/folder-commit-changes-dal.ts +++ b/backend/src/services/folder-commit-changes/folder-commit-changes-dal.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ import { Knex } from "knex"; import { TDbClient } from "@app/db"; @@ -14,32 +15,57 @@ import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; export type TFolderCommitChangesDALFactory = ReturnType; -export type CommitChangeWithCommitInfo = TFolderCommitChanges & { +// Base type with common fields +type BaseCommitChangeInfo = TFolderCommitChanges & { actorMetadata: unknown; actorType: string; message?: string | null; folderId: string; - folderName?: string; - folderVersion?: string; - secretKey?: string; - secretVersion?: string; - secretId?: string; - folderChangeId?: string; - objectType?: string; + createdAt: Date; +}; + +// Secret-specific change +export type SecretCommitChange = BaseCommitChangeInfo & { + resourceType: "secret"; + secretKey: string; + changeType: string; + secretVersionId?: string | null; + secretVersion: string; + secretId: string; versions?: { - secretKey?: string; - secretComment?: string; + secretKey: string; + secretComment: string; skipMultilineEncoding?: boolean | null; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; metadata?: unknown; tags?: string[] | null; secretReminderRecipients?: string[] | null; - secretValue?: string; + secretValue: string; + }[]; +}; + +// Folder-specific change +export type FolderCommitChange = BaseCommitChangeInfo & { + resourceType: "folder"; + folderName: string; + folderVersion: string; + folderChangeId: string; + versions?: { name?: string; }[]; }; +// Discriminated union +export type CommitChangeWithCommitInfo = SecretCommitChange | FolderCommitChange; + +// Type guards +export const isSecretCommitChange = (change: CommitChangeWithCommitInfo): change is SecretCommitChange => + change.resourceType === "secret"; + +export const isFolderCommitChange = (change: CommitChangeWithCommitInfo): change is FolderCommitChange => + change.resourceType === "folder"; + export const folderCommitChangesDALFactory = (db: TDbClient) => { const folderCommitChangesOrm = ormify(db, TableName.FolderCommitChanges); @@ -50,7 +76,6 @@ export const folderCommitChangesDALFactory = (db: TDbClient) => { ): Promise => { try { const docs = await (tx || db.replicaNode())(TableName.FolderCommitChanges) - // eslint-disable-next-line @typescript-eslint/no-misused-promises .where(buildFindFilter({ folderCommitId }, TableName.FolderCommitChanges)) .leftJoin( TableName.FolderCommit, @@ -91,57 +116,108 @@ export const folderCommitChangesDALFactory = (db: TDbClient) => { db.ref("createdAt").withSchema(TableName.FolderCommit), db.ref("folderId").withSchema(TableName.FolderCommit) ); - return docs.map((doc) => ({ - ...doc, - folderVersion: doc.folderVersion?.toString(), - secretVersion: doc.secretVersion?.toString() - })); + + return docs.map((doc) => { + // Determine if this is a secret or folder change based on populated fields + if (doc.secretKey && doc.secretVersion && doc.secretId) { + return { + ...doc, + resourceType: "secret", + secretKey: doc.secretKey, + secretVersion: doc.secretVersion.toString(), + secretId: doc.secretId + } as SecretCommitChange; + } + return { + ...doc, + resourceType: "folder", + folderName: doc.folderName, + folderVersion: doc.folderVersion.toString(), + folderChangeId: doc.folderChangeId + } as FolderCommitChange; + }); } catch (error) { throw new DatabaseError({ error, name: "FindByCommitId" }); } }; - const findBySecretVersionId = async (secretVersionId: string, tx?: Knex): Promise => { + const findBySecretVersionId = async (secretVersionId: string, tx?: Knex): Promise => { try { const docs = await (tx || db.replicaNode())< TFolderCommitChanges & Pick >(TableName.FolderCommitChanges) - // eslint-disable-next-line @typescript-eslint/no-misused-promises .where(buildFindFilter({ secretVersionId }, TableName.FolderCommitChanges)) .select(selectAllTableCols(TableName.FolderCommitChanges)) .join(TableName.FolderCommit, `${TableName.FolderCommitChanges}.folderCommitId`, `${TableName.FolderCommit}.id`) + .leftJoin( + TableName.SecretVersionV2, + `${TableName.FolderCommitChanges}.secretVersionId`, + `${TableName.SecretVersionV2}.id` + ) .select( db.ref("actorMetadata").withSchema(TableName.FolderCommit), db.ref("actorType").withSchema(TableName.FolderCommit), db.ref("message").withSchema(TableName.FolderCommit), db.ref("createdAt").withSchema(TableName.FolderCommit), - db.ref("folderId").withSchema(TableName.FolderCommit) + db.ref("folderId").withSchema(TableName.FolderCommit), + db.ref("key").withSchema(TableName.SecretVersionV2).as("secretKey"), + db.ref("version").withSchema(TableName.SecretVersionV2).as("secretVersion"), + db.ref("secretId").withSchema(TableName.SecretVersionV2) + ); + + return docs + .filter((doc) => doc.secretKey && doc.secretVersion && doc.secretId) + .map( + (doc): SecretCommitChange => ({ + ...doc, + resourceType: "secret", + secretKey: doc.secretKey, + secretVersion: doc.secretVersion.toString(), + secretId: doc.secretId + }) ); - return docs; } catch (error) { throw new DatabaseError({ error, name: "FindBySecretVersionId" }); } }; - const findByFolderVersionId = async (folderVersionId: string, tx?: Knex): Promise => { + const findByFolderVersionId = async (folderVersionId: string, tx?: Knex): Promise => { try { const docs = await (tx || db.replicaNode())< TFolderCommitChanges & Pick >(TableName.FolderCommitChanges) - // eslint-disable-next-line @typescript-eslint/no-misused-promises .where(buildFindFilter({ folderVersionId }, TableName.FolderCommitChanges)) .select(selectAllTableCols(TableName.FolderCommitChanges)) .join(TableName.FolderCommit, `${TableName.FolderCommitChanges}.folderCommitId`, `${TableName.FolderCommit}.id`) + .leftJoin( + TableName.SecretFolderVersion, + `${TableName.FolderCommitChanges}.folderVersionId`, + `${TableName.SecretFolderVersion}.id` + ) .select( db.ref("actorMetadata").withSchema(TableName.FolderCommit), db.ref("actorType").withSchema(TableName.FolderCommit), db.ref("message").withSchema(TableName.FolderCommit), db.ref("createdAt").withSchema(TableName.FolderCommit), - db.ref("folderId").withSchema(TableName.FolderCommit) + db.ref("folderId").withSchema(TableName.FolderCommit), + db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderName"), + db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("folderChangeId"), + db.ref("version").withSchema(TableName.SecretFolderVersion).as("folderVersion") + ); + + return docs + .filter((doc) => doc.folderName && doc.folderVersion && doc.folderChangeId) + .map( + (doc): FolderCommitChange => ({ + ...doc, + resourceType: "folder", + folderName: doc.folderName, + folderVersion: doc.folderVersion!.toString(), + folderChangeId: doc.folderChangeId + }) ); - return docs; } catch (error) { throw new DatabaseError({ error, name: "FindByFolderVersionId" }); } diff --git a/backend/src/services/folder-commit/folder-commit-dal.ts b/backend/src/services/folder-commit/folder-commit-dal.ts index 53fc5353b..e95dbb839 100644 --- a/backend/src/services/folder-commit/folder-commit-dal.ts +++ b/backend/src/services/folder-commit/folder-commit-dal.ts @@ -474,6 +474,24 @@ export const folderCommitDALFactory = (db: TDbClient) => { } }; + const findCommitBefore = async ( + folderId: string, + commitId: bigint, + tx?: Knex + ): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.FolderCommit) + .where({ folderId }) + .where("commitId", "<", commitId.toString()) + .select(selectAllTableCols(TableName.FolderCommit)) + .orderBy("commitId", "desc") + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindCommitBefore" }); + } + }; + return { ...restOfOrm, findByFolderId, @@ -489,6 +507,7 @@ export const folderCommitDALFactory = (db: TDbClient) => { findAllFolderCommitsAfter, findPreviousCommitTo, findById, - findByFolderIdPaginated + findByFolderIdPaginated, + findCommitBefore }; }; diff --git a/backend/src/services/folder-commit/folder-commit-schemas.ts b/backend/src/services/folder-commit/folder-commit-schemas.ts new file mode 100644 index 000000000..2a61c0886 --- /dev/null +++ b/backend/src/services/folder-commit/folder-commit-schemas.ts @@ -0,0 +1,142 @@ +import { z } from "zod"; + +// Base schema shared by both secret and folder changes +const baseChangeSchema = z.object({ + id: z.string(), + folderCommitId: z.string(), + changeType: z.string(), + isUpdate: z.boolean().optional(), + createdAt: z.union([z.string(), z.date()]), + updatedAt: z.union([z.string(), z.date()]), + actorMetadata: z + .union([ + z.object({ + id: z.string().optional(), + name: z.string().optional() + }), + z.unknown() + ]) + .optional(), + actorType: z.string(), + message: z.string().nullable().optional(), + folderId: z.string() +}); + +// Secret-specific versions schema +const secretVersionSchema = z.object({ + secretKey: z.string(), + secretComment: z.string(), + skipMultilineEncoding: z.boolean().nullable().optional(), + secretReminderRepeatDays: z.number().nullable().optional(), + tags: z.array(z.string()).nullable().optional(), + metadata: z.unknown().nullable().optional(), + secretReminderNote: z.string().nullable().optional(), + secretValue: z.string() +}); + +// Folder-specific versions schema +const folderVersionSchema = z.object({ + name: z.string().optional() +}); + +// Secret commit change schema +const secretCommitChangeSchema = baseChangeSchema.extend({ + resourceType: z.literal("secret"), + secretVersionId: z.string().optional().nullable(), + secretKey: z.string(), + secretVersion: z.union([z.string(), z.number()]), + secretId: z.string(), + versions: z.array(secretVersionSchema).optional() +}); + +// Folder commit change schema +const folderCommitChangeSchema = baseChangeSchema.extend({ + resourceType: z.literal("folder"), + folderVersionId: z.string().optional().nullable(), + folderName: z.string(), + folderChangeId: z.string(), + folderVersion: z.union([z.string(), z.number()]), + versions: z.array(folderVersionSchema).optional() +}); + +// Discriminated union for commit changes +export const commitChangeSchema = z.discriminatedUnion("resourceType", [ + secretCommitChangeSchema, + folderCommitChangeSchema +]); + +// Commit schema +const commitSchema = z.object({ + id: z.string(), + commitId: z.string(), + actorMetadata: z + .union([ + z.object({ + id: z.string().optional(), + name: z.string().optional() + }), + z.unknown() + ]) + .optional(), + actorType: z.string(), + message: z.string().nullable().optional(), + folderId: z.string(), + envId: z.string(), + createdAt: z.union([z.string(), z.date()]), + updatedAt: z.union([z.string(), z.date()]), + changes: z.array(commitChangeSchema).optional() +}); + +// Response schema +export const commitChangesResponseSchema = z.object({ + changes: commitSchema +}); + +// Base resource change schema for comparison results +const baseResourceChangeSchema = z.object({ + id: z.string(), + versionId: z.string(), + oldVersionId: z.string().optional(), + changeType: z.enum(["add", "delete", "update", "create"]), + commitId: z.union([z.string(), z.bigint()]), + createdAt: z.union([z.string(), z.date()]).optional(), + parentId: z.string().optional(), + isUpdate: z.boolean().optional(), + fromVersion: z.union([z.string(), z.number()]).optional() +}); + +// Secret resource change schema +const secretResourceChangeSchema = baseResourceChangeSchema.extend({ + type: z.literal("secret"), + secretKey: z.string().optional(), + secretVersion: z.union([z.string(), z.number()]).optional(), + secretId: z.string().optional(), + versions: z + .array( + z.object({ + secretKey: z.string().optional(), + secretComment: z.string().optional(), + skipMultilineEncoding: z.boolean().nullable().optional(), + secretReminderRepeatDays: z.number().nullable().optional(), + tags: z.array(z.string()).nullable().optional(), + metadata: z.unknown().nullable().optional(), + secretReminderNote: z.string().nullable().optional(), + secretValue: z.string().optional() + }) + ) + .optional() +}); + +// Folder resource change schema +const folderResourceChangeSchema = baseResourceChangeSchema.extend({ + type: z.literal("folder"), + folderName: z.string().optional(), + folderVersion: z.union([z.string(), z.number()]).optional(), + versions: z.array(folderVersionSchema).optional() +}); + +// Discriminated union for resource changes +export const resourceChangeSchema = z.discriminatedUnion("type", [ + secretResourceChangeSchema, + folderResourceChangeSchema +]); diff --git a/backend/src/services/folder-commit/folder-commit-service.test.ts b/backend/src/services/folder-commit/folder-commit-service.test.ts index a0253bf96..30da5a09a 100644 --- a/backend/src/services/folder-commit/folder-commit-service.test.ts +++ b/backend/src/services/folder-commit/folder-commit-service.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/return-await */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ import { Knex } from "knex"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +12,7 @@ import { ChangeType, CommitType, folderCommitServiceFactory, + ResourceType, TFolderCommitServiceFactory } from "./folder-commit-service"; @@ -518,8 +522,20 @@ describe("folderCommitServiceFactory", () => { const actorType = ActorType.USER; const differences = [ - { type: "secret", id: "secret-1", versionId: "v1", changeType: ChangeType.CREATE, commitId: BigInt(1) }, - { type: "folder", id: "folder-1", versionId: "v2", changeType: ChangeType.UPDATE, commitId: BigInt(1) } + { + type: ResourceType.SECRET, + id: "secret-1", + versionId: "v1", + changeType: ChangeType.CREATE, + commitId: BigInt(1) + }, + { + type: ResourceType.FOLDER, + id: "folder-1", + versionId: "v2", + changeType: ChangeType.UPDATE, + commitId: BigInt(1) + } ]; const secretVersions = { diff --git a/backend/src/services/folder-commit/folder-commit-service.ts b/backend/src/services/folder-commit/folder-commit-service.ts index 5e2283b76..2c3a11411 100644 --- a/backend/src/services/folder-commit/folder-commit-service.ts +++ b/backend/src/services/folder-commit/folder-commit-service.ts @@ -38,7 +38,7 @@ export enum CommitType { DELETE = "delete" } -enum ResourceType { +export enum ResourceType { SECRET = "secret", FOLDER = "folder" } @@ -68,8 +68,7 @@ type TCommitChangeDTO = { folderVersionId?: string; }; -export type ResourceChange = { - type: string; +type BaseChange = { id: string; versionId: string; oldVersionId?: string; @@ -78,12 +77,14 @@ export type ResourceChange = { createdAt?: Date; parentId?: string; isUpdate?: boolean; + fromVersion?: string; +}; + +type SecretChange = { + type: ResourceType.SECRET; secretKey?: string; secretVersion?: string; secretId?: string; - folderName?: string; - folderVersion?: string; - fromVersion?: string; versions?: { secretKey?: string; secretComment?: string; @@ -94,10 +95,20 @@ export type ResourceChange = { tags?: string[] | null; secretReminderRecipients?: string[] | null; secretValue?: string; - name?: string; }[]; }; +type FolderChange = { + type: ResourceType.FOLDER; + folderName?: string; + folderVersion?: string; + versions?: { + name: string; + }[]; +}; + +export type ResourceChange = BaseChange & (SecretChange | FolderChange); + type ActorInfo = { actorType: string; actorId?: string; @@ -150,7 +161,7 @@ export const folderCommitServiceFactory = ({ }: TFolderCommitServiceFactoryDep) => { const appCfg = getConfig(); - const checkProjectPermission = async ({ + const checkProjectCommitReadPermission = async ({ actor, actorId, projectId, @@ -377,7 +388,7 @@ export const folderCommitServiceFactory = ({ targetCommitId: string; defaultOperation?: "create" | "update" | "delete"; tx?: Knex; - }) => { + }): Promise => { const targetCommit = await folderCommitDAL.findById(targetCommitId, tx); if (!targetCommit) { throw new NotFoundError({ message: `Commit with ID ${targetCommitId} not found` }); @@ -387,17 +398,34 @@ export const folderCommitServiceFactory = ({ if (!currentCommitId) { const targetState = await reconstructFolderState(targetCommitId, tx); - return targetState.map((resource) => ({ - type: resource.type, - id: resource.id, - versionId: resource.versionId, - changeType: defaultOperation, - commitId: targetCommit.commitId, - secretKey: resource.secretKey, - secretVersion: resource.secretVersion, - folderName: resource.folderName, - folderVersion: resource.folderVersion - })) as ResourceChange[]; + return targetState + .map((resource): ResourceChange | null => { + if (resource.type === ResourceType.SECRET) { + return { + type: ResourceType.SECRET, + id: resource.id, + versionId: resource.versionId, + changeType: defaultOperation as ChangeType, + commitId: targetCommit.commitId, + secretKey: resource.secretKey, + secretVersion: resource.secretVersion, + secretId: resource.id + }; + } + if (resource.type === ResourceType.FOLDER) { + return { + type: ResourceType.FOLDER, + id: resource.id, + versionId: resource.versionId, + changeType: defaultOperation as ChangeType, + commitId: targetCommit.commitId, + folderName: resource.folderName, + folderVersion: resource.folderVersion + }; + } + return null; + }) + .filter((change): change is ResourceChange => !!change); } // Original logic for when currentCommitId is provided @@ -452,49 +480,88 @@ export const folderCommitServiceFactory = ({ const targetResource = targetMap[key]; if (!targetResource) { - differences.push({ - type: currentResource.type, - id: currentResource.id, - versionId: currentResource.versionId, - changeType: ChangeType.DELETE, - commitId: targetCommit.commitId, - secretKey: currentResource.secretKey, - secretVersion: currentResource.secretVersion, - folderName: currentResource.folderName, - folderVersion: currentResource.folderVersion, - fromVersion: currentResource.versionId - }); + // Resource was deleted + if (currentResource.type === ResourceType.SECRET) { + differences.push({ + type: ResourceType.SECRET, + id: currentResource.id, + versionId: currentResource.versionId, + changeType: ChangeType.DELETE, + commitId: targetCommit.commitId, + secretKey: currentResource.secretKey, + secretVersion: currentResource.secretVersion, + secretId: currentResource.id, + fromVersion: currentResource.versionId + }); + } else if (currentResource.type === ResourceType.FOLDER) { + differences.push({ + type: ResourceType.FOLDER, + id: currentResource.id, + versionId: currentResource.versionId, + changeType: ChangeType.DELETE, + commitId: targetCommit.commitId, + folderName: currentResource.folderName, + folderVersion: currentResource.folderVersion, + fromVersion: currentResource.versionId + }); + } } else if (currentResource.versionId !== targetResource.versionId) { - differences.push({ - type: targetResource.type, - id: targetResource.id, - versionId: targetResource.versionId, - changeType: ChangeType.UPDATE, - commitId: targetCommit.commitId, - secretKey: targetResource.secretKey, - secretVersion: targetResource.secretVersion, - folderName: targetResource.folderName, - folderVersion: targetResource.folderVersion, - fromVersion: currentResource.folderVersion || currentResource.secretVersion - }); + // Resource was updated + if (targetResource.type === ResourceType.SECRET) { + differences.push({ + type: ResourceType.SECRET, + id: targetResource.id, + versionId: targetResource.versionId, + changeType: ChangeType.UPDATE, + commitId: targetCommit.commitId, + secretKey: targetResource.secretKey, + secretVersion: targetResource.secretVersion, + secretId: targetResource.id, + fromVersion: currentResource.secretVersion + }); + } else if (targetResource.type === ResourceType.FOLDER) { + differences.push({ + type: ResourceType.FOLDER, + id: targetResource.id, + versionId: targetResource.versionId, + changeType: ChangeType.UPDATE, + commitId: targetCommit.commitId, + folderName: targetResource.folderName, + folderVersion: targetResource.folderVersion, + fromVersion: currentResource.folderVersion + }); + } } }); + // Find new resources Object.keys(targetMap).forEach((key) => { if (!currentMap[key]) { const targetResource = targetMap[key]; - differences.push({ - type: targetResource.type, - id: targetResource.id, - versionId: targetResource.versionId, - changeType: ChangeType.CREATE, - commitId: targetCommit.commitId, - createdAt: targetCommit.createdAt, - secretKey: targetResource.secretKey, - secretVersion: targetResource.secretVersion, - folderName: targetResource.folderName, - folderVersion: targetResource.folderVersion - }); + if (targetResource.type === ResourceType.SECRET) { + differences.push({ + type: ResourceType.SECRET, + id: targetResource.id, + versionId: targetResource.versionId, + changeType: ChangeType.CREATE, + commitId: targetCommit.commitId, + createdAt: targetCommit.createdAt, + secretKey: targetResource.secretKey, + secretVersion: targetResource.secretVersion, + secretId: targetResource.id + }); + } else if (targetResource.type === ResourceType.FOLDER) { + differences.push({ + type: ResourceType.FOLDER, + id: targetResource.id, + versionId: targetResource.versionId, + changeType: ChangeType.CREATE, + commitId: targetCommit.commitId, + createdAt: targetCommit.createdAt, + folderName: targetResource.folderName, + folderVersion: targetResource.folderVersion + }); + } } }); @@ -640,13 +707,18 @@ export const folderCommitServiceFactory = ({ ) => { const commitChanges = []; + // Filter only secret changes using discriminated union + const secretChanges = changes.filter( + (change): change is ResourceChange & SecretChange => change.type === ResourceType.SECRET + ); + // Collect all secretIds for batch lookup - const secretIds = changes.map((change) => secretVersions[change.id]?.secretId).filter(Boolean); + const secretIds = secretChanges.map((change) => secretVersions[change.id]?.secretId).filter(Boolean); // Fetch all latest versions in one call const latestVersionsMap = await secretVersionV2BridgeDAL.findLatestVersionMany(folderId, secretIds, tx); - for (const change of changes) { + for (const change of secretChanges) { const secretVersion = secretVersions[change.id]; // eslint-disable-next-line no-continue if (!secretVersion) continue; @@ -800,7 +872,12 @@ export const folderCommitServiceFactory = ({ ) => { const commitChanges = []; - for (const change of changes) { + // Filter only folder changes using discriminated union + const folderChanges = changes.filter( + (change): change is ResourceChange & FolderChange => change.type === ResourceType.FOLDER + ); + + for (const change of folderChanges) { const folderVersion = folderVersions[change.id]; switch (change.changeType) { @@ -913,9 +990,14 @@ export const folderCommitServiceFactory = ({ return commitChanges; }; - // Group differences by type for more efficient processing - const secretChanges = differences.filter((diff) => diff.type === ResourceType.SECRET); - const folderChanges = differences.filter((diff) => diff.type === ResourceType.FOLDER); + + // Group differences by type for more efficient processing using discriminated unions + const secretChanges = differences.filter( + (diff): diff is ResourceChange & SecretChange => diff.type === ResourceType.SECRET + ); + const folderChanges = differences.filter( + (diff): diff is ResourceChange & FolderChange => diff.type === ResourceType.FOLDER + ); // Batch fetch necessary data const secretVersions = await secretVersionV2BridgeDAL.findByIdsWithLatestVersion( @@ -933,8 +1015,8 @@ export const folderCommitServiceFactory = ({ // Process changes in parallel const [secretCommitChanges, folderCommitChanges] = await Promise.all([ - processSecretChanges(secretChanges, secretVersions, actorInfo, folderId, tx), - processFolderChanges(folderChanges, folderVersions) + processSecretChanges(differences, secretVersions, actorInfo, folderId, tx), + processFolderChanges(differences, folderVersions) ]); // Combine all changes @@ -988,8 +1070,31 @@ export const folderCommitServiceFactory = ({ /** * Retrieve a commit by ID */ - const getCommitById = async (id: string, tx?: Knex) => { - return folderCommitDAL.findById(id, tx); + const getCommitById = async ({ + commitId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + tx + }: { + commitId: string; + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + tx?: Knex; + }) => { + await checkProjectCommitReadPermission({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }); + return folderCommitDAL.findById(commitId, tx); }; /** @@ -1024,7 +1129,7 @@ export const folderCommitServiceFactory = ({ search?: string; sort: "asc" | "desc"; }) => { - await checkProjectPermission({ + await checkProjectCommitReadPermission({ actor, actorId, actorAuthMethod, @@ -1063,7 +1168,7 @@ export const folderCommitServiceFactory = ({ environment: string; path: string; }) => { - await checkProjectPermission({ + await checkProjectCommitReadPermission({ actor, actorId, actorAuthMethod, @@ -1099,7 +1204,7 @@ export const folderCommitServiceFactory = ({ projectId: string; commitId: string; }) => { - await checkProjectPermission({ + await checkProjectCommitReadPermission({ actor, actorId, actorAuthMethod, @@ -1382,7 +1487,8 @@ export const folderCommitServiceFactory = ({ envId: string, actorId: string, actorType: ActorType, - projectId: string + projectId: string, + message?: string ) => { await folderCommitDAL.transaction(async (tx) => { const targetCommit = await folderCommitDAL.findById(targetCommitId, tx); @@ -1480,7 +1586,7 @@ export const folderCommitServiceFactory = ({ actorInfo: { actorType, actorId, - message: "Deep rollback" + message: message || "Deep rollback" }, folderId: folder.id, projectId, @@ -1508,7 +1614,7 @@ export const folderCommitServiceFactory = ({ actorOrgId: string; projectId: string; }) => { - await checkProjectPermission({ + await checkProjectCommitReadPermission({ actor, actorId, actorAuthMethod, @@ -1555,7 +1661,7 @@ export const folderCommitServiceFactory = ({ ProjectPermissionSub.Commits ); // Check permissions first - await checkProjectPermission({ + await checkProjectCommitReadPermission({ actor, actorId, projectId, @@ -1569,32 +1675,12 @@ export const folderCommitServiceFactory = ({ throw new NotFoundError({ message: `Commit with ID ${commitId} not found` }); } - // Get all commits for this folder - const allCommits = await folderCommitDAL.findByFolderId(commitToRevert.folderId); - if (!allCommits || allCommits.length === 0) { - throw new NotFoundError({ message: `No commits found for folder ${commitToRevert.folderId}` }); - } + const previousCommit = await folderCommitDAL.findCommitBefore(commitToRevert.folderId, commitToRevert.commitId); - // Sort commits by commitId (which appears to be numeric) - const sortedCommits = allCommits.sort((a, b) => { - if (a.commitId < b.commitId) return -1; - if (a.commitId > b.commitId) return 1; - return 0; - }); - // Find the index of the commit to revert - const commitIndex = sortedCommits.findIndex((c) => c.id === commitId); - if (commitIndex === -1) { - throw new NotFoundError({ message: `Commit ${commitId} not found in the commit history` }); - } - - // If it's the first commit, we can't revert it (nothing before it) - if (commitIndex === 0) { + if (!previousCommit) { throw new BadRequestError({ message: "Cannot revert the first commit" }); } - // Get the commit just before the one we want to revert - const previousCommit = sortedCommits[commitIndex - 1]; - // Calculate the changes needed to go from current commit back to the previous one const inverseChanges = await compareFolderStates({ currentCommitId: commitToRevert.id, diff --git a/backend/src/services/folder-commit/folder-commit-types.ts b/backend/src/services/folder-commit/folder-commit-types.ts deleted file mode 100644 index ffbbf18cd..000000000 --- a/backend/src/services/folder-commit/folder-commit-types.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { z } from "zod"; - -const baseChangeSchema = z.object({ - id: z.string(), - folderCommitId: z.string(), - changeType: z.string(), - isUpdate: z.boolean().optional(), - createdAt: z.union([z.string(), z.date()]), - updatedAt: z.union([z.string(), z.date()]), - actorMetadata: z - .union([ - z.object({ - id: z.string().optional(), - name: z.string().optional() - }), - z.unknown() - ]) - .optional(), - actorType: z.string().optional(), - message: z.string().optional().nullable(), - folderId: z.string().optional() -}); - -const commitChangeSchema = baseChangeSchema.extend({ - secretVersionId: z.string().optional().nullable(), - folderVersionId: z.string().optional().nullable(), - folderName: z.string().optional().nullable(), - folderChangeId: z.string().optional().nullable(), - secretKey: z.string().optional().nullable(), - secretVersion: z.union([z.string(), z.number()]).optional().nullable(), - secretId: z.string().optional().nullable(), - folderVersion: z.union([z.string(), z.number()]).optional().nullable(), - versions: z - .array( - z.object({ - secretKey: z.string().optional().nullable(), - secretComment: z.string().optional().nullable(), - skipMultilineEncoding: z.boolean().optional().nullable(), - secretReminderRepeatDays: z.number().optional().nullable(), - secretReminderNote: z.string().optional().nullable(), - metadata: z.unknown().optional().nullable(), - tags: z.array(z.string()).optional().nullable(), - secretReminderRecipients: z.array(z.any()).optional().nullable(), - secretValue: z.string().optional().nullable(), - name: z.string().optional().nullable() - }) - ) - .optional() -}); - -const commitSchema = z.object({ - id: z.string(), - commitId: z.string(), - actorMetadata: z - .union([ - z.object({ - id: z.string().optional(), - name: z.string().optional() - }), - z.unknown() - ]) - .optional(), - actorType: z.string(), - message: z.string().nullable().optional(), - folderId: z.string(), - envId: z.string(), - createdAt: z.union([z.string(), z.date()]), - updatedAt: z.union([z.string(), z.date()]), - changes: z.array(commitChangeSchema).optional() -}); - -export const commitChangesResponseSchema = z.object({ - changes: commitSchema -}); diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index c8352cc67..0012aa3e1 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -10,7 +10,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns"; -import { CommitType, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; +import { ChangeType, CommitType, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretFolderDALFactory } from "./secret-folder-dal"; @@ -783,6 +783,27 @@ export const secretFolderServiceFactory = ({ return versions; }; + const getFolderVersions = async ( + change: { + folderVersion?: string; + isUpdate?: boolean; + changeType?: string; + }, + fromVersion: string, + folderId: string + ) => { + const currentVersion = change.folderVersion || "1"; + // eslint-disable-next-line no-await-in-loop + const versions = await getFolderVersionsByIds({ + folderId, + folderVersions: + change.isUpdate || change.changeType === ChangeType.UPDATE ? [currentVersion, fromVersion] : [currentVersion] + }); + return versions.map((v) => ({ + name: v.name + })); + }; + return { createFolder, updateFolder, @@ -794,6 +815,7 @@ export const secretFolderServiceFactory = ({ getFoldersMultiEnv, getFoldersDeepByEnvs, getProjectEnvironmentsFolders, - getFolderVersionsByIds + getFolderVersionsByIds, + getFolderVersions }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 0af512501..650dc7aeb 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -128,6 +128,7 @@ export const fnSecretBulkInsert = async ({ userActorId, identityActorId, actorType, + metadata: el.metadata ? JSON.stringify(el.metadata) : null, secretId: newSecretGroupedByKeyName[el.key][0].id })), tx @@ -273,7 +274,7 @@ export const fnSecretBulkUpdate = async ({ userId, encryptedComment, version, - metadata, + metadata: metadata ? JSON.stringify(metadata) : null, reminderNote, encryptedValue, reminderRepeatDays, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index d21156631..f83a3e148 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -16,6 +16,7 @@ import { } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { + ProjectPermissionActions, ProjectPermissionCommitsActions, ProjectPermissionSecretActions, ProjectPermissionSet, @@ -528,6 +529,7 @@ export const secretV2BridgeServiceFactory = ({ skipMultilineEncoding: inputSecret.skipMultilineEncoding, key: inputSecret.newSecretName || secretName, tags: inputSecret.tagIds, + metadata: JSON.stringify(secretMetadata), secretMetadata, ...encryptedValue } @@ -2173,7 +2175,13 @@ export const secretV2BridgeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); + + const canRead = + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback) || + permission.can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); + + if (!canRead) throw new ForbiddenRequestError({ message: "You do not have permission to read secret versions" }); + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId: folder.projectId @@ -2889,7 +2897,13 @@ export const secretV2BridgeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); + + const canRead = + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback) || + permission.can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); + + if (!canRead) throw new ForbiddenRequestError({ message: "You do not have permission to read secret versions" }); + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId: folder.projectId diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts index aaf74b735..0e869e05e 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -199,13 +199,13 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { .leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`) .leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`) .leftJoin( - TableName.SecretV2JnTag, - `${TableName.SecretV2}.id`, - `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + TableName.SecretVersionV2Tag, + `${TableName.SecretVersionV2}.id`, + `${TableName.SecretVersionV2Tag}.${TableName.SecretVersionV2}Id` ) .leftJoin( TableName.SecretTag, - `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretVersionV2Tag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) .where((qb) => { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 136a2e228..c28e8da1c 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -44,7 +44,8 @@ import { TGetSecretsRawByFolderMappingsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; -import { ActorType } from "../auth/auth-type"; +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { ChangeType } from "../folder-commit/folder-commit-service"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -3303,6 +3304,51 @@ export const secretServiceFactory = ({ return secrets; }; + const getChangeVersions = async ( + change: { + secretVersion: string; + secretId?: string; + id?: string; + isUpdate?: boolean; + changeType?: string; + }, + previousVersion: string, + actorId: string, + actor: ActorType, + actorOrgId: string, + actorAuthMethod: ActorAuthMethod, + folderId: string + ) => { + const currentVersion = change.secretVersion; + const secretId = change.secretId ? change.secretId : change.id; + if (!secretId) { + return; + } + const versions = await getSecretVersionsV2ByIds({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + secretId, + // if it's update add also the previous secretversionid + secretVersions: + change.isUpdate || change.changeType === ChangeType.UPDATE + ? [currentVersion, previousVersion] + : [currentVersion], + folderId + }); + return versions?.map((v) => ({ + secretKey: v.secretKey, + secretComment: v.secretComment, + skipMultilineEncoding: v.skipMultilineEncoding, + secretReminderRepeatDays: v.secretReminderRepeatDays, + tags: v.tags?.map((tag) => tag.slug), + metadata: v.metadata, + secretReminderNote: v.secretReminderNote, + secretValue: v.secretValue + })); + }; + return { attachTags, detachTags, @@ -3334,6 +3380,7 @@ export const secretServiceFactory = ({ getSecretAccessList, getSecretByIdRaw, getAccessibleSecrets, - getSecretVersionsV2ByIds + getSecretVersionsV2ByIds, + getChangeVersions }; }; diff --git a/frontend/src/hooks/api/folderCommits/queries.tsx b/frontend/src/hooks/api/folderCommits/queries.tsx index 503a0d3f1..2724b8f5c 100644 --- a/frontend/src/hooks/api/folderCommits/queries.tsx +++ b/frontend/src/hooks/api/folderCommits/queries.tsx @@ -58,7 +58,7 @@ const fetchFolderCommitsCount = async ({ params: { environment, path: directory, - workspaceId + projectId: workspaceId } } ); @@ -86,7 +86,7 @@ const fetchFolderCommitHistory = async ( params: { environment, path: directory, - workspaceId, + projectId: workspaceId, offset, limit, search, @@ -101,7 +101,7 @@ export const fetchCommitDetails = async (workspaceId: string, commitId: string) `/api/v1/pit/commits/${commitId}/changes`, { params: { - workspaceId + projectId: workspaceId } } ); @@ -124,7 +124,7 @@ export const fetchRollbackPreview = async ( envId, deepRollback, secretPath, - workspaceId + projectId: workspaceId } } ); @@ -146,7 +146,7 @@ const fetchRollback = async ( deepRollback, message, envId, - workspaceId + projectId: workspaceId } ); return data; @@ -156,7 +156,7 @@ const fetchRevert = async (commitId: string, workspaceId: string) => { const { data } = await apiRequest.post<{ success: boolean; message: string }>( `/api/v1/pit/commits/${commitId}/revert`, { - workspaceId + projectId: workspaceId } ); return data; diff --git a/frontend/src/pages/secret-manager/CommitDetailsPage/components/RollbackPreviewTab/RollbackPreviewTab.tsx b/frontend/src/pages/secret-manager/CommitDetailsPage/components/RollbackPreviewTab/RollbackPreviewTab.tsx index 8af159168..0e32fd54d 100644 --- a/frontend/src/pages/secret-manager/CommitDetailsPage/components/RollbackPreviewTab/RollbackPreviewTab.tsx +++ b/frontend/src/pages/secret-manager/CommitDetailsPage/components/RollbackPreviewTab/RollbackPreviewTab.tsx @@ -361,8 +361,8 @@ export const RollbackPreviewTab = (): JSX.Element => { subTitle={` ${ deepRollback - ? "This will revert all changes to how they appeared at the point in time of this commit." - : "This will revert this folder and all child folders to how they appeared at the point in time of this commit." + ? "This will revert this folder and all child folders to how they appeared at the point in time of this commit." + : "This will revert all changes to how they appeared at the point in time of this commit." } Any changes made after this commit will be permanently removed. `} diff --git a/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx b/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx index 3f301878f..222f7d99c 100644 --- a/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx +++ b/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx @@ -1,22 +1,18 @@ -/* eslint-disable react/prop-types */ -import { useRef, useState } from "react"; +/* eslint-disable no-nested-ternary */ +import { useCallback, useRef, useState } from "react"; import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; export interface Version { id?: string; version: number; - // Secret-specific fields - secretKey?: string; - secretValue?: string; - secretComment?: string; - skipMultilineEncoding?: boolean; - // Folder-specific fields - name?: string; - // Allow other properties [key: string]: any; } +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +type JsonObject = { [key: string]: JsonValue }; +type JsonArray = JsonValue[]; + export interface DiffViewItem { type: "secret" | "folder"; isAdded?: boolean; @@ -35,244 +31,423 @@ interface SecretVersionDiffViewProps { onToggleCollapse?: (id: string) => void; showHeader?: boolean; customHeader?: JSX.Element; + excludedFieldsHighlight?: string[]; } -const highlightChangedFields = ( - json: any, - changedFields: Set, - isOldVersion: boolean +const isObject = (obj: JsonValue): obj is JsonObject => { + return obj !== null && typeof obj === "object" && !Array.isArray(obj); +}; + +const isArray = (obj: JsonValue): obj is JsonArray => { + return Array.isArray(obj); +}; + +const deepEqual = (a: JsonValue, b: JsonValue): boolean => { + if (a === b) return true; + if (a == null || b == null) return false; + if (typeof a !== typeof b) return false; + + if (isArray(a) && isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item: JsonValue, index: number) => deepEqual(item, b[index])); + } + + if (isObject(a) && isObject(b)) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every((key) => keysB.includes(key) && deepEqual(a[key], b[key])); + } + + return false; +}; + +const getDiffPaths = (oldObj: JsonValue, newObj: JsonValue, path: string = ""): Set => { + const diffPaths = new Set(); + + if (oldObj === newObj) return diffPaths; + + if (oldObj == null || newObj == null) { + diffPaths.add(path || "root"); + return diffPaths; + } + + if (typeof oldObj !== typeof newObj) { + diffPaths.add(path || "root"); + return diffPaths; + } + + if (isArray(oldObj) && isArray(newObj)) { + return diffPaths; + } + + if (isObject(oldObj) && isObject(newObj)) { + const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]); + + allKeys.forEach((key) => { + const currentPath = path ? `${path}.${key}` : key; + + if (path.includes("[")) { + return; + } + + if (!(key in oldObj) || !(key in newObj) || !deepEqual(oldObj[key], newObj[key])) { + diffPaths.add(currentPath); + + if ( + key in oldObj && + key in newObj && + (isObject(oldObj[key]) || isArray(oldObj[key])) && + (isObject(newObj[key]) || isArray(newObj[key])) + ) { + const nestedDiffs = getDiffPaths(oldObj[key], newObj[key], currentPath); + nestedDiffs.forEach((p) => diffPaths.add(p)); + } + } + }); + return diffPaths; + } + + if (oldObj !== newObj) { + diffPaths.add(path || "root"); + } + + return diffPaths; +}; + +const getNestedValue = (obj: JsonValue, path: string): JsonValue => { + if (!path) return obj; + + const parts = path.split(/[.[\]]+/).filter(Boolean); + let current: JsonValue = obj; + + parts.forEach((part) => { + if (current == null) { + return; + } + if (isObject(current)) { + current = current[part]; + } else if (isArray(current)) { + const index = parseInt(part, 10); + if (!Number.isNaN(index)) { + current = current[index]; + } + } + }); + + return current; +}; + +const isPathDifferent = (jsonPath: string, diffPaths: Set): boolean => { + if (diffPaths.has(jsonPath)) return true; + + const diffPathsArray = Array.from(diffPaths); + return diffPathsArray.some((diffPath) => { + return ( + jsonPath.startsWith(`${diffPath}.`) || + jsonPath.startsWith(`${diffPath}[`) || + diffPath.startsWith(`${jsonPath}.`) || + diffPath.startsWith(`${jsonPath}[`) + ); + }); +}; + +const isContainerActuallyChanged = ( + path: string, + diffPaths: Set, + oldObj: JsonValue, + newObj: JsonValue +): boolean => { + if (diffPaths.has(path)) { + if (oldObj == null || newObj == null) return true; + if (typeof oldObj !== typeof newObj) return true; + if (isArray(oldObj) !== isArray(newObj)) return true; + if (isObject(oldObj) !== isObject(newObj)) return true; + + if (!isObject(oldObj) && !isArray(oldObj)) return true; + + return false; + } + + if (isArray(oldObj) && isArray(newObj)) { + return false; + } + + if (isObject(oldObj) && isObject(newObj)) { + return false; + } + + return oldObj !== newObj; +}; + +const renderJsonWithDiffs = ( + obj: JsonValue, + diffPaths: Set, + isOldVersion: boolean, + path: string = "", + indentLevel: number = 0, + keyName?: string, + isLastItem: boolean = false, + excludedFieldsHighlight: string[] = [], + oldVersionObj?: JsonValue, + newVersionObj?: JsonValue ): JSX.Element => { - const lines = JSON.stringify(json, null, 2).split("\n"); - return ( -
- {lines.map((line, idx) => { - const fieldMatch = line.match(/"([^"]+)":/); - if (fieldMatch && changedFields.has(fieldMatch[1])) { - // Check for different value types and highlight them + const indent = " ".repeat(indentLevel); - // 1. String values: "field": "value" - const stringMatch = line.match(/: "([^"]*)"(,?)$/); - if (stringMatch) { - const beforeValue = line.substring(0, line.indexOf(': "')); - const value = `"${stringMatch[1]}"`; - const afterValue = stringMatch[2] || ""; + let isDifferent = false; - return ( -
-
{isOldVersion ? "-" : "+"}
-
- {beforeValue}:{" "} - - {value} - - {afterValue} -
-
- ); - } + if (path.includes("[") && oldVersionObj && newVersionObj) { + const arrayMatch = path.match(/^([^[]+)\[(\d+)\]/); + if (arrayMatch) { + const arrayPath = arrayMatch[1]; + const itemIndex = parseInt(arrayMatch[2], 10); - // 2. Number values: "field": 123 - const numberMatch = line.match(/: (-?\d+(?:\.\d+)?)(,?)$/); - if (numberMatch) { - const beforeValue = line.substring(0, line.indexOf(": ") + 2); - const value = numberMatch[1]; - const afterValue = numberMatch[2] || ""; + const oldArray = getNestedValue(oldVersionObj, arrayPath); + const newArray = getNestedValue(newVersionObj, arrayPath); - return ( -
-
{isOldVersion ? "-" : "+"}
-
- {beforeValue.substring(0, beforeValue.length - 2)}:{" "} - - {value} - - {afterValue} -
-
- ); - } + if (isArray(oldArray) && isArray(newArray)) { + const currentItem = isOldVersion ? oldArray[itemIndex] : newArray[itemIndex]; - // 3. Null values: "field": null - const nullMatch = line.match(/: (null)(,?)$/); - if (nullMatch) { - const beforeValue = line.substring(0, line.indexOf(": ") + 2); - const value = nullMatch[1]; - const afterValue = nullMatch[2] || ""; + if (isOldVersion) { + isDifferent = !newArray.some((newItem: JsonValue) => deepEqual(currentItem, newItem)); + } else { + isDifferent = !oldArray.some((oldItem: JsonValue) => deepEqual(currentItem, oldItem)); + } + } else { + isDifferent = isPathDifferent(path, diffPaths); + } + } else { + isDifferent = isPathDifferent(path, diffPaths); + } + } else { + isDifferent = isPathDifferent(path, diffPaths); + } - return ( -
-
{isOldVersion ? "-" : "+"}
-
- {beforeValue.substring(0, beforeValue.length - 2)}:{" "} - - {value} - - {afterValue} -
-
- ); - } + const getLineClass = (different: boolean) => { + if (!different) return "flex"; + return isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300"; + }; - // 4. Boolean values: "field": true|false - const booleanMatch = line.match(/: (true|false)(,?)$/); - if (booleanMatch) { - const beforeValue = line.substring(0, line.indexOf(": ") + 2); - const value = booleanMatch[1]; - const afterValue = booleanMatch[2] || ""; + const getHighlightClass = (different: boolean) => { + if (!different) return ""; + return isOldVersion ? "bg-red-900 rounded px-1" : "bg-green-900 rounded px-1"; + }; - return ( -
-
{isOldVersion ? "-" : "+"}
-
- {beforeValue.substring(0, beforeValue.length - 2)}:{" "} - - {value} - - {afterValue} -
-
- ); - } + const prefix = isDifferent ? (isOldVersion ? "-" : "+") : " "; + const keyDisplay = keyName ? `"${keyName}": ` : ""; + const comma = !isLastItem ? "," : ""; - // 5. Array values: Handle the first line of an array - "field": [ - const arrayStartMatch = line.match(/: \[(,?)$/); - if (arrayStartMatch) { - // This is the start of an array, highlight the whole line - return ( -
-
{isOldVersion ? "-" : "+"}
-
{line}
-
- ); - } + const reactKey = `${path || "root"}-${keyName || "value"}-${indentLevel}-${typeof obj}`; - // 6. Empty Array values: "field": [] - const emptyArrayMatch = line.match(/: \[\](,?)$/); - if (emptyArrayMatch) { - const beforeValue = line.substring(0, line.indexOf(": ") + 2); - const value = "[]"; - const afterValue = emptyArrayMatch[1] || ""; + if ( + obj === null || + typeof obj === "string" || + typeof obj === "number" || + typeof obj === "boolean" + ) { + let valueDisplay = ""; + if (obj === null) valueDisplay = "null"; + else if (typeof obj === "string") valueDisplay = `"${obj}"`; + else valueDisplay = String(obj); - return ( -
-
{isOldVersion ? "-" : "+"}
-
- {beforeValue.substring(0, beforeValue.length - 2)}:{" "} - - {value} - - {afterValue} -
-
- ); - } + return ( +
+
{prefix}
+
+ {indent} + {keyName && {keyDisplay}} + {valueDisplay} + {comma} +
+
+ ); + } - // Check if this is part of an array or object that belongs to a changed field - // This handles array items, closing brackets, and other complex structure contents - const belongsToChangedField = () => { - // If we're inside an array or object of a changed field, highlight it - let openBrackets = 0; - let openBraces = 0; - let currentFieldName = null; + if (isArray(obj) && obj.length === 0) { + return ( +
+
{prefix}
+
+ {indent} + {keyName && {keyDisplay}} + [] + {comma} +
+
+ ); + } - for (let i = idx - 1; i >= 0; i -= 1) { - const prevLine = lines[i]; + if (isObject(obj) && Object.keys(obj).length === 0) { + return ( +
+
{prefix}
+
+ {indent} + {keyName && {keyDisplay}} + {"{}"} + {comma} +
+
+ ); + } - // Count brackets and braces to track nesting - openBrackets += (prevLine.match(/\[/g) || []).length; - openBrackets -= (prevLine.match(/\]/g) || []).length; - openBraces += (prevLine.match(/{/g) || []).length; - openBraces -= (prevLine.match(/}/g) || []).length; + let isContainerAddedOrRemoved = false; - // If we find a field and we're still inside its value, check if it's a changed field - const fieldNameMatch = prevLine.match(/"([^"]+)":/); - if (fieldNameMatch && (openBrackets > 0 || openBraces > 0)) { - [, currentFieldName] = fieldNameMatch; - return changedFields.has(currentFieldName); - } + if (oldVersionObj && newVersionObj) { + const oldValue = getNestedValue(oldVersionObj, path); + const newValue = getNestedValue(newVersionObj, path); - // If we've reached the root level, stop looking - if (openBrackets <= 0 && openBraces <= 0) { - return false; - } - } + if (oldValue == null || newValue == null) { + isContainerAddedOrRemoved = true; + } else if (typeof oldValue !== typeof newValue) { + isContainerAddedOrRemoved = true; + } else if (isArray(oldValue) !== isArray(newValue)) { + isContainerAddedOrRemoved = true; + } else if (isObject(oldValue) !== isObject(newValue)) { + isContainerAddedOrRemoved = true; + } + } else { + isContainerAddedOrRemoved = isContainerActuallyChanged( + path, + diffPaths, + isOldVersion ? obj : oldVersionObj || null, + isOldVersion ? newVersionObj || null : obj + ); + } - return false; - }; + if (isArray(obj)) { + return ( +
+
+
+ {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} +
+
+ {indent} + {keyName && ( + + {keyDisplay} + + )} + [ +
+
- // Regular line with changed field or part of a changed complex structure - if (belongsToChangedField()) { - return ( -
-
{isOldVersion ? "-" : "+"}
-
{line}
-
- ); - } + {obj.map((item: JsonValue, index: number) => { + const itemPath = path ? `${path}[${index}]` : `[${index}]`; + const isLast = index === obj.length - 1; - // Simple fallback for any other cases of changed fields return ( -
-
{isOldVersion ? "-" : "+"}
-
{line}
+
+ {renderJsonWithDiffs( + item, + diffPaths, + isOldVersion, + itemPath, + indentLevel + 1, + undefined, + isLast, + excludedFieldsHighlight, + oldVersionObj, + newVersionObj + )}
); - } + })} - // Unchanged lines +
+
+ {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} +
+
+ {indent} + ] + {comma} +
+
+
+ ); + } + + if (isObject(obj)) { + const keys = Object.keys(obj); + + return ( +
+
+
+ {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} +
+
+ {indent} + {keyName && ( + + {keyDisplay} + + )} + {"{"} +
+
+ + {keys.map((key, index) => { + const keyPath = path ? `${path}.${key}` : key; + const isLast = index === keys.length - 1; + const propKey = `${reactKey}-prop-${key}`; + + return ( +
+ {renderJsonWithDiffs( + obj[key], + diffPaths, + isOldVersion, + keyPath, + indentLevel + 1, + key, + isLast, + excludedFieldsHighlight, + oldVersionObj, + newVersionObj + )} +
+ ); + })} + +
+
+ {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} +
+
+ {indent} + {"}"} + {comma} +
+
+
+ ); + } + + return ( +
+
{prefix}
+
+ {indent} + {keyDisplay} + {String(obj)} + {comma} +
+
+ ); +}; + +const formatAddedJson = (json: JsonValue): JSX.Element => { + const lines = JSON.stringify(json, null, 2).split("\n"); + return ( +
+ {lines.map((line, lineIndex) => { + const lineKey = `added-${line.slice(0, 30)}-${lineIndex}`; return ( -
-
+
+
+
{line}
); @@ -281,224 +456,121 @@ const highlightChangedFields = ( ); }; -// Helper: Format added JSON with + in a separate column -const formatAddedJson = (json: any): JSX.Element => { - const lines = JSON.stringify(json, null, 2).split("\n"); - return ( -
- {lines.map((line, idx) => ( -
-
+
-
{line}
-
- ))} -
- ); -}; - -// Helper: Format deleted JSON with - in a separate column -const formatDeletedJson = (json: any): JSX.Element => { +const formatDeletedJson = (json: JsonValue): JSX.Element => { const lines = JSON.stringify(json, null, 2).split("\n"); return (
- {lines.map((line, idx) => ( -
-
-
-
{line}
-
- ))} + {lines.map((line, lineIndex) => { + const lineKey = `deleted-${line.slice(0, 30)}-${lineIndex}`; + return ( +
+
-
+
{line}
+
+ ); + })}
); }; -// Helper: Get differences between secret versions -export const getVersionDifferences = (versions: Version[]) => { - if (!versions || versions.length === 0) return []; - - // Sort versions by version number (descending) - const sortedVersions = [...versions].sort((a, b) => b.version - a.version); - const newVersion = sortedVersions[0]; - - // Fields to process - const fieldsToProcess = [ - "secretKey", - "secretValue", - "secretComment", - "skipMultilineEncoding", - "secretReminderRepeatDays", - "secretReminderNote", - "metadata", - "tags", - "secretReminderRecipients", - "name" - ]; - - // If only one version exists - if (sortedVersions.length === 1) { - return fieldsToProcess.reduce( - (differences, field) => { - if (newVersion[field] !== undefined && newVersion[field] !== null) { - let newVal = newVersion[field]; - - if (field === "tags" && Array.isArray(newVersion[field])) { - if (newVersion[field].length > 0 && typeof newVersion[field][0] === "object") { - newVal = newVersion[field].map((tag) => tag.name).join(", "); - } else if (Array.isArray(newVersion[field])) { - newVal = newVersion[field].join(", "); - } - } - - differences.push({ - field, - oldValue: null, - newValue: newVal - }); - } - return differences; - }, - [] as { field: string; oldValue: any; newValue: any }[] - ); - } - - // Otherwise, compare the two versions - const oldVersion = sortedVersions[1]; - - return fieldsToProcess.reduce( - (differences, field) => { - if (JSON.stringify(oldVersion[field]) !== JSON.stringify(newVersion[field])) { - let oldVal = oldVersion[field]; - let newVal = newVersion[field]; - - if (field === "tags" && Array.isArray(oldVersion[field])) { - if (oldVersion[field].length > 0 && typeof oldVersion[field][0] === "object") { - oldVal = oldVersion[field].map((tag) => tag.name).join(", "); - } else if (Array.isArray(oldVersion[field])) { - oldVal = oldVersion[field].join(", "); - } - } - - if (field === "tags" && Array.isArray(newVersion[field])) { - if (newVersion[field].length > 0 && typeof newVersion[field][0] === "object") { - newVal = newVersion[field].map((tag) => tag.name).join(", "); - } else if (Array.isArray(newVersion[field])) { - newVal = newVersion[field].join(", "); - } - } - - differences.push({ - field, - oldValue: oldVal, - newValue: newVal - }); - } - return differences; - }, - [] as { field: string; oldValue: any; newValue: any }[] - ); +const cleanVersionForComparison = (version: Version): JsonValue => { + const { id, version: versionNumber, ...cleanVersion } = version; + return cleanVersion; }; -// Helper: Get differences between folder versions -export const getFolderDifferences = (versions: Version[]) => { - if (!versions || versions.length === 0) return []; - - const sortedVersions = [...versions].sort((a, b) => b.version - a.version); - const newVersion = sortedVersions[0]; - - if (sortedVersions.length === 1) { - return [ - { - field: "folderName", - oldValue: null, - newValue: newVersion.name - } - ]; - } - - const oldVersion = sortedVersions[1]; - return [ - { - field: "folderName", - oldValue: oldVersion.name, - newValue: newVersion.name - } - ]; -}; - -export const SecretVersionDiffView: React.FC = ({ +export const SecretVersionDiffView = ({ item, isCollapsed = false, onToggleCollapse, showHeader = true, - customHeader -}) => { + customHeader, + excludedFieldsHighlight = ["metadata", "tags"] +}: SecretVersionDiffViewProps) => { const oldContainerRef = useRef(null); const newContainerRef = useRef(null); - const [isScrollingSynced, setIsScrollingSynced] = useState(false); const [internalCollapsed, setInternalCollapsed] = useState(isCollapsed); - const collapsed = onToggleCollapse ? isCollapsed : internalCollapsed; - - const handleToggle = () => { + const handleToggle = useCallback(() => { if (onToggleCollapse && item.id) { onToggleCollapse(item.id); } else { - setInternalCollapsed(!internalCollapsed); + setInternalCollapsed((prev) => !prev); } - }; + }, [onToggleCollapse, item.id]); - if (!item.versions) { + const collapsed = onToggleCollapse ? isCollapsed : internalCollapsed; + + if (!item.versions || item.versions.length === 0) { return
No details available
; } - const differences = getVersionDifferences(item.versions); - - if (differences.length === 0) { - return null; - } - - const changedFields = new Set(); - differences.forEach((diff) => { - if (JSON.stringify(diff.oldValue) !== JSON.stringify(diff.newValue)) { - changedFields.add(diff.field); - } - }); - - const handleScroll = (container: "old" | "new") => { - if (isScrollingSynced) return; - setIsScrollingSynced(true); - - if (container === "old" && oldContainerRef.current && newContainerRef.current) { - newContainerRef.current.scrollTop = oldContainerRef.current.scrollTop; - } else if (container === "new" && oldContainerRef.current && newContainerRef.current) { - oldContainerRef.current.scrollTop = newContainerRef.current.scrollTop; - } - - setTimeout(() => setIsScrollingSynced(false), 50); - }; - const sortedVersions = [...item.versions].sort((a, b) => b.version - a.version); let oldVersion = null; let newVersion = null; let oldVersionContent = null; let newVersionContent = null; + let diffPaths = new Set(); - if (item.isUpdated) { + if (item.isUpdated && sortedVersions.length >= 2) { if (item.isRollback) { [oldVersion, newVersion] = sortedVersions; } else { [newVersion, oldVersion] = sortedVersions; } - oldVersionContent = highlightChangedFields(oldVersion, changedFields, true); - newVersionContent = highlightChangedFields(newVersion, changedFields, false); + + const cleanOldVersion = cleanVersionForComparison(oldVersion); + const cleanNewVersion = cleanVersionForComparison(newVersion); + diffPaths = getDiffPaths(cleanOldVersion, cleanNewVersion); + + if (diffPaths.size === 0) { + return null; + } + + oldVersionContent = ( +
+ {renderJsonWithDiffs( + cleanOldVersion, + diffPaths, + true, + "", + 0, + undefined, + false, + excludedFieldsHighlight, + cleanOldVersion, + cleanNewVersion + )} +
+ ); + newVersionContent = ( +
+ {renderJsonWithDiffs( + cleanNewVersion, + diffPaths, + false, + "", + 0, + undefined, + false, + excludedFieldsHighlight, + cleanOldVersion, + cleanNewVersion + )} +
+ ); } else if (item.isAdded) { [newVersion] = sortedVersions; - oldVersionContent =
; - newVersionContent = formatAddedJson(newVersion); + const cleanNewVersion = cleanVersionForComparison(newVersion); + oldVersionContent =
No previous version
; + newVersionContent = formatAddedJson(cleanNewVersion); } else if (item.isDeleted) { [oldVersion] = sortedVersions; - oldVersionContent = formatDeletedJson(oldVersion); - newVersionContent =
; + const cleanOldVersion = cleanVersionForComparison(oldVersion); + oldVersionContent = formatDeletedJson(cleanOldVersion); + newVersionContent =
Version deleted
; + } else { + return null; } const renderHeader = () => { @@ -507,7 +579,7 @@ export const SecretVersionDiffView: React.FC = ({ } const isSecret = item.type === "secret"; - const key = isSecret ? item.secretKey || "" : item.folderName || ""; + const key = isSecret ? item.secretKey || "Unnamed Secret" : item.folderName || "Unnamed Folder"; let textStyle = "text-white"; let changeBadge = null; @@ -565,7 +637,6 @@ export const SecretVersionDiffView: React.FC = ({
handleScroll("old")} > {oldVersionContent}
@@ -573,7 +644,6 @@ export const SecretVersionDiffView: React.FC = ({
handleScroll("new")} > {newVersionContent}
diff --git a/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx b/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx index d8859bd32..8f529ba86 100644 --- a/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx +++ b/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx @@ -43,11 +43,12 @@ const CommitItem = ({
-
+
-
+