import { ForbiddenError, subject } from "@casl/ability"; import { Knex } from "knex"; import isEqual from "lodash.isequal"; import { SecretType, TableName } from "@app/db/schemas"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { hasSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { auth0ClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/auth0-client-secret/auth0-client-secret-rotation-fns"; import { azureClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns"; import { ldapPasswordRotationFactory } from "@app/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns"; import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; import { calculateNextRotationAt, decryptSecretRotationCredentials, encryptSecretRotationCredentials, expandSecretRotation, getNextUtcRotationInterval, getSecretRotationRotateSecretJobOptions, listSecretRotationOptions, parseRotationErrorMessage, throwOnImmutableParameterUpdate } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns"; import { SECRET_ROTATION_CONNECTION_MAP, SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; import { TCreateSecretRotationV2DTO, TDeleteSecretRotationV2DTO, TFindSecretRotationV2ByIdDTO, TFindSecretRotationV2ByNameDTO, TGetDashboardSecretRotationsV2, TGetDashboardSecretRotationV2Count, TListSecretRotationsV2ByProjectId, TQuickSearchSecretRotationsV2, TRotateSecretRotationV2, TRotationFactory, TSecretRotationRotateGeneratedCredentials, TSecretRotationV2, TSecretRotationV2GeneratedCredentials, TSecretRotationV2Raw, TSecretRotationV2TemporaryParameters, TSecretRotationV2WithConnection, TUpdateSecretRotationV2DTO } 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, 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"; import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; import { QueueJobs, TQueueServiceFactory } from "@app/queue"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { ActorType } from "@app/services/auth/auth-type"; import { TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TResourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; import { TSecretQueueFactory } from "@app/services/secret/secret-queue"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; import { fnSecretBulkDelete, fnSecretBulkInsert, fnSecretBulkUpdate, reshapeBridgeSecret } from "@app/services/secret-v2-bridge/secret-v2-bridge-fns"; import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; import { awsIamUserSecretRotationFactory } from "./aws-iam-user-secret/aws-iam-user-secret-rotation-fns"; import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; export type TSecretRotationV2ServiceFactoryDep = { secretRotationV2DAL: TSecretRotationV2DALFactory; appConnectionService: Pick; permissionService: Pick; projectBotService: Pick; kmsService: Pick; licenseService: Pick; auditLogService: Pick; keyStore: Pick; folderDAL: Pick; secretV2BridgeDAL: Pick< TSecretV2BridgeDALFactory, "bulkUpdate" | "insertMany" | "deleteMany" | "upsertSecretReferences" | "find" | "invalidateSecretCacheByProjectId" >; secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; resourceMetadataDAL: Pick; secretTagDAL: Pick; secretQueueService: Pick; snapshotService: Pick; queueService: Pick; appConnectionDAL: Pick; folderCommitService: Pick; }; export type TSecretRotationV2ServiceFactory = ReturnType; const MAX_GENERATED_CREDENTIALS_LENGTH = 2; type TRotationFactoryImplementation = TRotationFactory< TSecretRotationV2WithConnection, TSecretRotationV2GeneratedCredentials, TSecretRotationV2TemporaryParameters >; const SECRET_ROTATION_FACTORY_MAP: Record = { [SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, [SecretRotation.MsSqlCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, [SecretRotation.MySqlCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, [SecretRotation.OracleDBCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, [SecretRotation.Auth0ClientSecret]: auth0ClientSecretRotationFactory as TRotationFactoryImplementation, [SecretRotation.AzureClientSecret]: azureClientSecretRotationFactory as TRotationFactoryImplementation, [SecretRotation.AwsIamUserSecret]: awsIamUserSecretRotationFactory as TRotationFactoryImplementation, [SecretRotation.LdapPassword]: ldapPasswordRotationFactory as TRotationFactoryImplementation }; export const secretRotationV2ServiceFactory = ({ secretRotationV2DAL, folderDAL, secretV2BridgeDAL, secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, secretTagDAL, resourceMetadataDAL, permissionService, appConnectionService, projectBotService, licenseService, kmsService, auditLogService, secretQueueService, snapshotService, keyStore, queueService, folderCommitService, appConnectionDAL }: TSecretRotationV2ServiceFactoryDep) => { const $queueSendSecretRotationStatusNotification = async (secretRotation: TSecretRotationV2Raw) => { const appCfg = getConfig(); if (!appCfg.isSmtpConfigured) return; // comment out if testing email sending await queueService.queuePg( QueueJobs.SecretRotationV2SendNotification, { secretRotation }, { jobId: `secret-rotation-v2-notification-${secretRotation.id}`, retryLimit: 5, retryBackoff: true } ); }; const $throwOnConflictingSecrets = async ({ secretKeys, folderId, tx, secretPath }: { secretKeys: string[]; folderId: string; tx: Knex; secretPath: string; }) => { if (new Set(secretKeys).size !== secretKeys.length) { throw new BadRequestError({ message: `Secrets mapping keys must be unique. "${secretKeys.join(", ")}" contains duplicate keys.` }); } 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 ) => { const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretRotation) throw new BadRequestError({ message: "Failed to access secret rotations due to plan restriction. Upgrade plan to access secret rotations." }); const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId, projectId }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation ); const secretRotations = await secretRotationV2DAL.find({ ...(type && { type }), projectId }); return Promise.all( secretRotations .filter((rotation) => permission.can( ProjectPermissionSecretRotationActions.Read, subject(ProjectPermissionSub.SecretRotation, { environment: rotation.environment.slug, secretPath: rotation.folder.path }) ) ) .map((rotation) => expandSecretRotation(rotation, kmsService)) ); }; const findSecretRotationById = async ({ type, rotationId }: TFindSecretRotationV2ByIdDTO, actor: OrgServiceActor) => { const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretRotation) throw new BadRequestError({ message: "Failed to access secret rotation due to plan restriction. Upgrade plan to access secret rotations." }); const secretRotation = await secretRotationV2DAL.findById(rotationId); if (!secretRotation) throw new NotFoundError({ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` }); const { projectId, environment, folder, connection } = secretRotation; const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId, projectId }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretRotationActions.Read, subject(ProjectPermissionSub.SecretRotation, { environment: environment.slug, secretPath: folder.path }) ); if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) throw new BadRequestError({ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` }); return expandSecretRotation(secretRotation, kmsService); }; const findSecretRotationGeneratedCredentialsById = async ( { type, rotationId }: TFindSecretRotationV2ByIdDTO, actor: OrgServiceActor ) => { const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretRotation) throw new BadRequestError({ message: "Failed to access secret rotation credentials due to plan restriction. Upgrade plan to access secret rotations credentials." }); const secretRotation = await secretRotationV2DAL.findById(rotationId); if (!secretRotation) throw new NotFoundError({ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` }); const { projectId, environment, folder, connection, encryptedGeneratedCredentials } = secretRotation; const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId, projectId }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretRotationActions.ReadGeneratedCredentials, subject(ProjectPermissionSub.SecretRotation, { environment: environment.slug, secretPath: folder.path }) ); if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) throw new BadRequestError({ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` }); const generatedCredentials = await decryptSecretRotationCredentials({ projectId, encryptedGeneratedCredentials, kmsService }); return { generatedCredentials, secretRotation: secretRotation as TSecretRotationV2 }; }; const findSecretRotationByName = async ( { type, rotationName, secretPath, environment, projectId }: TFindSecretRotationV2ByNameDTO, actor: OrgServiceActor ) => { const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretRotation) throw new BadRequestError({ message: "Failed to access secret rotation due to plan restriction. Upgrade plan to access secret rotations." }); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` }); // we prevent conflicting names within a folder const secretRotation = await secretRotationV2DAL.findOne({ name: rotationName, folderId: folder.id }); if (!secretRotation) throw new NotFoundError({ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with name "${rotationName}"` }); const { connection, id: rotationId } = secretRotation; const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId, projectId }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretRotationActions.Read, subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) ); if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) throw new BadRequestError({ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` }); return expandSecretRotation(secretRotation, kmsService); }; const createSecretRotation = async ( { projectId, secretPath, environment, rotateAtUtc = { hours: 0, minutes: 0 }, secretsMapping, temporaryParameters, ...payload }: TCreateSecretRotationV2DTO, actor: OrgServiceActor ) => { const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretRotation) throw new BadRequestError({ message: "Failed to create secret rotation due to plan restriction. Upgrade plan to create secret rotations." }); const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId, projectId }); const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); if (!shouldUseSecretV2Bridge) 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, subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) ); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` }); const typeApp = SECRET_ROTATION_CONNECTION_MAP[payload.type]; // validates permission to connect and app is valid for rotation type const connection = await appConnectionService.connectAppConnectionById(typeApp, payload.connectionId, actor); const rotationFactory = SECRET_ROTATION_FACTORY_MAP[payload.type]( { parameters: payload.parameters, secretsMapping, connection, rotationInterval: payload.rotationInterval } as TSecretRotationV2WithConnection, appConnectionDAL, kmsService ); // even though we have a db constraint we want to check before any rotation of credentials is attempted // to prevent creation failure after external credentials have been modified const conflictingRotation = await secretRotationV2DAL.findOne({ name: payload.name, folderId: folder.id }); if (conflictingRotation) throw new BadRequestError({ message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${secretPath}"` }); try { const currentTime = new Date(); // callback structure to support transactional rollback when possible const secretRotation = await rotationFactory.issueCredentials(async (newCredentials) => { const encryptedGeneratedCredentials = await encryptSecretRotationCredentials({ generatedCredentials: [newCredentials] as TSecretRotationV2GeneratedCredentials, projectId, kmsService }); 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, rotationStatus: SecretRotationStatus.Success, lastRotationAttemptedAt: currentTime, lastRotatedAt: currentTime, nextRotationAt: calculateNextRotationAt({ lastRotatedAt: currentTime, isAutoRotationEnabled: Boolean(payload.isAutoRotationEnabled), rotateAtUtc, rotationInterval: payload.rotationInterval, rotationStatus: SecretRotationStatus.Success, isManualRotation: true }) }, tx ); const secretsPayload = rotationFactory.getSecretsPayload(newCredentials); const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); const mappedSecrets = await fnSecretBulkInsert({ folderId: folder.id, orgId: connection.orgId, tx, inputSecrets: secretsPayload.map(({ key, value }) => ({ key, encryptedValue: encryptor({ plainText: Buffer.from(value) }).cipherTextBlob, references: [] })), secretDAL: secretV2BridgeDAL, secretVersionDAL: secretVersionV2BridgeDAL, secretVersionTagDAL: secretVersionTagV2BridgeDAL, secretTagDAL, folderCommitService, resourceMetadataDAL, actor: { type: actor.type, actorId: actor.id } }); await secretRotationV2DAL.insertSecretMappings( mappedSecrets.map((secret) => ({ secretId: secret.id, rotationId: createdRotation.id })), tx ); return createdRotation; }); }, temporaryParameters); await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); await secretQueueService.syncSecrets({ orgId: connection.orgId, secretPath, projectId, environmentSlug: environment, excludeReplication: true }); return await expandSecretRotation(secretRotation, kmsService); } catch (err) { if (err instanceof DatabaseError) { const error = err.error as { code: string; message: string; table: string }; if (error.code === DatabaseErrorCode.UniqueViolation) { switch (error.table) { case TableName.SecretRotationV2: throw new BadRequestError({ message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${secretPath}"` }); default: throw err; } } throw err; } if (err instanceof BadRequestError) throw err; throw new BadRequestError({ message: parseRotationErrorMessage(err) }); } }; const updateSecretRotation = async (dto: TUpdateSecretRotationV2DTO, actor: OrgServiceActor) => { const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretRotation) throw new BadRequestError({ message: "Failed to update secret rotation due to plan restriction. Upgrade plan to update secret rotations." }); const { type, rotationId, ...payload } = dto; const secretRotation = await secretRotationV2DAL.findById(rotationId); if (!secretRotation) throw new NotFoundError({ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID ${rotationId}` }); throwOnImmutableParameterUpdate(dto, secretRotation); const { folder, environment, projectId, folderId, connection } = secretRotation; const secretsMapping = secretRotation.secretsMapping as TSecretRotationV2["secretsMapping"]; const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId, projectId }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretRotationActions.Edit, subject(ProjectPermissionSub.SecretRotation, { environment: environment.slug, secretPath: folder.path }) ); if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) throw new BadRequestError({ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` }); const nextRotationAt = calculateNextRotationAt({ ...(secretRotation as TSecretRotationV2), ...payload, isManualRotation: secretRotation.isLastRotationManual }); 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).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, secretTagDAL, folderCommitService, resourceMetadataDAL, actor: { type: actor.type, actorId: actor.id } }); secretsMappingUpdated = true; } return secretRotationV2DAL.updateById( rotationId, { ...payload, nextRotationAt }, tx ); }); if (secretsMappingUpdated) { await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); 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( QueueJobs.SecretRotationV2RotateSecrets, { rotationId, queuedAt: new Date(), isManualRotation: true }, getSecretRotationRotateSecretJobOptions(updatedSecretRotation) ); } return await expandSecretRotation(updatedSecretRotation, kmsService); } catch (err) { if (err instanceof DatabaseError) { const error = err.error as { code: string; message: string; table: string }; if (error.code === DatabaseErrorCode.UniqueViolation) { switch (error.table) { case TableName.SecretRotationV2: if (payload.name) throw new BadRequestError({ message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${folder.path}"` }); break; default: throw err; } } } if (err instanceof BadRequestError) throw err; throw err; } }; const deleteSecretRotation = async ( { type, rotationId, deleteSecrets, revokeGeneratedCredentials }: TDeleteSecretRotationV2DTO, actor: OrgServiceActor ) => { const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretRotation) throw new BadRequestError({ message: "Failed to delete secret rotation due to plan restriction. Upgrade plan to delete secret rotation." }); const secretRotation = await secretRotationV2DAL.findById(rotationId); if (!secretRotation) throw new NotFoundError({ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` }); const { folder, environment, projectId, encryptedGeneratedCredentials, connection, folderId, secretsMapping } = secretRotation; const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId, projectId }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretRotationActions.Delete, subject(ProjectPermissionSub.SecretRotation, { environment: environment.slug, secretPath: folder.path }) ); if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) throw new BadRequestError({ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` }); const deleteTransaction = async () => secretRotationV2DAL.transaction(async (tx) => { if (deleteSecrets) { await fnSecretBulkDelete({ secretDAL: secretV2BridgeDAL, secretQueueService, inputSecrets: Object.values(secretsMapping as TSecretRotationV2["secretsMapping"]).map((secretKey) => ({ secretKey, type: SecretType.Shared })), projectId, folderId, actorId: actor.id, // not actually used since rotated secrets are shared actorType: actor.type, folderCommitService, secretVersionDAL: secretVersionV2BridgeDAL, tx }); } return secretRotationV2DAL.deleteById(rotationId, tx); }); if (revokeGeneratedCredentials) { const appConnection = await decryptAppConnection(connection, kmsService); const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type]( { ...secretRotation, connection: appConnection } as TSecretRotationV2WithConnection, appConnectionDAL, kmsService ); const generatedCredentials = await decryptSecretRotationCredentials({ encryptedGeneratedCredentials, projectId, kmsService }); await rotationFactory.revokeCredentials(generatedCredentials, deleteTransaction); } else { await deleteTransaction(); } if (deleteSecrets) { await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); await secretQueueService.syncSecrets({ orgId: connection.orgId, secretPath: folder.path, projectId, environmentSlug: environment.slug, excludeReplication: true }); } return expandSecretRotation(secretRotation, kmsService); }; const rotateGeneratedCredentials = async ( secretRotation: TSecretRotationV2Raw, { auditLogInfo, jobId, shouldSendNotification, isFinalAttempt = true, isManualRotation = false }: TSecretRotationRotateGeneratedCredentials = {} ) => { const { connection, folder, environment, encryptedGeneratedCredentials, activeIndex, projectId, type, folderId, id: rotationId, parameters, secretsMapping } = secretRotation; let lock: Awaited> | undefined; try { try { lock = await keyStore.acquireLock([KeyStorePrefixes.SecretRotationLock(rotationId)], 60 * 1000); } catch (e) { throw new InternalServerError({ message: "Failed to acquire rotation lock." }); } const appConnection = await decryptAppConnection(connection, kmsService); const generatedCredentials = await decryptSecretRotationCredentials({ projectId, encryptedGeneratedCredentials, kmsService }); const inactiveIndex = (activeIndex + 1) % MAX_GENERATED_CREDENTIALS_LENGTH; const inactiveCredentials = generatedCredentials[inactiveIndex]; const activeCredentials = generatedCredentials[activeIndex]; const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation]( { ...secretRotation, connection: appConnection } as TSecretRotationV2WithConnection, appConnectionDAL, kmsService ); const updatedRotation = await rotationFactory.rotateCredentials( inactiveCredentials, async (newCredentials) => { const updatedCredentials = [...generatedCredentials]; updatedCredentials[inactiveIndex] = newCredentials; const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({ projectId, generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials, kmsService }); return secretRotationV2DAL.transaction(async (tx) => { const secretsPayload = rotationFactory.getSecretsPayload(newCredentials); const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); // update mapped secrets with new credential values await fnSecretBulkUpdate({ folderId, orgId: connection.orgId, tx, inputSecrets: secretsPayload.map(({ key, value }) => ({ filter: { key, folderId, type: SecretType.Shared }, data: { encryptedValue: encryptor({ plainText: Buffer.from(value) }).cipherTextBlob, references: [] } })), secretDAL: secretV2BridgeDAL, secretVersionDAL: secretVersionV2BridgeDAL, secretVersionTagDAL: secretVersionTagV2BridgeDAL, folderCommitService, actor: { type: ActorType.PLATFORM }, secretTagDAL, resourceMetadataDAL }); const currentTime = new Date(); return secretRotationV2DAL.updateById( secretRotation.id, { encryptedGeneratedCredentials: encryptedUpdatedCredentials, activeIndex: inactiveIndex, isLastRotationManual: isManualRotation, lastRotatedAt: currentTime, lastRotationAttemptedAt: currentTime, nextRotationAt: calculateNextRotationAt({ ...(secretRotation as TSecretRotationV2), rotationStatus: SecretRotationStatus.Success, lastRotatedAt: currentTime, isManualRotation }), rotationStatus: SecretRotationStatus.Success, lastRotationJobId: jobId, encryptedLastRotationMessage: null }, tx ); }); }, activeCredentials ); await auditLogService.createAuditLog({ ...(auditLogInfo ?? { actor: { type: ActorType.PLATFORM, metadata: {} } }), projectId, event: { type: EventType.SECRET_ROTATION_ROTATE_SECRETS, metadata: { type, rotationId, connectionId: connection.id, folderId, parameters, secretsMapping, status: SecretRotationStatus.Success, occurredAt: new Date(), message: null, jobId } } }); await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folder.id); await secretQueueService.syncSecrets({ orgId: connection.orgId, secretPath: folder.path, projectId, environmentSlug: environment.slug, excludeReplication: true }); return updatedRotation; } catch (error) { const errorMessage = parseRotationErrorMessage(error); if (isFinalAttempt) { const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); const { cipherTextBlob: encryptedMessage } = encryptor({ plainText: Buffer.from(errorMessage) }); const updatedRotation = await secretRotationV2DAL.updateById(secretRotation.id, { rotationStatus: SecretRotationStatus.Failed, lastRotationJobId: jobId, lastRotationAttemptedAt: new Date(), encryptedLastRotationMessage: encryptedMessage, nextRotationAt: getNextUtcRotationInterval(secretRotation.rotateAtUtc as TSecretRotationV2["rotateAtUtc"]) }); if (shouldSendNotification) { await $queueSendSecretRotationStatusNotification(updatedRotation); } } await auditLogService.createAuditLog({ ...(auditLogInfo ?? { actor: { type: ActorType.PLATFORM, metadata: {} } }), projectId, event: { type: EventType.SECRET_ROTATION_ROTATE_SECRETS, metadata: { type, rotationId, connectionId: connection.id, folderId, parameters, secretsMapping, occurredAt: new Date(), status: SecretRotationStatus.Failed, message: isFinalAttempt ? "See Rotation status for details" : "Rotation will be re-attempted shortly...", jobId } } }); throw new BadRequestError({ message: errorMessage }); } finally { await lock?.release(); } }; const rotateSecretRotation = async ( { rotationId, type, auditLogInfo }: TRotateSecretRotationV2, actor: OrgServiceActor ) => { const plan = await licenseService.getPlan(actor.orgId); if (!plan.secretRotation) throw new BadRequestError({ message: "Failed to rotate secret rotation secrets due to plan restriction. Upgrade plan to rotate secret rotation secrets." }); const secretRotation = await secretRotationV2DAL.findById(rotationId); if (!secretRotation) throw new NotFoundError({ message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` }); const { projectId, environment, folder, connection } = secretRotation; const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId, projectId }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretRotationActions.RotateSecrets, subject(ProjectPermissionSub.SecretRotation, { environment: environment.slug, secretPath: folder.path }) ); if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) throw new BadRequestError({ message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` }); const isRotationOccurring = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretRotationLock(secretRotation.id))); if (isRotationOccurring) throw new BadRequestError({ message: `A rotation is already in progress. Please try again shortly.` }); try { const updatedRotation = await rotateGeneratedCredentials(secretRotation, { auditLogInfo, isManualRotation: true }); return await expandSecretRotation(updatedRotation, kmsService); } catch (err) { throw new InternalServerError({ message: (err as Error).message ?? "Failed to rotate secrets: check Rotation status for details." }); } }; const getDashboardSecretRotationCount = async ( { projectId, environments, secretPath, search }: TGetDashboardSecretRotationV2Count, actor: OrgServiceActor ) => { // we don't check plan for dashboard like dynamic secret, actions will be prevented const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId, projectId }); const permissiveEnvironments = environments.filter((environment) => permission.can( ProjectPermissionSecretRotationActions.Read, subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) ) ); if (!permissiveEnvironments.length) return 0; const folders = await folderDAL.findBySecretPathMultiEnv(projectId, permissiveEnvironments, secretPath); if (!folders.length) { throw new NotFoundError({ message: `Folders with path '${secretPath}' in environments with slugs '${permissiveEnvironments.join( ", " )}' not found` }); } const count = await secretRotationV2DAL.findWithMappedSecretsCount({ $in: { folderId: folders.map((folder) => folder.id) }, search, projectId }); return count; }; const getDashboardSecretRotations = async ( { projectId, environments, secretPath, search, limit, offset = 0, orderBy = SecretsOrderBy.Name, orderDirection = OrderByDirection.ASC }: TGetDashboardSecretRotationsV2, actor: OrgServiceActor ) => { // we don't check plan for dashboard like dynamic secret, actions will be prevented const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId, projectId }); const permissiveEnvironments = environments.filter((environment) => permission.can( ProjectPermissionSecretRotationActions.Read, subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) ) ); if (!permissiveEnvironments.length) return []; const folders = await folderDAL.findBySecretPathMultiEnv(projectId, permissiveEnvironments, secretPath); if (!folders.length) { throw new NotFoundError({ message: `Folders with path '${secretPath}' in environments with slugs '${permissiveEnvironments.join( ", " )}' not found` }); } const folderIds = folders.map((folder) => folder.id); const secretRotations = await secretRotationV2DAL.findWithMappedSecrets( { $in: { folderId: folderIds }, search, projectId }, { limit, offset, sort: orderBy ? [[orderBy, orderDirection]] : undefined } ); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); const secretRotationsWithSecrets = await Promise.all( secretRotations.map(async ({ secrets, ...rotation }) => { const decryptedSecrets = secrets.map((secret) => { const canDescribeSecret = hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.DescribeSecret, { environment: rotation.environment.slug, secretPath: rotation.folder.path, secretName: secret.key, // TODO: scott/akhil our mapper seems to not propagate children's children types // @ts-expect-error eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment secretTags: (secret.tags as { slug: string; name: string; color: string }[]).map((i) => i.slug) } ); if (!canDescribeSecret) { return null; // return null so we know to display empty row in dashboard } const secretValueHidden = !hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.ReadValue, { environment: rotation.environment.slug, secretPath: rotation.folder.path, secretName: secret.key, // TODO: scott/akhil our mapper seems to not propagate children's children types // @ts-expect-error eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment secretTags: (secret.tags as { slug: string; name: string; color: string }[]).map((i) => i.slug) } ); return reshapeBridgeSecret( projectId, rotation.environment.slug, rotation.folder.path, { ...secret, value: secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() : "", comment: secret.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() : "" }, secretValueHidden && secret.type === SecretType.Shared ); }); const expandedRotation = await expandSecretRotation(rotation, kmsService); return { ...expandedRotation, secrets: decryptedSecrets }; }) ); return secretRotationsWithSecrets as (TSecretRotationV2 & { secrets: Awaited>[]; })[]; }; const getQuickSearchSecretRotations = async ( { folderMappings, filters: { search, ...options }, projectId }: TQuickSearchSecretRotationsV2, actor: OrgServiceActor ) => { const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, projectId, actorAuthMethod: actor.authMethod, actorOrgId: actor.orgId }); const permissiveFolderMappings = folderMappings.filter(({ path, environment }) => permission.can( ProjectPermissionSecretRotationActions.Read, subject(ProjectPermissionSub.SecretRotation, { environment, secretPath: path }) ) ); if (!permissiveFolderMappings.length) return []; const secretRotations = await secretRotationV2DAL.find( { projectId, $search: { name: `%${search}%` }, $in: { folderId: permissiveFolderMappings.map(({ folderId }) => folderId) } }, options ); return secretRotations as TSecretRotationV2[]; }; return { listSecretRotationOptions, listSecretRotationsByProjectId, createSecretRotation, updateSecretRotation, findSecretRotationById, findSecretRotationByName, deleteSecretRotation, findSecretRotationGeneratedCredentialsById, rotateSecretRotation, rotateGeneratedCredentials, getDashboardSecretRotationCount, getDashboardSecretRotations, getQuickSearchSecretRotations }; };