From 577c81be65b3a28fcb626d376f0b092b1ccea399 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Wed, 2 Apr 2025 14:11:59 -0700 Subject: [PATCH] improvements: address feedback --- ...50329002640_secrets-v2-unique-key-index.ts | 29 ---- .../v1/secret-approval-request-router.ts | 4 +- backend/src/ee/routes/v1/snapshot-router.ts | 3 +- .../secret-rotation-v2-endpoints.ts | 2 +- .../dynamic-secret/dynamic-secret-fns.ts | 3 +- .../secret-approval-request-secret-dal.ts | 14 +- .../secret-approval-request-service.ts | 10 +- .../secret-rotation-v2-dal.ts | 8 +- .../secret-rotation-v2-fns.ts | 8 +- .../secret-rotation-v2-queue.ts | 8 +- .../secret-rotation-v2-service.ts | 147 ++++++++++++------ .../secret-snapshot-service.ts | 75 ++++++--- .../services/secret-snapshot/snapshot-dal.ts | 25 ++- backend/src/keystore/keystore.ts | 3 +- backend/src/lib/knex/connection.ts | 4 +- backend/src/lib/knex/dynamic.ts | 20 ++- .../app-connection/app-connection-service.ts | 75 ++++----- .../app-connection/app-connection-types.ts | 52 ++++--- .../aws/aws-connection-types.ts | 2 +- ...zure-app-configuration-connection-types.ts | 2 +- .../azure-key-vault-connection-types.ts | 2 +- .../databricks/databricks-connection-types.ts | 2 +- .../gcp/gcp-connection-types.ts | 2 +- .../github/github-connection-types.ts | 2 +- .../humanitec/humanitec-connection-types.ts | 2 +- .../mssql/mssql-connection-types.ts | 2 +- .../postgres/postgres-connection-types.ts | 2 +- .../shared/sql/sql-connection-fns.ts | 15 +- .../secret-v2-bridge/secret-v2-bridge-dal.ts | 17 +- .../secret-v2-bridge-service.ts | 4 + .../SecretRotationV2StatusBadge.tsx | 42 ++--- ...ewSecretRotationV2GeneratedCredentials.tsx | 5 +- .../SecretRotationV2ConfigurationFields.tsx | 4 +- .../forms/SecretRotationV2ConnectionField.tsx | 19 ++- .../shared/SqlRotationParametersFields.tsx | 13 +- .../v2/NoticeBannerV2/NoticeBannerV2.tsx | 11 +- frontend/src/helpers/secretRotationsV2.ts | 18 +-- .../hooks/api/secretApprovalRequest/types.ts | 1 + .../src/hooks/api/secretSnapshots/queries.tsx | 10 +- .../src/hooks/api/secretSnapshots/types.ts | 2 +- .../components/DeleteAppConnectionModal.tsx | 14 +- .../SecretApprovalRequestChangeItem.tsx | 16 +- .../components/SnapshotView/SecretItem.tsx | 11 +- .../components/SnapshotView/SnapshotView.tsx | 12 +- 44 files changed, 453 insertions(+), 269 deletions(-) delete mode 100644 backend/src/db/migrations/20250329002640_secrets-v2-unique-key-index.ts diff --git a/backend/src/db/migrations/20250329002640_secrets-v2-unique-key-index.ts b/backend/src/db/migrations/20250329002640_secrets-v2-unique-key-index.ts deleted file mode 100644 index f97bb9b6f..000000000 --- a/backend/src/db/migrations/20250329002640_secrets-v2-unique-key-index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Knex } from "knex"; - -import { TableName } from "@app/db/schemas"; - -const INDEX_NAME = "idx_unique_secret_v2_key"; - -export async function up(knex: Knex): Promise { - const hasKeyCol = await knex.schema.hasColumn(TableName.SecretV2, "key"); - const hasFolderIdCol = await knex.schema.hasColumn(TableName.SecretV2, "folderId"); - const hasTypeCol = await knex.schema.hasColumn(TableName.SecretV2, "type"); - - if (hasKeyCol && hasFolderIdCol && hasTypeCol) { - await knex.raw(` - CREATE UNIQUE INDEX ${INDEX_NAME} - ON ${TableName.SecretV2} ("key", "folderId") - WHERE type = 'shared' - `); - } -} - -export async function down(knex: Knex): Promise { - const hasKeyCol = await knex.schema.hasColumn(TableName.SecretV2, "key"); - const hasFolderIdCol = await knex.schema.hasColumn(TableName.SecretV2, "folderId"); - const hasTypeCol = await knex.schema.hasColumn(TableName.SecretV2, "type"); - - if (hasKeyCol && hasFolderIdCol && hasTypeCol) { - await knex.raw(`DROP INDEX IF EXISTS ${INDEX_NAME}`); - } -} diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 98cb9244d..7d2cdcc0c 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -277,8 +277,10 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(), secretPath: z.string(), commits: secretRawSchema - .omit({ _id: true, environment: true, workspace: true, type: true, version: true }) + .omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true }) .extend({ + secretValue: z.string().optional(), + isRotatedSecret: z.boolean().optional(), op: z.string(), tags: SanitizedTagSchema.array().optional(), secretMetadata: ResourceMetadataSchema.nullish(), diff --git a/backend/src/ee/routes/v1/snapshot-router.ts b/backend/src/ee/routes/v1/snapshot-router.ts index 283b9b31e..494871ec1 100644 --- a/backend/src/ee/routes/v1/snapshot-router.ts +++ b/backend/src/ee/routes/v1/snapshot-router.ts @@ -33,7 +33,8 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => { .extend({ secretValueHidden: z.boolean(), secretId: z.string(), - tags: SanitizedTagSchema.array() + tags: SanitizedTagSchema.array(), + isRotatedSecret: z.boolean().optional() }) .array(), folderVersion: z.object({ id: z.string(), name: z.string() }).array(), diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts index cd3c8d4cb..05d3c7961 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts @@ -151,7 +151,7 @@ export const registerSecretRotationEndpoints = < rateLimit: readLimit }, schema: { - description: `Get the specified ${rotationType} Rotation by name and project ID.`, + description: `Get the specified ${rotationType} Rotation by name, secret path, environment and project ID.`, params: z.object({ rotationName: z .string() diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts index 5c5b9f30c..d4b634b5d 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -13,7 +13,8 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) = const reservedHosts = [appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI)].concat( (appCfg.DB_READ_REPLICAS || []).map((el) => getDbConnectionHost(el.DB_CONNECTION_URI)), - getDbConnectionHost(appCfg.REDIS_URL) + getDbConnectionHost(appCfg.REDIS_URL), + getDbConnectionHost(appCfg.AUDIT_LOGS_DB_CONNECTION_URI) ); // get host db ip diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts index f72977a9b..c1b18e43d 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts @@ -257,6 +257,11 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { db.ref("id").withSchema("secVerTag") ) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .select(selectAllTableCols(TableName.SecretApprovalRequestSecretV2)) .select({ secVerTagId: "secVerTag.id", @@ -285,7 +290,8 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") - ); + ) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); const formatedDoc = sqlNestRelationships({ data: doc, key: "id", @@ -304,14 +310,16 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { { key: "secretId", label: "secret" as const, - mapper: ({ orgSecVersion, orgSecKey, orgSecValue, orgSecComment, secretId }) => + mapper: ({ orgSecVersion, orgSecKey, orgSecValue, orgSecComment, secretId, rotationId }) => secretId ? { id: secretId, version: orgSecVersion, key: orgSecKey, encryptedValue: orgSecValue, - encryptedComment: orgSecComment + encryptedComment: orgSecComment, + isRotatedSecret: Boolean(rotationId), + rotationId } : undefined }, diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 879065ef5..e24cd923e 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -262,7 +262,13 @@ export const secretApprovalRequestServiceFactory = ({ id: el.id, version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, - secretValue: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + isRotatedSecret: el.secret.isRotatedSecret, + // eslint-disable-next-line no-nested-ternary + secretValue: el.secret.isRotatedSecret + ? undefined + : el.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() + : "", secretComment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "", @@ -609,7 +615,7 @@ export const secretApprovalRequestServiceFactory = ({ tx, inputSecrets: secretUpdationCommits.map((el) => { const encryptedValue = - typeof el.encryptedValue !== "undefined" + !el.secret.isRotatedSecret && typeof el.encryptedValue !== "undefined" ? { encryptedValue: el.encryptedValue as Buffer, references: el.encryptedValue diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts index 45809aae5..c3e7a12fb 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts @@ -189,9 +189,11 @@ export const secretRotationV2DALFactory = ( .countDistinct(`${TableName.SecretRotationV2}.name`); if (search) { - void query - .whereILike(`${TableName.SecretV2}.key`, `%${search}%`) - .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`); + void query.where((qb) => { + void qb + .whereILike(`${TableName.SecretV2}.key`, `%${search}%`) + .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`); + }); } const result = await query; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts index 20709e05a..376b497f1 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -23,7 +23,7 @@ export const listSecretRotationOptions = () => { return Object.values(SECRET_ROTATION_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name)); }; -const getNextUTCMidnight = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => { +const getNextUTCDayInterval = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => { const now = new Date(); return new Date( @@ -39,7 +39,7 @@ const getNextUTCMidnight = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"] ); }; -const getNextUTCMinute = ({ minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => { +const getNextUTCMinuteInterval = ({ minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => { const now = new Date(); return new Date( Date.UTC( @@ -58,10 +58,10 @@ export const getNextUtcRotationInterval = (rotateAtUtc?: TSecretRotationV2["rota const appCfg = getConfig(); if (appCfg.isRotationDevelopmentMode) { - return getNextUTCMinute(rotateAtUtc); + return getNextUTCMinuteInterval(rotateAtUtc); } - return getNextUTCMidnight(rotateAtUtc); + return getNextUTCDayInterval(rotateAtUtc); }; export const encryptSecretRotationCredentials = async ({ diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts index 51946bfa7..f15cc4974 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts @@ -80,7 +80,7 @@ export const secretRotationV2QueueServiceFactory = async ({ { batchSize: 1, workerCount: 1, - pollingIntervalSeconds: 0.5 + pollingIntervalSeconds: appCfg.isRotationDevelopmentMode ? 0.5 : 30 } ); @@ -122,7 +122,7 @@ export const secretRotationV2QueueServiceFactory = async ({ }, { batchSize: 1, - workerCount: 30, + workerCount: 2, pollingIntervalSeconds: 0.5 } ); @@ -179,8 +179,8 @@ export const secretRotationV2QueueServiceFactory = async ({ }, { batchSize: 1, - workerCount: 5, - pollingIntervalSeconds: 30 + workerCount: 2, + pollingIntervalSeconds: 1 } ); diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index aecf8242a..2576791da 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError, subject } from "@casl/ability"; +import { Knex } from "knex"; import isEqual from "lodash.isequal"; import { ActionProjectType, SecretType, TableName } from "@app/db/schemas"; @@ -46,7 +47,7 @@ import { } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; import { sqlCredentialsRotationFactory } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; -import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, InternalServerError, NotFoundError } from "@app/lib/errors"; @@ -141,6 +142,37 @@ export const secretRotationV2ServiceFactory = ({ ); }; + const $throwOnConflictingSecrets = async ({ + secretKeys, + folderId, + tx, + secretPath + }: { + secretKeys: string[]; + folderId: string; + tx: Knex; + secretPath: string; + }) => { + const conflictingSecrets = await secretV2BridgeDAL.find( + { + $in: { + [`${TableName.SecretV2}.key` as "key"]: secretKeys + }, + [`${TableName.SecretV2}.folderId` as "folderId"]: folderId, + [`${TableName.SecretV2}.type` as "type"]: SecretType.Shared + }, + { tx } + ); + + if (conflictingSecrets.length) { + throw new BadRequestError({ + message: `The following secrets already exist at the path "${secretPath}": ${conflictingSecrets + .map(({ key }) => key) + .join(", ")}` + }); + } + }; + const listSecretRotationsByProjectId = async ( { projectId, type }: TListSecretRotationsV2ByProjectId, actor: OrgServiceActor @@ -345,6 +377,7 @@ export const secretRotationV2ServiceFactory = ({ secretPath, environment, rotateAtUtc = { hours: 0, minutes: 0 }, + secretsMapping, ...payload }: TCreateSecretRotationV2DTO, actor: OrgServiceActor @@ -368,7 +401,10 @@ export const secretRotationV2ServiceFactory = ({ const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); if (!shouldUseSecretV2Bridge) - throw new BadRequestError({ message: "Project version does not support Secret Rotation V2" }); + throw new BadRequestError({ + message: + "Project version does not support Secret Rotation V2. Please upgrade your project via the Infiscal Dashboard to gain access." + }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretRotationActions.Create, @@ -389,7 +425,7 @@ export const secretRotationV2ServiceFactory = ({ const rotationFactory = SECRET_ROTATION_FACTORY_MAP[payload.type]({ parameters: payload.parameters, - secretsMapping: payload.secretsMapping, + secretsMapping, connection } as TSecretRotationV2WithConnection); @@ -405,9 +441,19 @@ export const secretRotationV2ServiceFactory = ({ }); return secretRotationV2DAL.transaction(async (tx) => { + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.SecretRotationV2Creation(folder.id)]); + + await $throwOnConflictingSecrets({ + secretPath, + secretKeys: Object.values(secretsMapping), + tx, + folderId: folder.id + }); + const createdRotation = await secretRotationV2DAL.create( { folderId: folder.id, + secretsMapping, ...payload, encryptedGeneratedCredentials, rotateAtUtc, @@ -483,12 +529,6 @@ export const secretRotationV2ServiceFactory = ({ throw new BadRequestError({ message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${secretPath}"` }); - case TableName.SecretV2: - throw new BadRequestError({ - message: `One or more of the following secrets already exists at the secret path "${secretPath}": ${Object.values( - payload.secretsMapping - ).join(", ")}` - }); default: throw err; } @@ -497,6 +537,8 @@ export const secretRotationV2ServiceFactory = ({ throw err; } + if (err instanceof BadRequestError) throw err; + throw new BadRequestError({ message: parseRotationErrorMessage(err) }); @@ -521,7 +563,8 @@ export const secretRotationV2ServiceFactory = ({ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID ${rotationId}` }); - const { folder, environment, projectId, folderId, connection, secretsMapping } = secretRotation; + const { folder, environment, projectId, folderId, connection } = secretRotation; + const secretsMapping = secretRotation.secretsMapping as TSecretRotationV2["secretsMapping"]; const { permission } = await permissionService.getProjectPermission({ actor: actor.type, @@ -551,26 +594,36 @@ export const secretRotationV2ServiceFactory = ({ isManualRotation: false }); + let secretsMappingUpdated = false; + try { const updatedSecretRotation = await secretRotationV2DAL.transaction(async (tx) => { + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.SecretRotationV2Creation(folder.id)]); + if (payload.secretsMapping && !isEqual(payload.secretsMapping, secretsMapping)) { + const currentMappingKeys = Object.values(secretsMapping); + await $throwOnConflictingSecrets({ + secretPath: folder.path, + secretKeys: Object.values(payload.secretsMapping).filter((key) => !currentMappingKeys.includes(key)), + tx, + folderId: folder.id + }); + // update mapped secrets names await fnSecretBulkUpdate({ folderId, orgId: connection.orgId, tx, - inputSecrets: Object.entries(secretsMapping as TSecretRotationV2["secretsMapping"]).map( - ([mappingKey, secretKey]) => ({ - filter: { - key: secretKey, - folderId, - type: SecretType.Shared - }, - data: { - key: payload.secretsMapping![mappingKey as keyof TSecretRotationV2["secretsMapping"]] - } - }) - ), + inputSecrets: Object.entries(secretsMapping).map(([mappingKey, secretKey]) => ({ + filter: { + key: secretKey, + folderId, + type: SecretType.Shared + }, + data: { + key: payload.secretsMapping![mappingKey as keyof TSecretRotationV2["secretsMapping"]] + } + })), secretDAL: secretV2BridgeDAL, secretVersionDAL: secretVersionV2BridgeDAL, secretVersionTagDAL: secretVersionTagV2BridgeDAL, @@ -578,14 +631,7 @@ export const secretRotationV2ServiceFactory = ({ resourceMetadataDAL }); - await snapshotService.performSnapshot(folder.id); - await secretQueueService.syncSecrets({ - orgId: connection.orgId, - secretPath: folder.path, - projectId, - environmentSlug: environment.slug, - excludeReplication: true - }); + secretsMappingUpdated = true; } return secretRotationV2DAL.updateById( @@ -598,6 +644,17 @@ export const secretRotationV2ServiceFactory = ({ ); }); + if (secretsMappingUpdated) { + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath: folder.path, + projectId, + environmentSlug: environment.slug, + excludeReplication: true + }); + } + // queue for rotation if adjusted time falls before next cron if (nextRotationAt && nextRotationAt.getTime() < getNextUtcRotationInterval().getTime()) { await queueService.queuePg( @@ -620,20 +677,14 @@ export const secretRotationV2ServiceFactory = ({ message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${folder.path}"` }); break; - case TableName.SecretV2: - if (payload.secretsMapping) - throw new BadRequestError({ - message: `One or more of the following secrets already exists at the secret path "${ - folder.path - }": ${Object.values(payload.secretsMapping).join(", ")}` - }); - break; default: throw err; } } } + if (err instanceof BadRequestError) throw err; + throw err; } }; @@ -695,15 +746,6 @@ export const secretRotationV2ServiceFactory = ({ actorId: actor.id, // not actually used since rotated secrets are shared tx }); - - await snapshotService.performSnapshot(folder.id); - await secretQueueService.syncSecrets({ - orgId: connection.orgId, - secretPath: folder.path, - projectId, - environmentSlug: environment.slug, - excludeReplication: true - }); } return secretRotationV2DAL.deleteById(rotationId, tx); @@ -728,6 +770,17 @@ export const secretRotationV2ServiceFactory = ({ await deleteTransaction; } + if (deleteSecrets) { + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath: folder.path, + projectId, + environmentSlug: environment.slug, + excludeReplication: true + }); + } + return expandSecretRotation(secretRotation, kmsService); }; 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 5fbb3f598..015a8d420 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -398,8 +398,32 @@ export const secretSnapshotServiceFactory = ({ if (shouldUseBridge) { const rollback = await snapshotDAL.transaction(async (tx) => { const rollbackSnaps = await snapshotDAL.findRecursivelySnapshotsV2Bridge(snapshot.id, tx); - // this will remove all secrets in current folder - const deletedTopLevelSecs = await secretV2BridgeDAL.delete({ folderId: snapshot.folderId }, tx); + const secretRotationIds = rollbackSnaps + .flatMap((snap) => snap.secretVersions) + .filter((el) => el.isRotatedSecret) + .map((el) => el.secretId); + + // this will remove all secrets in current folder except rotated secrets which we ignore + const deletedTopLevelSecs = await secretV2BridgeDAL.delete( + { + $complex: { + operator: "and", + value: [ + { + operator: "eq", + field: "folderId", + value: snapshot.folderId + }, + { + operator: "notIn", + field: "id", + value: secretRotationIds + } + ] + } + }, + tx + ); const deletedTopLevelSecsGroupById = groupBy(deletedTopLevelSecs, (item) => item.id); // this will remove all secrets and folders on child // due to sql foreign key and link list connection removing the folders removes everything below too @@ -424,28 +448,31 @@ export const secretSnapshotServiceFactory = ({ ); const secrets = await secretV2BridgeDAL.insertMany( rollbackSnaps.flatMap(({ secretVersions, folderId }) => - secretVersions.map( - ({ - latestSecretVersion, - version, - updatedAt, - createdAt, - secretId, - envId, - id, - tags, - // exclude the bottom fields from the secret - they are for versioning only. - userActorId, - identityActorId, - actorType, - ...el - }) => ({ - ...el, - id: secretId, - version: deletedTopLevelSecsGroupById[secretId] ? latestSecretVersion + 1 : latestSecretVersion, - folderId - }) - ) + secretVersions + .filter((v) => !v.isRotatedSecret) + .map( + ({ + latestSecretVersion, + version, + updatedAt, + createdAt, + secretId, + envId, + id, + tags, + // exclude the bottom fields from the secret - they are for versioning only. + userActorId, + identityActorId, + actorType, + isRotatedSecret, + ...el + }) => ({ + ...el, + id: secretId, + version: deletedTopLevelSecsGroupById[secretId] ? latestSecretVersion + 1 : latestSecretVersion, + folderId + }) + ) ), tx ); diff --git a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts index d8240f27e..c547d85c2 100644 --- a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts +++ b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts @@ -181,6 +181,11 @@ export const snapshotDALFactory = (db: TDbClient) => { `${TableName.SnapshotFolder}.folderVersionId`, `${TableName.SecretFolderVersion}.id` ) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretRotationV2SecretMapping}.secretId`, + `${TableName.SecretVersionV2}.secretId` + ) .select(selectAllTableCols(TableName.SecretVersionV2)) .select( db.ref("id").withSchema(TableName.Snapshot).as("snapshotId"), @@ -195,7 +200,8 @@ export const snapshotDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretVersionV2Tag).as("tagVersionId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), + db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping) ); return sqlNestRelationships({ data, @@ -221,7 +227,11 @@ export const snapshotDALFactory = (db: TDbClient) => { { key: "id", label: "secretVersions" as const, - mapper: (el) => SecretVersionsV2Schema.parse(el), + mapper: (el) => ({ + ...SecretVersionsV2Schema.parse(el), + isRotatedSecret: Boolean(el.rotationId), + rotationId: el.rotationId + }), childrenMapper: [ { key: "tagVersionId", @@ -476,6 +486,11 @@ export const snapshotDALFactory = (db: TDbClient) => { `${TableName.SecretVersionV2Tag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretVersionV2}.secretId`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .leftJoin<{ latestSecretVersion: number }>( (tx || db)(TableName.SecretVersionV2) .groupBy("secretId") @@ -506,7 +521,8 @@ export const snapshotDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretVersionV2Tag).as("tagVersionId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), + db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping) ); const formated = sqlNestRelationships({ @@ -523,7 +539,8 @@ export const snapshotDALFactory = (db: TDbClient) => { label: "secretVersions" as const, mapper: (el) => ({ ...SecretVersionsV2Schema.parse(el), - latestSecretVersion: el.latestSecretVersion as number + latestSecretVersion: el.latestSecretVersion as number, + isRotatedSecret: Boolean(el.rotationId) }), childrenMapper: [ { diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index ea26a2cf7..8fef532f5 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -8,7 +8,8 @@ export const PgSqlLock = { SuperAdminInit: 2024, KmsRootKeyInit: 2025, OrgGatewayRootCaInit: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-root-ca:${orgId}`), - OrgGatewayCertExchange: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-cert-exchange:${orgId}`) + OrgGatewayCertExchange: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-cert-exchange:${orgId}`), + SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`) } as const; export type TKeyStoreFactory = ReturnType; diff --git a/backend/src/lib/knex/connection.ts b/backend/src/lib/knex/connection.ts index 993615a0b..68b40e18f 100644 --- a/backend/src/lib/knex/connection.ts +++ b/backend/src/lib/knex/connection.ts @@ -1,6 +1,8 @@ import { URL } from "url"; // Import the URL class -export const getDbConnectionHost = (urlString: string) => { +export const getDbConnectionHost = (urlString?: string) => { + if (!urlString) return null; + try { const url = new URL(urlString); // Split hostname and port (if provided) diff --git a/backend/src/lib/knex/dynamic.ts b/backend/src/lib/knex/dynamic.ts index b8bc8ab57..a57464fac 100644 --- a/backend/src/lib/knex/dynamic.ts +++ b/backend/src/lib/knex/dynamic.ts @@ -2,11 +2,17 @@ import { Knex } from "knex"; import { UnauthorizedError } from "../errors"; -type TKnexDynamicPrimitiveOperator = { - operator: "eq" | "ne" | "startsWith" | "endsWith"; - value: string; - field: Extract; -}; +type TKnexDynamicPrimitiveOperator = + | { + operator: "eq" | "ne" | "startsWith" | "endsWith"; + value: string; + field: Extract; + } + | { + operator: "notIn"; + value: string[]; + field: Extract; + }; type TKnexDynamicInOperator = { operator: "in"; @@ -48,6 +54,10 @@ export const buildDynamicKnexQuery = ( void queryBuilder.whereILike(filterAst.field, `%${filterAst.value}`); break; } + case "notIn": { + void queryBuilder.whereNotIn(filterAst.field, filterAst.value); + break; + } case "and": { filterAst.value.forEach((el) => { void queryBuilder.andWhere((subQueryBuilder) => { diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 200b2fe0c..978f3bfd7 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -25,7 +25,7 @@ import { TAppConnectionRaw, TCreateAppConnectionDTO, TUpdateAppConnectionDTO, - TValidateAppConnectionCredentials + TValidateAppConnectionCredentialsSchema } from "./app-connection-types"; import { ValidateAwsConnectionCredentialsSchema } from "./aws"; import { awsConnectionService } from "./aws/aws-connection-service"; @@ -50,7 +50,7 @@ export type TAppConnectionServiceFactoryDep = { export type TAppConnectionServiceFactory = ReturnType; -const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { +const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema, [AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema, @@ -170,26 +170,22 @@ export const appConnectionServiceFactory = ({ } as TAppConnectionConfig); try { - const createTransaction = (connectionCredentials: TAppConnection["credentials"]) => - appConnectionDAL.transaction(async (tx) => { - const encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: connectionCredentials, - orgId: actor.orgId, - kmsService - }); - - return appConnectionDAL.create( - { - orgId: actor.orgId, - encryptedCredentials, - method, - app, - ...params - }, - tx - ); + const createConnection = async (connectionCredentials: TAppConnection["credentials"]) => { + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: connectionCredentials, + orgId: actor.orgId, + kmsService }); + return appConnectionDAL.create({ + orgId: actor.orgId, + encryptedCredentials, + method, + app, + ...params + }); + }; + let connection: TAppConnectionRaw; if (params.isPlatformManagedCredentials) { @@ -200,10 +196,10 @@ export const appConnectionServiceFactory = ({ credentials: validatedCredentials, method } as TAppConnectionConfig, - (platformCredentials) => createTransaction(platformCredentials) + (platformCredentials) => createConnection(platformCredentials) ); } else { - connection = await createTransaction(validatedCredentials); + connection = await createConnection(validatedCredentials); } return { @@ -277,26 +273,21 @@ export const appConnectionServiceFactory = ({ } try { - const updateTransaction = (connectionCredentials: TAppConnection["credentials"] | undefined) => - appConnectionDAL.transaction(async (tx) => { - const encryptedCredentials = connectionCredentials - ? await encryptAppConnectionCredentials({ - credentials: connectionCredentials, - orgId: actor.orgId, - kmsService - }) - : undefined; - - return appConnectionDAL.updateById( - connectionId, - { + const updateConnection = async (connectionCredentials: TAppConnection["credentials"] | undefined) => { + const encryptedCredentials = connectionCredentials + ? await encryptAppConnectionCredentials({ + credentials: connectionCredentials, orgId: actor.orgId, - encryptedCredentials, - ...params - }, - tx - ); + kmsService + }) + : undefined; + + return appConnectionDAL.updateById(connectionId, { + orgId: actor.orgId, + encryptedCredentials, + ...params }); + }; let updatedConnection: TAppConnectionRaw; @@ -312,10 +303,10 @@ export const appConnectionServiceFactory = ({ credentials: updatedCredentials, method } as TAppConnectionConfig, - (platformCredentials) => updateTransaction(platformCredentials) + (platformCredentials) => updateConnection(platformCredentials) ); } else { - updatedConnection = await updateTransaction(updatedCredentials); + updatedConnection = await updateConnection(updatedCredentials); } return await decryptAppConnection(updatedConnection, kmsService); diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index 1690f13da..be276d606 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -3,40 +3,54 @@ import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sq import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { AWSRegion } from "./app-connection-enums"; -import { TAwsConnection, TAwsConnectionConfig, TAwsConnectionInput, TValidateAwsConnectionCredentials } from "./aws"; +import { + TAwsConnection, + TAwsConnectionConfig, + TAwsConnectionInput, + TValidateAwsConnectionCredentialsSchema +} from "./aws"; import { TAzureAppConfigurationConnection, TAzureAppConfigurationConnectionConfig, TAzureAppConfigurationConnectionInput, - TValidateAzureAppConfigurationConnectionCredentials + TValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration"; import { TAzureKeyVaultConnection, TAzureKeyVaultConnectionConfig, TAzureKeyVaultConnectionInput, - TValidateAzureKeyVaultConnectionCredentials + TValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault"; import { TDatabricksConnection, TDatabricksConnectionConfig, TDatabricksConnectionInput, - TValidateDatabricksConnectionCredentials + TValidateDatabricksConnectionCredentialsSchema } from "./databricks"; -import { TGcpConnection, TGcpConnectionConfig, TGcpConnectionInput, TValidateGcpConnectionCredentials } from "./gcp"; +import { + TGcpConnection, + TGcpConnectionConfig, + TGcpConnectionInput, + TValidateGcpConnectionCredentialsSchema +} from "./gcp"; import { TGitHubConnection, TGitHubConnectionConfig, TGitHubConnectionInput, - TValidateGitHubConnectionCredentials + TValidateGitHubConnectionCredentialsSchema } from "./github"; import { THumanitecConnection, THumanitecConnectionConfig, THumanitecConnectionInput, - TValidateHumanitecConnectionCredentials + TValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; -import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentials } from "./mssql"; -import { TPostgresConnection, TPostgresConnectionInput, TValidatePostgresConnectionCredentials } from "./postgres"; +import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentialsSchema } from "./mssql"; +import { + TPostgresConnection, + TPostgresConnectionInput, + TValidatePostgresConnectionCredentialsSchema +} from "./postgres"; export type TAppConnection = { id: string } & ( | TAwsConnection @@ -87,16 +101,16 @@ export type TAppConnectionConfig = | THumanitecConnectionConfig | TSqlConnectionConfig; -export type TValidateAppConnectionCredentials = - | TValidateAwsConnectionCredentials - | TValidateGitHubConnectionCredentials - | TValidateGcpConnectionCredentials - | TValidateAzureKeyVaultConnectionCredentials - | TValidateAzureAppConfigurationConnectionCredentials - | TValidateDatabricksConnectionCredentials - | TValidateHumanitecConnectionCredentials - | TValidatePostgresConnectionCredentials - | TValidateMsSqlConnectionCredentials; +export type TValidateAppConnectionCredentialsSchema = + | TValidateAwsConnectionCredentialsSchema + | TValidateGitHubConnectionCredentialsSchema + | TValidateGcpConnectionCredentialsSchema + | TValidateAzureKeyVaultConnectionCredentialsSchema + | TValidateAzureAppConfigurationConnectionCredentialsSchema + | TValidateDatabricksConnectionCredentialsSchema + | TValidateHumanitecConnectionCredentialsSchema + | TValidatePostgresConnectionCredentialsSchema + | TValidateMsSqlConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/aws/aws-connection-types.ts b/backend/src/services/app-connection/aws/aws-connection-types.ts index a0b74c3d0..a311d604b 100644 --- a/backend/src/services/app-connection/aws/aws-connection-types.ts +++ b/backend/src/services/app-connection/aws/aws-connection-types.ts @@ -15,7 +15,7 @@ export type TAwsConnectionInput = z.infer & { app: AppConnection.AWS; }; -export type TValidateAwsConnectionCredentials = typeof ValidateAwsConnectionCredentialsSchema; +export type TValidateAwsConnectionCredentialsSchema = typeof ValidateAwsConnectionCredentialsSchema; export type TAwsConnectionConfig = DiscriminativePick & { orgId: string; diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts index db59a1558..8111b4c50 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts @@ -16,7 +16,7 @@ export type TAzureAppConfigurationConnectionInput = z.infer & { app: AppConnection.GCP; }; -export type TValidateGcpConnectionCredentials = typeof ValidateGcpConnectionCredentialsSchema; +export type TValidateGcpConnectionCredentialsSchema = typeof ValidateGcpConnectionCredentialsSchema; export type TGcpConnectionConfig = DiscriminativePick & { orgId: string; diff --git a/backend/src/services/app-connection/github/github-connection-types.ts b/backend/src/services/app-connection/github/github-connection-types.ts index 714c87174..600506277 100644 --- a/backend/src/services/app-connection/github/github-connection-types.ts +++ b/backend/src/services/app-connection/github/github-connection-types.ts @@ -15,6 +15,6 @@ export type TGitHubConnectionInput = z.infer; diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts index 94613bfba..b9d084a86 100644 --- a/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts @@ -15,7 +15,7 @@ export type THumanitecConnectionInput = z.infer app: AppConnection.MsSql; }; -export type TValidateMsSqlConnectionCredentials = typeof ValidateMsSqlConnectionCredentialsSchema; +export type TValidateMsSqlConnectionCredentialsSchema = typeof ValidateMsSqlConnectionCredentialsSchema; diff --git a/backend/src/services/app-connection/postgres/postgres-connection-types.ts b/backend/src/services/app-connection/postgres/postgres-connection-types.ts index 0c8bcff42..845b2b825 100644 --- a/backend/src/services/app-connection/postgres/postgres-connection-types.ts +++ b/backend/src/services/app-connection/postgres/postgres-connection-types.ts @@ -13,4 +13,4 @@ export type TPostgresConnectionInput = z.infer, - options?: Record -) => { +export const getSqlConnectionClient = async (appConnection: Pick) => { const { app, credentials: { host: baseHost, database, port, sslCertificate, password, username } @@ -41,7 +38,15 @@ export const getSqlConnectionClient = async ( password, connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, ssl, - options + // following dynamic secret mssql driver requirements (see sql-database.ts) + // @ts-expect-error this is because of knexjs type signature issue. This is directly passed to driver + options: + app === AppConnection.MsSql + ? { + trustServerCertificate: !sslCertificate, + cryptoCredentialsDetails: sslCertificate ? { ca: sslCertificate } : {} + } + : undefined } }); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 8d9e5f0cc..9bed01637 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -548,6 +548,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { try { const secrets = await (tx || db.replicaNode())(TableName.SecretV2) .where({ folderId }) + .where((bd) => { query.forEach((el) => { if (el.type === SecretType.Personal && !el.userId) { @@ -559,10 +560,20 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { userId: el.type === SecretType.Personal ? el.userId : null }); }); - }); - return secrets; + }) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) + .select(selectAllTableCols(TableName.SecretV2)) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); + return secrets.map((secret) => ({ + ...secret, + isRotatedSecret: Boolean(secret.rotationId) + })); } catch (error) { - throw new DatabaseError({ error, name: "find by blind indexes" }); + throw new DatabaseError({ error, name: "find by secret keys" }); } }; 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 dc209a794..3d6d80e2e 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 @@ -2234,6 +2234,10 @@ export const secretV2BridgeServiceFactory = ({ const destinationActions = [ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit] as const; sourceSecrets.forEach((secret) => { + if (secret.isRotatedSecret) { + throw new BadRequestError({ message: `Cannot move rotated secret: ${secret.key}` }); + } + for (const sourceAction of sourceActions) { if ( sourceAction === ProjectPermissionSecretActions.DescribeSecret || diff --git a/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx b/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx index ace829aae..a7e6cbbce 100644 --- a/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx +++ b/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx @@ -4,7 +4,7 @@ import { format, formatDistanceToNow } from "date-fns"; import { twMerge } from "tailwind-merge"; import { Tooltip } from "@app/components/v2"; -import { Badge, BadgeProps } from "@app/components/v2/Badge/Badge"; +import { Badge } from "@app/components/v2/Badge/Badge"; import { SecretRotationStatus, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; type Props = { @@ -78,40 +78,30 @@ export const SecretRotationV2StatusBadge = ({ secretRotation, className }: Props const daysToRotation = (new Date(nextRotationAt).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24); - let variant: BadgeProps["variant"]; - let label: string; - let tooltipContent: string; - - if (daysToRotation >= 7) { - variant = "success"; - label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`; - tooltipContent = `Rotates ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`; - } else if (daysToRotation < 0) { - variant = "primary"; - label = "Rotating"; - tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`; - } else if (daysToRotation < 1) { - variant = "primary"; - label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`; - tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`; - } else { - variant = "primary"; - label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`; - tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`; - } - return ( - + + + Rotates on {format(nextRotationAt, "MM/dd/yyyy")} at {format(nextRotationAt, "h:mm aa")} + {" "} + (Local Time) + + } + >
= 7 ? "success" : "primary"} className={twMerge( "flex h-5 w-min items-center gap-1.5 whitespace-nowrap capitalize", className )} > - {label} + {daysToRotation < 0 + ? "Rotating" + : `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`}
diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx index 79a431bc8..4aadc2702 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -59,11 +59,12 @@ const Content = ({ secretRotation }: ContentProps) => {
{Component} {nextRotationAt && ( -
+
Next rotation occurs on: {format(nextRotationAt, "MM/dd/yyyy")} at{" "} - {format(nextRotationAt, "h:mm aa")} + {format(nextRotationAt, "h:mm aa")}{" "} + (Local Time)
)} diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx index 0fd14bb1f..d2ef136f3 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx @@ -14,9 +14,7 @@ type Props = { }; export const SecretRotationV2ConfigurationFields = ({ isUpdate, environments }: Props) => { - const { control, watch } = useFormContext(); - - console.log(watch("rotateAtUtc")); + const { control } = useFormContext(); return ( <> diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx index 7bfa62201..665ab05a6 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx @@ -44,7 +44,24 @@ export const SecretRotationV2ConnectionField = ({ onChange: callback, isUpdate } isError={Boolean(error)} errorText={error?.message} label={`${connectionName} Connection`} - helperText={isUpdate ? "Cannot be updated" : undefined} + helperText={ + isUpdate ? ( + "Cannot be updated" + ) : ( +

+ Check out{" "} + + our docs + {" "} + to ensure your connection has the required permissions for secret rotation. +

+ ) + } > { />

- Infisical requires two database users to be created for rotation. Below is an example - statement for creating the required users. You may need to modify it to suit your needs. + Infisical requires two database users to be created for rotation.

-

+

+ These users are intended to be solely managed by Infisical. Altering their login after + rotation may cause unexpected failure. +

+

+ Below is an example statement for creating the required users. You may need to modify it + to suit your needs. +

+

             {rotationOption!.template.createUserStatement}
           
diff --git a/frontend/src/components/v2/NoticeBannerV2/NoticeBannerV2.tsx b/frontend/src/components/v2/NoticeBannerV2/NoticeBannerV2.tsx index 6d8e98c9f..2b41620bd 100644 --- a/frontend/src/components/v2/NoticeBannerV2/NoticeBannerV2.tsx +++ b/frontend/src/components/v2/NoticeBannerV2/NoticeBannerV2.tsx @@ -1,15 +1,22 @@ import { ReactNode } from "react"; import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; type Props = { title: string; children: ReactNode; + className?: string; }; -export const NoticeBannerV2 = ({ title, children }: Props) => { +export const NoticeBannerV2 = ({ title, children, className }: Props) => { return ( -
+
{title} diff --git a/frontend/src/helpers/secretRotationsV2.ts b/frontend/src/helpers/secretRotationsV2.ts index d3497d335..d52e59b25 100644 --- a/frontend/src/helpers/secretRotationsV2.ts +++ b/frontend/src/helpers/secretRotationsV2.ts @@ -14,15 +14,11 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record - new Date( - Date.UTC( - new Date().getUTCFullYear(), - new Date().getUTCMonth(), - new Date().getUTCDate(), - hours, - minutes, - 0, - 0 - ) +export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) => { + const now = new Date(); + + // convert utc rotation time to local datetime + return new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hours, minutes, 0, 0) ); +}; diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index 433d82855..f9684ee09 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -32,6 +32,7 @@ export type TSecretApprovalSecChange = { secretKey: string; secretValue?: string; secretComment?: string; + isRotatedSecret?: boolean; tags?: string[]; }; diff --git a/frontend/src/hooks/api/secretSnapshots/queries.tsx b/frontend/src/hooks/api/secretSnapshots/queries.tsx index d273bd5ea..daee39054 100644 --- a/frontend/src/hooks/api/secretSnapshots/queries.tsx +++ b/frontend/src/hooks/api/secretSnapshots/queries.tsx @@ -2,6 +2,7 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { SecretType, SecretV3RawSanitized } from "../secrets/types"; import { @@ -82,7 +83,8 @@ export const useGetSnapshotSecrets = ({ snapshotId }: TSnapshotDataProps) => createdAt: secretVersion.createdAt, updatedAt: secretVersion.updatedAt, type: "modified", - version: secretVersion.version + version: secretVersion.version, + isRotatedSecret: secretVersion.isRotatedSecret }; if (secretVersion.type === SecretType.Personal) { @@ -162,6 +164,12 @@ export const usePerformSecretRollback = () => { queryClient.invalidateQueries({ queryKey: secretSnapshotKeys.count({ workspaceId, environment, directory }) }); + queryClient.invalidateQueries({ + queryKey: dashboardKeys.getDashboardSecrets({ + projectId: workspaceId, + secretPath: directory ?? "/" + }) + }); } }); }; diff --git a/frontend/src/hooks/api/secretSnapshots/types.ts b/frontend/src/hooks/api/secretSnapshots/types.ts index 54bf152cc..e7ebb18de 100644 --- a/frontend/src/hooks/api/secretSnapshots/types.ts +++ b/frontend/src/hooks/api/secretSnapshots/types.ts @@ -11,7 +11,7 @@ export type TSecretSnapshot = { export type TSnapshotData = Omit & { id: string; - secretVersions: SecretVersions[]; + secretVersions: (SecretVersions & { isRotatedSecret?: boolean })[]; folderVersion: Array<{ name: string; id: string }>; environment: WorkspaceEnv; }; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/DeleteAppConnectionModal.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/DeleteAppConnectionModal.tsx index 729585c3e..9ad2ce92d 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/DeleteAppConnectionModal.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/DeleteAppConnectionModal.tsx @@ -1,5 +1,6 @@ import { createNotification } from "@app/components/notifications"; import { DeleteActionModal } from "@app/components/v2"; +import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { TAppConnection, useDeleteAppConnection } from "@app/hooks/api/appConnections"; @@ -46,6 +47,17 @@ export const DeleteAppConnectionModal = ({ isOpen, onOpenChange, appConnection } title={`Are you sure want to delete ${name}?`} deleteKey={name} onDeleteApproved={handleDeleteAppConnection} - /> + > + {appConnection.isPlatformManagedCredentials && ( + +

+ This App Connection's credentials are managed by Infisical. +

+

+ By deleting this connection you may lose permanent access to the associated resource. +

+
+ )} + ); }; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx index 22b0e1df7..212f929f7 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx @@ -97,7 +97,13 @@ export const SecretApprovalRequestChangeItem = ({ OLD {secretVersion?.secretKey} - + {newVersion?.isRotatedSecret ? ( + + Rotated Secret value will not be affected + + ) : ( + + )} {secretVersion?.secretComment} @@ -146,7 +152,13 @@ export const SecretApprovalRequestChangeItem = ({ NEW {newVersion?.secretKey} - + {newVersion?.isRotatedSecret ? ( + + Rotated Secret value will not be affected + + ) : ( + + )} {newVersion?.secretComment} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SnapshotView/SecretItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SnapshotView/SecretItem.tsx index 076cf3b6d..b8bf4cd72 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SnapshotView/SecretItem.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SnapshotView/SecretItem.tsx @@ -76,7 +76,7 @@ export const SecretItem = ({ mode, preSecret, postSecret }: Props) => {
- {mode === "modified" ? ( + {mode === "modified" && !preSecret?.isRotatedSecret ? ( <>
{preSecret?.key}
@@ -90,7 +90,14 @@ export const SecretItem = ({ mode, preSecret, postSecret }: Props) => {
) : ( - postSecret.key + <> + {postSecret.key} + {postSecret.isRotatedSecret && ( + + Rotated Secrets are not affected by Rollback + + )} + )}
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SnapshotView/SnapshotView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SnapshotView/SnapshotView.tsx index a573418d0..3789ac21d 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SnapshotView/SnapshotView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SnapshotView/SnapshotView.tsx @@ -33,11 +33,13 @@ type Props = { const LOADER_TEXT = ["Fetching your snapshot", "Creating the difference view"]; const deepCompareSecrets = (lhs: SecretV3RawSanitized, rhs: SecretV3RawSanitized) => - lhs.key === rhs.key && - lhs.value === rhs.value && - lhs.comment === rhs.comment && - lhs?.valueOverride === rhs?.valueOverride && - JSON.stringify(lhs.tags) === JSON.stringify(rhs.tags); + lhs.isRotatedSecret || + rhs.isRotatedSecret || + (lhs.key === rhs.key && + lhs.value === rhs.value && + lhs.comment === rhs.comment && + lhs?.valueOverride === rhs?.valueOverride && + JSON.stringify(lhs.tags) === JSON.stringify(rhs.tags)); export const SnapshotView = ({ snapshotId,