Files
infisical/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts
T
2025-07-23 14:31:51 +05:30

1376 lines
48 KiB
TypeScript

import { ForbiddenError, subject } from "@casl/ability";
import { Knex } from "knex";
import isEqual from "lodash.isequal";
import { ActionProjectType, SecretType, TableName } from "@app/db/schemas";
import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
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 { oktaClientSecretRotationFactory } from "./okta-client-secret/okta-client-secret-rotation-fns";
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
export type TSecretRotationV2ServiceFactoryDep = {
secretRotationV2DAL: TSecretRotationV2DALFactory;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">;
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findBySecretPathMultiEnv">;
secretV2BridgeDAL: Pick<
TSecretV2BridgeDALFactory,
"bulkUpdate" | "insertMany" | "deleteMany" | "upsertSecretReferences" | "find" | "invalidateSecretCacheByProjectId"
>;
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecretV2" | "deleteTagsToSecretV2" | "find">;
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "removeSecretReminder">;
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
queueService: Pick<TQueueServiceFactory, "queuePg">;
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
};
export type TSecretRotationV2ServiceFactory = ReturnType<typeof secretRotationV2ServiceFactory>;
const MAX_GENERATED_CREDENTIALS_LENGTH = 2;
type TRotationFactoryImplementation = TRotationFactory<
TSecretRotationV2WithConnection,
TSecretRotationV2GeneratedCredentials,
TSecretRotationV2TemporaryParameters
>;
const SECRET_ROTATION_FACTORY_MAP: Record<SecretRotation, TRotationFactoryImplementation> = {
[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,
[SecretRotation.OktaClientSecret]: oktaClientSecretRotationFactory as TRotationFactoryImplementation
};
export const secretRotationV2ServiceFactory = ({
secretRotationV2DAL,
folderDAL,
secretV2BridgeDAL,
secretVersionV2BridgeDAL,
secretVersionTagV2BridgeDAL,
secretTagDAL,
resourceMetadataDAL,
permissionService,
appConnectionService,
projectBotService,
licenseService,
kmsService,
auditLogService,
secretQueueService,
snapshotService,
keyStore,
queueService,
folderCommitService,
appConnectionDAL,
gatewayService
}: 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,
actionProjectType: ActionProjectType.SecretManager,
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,
actionProjectType: ActionProjectType.SecretManager,
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,
actionProjectType: ActionProjectType.SecretManager,
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,
actionProjectType: ActionProjectType.SecretManager,
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,
actionProjectType: ActionProjectType.SecretManager,
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,
gatewayService
);
// 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,
actionProjectType: ActionProjectType.SecretManager,
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,
actionProjectType: ActionProjectType.SecretManager,
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,
gatewayService
);
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<ReturnType<typeof keyStore.acquireLock>> | 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,
gatewayService
);
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,
actionProjectType: ActionProjectType.SecretManager,
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,
actionProjectType: ActionProjectType.SecretManager,
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,
actionProjectType: ActionProjectType.SecretManager,
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<ReturnType<typeof reshapeBridgeSecret>>[];
})[];
};
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,
actionProjectType: ActionProjectType.SecretManager
});
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
};
};