diff --git a/backend/src/db/migrations/20260205204147_redact-secret-version-values.ts b/backend/src/db/migrations/20260205204147_redact-secret-version-values.ts new file mode 100644 index 0000000000..d03740559c --- /dev/null +++ b/backend/src/db/migrations/20260205204147_redact-secret-version-values.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasIsRedactedColumn = await knex.schema.hasColumn(TableName.SecretVersionV2, "isRedacted"); + const hasRedactedAtColumn = await knex.schema.hasColumn(TableName.SecretVersionV2, "redactedAt"); + const hasRedactedByUserColumn = await knex.schema.hasColumn(TableName.SecretVersionV2, "redactedByUserId"); + const hasParentVersionIdColumn = await knex.schema.hasColumn(TableName.SecretVersionV2, "parentVersionId"); + + const missingColumns = + !hasIsRedactedColumn || !hasRedactedAtColumn || !hasRedactedByUserColumn || !hasParentVersionIdColumn; + + if (missingColumns) { + await knex.schema.alterTable(TableName.SecretVersionV2, (table) => { + if (!hasParentVersionIdColumn) { + table.uuid("parentVersionId").references("id").inTable(TableName.SecretVersionV2).onDelete("SET NULL"); + table.index("parentVersionId"); + } + + if (!hasIsRedactedColumn) table.boolean("isRedacted").defaultTo(false).notNullable(); + if (!hasRedactedAtColumn) table.timestamp("redactedAt").nullable(); + if (!hasRedactedByUserColumn) + table.uuid("redactedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasIsRedactedColumn = await knex.schema.hasColumn(TableName.SecretVersionV2, "isRedacted"); + const hasRedactedAtColumn = await knex.schema.hasColumn(TableName.SecretVersionV2, "redactedAt"); + const hasRedactedByUserColumn = await knex.schema.hasColumn(TableName.SecretVersionV2, "redactedByUserId"); + const hasParentVersionIdColumn = await knex.schema.hasColumn(TableName.SecretVersionV2, "parentVersionId"); + const hasColumns = hasIsRedactedColumn || hasRedactedAtColumn || hasRedactedByUserColumn || hasParentVersionIdColumn; + + if (hasColumns) { + await knex.schema.alterTable(TableName.SecretVersionV2, (table) => { + if (hasParentVersionIdColumn) { + table.dropIndex("parentVersionId"); + table.dropColumn("parentVersionId"); + } + if (hasIsRedactedColumn) table.dropColumn("isRedacted"); + if (hasRedactedAtColumn) table.dropColumn("redactedAt"); + if (hasRedactedByUserColumn) table.dropColumn("redactedByUserId"); + }); + } +} diff --git a/backend/src/db/schemas/secret-versions-v2.ts b/backend/src/db/schemas/secret-versions-v2.ts index 593a46b068..af156797ab 100644 --- a/backend/src/db/schemas/secret-versions-v2.ts +++ b/backend/src/db/schemas/secret-versions-v2.ts @@ -28,7 +28,11 @@ export const SecretVersionsV2Schema = z.object({ updatedAt: z.date(), userActorId: z.string().uuid().nullable().optional(), identityActorId: z.string().uuid().nullable().optional(), - actorType: z.string().nullable().optional() + actorType: z.string().nullable().optional(), + parentVersionId: z.string().uuid().nullable().optional(), + isRedacted: z.boolean().default(false), + redactedAt: z.date().nullable().optional(), + redactedByUserId: z.string().uuid().nullable().optional() }); export type TSecretVersionsV2 = z.infer; diff --git a/backend/src/ee/routes/v2/index.ts b/backend/src/ee/routes/v2/index.ts index c402ab00aa..0119f3ec4a 100644 --- a/backend/src/ee/routes/v2/index.ts +++ b/backend/src/ee/routes/v2/index.ts @@ -11,6 +11,7 @@ import { registerDeprecatedProjectRoleRouter } from "./deprecated-project-role-r import { registerGatewayV2Router } from "./gateway-router"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; +import { registerSecretVersionRouter } from "./secret-version-router"; export const registerV2EERoutes = async (server: FastifyZodProvider) => { await server.register( @@ -54,4 +55,6 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => { }, { prefix: "/secret-scanning" } ); + + await server.register(registerSecretVersionRouter, { prefix: "/secret-versions" }); }; diff --git a/backend/src/ee/routes/v2/secret-version-router.ts b/backend/src/ee/routes/v2/secret-version-router.ts new file mode 100644 index 0000000000..5c12fcbce4 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-version-router.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +import { SecretVersionsV2Schema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSecretVersionRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "DELETE", + url: "/:versionId/redact-value", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + versionId: z.string() + }), + response: { + 200: z.object({ + secretVersion: SecretVersionsV2Schema.omit({ encryptedValue: true, encryptedComment: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { secretVersion, projectId, environment, secretPath, secretKey, secretId } = + await server.services.secret.redactSecretVersionValue({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + versionId: req.params.versionId + }); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.REDACT_SECRET_VERSION_VALUE, + metadata: { + environment, + secretPath, + secretId, + secretKey, + secretVersionId: secretVersion.id, + secretVersion: secretVersion.version + } + } + }); + + return { secretVersion }; + } + }); +}; 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 98fe3ca79f..808595c382 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -151,6 +151,7 @@ export enum EventType { MOVE_SECRETS = "move-secrets", DELETE_SECRET = "delete-secret", DELETE_SECRETS = "delete-secrets", + REDACT_SECRET_VERSION_VALUE = "redact-secret-version-value", GET_PROJECT_KEY = "get-project-key", AUTHORIZE_INTEGRATION = "authorize-integration", UPDATE_INTEGRATION_AUTH = "update-integration-auth", @@ -896,6 +897,18 @@ interface DeleteSecretBatchEvent { }; } +interface RedactSecretVersionValueEvent { + type: EventType.REDACT_SECRET_VERSION_VALUE; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersionId: string; + secretVersion: number; + }; +} + interface GetProjectKeyEvent { type: EventType.GET_PROJECT_KEY; metadata: { @@ -5066,6 +5079,7 @@ export type Event = | MoveSecretsEvent | DeleteSecretEvent | DeleteSecretBatchEvent + | RedactSecretVersionValueEvent | GetProjectKeyEvent | AuthorizeIntegrationEvent | UpdateIntegrationAuthEvent diff --git a/backend/src/ee/services/secret-replication/secret-replication-service.ts b/backend/src/ee/services/secret-replication/secret-replication-service.ts index 94224f89fc..ed5c0677d8 100644 --- a/backend/src/ee/services/secret-replication/secret-replication-service.ts +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -309,6 +309,19 @@ export const secretReplicationServiceFactory = ({ const sourceSecrets = $getReplicatedSecretsV2(sourceDecryptedLocalSecrets, sourceImportedSecrets); const sourceSecretsGroupByKey = groupBy(sourceSecrets, (i) => i.key); + // Fetch latest version IDs for all source secrets to track parent-child relationships + const sourceSecretsGroupedByFolderId = groupBy(sourceSecrets, (s) => s.folderId); + const sourceSecretLatestVersions: Record = {}; + await Promise.all( + Object.entries(sourceSecretsGroupedByFolderId).map(async ([folderId, secrets]) => { + const secretIds = secrets.map((s) => s.id); + const latestVersions = await secretVersionV2BridgeDAL.findLatestVersionMany(folderId, secretIds); + Object.entries(latestVersions).forEach(([secretId, version]) => { + sourceSecretLatestVersions[secretId] = version.id; + }); + }) + ); + const lock = await keyStore.acquireLock( [getReplicationKeyLockPrefix(projectId, environmentSlug, secretPath)], 5000 @@ -495,7 +508,8 @@ export const secretReplicationServiceFactory = ({ encryptedComment: doc.encryptedComment, skipMultilineEncoding: doc.skipMultilineEncoding, secretMetadata: doc.rawSecretMetadata, - references: doc.secretValue ? getAllSecretReferences(doc.secretValue).nestedReferences : [] + references: doc.secretValue ? getAllSecretReferences(doc.secretValue).nestedReferences : [], + parentSecretVersionId: sourceSecretLatestVersions[doc.id] }; }) }); @@ -525,7 +539,8 @@ export const secretReplicationServiceFactory = ({ encryptedComment: doc.encryptedComment, skipMultilineEncoding: doc.skipMultilineEncoding, secretMetadata: doc.rawSecretMetadata, - references: doc.secretValue ? getAllSecretReferences(doc.secretValue).nestedReferences : [] + references: doc.secretValue ? getAllSecretReferences(doc.secretValue).nestedReferences : [], + parentSecretVersionId: sourceSecretLatestVersions[doc.id] } }; }) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dd6eb575f4..7334d3bf6e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1637,7 +1637,8 @@ export const registerRoutes = async ( secretV2BridgeService, secretApprovalRequestService, licenseService, - reminderService + reminderService, + secretVersionV2DAL: secretVersionV2BridgeDAL }); const secretSharingService = secretSharingServiceFactory({ diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index c0aef5e43e..16bc99c33a 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -1698,6 +1698,17 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secretVersions: secretRawSchema .omit({ secretValue: true }) .extend({ + isRedacted: z.boolean(), + redactedByActor: z + .object({ + username: z.string().nullable(), + email: z.string().nullable().optional(), + projectMembershipId: z.string().uuid().nullable().optional() + }) + .nullable() + .optional(), + redactedAt: z.date().nullable(), + redactedByUserId: z.string().uuid().nullable(), secretValueHidden: z.boolean() }) .array() 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 2c30c5bcf9..97b251c6b5 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 @@ -42,6 +42,9 @@ export type SecretCommitChange = BaseCommitChangeInfo & { tags?: string[] | null; secretReminderRecipients?: string[] | null; secretValue: string; + isRedacted: boolean; + redactedAt: Date | null; + redactedByUserId: string | null; }[]; }; diff --git a/backend/src/services/folder-commit/folder-commit-schemas.ts b/backend/src/services/folder-commit/folder-commit-schemas.ts index 9f99bd2ccd..5ff75154ec 100644 --- a/backend/src/services/folder-commit/folder-commit-schemas.ts +++ b/backend/src/services/folder-commit/folder-commit-schemas.ts @@ -29,7 +29,10 @@ const secretVersionSchema = z.object({ skipMultilineEncoding: z.boolean().nullable().optional(), tags: z.array(z.string()).nullable().optional(), metadata: z.unknown().nullable().optional(), - secretValue: z.string() + secretValue: z.string(), + isRedacted: z.boolean(), + redactedAt: z.date().nullable(), + redactedByUserId: z.string().nullable() }); // Folder-specific versions schema @@ -122,7 +125,10 @@ const secretResourceChangeSchema = baseResourceChangeSchema.extend({ tags: z.array(z.string()).nullable().optional(), metadata: z.unknown().nullable().optional(), secretReminderNote: z.string().nullable().optional(), - secretValue: z.string().optional() + secretValue: z.string().optional(), + isRedacted: z.boolean(), + redactedAt: z.date().nullable(), + redactedByUserId: z.string().nullable() }) ) .optional() diff --git a/backend/src/services/folder-commit/folder-commit-service.ts b/backend/src/services/folder-commit/folder-commit-service.ts index 1f509f49ee..986df5731c 100644 --- a/backend/src/services/folder-commit/folder-commit-service.ts +++ b/backend/src/services/folder-commit/folder-commit-service.ts @@ -106,6 +106,9 @@ type SecretChange = BaseChange & { metadata?: unknown; tags?: string[] | null; secretValue?: string; + isRedacted: boolean; + redactedAt: Date | null; + redactedByUserId: string | null; }[]; }; 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 c7e59b78ee..d38eccafd8 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 @@ -97,7 +97,8 @@ export const fnSecretBulkInsert = async ({ })) ) : null, - secretId: newSecretGroupedByKeyName[el.key][0].id + secretId: newSecretGroupedByKeyName[el.key][0].id, + parentVersionId: inputSecrets?.[index]?.parentSecretVersionId })), tx ); @@ -236,7 +237,8 @@ export const fnSecretBulkUpdate = async ({ secretId, userActorId, identityActorId, - actorType + actorType, + parentVersionId: inputSecrets?.[index]?.data?.parentSecretVersionId }) ), tx @@ -683,8 +685,9 @@ export const fnUpdateSecretLinkedReferences = async ({ const allSecretsToUpdate: Array = [...nestedSecretsToUpdate, ...secretsToCheck]; - // we track updated secrets grouped by folder for the commit creation - const updatedSecretsByFolder: Map> = new Map(); + // Use Map with secretId as key to avoid duplicates when a secret references the renamed secret multiple times + const updatedSecretsMap: Map = + new Map(); for await (const secretToUpdate of allSecretsToUpdate) { if (!secretToUpdate.encryptedValue) { @@ -724,20 +727,38 @@ export const fnUpdateSecretLinkedReferences = async ({ if (newValue !== originalValue) { const newEncryptedValue = encryptor({ plainText: Buffer.from(newValue) }).cipherTextBlob; - await secretDAL.updateById(secretToUpdate.id, { encryptedValue: newEncryptedValue }, tx); + // Update secret with version increment + const updatedSecret = await secretDAL.updateById( + secretToUpdate.id, + { encryptedValue: newEncryptedValue, $incr: { version: 1 } }, + tx + ); - // group by folder for commit creation - const folderSecrets = updatedSecretsByFolder.get(secretToUpdate.folderId) || []; - folderSecrets.push({ secret: secretToUpdate, newEncryptedValue }); - updatedSecretsByFolder.set(secretToUpdate.folderId, folderSecrets); + // Track updated secret by ID to avoid duplicates + updatedSecretsMap.set(secretToUpdate.id, { + secret: updatedSecret, + newEncryptedValue, + newVersion: updatedSecret.version + }); } } + // Group updated secrets by folder for commit creation + const updatedSecretsByFolder: Map< + string, + Array<{ secret: TSecretsV2; newEncryptedValue: Buffer; newVersion: number }> + > = new Map(); + for (const [, data] of updatedSecretsMap) { + const folderSecrets = updatedSecretsByFolder.get(data.secret.folderId) || []; + folderSecrets.push(data); + updatedSecretsByFolder.set(data.secret.folderId, folderSecrets); + } + for await (const [updateFolderId, folderSecrets] of updatedSecretsByFolder) { const secretVersions = await secretVersionDAL.insertMany( - folderSecrets.map(({ secret, newEncryptedValue }) => ({ + folderSecrets.map(({ secret, newEncryptedValue, newVersion }) => ({ secretId: secret.id, - version: secret.version + 1, + version: newVersion, key: secret.key, encryptedValue: newEncryptedValue, encryptedComment: secret.encryptedComment, @@ -852,9 +873,11 @@ export const fnUpdateMovedSecretReferences = async ({ decryptor, tx }: TFnUpdateMovedSecretReferences) => { - const updatedSecretsByFolder: Map> = new Map(); + // Use Map with secretId as key to avoid duplicates when a secret references multiple moved secrets + const updatedSecretsMap: Map = + new Map(); - const destPathPart = destinationSecretPath === "/" ? "" : `.${destinationSecretPath.slice(1).replace(/\//g, ".")}`; + const destPathPart = destinationSecretPath === "/" ? "" : `.${destinationSecretPath.slice(1).replaceAll("/", ".")}`; const newNestedRef = `\${${destinationEnvironment}${destPathPart}.${secretKey}}`; // case: local references, not stored in the db, we need to scan the folder to find secrets that reference the old secret ky @@ -889,12 +912,19 @@ export const fnUpdateMovedSecretReferences = async ({ if (newValue !== originalValue) { const newEncryptedValue = encryptor({ plainText: Buffer.from(newValue) }).cipherTextBlob; - await secretDAL.updateById(secretToUpdate.id, { encryptedValue: newEncryptedValue }, tx); + // Update secret with version increment - use $incr to properly increment version + const updatedSecret = await secretDAL.updateById( + secretToUpdate.id, + { encryptedValue: newEncryptedValue, $incr: { version: 1 } }, + tx + ); - // group by folder for commit creation - const folderSecrets = updatedSecretsByFolder.get(secretToUpdate.folderId) || []; - folderSecrets.push({ secret: secretToUpdate, newEncryptedValue }); - updatedSecretsByFolder.set(secretToUpdate.folderId, folderSecrets); + // Track updated secret by ID to avoid duplicates + updatedSecretsMap.set(secretToUpdate.id, { + secret: updatedSecret, + newEncryptedValue, + newVersion: updatedSecret.version + }); // update the secret references table (only for nested refs) const updatedNestedRefs = [ @@ -947,11 +977,19 @@ export const fnUpdateMovedSecretReferences = async ({ if (newValue !== originalValue) { const newEncryptedValue = encryptor({ plainText: Buffer.from(newValue) }).cipherTextBlob; - await secretDAL.updateById(secretToUpdate.id, { encryptedValue: newEncryptedValue }, tx); + // Update secret with version increment + const updatedSecret = await secretDAL.updateById( + secretToUpdate.id, + { encryptedValue: newEncryptedValue, $incr: { version: 1 } }, + tx + ); - const folderSecrets = updatedSecretsByFolder.get(secretToUpdate.folderId) || []; - folderSecrets.push({ secret: secretToUpdate, newEncryptedValue }); - updatedSecretsByFolder.set(secretToUpdate.folderId, folderSecrets); + // Track updated secret by ID to avoid duplicates + updatedSecretsMap.set(secretToUpdate.id, { + secret: updatedSecret, + newEncryptedValue, + newVersion: updatedSecret.version + }); const updatedNestedRefs = nestedReferences.filter( (ref) => @@ -990,11 +1028,19 @@ export const fnUpdateMovedSecretReferences = async ({ if (newValue !== originalValue) { const newEncryptedValue = encryptor({ plainText: Buffer.from(newValue) }).cipherTextBlob; - await secretDAL.updateById(secretToUpdate.id, { encryptedValue: newEncryptedValue }, tx); + // Update secret with version increment + const updatedSecret = await secretDAL.updateById( + secretToUpdate.id, + { encryptedValue: newEncryptedValue, $incr: { version: 1 } }, + tx + ); - const folderSecrets = updatedSecretsByFolder.get(secretToUpdate.folderId) || []; - folderSecrets.push({ secret: secretToUpdate, newEncryptedValue }); - updatedSecretsByFolder.set(secretToUpdate.folderId, folderSecrets); + // Track updated secret by ID to avoid duplicates + updatedSecretsMap.set(secretToUpdate.id, { + secret: updatedSecret, + newEncryptedValue, + newVersion: updatedSecret.version + }); const updatedNestedRefs = nestedReferences.map((ref) => { if ( @@ -1060,11 +1106,19 @@ export const fnUpdateMovedSecretReferences = async ({ if (valueChanged) { const newEncryptedValue = encryptor({ plainText: Buffer.from(updatedValue) }).cipherTextBlob; - await secretDAL.updateById(destinationMovedSecret.id, { encryptedValue: newEncryptedValue }, tx); + // Update secret with version increment + const updatedSecret = await secretDAL.updateById( + destinationMovedSecret.id, + { encryptedValue: newEncryptedValue, $incr: { version: 1 } }, + tx + ); - const folderSecrets = updatedSecretsByFolder.get(destinationMovedSecret.folderId) || []; - folderSecrets.push({ secret: destinationMovedSecret, newEncryptedValue }); - updatedSecretsByFolder.set(destinationMovedSecret.folderId, folderSecrets); + // Track updated secret by ID to avoid duplicates + updatedSecretsMap.set(destinationMovedSecret.id, { + secret: updatedSecret, + newEncryptedValue, + newVersion: updatedSecret.version + }); const { nestedReferences: finalNestedReferences } = getAllSecretReferences(updatedValue); await secretDAL.upsertSecretReferences( @@ -1074,11 +1128,22 @@ export const fnUpdateMovedSecretReferences = async ({ } } + // Group updated secrets by folder for commit creation + const updatedSecretsByFolder: Map< + string, + Array<{ secret: TSecretsV2; newEncryptedValue: Buffer; newVersion: number }> + > = new Map(); + for (const [, data] of updatedSecretsMap) { + const folderSecrets = updatedSecretsByFolder.get(data.secret.folderId) || []; + folderSecrets.push(data); + updatedSecretsByFolder.set(data.secret.folderId, folderSecrets); + } + for await (const [updateFolderId, folderSecrets] of updatedSecretsByFolder) { const secretVersions = await secretVersionDAL.insertMany( - folderSecrets.map(({ secret, newEncryptedValue }) => ({ + folderSecrets.map(({ secret, newEncryptedValue, newVersion }) => ({ secretId: secret.id, - version: secret.version + 1, + version: newVersion, key: secret.key, encryptedValue: newEncryptedValue, encryptedComment: secret.encryptedComment, 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 82609c3a64..efc5ce3bbb 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 @@ -51,7 +51,7 @@ import { TReminderServiceFactory } from "../reminder/reminder-types"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { ResourceMetadataWithEncryptionDTO } from "../resource-metadata/resource-metadata-schema"; import { TSecretQueueFactory } from "../secret/secret-queue"; -import { TGetASecretByIdDTO } from "../secret/secret-types"; +import { TGetASecretByIdDTO, TRedactSecretVersionValueDTO } from "../secret/secret-types"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; @@ -2607,6 +2607,7 @@ export const secretV2BridgeServiceFactory = ({ sort: [["createdAt", "desc"]] } }); + return secretVersions.map((el) => { const secretValueHidden = !hasSecretReadValueOrDescribePermission( permission, @@ -2621,17 +2622,31 @@ export const secretV2BridgeServiceFactory = ({ } ); - return reshapeBridgeSecret( - folder.projectId, - folder.environment.envSlug, - folderWithPath.path, - { - ...el, - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", - comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" - }, - secretValueHidden - ); + return { + ...reshapeBridgeSecret( + folder.projectId, + folder.environment.envSlug, + folderWithPath.path, + { + ...el, + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() + : "" + }, + secretValueHidden + ), + redactedByActor: el.isRedacted + ? { + username: el.redactedByUserName, + email: el.redactedByUserEmail, + projectMembershipId: el.redactedByMembershipId + } + : null, + isRedacted: el.isRedacted, + redactedAt: el.redactedAt || null, + redactedByUserId: el.redactedByUserId || null + }; }); }; @@ -3616,24 +3631,31 @@ export const secretV2BridgeServiceFactory = ({ } ); - return reshapeBridgeSecret( - projectId, - environment.slug, - secretPath, - { - ...el, - secretMetadata: (el.metadata as { key: string; value?: string; encryptedValue: string }[])?.map((meta) => ({ - isEncrypted: Boolean(meta.encryptedValue), - key: meta.key, - value: meta.encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: Buffer.from(meta.encryptedValue, "base64") }).toString() - : meta.value || "" - })), - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", - comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" - }, - secretValueHidden - ); + return { + ...reshapeBridgeSecret( + projectId, + environment.slug, + secretPath, + { + ...el, + secretMetadata: (el.metadata as { key: string; value?: string; encryptedValue: string }[])?.map((meta) => ({ + isEncrypted: Boolean(meta.encryptedValue), + key: meta.key, + value: meta.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: Buffer.from(meta.encryptedValue, "base64") }).toString() + : meta.value || "" + })), + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() + : "" + }, + secretValueHidden + ), + isRedacted: el.isRedacted, + redactedAt: el.redactedAt || null, + redactedByUserId: el.redactedByUserId || null + }; }); }; @@ -3642,6 +3664,142 @@ export const secretV2BridgeServiceFactory = ({ return secrets.map((el) => ({ id: el.id, key: el.key })); }; + const redactSecretVersionValue = async ({ + versionId, + actor, + actorId, + actorOrgId, + actorAuthMethod + }: TRedactSecretVersionValueDTO) => { + const secretVersion = await secretVersionDAL.findOne({ id: versionId }); + + if (!secretVersion) { + throw new NotFoundError({ message: `Secret version with ID '${versionId}' not found` }); + } + const secret = await secretDAL.findOne({ id: secretVersion.secretId }); + + if (!secret) { + throw new NotFoundError({ message: `Secret with ID '${secretVersion.secretId}' not found` }); + } + + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(secretVersion.projectId, [ + secretVersion.folderId + ]); + + if (!folderWithPath) { + throw new NotFoundError({ message: `Folder path for folder with ID '${secretVersion.folderId}' not found` }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: secretVersion.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment: folderWithPath.environmentSlug, + secretPath: folderWithPath.path, + secretName: secret.key, + secretTags: secret.tags.map((i) => i.slug) + }) + ); + + if (secretVersion.isRedacted) { + throw new BadRequestError({ message: `Secret version with ID '${versionId}' is already redacted` }); + } + + // check if its the latest version + const latestVersions = await secretVersionDAL.findByIdsWithLatestVersion(secretVersion.folderId, [ + secretVersion.secretId + ]); + + const latestVersion = latestVersions[secretVersion.secretId]; + + if (!latestVersion) { + throw new BadRequestError({ message: "Failed to find latest version" }); + } + + if (latestVersion.version === secretVersion.version) { + throw new BadRequestError({ message: "Cannot redact the latest version" }); + } + + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: secretVersion.projectId + }); + // we need to encrypt it, even though its an empty string, or there'll be decryption errors when we try to decrypt the value of the secret version + const encryptedValue = secretManagerEncryptor({ plainText: Buffer.from("") }).cipherTextBlob; + + const updatedSecretVersion = await secretVersionDAL.updateById(versionId, { + encryptedValue, + isRedacted: true, + redactedAt: new Date(), + redactedByUserId: actorId + }); + + // Cascade redaction to child versions (replicated secret versions) + const MAX_REDACTION_DEPTH = 100; + const visitedVersionIds = new Set(); + + const redactChildVersions = async (parentVersionIds: string[], depth = 0): Promise => { + if (!parentVersionIds.length) return; + if (depth >= MAX_REDACTION_DEPTH) { + logger.warn( + { versionId, depth, maxDepth: MAX_REDACTION_DEPTH }, + "Max redaction depth reached, stopping cascade" + ); + return; + } + + const childVersions = await secretVersionDAL.findByParentVersionIds(parentVersionIds); + if (!childVersions.length) return; + + // filter out already visited versions to prevent infinite loops + const unvisitedChildren = childVersions.filter((cv) => !visitedVersionIds.has(cv.id)); + if (!unvisitedChildren.length) return; + + // mark versions as visited + unvisitedChildren.forEach((cv) => visitedVersionIds.add(cv.id)); + + // redact all child versions that aren't already redacted + const childVersionIdsToRedact = unvisitedChildren.filter((cv) => !cv.isRedacted).map((cv) => cv.id); + + if (childVersionIdsToRedact.length) { + await secretVersionDAL.update( + { $in: { id: childVersionIdsToRedact } }, + { + encryptedValue, + isRedacted: true, + redactedAt: new Date(), + redactedByUserId: actorId + } + ); + } + + // recursively redact grandchildren + await redactChildVersions( + unvisitedChildren.map((cv) => cv.id), + depth + 1 + ); + }; + + await redactChildVersions([versionId]); + + return { + secretVersion: updatedSecretVersion, + projectId: secretVersion.projectId, + environment: folderWithPath.environmentSlug, + secretPath: folderWithPath.path, + secretKey: secret.key, + secretId: secret.id + }; + }; + return { createSecret, deleteSecret, @@ -3664,6 +3822,7 @@ export const secretV2BridgeServiceFactory = ({ getAccessibleSecrets, getSecretVersionsByIds, findSecretIdsByFolderIdAndKeys, - $validateSecretReferences + $validateSecretReferences, + redactSecretVersionValue }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 78c8564ebb..0721e3dce1 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -175,6 +175,7 @@ export type TFnSecretBulkInsert = { tagIds?: string[]; references: TSecretReference[]; secretMetadata?: { key: string; value?: string | null; encryptedValue?: Buffer | null }[]; + parentSecretVersionId?: string; } >; resourceMetadataDAL: Pick; @@ -207,6 +208,7 @@ export type TFnSecretBulkUpdate = { data: TRequireReferenceIfValue & { tags?: string[]; secretMetadata?: { key: string; value?: string | null; encryptedValue?: Buffer | null }[]; + parentSecretVersionId?: string; }; }[]; resourceMetadataDAL: Pick; 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 413ae5a912..4d20fb9ba6 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -6,8 +6,10 @@ import { AccessScope, SecretVersionsV2Schema, TableName, + TMemberships, TSecretVersionsV2, - TSecretVersionsV2Update + TSecretVersionsV2Update, + TUsers } from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindOpt } from "@app/lib/knex"; @@ -19,6 +21,24 @@ export type TSecretVersionV2DALFactory = ReturnType { const secretVersionV2Orm = ormify(db, TableName.SecretVersionV2); + const findOne = async (filter: Partial, tx?: Knex) => { + try { + const doc = await (tx || db.replicaNode())(TableName.SecretVersionV2) + // eslint-disable-next-line + .where(buildFindFilter(filter, TableName.SecretVersionV2)) + .leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`) + .leftJoin(TableName.SecretFolder, `${TableName.SecretV2}.folderId`, `${TableName.SecretFolder}.id`) + .leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .select(selectAllTableCols(TableName.SecretVersionV2)) + .select(db.ref("projectId").withSchema(TableName.Environment).as("projectId")) + .first(); + + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindOne" }); + } + }; + const findBySecretId = async (secretId: string, { offset, limit, sort, tx }: TFindOpt = {}) => { try { const query = (tx || db.replicaNode())(TableName.SecretVersionV2) @@ -197,26 +217,47 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { const query = (tx || db.replicaNode())(TableName.SecretVersionV2) .leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.SecretVersionV2}.folderId`) .leftJoin(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) - .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`) + .leftJoin( + `${TableName.Users} as user_actor`, + "user_actor.id", + `${TableName.SecretVersionV2}.userActorId` + ) + .leftJoin( + `${TableName.Users} as redacted_by_user`, + "redacted_by_user.id", + `${TableName.SecretVersionV2}.redactedByUserId` + ) .leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`) - .leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.UserGroupMembership, + `${TableName.UserGroupMembership}.userId`, + `user_actor.id` as `${TableName.Users}.id` + ) .leftJoin( TableName.IdentityGroupMembership, `${TableName.IdentityGroupMembership}.identityId`, `${TableName.Identity}.id` ) - .leftJoin(TableName.Membership, (qb) => { + .leftJoin(`${TableName.Membership} as actorMembership`, (qb) => { void qb - .on(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project])) - .andOn(`${TableName.Membership}.scopeProjectId`, `${TableName.Environment}.projectId`) + .on(`actorMembership.scope`, db.raw("?", [AccessScope.Project])) + .andOn(`actorMembership.scopeProjectId`, `${TableName.Environment}.projectId`) .andOn((sqb) => { void sqb - .on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`) - .orOn(`${TableName.Membership}.actorIdentityId`, `${TableName.SecretVersionV2}.identityActorId`) - .orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`) - .orOn(`${TableName.Membership}.actorGroupId`, `${TableName.IdentityGroupMembership}.groupId`); + .on(`actorMembership.actorUserId`, `${TableName.SecretVersionV2}.userActorId`) + .orOn(`actorMembership.actorIdentityId`, `${TableName.SecretVersionV2}.identityActorId`) + .orOn(`actorMembership.actorGroupId`, `${TableName.UserGroupMembership}.groupId`) + .orOn(`actorMembership.actorGroupId`, `${TableName.IdentityGroupMembership}.groupId`); }); }) + + .leftJoin(`${TableName.Membership} as redactedByMembership`, (qb) => { + void qb + .on(`redactedByMembership.scope`, db.raw("?", [AccessScope.Project])) + .andOn(`redactedByMembership.scopeProjectId`, `${TableName.Environment}.projectId`) + .andOn(`redactedByMembership.actorUserId`, `${TableName.SecretVersionV2}.redactedByUserId`); + }) + .leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`) .leftJoin( TableName.SecretVersionV2Tag, @@ -234,10 +275,13 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { }) .select( selectAllTableCols(TableName.SecretVersionV2), - db.ref("username").withSchema(TableName.Users).as("userActorName"), + db.ref("username").withSchema("user_actor").as("userActorName"), + db.ref("username").withSchema("redacted_by_user").as("redactedByUserName"), + db.ref("email").withSchema("redacted_by_user").as("redactedByUserEmail"), db.ref("name").withSchema(TableName.Identity).as("identityActorName"), - db.ref("id").withSchema(TableName.Membership).as("membershipId"), - db.ref("actorGroupId").withSchema(TableName.Membership).as("groupId"), + db.ref("id").withSchema("actorMembership").as("membershipId"), + db.ref("id").withSchema("redactedByMembership").as("redactedByMembershipId"), + db.ref("actorGroupId").withSchema("actorMembership").as("groupId"), db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") @@ -266,7 +310,10 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { userActorName: el.userActorName, identityActorName: el.identityActorName, membershipId: el.membershipId, - groupId: el.groupId + groupId: el.groupId, + redactedByUserEmail: el.redactedByUserEmail, + redactedByUserName: el.redactedByUserName, + redactedByMembershipId: el.redactedByMembershipId }), childrenMapper: [ { @@ -460,6 +507,16 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { } }; + const findByParentVersionIds = async (parentVersionIds: string[], tx?: Knex): Promise => { + if (!parentVersionIds.length) return []; + try { + const docs = await (tx || db)(TableName.SecretVersionV2).whereIn("parentVersionId", parentVersionIds).select("*"); + return docs.map((doc) => SecretVersionsV2Schema.parse(doc)); + } catch (error) { + throw new DatabaseError({ error, name: "FindByParentVersionIds" }); + } + }; + return { ...secretVersionV2Orm, pruneExcessVersions, @@ -469,6 +526,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { findVersionsBySecretIdWithActors, findBySecretId, findByIdsWithLatestVersion, - findByIdAndPreviousVersion + findByIdAndPreviousVersion, + findOne, + findByParentVersionIds }; }; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index aa53ec8160..c2b801d301 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -1344,7 +1344,8 @@ export const secretQueueFactory = ({ reminderNote: el.secretReminderNote, reminderRepeatDays: el.secretReminderRepeatDays, secretId: el.secretId, - envId: el.envId + envId: el.envId, + isRedacted: false }; el.tags.forEach(({ secretTagId }) => { projectV3SecretVersionTags.push({ secret_tagsId: secretTagId, secret_versions_v2Id: el.id }); @@ -1406,7 +1407,8 @@ export const secretQueueFactory = ({ reminderNote: el.secretReminderNote, reminderRepeatDays: el.secretReminderRepeatDays, secretId: el.secretId, - envId: el.envId + envId: el.envId, + isRedacted: false }; }); diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 3a9a319443..34fd4994e9 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -55,6 +55,7 @@ import { fnSecretsFromImports } from "../secret-import/secret-import-fns"; import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; import { TSecretV2BridgeServiceFactory } from "../secret-v2-bridge/secret-v2-bridge-service"; import { TGetSecretReferencesTreeDTO } from "../secret-v2-bridge/secret-v2-bridge-types"; +import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretDALFactory } from "./secret-dal"; import { conditionallyHideSecretValue, @@ -90,6 +91,7 @@ import { TGetSecretsRawDTO, TGetSecretVersionsDTO, TMoveSecretsDTO, + TRedactSecretVersionValueDTO, TStartSecretsV2MigrationDTO, TUpdateBulkSecretDTO, TUpdateManySecretRawDTO, @@ -132,6 +134,7 @@ type TSecretServiceFactoryDep = { >; licenseService: Pick; reminderService: Pick; + secretVersionV2DAL: Pick; }; export type TSecretServiceFactory = ReturnType; @@ -155,7 +158,8 @@ export const secretServiceFactory = ({ secretV2BridgeService, secretApprovalRequestService, licenseService, - reminderService + reminderService, + secretVersionV2DAL }: TSecretServiceFactoryDep) => { const getSecretReference = async (projectId: string) => { // if bot key missing means e2e still exist @@ -2602,7 +2606,10 @@ export const secretServiceFactory = ({ if ((err as Error).message === "BadRequest: Failed to find secret") { return null; } + + logger.error(err); }); + if (secretVersionV2) return secretVersionV2; const secret = await secretDAL.findById(secretId); @@ -2657,16 +2664,22 @@ export const secretServiceFactory = ({ } ); - return decryptSecretRaw( - { - secretValueHidden, - ...el, - workspace: folder.projectId, - environment: folder.environment.envSlug, - secretPath: folderWithPath.path - }, - botKey - ); + return { + ...decryptSecretRaw( + { + secretValueHidden, + ...el, + workspace: folder.projectId, + environment: folder.environment.envSlug, + secretPath: folderWithPath.path + }, + botKey + ), + redactedByActor: null, + isRedacted: false, + redactedAt: null, + redactedByUserId: null + }; }); }; @@ -3515,10 +3528,38 @@ export const secretServiceFactory = ({ skipMultilineEncoding: v.skipMultilineEncoding, tags: v.tags?.map((tag) => tag.slug), metadata: v.secretMetadata, - secretValue: v.secretValue + secretValue: v.secretValue, + isRedacted: v.isRedacted, + redactedAt: v.redactedAt, + redactedByUserId: v.redactedByUserId })); }; + const redactSecretVersionValue = async (dto: TRedactSecretVersionValueDTO) => { + const { versionId, ...rest } = dto; + + const version = await secretVersionV2DAL.findOne({ id: versionId }); + + if (!version) { + throw new NotFoundError({ message: `Secret version with ID '${versionId}' not found` }); + } + + const project = await projectDAL.findById(version.projectId); + if (!project) { + throw new NotFoundError({ message: `Project with ID '${version.projectId}' not found` }); + } + + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(project.id); + if (!shouldUseSecretV2Bridge) { + throw new BadRequestError({ + message: "Project version not supported", + name: "UnsupportedProjectVersionError" + }); + } + + return secretV2BridgeService.redactSecretVersionValue({ versionId, ...rest }); + }; + return { attachTags, detachTags, @@ -3552,6 +3593,7 @@ export const secretServiceFactory = ({ getAccessibleSecrets, getSecretVersionsV2ByIds, getChangeVersions, + redactSecretVersionValue, getSecretReferenceDependencyTree }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 7b78e0fda6..311ffbd431 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -582,3 +582,7 @@ export type TProcessNewCommitRawDTO = { delete?: { folderName: string; id: string }[]; }; }; + +export type TRedactSecretVersionValueDTO = { + versionId: string; +} & Omit; diff --git a/frontend/src/components/secrets/diff/SecretDiffView.tsx b/frontend/src/components/secrets/diff/SecretDiffView.tsx index 93252c0099..3e66719062 100644 --- a/frontend/src/components/secrets/diff/SecretDiffView.tsx +++ b/frontend/src/components/secrets/diff/SecretDiffView.tsx @@ -1,7 +1,13 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ import { useRef, useState } from "react"; -import { faCircleCheck, faCircleXmark, faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons"; +import { + faCircleCheck, + faCircleXmark, + faEye, + faEyeSlash, + faTriangleExclamation +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { isSingleLine, scrollToFirstChange } from "@app/components/utilities/diff"; @@ -23,6 +29,7 @@ import { MultiLineDiff } from "./MultiLineDiff"; import { SingleLineDiff } from "./SingleLineDiff"; export interface SecretVersionData { + isRedacted?: boolean; secretKey?: string; secretValue?: string; secretValueHidden?: boolean; @@ -188,6 +195,8 @@ export const SecretDiffView = ({ const showOldVersion = operationType === "update" || operationType === "delete"; const showNewVersion = operationType === "update" || operationType === "create"; + const isRollingToRedactedVersion = newVersion?.isRedacted; + return (
{showOldVersion ? ( @@ -274,9 +283,26 @@ export const SecretDiffView = ({
New Secret -
- - New + +
+ {isRollingToRedactedVersion && ( +
+ +
+ + Redacted Version +
+
+
+ )} + +
+ + New +
diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 54df6cdff6..2600c4db0d 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -27,6 +27,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.UNAUTHORIZE_INTEGRATION]: "Unauthorize integration", [EventType.CREATE_INTEGRATION]: "Create integration", [EventType.DELETE_INTEGRATION]: "Delete integration", + [EventType.REDACT_SECRET_VERSION_VALUE]: "Redact secret version value", [EventType.ADD_TRUSTED_IP]: "Add trusted IP", [EventType.UPDATE_TRUSTED_IP]: "Update trusted IP", [EventType.DELETE_TRUSTED_IP]: "Delete trusted IP", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 36e1082fbb..86c298a77d 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -29,6 +29,7 @@ export enum EventType { CREATE_SECRET = "create-secret", UPDATE_SECRET = "update-secret", DELETE_SECRET = "delete-secret", + REDACT_SECRET_VERSION_VALUE = "redact-secret-version-value", GET_PROJECT_KEY = "get-project-key", AUTHORIZE_INTEGRATION = "authorize-integration", UPDATE_INTEGRATION_AUTH = "update-integration-auth", diff --git a/frontend/src/hooks/api/secrets/index.ts b/frontend/src/hooks/api/secrets/index.ts index e8ab4b9975..6bbd156d4a 100644 --- a/frontend/src/hooks/api/secrets/index.ts +++ b/frontend/src/hooks/api/secrets/index.ts @@ -5,6 +5,7 @@ export { useDeleteSecretBatch, useDeleteSecretV3, useMoveSecrets, + useRedactSecretValue, useUpdateSecretBatch, useUpdateSecretV3 } from "./mutations"; diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 989dacfcaa..33f888c5a4 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -519,3 +519,16 @@ export const useCreateCommit = () => { } }); }; + +export const useRedactSecretValue = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ versionId }) => { + const { data } = await apiRequest.delete(`/api/v2/secret-versions/${versionId}/redact-value`); + return data; + }, + onSuccess: (_, { secretId }) => { + queryClient.invalidateQueries({ queryKey: secretKeys.getSecretVersion(secretId) }); + } + }); +}; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 9f264d07fd..b2e28f4b94 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -112,6 +112,14 @@ export type SecretVersions = { membershipId?: string | null; groupId?: string | null; } | null; + isRedacted: boolean; + redactedByActor: { + username: string | null; + email: string | null; + projectMembershipId: string | null; + } | null; + redactedAt: string | null; + redactedByUserId: string | null; }; // dto 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 3c411a3e4a..a4d5c1297c 100644 --- a/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx +++ b/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx @@ -15,6 +15,9 @@ import { IconButton, Tooltip } from "@app/components/v2"; export interface Version { id?: string; version: number; + isRedacted?: boolean; + redactedAt?: Date | null; + redactedByUserId?: string | null; [key: string]: any; } @@ -115,6 +118,7 @@ export const SecretVersionDiffView = ({ | undefined; return { + isRedacted: version.isRedacted, secretKey: version.secretKey as string | undefined, secretValue: version.secretValue as string | undefined, secretValueHidden: version.secretValueHidden as boolean | undefined, diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretTableRow/SecretVersionHistory.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretTableRow/SecretVersionHistory.tsx index 573f780b41..a6bd313cde 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretTableRow/SecretVersionHistory.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretTableRow/SecretVersionHistory.tsx @@ -9,6 +9,7 @@ import { EyeIcon, EyeOffIcon, HardDriveIcon, + LockIcon, RotateCcwIcon, ServerCogIcon, UserIcon @@ -191,7 +192,8 @@ function VersionItem({ const handleRestore = async () => { try { - const value = await handleFetchSecretValue(); + // For redacted versions, restore with empty string + const value = version.isRedacted ? "" : await handleFetchSecretValue(); const result = await updateSecret({ projectId: currentProject.id, @@ -245,12 +247,20 @@ function VersionItem({ v{version.version} {isCurrentVersion && Current} + {version.isRedacted && ( + + + Redacted + + )}
{format(new Date(version.createdAt), "MMM d, yyyy, h:mm a")} @@ -258,7 +268,7 @@ function VersionItem({
{/* Actions */} - {canReadValue && !version.secretValueHidden && ( + {canReadValue && (
@@ -270,6 +280,11 @@ function VersionItem({ Are you sure you want to restore this secret to version {version.version}? This will overwrite the current value. + {version.isRedacted && ( + + Note: This version was redacted, so the value will be set to empty. + + )} @@ -307,54 +322,92 @@ function VersionItem({ )} )} - - - - - - - Copy Value - - - - - {isValueVisible ? : } - - - {isValueVisible ? "Hide Value" : "Show Value"} - + {!version.secretValueHidden && !version.isRedacted && ( + <> + + + + + + + Copy Value + + + + + {isValueVisible ? : } + + + {isValueVisible ? "Hide Value" : "Show Value"} + + + )}
)}
{/* Value input display */} - - -
- {/* eslint-disable-next-line no-nested-ternary */} - {isValueVisible ? ( - isFetchingValue ? ( - •••••••••••••••••••• + {version.isRedacted ? ( + + +
+ + Redacted +
+
+ +
+
+ + Value Redacted +
+ {version.redactedByActor && ( + + Redacted by{" "} + + {!version.redactedByActor.projectMembershipId + ? `${version.redactedByActor.username || version.redactedByActor.email} (Removed from project)` + : version.redactedByActor.username || + version.redactedByActor.email || + "Unknown User"} + + {version.redactedAt && ( + on {format(new Date(version.redactedAt), "MMM d, yyyy, h:mm a")} + )} + + )} +
+
+
+ ) : ( + + +
+ {/* eslint-disable-next-line no-nested-ternary */} + {isValueVisible ? ( + isFetchingValue ? ( + •••••••••••••••••••• + ) : ( + secretValue || EMPTY + ) ) : ( - secretValue || EMPTY - ) - ) : ( - •••••••••••••••••••• - )} -
-
- Access Denied -
+ •••••••••••••••••••• + )} +
+
+ Access Denied +
+ )} {/* Modified by */} {version.actor && ( diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index 6230829142..5a4b68e56b 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -54,7 +54,7 @@ import { import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { getProjectBaseURL } from "@app/helpers/project"; import { usePopUp, useToggle } from "@app/hooks"; -import { useGetSecretVersion } from "@app/hooks/api"; +import { useGetSecretVersion, useRedactSecretValue } from "@app/hooks/api"; import { dashboardKeys, fetchSecretValue, @@ -104,6 +104,8 @@ export const SecretDetailSidebar = ({ const [isFieldFocused, setIsFieldFocused] = useToggle(); const queryClient = useQueryClient(); + const { mutateAsync: redactSecretValue } = useRedactSecretValue(); + const canFetchSecretValue = Boolean(originalSecret) && !originalSecret.secretValueHidden && !originalSecret.isEmpty; @@ -215,7 +217,8 @@ export const SecretDetailSidebar = ({ const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp([ "secretAccessUpgradePlan", - "secretReferenceTree" + "secretReferenceTree", + "redactSecretValue" ] as const); const tagFields = useFieldArray({ @@ -369,6 +372,7 @@ export const SecretDetailSidebar = ({ /> + { if (isOpen && isDirty) { @@ -767,6 +771,16 @@ export const SecretDetailSidebar = ({ secretVersion={version} secret={secret} currentVersion={secretVersion.length} + onRedactSecretValue={async (versionId) => { + await redactSecretValue({ versionId, secretId: secret.id }); + + createNotification({ + title: "Secret value redacted", + text: "The secret value has been redacted successfully and is no longer persisted or viewable.", + type: "success" + }); + }} + canEditSecret={!cannotEditSecret} onRevert={async (versionValue) => { await fetchValue(); diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretVersionItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretVersionItem.tsx index 525b54444b..9b0a310e2d 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretVersionItem.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretVersionItem.tsx @@ -5,7 +5,9 @@ import { faBan, faDesktop, faEyeSlash, + faLock, faServer, + faTrash, faUser } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -14,8 +16,9 @@ import { format } from "date-fns"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { IconButton, Tooltip } from "@app/components/v2"; +import { DeleteActionModal, IconButton, Tooltip } from "@app/components/v2"; import { useProject } from "@app/context"; +import { usePopUp } from "@app/hooks"; import { ActorType } from "@app/hooks/api/auditLogs/enums"; import { fetchSecretVersionValue } from "@app/hooks/api/secrets/queries"; import { SecretV3RawSanitized, SecretVersions } from "@app/hooks/api/secrets/types"; @@ -25,18 +28,33 @@ interface SecretVersionItemProps { secret: SecretV3RawSanitized; currentVersion: number; onRevert: (secretValue: string) => void; + onRedactSecretValue: (versionId: string) => Promise; canReadValue: boolean; + canEditSecret: boolean; } export const SecretVersionItem = ({ - secretVersion: { createdAt, version, actor, secretValueHidden }, + secretVersion: { + createdAt, + version, + actor, + secretValueHidden, + id: versionId, + isRedacted, + redactedAt, + redactedByActor + }, secret, currentVersion, onRevert, - canReadValue + onRedactSecretValue, + canReadValue, + canEditSecret }: SecretVersionItemProps) => { const { currentProject } = useProject(); + const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["redactSecretValue"] as const); + const navigate = useNavigate(); const getModifiedByIcon = (userType: string | undefined | null) => { @@ -159,160 +177,244 @@ export const SecretVersionItem = ({ }; return ( -
-
-
-
-
- v{version} + <> + handlePopUpToggle("redactSecretValue", isOpen)} + onDeleteApproved={async () => { + await onRedactSecretValue(versionId); + + handlePopUpToggle("redactSecretValue", false); + }} + deleteKey="confirm" + title={`Are you sure you want to redact the secret value on version ${version}? This action is irreversible.`} + /> +
+
+
+
+
+ v{version} +
+
{format(new Date(createdAt), "Pp")}
-
{format(new Date(createdAt), "Pp")}
-
-
-
-
-
-
- {actor && ( -
-
- Modified by: - - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} -
- onModifyHistoryClick( - actor.actorId, - actor.actorType, - actor.membershipId, - actor.groupId, - actor.name - ) - : undefined +
+
+
+
+
+ {actor && ( +
+
+ Modified by: + - - {!actor.membershipId && - actor.actorType && - [ActorType.USER, ActorType.IDENTITY].includes( - actor.actorType as ActorType - ) && } -
- + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+ onModifyHistoryClick( + actor.actorId, + actor.actorType, + actor.membershipId, + actor.groupId, + actor.name + ) + : undefined + } + className={actor.membershipId ? "cursor-pointer" : undefined} + > + + {!actor.membershipId && + actor.actorType && + [ActorType.USER, ActorType.IDENTITY].includes( + actor.actorType as ActorType + ) && } +
+ +
-
- )} -
-
- Value: -
-
-
- + +
+ + **** + - - -
- - **** - - +
+ ) : ( +
+
+ +
+ Redacted + +
+ +
+ Redacted by{" "} + + {!redactedByActor?.projectMembershipId + ? `${redactedByActor?.username} (Removed from project)` + : redactedByActor.username || + redactedByActor.email || + "Unknown User"} + {" "} + {redactedAt && ( + + on {format(new Date(redactedAt || ""), "Pp")} + + )} +
+
+ } + > +
+ Redacted + +
+ +
+
+ )}
-
- {!secret?.isRotatedSecret && canReadValue && ( -
- - { - if (secretValue) { - onRevert(secretValue); - return; - } +
+ {!secret?.isRotatedSecret && canReadValue && ( +
+ + { + if (secretValue) { + onRevert(secretValue); + return; + } - const value = await handleGetSecretValue(); + const value = await handleGetSecretValue(); - onRevert(value); - }} + onRevert(value); + }} + > + + + +
+ )} + + {!secret?.isRotatedSecret && canEditSecret && !isRedacted && ( +
- - - + + handlePopUpOpen("redactSecretValue")} + > + + + +
+ )}
- )} -
+
+ ); }; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 17f6579025..f9379c1c1e 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -12,6 +12,7 @@ "./src/*" ] }, + "types": [], /* Bundler mode */ "moduleResolution": "bundler",